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
|
||||
*.log*
|
||||
package-lock.json
|
||||
|
||||
+6
-2
@@ -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 .",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 { 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. "
|
||||
{slugifyProjectName(trimmedName) || 'my-project'}").
|
||||
</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, '')
|
||||
}
|
||||
Reference in New Issue
Block a user