Initial Aurora Dockside Alpha 23

This commit is contained in:
reaper
2026-08-13 14:32:54 -05:00
commit 133a3d428d
80 changed files with 16791 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
+56
View File
@@ -0,0 +1,56 @@
# Dependencies
node_modules/
# Build output
out/
dist/
build/
release/
# Environment / secrets
.env
.env.*
!.env.example
# Aurora runtime/generated data
.aurora/
certificates/
*.pem
*.key
*.crt
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS
.DS_Store
Thumbs.db
# Editors
.vscode/
.idea/
*.swp
*.swo
# Temporary files
tmp/
temp/
*.tmp
# Archives / installers
*.zip
*.tar
*.tar.gz
*.dmg
*.AppImage
*.exe
*.deb
*.rpm
# Local AI / development session files
.claude/
CONVERSATION-LOG.md
conversation log.md
+6
View File
@@ -0,0 +1,6 @@
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
+4
View File
@@ -0,0 +1,4 @@
singleQuote: true
semi: false
printWidth: 100
trailingComma: none
+49
View File
@@ -0,0 +1,49 @@
# Aurora Dockside 2.0
Aurora Dockside is becoming a modular Docker-based local development platform for PHP and Node.js projects. Dockside is the Electron GUI; Aurora Engine owns project configuration, Docker Compose generation, runtimes, and modules.
## Alpha 2 module architecture
Each project stores its state in `.aurora/` and can be extended at runtime from the **Module Library**. Module manifests live in `resources/modules/*.json` and describe their category, settings, dependencies, conflicts, optional Docker service, and application defaults. Dockside renders module configuration controls from those manifests rather than hard-coding a UI for every module.
Bundled modules:
- Applications: WordPress, Laravel, Drupal
- Services: Redis
- Developer tools: Mailpit, Adminer
The PHP service is now built from `.aurora/Dockerfile.php` with common extensions and Composer. Installing/removing a service module regenerates Compose and reconciles the running project with `docker compose up -d --remove-orphans`. Application modules are mutually protected by manifest conflicts and can scaffold application source into an existing blank Aurora project.
## Development
```bash
npm install
npm run typecheck
npm run dev
```
Docker Engine with Docker Compose v2 is required.
## Project files
```text
project/
.aurora/
config.json
compose.yaml
nginx.conf
Dockerfile.php
```
This is an alpha. Database snapshots/import/export and automatic HTTPS/router domains are still being rebuilt for the native Aurora Engine.
## Alpha 3 engine milestone
Aurora now owns database portability instead of delegating it to DDEV. MariaDB and PostgreSQL projects support compressed snapshots, restore, SQL/SQL.GZ import, and export through Docker Compose. Database containers include health checks and the web service waits for the database to become healthy before starting. Aurora also shuts down registered project stacks when Dockside exits.
Snapshots are stored per-project under `.aurora/snapshots/` and can be managed from the existing Database panel.
## Alpha 6 router
Aurora now starts a shared Traefik router on localhost ports 80 and 443 and attaches projects to the external `aurora-router` Docker network. New projects are exposed at both `http://<project>.aurora.localhost` and `https://<project>.aurora.localhost`; subdomain hosts are routed to the same project for WordPress multisite testing. HTTPS currently uses Traefik's generated local certificate, so browsers may show a trust warning until Aurora CA installation is added. The random localhost port remains published as a diagnostic fallback, but is no longer the primary project URL.
+33
View File
@@ -0,0 +1,33 @@
appId: com.aurora-dockside.app
productName: Aurora Dockside
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
win:
executableName: aurora-dockside
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
entitlementsInherit: build/entitlements.mac.plist
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- deb
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
+18
View File
@@ -0,0 +1,18 @@
import { resolve } from 'path'
import { defineConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
main: {},
preload: {},
renderer: {
resolve: {
alias: {
'@renderer': resolve('src/renderer/src'),
'@shared': resolve('src/shared')
}
},
plugins: [react(), tailwindcss()]
}
})
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig } from 'eslint/config'
import tseslint from '@electron-toolkit/eslint-config-ts'
import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier'
import eslintPluginReact from 'eslint-plugin-react'
import eslintPluginReactHooks from 'eslint-plugin-react-hooks'
import eslintPluginReactRefresh from 'eslint-plugin-react-refresh'
export default defineConfig(
{ ignores: ['**/node_modules', '**/dist', '**/out'] },
tseslint.configs.recommended,
eslintPluginReact.configs.flat.recommended,
eslintPluginReact.configs.flat['jsx-runtime'],
{
settings: {
react: {
version: 'detect'
}
}
},
{
files: ['**/*.{ts,tsx}'],
plugins: {
'react-hooks': eslintPluginReactHooks,
'react-refresh': eslintPluginReactRefresh
},
rules: {
...eslintPluginReactHooks.configs.recommended.rules,
...eslintPluginReactRefresh.configs.vite.rules
}
},
eslintConfigPrettier
)
+11626
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
{
"name": "aurora-dockside",
"version": "2.0.0-alpha.23",
"description": "A modern, modular Docker development platform for building, running, and managing web applications locally.",
"main": "./out/main/index.js",
"homepage": "https://github.com/KrisAsvestas/auroradockside",
"author": {
"name": "Kris Asvestas"
},
"scripts": {
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint --cache .",
"lint:fix": "eslint --cache --fix .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@tanstack/react-query": "^5.101.4",
"clsx": "^2.1.1",
"lucide-react": "^1.28.0",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.19.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.1.10",
"@vitest/ui": "^4.1.10",
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"jsdom": "^30.0.1",
"prettier": "^3.7.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
"vite": "^7.2.6",
"vitest": "^4.1.10"
},
"repository": {
"type": "git",
"url": "git+https://github.com/KrisAsvestas/auroradockside.git"
},
"bugs": {
"url": "https://github.com/KrisAsvestas/auroradockside/issues"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

+4
View File
@@ -0,0 +1,4 @@
{
"id":"adminer","name":"Adminer","version":"1.0.0","category":"tool","description":"Lightweight browser database administration.","icon":"database","dependencies":[],"conflicts":[],"settings":[],
"compose":{"service":"adminer","image":"adminer:latest","ports":[8080],"dependsOn":["db"]}
}
+3
View File
@@ -0,0 +1,3 @@
{
"id":"drupal","name":"Drupal","version":"1.0.0","category":"application","description":"Drupal CMS using the recommended Composer project layout.","icon":"layers","dependencies":[],"conflicts":["wordpress","laravel"],"defaults":{"docroot":"web"},"settings":[]
}
+4
View File
@@ -0,0 +1,4 @@
{
"id":"laravel","name":"Laravel","version":"1.0.0","category":"application","description":"Laravel application with Composer and Vite-ready Node runtime.","icon":"code","dependencies":[],"conflicts":["wordpress","drupal"],"defaults":{"docroot":"public"},
"settings":[{"id":"starter","label":"Starter kit","type":"select","default":"none","options":["none","breeze"]}]
}
+4
View File
@@ -0,0 +1,4 @@
{
"id":"mailpit","name":"Mailpit","version":"1.0.0","category":"tool","description":"Local SMTP inbox and browser-based mail viewer.","icon":"mail","dependencies":[],"conflicts":[],"settings":[],
"compose":{"service":"mailpit","image":"axllent/mailpit:latest","ports":[8025,1025]}
}
+5
View File
@@ -0,0 +1,5 @@
{
"id":"redis","name":"Redis","version":"1.0.0","category":"service","description":"Redis 8 cache and data store.","icon":"database","dependencies":[],"conflicts":[],
"settings":[{"id":"persistence","label":"Enable persistence","type":"boolean","default":true}],
"compose":{"service":"redis","image":"redis:8-alpine","ports":[6379]}
}
+4
View File
@@ -0,0 +1,4 @@
{
"id":"wordpress","name":"WordPress","version":"1.0.0","category":"application","description":"WordPress CMS with WP-CLI tooling.","icon":"globe","dependencies":[],"conflicts":["laravel","drupal"],"defaults":{"docroot":""},
"settings":[{"id":"wp_debug","label":"WP_DEBUG","type":"boolean","default":true},{"id":"multisite","label":"Enable Multisite","type":"boolean","default":false}]
}
+480
View File
@@ -0,0 +1,480 @@
import { app } from 'electron'
import { execFile } from 'child_process'
import { promisify } from 'util'
import { mkdir, readFile, writeFile, access, rm, readdir } from 'fs/promises'
import { join } from 'path'
import type { AuroraProjectDetail, AuroraProjectSummary, AuroraInstalledModule, AuroraModuleManifest, AuroraStackOptions } from '../shared/types'
import { getModuleManifest, getModuleRegistry } from './moduleRegistry'
const execFileAsync = promisify(execFile)
const EXTRA_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/opt/local/bin']
export const AURORA_ENV = { ...process.env, PATH: [...EXTRA_PATH_DIRS, process.env.PATH].join(':') }
type AuroraConfig = {
name: string
type: string
docroot: string
php: string
node: string
webserver: 'nginx'
database: 'mariadb' | 'postgres'
databaseVersion: string
modules: string[]
moduleSettings?: Record<string, Record<string, string | number | boolean>>
primaryProtocol?: 'http' | 'https'
wordpressMultisite?: 'none' | 'subdirectory' | 'subdomain'
xdebug?: boolean
}
type Registry = { projects: Record<string, string> }
const configDir = (root: string): string => join(root, '.aurora')
const configPath = (root: string): string => join(configDir(root), 'config.json')
const composePath = (root: string): string => join(configDir(root), 'compose.yaml')
const phpDockerfilePath = (root: string): string => join(configDir(root), 'Dockerfile.php')
const registryPath = (): string => join(app.getPath('userData'), 'projects.json')
const routerDir = (): string => join(app.getPath('userData'), 'router')
const routerConfigPath = (): string => join(routerDir(), 'dynamic.yaml')
const certDir = (): string => join(app.getPath('userData'), 'certificates')
const caDir = (): string => join(certDir(), 'ca')
const projectsCertDir = (): string => join(certDir(), 'projects')
const caKeyPath = (): string => join(caDir(), 'aurora-root-ca.key')
const caCertPath = (): string => join(caDir(), 'aurora-root-ca.crt')
const projectCertPath = (name: string): string => join(projectsCertDir(), `${safeName(name)}.crt`)
const projectKeyPath = (name: string): string => join(projectsCertDir(), `${safeName(name)}.key`)
const safeName = (name: string): string => name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'project'
const projectHost = (name: string): string => `${safeName(name)}.aurora.localhost`
export const projectUrls = (name: string) => ({ http: `http://${projectHost(name)}`, https: `https://${projectHost(name)}` })
async function loadRegistry(): Promise<Registry> {
try { return JSON.parse(await readFile(registryPath(), 'utf8')) as Registry } catch { return { projects: {} } }
}
async function saveRegistry(registry: Registry): Promise<void> {
await mkdir(app.getPath('userData'), { recursive: true })
await writeFile(registryPath(), JSON.stringify(registry, null, 2))
}
async function readConfig(root: string): Promise<AuroraConfig> {
return JSON.parse(await readFile(configPath(root), 'utf8')) as AuroraConfig
}
async function writeConfig(root: string, config: AuroraConfig): Promise<void> {
await mkdir(configDir(root), { recursive: true })
await writeFile(configPath(root), JSON.stringify(config, null, 2))
await writeFile(composePath(root), await renderCompose(config))
}
function indent(lines: string, spaces = 4): string {
const pad = ' '.repeat(spaces)
return lines.split('\n').map((line) => line ? pad + line : line).join('\n')
}
async function renderModuleService(module: AuroraModuleManifest, config: AuroraConfig): Promise<string> {
if (!module.compose) return ''
const { service, image, ports = [], dependsOn = [] } = module.compose
const settings = config.moduleSettings?.[module.id] ?? {}
const lines = [`${service}:`, ` image: ${image}`]
if (module.id === 'redis' && settings.persistence !== false) {
lines.push(' volumes:', ' - redis_data:/data')
}
if (ports.length) {
lines.push(' ports:')
for (const port of ports) lines.push(` - "127.0.0.1::${port}"`)
}
if (dependsOn.length) lines.push(' depends_on:', ...dependsOn.map((dep) => ` - ${dep}`))
if (module.id === 'mailpit') lines.push(' networks:', ' default:', ' aurora-router:', ' aliases:', ` - aurora-${safeName(config.name)}-mailpit`)
return indent(lines.join('\n'), 2)
}
async function renderCompose(c: AuroraConfig): Promise<string> {
const db = c.database === 'postgres'
? ` db:\n image: postgres:${c.databaseVersion}\n environment:\n POSTGRES_DB: db\n POSTGRES_USER: db\n POSTGRES_PASSWORD: db\n volumes:\n - db_data:/var/lib/postgresql/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U db -d db\"]\n interval: 3s\n timeout: 3s\n retries: 20`
: ` db:\n image: mariadb:${c.databaseVersion}\n environment:\n MARIADB_DATABASE: db\n MARIADB_USER: db\n MARIADB_PASSWORD: db\n MARIADB_ROOT_PASSWORD: root\n volumes:\n - db_data:/var/lib/mysql\n healthcheck:\n test: [\"CMD\", \"healthcheck.sh\", \"--connect\", \"--innodb_initialized\"]\n interval: 3s\n timeout: 3s\n retries: 20`
const manifests = await Promise.all(c.modules.filter((id) => id !== 'adminer').map((id) => getModuleManifest(id)))
const extras = (await Promise.all(manifests.map((m) => renderModuleService(m, c)))).filter(Boolean)
const volumes = [' db_data:']
if (c.modules.includes('redis') && c.moduleSettings?.redis?.persistence !== false) volumes.push(' redis_data:')
const adminer = c.modules.includes('adminer') ? ` adminer:\n image: adminer:5\n environment:\n ADMINER_DEFAULT_SERVER: db\n networks:\n default:\n aurora-router:\n aliases:\n - aurora-${safeName(c.name)}-adminer\n depends_on:\n db:\n condition: service_healthy` : ''
return `name: aurora-${safeName(c.name)}\nservices:\n web:\n image: nginx:1.29-alpine\n working_dir: /var/www/html\n volumes:\n - ../:/var/www/html\n - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro\n ports:\n - "127.0.0.1::80"\n networks:\n default:\n aurora-router:\n aliases:\n - aurora-${safeName(c.name)}-web\n depends_on:\n php:\n condition: service_started\n db:\n condition: service_healthy\n php:\n build:\n context: .\n dockerfile: Dockerfile.php\n args:\n PHP_VERSION: ${c.php}\n working_dir: /var/www/html\n volumes:\n - ../:/var/www/html\n node:\n image: node:${c.node}-alpine\n working_dir: /var/www/html\n volumes:\n - ../:/var/www/html\n command: ["sh", "-c", "sleep infinity"]\n${db}${adminer ? `\n${adminer}` : ''}${extras.length ? `\n${extras.join('\n')}` : ''}\nvolumes:\n${volumes.join('\n')}\nnetworks:\n aurora-router:\n external: true\n name: aurora-router\n`
}
async function writePhpDockerfile(root: string, xdebug = false): Promise<void> {
await writeFile(phpDockerfilePath(root), `ARG PHP_VERSION=8.4
FROM php:${'${PHP_VERSION}'}-fpm-alpine
RUN apk add --no-cache icu-dev libzip-dev libpng-dev libjpeg-turbo-dev freetype-dev oniguruma-dev postgresql-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) mysqli pdo_mysql pdo_pgsql intl zip gd mbstring opcache
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
${xdebug ? 'RUN apk add --no-cache $PHPIZE_DEPS linux-headers && pecl install xdebug && docker-php-ext-enable xdebug' : ''}
`)
}
async function writeNginx(root: string, docroot: string): Promise<void> {
const webroot = docroot ? `/var/www/html/${docroot}` : '/var/www/html'
await writeFile(join(configDir(root), 'nginx.conf'), `map $http_x_forwarded_proto $aurora_https {
default off;
https on;
}
server {
listen 80;
server_name _;
root ${webroot};
index index.php index.html;
location / { try_files $uri $uri/ /index.php?$query_string; }
location ~ \.php$ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param HTTPS $aurora_https; fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto; fastcgi_param HTTP_X_FORWARDED_HOST $http_x_forwarded_host; fastcgi_pass php:9000; }
}
`)
}
export async function createProject(root: string, name: string, type: string, docroot: string, stack?: Partial<AuroraStackOptions>): Promise<void> {
await mkdir(root, { recursive: true })
const normalizedType = type || 'generic'
const defaultDocroot = docroot || (normalizedType === 'laravel' ? 'public' : normalizedType === 'drupal' ? 'web' : '')
const modules = normalizedType === 'generic' || normalizedType === 'php' ? [] : [normalizedType]
if (stack?.adminer !== false) modules.push('adminer')
if (stack?.redis) modules.push('redis')
if (stack?.mailpit) modules.push('mailpit')
const config: AuroraConfig = { name, type: normalizedType, docroot: defaultDocroot, php: stack?.phpVersion || '8.4', node: stack?.nodeVersion || '24', webserver: 'nginx', database: stack?.database || 'mariadb', databaseVersion: stack?.databaseVersion || '11.8', modules, moduleSettings: {}, primaryProtocol: 'https', xdebug: stack?.xdebug === true }
await writeConfig(root, config); await writeNginx(root, defaultDocroot); await writePhpDockerfile(root, config.xdebug)
const reg = await loadRegistry(); reg.projects[name] = root; await saveRegistry(reg)
if (normalizedType === 'generic' || normalizedType === 'php') await writeFile(join(root, 'index.php'), `<?php echo '<h1>${name}</h1><p>Aurora Dockside is running.</p>';`)
}
export async function unregisterProject(name: string, deleteFiles: boolean): Promise<void> {
const reg = await loadRegistry(); const root = reg.projects[name]; delete reg.projects[name]; await saveRegistry(reg)
if (deleteFiles && root) await rm(root, { recursive: true, force: true })
}
async function composeJson(root: string): Promise<any[]> {
try { const { stdout } = await execFileAsync('docker', ['compose', '-f', composePath(root), 'ps', '--format', 'json'], { env: AURORA_ENV, maxBuffer: 8*1024*1024 }); return stdout.trim().split('\n').filter(Boolean).map(x => JSON.parse(x)) } catch { return [] }
}
export async function listProjects(): Promise<AuroraProjectSummary[]> {
const reg = await loadRegistry(); const out: AuroraProjectSummary[] = []
for (const [name, root] of Object.entries(reg.projects)) {
try { await access(configPath(root)); const c = await readConfig(root); const ps = await composeJson(root); const running = ps.some(p => p.State === 'running'); const urls = projectUrls(c.name); const primary = c.primaryProtocol === 'http' ? urls.http : urls.https
out.push({ name, status: running?'running':'stopped', status_desc: running?'Running':'Stopped', type:c.type, approot:root, shortroot:root, docroot:c.docroot, primary_url:primary, httpurl:urls.http, httpsurl:urls.https, mutagen_enabled:false })
} catch { /* stale registry entry */ }
} return out
}
export async function describeProject(name: string): Promise<AuroraProjectDetail> {
const reg = await loadRegistry(); const root = reg.projects[name]; if (!root) throw new Error(`Aurora project '${name}' not found`)
const c = await readConfig(root); const ps = await composeJson(root); const running = ps.some(p=>p.State==='running'); const currentRouterStatus = await routerStatus(); const urlSet=projectUrls(c.name); const primary=c.primaryProtocol === 'http' ? urlSet.http : urlSet.https
const services: Record<string, any> = {}; for (const p of ps) services[p.Service]={short_name:p.Service,full_name:p.Name,status:p.State,image:p.Image,exposed_ports:'',host_ports:'',host_ports_mapping:[]}
return { name,status:running?'running':'stopped',status_desc:running?'Running':'Stopped',type:c.type,approot:root,shortroot:root,docroot:c.docroot,primary_url:primary,httpurl:urlSet.http,httpsurl:urlSet.https,mutagen_enabled:false,database_type:c.database,database_version:c.databaseVersion,dbinfo:{database_type:c.database,database_version:c.databaseVersion,dbPort:c.database==='postgres'?'5432':'3306',dbname:'db',host:'db',password:'db',published_port:0,username:'db'},hostname:projectHost(c.name),hostnames:[projectHost(c.name)],httpURLs:[urlSet.http],httpsURLs:[urlSet.https],urls:[urlSet.http,urlSet.https],php_version:c.php,nodejs_version:c.node,webserver_type:'nginx',router:'file',router_status:currentRouterStatus,certificate_status:await certificateStatus(c.name),ca_trust_status:await caTrustStatus(),firefox_trust_status:await firefoxTrustStatus(),chromium_trust_status:await chromiumTrustStatus(),wordpress_multisite:c.type==='wordpress'?(c.wordpressMultisite??'none'):undefined,wordpress_network_admin_url:c.type==='wordpress'&&c.wordpressMultisite&&c.wordpressMultisite!=='none'?`${primary.replace(/\/$/,'')}/wp-admin/network/`:undefined,adminer_url:c.modules.includes('adminer')?`https://adminer.${projectHost(c.name)}`:undefined,services,xdebug_enabled:c.xdebug===true }
}
export async function updateEnvironment(root:string, updates:{phpVersion?:string;nodeVersion?:string;database?:string;xdebugEnabled?:boolean;primaryProtocol?:'http'|'https'}):Promise<void>{
const c=await readConfig(root)
if(updates.phpVersion)c.php=updates.phpVersion
if(updates.nodeVersion)c.node=updates.nodeVersion
if(typeof updates.xdebugEnabled === 'boolean') c.xdebug=updates.xdebugEnabled
if(updates.database){const [kind,version]=updates.database.split(':'); if(kind==='mariadb'||kind==='postgres'){c.database=kind;c.databaseVersion=version||c.databaseVersion}}
if(updates.primaryProtocol === 'http' || updates.primaryProtocol === 'https') c.primaryProtocol = updates.primaryProtocol
await writeConfig(root,c); await writeNginx(root,c.docroot); await writePhpDockerfile(root, c.xdebug === true)
}
export async function getProjectConfig(root: string): Promise<AuroraConfig> { return readConfig(root) }
export async function setWordpressMultisite(root: string, mode: 'none' | 'subdirectory' | 'subdomain'): Promise<void> {
const config = await readConfig(root)
config.wordpressMultisite = mode
await writeConfig(root, config)
}
export async function routerStatus(): Promise<'running' | 'provider-error' | 'stopped'> {
try {
const { stdout } = await execFileAsync('docker', ['inspect', '-f', '{{.State.Running}}', 'aurora-router'], { env: AURORA_ENV })
if (stdout.trim() !== 'true') return 'stopped'
const { stdout: logs = '', stderr: logErrors = '' } = await execFileAsync('docker', ['logs', '--tail', '40', 'aurora-router'], { env: AURORA_ENV, maxBuffer: 2 * 1024 * 1024 })
const recentLogs = `${logs}\n${logErrors}`
if (recentLogs.includes('Error while building configuration') || recentLogs.includes('field not found, node:')) return 'provider-error'
return 'running'
} catch { return 'stopped' }
}
export async function routerRunning(): Promise<boolean> {
return (await routerStatus()) === 'running'
}
export async function listModules(): Promise<AuroraModuleManifest[]> {
return getModuleRegistry()
}
export async function listInstalledModules(name: string): Promise<AuroraInstalledModule[]> {
const reg = await loadRegistry()
const root = reg.projects[name]
if (!root) throw new Error(`Aurora project '${name}' not found`)
const config = await readConfig(root)
const manifests = await Promise.all(config.modules.map((id) => getModuleManifest(id)))
return manifests.map((module) => ({ id: module.id, name: module.name, version: module.version, category: module.category }))
}
export async function setModule(
name: string,
moduleId: string,
install: boolean,
settings: Record<string, string | number | boolean> = {}
): Promise<void> {
const reg = await loadRegistry()
const root = reg.projects[name]
if (!root) throw new Error(`Aurora project '${name}' not found`)
const config = await readConfig(root)
const module = await getModuleManifest(moduleId)
config.moduleSettings ??= {}
if (install) {
const conflict = module.conflicts.find((id) => config.modules.includes(id))
if (conflict) throw new Error(`${module.name} conflicts with the installed '${conflict}' application module.`)
for (const dependency of module.dependencies) {
if (!config.modules.includes(dependency)) config.modules.push(dependency)
}
if (!config.modules.includes(moduleId)) config.modules.push(moduleId)
config.moduleSettings[moduleId] = Object.fromEntries(module.settings.map((item) => [item.id, item.default]))
Object.assign(config.moduleSettings[moduleId], settings)
if (module.category === 'application') {
config.type = moduleId
if (module.defaults?.docroot !== undefined) config.docroot = module.defaults.docroot
}
} else {
const dependents = (await getModuleRegistry()).filter((item) => item.dependencies.includes(moduleId) && config.modules.includes(item.id))
if (dependents.length) throw new Error(`Cannot remove ${module.name}; required by ${dependents.map((item) => item.name).join(', ')}.`)
config.modules = config.modules.filter((id) => id !== moduleId)
delete config.moduleSettings[moduleId]
if (config.type === moduleId) config.type = 'generic'
}
await writeConfig(root, config)
await writeNginx(root, config.docroot)
await writePhpDockerfile(root, config.xdebug === true)
}
export async function scaffoldApplicationModule(name: string, moduleId: string): Promise<void> {
const root = await getProjectRoot(name)
const module = await getModuleManifest(moduleId)
if (module.category !== 'application') return
if (moduleId === 'wordpress') {
await execFileAsync('docker', ['run', '--rm', '-v', `${root}:/app`, '-w', '/app', 'wordpress:cli', 'core', 'download', '--skip-content', '--force', '--allow-root'], { env: AURORA_ENV, maxBuffer: 16 * 1024 * 1024 })
return
}
const packageName = moduleId === 'laravel' ? 'laravel/laravel' : moduleId === 'drupal' ? 'drupal/recommended-project' : null
if (!packageName) return
await execFileAsync('docker', ['run', '--rm', '-v', `${root}:/app`, 'composer:2', 'sh', '-lc', `rm -rf /tmp/aurora-app && composer create-project ${packageName} /tmp/aurora-app --no-interaction && cp -a /tmp/aurora-app/. /app/`], { env: AURORA_ENV, maxBuffer: 32 * 1024 * 1024 })
}
export async function getProjectRoot(name:string):Promise<string>{const r=await loadRegistry();if(!r.projects[name])throw new Error(`Project ${name} not found`);return r.projects[name]}
export async function getProjectConfigByRoot(root: string): Promise<{ database: 'mariadb' | 'postgres'; databaseVersion: string; name: string }> {
const config = await readConfig(root)
return { database: config.database, databaseVersion: config.databaseVersion, name: config.name }
}
async function ensureCertificateAuthority(): Promise<void> {
await mkdir(caDir(), { recursive: true })
try { await access(caKeyPath()); await access(caCertPath()); return } catch { /* create below */ }
await execFileAsync('openssl', ['req','-x509','-newkey','rsa:3072','-sha256','-days','3650','-nodes','-keyout',caKeyPath(),'-out',caCertPath(),'-subj','/CN=Aurora Dockside Local Development CA','-addext','basicConstraints=critical,CA:TRUE','-addext','keyUsage=critical,keyCertSign,cRLSign'], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
}
async function ensureProjectCertificate(name: string): Promise<void> {
await ensureCertificateAuthority()
await mkdir(projectsCertDir(), { recursive: true })
const cert = projectCertPath(name); const key = projectKeyPath(name)
try { await access(cert); await access(key); return } catch { /* create below */ }
const host = projectHost(name)
const csr = join(projectsCertDir(), `${safeName(name)}.csr`)
await execFileAsync('openssl', ['req','-new','-newkey','rsa:2048','-nodes','-keyout',key,'-out',csr,'-subj',`/CN=${host}`,'-addext',`subjectAltName=DNS:${host},DNS:*.${host}`], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
await execFileAsync('openssl', ['x509','-req','-in',csr,'-CA',caCertPath(),'-CAkey',caKeyPath(),'-CAcreateserial','-out',cert,'-days','825','-sha256','-copy_extensions','copy'], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
await rm(csr, { force: true })
}
export async function certificateStatus(name: string): Promise<'generated' | 'missing'> {
try { await access(projectCertPath(name)); await access(projectKeyPath(name)); return 'generated' } catch { return 'missing' }
}
export async function caTrustStatus(): Promise<'trusted' | 'not-trusted' | 'unknown'> {
if (process.platform !== 'linux') return 'unknown'
try { await access('/usr/local/share/ca-certificates/aurora-dockside-local-ca.crt'); return 'trusted' } catch { return 'not-trusted' }
}
const firefoxRoots = (): string[] => {
const home = process.env.HOME || app.getPath('home')
return [join(home,'.mozilla','firefox'), join(home,'snap','firefox','common','.mozilla','firefox')]
}
async function pathExists(path: string): Promise<boolean> {
try { await access(path); return true } catch { return false }
}
async function firefoxProfiles(): Promise<string[]> {
const profiles: string[] = []
for (const root of firefoxRoots()) {
// Prefer the active/default profile declared by Firefox itself.
try {
const ini = await readFile(join(root, 'profiles.ini'), 'utf8')
const sections = ini.split(/^\s*\[/m).map((section, index) => index === 0 ? section : '[' + section)
for (const section of sections) {
if (!/^\[Profile\d+\]/m.test(section) || !/^Default=1\s*$/m.test(section)) continue
const match = section.match(/^Path=(.+)\s*$/m)
if (!match) continue
const profile = join(root, match[1].trim())
if (await pathExists(join(profile, 'cert9.db'))) profiles.push(profile)
}
} catch { /* no profiles.ini */ }
// Fall back to every NSS profile, but never add duplicates.
try {
for (const entry of await readdir(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const dir = join(root, entry.name)
if (!profiles.includes(dir) && await pathExists(join(dir,'cert9.db'))) profiles.push(dir)
}
} catch { /* Firefox root does not exist */ }
}
return profiles
}
async function hasCertutil(): Promise<boolean> {
try { await execFileAsync('certutil',['-L','-d','sql:/dev/null'],{env:AURORA_ENV,maxBuffer:1024*1024}); return true } catch (e:any) {
return e?.code !== 'ENOENT'
}
}
async function nssHasTrustedAuroraCA(db: string): Promise<boolean> {
try {
const { stdout } = await execFileAsync('certutil',['-L','-d',`sql:${db}`],{env:AURORA_ENV,maxBuffer:1024*1024})
const line = stdout.split(/\r?\n/).find((value) => value.includes('Aurora Dockside Local Development CA'))
return Boolean(line && /\bC,,\s*$/.test(line.trim()))
} catch { return false }
}
export async function firefoxTrustStatus(): Promise<'trusted' | 'not-trusted' | 'unavailable' | 'unknown'> {
if (process.platform !== 'linux') return 'unknown'
if (!(await hasCertutil())) return 'unavailable'
const profiles = await firefoxProfiles()
if (!profiles.length) return 'unknown'
for (const profile of profiles) if (!(await nssHasTrustedAuroraCA(profile))) return 'not-trusted'
return 'trusted'
}
const chromiumNssDb = (): string => join(process.env.HOME || app.getPath('home'), '.pki', 'nssdb')
async function chromiumTrustTargetExists(): Promise<boolean> {
const db = chromiumNssDb()
if (await pathExists(join(db, 'cert9.db'))) return true
for (const command of ['google-chrome','google-chrome-stable','chromium','chromium-browser','brave-browser','microsoft-edge','vivaldi']) {
try { await execFileAsync('which',[command],{env:AURORA_ENV,maxBuffer:1024*1024}); return true } catch { /* try next */ }
}
return false
}
export async function chromiumTrustStatus(): Promise<'trusted' | 'not-trusted' | 'unavailable' | 'unknown'> {
if (process.platform !== 'linux') return 'unknown'
if (!(await chromiumTrustTargetExists())) return 'unknown'
if (!(await hasCertutil())) return 'unavailable'
return await nssHasTrustedAuroraCA(chromiumNssDb()) ? 'trusted' : 'not-trusted'
}
async function installIntoNssDb(db: string): Promise<void> {
await mkdir(db, { recursive: true })
if (!(await pathExists(join(db,'cert9.db')))) {
await execFileAsync('certutil',['-N','--empty-password','-d',`sql:${db}`],{env:AURORA_ENV,maxBuffer:1024*1024})
}
try { await execFileAsync('certutil',['-D','-d',`sql:${db}`,'-n','Aurora Dockside Local Development CA'],{env:AURORA_ENV,maxBuffer:1024*1024}) } catch { /* absent is fine */ }
await execFileAsync('certutil',['-A','-d',`sql:${db}`,'-n','Aurora Dockside Local Development CA','-t','C,,','-i',caCertPath()],{env:AURORA_ENV,maxBuffer:1024*1024})
if (!(await nssHasTrustedAuroraCA(db))) throw new Error(`Aurora CA import verification failed for NSS database: ${db}`)
}
export async function trustAuroraCA(): Promise<void> {
await ensureCertificateAuthority()
if (process.platform !== 'linux') throw new Error('Automatic CA trust is currently implemented for Linux only.')
const script = `install -m 0644 "${caCertPath().replace(/"/g, '\\"')}" /usr/local/share/ca-certificates/aurora-dockside-local-ca.crt && update-ca-certificates`
await execFileAsync('pkexec', ['sh','-c',script], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
if (!(await hasCertutil())) {
await execFileAsync('pkexec',['sh','-c','apt-get update && apt-get install -y libnss3-tools'],{env:AURORA_ENV,maxBuffer:32*1024*1024})
}
// These are the exact stores verified on Ubuntu: Snap/native Firefox profiles
// and the shared Chromium NSS database at ~/.pki/nssdb.
const profiles = await firefoxProfiles()
for (const profile of profiles) await installIntoNssDb(profile)
if (await chromiumTrustTargetExists()) await installIntoNssDb(chromiumNssDb())
const firefox = await firefoxTrustStatus()
const chromium = await chromiumTrustStatus()
if (profiles.length && firefox !== 'trusted') throw new Error('Aurora CA could not be verified in the active Firefox NSS trust store.')
if (await chromiumTrustTargetExists() && chromium !== 'trusted') throw new Error('Aurora CA could not be verified in ~/.pki/nssdb for Chromium-family browsers.')
}
async function writeRouterConfig(): Promise<void> {
const registry = await loadRegistry()
const routers: string[] = []
const services: string[] = []
for (const [name, root] of Object.entries(registry.projects)) {
try {
const config = await readConfig(root)
await ensureProjectCertificate(config.name || name)
const safe = safeName(config.name || name)
const host = projectHost(config.name || name)
const rule = config.type === 'wordpress' && config.wordpressMultisite === 'subdomain'
? `Host(\`${host}\`) || HostRegexp(\`^[a-z0-9-]+\\.${host.replace(/\./g, '\\.')}$\`)`
: `Host(\`${host}\`)`
routers.push(
` aurora-${safe}-http:\n rule: '${rule}'\n entryPoints: [web]\n service: aurora-${safe}`,
` aurora-${safe}-https:\n rule: '${rule}'\n entryPoints: [websecure]\n service: aurora-${safe}\n tls: {}`
)
services.push(` aurora-${safe}:\n loadBalancer:\n servers:\n - url: http://aurora-${safe}-web:80`)
if (config.modules.includes('adminer')) routers.push(
` aurora-${safe}-adminer-http:\n rule: 'Host(\`adminer.${host}\`)'\n entryPoints: [web]\n service: aurora-${safe}-adminer`,
` aurora-${safe}-adminer-https:\n rule: 'Host(\`adminer.${host}\`)'\n entryPoints: [websecure]\n service: aurora-${safe}-adminer\n tls: {}`
)
if (config.modules.includes('adminer')) services.push(` aurora-${safe}-adminer:\n loadBalancer:\n servers:\n - url: http://aurora-${safe}-adminer:8080`)
if (config.modules.includes('mailpit')) {
routers.push(
` aurora-${safe}-mailpit-http:\n rule: 'Host(\`mail.${host}\`)'\n entryPoints: [web]\n service: aurora-${safe}-mailpit`,
` aurora-${safe}-mailpit-https:\n rule: 'Host(\`mail.${host}\`)'\n entryPoints: [websecure]\n service: aurora-${safe}-mailpit\n tls: {}`
)
services.push(` aurora-${safe}-mailpit:\n loadBalancer:\n servers:\n - url: http://aurora-${safe}-mailpit:8025`)
}
} catch { /* ignore stale registry entries */ }
}
const tlsCerts: string[] = []
for (const [name, root] of Object.entries(registry.projects)) {
try { const config = await readConfig(root); const safe = safeName(config.name || name); tlsCerts.push(` - certFile: /etc/traefik/certs/${safe}.crt\n keyFile: /etc/traefik/certs/${safe}.key`) } catch { /* stale */ }
}
const dynamic = `http:\n routers:\n${routers.length ? routers.join('\n') : ' {}'}\n services:\n${services.length ? services.join('\n') : ' {}'}\n${tlsCerts.length ? `tls:\n certificates:\n${tlsCerts.join('\n')}\n` : ''}`
await mkdir(routerDir(), { recursive: true })
await writeFile(routerConfigPath(), dynamic)
}
export async function ensureRouter(): Promise<void> {
try { await execFileAsync('docker', ['network', 'inspect', 'aurora-router'], { env: AURORA_ENV }) }
catch { await execFileAsync('docker', ['network', 'create', 'aurora-router'], { env: AURORA_ENV }) }
await writeRouterConfig()
try {
const { stdout } = await execFileAsync('docker', ['inspect', '-f', '{{.State.Running}} {{json .Config.Cmd}}', 'aurora-router'], { env: AURORA_ENV })
const fileProvider = stdout.includes('--providers.file.directory=/etc/traefik/dynamic')
if (stdout.trimStart().startsWith('true') && fileProvider) return
await execFileAsync('docker', ['rm', '-f', 'aurora-router'], { env: AURORA_ENV }).catch(() => undefined)
} catch { /* router does not exist yet */ }
await execFileAsync('docker', [
'run', '-d', '--name', 'aurora-router', '--restart', 'unless-stopped',
'--network', 'aurora-router',
'-p', '127.0.0.1:80:80', '-p', '127.0.0.1:443:443',
'-v', `${routerDir()}:/etc/traefik/dynamic:ro`,
'-v', `${projectsCertDir()}:/etc/traefik/certs:ro`,
'traefik:v3.5',
'--providers.file.directory=/etc/traefik/dynamic', '--providers.file.watch=true',
'--entrypoints.web.address=:80', '--entrypoints.websecure.address=:443',
'--api.dashboard=false', '--log.level=INFO'
], { env: AURORA_ENV, maxBuffer: 8 * 1024 * 1024 })
}
export async function powerOffProjects(): Promise<void> {
const registry = await loadRegistry()
await Promise.allSettled(Object.values(registry.projects).map(async (root) => {
try {
await access(composePath(root))
await execFileAsync('docker', ['compose', '-f', composePath(root), 'down'], { env: AURORA_ENV, cwd: root })
} catch { /* stale project or Docker unavailable */ }
}))
}
+11
View File
@@ -0,0 +1,11 @@
import { spawn, type ChildProcess } from 'child_process'
import type { WebContents } from 'electron'
import { AURORA_ENV, powerOffProjects } from './auroraEngine'
const running = new Map<string, ChildProcess>(); const cancelledIds = new Set<string>()
const ANSI_PATTERN = /[\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
const stripAnsi=(s:string)=>s.replace(ANSI_PATTERN,'')
export function runCommandStreamed(operationId:string, command:string, args:string[], sender:WebContents, options:{cwd?:string}={}):Promise<void>{return new Promise((resolve,reject)=>{const child=spawn(command,args,{env:AURORA_ENV,cwd:options.cwd,stdio:['ignore','pipe','pipe']});running.set(operationId,child);const tail:string[]=[];const forward=(stream:'stdout'|'stderr')=>(data:Buffer)=>{const chunk=stripAnsi(data.toString());tail.push(chunk);if(tail.length>20)tail.shift();if(!sender.isDestroyed())sender.send('terminal:data',{operationId,stream,chunk})};child.stdout.on('data',forward('stdout'));child.stderr.on('data',forward('stderr'));child.on('error',e=>{running.delete(operationId);sender.send('terminal:exit',{operationId,exitCode:null,cancelled:false});reject(e)});child.on('close',code=>{running.delete(operationId);const cancelled=cancelledIds.delete(operationId);if(!sender.isDestroyed())sender.send('terminal:exit',{operationId,exitCode:code,cancelled});if(cancelled)reject(new Error('Command cancelled'));else if(code===0)resolve();else reject(new Error(tail.join('').trim()||`${command} exited with code ${code}`))})})}
export function cancelCommand(id:string):boolean{const c=running.get(id);if(!c)return false;cancelledIds.add(id);c.kill();return true}
export function startLogStream(operationId:string, command:string,args:string[],sender:WebContents,options:{cwd?:string}={}):void{const child=spawn(command,args,{env:AURORA_ENV,cwd:options.cwd,stdio:['ignore','pipe','pipe']});running.set(operationId,child);const f=(stream:'stdout'|'stderr')=>(d:Buffer)=>{if(!sender.isDestroyed())sender.send('logs:data',{operationId,stream,chunk:stripAnsi(d.toString())})};child.stdout.on('data',f('stdout'));child.stderr.on('data',f('stderr'));const done=()=>{running.delete(operationId);if(!sender.isDestroyed())sender.send('logs:exit',{operationId})};child.on('close',done);child.on('error',done)}
export function killAllRunningCommands():void{for(const c of running.values())c.kill();running.clear()}
export async function powerOffAllProjects():Promise<void>{await powerOffProjects()}
+107
View File
@@ -0,0 +1,107 @@
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'
import { registerTerminalIpc } from './ipc/terminal'
import { registerDatabaseIpc } from './ipc/database'
import { registerModulesIpc } from './ipc/modules'
import { registerLogsIpc } from './ipc/logs'
import { registerCreateIpc } from './ipc/create'
import { registerWindowIpc } from './ipc/window'
import { registerSecretsIpc } from './ipc/secrets'
import { registerWordpressIpc } from './ipc/wordpress'
import { killAllRunningCommands, powerOffAllProjects } from './commandRunner'
function createWindow(): void {
// Create the browser window.
const mainWindow = new BrowserWindow({
title: 'Aurora Dockside',
width: 1100,
height: 720,
show: false,
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
// Set app user model id for windows
electronApp.setAppUserModelId('com.aurora-dockside.app')
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
registerProjectsIpc()
registerTerminalIpc()
registerDatabaseIpc()
registerModulesIpc()
registerLogsIpc()
registerCreateIpc()
registerWindowIpc()
registerSecretsIpc()
registerWordpressIpc()
createWindow()
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.
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
// Kill any still-running Aurora processes (e.g. an open `Aurora 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
// code. You can also put them in separate files and require them here.
+214
View File
@@ -0,0 +1,214 @@
import { dialog, ipcMain } from 'electron'
import { createProject, getProjectConfigByRoot } from '../auroraEngine'
import { runCommandStreamed } from '../commandRunner'
import { execFile } from 'child_process'
import { promisify } from 'util'
import { AURORA_ENV, ensureRouter, projectUrls, setWordpressMultisite } from '../auroraEngine'
import { saveSiteCredentials } from './secrets'
import type { AuroraStackOptions } from '../../shared/types'
const execFileAsync = promisify(execFile)
const safeName = (name: string): string =>
name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'project'
async function hostUserArgs(): Promise<string[]> {
if (process.platform === 'win32') return []
try {
const [{ stdout: uid }, { stdout: gid }] = await Promise.all([
execFileAsync('id', ['-u'], { env: AURORA_ENV }),
execFileAsync('id', ['-g'], { env: AURORA_ENV })
])
return ['--user', `${uid.trim()}:${gid.trim()}`]
} catch {
return []
}
}
async function wordpressBaseArgs(directory: string, network = false): Promise<string[]> {
const userArgs = await hostUserArgs()
const config = await getProjectConfigByRoot(directory)
return [
'run', '--rm',
...userArgs,
'-e', 'HOME=/tmp',
'-e', 'WP_CLI_CACHE_DIR=/tmp/wp-cli-cache',
...(network ? ['--network', `aurora-${safeName(config.name)}_default`] : []),
'-v', `${directory}:/app`,
'-w', '/app',
'--entrypoint', 'php',
'wordpress:cli',
'-d', 'memory_limit=512M',
'/usr/local/bin/wp'
]
}
async function currentProjectUrl(directory: string): Promise<string> {
const config = await getProjectConfigByRoot(directory)
await ensureRouter()
// Aurora provisions WordPress with HTTPS as its canonical URL. WP-CLI writes
// the URL directly and does not need to make a browser-trusted TLS request.
return projectUrls(config.name).https
}
export function registerCreateIpc(): void {
ipcMain.handle('create:pickDirectory', async () => {
const r = await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] })
return r.canceled ? null : r.filePaths[0]
})
ipcMain.handle('create:configure', async (_e, _id: string, directory: string, name: string, type: string, docroot: string, stack?: Partial<AuroraStackOptions>) =>
createProject(directory, name, type, docroot, stack)
)
ipcMain.handle('create:downloadWordpress', async (e, id: string, directory: string, locale: string) => {
const args = await wordpressBaseArgs(directory)
return runCommandStreamed(
id,
'docker',
[...args, 'core', 'download', `--locale=${locale || 'en_US'}`, '--force'],
e.sender,
{ cwd: directory }
)
})
ipcMain.handle(
'create:setupWordpress',
async (
e,
id: string,
directory: string,
_siteUrl: string,
title: string,
user: string,
password: string,
email: string,
multisite: 'none' | 'subdirectory' | 'subdomain'
) => {
const base = await wordpressBaseArgs(directory, true)
const siteUrl = await currentProjectUrl(directory)
// wp-config.php is generated against Docker's internal database hostname.
await runCommandStreamed(
`${id}-config`,
'docker',
[...base, 'config', 'create', '--dbname=db', '--dbuser=db', '--dbpass=db', '--dbhost=db:3306', '--skip-check', '--force'],
e.sender,
{ cwd: directory }
)
// Always establish a known-good single-site installation first. Multisite
// is a conversion step, not an alternate bootstrap path. This gives us a
// populated database to verify before attempting network conversion.
await runCommandStreamed(
`${id}-install`,
'docker',
[
...base,
'core', 'install',
`--url=${siteUrl}`,
`--title=${title}`,
`--admin_user=${user}`,
`--admin_password=${password}`,
`--admin_email=${email}`,
'--skip-email'
],
e.sender,
{ cwd: directory }
)
// Hard gate #1: never continue from an empty/partial WordPress database.
await runCommandStreamed(
`${id}-verify-install`,
'docker',
[...base, 'core', 'is-installed'],
e.sender,
{ cwd: directory }
)
if (multisite !== 'none') {
const multisiteArgs = multisite === 'subdomain' ? ['--subdomains'] : []
await runCommandStreamed(
`${id}-multisite-convert`,
'docker',
[
...base,
'core', 'multisite-convert',
`--title=${title}`,
...multisiteArgs
],
e.sender,
{ cwd: directory }
)
// Hard gate #2: a multisite project is not ready unless WP-CLI can see
// the network installation. Any non-zero exit propagates to Dockside.
await runCommandStreamed(
`${id}-verify-network`,
'docker',
[...base, 'core', 'is-installed', '--network'],
e.sender,
{ cwd: directory }
)
// Verify the network table itself as a second independent sanity check.
await runCommandStreamed(
`${id}-verify-network-db`,
'docker',
[...base, 'db', 'query', "SHOW TABLES LIKE 'wp_blogs';", '--skip-column-names'],
e.sender,
{ cwd: directory }
)
}
await setWordpressMultisite(directory, multisite)
await ensureRouter()
// Development-friendly defaults. Failure here should fail creation so the
// user never gets a project Aurora claims is fully provisioned when it is not.
await runCommandStreamed(
`${id}-defaults`,
'docker',
[
...base,
'rewrite', 'structure', '/%postname%/', '--hard'
],
e.sender,
{ cwd: directory }
)
await runCommandStreamed(
`${id}-debug`,
'docker',
[...base, 'config', 'set', 'WP_DEBUG', 'true', '--raw'],
e.sender,
{ cwd: directory }
)
await runCommandStreamed(
`${id}-env`,
'docker',
[...base, 'config', 'set', 'WP_ENVIRONMENT_TYPE', 'local'],
e.sender,
{ cwd: directory }
)
// Store the original development login locally after every provisioning
// gate has succeeded. WordPress stores only a password hash, so Aurora
// must retain the original password if it is to display it later.
await saveSiteCredentials(directory, {
platform: 'wordpress',
adminUrl: `${siteUrl.replace(/\/$/, '')}/wp-admin/`,
username: user,
password,
email
})
}
)
ipcMain.handle('create:downloadDrupal', async (e, id: string, directory: string) =>
runCommandStreamed(id, 'docker', ['run', '--rm', '-v', `${directory}:/app`, '-w', '/app', 'composer:2', 'create-project', 'drupal/recommended-project', '.', '--no-interaction'], e.sender, { cwd: directory })
)
ipcMain.handle('create:requireDrush', async (e, id: string, directory: string) =>
runCommandStreamed(id, 'docker', ['run', '--rm', '-v', `${directory}:/app`, '-w', '/app', 'composer:2', 'require', 'drush/drush', '--no-interaction'], e.sender, { cwd: directory })
)
ipcMain.handle('create:setupDrupal', async () => {})
}
+81
View File
@@ -0,0 +1,81 @@
import { dialog, ipcMain } from 'electron'
import { createReadStream, createWriteStream } from 'fs'
import { mkdir, readdir, rm, stat } from 'fs/promises'
import { basename, join } from 'path'
import { spawn } from 'child_process'
import { createGunzip, createGzip } from 'zlib'
import { AURORA_ENV, getProjectConfigByRoot } from '../auroraEngine'
import type { AuroraSnapshot } from '../../shared/types'
const snapshotsDir = (root: string): string => join(root, '.aurora', 'snapshots')
const composeFile = (root: string): string => join(root, '.aurora', 'compose.yaml')
function safeSnapshotName(value?: string): string {
const fallback = new Date().toISOString().replace(/[:.]/g, '-')
const safe = (value?.trim() || fallback).replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
return safe || fallback
}
function dbCommand(type: 'mariadb' | 'postgres', mode: 'dump' | 'restore'): { command: string; args: string[] } {
if (type === 'postgres') {
return mode === 'dump'
? { command: 'pg_dump', args: ['-U', 'db', '-d', 'db', '--clean', '--if-exists'] }
: { command: 'psql', args: ['-U', 'db', '-d', 'db', '-v', 'ON_ERROR_STOP=1'] }
}
return mode === 'dump'
? { command: 'mariadb-dump', args: ['-udb', '-pdb', '--single-transaction', '--routines', '--triggers', 'db'] }
: { command: 'mariadb', args: ['-udb', '-pdb', 'db'] }
}
function runDatabasePipe(root: string, type: 'mariadb' | 'postgres', mode: 'dump' | 'restore', filePath: string): Promise<void> {
return new Promise((resolve, reject) => {
const db = dbCommand(type, mode)
const child = spawn('docker', ['compose', '-f', composeFile(root), 'exec', '-T', 'db', db.command, ...db.args], {
cwd: root, env: AURORA_ENV, stdio: ['pipe', 'pipe', 'pipe']
})
let stderr = ''
child.stderr.on('data', (chunk) => { stderr += chunk.toString() })
const gz = filePath.endsWith('.gz')
let outputFinished = Promise.resolve()
if (mode === 'dump') {
const output = createWriteStream(filePath)
const source = gz ? child.stdout.pipe(createGzip()) : child.stdout
source.pipe(output)
outputFinished = new Promise<void>((done, fail) => { output.on('finish', done); output.on('error', fail) })
} else {
const input = createReadStream(filePath)
const source = gz ? input.pipe(createGunzip()) : input
source.pipe(child.stdin)
input.on('error', reject)
}
child.on('error', reject)
child.on('close', async (code) => {
if (code !== 0) return reject(new Error(stderr.trim() || `Database command exited with code ${code}`))
try { await outputFinished; resolve() } catch (error) { reject(error) }
})
})
}
async function listSnapshots(root: string): Promise<AuroraSnapshot[]> {
const dir = snapshotsDir(root)
await mkdir(dir, { recursive: true })
const files = (await readdir(dir)).filter((name) => name.endsWith('.sql.gz'))
const rows = await Promise.all(files.map(async (name) => ({ Name: name.replace(/\.sql\.gz$/, ''), Created: (await stat(join(dir, name))).mtime.toISOString() })))
return rows.sort((a, b) => b.Created.localeCompare(a.Created))
}
export function registerDatabaseIpc():void {
ipcMain.handle('database:listSnapshots', (_event, _name: string, root: string) => listSnapshots(root))
ipcMain.handle('database:createSnapshot', async (_event, _operationId: string, root: string, name?: string) => {
const config = await getProjectConfigByRoot(root); await mkdir(snapshotsDir(root), { recursive: true })
await runDatabasePipe(root, config.database, 'dump', join(snapshotsDir(root), `${safeSnapshotName(name)}.sql.gz`))
})
ipcMain.handle('database:restoreSnapshot', async (_event, _operationId: string, root: string, name: string) => {
const config = await getProjectConfigByRoot(root); await runDatabasePipe(root, config.database, 'restore', join(snapshotsDir(root), `${safeSnapshotName(name)}.sql.gz`))
})
ipcMain.handle('database:deleteSnapshot', async (_event, _operationId: string, root: string, name: string) => rm(join(snapshotsDir(root), `${safeSnapshotName(name)}.sql.gz`), { force: true }))
ipcMain.handle('database:importFile', async (_event, _operationId: string, root: string, filePath: string) => { const config = await getProjectConfigByRoot(root); await runDatabasePipe(root, config.database, 'restore', filePath) })
ipcMain.handle('database:exportFile', async (_event, _operationId: string, root: string, filePath: string) => { const config = await getProjectConfigByRoot(root); await runDatabasePipe(root, config.database, 'dump', filePath) })
ipcMain.handle('database:pickImportFile', async () => { const r=await dialog.showOpenDialog({properties:['openFile'],filters:[{name:'SQL dumps',extensions:['sql','gz']}]}); return r.canceled?null:r.filePaths[0] })
ipcMain.handle('database:pickExportPath', async (_e,name:string) => { const r=await dialog.showSaveDialog({defaultPath: basename(name).endsWith('.gz') ? name : `${name}.sql.gz`, filters:[{name:'Compressed SQL dump',extensions:['gz']},{name:'SQL dump',extensions:['sql']}]}); return r.canceled?null:r.filePath })
}
+4
View File
@@ -0,0 +1,4 @@
import { ipcMain } from 'electron'
import { getProjectRoot } from '../auroraEngine'
import { startLogStream } from '../commandRunner'
export function registerLogsIpc():void{ipcMain.handle('logs:start',async(event,id:string,name:string,service:string)=>{const root=await getProjectRoot(name);startLogStream(id,'docker',['compose','-f',`${root}/.aurora/compose.yaml`,'logs','-f','--tail','200',service],event.sender,{cwd:root})})}
+43
View File
@@ -0,0 +1,43 @@
import { ipcMain } from 'electron'
import { getProjectRoot, listInstalledModules, listModules, scaffoldApplicationModule, setModule } from '../auroraEngine'
import { runCommandStreamed } from '../commandRunner'
export function registerModulesIpc(): void {
ipcMain.handle('modules:listRegistry', () => listModules())
ipcMain.handle('modules:listInstalled', (_event, name: string) => listInstalledModules(name))
ipcMain.handle(
'modules:install',
async (
event,
operationId: string,
name: string,
moduleId: string,
settings: Record<string, string | number | boolean>
) => {
await setModule(name, moduleId, true, settings)
await scaffoldApplicationModule(name, moduleId)
const root = await getProjectRoot(name)
return runCommandStreamed(
operationId,
'docker',
['compose', '-f', `${root}/.aurora/compose.yaml`, 'up', '-d', '--remove-orphans'],
event.sender,
{ cwd: root }
)
}
)
ipcMain.handle(
'modules:remove',
async (event, operationId: string, name: string, moduleId: string) => {
await setModule(name, moduleId, false)
const root = await getProjectRoot(name)
return runCommandStreamed(
operationId,
'docker',
['compose', '-f', `${root}/.aurora/compose.yaml`, 'up', '-d', '--remove-orphans'],
event.sender,
{ cwd: root }
)
}
)
}
+44
View File
@@ -0,0 +1,44 @@
import { ipcMain } from 'electron'
import { spawn } from 'child_process'
import { listProjects, describeProject, getProjectRoot, unregisterProject, updateEnvironment, ensureRouter, getProjectConfig, projectUrls, AURORA_ENV, trustAuroraCA } from '../auroraEngine'
import { runCommandStreamed } from '../commandRunner'
import { execFile } from 'child_process'
import { promisify } from 'util'
const execFileAsync = promisify(execFile)
const composeArgs=(root:string,...args:string[])=>['compose','-f',`${root}/.aurora/compose.yaml`,...args]
const allowedServices = new Set(['web','php','db','node','adminer','redis','mailpit'])
export function registerProjectsIpc():void{
ipcMain.handle('projects:list',()=>listProjects()); ipcMain.handle('projects:describe',(_e,name:string)=>describeProject(name))
ipcMain.handle('projects:start',async(e,id:string,name:string)=>{const root=await getProjectRoot(name);await updateEnvironment(root,{});await ensureRouter();return runCommandStreamed(id,'docker',composeArgs(root,'up','-d','--build','--remove-orphans'),e.sender,{cwd:root})})
ipcMain.handle('projects:stop',async(e,id:string,name:string)=>{const root=await getProjectRoot(name);return runCommandStreamed(id,'docker',composeArgs(root,'down'),e.sender,{cwd:root})})
ipcMain.handle('projects:restart',async(e,id:string,name:string)=>{const root=await getProjectRoot(name);await updateEnvironment(root,{});await ensureRouter();return runCommandStreamed(id,'docker',composeArgs(root,'up','-d','--build','--force-recreate','--remove-orphans'),e.sender,{cwd:root})})
ipcMain.handle('projects:restartService',async(e,id:string,name:string,service:string)=>{if(!allowedServices.has(service))throw new Error('Invalid service');const root=await getProjectRoot(name);return runCommandStreamed(id,'docker',composeArgs(root,'restart',service),e.sender,{cwd:root})})
ipcMain.handle('projects:phpInfo',async(e,id:string,name:string)=>{const root=await getProjectRoot(name);return runCommandStreamed(id,'docker',composeArgs(root,'exec','-T','php','php','-i'),e.sender,{cwd:root})})
ipcMain.handle('projects:openTerminal',async(_e,name:string)=>{const root=await getProjectRoot(name);const candidates: Array<[string,string[]]>=process.platform==='linux'?[['x-terminal-emulator',['--working-directory',root]],['gnome-terminal',['--working-directory',root]],['konsole',['--workdir',root]]]:[];for(const [cmd,args] of candidates){try{const child=spawn(cmd,args,{detached:true,stdio:'ignore'});child.unref();return}catch{}}throw new Error('No supported terminal application was found.')})
ipcMain.handle('projects:delete',async(e,id:string,name:string,approot:string,deleteFiles:boolean)=>{
let root=approot
try { root=await getProjectRoot(name) } catch { /* use renderer-provided root for stale entries */ }
try {
await runCommandStreamed(id,'docker',composeArgs(root,'down','-v','--remove-orphans'),e.sender,{cwd:root})
} catch (error) {
// A malformed/missing compose file must never make a project undeletable.
console.warn(`Aurora cleanup for '${name}' skipped:`, error)
}
await unregisterProject(name,deleteFiles)
})
ipcMain.handle('projects:trustCA',async()=>{ await trustAuroraCA(); await ensureRouter() })
ipcMain.handle('projects:updateEnvironment',async(_e,_id:string,_name:string,root:string,updates:any)=>{
await updateEnvironment(root,updates)
if (updates.primaryProtocol) {
const config = await getProjectConfig(root)
if (config.type === 'wordpress') {
const url = projectUrls(config.name)[updates.primaryProtocol === 'http' ? 'http' : 'https']
const network = `aurora-${config.name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'project'}_default`
// Keep WordPress' canonical home/siteurl aligned with Dockside's primary protocol.
// WP-CLI talks to MariaDB over the project network; it does not need to trust local TLS.
await execFileAsync('docker', ['run','--rm','--network',network,'-e','HOME=/tmp','-v',`${root}:/app`,'-w','/app','wordpress:cli','option','update','home',url,'--allow-root'], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
await execFileAsync('docker', ['run','--rm','--network',network,'-e','HOME=/tmp','-v',`${root}:/app`,'-w','/app','wordpress:cli','option','update','siteurl',url,'--allow-root'], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
}
}
})
}
+25
View File
@@ -0,0 +1,25 @@
import { ipcMain } from 'electron'
import { chmod, readFile, writeFile } from 'fs/promises'
import { join } from 'path'
import type { AuroraSiteCredentials } from '../../shared/types'
const secretsPath = (root: string): string => join(root, '.aurora', 'secrets.json')
export async function saveSiteCredentials(root: string, credentials: AuroraSiteCredentials): Promise<void> {
const path = secretsPath(root)
await writeFile(path, JSON.stringify({ site: credentials }, null, 2) + '\n', { mode: 0o600 })
if (process.platform !== 'win32') await chmod(path, 0o600)
}
async function readSiteCredentials(root: string): Promise<AuroraSiteCredentials | null> {
try {
const data = JSON.parse(await readFile(secretsPath(root), 'utf8')) as { site?: AuroraSiteCredentials }
return data.site ?? null
} catch {
return null
}
}
export function registerSecretsIpc(): void {
ipcMain.handle('secrets:getSiteCredentials', async (_event, root: string) => readSiteCredentials(root))
}
+6
View File
@@ -0,0 +1,6 @@
import { ipcMain } from 'electron'
import { cancelCommand } from '../commandRunner'
export function registerTerminalIpc(): void {
ipcMain.handle('terminal:cancel', (_event, operationId: string) => cancelCommand(operationId))
}
+23
View File
@@ -0,0 +1,23 @@
import { ipcMain } from 'electron'
const MIN_ZOOM = -3
const MAX_ZOOM = 5
export function registerWindowIpc(): void {
ipcMain.handle('window:zoomIn', (event) => {
const level = Math.min(MAX_ZOOM, event.sender.getZoomLevel() + 0.5)
event.sender.setZoomLevel(level)
return level
})
ipcMain.handle('window:zoomOut', (event) => {
const level = Math.max(MIN_ZOOM, event.sender.getZoomLevel() - 0.5)
event.sender.setZoomLevel(level)
return level
})
ipcMain.handle('window:zoomReset', (event) => {
event.sender.setZoomLevel(0)
return 0
})
}
+55
View File
@@ -0,0 +1,55 @@
import { ipcMain } from 'electron'
import { runCommandStreamed } from '../commandRunner'
import { getProjectConfig, getProjectRoot } from '../auroraEngine'
const safeName = (name: string): string =>
name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'project'
function wpDockerArgs(name: string, root: string, wpArgs: string[]): string[] {
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
const gid = typeof process.getgid === 'function' ? process.getgid() : undefined
const userArgs = uid !== undefined && gid !== undefined ? ['--user', `${uid}:${gid}`] : []
return [
'run', '--rm',
...userArgs,
'--network', `aurora-${safeName(name)}_default`,
'-e', 'HOME=/tmp',
'-e', 'WP_CLI_CACHE_DIR=/tmp/wp-cli-cache',
'-v', `${root}:/app`,
'-w', '/app',
'wordpress:cli',
...wpArgs
]
}
export function registerWordpressIpc(): void {
ipcMain.handle('wordpress:run', async (event, operationId: string, name: string, action: string, payload?: string) => {
const root = await getProjectRoot(name)
const config = await getProjectConfig(root)
if (config.type !== 'wordpress') throw new Error(`Project '${name}' is not a WordPress project`)
let args: string[]
switch (action) {
case 'cache-flush': args = ['cache', 'flush']; break
case 'rewrite-flush': args = ['rewrite', 'flush']; break
case 'maintenance-on': args = ['maintenance-mode', 'activate']; break
case 'maintenance-off': args = ['maintenance-mode', 'deactivate']; break
case 'debug-on': args = ['config', 'set', 'WP_DEBUG', 'true', '--raw']; break
case 'debug-off': args = ['config', 'set', 'WP_DEBUG', 'false', '--raw']; break
case 'core-version': args = ['core', 'version']; break
case 'plugin-list': args = ['plugin', 'list']; break
case 'theme-list': args = ['theme', 'list']; break
case 'search-replace': {
const [from, to] = (payload ?? '').split('\n')
if (!from || !to) throw new Error('Search and replacement values are required')
args = ['search-replace', from, to, '--all-tables-with-prefix', '--precise', '--report-changed-only']
if (config.wordpressMultisite && config.wordpressMultisite !== 'none') args.push('--network')
break
}
default: throw new Error(`Unknown WordPress action '${action}'`)
}
return runCommandStreamed(operationId, 'docker', wpDockerArgs(name, root, args), event.sender, { cwd: root })
})
}
+24
View File
@@ -0,0 +1,24 @@
import { app } from 'electron'
import { readFile, readdir } from 'fs/promises'
import { join } from 'path'
import type { AuroraModuleManifest } from '../shared/types'
let cache: AuroraModuleManifest[] | null = null
function moduleDirectory(): string {
return join(app.getAppPath(), 'resources', 'modules')
}
export async function getModuleRegistry(): Promise<AuroraModuleManifest[]> {
if (cache) return cache
const dir = moduleDirectory()
const files = (await readdir(dir)).filter((file) => file.endsWith('.json')).sort()
cache = await Promise.all(files.map(async (file) => JSON.parse(await readFile(join(dir, file), 'utf8')) as AuroraModuleManifest))
return cache
}
export async function getModuleManifest(id: string): Promise<AuroraModuleManifest> {
const module = (await getModuleRegistry()).find((item) => item.id === id)
if (!module) throw new Error(`Unknown Aurora module '${id}'`)
return module
}
+9
View File
@@ -0,0 +1,9 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import type { Api } from './index'
declare global {
interface Window {
electron: ElectronAPI
api: Api
}
}
+207
View File
@@ -0,0 +1,207 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import type {
AuroraInstalledModule,
AuroraModuleManifest,
AuroraProjectDetail,
AuroraProjectSummary,
AuroraSnapshot,
AuroraSiteCredentials,
AuroraStackOptions,
EnvironmentUpdate,
LogDataEvent,
LogExitEvent,
TerminalDataEvent,
TerminalExitEvent
} from '../shared/types'
// Custom APIs for renderer
const api = {
projects: {
list: (): Promise<AuroraProjectSummary[]> => ipcRenderer.invoke('projects:list'),
describe: (name: string): Promise<AuroraProjectDetail> =>
ipcRenderer.invoke('projects:describe', name),
start: (operationId: string, name: string): Promise<void> =>
ipcRenderer.invoke('projects:start', operationId, name),
stop: (operationId: string, name: string): Promise<void> =>
ipcRenderer.invoke('projects:stop', operationId, name),
restart: (operationId: string, name: string): Promise<void> =>
ipcRenderer.invoke('projects:restart', 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,
approot: string,
updates: EnvironmentUpdate
): Promise<void> =>
ipcRenderer.invoke('projects:updateEnvironment', operationId, name, approot, updates),
trustCA: (): Promise<void> => ipcRenderer.invoke('projects:trustCA'),
restartService: (operationId: string, name: string, service: string): Promise<void> =>
ipcRenderer.invoke('projects:restartService', operationId, name, service),
phpInfo: (operationId: string, name: string): Promise<void> =>
ipcRenderer.invoke('projects:phpInfo', operationId, name),
openTerminal: (name: string): Promise<void> => ipcRenderer.invoke('projects:openTerminal', name)
},
wordpress: {
run: (operationId: string, name: string, action: string, payload?: string): Promise<void> =>
ipcRenderer.invoke('wordpress:run', operationId, name, action, payload)
},
terminal: {
cancel: (operationId: string): Promise<boolean> =>
ipcRenderer.invoke('terminal:cancel', operationId),
onData: (callback: (event: TerminalDataEvent) => void): (() => void) => {
const listener = (_event: IpcRendererEvent, data: TerminalDataEvent): void => callback(data)
ipcRenderer.on('terminal:data', listener)
return () => ipcRenderer.removeListener('terminal:data', listener)
},
onExit: (callback: (event: TerminalExitEvent) => void): (() => void) => {
const listener = (_event: IpcRendererEvent, data: TerminalExitEvent): void => callback(data)
ipcRenderer.on('terminal:exit', listener)
return () => ipcRenderer.removeListener('terminal:exit', listener)
}
},
database: {
listSnapshots: (name: string, approot: string): Promise<AuroraSnapshot[]> =>
ipcRenderer.invoke('database:listSnapshots', name, approot),
createSnapshot: (operationId: string, approot: string, snapshotName?: string): Promise<void> =>
ipcRenderer.invoke('database:createSnapshot', operationId, approot, snapshotName),
restoreSnapshot: (operationId: string, approot: string, snapshotName: string): Promise<void> =>
ipcRenderer.invoke('database:restoreSnapshot', operationId, approot, snapshotName),
deleteSnapshot: (operationId: string, approot: string, snapshotName: string): Promise<void> =>
ipcRenderer.invoke('database:deleteSnapshot', operationId, approot, snapshotName),
importFile: (operationId: string, approot: string, filePath: string): Promise<void> =>
ipcRenderer.invoke('database:importFile', operationId, approot, filePath),
exportFile: (operationId: string, approot: string, filePath: string): Promise<void> =>
ipcRenderer.invoke('database:exportFile', operationId, approot, filePath),
pickImportFile: (): Promise<string | null> => ipcRenderer.invoke('database:pickImportFile'),
pickExportPath: (defaultFileName: string): Promise<string | null> =>
ipcRenderer.invoke('database:pickExportPath', defaultFileName)
},
modules: {
listRegistry: (): Promise<AuroraModuleManifest[]> => ipcRenderer.invoke('modules:listRegistry'),
listInstalled: (name: string): Promise<AuroraInstalledModule[]> =>
ipcRenderer.invoke('modules:listInstalled', name),
install: (
operationId: string,
name: string,
moduleId: string,
settings: Record<string, string | number | boolean>
): Promise<void> => ipcRenderer.invoke('modules:install', operationId, name, moduleId, settings),
remove: (operationId: string, name: string, moduleId: string): Promise<void> =>
ipcRenderer.invoke('modules:remove', operationId, name, moduleId)
},
logs: {
start: (operationId: string, name: string, service: string): Promise<void> =>
ipcRenderer.invoke('logs:start', operationId, name, service),
onData: (callback: (event: LogDataEvent) => void): (() => void) => {
const listener = (_event: IpcRendererEvent, data: LogDataEvent): void => callback(data)
ipcRenderer.on('logs:data', listener)
return () => ipcRenderer.removeListener('logs:data', listener)
},
onExit: (callback: (event: LogExitEvent) => void): (() => void) => {
const listener = (_event: IpcRendererEvent, data: LogExitEvent): void => callback(data)
ipcRenderer.on('logs:exit', listener)
return () => ipcRenderer.removeListener('logs:exit', listener)
}
},
create: {
pickDirectory: (): Promise<string | null> => ipcRenderer.invoke('create:pickDirectory'),
configure: (
operationId: string,
directory: string,
projectName: string,
projectType: string,
docroot: string,
stack?: Partial<AuroraStackOptions>
): Promise<void> =>
ipcRenderer.invoke(
'create:configure',
operationId,
directory,
projectName,
projectType,
docroot,
stack
),
downloadWordpress: (operationId: string, directory: string, locale: string): Promise<void> =>
ipcRenderer.invoke('create:downloadWordpress', operationId, directory, locale),
setupWordpress: (
operationId: string,
directory: string,
siteUrl: string,
title: string,
adminUser: string,
adminPassword: string,
adminEmail: string,
multisite: 'none' | 'subdirectory' | 'subdomain'
): Promise<void> =>
ipcRenderer.invoke(
'create:setupWordpress',
operationId,
directory,
siteUrl,
title,
adminUser,
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
)
},
secrets: {
getSiteCredentials: (approot: string): Promise<AuroraSiteCredentials | null> =>
ipcRenderer.invoke('secrets:getSiteCredentials', approot)
},
zoom: {
in: (): Promise<number> => ipcRenderer.invoke('window:zoomIn'),
out: (): Promise<number> => ipcRenderer.invoke('window:zoomOut'),
reset: (): Promise<number> => ipcRenderer.invoke('window:zoomReset')
}
}
export type Api = typeof api
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
}
} else {
// @ts-ignore (define in dts)
window.electron = electronAPI
// @ts-ignore (define in dts)
window.api = api
}
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Aurora Dockside</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
vi.stubGlobal('api', {
projects: {
list: vi.fn().mockResolvedValue([]),
describe: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
restart: vi.fn()
},
terminal: {
cancel: vi.fn(),
onData: vi.fn().mockReturnValue(() => {}),
onExit: vi.fn().mockReturnValue(() => {})
}
})
function renderApp(): ReturnType<typeof render> {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
)
}
describe('App', () => {
it('renders the app title', () => {
renderApp()
expect(screen.getByText('Aurora Dockside')).toBeInTheDocument()
})
it('shows an empty state when there are no Aurora projects', async () => {
renderApp()
await waitFor(() => expect(screen.getByText(/No Aurora projects found/)).toBeInTheDocument())
})
})
+133
View File
@@ -0,0 +1,133 @@
import { useCallback, useState } from 'react'
import { Anchor, FolderOpen, Plus, Settings, Sparkles, TerminalSquare } from 'lucide-react'
import { ProjectDetail } from './components/projects/ProjectDetail'
import { ProjectList } from './components/projects/ProjectList'
import { TerminalPanel } from './components/terminal/TerminalPanel'
import { StatusBar } from './components/layout/StatusBar'
import { Toaster } from './components/ui/Toaster'
import { CreateProjectModal } from './components/create/CreateProjectModal'
import { SettingsModal } from './components/settings/SettingsModal'
import { useAppStore } from './stores/appStore'
import { useTerminalEvents } from './hooks/useTerminalEvents'
import { useAppliedTheme } from './hooks/useAppliedTheme'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import docksideIcon from './assets/dockside-icon.png'
function App(): React.JSX.Element {
const selectedProjectName = useAppStore((s) => s.selectedProjectName)
const [isCreateOpen, setIsCreateOpen] = useState(false)
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
useTerminalEvents()
useAppliedTheme()
useKeyboardShortcuts({
onNewProject: useCallback(() => setIsCreateOpen(true), []),
onOpenSettings: useCallback(() => setIsSettingsOpen(true), [])
})
return (
<div className="flex h-screen w-screen flex-col bg-[linear-gradient(135deg,rgba(8,145,178,0.12)_0%,transparent_36%),linear-gradient(180deg,#f8fafc_0%,#eef6f5_52%,#e7eef5_100%)] text-neutral-900 dark:bg-[linear-gradient(135deg,rgba(45,212,191,0.10)_0%,transparent_36%),linear-gradient(180deg,#070a0f_0%,#0f172a_54%,#092f34_100%)] dark:text-neutral-100">
<div className="flex flex-1 overflow-hidden">
<aside className="flex w-80 flex-shrink-0 flex-col border-r border-white/70 bg-white/[0.78] shadow-[8px_0_30px_rgba(15,23,42,0.06)] backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.72] dark:shadow-black/25">
<div className="flex items-center justify-between border-b border-neutral-200/70 px-4 py-3 dark:border-white/10">
<div className="flex min-w-0 items-center gap-3">
<img
src={docksideIcon}
alt=""
className="size-10 rounded-xl shadow-sm shadow-cyan-900/20"
/>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold tracking-wide">Aurora Dockside</h1>
<p className="truncate text-xs text-neutral-500 dark:text-neutral-400">
Aurora modular dev platform
</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setIsCreateOpen(true)}
title="New Project"
className="rounded-md p-1.5 text-neutral-500 transition hover:bg-cyan-50 hover:text-cyan-700 dark:hover:bg-cyan-400/10 dark:hover:text-cyan-300"
>
<Plus size={16} />
</button>
<button
type="button"
onClick={() => setIsSettingsOpen(true)}
title="Settings"
className="rounded-md p-1.5 text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/10 dark:hover:text-neutral-100"
>
<Settings size={16} />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<ProjectList />
</div>
</aside>
<main className="flex-1 overflow-y-auto">
{selectedProjectName ? (
<ProjectDetail name={selectedProjectName} />
) : (
<div className="flex h-full items-center justify-center p-8">
<div className="relative grid w-full max-w-3xl overflow-hidden rounded-2xl border border-white/70 bg-white/[0.84] shadow-[0_24px_80px_rgba(15,23,42,0.12)] backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.70] dark:shadow-black/30">
<div className="absolute inset-0 bg-[linear-gradient(rgba(8,145,178,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(8,145,178,0.08)_1px,transparent_1px)] bg-[size:34px_34px] dark:bg-[linear-gradient(rgba(45,212,191,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(45,212,191,0.08)_1px,transparent_1px)]" />
<div className="relative grid gap-7 p-8">
<div className="flex items-start justify-between gap-6">
<div>
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-cyan-200 bg-cyan-50 px-3 py-1 text-xs font-medium text-cyan-800 dark:border-cyan-400/20 dark:bg-cyan-400/10 dark:text-cyan-200">
<Sparkles size={13} />
Local environments, neatly handled
</div>
<h2 className="max-w-xl text-3xl font-semibold leading-tight text-neutral-950 dark:text-white">
Build local development stacks your way.
</h2>
<p className="mt-3 max-w-xl text-sm leading-6 text-neutral-600 dark:text-neutral-300">
Create Docker-powered PHP and Node.js projects, then add applications, services, and tools as modules.
</p>
</div>
<img
src={docksideIcon}
alt=""
className="hidden size-24 flex-shrink-0 rounded-3xl shadow-lg shadow-cyan-900/20 sm:block"
/>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-neutral-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/[0.05]">
<FolderOpen size={18} className="mb-3 text-cyan-700 dark:text-cyan-300" />
<p className="text-sm font-semibold">Project overview</p>
<p className="mt-1 text-xs leading-5 text-neutral-500 dark:text-neutral-400">
Status, stack details, paths, and services.
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/[0.05]">
<TerminalSquare size={18} className="mb-3 text-cyan-700 dark:text-cyan-300" />
<p className="text-sm font-semibold">Live operations</p>
<p className="mt-1 text-xs leading-5 text-neutral-500 dark:text-neutral-400">
Start, stop, restart, and inspect logs.
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/[0.05]">
<Anchor size={18} className="mb-3 text-cyan-700 dark:text-cyan-300" />
<p className="text-sm font-semibold">Database control</p>
<p className="mt-1 text-xs leading-5 text-neutral-500 dark:text-neutral-400">
Snapshots, imports, exports, and add-ons.
</p>
</div>
</div>
</div>
</div>
</div>
)}
</main>
</div>
<TerminalPanel />
<StatusBar />
<Toaster />
{isCreateOpen && <CreateProjectModal onClose={() => setIsCreateOpen(false)} />}
{isSettingsOpen && <SettingsModal onClose={() => setIsSettingsOpen(false)} />}
</div>
)
}
export default App
Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

+61
View File
@@ -0,0 +1,61 @@
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
body {
margin: 0;
user-select: none;
background: #f8fafc;
font-feature-settings:
'liga' 1,
'calt' 1;
}
code {
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
}
button,
a,
input {
-webkit-tap-highlight-color: transparent;
}
button:not(:disabled),
a {
cursor: pointer;
}
button:focus-visible,
a:focus-visible,
input:focus-visible {
outline: 2px solid #06b6d4;
outline-offset: 2px;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(100, 116, 139, 0.35);
border: 3px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(100, 116, 139, 0.55);
}
@@ -0,0 +1,366 @@
import { useRef, useState } from 'react'
import { clsx } from 'clsx'
import {
ArrowLeft,
ArrowRight,
Boxes,
Check,
FileCode2,
FolderOpen,
Globe2,
Layers3,
Package,
Sparkles,
X
} from 'lucide-react'
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'
const fieldClass =
'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'
const TYPE_ICONS: Record<string, typeof Globe2> = {
'': Sparkles,
php: FileCode2,
wordpress: Globe2,
drupal: Layers3,
laravel: FileCode2,
backdrop: Layers3,
craftcms: Package,
magento2: Package,
shopware6: Package,
symfony: Boxes,
typo3: Layers3
}
export function CreateProjectModal({ onClose }: { onClose: () => void }): React.JSX.Element {
const [step, setStep] = useState<Step>('site')
const [directory, setDirectory] = useState<string | null>(null)
const [projectName, setProjectName] = useState('')
const [projectType, setProjectType] = useState('')
const [docroot, setDocroot] = useState('')
const [setupValid, setSetupValid] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
const [phpVersion, setPhpVersion] = useState('8.4')
const [nodeVersion, setNodeVersion] = useState('24')
const [database, setDatabase] = useState<'mariadb' | 'postgres'>('mariadb')
const [databaseVersion, setDatabaseVersion] = useState('11.8')
const [adminer, setAdminer] = useState(true)
const [redis, setRedis] = useState(false)
const [mailpit, setMailpit] = useState(false)
const [xdebug, setXdebug] = useState(false)
const createProject = useCreateProject()
const selectProject = useAppStore((s) => s.selectProject)
const setupRef = useRef<TypeSetupHandle>(null)
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> {
const picked = await window.api.create.pickDirectory()
if (!picked) return
setDirectory(picked)
if (!projectName) {
const name = picked.split('/').filter(Boolean).pop() ?? ''
setProjectName(slugifyProjectName(name))
}
}
async function handleSubmit(): Promise<void> {
if (!directory || !canSubmit) return
const name = projectName.trim()
setIsSubmitting(true)
try {
await createProject.mutateAsync({ directory, projectName: name, projectType, docroot, stack: { phpVersion, nodeVersion, database, databaseVersion, adminer, redis, mailpit, xdebug } })
} catch {
setIsSubmitting(false)
return
}
// Post-create (starting the project, downloading/installing WordPress,
// etc.) can run long. Close the modal as soon as the quick `Aurora
// 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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-neutral-950/55 p-3 backdrop-blur-sm sm:p-4">
<div
className="grid w-full max-w-4xl overflow-hidden rounded-2xl border border-white/70 bg-white shadow-[0_30px_100px_rgba(15,23,42,0.28)] dark:border-white/10 dark:bg-neutral-950 md:grid-cols-[0.82fr_1.18fr]"
style={{ height: 'min(760px, calc(100vh - 24px))', maxHeight: 'calc(100vh - 24px)' }}
>
<aside className="relative hidden overflow-hidden bg-neutral-950 p-6 text-white md:block">
<div className="absolute inset-0 bg-[linear-gradient(rgba(45,212,191,0.11)_1px,transparent_1px),linear-gradient(90deg,rgba(45,212,191,0.11)_1px,transparent_1px)] bg-[size:34px_34px]" />
<div className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-cyan-500/20 to-transparent" />
<div className="relative flex h-full flex-col justify-between">
<div>
<img
src={docksideIcon}
alt=""
className="mb-5 size-16 rounded-2xl shadow-lg shadow-cyan-950/30"
/>
<p className="text-xs font-semibold uppercase tracking-wide text-cyan-200">
Project launch
</p>
<h2 className="mt-3 text-3xl font-semibold leading-tight">
Create a local site that feels ready to work.
</h2>
</div>
<div className="grid gap-3">
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4">
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
Destination
</p>
<p className="mt-1 truncate text-sm font-semibold">
{directory ? directory.split('/').filter(Boolean).pop() : 'Choose a folder'}
</p>
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4">
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
Project
</p>
<p className="mt-1 truncate text-sm font-semibold">
{projectName.trim() || 'Name pending'}
</p>
</div>
</div>
</div>
</aside>
<div className="min-h-0 min-w-0 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]">
<div className="sticky top-0 z-20 flex items-center justify-between border-b border-neutral-200/80 bg-white/95 px-5 py-4 backdrop-blur dark:border-white/10 dark:bg-neutral-950/95">
<div>
<h2 className="text-base font-semibold">
{step === 'site' ? 'New Project' : `Set up ${getTypeLabel(projectType)}`}
</h2>
<div className="mt-2 flex items-center gap-2">
{(['site', 'setup'] as const).map((item, index) => (
<div
key={item}
className={clsx(
'flex items-center gap-2 rounded-full border px-2.5 py-1 text-xs font-medium',
step === item
? 'border-cyan-200 bg-cyan-50 text-cyan-800 dark:border-cyan-400/25 dark:bg-cyan-400/10 dark:text-cyan-200'
: 'border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/10 dark:bg-white/[0.04] dark:text-neutral-400'
)}
>
<span className="grid size-4 place-items-center rounded-full bg-current text-[10px]">
<span className="text-white dark:text-neutral-950">{index + 1}</span>
</span>
{item === 'site' ? 'Project' : 'Setup'}
</div>
))}
</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-lg p-1.5 text-neutral-400 transition hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-white/10 dark:hover:text-neutral-200"
>
<X size={16} />
</button>
</div>
<div className="p-5">
<div className="flex flex-col gap-5">
{step === 'site' ? (
<>
<div>
<label className={labelClass}>Project folder</label>
<button
type="button"
onClick={handlePickDirectory}
className={clsx(
'group flex w-full items-center gap-3 rounded-xl border border-dashed px-3 py-3 text-left text-sm transition',
directory
? 'border-cyan-200 bg-cyan-50/60 text-neutral-900 dark:border-cyan-400/25 dark:bg-cyan-400/10 dark:text-neutral-100'
: 'border-neutral-300 bg-neutral-50/70 text-neutral-500 hover:border-cyan-200 hover:bg-cyan-50/50 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/25 dark:hover:bg-cyan-400/10'
)}
>
<span className="grid size-9 flex-shrink-0 place-items-center rounded-lg bg-white text-cyan-700 shadow-sm dark:bg-neutral-950/70 dark:text-cyan-300">
<FolderOpen size={17} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{directory ?? 'Choose a folder…'}
</span>
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
This becomes the project root.
</span>
</span>
{directory && <Check size={16} className="text-cyan-700 dark:text-cyan-300" />}
</button>
</div>
<div>
<label className={labelClass}>Project name</label>
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
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>
<label className={labelClass}>Project type</label>
<div className="grid gap-2 sm:grid-cols-2">
{PROJECT_TYPES.map((t) => {
const Icon = TYPE_ICONS[t.value] ?? Boxes
const isSelected = projectType === t.value
return (
<button
key={t.value}
type="button"
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')
if (t.value === 'wordpress' && database === 'postgres') { setDatabase('mariadb'); setDatabaseVersion('11.8') }
}}
className={clsx(
'flex min-h-16 items-center gap-3 rounded-xl border px-3 py-3 text-left transition',
isSelected
? 'border-cyan-300 bg-cyan-50 text-cyan-950 shadow-sm shadow-cyan-900/5 dark:border-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-100'
: 'border-neutral-200 bg-white/70 hover:border-cyan-200 hover:bg-cyan-50/50 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/25 dark:hover:bg-cyan-400/10'
)}
>
<span
className={clsx(
'grid size-9 flex-shrink-0 place-items-center rounded-lg',
isSelected
? 'bg-cyan-600 text-white dark:bg-cyan-300 dark:text-neutral-950'
: 'bg-neutral-100 text-neutral-500 dark:bg-neutral-950/70 dark:text-neutral-400'
)}
>
<Icon size={17} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">{t.label}</span>
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
{t.value ? 'Use Aurora type preset' : 'Let Aurora inspect it'}
</span>
</span>
</button>
)
})}
</div>
</div>
<div className="rounded-xl border border-neutral-200 bg-neutral-50/70 p-4 dark:border-white/10 dark:bg-white/[0.04]">
<div className="mb-3">
<p className="text-sm font-semibold">Development stack</p>
<p className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">Aurora core owns the runtime; the application is a module layered on top.</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div><label className={labelClass}>PHP</label><select className={fieldClass} value={phpVersion} onChange={(e)=>setPhpVersion(e.target.value)}>{['8.2','8.3','8.4','8.5'].map(v=><option key={v}>{v}</option>)}</select></div>
<div><label className={labelClass}>Node.js</label><select className={fieldClass} value={nodeVersion} onChange={(e)=>setNodeVersion(e.target.value)}>{['20','22','24'].map(v=><option key={v}>{v}</option>)}</select></div>
<div><label className={labelClass}>Database</label><select className={fieldClass} value={`${database}:${databaseVersion}`} onChange={(e)=>{const [kind,version]=e.target.value.split(':');setDatabase(kind as 'mariadb'|'postgres');setDatabaseVersion(version)}}><option value="mariadb:11.8">MariaDB 11.8</option><option value="mariadb:10.11">MariaDB 10.11</option>{projectType !== 'wordpress' && <><option value="postgres:17">PostgreSQL 17</option><option value="postgres:16">PostgreSQL 16</option></>}</select></div>
<div className="grid grid-cols-2 gap-2 pt-5">
{[['Adminer',adminer,setAdminer],['Redis',redis,setRedis],['Mailpit',mailpit,setMailpit],['Xdebug',xdebug,setXdebug]].map(([label,value,setter])=><label key={label as string} className="flex items-center gap-2 text-xs font-medium"><input type="checkbox" checked={value as boolean} onChange={(e)=>(setter as (v:boolean)=>void)(e.target.checked)} />{label as string}</label>)}
</div>
</div>
</div>
<div>
<label className={labelClass}>Docroot (optional)</label>
<input
type="text"
value={docroot}
onChange={(e) => setDocroot(e.target.value)}
placeholder="e.g. web, public — leave blank for project root"
className={fieldClass}
/>
</div>
</>
) : projectType === 'wordpress' ? (
<WordpressSetup
ref={setupRef}
projectName={projectName.trim()}
onValidityChange={setSetupValid}
/>
) : projectType === 'drupal' ? (
<DrupalSetup
ref={setupRef}
projectName={projectName.trim()}
onValidityChange={setSetupValid}
/>
) : (
<GenericSetup
ref={setupRef}
projectName={projectName.trim()}
onValidityChange={setSetupValid}
/>
)}
</div>
</div>
<div className="sticky bottom-0 z-20 flex items-center justify-between gap-3 border-t border-neutral-200/80 bg-neutral-50/95 p-4 backdrop-blur dark:border-white/10 dark:bg-neutral-950/95">
<button
type="button"
onClick={step === 'site' ? onClose : () => setStep('site')}
className="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium text-neutral-500 transition hover:bg-white hover:text-neutral-900 dark:hover:bg-white/10 dark:hover:text-neutral-100"
>
{step === 'site' ? null : <ArrowLeft size={14} />}
{step === 'site' ? 'Cancel' : 'Go back'}
</button>
{step === 'site' ? (
<button
type="button"
disabled={!canContinue}
onClick={() => setStep('setup')}
className="inline-flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-cyan-900/20 transition hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-40"
>
Continue
<ArrowRight size={14} />
</button>
) : (
<button
type="button"
disabled={!canSubmit}
onClick={handleSubmit}
className="inline-flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-cyan-900/20 transition hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-40"
>
{isSubmitting ? 'Creating…' : 'Add Site'}
</button>
)}
</div>
</div>
</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 @@
// Aurora 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/useAurora'
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,63 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import { PlayCircle } from 'lucide-react'
import { useStartProject } from '../../../hooks/useAurora'
import type { TypeSetupContext, TypeSetupHandle, TypeSetupProps } from './shared'
// Fallback setup panel for any project type without a dedicated one yet
// (see registry.ts). Just scaffolds via `Aurora config` and optionally starts
// the project — no type-specific installer.
export const GenericSetup = forwardRef<TypeSetupHandle, TypeSetupProps>(function GenericSetup(
{ onValidityChange },
ref
) {
const [startAfterCreate, setStartAfterCreate] = useState(true)
const startProject = useStartProject()
useEffect(() => {
onValidityChange(true)
}, [onValidityChange])
useImperativeHandle(ref, () => ({
runPostCreate: async ({ projectName }: TypeSetupContext) => {
if (startAfterCreate) {
await startProject.mutateAsync(projectName)
}
}
}))
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">
<PlayCircle size={18} />
</div>
<div>
<p className="text-sm font-semibold text-cyan-950 dark:text-cyan-100">
Ready after Aurora config
</p>
<p className="mt-1 text-xs leading-5 text-cyan-800/80 dark:text-cyan-100/75">
This type uses Aurora defaults, then you can finish the app-specific install in the
project.
</p>
</div>
</div>
</div>
<label className="flex items-center justify-between gap-3 rounded-xl border border-neutral-200 bg-white/70 p-4 text-sm dark:border-white/10 dark:bg-white/[0.04]">
<span>
<span className="block font-semibold">Start after creating</span>
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
Launch the environment as soon as config is written.
</span>
</span>
<input
type="checkbox"
checked={startAfterCreate}
onChange={(e) => setStartAfterCreate(e.target.checked)}
className="size-4 accent-cyan-600"
/>
</label>
</div>
)
})
@@ -0,0 +1,200 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import { ChevronDown, ChevronUp, Globe2, KeyRound, Mail, Type, UserRound } from 'lucide-react'
import { useDownloadWordpress, useSetupWordpress } from '../../../hooks/useCreateProject'
import { useStartProject } from '../../../hooks/useAurora'
import type { TypeSetupContext, TypeSetupHandle, TypeSetupProps } from './shared'
const LANGUAGES = [
{ value: 'en_US', label: 'English (United States)' },
{ value: 'en_GB', label: 'English (UK)' },
{ value: 'de_DE', label: 'German' },
{ value: 'es_ES', label: 'Spanish (Spain)' },
{ value: 'fr_FR', label: 'French (France)' },
{ value: 'it_IT', label: 'Italian' },
{ value: 'pt_BR', label: 'Portuguese (Brazil)' },
{ value: 'nl_NL', label: 'Dutch' },
{ value: 'ja', label: 'Japanese' }
]
type Multisite = 'none' | 'subdirectory' | 'subdomain'
const MULTISITE_OPTIONS: { value: Multisite; label: string }[] = [
{ value: 'none', label: 'No' },
{ value: 'subdirectory', label: 'Yes Subdirectory' },
{ value: 'subdomain', label: 'Yes Subdomain' }
]
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 WordpressSetup = forwardRef<TypeSetupHandle, TypeSetupProps>(function WordpressSetup(
{ projectName, onValidityChange },
ref
) {
const [siteTitle, setSiteTitle] = useState('')
const [adminUser, setAdminUser] = useState('admin')
const [adminPassword, setAdminPassword] = useState('')
const [adminEmail, setAdminEmail] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false)
const [language, setLanguage] = useState('en_US')
const [multisite, setMultisite] = useState<Multisite>('none')
const startProject = useStartProject()
const downloadWordpress = useDownloadWordpress()
const setupWordpress = useSetupWordpress()
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) => {
// wp-cli needs the containers running, so this ignores any "start
// after creating" preference — an unstarted WordPress project would
// just be the same half-built state this whole flow exists to avoid.
await startProject.mutateAsync(name)
await downloadWordpress.mutateAsync({ directory, locale: language })
await setupWordpress.mutateAsync({
directory,
siteUrl: `https://${name}.Aurora.site`,
title: siteTitle.trim() || name,
adminUser: adminUser.trim(),
adminPassword: adminPassword.trim(),
adminEmail: adminEmail.trim(),
multisite
})
}
}))
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">
<Globe2 size={18} />
</div>
<div>
<p className="text-sm font-semibold text-cyan-950 dark:text-cyan-100">
WordPress install
</p>
<p className="mt-1 text-xs leading-5 text-cyan-800/80 dark:text-cyan-100/75">
Core downloads automatically 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 title</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={siteTitle}
onChange={(e) => setSiteTitle(e.target.value)}
placeholder={projectName || 'My WordPress 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}>Select language</label>
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className={inputClass}
>
{LANGUAGES.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Is this a WordPress Multisite?</label>
<select
value={multisite}
onChange={(e) => setMultisite(e.target.value as Multisite)}
className={inputClass}
>
{MULTISITE_OPTIONS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
</div>
)}
</div>
)
})
@@ -0,0 +1,10 @@
export const PROJECT_TYPES = [
{ value: '', label: 'Blank PHP + Node' },
{ value: 'wordpress', label: 'WordPress' },
{ value: 'laravel', label: 'Laravel' },
{ value: 'drupal', label: 'Drupal' }
]
export function getTypeLabel(projectType: string): string {
return PROJECT_TYPES.find((type) => type.value === projectType)?.label ?? 'project'
}
@@ -0,0 +1,22 @@
import type { ForwardRefExoticComponent, RefAttributes } from 'react'
export interface TypeSetupContext {
directory: string
projectName: string
}
// Each project type's setup panel exposes this so the wizard can trigger
// whatever post-`Aurora config` work that type needs (downloading app core,
// running an installer, seeding a database, ...) without knowing the details.
export interface TypeSetupHandle {
runPostCreate: (ctx: TypeSetupContext) => Promise<void>
}
export interface TypeSetupProps {
projectName: string
onValidityChange: (valid: boolean) => void
}
export type TypeSetupComponent = ForwardRefExoticComponent<
TypeSetupProps & RefAttributes<TypeSetupHandle>
>
@@ -0,0 +1,40 @@
import { Loader2, X } from 'lucide-react'
import { useStatusStore } from '../../stores/statusStore'
import { useTerminalStore } from '../../stores/terminalStore'
export function StatusBar(): React.JSX.Element {
const operationId = useStatusStore((s) => s.operationId)
const label = useStatusStore((s) => s.label)
const setActiveOperation = useTerminalStore((s) => s.setActiveOperation)
const setPanelOpen = useTerminalStore((s) => s.setPanelOpen)
return (
<footer className="flex h-8 flex-shrink-0 items-center justify-between border-t border-white/70 bg-white/75 px-3 text-xs text-neutral-500 backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/75 dark:text-neutral-400">
{operationId && label ? (
<>
<button
type="button"
onClick={() => {
setActiveOperation(operationId)
setPanelOpen(true)
}}
className="flex items-center gap-1.5 transition hover:text-neutral-900 dark:hover:text-neutral-100"
>
<Loader2 size={12} className="animate-spin" />
{label}
</button>
<button
type="button"
onClick={() => window.api.terminal.cancel(operationId)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-400/10"
>
<X size={12} />
Cancel
</button>
</>
) : (
<span>Ready</span>
)}
</footer>
)
}
@@ -0,0 +1,122 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { clsx } from 'clsx'
import { useLogStream } from '../../hooks/useLogStream'
function LogPane({
name,
service,
filter
}: {
name: string
service: string
filter: string
}): React.JSX.Element {
const { lines, isStreaming } = useLogStream(name, service)
const scrollRef = useRef<HTMLDivElement>(null)
const filteredLines = useMemo(() => {
if (!filter.trim()) return lines
const q = filter.toLowerCase()
return lines.filter((line) => line.text.toLowerCase().includes(q))
}, [lines, filter])
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
}, [filteredLines.length])
return (
<>
<span
className={clsx(
'flex items-center gap-1 text-xs',
isStreaming ? 'text-emerald-600 dark:text-emerald-400' : 'text-neutral-400'
)}
>
<span
className={clsx(
'h-1.5 w-1.5 rounded-full',
isStreaming ? 'bg-emerald-500' : 'bg-neutral-400'
)}
/>
{isStreaming ? 'streaming' : 'stopped'}
</span>
<div
ref={scrollRef}
className="flex-1 overflow-y-auto bg-neutral-950 px-4 py-3 font-mono text-xs text-neutral-200"
>
{filteredLines.length === 0 ? (
<p className="text-neutral-500">
{lines.length === 0 ? 'Waiting for log output…' : 'No lines match the filter.'}
</p>
) : (
<div>
{filteredLines.map((line, i) => (
<div
key={i}
className={clsx('whitespace-pre-wrap', line.stream === 'stderr' && 'text-red-400')}
>
{line.text}
</div>
))}
</div>
)}
</div>
</>
)
}
export function LogViewer({
name,
services,
onClose
}: {
name: string
services: string[]
onClose: () => void
}): React.JSX.Element {
const [service, setService] = useState(services[0] ?? 'web')
const [filter, setFilter] = useState('')
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-8">
<div className="flex h-full w-full max-w-3xl flex-col rounded-xl bg-white shadow-2xl dark:bg-neutral-900">
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
<div className="flex items-center gap-3">
<h2 className="text-sm font-semibold">Logs {name}</h2>
</div>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
>
<X size={16} />
</button>
</div>
<div className="flex items-center gap-2 border-b border-neutral-200 p-3 dark:border-neutral-800">
<select
value={service}
onChange={(e) => setService(e.target.value)}
className="rounded-md border border-neutral-300 px-2 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
>
{services.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter logs…"
className="flex-1 rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
/>
</div>
<LogPane key={service} name={name} service={service} filter={filter} />
</div>
</div>
)
}
@@ -0,0 +1,71 @@
import { useMemo, useState } from 'react'
import { Boxes, ChevronLeft, X } from 'lucide-react'
import type { AuroraModuleManifest } from '@shared/types'
import { useInstalledModules, useInstallModule, useModuleRegistry } from '../../hooks/useModules'
const CATEGORY_LABELS = { application: 'Applications', service: 'Services', tool: 'Developer Tools' } as const
function ModuleSettings({ module, values, onChange }: { module: AuroraModuleManifest; values: Record<string, string | number | boolean>; onChange: (values: Record<string, string | number | boolean>) => void }): React.JSX.Element {
return <div className="grid gap-4">
{module.settings.map((setting) => <label key={setting.id} className="grid gap-1.5 text-sm">
<span className="font-medium">{setting.label}</span>
{setting.type === 'boolean' ? (
<input type="checkbox" checked={Boolean(values[setting.id])} onChange={(e) => onChange({ ...values, [setting.id]: e.target.checked })} className="size-4" />
) : setting.type === 'select' ? (
<select value={String(values[setting.id] ?? '')} onChange={(e) => onChange({ ...values, [setting.id]: e.target.value })} className="rounded-md border border-neutral-300 bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-950">
{setting.options?.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
) : (
<input value={String(values[setting.id] ?? '')} onChange={(e) => onChange({ ...values, [setting.id]: setting.type === 'number' ? Number(e.target.value) : e.target.value })} className="rounded-md border border-neutral-300 bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-950" />
)}
</label>)}
</div>
}
export function ModuleBrowserModal({ name, onClose }: { name: string; onClose: () => void }): React.JSX.Element {
const [query, setQuery] = useState('')
const [selected, setSelected] = useState<AuroraModuleManifest | null>(null)
const [settings, setSettings] = useState<Record<string, string | number | boolean>>({})
const { data: registry = [], isLoading } = useModuleRegistry()
const { data: installed = [] } = useInstalledModules(name)
const install = useInstallModule(name)
const installedIds = useMemo(() => new Set(installed.map((item) => item.id)), [installed])
const filtered = useMemo(() => registry.filter((item) => !query.trim() || `${item.name} ${item.description}`.toLowerCase().includes(query.toLowerCase())), [registry, query])
function choose(module: AuroraModuleManifest): void {
setSelected(module)
setSettings(Object.fromEntries(module.settings.map((setting) => [setting.id, setting.default])))
}
async function installSelected(): Promise<void> {
if (!selected) return
await install.mutateAsync({ moduleId: selected.id, settings })
setSelected(null)
}
return <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-8 backdrop-blur-sm">
<div className="flex h-full w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-white/10 bg-white shadow-2xl dark:bg-neutral-900">
<header className="flex items-center justify-between border-b border-neutral-200 px-5 py-4 dark:border-neutral-800">
<div className="flex items-center gap-3">
{selected && <button onClick={() => setSelected(null)} className="rounded-md p-1 hover:bg-neutral-100 dark:hover:bg-neutral-800"><ChevronLeft size={18}/></button>}
<div><h2 className="font-semibold">{selected ? selected.name : 'Aurora Module Library'}</h2><p className="text-xs text-neutral-500">{selected ? selected.description : 'Extend this project without rebuilding Dockside.'}</p></div>
</div>
<button onClick={onClose} className="rounded-md p-1 hover:bg-neutral-100 dark:hover:bg-neutral-800"><X size={18}/></button>
</header>
{selected ? <div className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-xl rounded-xl border border-neutral-200 p-5 dark:border-neutral-800">
<div className="mb-5 flex items-center gap-3"><span className="grid size-11 place-items-center rounded-xl bg-cyan-50 text-cyan-700 dark:bg-cyan-400/10 dark:text-cyan-300"><Boxes size={20}/></span><div><div className="font-semibold">{selected.name}</div><div className="text-xs uppercase tracking-wide text-neutral-500">{selected.category} · v{selected.version}</div></div></div>
{selected.settings.length ? <ModuleSettings module={selected} values={settings} onChange={setSettings}/> : <p className="text-sm text-neutral-500">This module has no configuration options.</p>}
{selected.conflicts.length > 0 && <p className="mt-4 rounded-lg bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-400/10 dark:text-amber-200">Application conflict protection: {selected.conflicts.join(', ')}</p>}
<button disabled={install.isPending || installedIds.has(selected.id)} onClick={() => void installSelected()} className="mt-6 w-full rounded-lg bg-cyan-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50">{installedIds.has(selected.id) ? 'Already installed' : install.isPending ? 'Installing…' : `Install ${selected.name}`}</button>
</div>
</div> : <>
<div className="border-b border-neutral-200 p-4 dark:border-neutral-800"><input autoFocus value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search modules…" className="w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-950"/></div>
<div className="flex-1 overflow-y-auto p-5">{isLoading ? <p className="text-sm text-neutral-500">Loading module registry</p> : (['application','service','tool'] as const).map((category) => {
const items = filtered.filter((item) => item.category === category); if (!items.length) return null
return <section key={category} className="mb-6"><h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-neutral-500">{CATEGORY_LABELS[category]}</h3><div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">{items.map((module) => <button key={module.id} onClick={() => choose(module)} className="rounded-xl border border-neutral-200 p-4 text-left transition hover:border-cyan-300 hover:bg-cyan-50/50 dark:border-neutral-800 dark:hover:border-cyan-400/30 dark:hover:bg-cyan-400/5"><div className="flex items-center justify-between"><span className="font-semibold">{module.name}</span><span className="text-[10px] uppercase text-neutral-400">{installedIds.has(module.id) ? 'Installed' : `v${module.version}`}</span></div><p className="mt-2 text-xs leading-relaxed text-neutral-500">{module.description}</p></button>)}</div></section>
})}</div>
</>}
</div>
</div>
}
@@ -0,0 +1,178 @@
import { useState } from 'react'
import { Camera, Download, RotateCcw, Trash2, Upload } from 'lucide-react'
import {
useCreateSnapshot,
useDeleteSnapshot,
useExportDatabase,
useImportDatabase,
useRestoreSnapshot,
useSnapshots
} from '../../hooks/useDatabase'
function formatDate(iso: string): string {
return new Date(iso).toLocaleString()
}
export function DatabaseSection({
name,
approot
}: {
name: string
approot: string
}): React.JSX.Element {
const { data: snapshots, isLoading } = useSnapshots(name, approot)
const createSnapshot = useCreateSnapshot(name, approot)
const restoreSnapshot = useRestoreSnapshot(name, approot)
const deleteSnapshot = useDeleteSnapshot(name, approot)
const importDatabase = useImportDatabase(name, approot)
const exportDatabase = useExportDatabase(name, approot)
// Electron doesn't support window.prompt() (it silently returns null with
// no dialog), so snapshot naming needs an inline input instead.
const [isNaming, setIsNaming] = useState(false)
const [snapshotNameDraft, setSnapshotNameDraft] = useState('')
const isBusy =
createSnapshot.isPending ||
restoreSnapshot.isPending ||
deleteSnapshot.isPending ||
importDatabase.isPending ||
exportDatabase.isPending
function submitSnapshotName(): void {
const trimmed = snapshotNameDraft.trim()
createSnapshot.mutate(trimmed || undefined)
setIsNaming(false)
setSnapshotNameDraft('')
}
return (
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
Database
</h3>
<div className="flex flex-wrap items-center gap-2">
{isNaming ? (
<>
<input
autoFocus
type="text"
placeholder="Snapshot name (optional)"
value={snapshotNameDraft}
onChange={(e) => setSnapshotNameDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submitSnapshotName()
if (e.key === 'Escape') setIsNaming(false)
}}
className="rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs dark:border-white/10 dark:bg-neutral-950"
/>
<button
type="button"
onClick={submitSnapshotName}
className="rounded-md bg-cyan-600 px-2.5 py-1 text-xs font-medium text-white transition hover:bg-cyan-500"
>
Create
</button>
<button
type="button"
onClick={() => setIsNaming(false)}
className="rounded-md px-2.5 py-1 text-xs font-medium text-neutral-500 transition hover:bg-neutral-100 dark:hover:bg-white/10"
>
Cancel
</button>
</>
) : (
<>
<button
type="button"
disabled={isBusy}
onClick={() => importDatabase.mutate()}
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 bg-white/70 px-2.5 py-1 text-xs font-medium transition hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:bg-white/[0.05] dark:hover:bg-white/10"
>
<Upload size={12} /> Import
</button>
<button
type="button"
disabled={isBusy}
onClick={() => exportDatabase.mutate()}
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 bg-white/70 px-2.5 py-1 text-xs font-medium transition hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:bg-white/[0.05] dark:hover:bg-white/10"
>
<Download size={12} /> Export
</button>
<button
type="button"
disabled={isBusy}
onClick={() => setIsNaming(true)}
className="inline-flex items-center gap-1.5 rounded-md bg-cyan-600 px-2.5 py-1 text-xs font-medium text-white shadow-sm shadow-cyan-900/[0.15] transition hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-40"
>
<Camera size={12} /> Snapshot
</button>
</>
)}
</div>
</div>
{isLoading ? (
<p className="rounded-lg border border-dashed border-neutral-300 bg-neutral-50/70 px-3 py-4 text-sm text-neutral-500 dark:border-white/10 dark:bg-white/[0.03] dark:text-neutral-400">
Loading snapshots
</p>
) : !snapshots || snapshots.length === 0 ? (
<p className="rounded-lg border border-dashed border-neutral-300 bg-neutral-50/70 px-3 py-4 text-sm text-neutral-500 dark:border-white/10 dark:bg-white/[0.03] dark:text-neutral-400">
No snapshots yet.
</p>
) : (
<div className="overflow-hidden rounded-lg border border-neutral-200/80 bg-white dark:border-white/10 dark:bg-neutral-950/70">
<table className="w-full text-sm">
<thead className="bg-neutral-50 text-left text-xs uppercase text-neutral-500 dark:bg-white/[0.04] dark:text-neutral-400">
<tr>
<th className="px-3 py-2 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Created</th>
<th className="px-3 py-2 font-medium" />
</tr>
</thead>
<tbody>
{snapshots.map((snapshot) => (
<tr
key={snapshot.Name}
className="border-t border-neutral-200/80 transition hover:bg-cyan-50/40 dark:border-white/10 dark:hover:bg-cyan-400/5"
>
<td className="px-3 py-2 font-medium">{snapshot.Name}</td>
<td className="px-3 py-2 text-neutral-500 dark:text-neutral-400">
{formatDate(snapshot.Created)}
</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-2">
<button
type="button"
disabled={isBusy}
onClick={() => restoreSnapshot.mutate(snapshot.Name)}
title="Restore"
className="rounded p-1 text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-white/10 dark:hover:text-neutral-100"
>
<RotateCcw size={14} />
</button>
<button
type="button"
disabled={isBusy}
onClick={() => {
if (window.confirm(`Delete snapshot "${snapshot.Name}"?`)) {
deleteSnapshot.mutate(snapshot.Name)
}
}}
title="Delete"
className="rounded p-1 text-red-500 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-red-400/10"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}
@@ -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 &quot;{projectName}&quot;?
</h2>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
Aurora will take a database snapshot first, then remove the project&apos;s containers
and Aurora 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>
)
}
@@ -0,0 +1,22 @@
import { Database, ExternalLink, Info, RotateCw, TerminalSquare, Wrench } from 'lucide-react'
import type { AuroraProjectDetail } from '@shared/types'
import { useTerminalStore } from '../../stores/terminalStore'
import { useStatusStore } from '../../stores/statusStore'
export function DeveloperServices({ project }: { project: AuroraProjectDetail }): React.JSX.Element {
const button = 'inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-2 text-xs font-semibold text-neutral-700 shadow-sm transition hover:border-cyan-300 hover:text-cyan-700 dark:border-white/10 dark:bg-white/[0.05] dark:text-neutral-200 dark:hover:border-cyan-400/40 dark:hover:text-cyan-300'
async function streamed(label: string, fn: (id: string) => Promise<void>): Promise<void> {
const id=crypto.randomUUID(); useTerminalStore.getState().startOperation(id,label); useStatusStore.getState().begin(id,label); await fn(id)
}
return <section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
<div className="mb-4"><h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400"><Wrench size={14} className="text-cyan-600 dark:text-cyan-300"/> Project developer services</h3><p className="mt-1 text-xs text-neutral-500">Database administration, PHP diagnostics, terminal access and service controls.</p></div>
<div className="flex flex-wrap gap-2">
{project.adminer_url && <a className={button} href={project.adminer_url} target="_blank" rel="noreferrer"><Database size={13}/> Adminer <ExternalLink size={11}/></a>}
<button className={button} onClick={() => void streamed(`PHP info · ${project.name}`,(id)=>window.api.projects.phpInfo(id,project.name))}><Info size={13}/> PHP info</button>
<button className={button} onClick={() => void window.api.projects.openTerminal(project.name)}><TerminalSquare size={13}/> Project terminal</button>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
{Object.values(project.services).map(service => <div key={service.short_name} className="flex items-center justify-between rounded-lg border border-neutral-200/80 bg-white/70 px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]"><div className="min-w-0"><div className="truncate text-xs font-semibold">{service.short_name}</div><div className="truncate text-[10px] text-neutral-500">{service.status}</div></div><button title={`Restart ${service.short_name}`} className="rounded p-1.5 hover:bg-black/5 dark:hover:bg-white/10" onClick={() => void streamed(`Restart ${service.short_name} · ${project.name}`,(id)=>window.api.projects.restartService(id,project.name,service.short_name))}><RotateCw size={13}/></button></div>)}
</div>
</section>
}
@@ -0,0 +1,15 @@
import { useState } from 'react'
import { Boxes, Trash2 } from 'lucide-react'
import { useInstalledModules, useRemoveModule } from '../../hooks/useModules'
import { ModuleBrowserModal } from '../modules/ModuleBrowserModal'
export function ModulesSection({ name }: { name: string }): React.JSX.Element {
const { data: installed = [], isLoading } = useInstalledModules(name)
const remove = useRemoveModule(name)
const [open, setOpen] = useState(false)
return <section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
<div className="mb-3 flex items-center justify-between"><div><h3 className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Modules</h3><p className="mt-1 text-xs text-neutral-400">Applications, services and developer tools.</p></div><button onClick={() => setOpen(true)} className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 bg-white/70 px-2.5 py-1 text-xs font-medium dark:border-white/10 dark:bg-white/[0.05]"><Boxes size={12}/> Module Library</button></div>
{isLoading ? <p className="text-sm text-neutral-500">Loading modules</p> : installed.length === 0 ? <p className="rounded-lg border border-dashed border-neutral-300 p-4 text-sm text-neutral-500 dark:border-white/10">No modules installed. The base PHP + Node environment is ready.</p> : <div className="grid gap-2 sm:grid-cols-2">{installed.map((module) => <div key={module.id} className="flex items-center justify-between rounded-lg border border-neutral-200 p-3 dark:border-white/10"><div><div className="text-sm font-semibold">{module.name}</div><div className="text-xs capitalize text-neutral-500">{module.category} · v{module.version}</div></div><button disabled={remove.isPending} onClick={() => { if (window.confirm(`Remove module "${module.name}"?`)) remove.mutate(module.id) }} className="rounded p-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-400/10"><Trash2 size={14}/></button></div>)}</div>}
{open && <ModuleBrowserModal name={name} onClose={() => setOpen(false)}/>}
</section>
}
@@ -0,0 +1,498 @@
import { useEffect, useState } from 'react'
import { clsx } from 'clsx'
import {
Boxes,
Code2,
Database,
ExternalLink,
FileText,
Gauge,
KeyRound,
Copy,
Eye,
EyeOff,
Play,
RotateCw,
Server,
Square,
Trash2,
Zap,
Globe2,
LockKeyhole,
Radio
} from 'lucide-react'
import type { AuroraSiteCredentials, EnvironmentUpdate } from '@shared/types'
import {
useDeleteProject,
useProjectDetail,
useRestartProject,
useStartProject,
useStopProject,
useUpdateEnvironment
} from '../../hooks/useAurora'
import { StatusBadge } from './StatusBadge'
import { DatabaseSection } from './DatabaseSection'
import { ModulesSection } from './ModulesSection'
import { WordpressTools } from './WordpressTools'
import { DeveloperServices } from './DeveloperServices'
import { DeleteProjectModal } from './DeleteProjectModal'
import { LogViewer } from '../logs/LogViewer'
import { useAppStore } from '../../stores/appStore'
const NODE_VERSIONS = ['20', '22', '24']
const PHP_VERSIONS = [
'5.6',
'7.0',
'7.1',
'7.2',
'7.3',
'7.4',
'8.0',
'8.1',
'8.2',
'8.3',
'8.4',
'8.5'
]
const WEBSERVER_TYPES = [
{ value: 'nginx-fpm', label: 'nginx' },
{ value: 'apache-fpm', label: 'Apache' },
{ value: 'generic', label: 'Generic' }
]
const DATABASE_OPTIONS = [
{ value: 'mariadb:11.8', label: 'MariaDB 11.8' },
{ value: 'mariadb:10.11', label: 'MariaDB 10.11' },
{ value: 'mariadb:10.6', label: 'MariaDB 10.6' },
{ value: 'mysql:8.4', label: 'MySQL 8.4' },
{ value: 'mysql:8.0', label: 'MySQL 8.0' },
{ value: 'mysql:5.7', label: 'MySQL 5.7' },
{ value: 'postgres:17', label: 'PostgreSQL 17' },
{ value: 'postgres:16', label: 'PostgreSQL 16' },
{ value: 'postgres:15', label: 'PostgreSQL 15' }
]
const heroFieldClass =
'w-full rounded-md border border-white/10 bg-white/5 px-1.5 py-1 text-sm font-semibold text-white transition hover:border-cyan-300/40 hover:bg-white/10 focus:border-cyan-300/60 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50'
export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
const { data: project, isLoading, isError, error } = useProjectDetail(name)
const startProject = useStartProject()
const stopProject = useStopProject()
const restartProject = useRestartProject()
const deleteProject = useDeleteProject()
const updateEnvironment = useUpdateEnvironment()
const selectProject = useAppStore((s) => s.selectProject)
const [isLogsOpen, setIsLogsOpen] = useState(false)
const [isDeleteOpen, setIsDeleteOpen] = useState(false)
const [siteCredentials, setSiteCredentials] = useState<AuroraSiteCredentials | null>(null)
const [showSitePassword, setShowSitePassword] = useState(false)
const [isTrustingCA, setIsTrustingCA] = useState(false)
useEffect(() => {
let active = true
if (!project || project.type !== 'wordpress') {
setSiteCredentials(null)
return () => { active = false }
}
void window.api.secrets.getSiteCredentials(project.approot).then((credentials) => {
if (active) setSiteCredentials(credentials)
})
return () => { active = false }
}, [project?.approot, project?.type])
function copyCredential(value: string): void {
void navigator.clipboard.writeText(value)
}
const isBusy =
startProject.isPending ||
stopProject.isPending ||
restartProject.isPending ||
deleteProject.isPending
const isEnvUpdating = updateEnvironment.isPending || restartProject.isPending
function handleDeleteConfirm(deleteFiles: boolean): void {
if (!project) return
setIsDeleteOpen(false)
deleteProject.mutate(
{ name: project.name, approot: project.approot, deleteFiles },
{ onSuccess: () => selectProject(null) }
)
}
async function applyEnvironmentChange(updates: EnvironmentUpdate): Promise<void> {
if (!project) return
await updateEnvironment.mutateAsync({ name: project.name, approot: project.approot, updates })
if (project.status === 'running') {
await restartProject.mutateAsync(project.name)
}
}
function handleDatabaseChange(value: string): void {
const confirmed = window.confirm(
'Changing the database type restarts the project and may require Aurora to migrate or ' +
'recreate the database. Consider taking a snapshot first if this project has data you ' +
'want to keep. Continue?'
)
if (!confirmed) return
void applyEnvironmentChange({ database: value })
}
if (isLoading) {
return <div className="p-6 text-sm text-neutral-500 dark:text-neutral-400">Loading {name}</div>
}
if (isError || !project) {
return (
<div className="p-6 text-sm text-red-600 dark:text-red-400">
{error instanceof Error ? error.message : `Failed to load ${name}.`}
</div>
)
}
const isRunning = project.status === 'running'
const services = Object.values(project.services)
const runningServices = services.filter((service) => service.status === 'running').length
const phpVersions =
project.php_version && !PHP_VERSIONS.includes(project.php_version)
? [project.php_version, ...PHP_VERSIONS]
: PHP_VERSIONS
const currentDatabase = `${project.dbinfo.database_type}:${project.dbinfo.database_version}`
const allowedDatabaseOptions = project.type === 'wordpress' ? DATABASE_OPTIONS.filter((o) => o.value.startsWith('mariadb:')) : DATABASE_OPTIONS
const databaseOptions = allowedDatabaseOptions.some((o) => o.value === currentDatabase)
? DATABASE_OPTIONS
: [
{
value: currentDatabase,
label: `${project.dbinfo.database_type} ${project.dbinfo.database_version}`
},
...allowedDatabaseOptions
]
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5 p-6">
<header className="overflow-hidden rounded-2xl border border-white/70 bg-neutral-950 text-white shadow-[0_24px_70px_rgba(15,23,42,0.18)] dark:border-white/10">
<div className="relative p-5">
<div className="absolute inset-0 bg-[linear-gradient(rgba(45,212,191,0.09)_1px,transparent_1px),linear-gradient(90deg,rgba(45,212,191,0.09)_1px,transparent_1px)] bg-[size:36px_36px]" />
<div className="absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-cyan-500/10 to-transparent" />
<div className="relative flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs font-medium text-cyan-100">
<Gauge size={13} />
{runningServices} of {services.length} services running
</div>
<div className="flex items-center gap-2">
<h2 className="truncate text-3xl font-semibold">{project.name}</h2>
<StatusBadge status={project.status} />
</div>
<p className="mt-2 max-w-3xl truncate text-sm text-neutral-300">{project.approot}</p>
</div>
<div className="flex flex-wrap justify-end gap-2">
<button
type="button"
disabled={isRunning || isBusy}
onClick={() => startProject.mutate(project.name)}
className="inline-flex items-center gap-1.5 rounded-md bg-emerald-500 px-3 py-1.5 text-sm font-semibold text-white shadow-sm shadow-emerald-950/20 transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-40"
>
<Play size={14} /> Start
</button>
<button
type="button"
disabled={!isRunning || isBusy}
onClick={() => stopProject.mutate(project.name)}
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/10 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-40"
>
<Square size={14} /> Stop
</button>
<button
type="button"
disabled={!isRunning || isBusy}
onClick={() => restartProject.mutate(project.name)}
className="inline-flex items-center gap-1.5 rounded-md bg-cyan-400 px-3 py-1.5 text-sm font-semibold text-neutral-950 shadow-sm shadow-cyan-950/20 transition hover:bg-cyan-300 disabled:cursor-not-allowed disabled:opacity-40"
>
<RotateCw size={14} /> Restart
</button>
<button
type="button"
disabled={!isRunning}
onClick={() => setIsLogsOpen(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/10 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-40"
>
<FileText size={14} /> Logs
</button>
{project.type === 'wordpress' && isRunning && (
<a
href={`${project.primary_url}/wp-admin/`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/10 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-white/[0.15]"
>
<KeyRound size={14} /> WP Admin
</a>
)}
{project.type === 'wordpress' && isRunning && project.wordpress_network_admin_url && (
<a href={project.wordpress_network_admin_url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/10 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-white/[0.15]">
<Globe2 size={14} /> Network Admin
</a>
)}
<button
type="button"
disabled={isBusy}
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
</button>
</div>
</div>
<div className="relative mt-6 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
<Boxes size={17} className="mb-3 text-cyan-200" />
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
Project type
</p>
<p className="mt-1 truncate text-sm font-semibold text-white">{project.type}</p>{project.type === 'wordpress' && <p className="mt-1 text-[11px] text-neutral-400">{project.wordpress_multisite === 'subdomain' ? 'Multisite · Subdomain' : project.wordpress_multisite === 'subdirectory' ? 'Multisite · Subdirectory' : 'Single site'}</p>}
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
<Code2 size={17} className="mb-3 text-cyan-200" />
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-neutral-400">
PHP
</p>
<select
value={project.php_version ?? ''}
disabled={isEnvUpdating}
onChange={(e) => void applyEnvironmentChange({ phpVersion: e.target.value })}
className={heroFieldClass}
>
{phpVersions.map((v) => (
<option key={v} value={v} className="text-neutral-900">
{v}
</option>
))}
</select>
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
<Code2 size={17} className="mb-3 text-cyan-200" />
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-neutral-400">Node.js</p>
<select value={project.nodejs_version ?? '24'} disabled={isEnvUpdating} onChange={(e) => void applyEnvironmentChange({ nodeVersion: e.target.value })} className={heroFieldClass}>
{NODE_VERSIONS.map((v) => <option key={v} value={v} className="text-neutral-900">{v}</option>)}
</select>
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
<Server size={17} className="mb-3 text-cyan-200" />
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-neutral-400">
Web server
</p>
<select
value={project.webserver_type ?? 'nginx-fpm'}
disabled={isEnvUpdating}
onChange={(e) => void applyEnvironmentChange({ webserverType: e.target.value })}
className={heroFieldClass}
>
{WEBSERVER_TYPES.map((w) => (
<option key={w.value} value={w.value} className="text-neutral-900">
{w.label}
</option>
))}
</select>
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
<div className="mb-3 flex items-center justify-between">
<Zap size={17} className="text-cyan-200" />
<button
type="button"
role="switch"
aria-checked={project.xdebug_enabled}
aria-label="Toggle Xdebug"
disabled={isEnvUpdating}
onClick={() =>
void applyEnvironmentChange({ xdebugEnabled: !project.xdebug_enabled })
}
className={clsx(
'relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition disabled:cursor-not-allowed disabled:opacity-50',
project.xdebug_enabled ? 'bg-cyan-400' : 'bg-white/15'
)}
>
<span
className={clsx(
'inline-block size-3.5 transform rounded-full bg-white shadow transition',
project.xdebug_enabled ? 'translate-x-4' : 'translate-x-1'
)}
/>
</button>
</div>
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">Xdebug</p>
<p className="mt-1 truncate text-sm font-semibold text-white">
{project.xdebug_enabled ? 'Enabled' : 'Off'}
</p>
</div>
</div>
</div>
</header>
<div className="grid gap-5 xl:grid-cols-[1.05fr_0.95fr]">
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400"><Globe2 size={14} className="text-cyan-600 dark:text-cyan-300" /> Site URLs</h3>
<span className={clsx(
'inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-[11px] font-semibold',
project.router_status === 'running'
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
: project.router_status === 'provider-error'
? 'bg-amber-500/10 text-amber-700 dark:text-amber-300'
: 'bg-neutral-500/10 text-neutral-500'
)}>
<Radio size={11} /> Router {project.router_status === 'running' ? 'online' : project.router_status === 'provider-error' ? 'Docker provider error' : 'offline'}
</span>
</div>
<div className="grid gap-2">
{[{label:'HTTP',url:project.httpurl,icon:<Globe2 size={14}/>},{label:'HTTPS',url:project.httpsurl,icon:<LockKeyhole size={14}/>}].map((item) => (
<div key={item.label} className="flex items-center gap-2 rounded-lg border border-neutral-200/70 bg-neutral-50/70 p-2 dark:border-white/10 dark:bg-white/[0.04]">
<span className="flex w-16 items-center gap-1.5 text-xs font-semibold text-neutral-500">{item.icon}{item.label}</span>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{item.url}</span>
<a href={item.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded-md bg-cyan-600 px-2.5 py-1.5 text-xs font-semibold text-white hover:bg-cyan-500">Open <ExternalLink size={11}/></a>
</div>
))}
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 border-t border-neutral-200/70 pt-3 dark:border-white/10">
<div>
<p className="text-xs font-semibold text-neutral-700 dark:text-neutral-200">Primary protocol</p>
<p className="text-[11px] text-neutral-500">Both remain available. WordPress canonical URLs follow this setting.</p>
</div>
<div className="inline-flex rounded-lg border border-neutral-200 bg-neutral-100 p-1 dark:border-white/10 dark:bg-white/5">
{(['http','https'] as const).map((protocol) => { const active = project.primary_url.startsWith(`${protocol}:`); return <button key={protocol} type="button" disabled={isEnvUpdating || !isRunning} onClick={() => void applyEnvironmentChange({ primaryProtocol: protocol })} className={clsx('rounded-md px-3 py-1.5 text-xs font-semibold uppercase transition disabled:cursor-not-allowed disabled:opacity-50', active ? 'bg-white text-cyan-700 shadow-sm dark:bg-neutral-800 dark:text-cyan-300' : 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white')}>{protocol}</button> })}
</div>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-neutral-200/70 bg-neutral-50/70 p-3 dark:border-white/10 dark:bg-white/[0.04]">
<div className="text-[11px]">
<p className="font-semibold text-neutral-700 dark:text-neutral-200">Aurora HTTPS certificate</p>
<p className="mt-0.5 text-neutral-500">Certificate: {project.certificate_status === 'generated' ? 'Generated' : 'Missing'} · System: {project.ca_trust_status === 'trusted' ? 'Trusted' : project.ca_trust_status === 'not-trusted' ? 'Not trusted' : 'Unknown'} · Firefox: {project.firefox_trust_status === 'trusted' ? 'Trusted' : project.firefox_trust_status === 'not-trusted' ? 'Not trusted' : project.firefox_trust_status === 'unavailable' ? 'Needs NSS tools' : 'Unknown'} · Chromium: {project.chromium_trust_status === 'trusted' ? 'Trusted' : project.chromium_trust_status === 'not-trusted' ? 'Not trusted' : project.chromium_trust_status === 'unavailable' ? 'Needs NSS tools' : 'Not detected'}</p>
</div>
{(project.ca_trust_status !== 'trusted' || project.firefox_trust_status === 'not-trusted' || project.firefox_trust_status === 'unavailable' || project.chromium_trust_status === 'not-trusted' || project.chromium_trust_status === 'unavailable') && (
<button type="button" disabled={isTrustingCA} onClick={async () => { try { setIsTrustingCA(true); await window.api.projects.trustCA(); window.location.reload() } finally { setIsTrustingCA(false) } }} className="rounded-md bg-cyan-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-cyan-500 disabled:opacity-50">
{isTrustingCA ? 'Installing…' : 'Install / Repair Browser Trust'}
</button>
)}
</div>
</section>
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
<h3 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
<Database size={14} className="text-cyan-600 dark:text-cyan-300" />
Database credentials
</h3>
<dl className="grid grid-cols-[minmax(110px,0.45fr)_1fr] items-center gap-x-4 gap-y-2 text-sm">
<dt className="text-neutral-500 dark:text-neutral-400">Type</dt>
<dd className="font-medium">
<select
value={currentDatabase}
disabled={isEnvUpdating}
onChange={(e) => handleDatabaseChange(e.target.value)}
className="w-full max-w-[220px] rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm transition hover:border-cyan-300 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-neutral-950"
>
{databaseOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</dd>
<dt className="text-neutral-500 dark:text-neutral-400">Database</dt>
<dd className="font-medium">{project.dbinfo.dbname}</dd>
<dt className="text-neutral-500 dark:text-neutral-400">Username</dt>
<dd className="font-medium">{project.dbinfo.username}</dd>
<dt className="text-neutral-500 dark:text-neutral-400">Password</dt>
<dd className="font-mono text-xs">{project.dbinfo.password}</dd>
<dt className="text-neutral-500 dark:text-neutral-400">Port</dt>
<dd className="font-medium">{project.dbinfo.published_port}</dd>
</dl>
</section>
</div>
{project.type === 'wordpress' && siteCredentials && (
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
<KeyRound size={14} className="text-cyan-600 dark:text-cyan-300" />
Site credentials
</h3>
<a href={siteCredentials.adminUrl} target="_blank" rel="noreferrer" className="flex items-center gap-1.5 rounded-md bg-cyan-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-cyan-500">
Open WP Admin <ExternalLink size={12} />
</a>
</div>
<dl className="grid grid-cols-[minmax(110px,0.3fr)_1fr] items-center gap-x-4 gap-y-2 text-sm">
<dt className="text-neutral-500 dark:text-neutral-400">Admin URL</dt>
<dd className="flex min-w-0 items-center gap-2"><span className="truncate font-mono text-xs">{siteCredentials.adminUrl}</span><button title="Copy admin URL" onClick={() => copyCredential(siteCredentials.adminUrl)} className="rounded p-1 hover:bg-black/5 dark:hover:bg-white/10"><Copy size={13} /></button></dd>
<dt className="text-neutral-500 dark:text-neutral-400">Username</dt>
<dd className="flex items-center gap-2"><span className="font-medium">{siteCredentials.username}</span><button title="Copy username" onClick={() => copyCredential(siteCredentials.username)} className="rounded p-1 hover:bg-black/5 dark:hover:bg-white/10"><Copy size={13} /></button></dd>
<dt className="text-neutral-500 dark:text-neutral-400">Password</dt>
<dd className="flex items-center gap-2"><span className="min-w-[150px] font-mono text-xs">{showSitePassword ? siteCredentials.password : '••••••••••••'}</span><button title={showSitePassword ? 'Hide password' : 'Show password'} onClick={() => setShowSitePassword((v) => !v)} className="rounded p-1 hover:bg-black/5 dark:hover:bg-white/10">{showSitePassword ? <EyeOff size={14} /> : <Eye size={14} />}</button><button title="Copy password" onClick={() => copyCredential(siteCredentials.password)} className="rounded p-1 hover:bg-black/5 dark:hover:bg-white/10"><Copy size={13} /></button></dd>
<dt className="text-neutral-500 dark:text-neutral-400">Email</dt>
<dd className="flex items-center gap-2"><span className="truncate font-medium">{siteCredentials.email}</span><button title="Copy email" onClick={() => copyCredential(siteCredentials.email)} className="rounded p-1 hover:bg-black/5 dark:hover:bg-white/10"><Copy size={13} /></button></dd>
</dl>
</section>
)}
{project.type === 'wordpress' && isRunning && <WordpressTools project={project} />}
{isRunning && <DeveloperServices project={project} />}
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
Services
</h3>
<div className="grid gap-3 md:grid-cols-2">
{services.map((service) => (
<div
key={service.short_name}
className="rounded-xl border border-neutral-200/80 bg-white/80 p-4 transition hover:border-cyan-200 hover:shadow-sm dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/30"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{service.short_name}</p>
<p className="mt-1 truncate text-xs text-neutral-500 dark:text-neutral-400">
{service.full_name}
</p>
</div>
<StatusBadge status={service.status} />
</div>
<p className="mt-4 truncate rounded-lg bg-neutral-100 px-3 py-2 font-mono text-xs text-neutral-600 dark:bg-neutral-950/70 dark:text-neutral-300">
{service.image}
</p>
</div>
))}
</div>
</section>
<div className="grid gap-5 xl:grid-cols-2">
<DatabaseSection name={project.name} approot={project.approot} />
<ModulesSection name={project.name} />
</div>
{isLogsOpen && (
<LogViewer
name={project.name}
services={Object.keys(project.services)}
onClose={() => setIsLogsOpen(false)}
/>
)}
{isDeleteOpen && (
<DeleteProjectModal
projectName={project.name}
onCancel={() => setIsDeleteOpen(false)}
onConfirm={handleDeleteConfirm}
/>
)}
</div>
)
}
@@ -0,0 +1,78 @@
import { clsx } from 'clsx'
import { FolderKanban, Loader2 } from 'lucide-react'
import { useProjects } from '../../hooks/useAurora'
import { useAppStore } from '../../stores/appStore'
import { StatusBadge } from './StatusBadge'
export function ProjectList(): React.JSX.Element {
const { data: projects, isLoading, isError, error } = useProjects()
const selectedProjectName = useAppStore((s) => s.selectedProjectName)
const selectProject = useAppStore((s) => s.selectProject)
if (isLoading) {
return (
<div className="flex items-center gap-2 p-4 text-sm text-neutral-500 dark:text-neutral-400">
<Loader2 size={15} className="animate-spin text-cyan-600 dark:text-cyan-300" />
Loading projects
</div>
)
}
if (isError) {
return (
<div className="p-4 text-sm text-red-600 dark:text-red-400">
{error instanceof Error ? error.message : 'Failed to load Aurora projects.'}
</div>
)
}
if (!projects || projects.length === 0) {
return (
<div className="m-3 rounded-lg border border-dashed border-neutral-300 bg-white/60 p-4 text-sm text-neutral-500 dark:border-neutral-700 dark:bg-white/[0.03] dark:text-neutral-400">
<div className="mb-3 grid size-10 place-items-center rounded-lg bg-cyan-50 text-cyan-700 dark:bg-cyan-400/10 dark:text-cyan-300">
<FolderKanban size={18} />
</div>
<p className="font-medium text-neutral-800 dark:text-neutral-200">No Aurora projects yet</p>
<p className="mt-1 leading-5">
Create a project with the + button.{' '}
</p>
</div>
)
}
return (
<ul className="flex flex-col gap-1.5 p-2.5">
{projects.map((project) => (
<li key={project.name}>
<button
type="button"
onClick={() => selectProject(project.name)}
className={clsx(
'group relative flex w-full flex-col gap-1 overflow-hidden rounded-lg border px-3 py-2.5 text-left transition',
selectedProjectName === project.name
? 'border-cyan-200 bg-cyan-50/90 shadow-sm shadow-cyan-900/5 dark:border-cyan-400/25 dark:bg-cyan-400/10'
: 'border-transparent hover:border-neutral-200 hover:bg-white/70 hover:shadow-sm dark:hover:border-white/10 dark:hover:bg-white/[0.04]'
)}
>
<span
className={clsx(
'absolute inset-y-2 left-0 w-1 rounded-r-full transition-opacity',
selectedProjectName === project.name
? 'bg-cyan-500 opacity-100'
: 'bg-neutral-300 opacity-0 group-hover:opacity-100 dark:bg-neutral-600'
)}
/>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-semibold">{project.name}</span>
<StatusBadge status={project.status} />
</div>
<span className="truncate text-xs text-neutral-500 dark:text-neutral-400">
{project.type} · {project.shortroot}
</span>
</button>
</li>
))}
</ul>
)
}
@@ -0,0 +1,36 @@
import { clsx } from 'clsx'
const STATUS_STYLES: Record<string, string> = {
running:
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-400/25 dark:bg-emerald-400/10 dark:text-emerald-300',
stopped:
'border-neutral-200 bg-neutral-100 text-neutral-600 dark:border-white/10 dark:bg-white/[0.08] dark:text-neutral-400',
paused:
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-300'
}
const DEFAULT_STYLE =
'border-neutral-200 bg-neutral-100 text-neutral-600 dark:border-white/10 dark:bg-white/[0.08] dark:text-neutral-400'
export function StatusBadge({ status }: { status: string }): React.JSX.Element {
return (
<span
className={clsx(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium capitalize',
STATUS_STYLES[status] ?? DEFAULT_STYLE
)}
>
<span
className={clsx(
'size-1.5 rounded-full',
status === 'running'
? 'bg-emerald-500'
: status === 'paused'
? 'bg-amber-500'
: 'bg-neutral-400'
)}
/>
{status}
</span>
)
}
@@ -0,0 +1,56 @@
import { useState } from 'react'
import { Bug, Hammer, List, RefreshCw, Search, ShieldAlert, TerminalSquare, Wrench } from 'lucide-react'
import type { AuroraProjectDetail } from '@shared/types'
import { useTerminalStore } from '../../stores/terminalStore'
import { useStatusStore } from '../../stores/statusStore'
export function WordpressTools({ project }: { project: AuroraProjectDetail }): React.JSX.Element {
const [busy, setBusy] = useState<string | null>(null)
const [debug, setDebug] = useState<boolean | null>(null)
const [maintenance, setMaintenance] = useState(false)
async function run(action: string, label: string, payload?: string): Promise<void> {
const id = crypto.randomUUID()
useTerminalStore.getState().startOperation(id, label)
useStatusStore.getState().begin(id, label)
setBusy(action)
try { await window.api.wordpress.run(id, project.name, action, payload) }
finally { setBusy(null) }
}
function searchReplace(): void {
const from = window.prompt('Search for (old URL or text):')
if (!from) return
const to = window.prompt('Replace with:')
if (!to || from === to) return
if (!window.confirm(`Replace all occurrences of:\n\n${from}\n\nwith:\n\n${to}\n\nA database snapshot is recommended first. Continue?`)) return
void run('search-replace', `WP search-replace · ${project.name}`, `${from}\n${to}`)
}
const button = 'inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-2 text-xs font-semibold text-neutral-700 shadow-sm transition hover:border-cyan-300 hover:text-cyan-700 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:bg-white/[0.05] dark:text-neutral-200 dark:hover:border-cyan-400/40 dark:hover:text-cyan-300'
return (
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400"><Wrench size={14} className="text-cyan-600 dark:text-cyan-300" /> WordPress developer tools</h3>
<p className="mt-1 text-xs text-neutral-500">WP-CLI commands run inside the project network and stream output to Dockside's terminal.</p>
</div>
<div className="text-right text-[11px] text-neutral-500">
<div>PHP {project.php_version ?? ''} · Node {project.nodejs_version ?? ''}</div>
<div>{project.wordpress_multisite === 'subdomain' ? 'Multisite · Subdomain' : project.wordpress_multisite === 'subdirectory' ? 'Multisite · Subdirectory' : 'Single site'}</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button className={button} disabled={!!busy} onClick={() => void run('cache-flush', `WP cache flush · ${project.name}`)}><RefreshCw size={13}/> Flush cache</button>
<button className={button} disabled={!!busy} onClick={() => void run('rewrite-flush', `WP rewrite flush · ${project.name}`)}><Hammer size={13}/> Flush rewrites</button>
<button className={button} disabled={!!busy} onClick={() => { const next=!maintenance; setMaintenance(next); void run(next?'maintenance-on':'maintenance-off', `${next?'Enable':'Disable'} maintenance · ${project.name}`) }}><ShieldAlert size={13}/> {maintenance ? 'Disable' : 'Enable'} maintenance</button>
<button className={button} disabled={!!busy} onClick={() => { const next=debug === null ? true : !debug; setDebug(next); void run(next?'debug-on':'debug-off', `${next?'Enable':'Disable'} WP_DEBUG · ${project.name}`) }}><Bug size={13}/> {debug ? 'Disable' : 'Enable'} WP_DEBUG</button>
<button className={button} disabled={!!busy} onClick={searchReplace}><Search size={13}/> Search & replace</button>
<button className={button} disabled={!!busy} onClick={() => void run('plugin-list', `WP plugins · ${project.name}`)}><List size={13}/> Plugins</button>
<button className={button} disabled={!!busy} onClick={() => void run('theme-list', `WP themes · ${project.name}`)}><List size={13}/> Themes</button>
<button className={button} disabled={!!busy} onClick={() => void run('core-version', `WP-CLI · ${project.name}`)}><TerminalSquare size={13}/> WP version</button>
</div>
</section>
)
}
@@ -0,0 +1,109 @@
import { Minus, Plus, RotateCcw, X } from 'lucide-react'
import { clsx } from 'clsx'
import { useThemeStore, type Theme } from '../../stores/themeStore'
const THEMES: { value: Theme; label: string }[] = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'System' }
]
const SHORTCUTS = [
{ keys: 'Cmd/Ctrl + N', action: 'New project' },
{ keys: 'Cmd/Ctrl + ,', action: 'Open settings' },
{ keys: 'Cmd/Ctrl + =', action: 'Zoom in' },
{ keys: 'Cmd/Ctrl + -', action: 'Zoom out' },
{ keys: 'Cmd/Ctrl + 0', action: 'Reset zoom' }
]
export function SettingsModal({ onClose }: { onClose: () => void }): React.JSX.Element {
const theme = useThemeStore((s) => s.theme)
const setTheme = useThemeStore((s) => s.setTheme)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-8">
<div className="flex w-full max-w-md flex-col rounded-xl bg-white shadow-2xl dark:bg-neutral-900">
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
<h2 className="text-sm font-semibold">Settings</h2>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
>
<X size={16} />
</button>
</div>
<div className="flex flex-col gap-6 p-4">
<section>
<h3 className="mb-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
Theme
</h3>
<div className="flex gap-2">
{THEMES.map((t) => (
<button
key={t.value}
type="button"
onClick={() => setTheme(t.value)}
className={clsx(
'flex-1 rounded-md border px-3 py-1.5 text-sm font-medium',
theme === t.value
? 'border-neutral-900 bg-neutral-900 text-white dark:border-neutral-100 dark:bg-neutral-100 dark:text-neutral-900'
: 'border-neutral-300 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800'
)}
>
{t.label}
</button>
))}
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
Zoom
</h3>
<div className="flex gap-2">
<button
type="button"
onClick={() => window.api.zoom.out()}
className="flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
>
<Minus size={14} /> Out
</button>
<button
type="button"
onClick={() => window.api.zoom.in()}
className="flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
>
<Plus size={14} /> In
</button>
<button
type="button"
onClick={() => window.api.zoom.reset()}
className="flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
>
<RotateCcw size={14} /> Reset
</button>
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
Keyboard shortcuts
</h3>
<dl className="flex flex-col gap-1 text-sm">
{SHORTCUTS.map((s) => (
<div key={s.keys} className="flex items-center justify-between">
<dt className="text-neutral-500 dark:text-neutral-400">{s.action}</dt>
<dd className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs dark:bg-neutral-800">
{s.keys}
</dd>
</div>
))}
</dl>
</section>
</div>
</div>
</div>
)
}
@@ -0,0 +1,40 @@
import { useEffect, useRef } from 'react'
import { X } from 'lucide-react'
import { useTerminalStore } from '../../stores/terminalStore'
export function TerminalPanel(): React.JSX.Element | null {
const isPanelOpen = useTerminalStore((s) => s.isPanelOpen)
const activeOperationId = useTerminalStore((s) => s.activeOperationId)
const operation = useTerminalStore((s) =>
s.activeOperationId ? s.operations[s.activeOperationId] : null
)
const setPanelOpen = useTerminalStore((s) => s.setPanelOpen)
const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
}, [operation?.lines.length])
if (!isPanelOpen || !activeOperationId || !operation) return null
return (
<div className="flex h-64 flex-shrink-0 flex-col border-t border-cyan-500/20 bg-neutral-950 shadow-[0_-16px_40px_rgba(15,23,42,0.2)] dark:border-cyan-400/20">
<div className="flex items-center justify-between border-b border-white/10 bg-white/[0.03] px-3 py-1.5">
<span className="text-xs font-medium text-neutral-300">{operation.label}</span>
<button
type="button"
onClick={() => setPanelOpen(false)}
className="rounded p-1 text-neutral-400 transition hover:bg-white/10 hover:text-neutral-200"
>
<X size={14} />
</button>
</div>
<div
ref={scrollRef}
className="flex-1 overflow-y-auto px-3 py-2 font-mono text-xs text-neutral-200"
>
<pre className="whitespace-pre-wrap">{operation.lines.join('')}</pre>
</div>
</div>
)
}
@@ -0,0 +1,45 @@
import { useEffect } from 'react'
import { CheckCircle2, XCircle } from 'lucide-react'
import { clsx } from 'clsx'
import { useToastStore, type Toast } from '../../stores/toastStore'
const AUTO_DISMISS_MS = 4000
function ToastItem({ toast }: { toast: Toast }): React.JSX.Element {
const removeToast = useToastStore((s) => s.removeToast)
useEffect(() => {
const timer = setTimeout(() => removeToast(toast.id), AUTO_DISMISS_MS)
return () => clearTimeout(timer)
}, [toast.id, removeToast])
const isSuccess = toast.variant === 'success'
return (
<div
className={clsx(
'flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg backdrop-blur',
isSuccess
? 'border-emerald-200 bg-emerald-50/95 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/95 dark:text-emerald-300'
: 'border-red-200 bg-red-50/95 text-red-800 dark:border-red-900 dark:bg-red-950/95 dark:text-red-300'
)}
>
{isSuccess ? <CheckCircle2 size={16} /> : <XCircle size={16} />}
{toast.message}
</div>
)
}
export function Toaster(): React.JSX.Element {
const toasts = useToastStore((s) => s.toasts)
return (
<div className="pointer-events-none fixed right-4 top-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<div key={toast.id} className="pointer-events-auto">
<ToastItem toast={toast} />
</div>
))}
</div>
)
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+27
View File
@@ -0,0 +1,27 @@
import { useEffect } from 'react'
import { useThemeStore } from '../stores/themeStore'
// Applies the resolved theme (light/dark/system) to <html class="dark">,
// which is what the `@custom-variant dark` rule in main.css keys off. Must
// run somewhere mounted once near the app root.
export function useAppliedTheme(): void {
const theme = useThemeStore((s) => s.theme)
useEffect(() => {
const root = document.documentElement
const media = window.matchMedia('(prefers-color-scheme: dark)')
const apply = (): void => {
const isDark = theme === 'dark' || (theme === 'system' && media.matches)
root.classList.toggle('dark', isDark)
}
apply()
if (theme === 'system') {
media.addEventListener('change', apply)
return () => media.removeEventListener('change', apply)
}
return undefined
}, [theme])
}
+116
View File
@@ -0,0 +1,116 @@
import {
useMutation,
useQuery,
useQueryClient,
type UseMutationResult,
type UseQueryResult
} from '@tanstack/react-query'
import type { AuroraProjectDetail, AuroraProjectSummary, EnvironmentUpdate } from '@shared/types'
import { useTerminalStore } from '../stores/terminalStore'
import { useStatusStore } from '../stores/statusStore'
const PROJECTS_KEY = ['projects'] as const
const projectDetailKey = (name: string): readonly [string, string] => ['project', name] as const
export function useProjects(): UseQueryResult<AuroraProjectSummary[], Error> {
return useQuery({
queryKey: PROJECTS_KEY,
queryFn: () => window.api.projects.list(),
refetchInterval: 5000
})
}
export function useProjectDetail(name: string | null): UseQueryResult<AuroraProjectDetail, Error> {
return useQuery({
queryKey: name ? projectDetailKey(name) : ['project', 'none'],
queryFn: () => window.api.projects.describe(name!),
enabled: name !== null,
retry: false,
refetchInterval: (query) => query.state.status === 'error' ? false : 5000
})
}
// Runs a streamed Aurora command (start/stop/restart) for a project. Generates
// the operationId here so the terminal panel and status bar can start
// tracking it before the IPC call even resolves.
function useProjectAction(
verb: string,
action: (operationId: string, name: string) => Promise<void>
): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (name: string) => {
const operationId = crypto.randomUUID()
const label = `${verb} ${name}`
useTerminalStore.getState().startOperation(operationId, label)
useStatusStore.getState().begin(operationId, label)
await action(operationId, name)
},
onSettled: (_data, _error, name) => {
queryClient.invalidateQueries({ queryKey: PROJECTS_KEY })
queryClient.invalidateQueries({ queryKey: projectDetailKey(name) })
}
})
}
export function useStartProject(): UseMutationResult<void, Error, string> {
return useProjectAction('Start', (operationId, name) =>
window.api.projects.start(operationId, name)
)
}
export function useStopProject(): UseMutationResult<void, Error, string> {
return useProjectAction('Stop', (operationId, name) =>
window.api.projects.stop(operationId, name)
)
}
export function useRestartProject(): UseMutationResult<void, Error, string> {
return useProjectAction('Restart', (operationId, name) =>
window.api.projects.restart(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 `Aurora config` — the caller is expected to follow a successful
// call with useRestartProject() if the project is currently running, same
// split as useCreateProject's configure/start pair.
export function useUpdateEnvironment(): UseMutationResult<
void,
Error,
{ name: string; approot: string; updates: EnvironmentUpdate }
> {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ name, approot, updates }) => {
const operationId = crypto.randomUUID()
useTerminalStore.getState().startOperation(operationId, `Update ${name} environment`)
useStatusStore.getState().begin(operationId, `Update ${name} environment`)
await window.api.projects.updateEnvironment(operationId, name, approot, updates)
},
onSettled: (_data, _error, { name }) => {
queryClient.invalidateQueries({ queryKey: PROJECTS_KEY })
queryClient.invalidateQueries({ queryKey: projectDetailKey(name) })
}
})
}
+135
View File
@@ -0,0 +1,135 @@
import type { AuroraStackOptions } from '@shared/types'
import { useMutation, useQueryClient, type UseMutationResult } from '@tanstack/react-query'
import { useTerminalStore } from '../stores/terminalStore'
import { useStatusStore } from '../stores/statusStore'
export interface CreateProjectInput {
directory: string
projectName: string
projectType: string
docroot: string
stack?: Partial<AuroraStackOptions>
}
export interface WordpressSetupInput {
directory: string
siteUrl: string
title: string
adminUser: string
adminPassword: string
adminEmail: string
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)
useStatusStore.getState().begin(operationId, label)
return operationId
}
// Only runs `Aurora config`, not `Aurora start` — the caller is expected to
// follow a successful creation with the existing useStartProject() mutation,
// reusing its own tracked operation/toast lifecycle rather than needing this
// one to juggle two unrelated command phases under a single operationId.
export function useCreateProject(): UseMutationResult<void, Error, CreateProjectInput> {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ directory, projectName, projectType, docroot, stack }: CreateProjectInput) => {
const operationId = beginOperation(`Create project ${projectName}`)
await window.api.create.configure(operationId, directory, projectName, projectType, docroot, stack)
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] })
})
}
// `Aurora config --project-type=wordpress` only scaffolds the wp-config.php
// bridge, not WordPress core itself — see create.ts in the main process for
// why this and useSetupWordpress are separate tracked operations run after
// the project has been started.
export function useDownloadWordpress(): UseMutationResult<
void,
Error,
{ directory: string; locale: string }
> {
return useMutation({
mutationFn: async ({ directory, locale }) => {
const operationId = beginOperation('Download WordPress core')
await window.api.create.downloadWordpress(operationId, directory, locale)
}
})
}
export function useSetupWordpress(): UseMutationResult<void, Error, WordpressSetupInput> {
return useMutation({
mutationFn: async ({
directory,
siteUrl,
title,
adminUser,
adminPassword,
adminEmail,
multisite
}) => {
const operationId = beginOperation('Install WordPress')
await window.api.create.setupWordpress(
operationId,
directory,
siteUrl,
title,
adminUser,
adminPassword,
adminEmail,
multisite
)
}
})
}
// `Aurora config --project-type=drupal*` only scaffolds Aurora'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
)
}
})
}
+103
View File
@@ -0,0 +1,103 @@
import {
useMutation,
useQuery,
useQueryClient,
type UseMutationResult,
type UseQueryResult
} from '@tanstack/react-query'
import type { AuroraSnapshot } from '@shared/types'
import { useTerminalStore } from '../stores/terminalStore'
import { useStatusStore } from '../stores/statusStore'
const snapshotsKey = (name: string): readonly [string, string] => ['snapshots', name] as const
export function useSnapshots(name: string, approot: string): UseQueryResult<AuroraSnapshot[], Error> {
return useQuery({
queryKey: snapshotsKey(name),
queryFn: () => window.api.database.listSnapshots(name, approot)
})
}
// Same tracked-operation pattern as useProjectAction in useAurora.ts: generate
// an operationId up front so the terminal panel/status bar pick it up before
// the (potentially slow) Aurora command resolves.
function beginOperation(label: string): string {
const operationId = crypto.randomUUID()
useTerminalStore.getState().startOperation(operationId, label)
useStatusStore.getState().begin(operationId, label)
return operationId
}
export function useCreateSnapshot(
name: string,
approot: string
): UseMutationResult<void, Error, string | undefined> {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (snapshotName?: string) => {
const operationId = beginOperation(`Create snapshot for ${name}`)
await window.api.database.createSnapshot(operationId, approot, snapshotName)
},
onSettled: () => queryClient.invalidateQueries({ queryKey: snapshotsKey(name) })
})
}
export function useRestoreSnapshot(
name: string,
approot: string
): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (snapshotName: string) => {
const operationId = beginOperation(`Restore snapshot ${snapshotName}`)
await window.api.database.restoreSnapshot(operationId, approot, snapshotName)
},
onSettled: () => queryClient.invalidateQueries({ queryKey: snapshotsKey(name) })
})
}
export function useDeleteSnapshot(
name: string,
approot: string
): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (snapshotName: string) => {
const operationId = beginOperation(`Delete snapshot ${snapshotName}`)
await window.api.database.deleteSnapshot(operationId, approot, snapshotName)
},
onSettled: () => queryClient.invalidateQueries({ queryKey: snapshotsKey(name) })
})
}
// Resolves to false if the user cancels the file picker, true if the import ran.
export function useImportDatabase(
name: string,
approot: string
): UseMutationResult<boolean, Error, void> {
return useMutation({
mutationFn: async () => {
const filePath = await window.api.database.pickImportFile()
if (!filePath) return false
const operationId = beginOperation(`Import database into ${name}`)
await window.api.database.importFile(operationId, approot, filePath)
return true
}
})
}
// Resolves to false if the user cancels the save dialog, true if the export ran.
export function useExportDatabase(
name: string,
approot: string
): UseMutationResult<boolean, Error, void> {
return useMutation({
mutationFn: async () => {
const filePath = await window.api.database.pickExportPath(`${name}.sql.gz`)
if (!filePath) return false
const operationId = beginOperation(`Export database from ${name}`)
await window.api.database.exportFile(operationId, approot, filePath)
return true
}
})
}
@@ -0,0 +1,44 @@
import { useEffect } from 'react'
interface ShortcutHandlers {
onNewProject: () => void
onOpenSettings: () => void
}
// Global keyboard shortcuts: Cmd/Ctrl+N (new project), Cmd/Ctrl+, (settings),
// Cmd/Ctrl +/-/0 (zoom in/out/reset). Mount once near the app root.
export function useKeyboardShortcuts({ onNewProject, onOpenSettings }: ShortcutHandlers): void {
useEffect(() => {
const handler = (e: KeyboardEvent): void => {
const mod = e.metaKey || e.ctrlKey
if (!mod) return
switch (e.key) {
case 'n':
e.preventDefault()
onNewProject()
break
case ',':
e.preventDefault()
onOpenSettings()
break
case '=':
case '+':
e.preventDefault()
window.api.zoom.in()
break
case '-':
e.preventDefault()
window.api.zoom.out()
break
case '0':
e.preventDefault()
window.api.zoom.reset()
break
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onNewProject, onOpenSettings])
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useRef, useState } from 'react'
interface LogLine {
stream: 'stdout' | 'stderr'
text: string
}
interface UseLogStreamResult {
lines: LogLine[]
isStreaming: boolean
}
// Owns the lifecycle of a single `Aurora logs -f` subprocess for one
// (name, service) pair. Callers must remount this (e.g. `key={service}`)
// when the service changes — state resets via fresh useState initializers
// on mount rather than manual resets inside the effect, since React's
// hooks lint flags synchronous setState-to-reset calls in an effect body.
// Always stops the subprocess on unmount so a closed log viewer doesn't
// leave an orphaned `Aurora logs -f` process running.
export function useLogStream(name: string, service: string): UseLogStreamResult {
const [lines, setLines] = useState<LogLine[]>([])
const [isStreaming, setIsStreaming] = useState(true)
// Data arrives as arbitrary byte chunks, not newline-delimited — a chunk
// can span partial lines or bundle many lines together. Buffer per stream
// so filtering/display operates on real lines instead of raw chunks.
const buffers = useRef({ stdout: '', stderr: '' })
useEffect(() => {
const operationId = crypto.randomUUID()
buffers.current = { stdout: '', stderr: '' }
const unsubData = window.api.logs.onData((event) => {
if (event.operationId !== operationId) return
const combined = buffers.current[event.stream] + event.chunk
const parts = combined.split('\n')
buffers.current[event.stream] = parts.pop() ?? ''
if (parts.length === 0) return
setLines((prev) => [...prev, ...parts.map((text) => ({ stream: event.stream, text }))])
})
const unsubExit = window.api.logs.onExit((event) => {
if (event.operationId !== operationId) return
setIsStreaming(false)
})
window.api.logs.start(operationId, name, service)
return () => {
unsubData()
unsubExit()
window.api.terminal.cancel(operationId)
}
}, [name, service])
return { lines, isStreaming }
}
+51
View File
@@ -0,0 +1,51 @@
import { useMutation, useQuery, useQueryClient, type UseMutationResult, type UseQueryResult } from '@tanstack/react-query'
import type { AuroraInstalledModule, AuroraModuleManifest } from '@shared/types'
import { useTerminalStore } from '../stores/terminalStore'
import { useStatusStore } from '../stores/statusStore'
const installedModulesKey = (name: string): readonly [string, string, string] => ['modules', 'installed', name] as const
export function useModuleRegistry(): UseQueryResult<AuroraModuleManifest[], Error> {
return useQuery({ queryKey: ['modules', 'registry'], queryFn: () => window.api.modules.listRegistry(), staleTime: Infinity })
}
export function useInstalledModules(name: string): UseQueryResult<AuroraInstalledModule[], Error> {
return useQuery({ queryKey: installedModulesKey(name), queryFn: () => window.api.modules.listInstalled(name) })
}
function beginOperation(label: string): string {
const operationId = crypto.randomUUID()
useTerminalStore.getState().startOperation(operationId, label)
useStatusStore.getState().begin(operationId, label)
return operationId
}
type InstallInput = { moduleId: string; settings?: Record<string, string | number | boolean> }
export function useInstallModule(name: string): UseMutationResult<void, Error, InstallInput> {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ moduleId, settings = {} }) => {
const operationId = beginOperation(`Install ${moduleId}`)
await window.api.modules.install(operationId, name, moduleId, settings)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: installedModulesKey(name) })
queryClient.invalidateQueries({ queryKey: ['projects'] })
}
})
}
export function useRemoveModule(name: string): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (moduleId) => {
const operationId = beginOperation(`Remove ${moduleId}`)
await window.api.modules.remove(operationId, name, moduleId)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: installedModulesKey(name) })
queryClient.invalidateQueries({ queryKey: ['projects'] })
}
})
}
@@ -0,0 +1,41 @@
import { useEffect } from 'react'
import { useTerminalStore } from '../stores/terminalStore'
import { useStatusStore } from '../stores/statusStore'
import { useToastStore } from '../stores/toastStore'
// Wires the main process's terminal:data / terminal:exit IPC events into the
// terminal/status/toast stores. Mount once near the app root — every
// long-running Aurora command (start/stop/restart, and later snapshots/addons)
// flows through this same event stream regardless of which mutation kicked
// it off, so this is the single place that owns "what happens when a
// command finishes."
export function useTerminalEvents(): void {
useEffect(() => {
const unsubData = window.api.terminal.onData(({ operationId, chunk }) => {
useTerminalStore.getState().appendChunk(operationId, chunk)
})
const unsubExit = window.api.terminal.onExit(({ operationId, exitCode, cancelled }) => {
const op = useTerminalStore.getState().operations[operationId]
const status = cancelled ? 'cancelled' : exitCode === 0 ? 'success' : 'error'
useTerminalStore.getState().finishOperation(operationId, status, exitCode)
if (useStatusStore.getState().operationId === operationId) {
useStatusStore.getState().end()
}
if (op) {
if (status === 'success') {
useToastStore.getState().addToast('success', `${op.label} succeeded`)
} else if (status === 'error') {
useToastStore.getState().addToast('error', `${op.label} failed`)
}
}
})
return () => {
unsubData()
unsubExit()
}
}, [])
}
+22
View File
@@ -0,0 +1,22 @@
import './assets/main.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false
}
}
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>
)
+11
View File
@@ -0,0 +1,11 @@
import { create } from 'zustand'
interface AppState {
selectedProjectName: string | null
selectProject: (name: string | null) => void
}
export const useAppStore = create<AppState>((set) => ({
selectedProjectName: null,
selectProject: (name): void => set({ selectedProjectName: name })
}))
+15
View File
@@ -0,0 +1,15 @@
import { create } from 'zustand'
interface StatusState {
operationId: string | null
label: string | null
begin: (operationId: string, label: string) => void
end: () => void
}
export const useStatusStore = create<StatusState>((set) => ({
operationId: null,
label: null,
begin: (operationId, label): void => set({ operationId, label }),
end: (): void => set({ operationId: null, label: null })
}))
+65
View File
@@ -0,0 +1,65 @@
import { create } from 'zustand'
export type OperationStatus = 'running' | 'success' | 'error' | 'cancelled'
export interface TerminalOperation {
id: string
label: string
lines: string[]
status: OperationStatus
exitCode: number | null
}
interface TerminalState {
operations: Record<string, TerminalOperation>
activeOperationId: string | null
isPanelOpen: boolean
startOperation: (id: string, label: string) => void
appendChunk: (id: string, chunk: string) => void
finishOperation: (
id: string,
status: Exclude<OperationStatus, 'running'>,
exitCode: number | null
) => void
setActiveOperation: (id: string | null) => void
setPanelOpen: (open: boolean) => void
}
export const useTerminalStore = create<TerminalState>((set) => ({
operations: {},
activeOperationId: null,
isPanelOpen: false,
startOperation: (id, label): void =>
set((state) => ({
operations: {
...state.operations,
[id]: { id, label, lines: [], status: 'running', exitCode: null }
},
activeOperationId: id,
isPanelOpen: true
})),
appendChunk: (id, chunk): void =>
set((state) => {
const op = state.operations[id]
if (!op) return state
return {
operations: {
...state.operations,
[id]: { ...op, lines: [...op.lines, chunk] }
}
}
}),
finishOperation: (id, status, exitCode): void =>
set((state) => {
const op = state.operations[id]
if (!op) return state
return {
operations: {
...state.operations,
[id]: { ...op, status, exitCode }
}
}
}),
setActiveOperation: (id): void => set({ activeOperationId: id }),
setPanelOpen: (open): void => set({ isPanelOpen: open })
}))
+21
View File
@@ -0,0 +1,21 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type Theme = 'light' | 'dark' | 'system'
interface ThemeState {
theme: Theme
setTheme: (theme: Theme) => void
}
export const useThemeStore = create<ThemeState>()(
persist<ThemeState>(
(set) => ({
theme: 'system',
setTheme: (theme) => {
set({ theme })
}
}),
{ name: 'aurora-dockside-theme' }
)
)
+24
View File
@@ -0,0 +1,24 @@
import { create } from 'zustand'
export type ToastVariant = 'success' | 'error'
export interface Toast {
id: string
variant: ToastVariant
message: string
}
interface ToastState {
toasts: Toast[]
addToast: (variant: ToastVariant, message: string) => void
removeToast: (id: string) => void
}
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
addToast: (variant, message): void =>
set((state) => ({
toasts: [...state.toasts, { id: crypto.randomUUID(), variant, message }]
})),
removeToast: (id): void => set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }))
}))
+197
View File
@@ -0,0 +1,197 @@
export type ProjectStatus = 'running' | 'stopped' | 'paused' | 'starting' | 'stopping' | string
export interface AuroraProjectSummary {
name: string
status: ProjectStatus
status_desc: string
type: string
approot: string
shortroot: string
docroot: string
primary_url: string
httpurl: string
httpsurl: string
mutagen_enabled: boolean
mutagen_status?: string
}
export interface AuroraServiceHostPortMapping {
exposed_port: string
host_port: string
}
export interface AuroraService {
short_name: string
full_name: string
status: string
image: string
exposed_ports: string
host_ports: string
host_ports_mapping: AuroraServiceHostPortMapping[]
http_url?: string
https_url?: string
host_http_url?: string
host_https_url?: string
virtual_host?: string
'describe-info'?: string
'describe-url-port'?: string
}
export interface AuroraDbInfo {
database_type: string
database_version: string
dbPort: string
dbname: string
host: string
password: string
published_port: number
username: string
}
export interface AuroraProjectDetail extends AuroraProjectSummary {
database_type: string
database_version: string
dbinfo: AuroraDbInfo
hostname: string
hostnames: string[]
httpURLs: string[]
httpsURLs: string[]
urls: string[]
php_version?: string
nodejs_version?: string
webserver_type?: string
performance_mode?: string
router: string
router_status?: string
certificate_status?: 'generated' | 'missing'
ca_trust_status?: 'trusted' | 'not-trusted' | 'unknown'
firefox_trust_status?: 'trusted' | 'not-trusted' | 'unavailable' | 'unknown'
chromium_trust_status?: 'trusted' | 'not-trusted' | 'unavailable' | 'unknown'
wordpress_multisite?: 'none' | 'subdirectory' | 'subdomain'
wordpress_network_admin_url?: string
adminer_url?: string
services: Record<string, AuroraService>
xdebug_enabled: boolean
}
export interface AuroraSiteCredentials {
platform: string
adminUrl: string
username: string
password: string
email: string
}
export interface AuroraStackOptions {
phpVersion: string
nodeVersion: string
database: 'mariadb' | 'postgres'
databaseVersion: string
adminer: boolean
redis: boolean
mailpit: boolean
xdebug: boolean
}
export interface EnvironmentUpdate {
phpVersion?: string
nodeVersion?: string
webserverType?: string
database?: string
xdebugEnabled?: boolean
primaryProtocol?: 'http' | 'https'
}
export interface AuroraSnapshot {
Name: string
Created: string
}
export type AuroraModuleCategory = 'application' | 'service' | 'tool'
export type AuroraModuleSettingType = 'boolean' | 'select' | 'text' | 'number'
export interface AuroraModuleSetting {
id: string
label: string
type: AuroraModuleSettingType
default: string | number | boolean
options?: string[]
}
export interface AuroraModuleManifest {
id: string
name: string
version: string
category: AuroraModuleCategory
description: string
icon?: string
dependencies: string[]
conflicts: string[]
defaults?: { docroot?: string }
settings: AuroraModuleSetting[]
compose?: {
service: string
image: string
ports?: number[]
dependsOn?: string[]
}
}
export interface AuroraInstalledModule {
id: string
name: string
version: string
category: AuroraModuleCategory
}
export interface AuroraAddonRegistryEntry {
title: string
github_url: string
description: string
user: string
repo: string
repo_id: number
default_branch: string
tag_name: string | null
engine_version_constraint: string
dependencies: string[] | null
type: string
created_at: string
updated_at: string
workflow_status: string
stars: number
}
export interface AuroraInstalledAddon {
Name: string
Repository: string
Version: string
Dependencies: string[] | null
InstallDate: string
ProjectFiles: string[] | null
GlobalFiles: string[] | null
RemovalActions: string[] | null
}
export interface TerminalDataEvent {
operationId: string
stream: 'stdout' | 'stderr'
chunk: string
}
export interface TerminalExitEvent {
operationId: string
exitCode: number | null
cancelled: boolean
}
export interface LogDataEvent {
operationId: string
stream: 'stdout' | 'stderr'
chunk: string
}
export interface LogExitEvent {
operationId: string
}
+17
View File
@@ -0,0 +1,17 @@
import '@testing-library/jest-dom/vitest'
// jsdom doesn't implement matchMedia; useAppliedTheme() relies on it to
// resolve the "system" theme.
if (!window.matchMedia) {
window.matchMedia = (query: string): MediaQueryList =>
({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false
}) as MediaQueryList
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node"]
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"include": [
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts"
],
"compilerOptions": {
"composite": true,
"jsx": "react-jsx",
"baseUrl": ".",
"types": [
"vitest/globals",
"@testing-library/jest-dom"
],
"paths": {
"@renderer/*": [
"src/renderer/src/*"
],
"@shared/*": [
"src/shared/*"
]
}
}
}
+24
View File
@@ -0,0 +1,24 @@
import { resolve } from 'path'
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@renderer': resolve('src/renderer/src'),
'@shared': resolve('src/shared')
}
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/renderer/src/**/*.{ts,tsx}']
}
}
})