Compare commits
9
Commits
f68261994e
..
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e2b0d00b9 | ||
|
|
0fc1a10a18 | ||
|
|
68a5f80c31 | ||
|
|
d014581eea | ||
|
|
8d52a23c75 | ||
|
|
c2b95d057f | ||
|
|
5beb70f1e2 | ||
|
|
b5085c1e8a | ||
|
|
86be76132d |
@@ -5,3 +5,4 @@ out
|
|||||||
._*
|
._*
|
||||||
.eslintcache
|
.eslintcache
|
||||||
*.log*
|
*.log*
|
||||||
|
package-lock.json
|
||||||
|
|||||||
+6
-2
@@ -1,9 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "aurora-dockside",
|
"name": "aurora-dockside",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"description": "A desktop GUI for managing DDEV local development environments",
|
"description": "A desktop GUI for managing DDEV local development environments",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "",
|
"homepage": "http://localhost:3000/reaper/aurora-dockside",
|
||||||
|
"author": {
|
||||||
|
"name": "reaper",
|
||||||
|
"email": "[email protected]"
|
||||||
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
|
|||||||
@@ -10,6 +10,19 @@ const ENV_WITH_DDEV_PATH = {
|
|||||||
const running = new Map<string, ChildProcess>()
|
const running = new Map<string, ChildProcess>()
|
||||||
const cancelledIds = new Set<string>()
|
const cancelledIds = new Set<string>()
|
||||||
|
|
||||||
|
// ddev colorizes its output unconditionally, regardless of TTY-ness, so a
|
||||||
|
// GUI panel rendering raw stdout/stderr ends up showing literal escape
|
||||||
|
// codes instead of color. Strip them before they ever reach the renderer.
|
||||||
|
const ANSI_PATTERN = new RegExp(
|
||||||
|
// eslint-disable-next-line no-control-regex -- intentional: matches ESC/BEL bytes in ddev's colorized output
|
||||||
|
'[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))',
|
||||||
|
'g'
|
||||||
|
)
|
||||||
|
|
||||||
|
function stripAnsi(text: string): string {
|
||||||
|
return text.replace(ANSI_PATTERN, '')
|
||||||
|
}
|
||||||
|
|
||||||
export class CommandFailedError extends Error {
|
export class CommandFailedError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
@@ -37,7 +50,16 @@ export function runStreamed(
|
|||||||
options: { cwd?: string } = {}
|
options: { cwd?: string } = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn('ddev', args, { env: ENV_WITH_DDEV_PATH, cwd: options.cwd })
|
// stdin must be closed, not just unused — an open-but-silent pipe
|
||||||
|
// leaves ddev blocked forever on any prompt it tries to read (e.g. the
|
||||||
|
// first-run telemetry opt-in), since a GUI-spawned child has no
|
||||||
|
// terminal to answer it. A closed stdin gets an immediate EOF instead,
|
||||||
|
// which ddev treats as "use the default" rather than hanging.
|
||||||
|
const child = spawn('ddev', args, {
|
||||||
|
env: ENV_WITH_DDEV_PATH,
|
||||||
|
cwd: options.cwd,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
})
|
||||||
running.set(operationId, child)
|
running.set(operationId, child)
|
||||||
|
|
||||||
const tail: string[] = []
|
const tail: string[] = []
|
||||||
@@ -47,7 +69,7 @@ export function runStreamed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
child.stdout.on('data', (data: Buffer) => {
|
child.stdout.on('data', (data: Buffer) => {
|
||||||
const chunk = data.toString()
|
const chunk = stripAnsi(data.toString())
|
||||||
trackTail(chunk)
|
trackTail(chunk)
|
||||||
if (!sender.isDestroyed()) {
|
if (!sender.isDestroyed()) {
|
||||||
sender.send('terminal:data', { operationId, stream: 'stdout', chunk })
|
sender.send('terminal:data', { operationId, stream: 'stdout', chunk })
|
||||||
@@ -55,7 +77,7 @@ export function runStreamed(
|
|||||||
})
|
})
|
||||||
|
|
||||||
child.stderr.on('data', (data: Buffer) => {
|
child.stderr.on('data', (data: Buffer) => {
|
||||||
const chunk = data.toString()
|
const chunk = stripAnsi(data.toString())
|
||||||
trackTail(chunk)
|
trackTail(chunk)
|
||||||
if (!sender.isDestroyed()) {
|
if (!sender.isDestroyed()) {
|
||||||
sender.send('terminal:data', { operationId, stream: 'stderr', chunk })
|
sender.send('terminal:data', { operationId, stream: 'stderr', chunk })
|
||||||
@@ -105,12 +127,12 @@ export function cancelCommand(operationId: string): boolean {
|
|||||||
// stopping a log stream reuses cancelCommand — but pushes chunks over a
|
// stopping a log stream reuses cancelCommand — but pushes chunks over a
|
||||||
// dedicated logs:data channel instead of terminal:data.
|
// dedicated logs:data channel instead of terminal:data.
|
||||||
export function startLogStream(operationId: string, args: string[], sender: WebContents): void {
|
export function startLogStream(operationId: string, args: string[], sender: WebContents): void {
|
||||||
const child = spawn('ddev', args, { env: ENV_WITH_DDEV_PATH })
|
const child = spawn('ddev', args, { env: ENV_WITH_DDEV_PATH, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
running.set(operationId, child)
|
running.set(operationId, child)
|
||||||
|
|
||||||
const forward = (stream: 'stdout' | 'stderr') => (data: Buffer) => {
|
const forward = (stream: 'stdout' | 'stderr') => (data: Buffer) => {
|
||||||
if (!sender.isDestroyed()) {
|
if (!sender.isDestroyed()) {
|
||||||
sender.send('logs:data', { operationId, stream, chunk: data.toString() })
|
sender.send('logs:data', { operationId, stream, chunk: stripAnsi(data.toString()) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
child.stdout.on('data', forward('stdout'))
|
child.stdout.on('data', forward('stdout'))
|
||||||
@@ -133,3 +155,23 @@ export function killAllRunningCommands(): void {
|
|||||||
}
|
}
|
||||||
running.clear()
|
running.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stops every running project's containers in one shot (equivalent to
|
||||||
|
// `ddev stop` on each of them, but faster) so nothing is left running in the
|
||||||
|
// background after the app quits. Resolves rather than rejects on any
|
||||||
|
// failure — a missing/unresponsive ddev or docker shouldn't block quitting —
|
||||||
|
// and the timeout guards against poweroff hanging if the docker daemon is
|
||||||
|
// stuck, which would otherwise stall app exit indefinitely.
|
||||||
|
export function powerOffAllProjects(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn('ddev', ['poweroff'], { env: ENV_WITH_DDEV_PATH, stdio: 'ignore' })
|
||||||
|
const timeout = setTimeout(() => child.kill(), 10_000)
|
||||||
|
|
||||||
|
const finish = (): void => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
child.on('error', finish)
|
||||||
|
child.on('close', finish)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { spawn } from 'child_process'
|
||||||
|
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
const EXTRA_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/opt/local/bin']
|
||||||
|
const ENV_WITH_DDEV_PATH = {
|
||||||
|
...process.env,
|
||||||
|
PATH: [...EXTRA_PATH_DIRS, process.env.PATH].join(':')
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WarmupMarker {
|
||||||
|
ddevVersion: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerPath(): string {
|
||||||
|
return join(app.getPath('userData'), 'image-warmup.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readMarker(): Promise<WarmupMarker | null> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(markerPath(), 'utf-8')) as WarmupMarker
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeMarker(marker: WarmupMarker): Promise<void> {
|
||||||
|
await mkdir(app.getPath('userData'), { recursive: true })
|
||||||
|
await writeFile(markerPath(), JSON.stringify(marker), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(cmd: string, args: string[]): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(cmd, args, { env: ENV_WITH_DDEV_PATH, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
let out = ''
|
||||||
|
child.stdout.on('data', (d: Buffer) => (out += d.toString()))
|
||||||
|
child.stderr.on('data', (d: Buffer) => (out += d.toString()))
|
||||||
|
child.on('error', reject)
|
||||||
|
child.on('close', (code) => (code === 0 ? resolve(out) : reject(new Error(out))))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// `ddev version` reports the exact default image tags (webserver, db,
|
||||||
|
// router, ssh-agent, xhgui) for whatever DDEV release is installed, so this
|
||||||
|
// stays correct across DDEV upgrades without hardcoding image names here.
|
||||||
|
async function getDefaultImages(): Promise<{ version: string; images: string[] }> {
|
||||||
|
const out = await run('ddev', ['version', '--json-output'])
|
||||||
|
const lastLine = out.trim().split('\n').pop() ?? '{}'
|
||||||
|
const envelope = JSON.parse(lastLine) as { raw?: Record<string, string> }
|
||||||
|
const raw = envelope.raw ?? {}
|
||||||
|
const version = raw['DDEV version'] ?? 'unknown'
|
||||||
|
const images = Object.values(raw).filter((v) => /^[\w-]+\/[\w.-]+:[\w.-]+$/.test(v))
|
||||||
|
return { version, images: [...new Set(images)] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-pulls DDEV's core Docker images (webserver, db, router, ssh-agent) in
|
||||||
|
// the background so a first-ever `ddev start` doesn't stall on a multi-GB
|
||||||
|
// download the moment someone tries to use a freshly installed app. Runs
|
||||||
|
// once per DDEV version — re-warms automatically after a DDEV upgrade,
|
||||||
|
// since image tags are versioned. No-ops quietly if ddev/docker aren't
|
||||||
|
// available yet; a real `ddev start` will surface that error properly.
|
||||||
|
export async function warmupDdevImagesIfNeeded(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { version, images } = await getDefaultImages()
|
||||||
|
const marker = await readMarker()
|
||||||
|
if (marker?.ddevVersion === version) return
|
||||||
|
|
||||||
|
for (const image of images) {
|
||||||
|
try {
|
||||||
|
await run('docker', ['pull', image])
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[image warmup] failed to pull ${image}:`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeMarker({ ddevVersion: version })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[image warmup] skipped:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-4
@@ -9,7 +9,8 @@ import { registerAddonsIpc } from './ipc/addons'
|
|||||||
import { registerLogsIpc } from './ipc/logs'
|
import { registerLogsIpc } from './ipc/logs'
|
||||||
import { registerCreateIpc } from './ipc/create'
|
import { registerCreateIpc } from './ipc/create'
|
||||||
import { registerWindowIpc } from './ipc/window'
|
import { registerWindowIpc } from './ipc/window'
|
||||||
import { killAllRunningCommands } from './commandRunner'
|
import { killAllRunningCommands, powerOffAllProjects } from './commandRunner'
|
||||||
|
import { warmupDdevImagesIfNeeded } from './imageWarmup'
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
// Create the browser window.
|
// Create the browser window.
|
||||||
@@ -68,6 +69,10 @@ app.whenReady().then(() => {
|
|||||||
|
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
|
// Fire-and-forget: warms the Docker image cache in the background so it
|
||||||
|
// never blocks window creation or app usage.
|
||||||
|
void warmupDdevImagesIfNeeded()
|
||||||
|
|
||||||
app.on('activate', function () {
|
app.on('activate', function () {
|
||||||
// On macOS it's common to re-create a window in the app when the
|
// On macOS it's common to re-create a window in the app when the
|
||||||
// dock icon is clicked and there are no other windows open.
|
// dock icon is clicked and there are no other windows open.
|
||||||
@@ -84,10 +89,19 @@ app.on('window-all-closed', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Kill any still-running ddev processes (e.g. an open `ddev logs -f` stream)
|
// Kill any still-running ddev processes (e.g. an open `ddev logs -f` stream),
|
||||||
// so they don't linger as orphans after the app exits.
|
// then power off every running project so sites don't keep running in the
|
||||||
app.on('before-quit', () => {
|
// background after the app exits. Quit is deferred until poweroff finishes
|
||||||
|
// (or times out), so this intercepts the first before-quit and re-fires
|
||||||
|
// app.quit() itself once cleanup is done — the isQuitting guard stops that
|
||||||
|
// from looping back into this handler.
|
||||||
|
let isQuitting = false
|
||||||
|
app.on('before-quit', (event) => {
|
||||||
|
if (isQuitting) return
|
||||||
|
event.preventDefault()
|
||||||
|
isQuitting = true
|
||||||
killAllRunningCommands()
|
killAllRunningCommands()
|
||||||
|
void powerOffAllProjects().finally(() => app.quit())
|
||||||
})
|
})
|
||||||
|
|
||||||
// In this file you can include the rest of your app's specific main process
|
// In this file you can include the rest of your app's specific main process
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { getTypeLabel, PROJECT_TYPES } from './types/registry'
|
|||||||
import { GenericSetup } from './types/GenericSetup'
|
import { GenericSetup } from './types/GenericSetup'
|
||||||
import { WordpressSetup } from './types/WordpressSetup'
|
import { WordpressSetup } from './types/WordpressSetup'
|
||||||
import type { TypeSetupHandle } from './types/shared'
|
import type { TypeSetupHandle } from './types/shared'
|
||||||
|
import { isValidProjectName, slugifyProjectName } from './projectName'
|
||||||
import docksideIcon from '../../assets/dockside-icon.png'
|
import docksideIcon from '../../assets/dockside-icon.png'
|
||||||
|
|
||||||
type Step = 'site' | 'setup'
|
type Step = 'site' | 'setup'
|
||||||
@@ -56,7 +57,9 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
|||||||
const selectProject = useAppStore((s) => s.selectProject)
|
const selectProject = useAppStore((s) => s.selectProject)
|
||||||
const setupRef = useRef<TypeSetupHandle>(null)
|
const setupRef = useRef<TypeSetupHandle>(null)
|
||||||
|
|
||||||
const canContinue = directory !== null && projectName.trim().length > 0
|
const trimmedName = projectName.trim()
|
||||||
|
const nameValid = trimmedName.length > 0 && isValidProjectName(trimmedName)
|
||||||
|
const canContinue = directory !== null && nameValid
|
||||||
const canSubmit = canContinue && setupValid && !isSubmitting
|
const canSubmit = canContinue && setupValid && !isSubmitting
|
||||||
|
|
||||||
async function handlePickDirectory(): Promise<void> {
|
async function handlePickDirectory(): Promise<void> {
|
||||||
@@ -65,7 +68,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
|||||||
setDirectory(picked)
|
setDirectory(picked)
|
||||||
if (!projectName) {
|
if (!projectName) {
|
||||||
const name = picked.split('/').filter(Boolean).pop() ?? ''
|
const name = picked.split('/').filter(Boolean).pop() ?? ''
|
||||||
setProjectName(name)
|
setProjectName(slugifyProjectName(name))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,12 +78,20 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
|||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await createProject.mutateAsync({ directory, projectName: name, projectType, docroot })
|
await createProject.mutateAsync({ directory, projectName: name, projectType, docroot })
|
||||||
await setupRef.current?.runPostCreate({ directory, projectName: name })
|
} catch {
|
||||||
selectProject(name)
|
|
||||||
onClose()
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Post-create (starting the project, downloading/installing WordPress,
|
||||||
|
// etc.) can run long. Close the modal as soon as the quick `ddev
|
||||||
|
// config` step succeeds instead of blocking the whole wizard on it —
|
||||||
|
// the terminal panel already tracks and surfaces this operation's
|
||||||
|
// progress and any failure independently of the modal.
|
||||||
|
const runPostCreate = setupRef.current?.runPostCreate
|
||||||
|
selectProject(name)
|
||||||
|
onClose()
|
||||||
|
runPostCreate?.({ directory, projectName: name }).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -198,6 +209,12 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
|||||||
placeholder="my-project"
|
placeholder="my-project"
|
||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
/>
|
/>
|
||||||
|
{trimmedName.length > 0 && !nameValid && (
|
||||||
|
<p className="mt-1.5 text-xs text-red-600 dark:text-red-400">
|
||||||
|
Use only letters, numbers, and hyphens — no spaces (e.g. "
|
||||||
|
{slugifyProjectName(trimmedName) || 'my-project'}").
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { isValidProjectName, slugifyProjectName } from './projectName'
|
||||||
|
|
||||||
|
describe('isValidProjectName', () => {
|
||||||
|
it('accepts hostname-safe names', () => {
|
||||||
|
expect(isValidProjectName('my-project')).toBe(true)
|
||||||
|
expect(isValidProjectName('project1')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects names with spaces', () => {
|
||||||
|
expect(isValidProjectName('Aurora Admin')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects names starting or ending with a hyphen', () => {
|
||||||
|
expect(isValidProjectName('-project')).toBe(false)
|
||||||
|
expect(isValidProjectName('project-')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty names', () => {
|
||||||
|
expect(isValidProjectName('')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('slugifyProjectName', () => {
|
||||||
|
it('lowercases and replaces spaces with hyphens', () => {
|
||||||
|
expect(slugifyProjectName('Aurora Admin')).toBe('aurora-admin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collapses runs of non-alphanumeric characters', () => {
|
||||||
|
expect(slugifyProjectName('My Cool!! Project')).toBe('my-cool-project')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims leading and trailing hyphens', () => {
|
||||||
|
expect(slugifyProjectName(' -Weird Name- ')).toBe('weird-name')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('produces a name that passes validation', () => {
|
||||||
|
const slug = slugifyProjectName('Aurora Admin')
|
||||||
|
expect(isValidProjectName(slug)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// DDEV project names must be valid hostname labels: alphanumeric and
|
||||||
|
// hyphens only, can't start/end with a hyphen.
|
||||||
|
const PROJECT_NAME_PATTERN = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/
|
||||||
|
|
||||||
|
export function isValidProjectName(name: string): boolean {
|
||||||
|
return PROJECT_NAME_PATTERN.test(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slugifyProjectName(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user