Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29158d32ba | ||
|
|
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.2",
|
||||
"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
|
||||
|
||||
@@ -21,6 +21,9 @@ export function registerCreateIpc(): void {
|
||||
) => {
|
||||
const args = ['config', `--project-name=${projectName}`, '--auto']
|
||||
if (projectType) args.push(`--project-type=${projectType}`)
|
||||
// ddev creates a missing docroot automatically (--create-docroot is a
|
||||
// deprecated no-op as of DDEV v1.25) — Drupal's docroot ('web') gets
|
||||
// created here, ahead of the composer install that populates it.
|
||||
if (docroot.trim()) args.push(`--docroot=${docroot.trim()}`)
|
||||
return runStreamed(operationId, args, event.sender, { cwd: directory })
|
||||
}
|
||||
@@ -71,4 +74,54 @@ export function registerCreateIpc(): void {
|
||||
return runStreamed(operationId, args, event.sender, { cwd: directory })
|
||||
}
|
||||
)
|
||||
|
||||
// Same class of gap as WordPress: `ddev config --project-type=drupal*`
|
||||
// only writes DDEV's settings.php bridge, not Drupal core — that's what
|
||||
// was producing the 403 (empty docroot, nothing for the webserver to
|
||||
// serve). `drupal/recommended-project`'s own composer.json places core
|
||||
// under web/ via installer-paths, so this runs from the project root
|
||||
// (matching the --docroot=web set by create:configure), not inside web/.
|
||||
ipcMain.handle('create:downloadDrupal', (event, operationId: string, directory: string) =>
|
||||
runStreamed(
|
||||
operationId,
|
||||
['composer', 'create-project', 'drupal/recommended-project'],
|
||||
event.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
)
|
||||
|
||||
// drush was dropped from drupal/recommended-project's default
|
||||
// dependencies in recent Drupal releases, so `drush site:install` has
|
||||
// nothing to run without adding it explicitly first.
|
||||
ipcMain.handle('create:requireDrush', (event, operationId: string, directory: string) =>
|
||||
runStreamed(operationId, ['composer', 'require', 'drush/drush'], event.sender, {
|
||||
cwd: directory
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'create:setupDrupal',
|
||||
(
|
||||
event,
|
||||
operationId: string,
|
||||
directory: string,
|
||||
siteName: string,
|
||||
adminUser: string,
|
||||
adminPassword: string,
|
||||
adminEmail: string,
|
||||
profile: string
|
||||
) => {
|
||||
const args = [
|
||||
'drush',
|
||||
'site:install',
|
||||
profile || 'standard',
|
||||
`--site-name=${siteName}`,
|
||||
`--account-name=${adminUser}`,
|
||||
`--account-pass=${adminPassword}`,
|
||||
`--account-mail=${adminEmail}`,
|
||||
'-y'
|
||||
]
|
||||
return runStreamed(operationId, args, event.sender, { cwd: directory })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { rm } from 'fs/promises'
|
||||
import { describeProject, listProjects } from '../ddev'
|
||||
import { runStreamed } from '../commandRunner'
|
||||
|
||||
@@ -19,10 +20,19 @@ export function registerProjectsIpc(): void {
|
||||
runStreamed(operationId, ['restart', name, '-y'], event.sender)
|
||||
)
|
||||
// Removes DDEV's project registration + containers + database (auto-
|
||||
// snapshotted first, unless omitted) — does not touch the project's files
|
||||
// on disk.
|
||||
ipcMain.handle('projects:delete', (event, operationId: string, name: string) =>
|
||||
runStreamed(operationId, ['delete', name, '--yes'], event.sender)
|
||||
// snapshotted first, unless omitted). Project files on disk are only
|
||||
// touched if the caller opts in via deleteFiles — approot comes from the
|
||||
// renderer's already-fetched project data (not user-typed), and is only
|
||||
// used once `ddev delete` has actually succeeded, so a failed delete never
|
||||
// touches the filesystem.
|
||||
ipcMain.handle(
|
||||
'projects:delete',
|
||||
async (event, operationId: string, name: string, approot: string, deleteFiles: boolean) => {
|
||||
await runStreamed(operationId, ['delete', name, '--yes'], event.sender)
|
||||
if (deleteFiles) {
|
||||
await rm(approot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Reconfigures a project's PHP version, web server, database, or Xdebug
|
||||
|
||||
+30
-2
@@ -25,8 +25,13 @@ const api = {
|
||||
ipcRenderer.invoke('projects:stop', operationId, name),
|
||||
restart: (operationId: string, name: string): Promise<void> =>
|
||||
ipcRenderer.invoke('projects:restart', operationId, name),
|
||||
delete: (operationId: string, name: string): Promise<void> =>
|
||||
ipcRenderer.invoke('projects:delete', operationId, name),
|
||||
delete: (
|
||||
operationId: string,
|
||||
name: string,
|
||||
approot: string,
|
||||
deleteFiles: boolean
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke('projects:delete', operationId, name, approot, deleteFiles),
|
||||
updateEnvironment: (
|
||||
operationId: string,
|
||||
name: string,
|
||||
@@ -129,6 +134,29 @@ const api = {
|
||||
adminPassword,
|
||||
adminEmail,
|
||||
multisite
|
||||
),
|
||||
downloadDrupal: (operationId: string, directory: string): Promise<void> =>
|
||||
ipcRenderer.invoke('create:downloadDrupal', operationId, directory),
|
||||
requireDrush: (operationId: string, directory: string): Promise<void> =>
|
||||
ipcRenderer.invoke('create:requireDrush', operationId, directory),
|
||||
setupDrupal: (
|
||||
operationId: string,
|
||||
directory: string,
|
||||
siteName: string,
|
||||
adminUser: string,
|
||||
adminPassword: string,
|
||||
adminEmail: string,
|
||||
profile: string
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke(
|
||||
'create:setupDrupal',
|
||||
operationId,
|
||||
directory,
|
||||
siteName,
|
||||
adminUser,
|
||||
adminPassword,
|
||||
adminEmail,
|
||||
profile
|
||||
)
|
||||
},
|
||||
zoom: {
|
||||
|
||||
@@ -16,9 +16,11 @@ import {
|
||||
import { useCreateProject } from '../../hooks/useCreateProject'
|
||||
import { useAppStore } from '../../stores/appStore'
|
||||
import { getTypeLabel, PROJECT_TYPES } from './types/registry'
|
||||
import { DrupalSetup } from './types/DrupalSetup'
|
||||
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 +58,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 +69,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 +79,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 })
|
||||
selectProject(name)
|
||||
onClose()
|
||||
} finally {
|
||||
} 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()
|
||||
runPostCreate?.({ directory, projectName: name }).catch(() => {})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -198,6 +210,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>
|
||||
@@ -211,7 +229,15 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => setProjectType(t.value)}
|
||||
onClick={() => {
|
||||
setProjectType(t.value)
|
||||
// drupal/recommended-project's installer-paths
|
||||
// expect a `web` docroot — default it in so a
|
||||
// fresh Drupal project doesn't end up serving
|
||||
// from an unexpected root (only if the user
|
||||
// hasn't already typed a docroot themselves).
|
||||
if (t.value === 'drupal' && !docroot.trim()) setDocroot('web')
|
||||
}}
|
||||
className={clsx(
|
||||
'flex min-h-16 items-center gap-3 rounded-xl border px-3 py-3 text-left transition',
|
||||
isSelected
|
||||
@@ -258,6 +284,12 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
||||
projectName={projectName.trim()}
|
||||
onValidityChange={setSetupValid}
|
||||
/>
|
||||
) : projectType === 'drupal' ? (
|
||||
<DrupalSetup
|
||||
ref={setupRef}
|
||||
projectName={projectName.trim()}
|
||||
onValidityChange={setSetupValid}
|
||||
/>
|
||||
) : (
|
||||
<GenericSetup
|
||||
ref={setupRef}
|
||||
|
||||
@@ -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, '')
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, KeyRound, Layers3, Mail, Type, UserRound } from 'lucide-react'
|
||||
import { useDownloadDrupal, useRequireDrush, useSetupDrupal } from '../../../hooks/useCreateProject'
|
||||
import { useStartProject } from '../../../hooks/useDdev'
|
||||
import type { TypeSetupContext, TypeSetupHandle, TypeSetupProps } from './shared'
|
||||
|
||||
const PROFILES = [
|
||||
{ value: 'standard', label: 'Standard' },
|
||||
{ value: 'minimal', label: 'Minimal' },
|
||||
{ value: 'demo_umami', label: 'Umami demo' }
|
||||
]
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-lg border border-neutral-300 bg-white/80 px-3 py-2 text-sm shadow-sm transition placeholder:text-neutral-400 focus:border-cyan-400 dark:border-white/10 dark:bg-neutral-950/70 dark:placeholder:text-neutral-600'
|
||||
const labelClass =
|
||||
'mb-1.5 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400'
|
||||
|
||||
export const DrupalSetup = forwardRef<TypeSetupHandle, TypeSetupProps>(function DrupalSetup(
|
||||
{ projectName, onValidityChange },
|
||||
ref
|
||||
) {
|
||||
const [siteName, setSiteName] = useState('')
|
||||
const [adminUser, setAdminUser] = useState('admin')
|
||||
const [adminPassword, setAdminPassword] = useState('')
|
||||
const [adminEmail, setAdminEmail] = useState('')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [profile, setProfile] = useState('standard')
|
||||
|
||||
const startProject = useStartProject()
|
||||
const downloadDrupal = useDownloadDrupal()
|
||||
const requireDrush = useRequireDrush()
|
||||
const setupDrupal = useSetupDrupal()
|
||||
|
||||
const isValid =
|
||||
adminUser.trim().length > 0 && adminPassword.trim().length > 0 && adminEmail.trim().length > 0
|
||||
|
||||
useEffect(() => {
|
||||
onValidityChange(isValid)
|
||||
}, [isValid, onValidityChange])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
runPostCreate: async ({ directory, projectName: name }: TypeSetupContext) => {
|
||||
// drush needs the containers running, so this ignores any "start
|
||||
// after creating" preference — an unstarted Drupal project would just
|
||||
// be the same half-built state this whole flow exists to avoid.
|
||||
await startProject.mutateAsync(name)
|
||||
await downloadDrupal.mutateAsync({ directory })
|
||||
await requireDrush.mutateAsync({ directory })
|
||||
await setupDrupal.mutateAsync({
|
||||
directory,
|
||||
siteName: siteName.trim() || name,
|
||||
adminUser: adminUser.trim(),
|
||||
adminPassword: adminPassword.trim(),
|
||||
adminEmail: adminEmail.trim(),
|
||||
profile
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-xl border border-cyan-200 bg-cyan-50 p-4 dark:border-cyan-400/20 dark:bg-cyan-400/10">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid size-10 flex-shrink-0 place-items-center rounded-lg bg-cyan-600 text-white dark:bg-cyan-300 dark:text-neutral-950">
|
||||
<Layers3 size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-cyan-950 dark:text-cyan-100">Drupal install</p>
|
||||
<p className="mt-1 text-xs leading-5 text-cyan-800/80 dark:text-cyan-100/75">
|
||||
Core downloads via Composer and the project starts for setup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className={labelClass}>Site name</label>
|
||||
<div className="relative">
|
||||
<Type
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={siteName}
|
||||
onChange={(e) => setSiteName(e.target.value)}
|
||||
placeholder={projectName || 'My Drupal Site'}
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Admin username</label>
|
||||
<div className="relative">
|
||||
<UserRound
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={adminUser}
|
||||
onChange={(e) => setAdminUser(e.target.value)}
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Admin password</label>
|
||||
<div className="relative">
|
||||
<KeyRound
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className={labelClass}>Admin email</label>
|
||||
<div className="relative">
|
||||
<Mail
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
value={adminEmail}
|
||||
onChange={(e) => setAdminEmail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
className="flex items-center justify-between rounded-xl border border-neutral-200 bg-white/70 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 transition hover:border-cyan-200 hover:bg-cyan-50/50 hover:text-cyan-800 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/25 dark:hover:bg-cyan-400/10 dark:hover:text-cyan-200"
|
||||
>
|
||||
<span>Advanced options</span>
|
||||
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="grid gap-3 rounded-xl border border-neutral-200 bg-neutral-50/70 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div>
|
||||
<label className={labelClass}>Install profile</label>
|
||||
<select
|
||||
value={profile}
|
||||
onChange={(e) => setProfile(e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
{PROFILES.map((p) => (
|
||||
<option key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
export function DeleteProjectModal({
|
||||
projectName,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: {
|
||||
projectName: string
|
||||
onCancel: () => void
|
||||
onConfirm: (deleteFiles: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
const [deleteFiles, setDeleteFiles] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-neutral-950/55 p-8 backdrop-blur-sm">
|
||||
<div className="grid w-full max-w-md gap-4 rounded-2xl border border-white/70 bg-white p-5 shadow-[0_30px_100px_rgba(15,23,42,0.28)] dark:border-white/10 dark:bg-neutral-950">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid size-10 flex-shrink-0 place-items-center rounded-lg bg-red-100 text-red-600 dark:bg-red-500/10 dark:text-red-400">
|
||||
<AlertTriangle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Delete "{projectName}"?
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
DDEV will take a database snapshot first, then remove the project's containers
|
||||
and DDEV registration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 rounded-xl border border-neutral-200 bg-neutral-50/70 p-3 text-sm dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteFiles}
|
||||
onChange={(e) => setDeleteFiles(e.target.checked)}
|
||||
className="mt-0.5 size-4 accent-red-600"
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Also delete project files from disk
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Permanently removes the project folder. This cannot be undone by the database
|
||||
snapshot.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/10 dark:hover:text-neutral-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm(deleteFiles)}
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-red-900/20 transition hover:bg-red-500"
|
||||
>
|
||||
{deleteFiles ? 'Delete project + files' : 'Delete project'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { StatusBadge } from './StatusBadge'
|
||||
import { DatabaseSection } from './DatabaseSection'
|
||||
import { AddonsSection } from './AddonsSection'
|
||||
import { DeleteProjectModal } from './DeleteProjectModal'
|
||||
import { LogViewer } from '../logs/LogViewer'
|
||||
import { useAppStore } from '../../stores/appStore'
|
||||
|
||||
@@ -75,6 +76,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||
const updateEnvironment = useUpdateEnvironment()
|
||||
const selectProject = useAppStore((s) => s.selectProject)
|
||||
const [isLogsOpen, setIsLogsOpen] = useState(false)
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false)
|
||||
|
||||
const isBusy =
|
||||
startProject.isPending ||
|
||||
@@ -84,16 +86,13 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||
|
||||
const isEnvUpdating = updateEnvironment.isPending || restartProject.isPending
|
||||
|
||||
function handleDelete(): void {
|
||||
function handleDeleteConfirm(deleteFiles: boolean): void {
|
||||
if (!project) return
|
||||
const confirmed = window.confirm(
|
||||
`Delete "${project.name}"? DDEV will take a database snapshot first, then remove the ` +
|
||||
`project's containers and DDEV registration. Your project files on disk are not touched.`
|
||||
setIsDeleteOpen(false)
|
||||
deleteProject.mutate(
|
||||
{ name: project.name, approot: project.approot, deleteFiles },
|
||||
{ onSuccess: () => selectProject(null) }
|
||||
)
|
||||
if (!confirmed) return
|
||||
deleteProject.mutate(project.name, {
|
||||
onSuccess: () => selectProject(null)
|
||||
})
|
||||
}
|
||||
|
||||
async function applyEnvironmentChange(updates: EnvironmentUpdate): Promise<void> {
|
||||
@@ -210,7 +209,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={handleDelete}
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-red-300/20 bg-red-400/10 px-3 py-1.5 text-sm font-medium text-red-100 transition hover:bg-red-400/[0.15] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
@@ -395,6 +394,14 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||
onClose={() => setIsLogsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isDeleteOpen && (
|
||||
<DeleteProjectModal
|
||||
projectName={project.name}
|
||||
onCancel={() => setIsDeleteOpen(false)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,15 @@ export interface WordpressSetupInput {
|
||||
multisite: 'none' | 'subdirectory' | 'subdomain'
|
||||
}
|
||||
|
||||
export interface DrupalSetupInput {
|
||||
directory: string
|
||||
siteName: string
|
||||
adminUser: string
|
||||
adminPassword: string
|
||||
adminEmail: string
|
||||
profile: string
|
||||
}
|
||||
|
||||
function beginOperation(label: string): string {
|
||||
const operationId = crypto.randomUUID()
|
||||
useTerminalStore.getState().startOperation(operationId, label)
|
||||
@@ -83,3 +92,42 @@ export function useSetupWordpress(): UseMutationResult<void, Error, WordpressSet
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// `ddev config --project-type=drupal*` only scaffolds DDEV's settings.php
|
||||
// bridge, not Drupal core itself — see create.ts in the main process for why
|
||||
// this, useRequireDrush, and useSetupDrupal are separate tracked operations
|
||||
// run after the project has been started.
|
||||
export function useDownloadDrupal(): UseMutationResult<void, Error, { directory: string }> {
|
||||
return useMutation({
|
||||
mutationFn: async ({ directory }) => {
|
||||
const operationId = beginOperation('Download Drupal core')
|
||||
await window.api.create.downloadDrupal(operationId, directory)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useRequireDrush(): UseMutationResult<void, Error, { directory: string }> {
|
||||
return useMutation({
|
||||
mutationFn: async ({ directory }) => {
|
||||
const operationId = beginOperation('Add Drush')
|
||||
await window.api.create.requireDrush(operationId, directory)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useSetupDrupal(): UseMutationResult<void, Error, DrupalSetupInput> {
|
||||
return useMutation({
|
||||
mutationFn: async ({ directory, siteName, adminUser, adminPassword, adminEmail, profile }) => {
|
||||
const operationId = beginOperation('Install Drupal')
|
||||
await window.api.create.setupDrupal(
|
||||
operationId,
|
||||
directory,
|
||||
siteName,
|
||||
adminUser,
|
||||
adminPassword,
|
||||
adminEmail,
|
||||
profile
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -70,10 +70,25 @@ export function useRestartProject(): UseMutationResult<void, Error, string> {
|
||||
)
|
||||
}
|
||||
|
||||
export function useDeleteProject(): UseMutationResult<void, Error, string> {
|
||||
return useProjectAction('Delete', (operationId, name) =>
|
||||
window.api.projects.delete(operationId, name)
|
||||
)
|
||||
export function useDeleteProject(): UseMutationResult<
|
||||
void,
|
||||
Error,
|
||||
{ name: string; approot: string; deleteFiles: boolean }
|
||||
> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, approot, deleteFiles }) => {
|
||||
const operationId = crypto.randomUUID()
|
||||
const label = `Delete ${name}`
|
||||
useTerminalStore.getState().startOperation(operationId, label)
|
||||
useStatusStore.getState().begin(operationId, label)
|
||||
await window.api.projects.delete(operationId, name, approot, deleteFiles)
|
||||
},
|
||||
onSettled: (_data, _error, { name }) => {
|
||||
queryClient.invalidateQueries({ queryKey: PROJECTS_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: projectDetailKey(name) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Only runs `ddev config` — the caller is expected to follow a successful
|
||||
|
||||
Reference in New Issue
Block a user