Compare commits
6
Commits
f68261994e
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d014581eea | ||
|
|
8d52a23c75 | ||
|
|
c2b95d057f | ||
|
|
5beb70f1e2 | ||
|
|
b5085c1e8a | ||
|
|
86be76132d |
Generated
+11561
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -3,7 +3,11 @@
|
||||
"version": "1.0.0",
|
||||
"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'))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { registerLogsIpc } from './ipc/logs'
|
||||
import { registerCreateIpc } from './ipc/create'
|
||||
import { registerWindowIpc } from './ipc/window'
|
||||
import { killAllRunningCommands } 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.
|
||||
|
||||
@@ -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