9 Commits
Author SHA1 Message Date
reaperandClaude Sonnet 5 9e2b0d00b9 Bump version to 1.0.1
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-08 20:13:02 -05:00
reaperandClaude Sonnet 5 0fc1a10a18 Remove stray npm lockfile
This project uses pnpm (pnpm-lock.yaml, pnpm-workspace.yaml) — the
package-lock.json was an accidental npm-install artifact. Ignore it
going forward so it doesn't get re-added by mistake.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-08 20:07:36 -05:00
reaperandClaude Sonnet 5 68a5f80c31 Power off all DDEV projects on app quit
Sites were previously left running in the background after closing the
app. before-quit now defers quitting until `ddev poweroff` finishes (or
times out), stopping every running project's containers in one shot.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-08 19:58:00 -05:00
Kris d014581eea Add npm dependency lockfile 2026-08-08 16:51:40 -05:00
reaperandClaude Sonnet 5 8d52a23c75 Pre-pull DDEV's default Docker images in the background on launch
A first-ever ddev start pulls several GB across the webserver, db,
router, and ssh-agent images, which reads as the app hanging rather
than as an expected download. Warms the cache automatically once per
DDEV version (re-warms after a DDEV upgrade, since image tags are
versioned) using `ddev version --json-output` to discover the exact
default image tags — stays correct without hardcoding image names.
Runs fire-and-forget right after window creation so it never blocks
app startup or usage, and no-ops quietly if ddev/docker aren't
available yet.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-08 05:53:18 -05:00
reaperandClaude Sonnet 5 c2b95d057f Close create wizard modal as soon as ddev config succeeds
handleSubmit awaited the entire post-create chain (start, then for
WordPress: download core + install) before ever calling onClose(),
so the modal overlay sat there for the whole multi-step process with
no way to see what was actually happening — the terminal panel that
already tracks this operation's live output was hidden behind it.
Close right after the quick config step succeeds and let post-create
continue as a tracked background operation; failures still surface
via the existing toast/terminal-panel error handling regardless of
modal state.

Also extracts the project-name validation helpers out of
CreateProjectModal.tsx into their own module — mixing component and
non-component exports in one file breaks React Fast Refresh.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-08 05:53:10 -05:00
reaperandClaude Sonnet 5 5beb70f1e2 Fix streamed ddev commands hanging forever on interactive prompts
Node's spawn() leaves a child's stdin as an open-but-silent pipe by
default. A GUI-spawned ddev process has no terminal to answer a
prompt (e.g. the first-run telemetry opt-in), and a blocking read on
an open pipe with no EOF just hangs indefinitely instead of failing
or defaulting — reproduced by a "Start" operation that never
progressed past "Permission to beam up?" with no way to recover short
of force-quitting the app. Closing stdin gives ddev an immediate EOF,
which it treats as "use the default" rather than blocking.

Also strips ANSI escape codes from stdout/stderr before forwarding to
the renderer — ddev colorizes output unconditionally regardless of
TTY-ness, so the terminal panel and log viewer were rendering literal
escape sequences as visible text.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-08 05:52:52 -05:00
reaperandClaude Sonnet 5 b5085c1e8a Validate and slugify project names in the create wizard
DDEV project names must be valid hostname labels. The wizard
auto-filled the name from the picked folder's basename verbatim, so a
folder like "Aurora Admin" produced a name with a space that DDEV's
`ddev config` rejected with an opaque CLI error the UI never
surfaced. Slugify the auto-filled name and block manual entry of
invalid names before they ever reach the ddev CLI.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-08 05:30:09 -05:00
reaperandClaude Sonnet 5 86be76132d Add author and homepage metadata for Linux packaging
electron-builder's .deb target (fpm) refuses to build without an
author email and project homepage in package.json.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-08 05:30:02 -05:00
8 changed files with 231 additions and 17 deletions
+1
View File
@@ -5,3 +5,4 @@ out
._*
.eslintcache
*.log*
package-lock.json
+6 -2
View File
@@ -1,9 +1,13 @@
{
"name": "aurora-dockside",
"version": "1.0.0",
"version": "1.0.1",
"description": "A desktop GUI for managing DDEV local development environments",
"main": "./out/main/index.js",
"author": "",
"homepage": "http://localhost:3000/reaper/aurora-dockside",
"author": {
"name": "reaper",
"email": "[email protected]"
},
"license": "MIT",
"scripts": {
"format": "prettier --write .",
+47 -5
View File
@@ -10,6 +10,19 @@ const ENV_WITH_DDEV_PATH = {
const running = new Map<string, ChildProcess>()
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 {
constructor(
message: string,
@@ -37,7 +50,16 @@ export function runStreamed(
options: { cwd?: string } = {}
): Promise<void> {
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)
const tail: string[] = []
@@ -47,7 +69,7 @@ export function runStreamed(
}
child.stdout.on('data', (data: Buffer) => {
const chunk = data.toString()
const chunk = stripAnsi(data.toString())
trackTail(chunk)
if (!sender.isDestroyed()) {
sender.send('terminal:data', { operationId, stream: 'stdout', chunk })
@@ -55,7 +77,7 @@ export function runStreamed(
})
child.stderr.on('data', (data: Buffer) => {
const chunk = data.toString()
const chunk = stripAnsi(data.toString())
trackTail(chunk)
if (!sender.isDestroyed()) {
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
// dedicated logs:data channel instead of terminal:data.
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)
const forward = (stream: 'stdout' | 'stderr') => (data: Buffer) => {
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'))
@@ -133,3 +155,23 @@ export function killAllRunningCommands(): void {
}
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)
})
}
+81
View File
@@ -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
View File
@@ -9,7 +9,8 @@ import { registerAddonsIpc } from './ipc/addons'
import { registerLogsIpc } from './ipc/logs'
import { registerCreateIpc } from './ipc/create'
import { registerWindowIpc } from './ipc/window'
import { killAllRunningCommands } from './commandRunner'
import { killAllRunningCommands, powerOffAllProjects } from './commandRunner'
import { warmupDdevImagesIfNeeded } from './imageWarmup'
function createWindow(): void {
// Create the browser window.
@@ -68,6 +69,10 @@ app.whenReady().then(() => {
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 () {
// 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.
@@ -84,10 +89,19 @@ app.on('window-all-closed', () => {
}
})
// 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.
app.on('before-quit', () => {
// Kill any still-running ddev processes (e.g. an open `ddev logs -f` stream),
// then power off every running project so sites don't keep running in the
// 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()
void powerOffAllProjects().finally(() => app.quit())
})
// 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 { WordpressSetup } from './types/WordpressSetup'
import type { TypeSetupHandle } from './types/shared'
import { isValidProjectName, slugifyProjectName } from './projectName'
import docksideIcon from '../../assets/dockside-icon.png'
type Step = 'site' | 'setup'
@@ -56,7 +57,9 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
const selectProject = useAppStore((s) => s.selectProject)
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
async function handlePickDirectory(): Promise<void> {
@@ -65,7 +68,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
setDirectory(picked)
if (!projectName) {
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)
try {
await createProject.mutateAsync({ directory, projectName: name, projectType, docroot })
await setupRef.current?.runPostCreate({ directory, projectName: name })
} catch {
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()
} finally {
setIsSubmitting(false)
}
runPostCreate?.({ directory, projectName: name }).catch(() => {})
}
return (
@@ -198,6 +209,12 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
placeholder="my-project"
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. &quot;
{slugifyProjectName(trimmedName) || 'my-project'}&quot;).
</p>
)}
</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, '')
}