Add core DDEV project management (list/describe/start/stop/restart)
Wire up the full stack for step 2 of the build plan: a ddev.ts CLI wrapper in the main process (execFile + JSON envelope parsing, with a PATH fallback since GUI apps on macOS don't inherit Homebrew's PATH), IPC handlers exposed through a typed preload window.api surface, TanStack Query hooks for polling/mutations, and a two-pane UI (project list sidebar + detail panel with URLs, services, and DB credentials). Verified end-to-end against a real scratch DDEV project: typecheck, lint, and tests pass, and the running app correctly displays live project data and reflects state changes (start/stop) via polling.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import type { DdevProjectDetail, DdevProjectSummary } from '../shared/types'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
// GUI apps launched from Finder/Dock on macOS don't inherit the shell's PATH,
|
||||
// so Homebrew-installed `ddev` at /opt/homebrew/bin can be invisible even
|
||||
// though it works fine when launched from a terminal (`pnpm dev`).
|
||||
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(':')
|
||||
}
|
||||
|
||||
export class DdevCliError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'DdevCliError'
|
||||
}
|
||||
}
|
||||
|
||||
interface DdevJsonEnvelope<T> {
|
||||
level: string
|
||||
msg: T | string
|
||||
raw?: T
|
||||
time: string
|
||||
}
|
||||
|
||||
async function execDdev<T>(args: string[]): Promise<DdevJsonEnvelope<T>> {
|
||||
let stdout: string
|
||||
try {
|
||||
;({ stdout } = await execFileAsync('ddev', [...args, '--json-output'], {
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
env: ENV_WITH_DDEV_PATH
|
||||
}))
|
||||
} catch (error) {
|
||||
const execError = error as { stdout?: string; stderr?: string; message: string }
|
||||
const parsed = tryParseLastJsonLine<DdevJsonEnvelope<T>>(execError.stdout ?? '')
|
||||
if (parsed?.msg && typeof parsed.msg === 'string') {
|
||||
throw new DdevCliError(parsed.msg)
|
||||
}
|
||||
if (execError.message.includes('ENOENT')) {
|
||||
throw new DdevCliError('ddev is not installed or not on PATH')
|
||||
}
|
||||
throw new DdevCliError(execError.stderr?.trim() || execError.message)
|
||||
}
|
||||
|
||||
const envelope = tryParseLastJsonLine<DdevJsonEnvelope<T>>(stdout)
|
||||
if (!envelope) {
|
||||
throw new DdevCliError(`Could not parse ddev output: ${stdout.slice(0, 500)}`)
|
||||
}
|
||||
if (envelope.level === 'fatal' || envelope.level === 'error') {
|
||||
throw new DdevCliError(typeof envelope.msg === 'string' ? envelope.msg : 'ddev command failed')
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
// Read commands (list/describe): the payload lives under `raw`.
|
||||
async function runDdevRead<T>(args: string[]): Promise<T> {
|
||||
const envelope = await execDdev<T>(args)
|
||||
if (envelope.raw === undefined) {
|
||||
throw new DdevCliError(
|
||||
typeof envelope.msg === 'string' ? envelope.msg : 'ddev returned no data'
|
||||
)
|
||||
}
|
||||
return envelope.raw
|
||||
}
|
||||
|
||||
// Mutation commands (start/stop/restart): success is just a non-fatal `msg`, no `raw`.
|
||||
async function runDdevCommand(args: string[]): Promise<void> {
|
||||
await execDdev<never>(args)
|
||||
}
|
||||
|
||||
// ddev sometimes prints non-JSON progress lines before the final JSON envelope
|
||||
// (opt-in prompts, deprecation notices); the envelope is always the last line.
|
||||
function tryParseLastJsonLine<T>(output: string): T | null {
|
||||
const lines = output.trim().split('\n')
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim()
|
||||
if (!line.startsWith('{')) continue
|
||||
try {
|
||||
return JSON.parse(line) as T
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function listProjects(): Promise<DdevProjectSummary[]> {
|
||||
const raw = await runDdevRead<DdevProjectSummary[] | null>(['list'])
|
||||
return raw ?? []
|
||||
}
|
||||
|
||||
export async function describeProject(name: string): Promise<DdevProjectDetail> {
|
||||
return runDdevRead<DdevProjectDetail>(['describe', name])
|
||||
}
|
||||
|
||||
export async function startProject(name: string): Promise<void> {
|
||||
await runDdevCommand(['start', name])
|
||||
}
|
||||
|
||||
export async function stopProject(name: string): Promise<void> {
|
||||
await runDdevCommand(['stop', name])
|
||||
}
|
||||
|
||||
export async function restartProject(name: string): Promise<void> {
|
||||
await runDdevCommand(['restart', name])
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { app, shell, BrowserWindow } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
import { registerProjectsIpc } from './ipc/projects'
|
||||
|
||||
function createWindow(): void {
|
||||
// Create the browser window.
|
||||
@@ -50,6 +51,8 @@ app.whenReady().then(() => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
registerProjectsIpc()
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on('activate', function () {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { describeProject, listProjects, restartProject, startProject, stopProject } from '../ddev'
|
||||
|
||||
export function registerProjectsIpc(): void {
|
||||
ipcMain.handle('projects:list', () => listProjects())
|
||||
ipcMain.handle('projects:describe', (_event, name: string) => describeProject(name))
|
||||
ipcMain.handle('projects:start', (_event, name: string) => startProject(name))
|
||||
ipcMain.handle('projects:stop', (_event, name: string) => stopProject(name))
|
||||
ipcMain.handle('projects:restart', (_event, name: string) => restartProject(name))
|
||||
}
|
||||
Reference in New Issue
Block a user