feat: run WordPress projects on native runtime

This commit is contained in:
reaper
2026-08-22 04:11:44 -05:00
parent 6fc33cc8a4
commit 8356a6949a
12 changed files with 1687 additions and 378 deletions
+8 -7
View File
@@ -12,24 +12,25 @@ Each signed runtime bundle contains a `runtime.json` manifest plus versioned exe
Runtime archives are created from a staging directory with `npm run build:native-runtime -- <staging-directory> <output.tar.gz>`. The packager resolves every executable inside the staging root, calculates its SHA-256 checksum, writes the immutable `runtime.json`, excludes the build template, and creates the distributable archive. Dockside independently verifies those checksums before declaring a runtime available. Runtime archives are created from a staging directory with `npm run build:native-runtime -- <staging-directory> <output.tar.gz>`. The packager resolves every executable inside the staging root, calculates its SHA-256 checksum, writes the immutable `runtime.json`, excludes the build template, and creates the distributable archive. Dockside independently verifies those checksums before declaring a runtime available.
The first reproducible bundle target is Linux x64. Run `npm run build:native-linux-x64` on a Docker-capable build machine. Docker is used only to create the portable artifact; users of that artifact do not need Docker. The recipe pins PHP 8.5.9, nginx 1.30.4, and MariaDB 11.8.8 with their runtime libraries, runs version smoke checks outside the build container, and then invokes the normal checksum packager. The first reproducible bundle target is Linux x64. Run `npm run build:native-linux-x64` on a Docker-capable build machine. Docker is used only to create the portable artifact; users of that artifact do not need Docker. The recipe pins PHP 8.5.9, nginx 1.30.4, MariaDB 11.8.8, and WP-CLI 2.12.0 with their runtime libraries, runs executable, PHP-extension, and database-initialization smoke checks outside the build container, and then invokes the normal checksum packager.
Dockside can install a bundled runtime or a user-selected runtime archive from Settings. Installation rejects absolute and parent-traversing archive entries, rejects symbolic links, verifies the target platform and architecture, and verifies every declared executable checksum before atomically replacing an older runtime. Electron packages include matching archives from `dist/native-runtime` when they are present at packaging time. Dockside can install a bundled runtime or a user-selected runtime archive from Settings. Installation rejects absolute and parent-traversing archive entries, rejects symbolic links, verifies the target platform and architecture, and verifies every declared executable checksum before atomically replacing an older runtime. Electron packages include matching archives from `dist/native-runtime` when they are present at packaging time.
## Isolation model ## Isolation model
Every project receives reserved loopback ports, generated service configuration, isolated database data, logs, PID files, and environment variables below `.aurora/native`. A shared Aurora router owns friendly HTTPS project hostnames. Project files remain directly accessible on the host. Every project receives reserved loopback ports, generated service configuration, isolated database data, logs, PID files, nginx temporary storage, and environment variables below `.aurora/native`. Native projects currently use a direct loopback HTTP URL; container projects retain Aurora's friendly HTTP/HTTPS router names. Project files remain directly accessible on the host.
## Delivery sequence ## Delivery sequence
1. Runtime manifest, platform detection, checksum verification, and engine abstraction. 1. Runtime manifest, platform detection, checksum verification, and engine abstraction.
2. Native process supervisor and loopback port allocator. 2. Native process supervisor and loopback port allocator.
3. Linux x64 bundle with PHP 8.4, nginx, and MariaDB 11.8. 3. Linux x64 bundle with PHP 8.5, nginx, MariaDB 11.8, and WP-CLI.
4. WordPress provisioning, lifecycle, logs, database import/export, and Adminer. 4. Native project lifecycle and single-site WordPress provisioning.
5. macOS arm64/x64 and Windows x64 bundles. 5. Native logs, database import/export, and database administration.
6. Additional PHP/database versions, Apache, Drupal, Node.js, and developer services. 6. macOS arm64/x64 and Windows x64 bundles.
7. Additional PHP/database versions, Apache, Drupal, Node.js, and developer services.
Native project creation must stay disabled until the platform bundle passes executable, service-health, database, routing, and cleanup checks. Existing projects default to the container engine for backward compatibility. Dockside enables native project creation only when an installed platform bundle contains the selected PHP branch, nginx, MariaDB 11.8, and WP-CLI. Existing projects default to the container engine for backward compatibility. The native smoke suite starts all three services, serves PHP through FastCGI, provisions a real WordPress site, and verifies its HTTP response and cleanup.
## Runtime update notifications ## Runtime update notifications
+110 -20
View File
@@ -1,35 +1,125 @@
'use strict' 'use strict'
function safeName(name) { function safeName(name) {
return name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'project' return (
name
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '') || 'project'
)
} }
function wpArgs(context, network) { function wpArgs(context, network) {
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
const gid = typeof process.getgid === 'function' ? process.getgid() : undefined const gid = typeof process.getgid === 'function' ? process.getgid() : undefined
return ['run', '--rm', ...(uid === undefined ? [] : ['--user', `${uid}:${gid}`]), '-e', 'HOME=/tmp', '-e', 'WP_CLI_CACHE_DIR=/tmp/wp-cli-cache', ...(network ? ['--network', `aurora-${safeName(context.projectName)}_default`] : []), '-v', `${context.directory}:/app`, '-w', '/app', '--entrypoint', 'php', 'wordpress:cli', '-d', 'memory_limit=512M', '/usr/local/bin/wp'] return [
'run',
'--rm',
...(uid === undefined ? [] : ['--user', `${uid}:${gid}`]),
'-e',
'HOME=/tmp',
'-e',
'WP_CLI_CACHE_DIR=/tmp/wp-cli-cache',
...(network ? ['--network', `aurora-${safeName(context.projectName)}_default`] : []),
'-v',
`${context.directory}:/app`,
'-w',
'/app',
'--entrypoint',
'php',
'wordpress:cli',
'-d',
'memory_limit=512M',
'/usr/local/bin/wp'
]
} }
exports.projectCreate = async function projectCreate(context) { exports.projectCreate = async function projectCreate(context) {
const s = context.settings const s = context.settings
const title = String(s.title || '').trim() || context.projectName const title = String(s.title || '').trim() || context.projectName
const base = wpArgs(context, false) const native = context.environment.runtimeEngine === 'native'
const networkBase = wpArgs(context, true) const base = native ? [`--path=${context.directory}`] : wpArgs(context, false)
const siteUrl = context.urls.https const networkBase = native ? base : wpArgs(context, true)
await context.ensureRouter() const command = native ? context.native.wp : 'docker'
await context.run('start', 'docker', ['compose', '-f', `${context.directory}/.aurora/compose.yaml`, 'up', '-d', '--build', '--remove-orphans']) const siteUrl = native ? context.urls.http : context.urls.https
await context.run('download', 'docker', [...base, 'core', 'download', `--locale=${s.locale || 'en_US'}`, '--force']) const wp = (label, args) => context.run(label, command, [...(native ? [] : networkBase), ...args])
await context.run('config', 'docker', [...networkBase, 'config', 'create', '--dbname=db', '--dbuser=db', '--dbpass=db', '--dbhost=db:3306', '--skip-check', '--force']) if (native) {
await context.run('install', 'docker', [...networkBase, 'core', 'install', `--url=${siteUrl}`, `--title=${title}`, `--admin_user=${s.admin_user}`, `--admin_password=${s.admin_password}`, `--admin_email=${s.admin_email}`, '--skip-email']) await context.native.start()
await context.run('verify-install', 'docker', [...networkBase, 'core', 'is-installed']) const port = Number(context.native.databasePort)
if (s.multisite !== 'none') { const bootstrap = `$db=new mysqli('127.0.0.1','root','',null,${port});if($db->connect_error)throw new Exception($db->connect_error);$db->query('CREATE DATABASE IF NOT EXISTS db');$db->query("CREATE USER IF NOT EXISTS 'db'@'127.0.0.1' IDENTIFIED BY 'db'");$db->query("GRANT ALL ON db.* TO 'db'@'127.0.0.1'");`
await context.run('multisite-convert', 'docker', [...networkBase, 'core', 'multisite-convert', `--title=${title}`, ...(s.multisite === 'subdomain' ? ['--subdomains'] : [])]) await context.run('database', context.native.php, ['-r', bootstrap])
await context.run('verify-network', 'docker', [...networkBase, 'core', 'is-installed', '--network']) } else {
await context.run('verify-network-db', 'docker', [...networkBase, 'db', 'query', "SHOW TABLES LIKE 'wp_blogs';", '--skip-column-names']) await context.ensureRouter()
await context.run('start', 'docker', [
'compose',
'-f',
`${context.directory}/.aurora/compose.yaml`,
'up',
'-d',
'--build',
'--remove-orphans'
])
} }
await context.setProjectMetadata({ multisite: String(s.multisite), routingWildcard: s.multisite === 'subdomain' }) await context.run('download', command, [
await context.run('permalinks', 'docker', [...networkBase, 'rewrite', 'structure', '/%postname%/', '--hard']) ...base,
await context.run('debug', 'docker', [...networkBase, 'config', 'set', 'WP_DEBUG', String(Boolean(s.wp_debug)), '--raw']) 'core',
await context.run('environment', 'docker', [...networkBase, 'config', 'set', 'WP_ENVIRONMENT_TYPE', 'local']) 'download',
await context.saveCredentials({ platform: context.moduleId, adminUrl: `${siteUrl}/wp-admin/`, username: String(s.admin_user), password: String(s.admin_password), email: String(s.admin_email) }) `--locale=${s.locale || 'en_US'}`,
'--force'
])
await context.run('config', command, [
...networkBase,
'config',
'create',
'--dbname=db',
'--dbuser=db',
'--dbpass=db',
`--dbhost=${native ? `127.0.0.1:${context.native.databasePort}` : 'db:3306'}`,
'--skip-check',
'--force'
])
await wp('install', [
'core',
'install',
`--url=${siteUrl}`,
`--title=${title}`,
`--admin_user=${s.admin_user}`,
`--admin_password=${s.admin_password}`,
`--admin_email=${s.admin_email}`,
'--skip-email'
])
await wp('verify-install', ['core', 'is-installed'])
if (s.multisite !== 'none') {
await wp('multisite-convert', [
'core',
'multisite-convert',
`--title=${title}`,
...(s.multisite === 'subdomain' ? ['--subdomains'] : [])
])
await wp('verify-network', ['core', 'is-installed', '--network'])
await wp('verify-network-db', [
'db',
'query',
"SHOW TABLES LIKE 'wp_blogs';",
'--skip-column-names'
])
}
await context.setProjectMetadata({
multisite: String(s.multisite),
routingWildcard: s.multisite === 'subdomain'
})
if (native) {
await wp('permalinks', ['option', 'update', 'permalink_structure', '/%postname%/'])
} else {
await wp('permalinks', ['rewrite', 'structure', '/%postname%/', '--hard'])
}
await wp('debug', ['config', 'set', 'WP_DEBUG', String(Boolean(s.wp_debug)), '--raw'])
await wp('environment', ['config', 'set', 'WP_ENVIRONMENT_TYPE', 'local'])
await context.saveCredentials({
platform: context.moduleId,
adminUrl: `${siteUrl}/wp-admin/`,
username: String(s.admin_user),
password: String(s.admin_password),
email: String(s.admin_email)
})
} }
+5 -1
View File
@@ -2,7 +2,11 @@ FROM php:8.5.9-fpm-alpine3.23
RUN apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/main \ RUN apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/main \
nginx=1.30.4-r3 mariadb=11.8.8-r0 mariadb-client=11.8.8-r0 bash file pax-utils \ nginx=1.30.4-r3 mariadb=11.8.8-r0 mariadb-client=11.8.8-r0 bash file pax-utils \
&& mkdir -p /stage/root /stage/bin /stage/lib && mkdir -p /stage/root /stage/bin /stage/lib \
&& curl -fsSLo /tmp/wp-cli.phar https://github.com/wp-cli/wp-cli/releases/download/v2.12.0/wp-cli-2.12.0.phar \
&& echo 'ce34ddd838f7351d6759068d09793f26755463b4a4610a5a5c0a97b68220d85c /tmp/wp-cli.phar' | sha256sum -c -
RUN docker-php-ext-install -j2 mysqli pdo_mysql
COPY stage-runtime.sh /usr/local/bin/stage-runtime COPY stage-runtime.sh /usr/local/bin/stage-runtime
RUN chmod +x /usr/local/bin/stage-runtime && /usr/local/bin/stage-runtime RUN chmod +x /usr/local/bin/stage-runtime && /usr/local/bin/stage-runtime
+22 -1
View File
@@ -63,6 +63,18 @@ EOF
chmod +x "$stage/bin/$name" chmod +x "$stage/bin/$name"
done done
for name in php php-fpm; do
target=/usr/local/bin/php
[ "$name" = php-fpm ] && target=/usr/local/sbin/php-fpm
cat > "$stage/bin/$name" <<EOF
#!/bin/sh
runtime_root=\$(CDPATH= cd -- "\$(dirname -- "\$0")/.." && pwd)
extension_dir=\$(find "\$runtime_root/root/usr/local/lib/php/extensions" -mindepth 1 -maxdepth 1 -type d -print -quit)
exec "\$runtime_root/bin/aurora-exec" "$target" -d "extension_dir=\$extension_dir" "\$@"
EOF
chmod +x "$stage/bin/$name"
done
# mariadb-install-db is a shell script that calls helpers below --basedir. # mariadb-install-db is a shell script that calls helpers below --basedir.
# Put the ELF programs behind relocatable wrappers so those calls also use the # Put the ELF programs behind relocatable wrappers so those calls also use the
# bundled musl loader instead of the host's /lib interpreter. # bundled musl loader instead of the host's /lib interpreter.
@@ -91,6 +103,14 @@ exec "$(dirname "$0")/aurora-exec" /usr/libexec/aurora/mariadbd "$@"
EOF EOF
chmod +x "$stage/bin/mariadbd" chmod +x "$stage/bin/mariadbd"
cp /tmp/wp-cli.phar "$root/usr/local/bin/wp-cli.phar"
cat > "$stage/bin/wp" <<'EOF'
#!/bin/sh
runtime_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
exec "$runtime_root/bin/php" -d memory_limit=512M "$runtime_root/root/usr/local/bin/wp-cli.phar" "$@"
EOF
chmod +x "$stage/bin/wp"
php_version=$(php -r 'echo PHP_VERSION;') php_version=$(php -r 'echo PHP_VERSION;')
nginx_version=$(nginx -v 2>&1 | sed 's#nginx version: nginx/##') nginx_version=$(nginx -v 2>&1 | sed 's#nginx version: nginx/##')
mariadb_version=$(mariadbd --version | sed -n 's/.* Ver \([^ -]*\).*/\1/p') mariadb_version=$(mariadbd --version | sed -n 's/.* Ver \([^ -]*\).*/\1/p')
@@ -103,7 +123,8 @@ cat > "$stage/runtime.template.json" <<EOF
"components": [ "components": [
{ "id": "php", "version": "$php_version", "executable": "bin/php-fpm" }, { "id": "php", "version": "$php_version", "executable": "bin/php-fpm" },
{ "id": "nginx", "version": "$nginx_version", "executable": "bin/nginx" }, { "id": "nginx", "version": "$nginx_version", "executable": "bin/nginx" },
{ "id": "mariadb", "version": "$mariadb_version", "executable": "bin/mariadbd" } { "id": "mariadb", "version": "$mariadb_version", "executable": "bin/mariadbd" },
{ "id": "wp-cli", "version": "2.12.0", "executable": "bin/wp" }
] ]
} }
EOF EOF
+2 -1
View File
@@ -28,7 +28,8 @@ for (const executable of [
'php-fpm', 'php-fpm',
'nginx', 'nginx',
'mariadbd', 'mariadbd',
'mariadb-install-db' 'mariadb-install-db',
'wp'
]) { ]) {
const path = join(stagingRoot, 'bin', executable) const path = join(stagingRoot, 'bin', executable)
if (!existsSync(path)) throw new Error(`Builder did not produce ${path}`) if (!existsSync(path)) throw new Error(`Builder did not produce ${path}`)
+15
View File
@@ -28,6 +28,12 @@ const checks = [
join(root, 'bin/mariadbd'), join(root, 'bin/mariadbd'),
['--version'], ['--version'],
template.components.find((item) => item.id === 'mariadb')?.version template.components.find((item) => item.id === 'mariadb')?.version
],
[
'WP-CLI',
join(root, 'bin/wp'),
['--version'],
template.components.find((item) => item.id === 'wp-cli')?.version
] ]
] ]
for (const [name, command, args, version] of checks) { for (const [name, command, args, version] of checks) {
@@ -40,6 +46,15 @@ for (const [name, command, args, version] of checks) {
process.stdout.write(`${name} ${version} OK\n`) process.stdout.write(`${name} ${version} OK\n`)
} }
const extensions = spawnSync(
join(root, 'bin/php'),
['-r', "exit(extension_loaded('mysqli') && extension_loaded('pdo_mysql') ? 0 : 1);"],
{ encoding: 'utf8' }
)
if (extensions.error || extensions.status !== 0)
throw extensions.error || new Error(`PHP database extensions failed: ${extensions.stderr}`)
process.stdout.write('PHP mysqli and pdo_mysql extensions OK\n')
const databaseDirectory = mkdtempSync(join(tmpdir(), 'aurora-native-mariadb-')) const databaseDirectory = mkdtempSync(join(tmpdir(), 'aurora-native-mariadb-'))
const temporaryDirectory = mkdtempSync(join(tmpdir(), 'aurora-native-mariadb-tmp-')) const temporaryDirectory = mkdtempSync(join(tmpdir(), 'aurora-native-mariadb-tmp-'))
try { try {
+735 -146
View File
File diff suppressed because it is too large Load Diff
+190 -29
View File
@@ -1,39 +1,200 @@
import { ipcMain, type WebContents } from 'electron' import { ipcMain, type WebContents } from 'electron'
import { spawn } from 'child_process' import { spawn } from 'child_process'
import { listProjects, describeProject, getProjectRoot, unregisterProject, updateEnvironment, ensureRouter, trustAuroraCA } from '../auroraEngine' import {
listProjects,
describeProject,
getNativeProjectDefinition,
getProjectRoot,
unregisterProject,
updateEnvironment,
ensureRouter,
trustAuroraCA
} from '../auroraEngine'
import { nativeServiceSpecs, startNativeProject, stopNativeProject } from '../native/nativeProject'
import { runCommandStreamed } from '../commandRunner' import { runCommandStreamed } from '../commandRunner'
import { runProjectLifecycleHooks } from '../moduleRuntime' import { runProjectLifecycleHooks } from '../moduleRuntime'
import type { EnvironmentUpdate } from '../../shared/types' import type { EnvironmentUpdate } from '../../shared/types'
const composeArgs=(root:string,...args:string[]):string[]=>['compose','-f',`${root}/.aurora/compose.yaml`,...args] const composeArgs = (root: string, ...args: string[]): string[] => [
const allowedServices = new Set(['web','php','db','node','adminer','redis','mailpit']) 'compose',
function launchTerminal(command:string,args:string[]):Promise<void>{return new Promise((resolve,reject)=>{const child=spawn(command,args,{detached:true,stdio:'ignore'});child.once('error',reject);child.once('spawn',()=>{child.unref();resolve()})})} '-f',
async function startProject(operationId:string,name:string,sender:WebContents,forceRecreate=false):Promise<void>{ `${root}/.aurora/compose.yaml`,
const root=await getProjectRoot(name); await updateEnvironment(root,{}); await ensureRouter() ...args
try { await runCommandStreamed(operationId,'docker',composeArgs(root,'up','-d','--build',...(forceRecreate?['--force-recreate']:[]),'--remove-orphans'),sender,{cwd:root,emitExit:false}); await runProjectLifecycleHooks(root,'projectStart',operationId,sender); if(!sender.isDestroyed())sender.send('terminal:exit',{operationId,exitCode:0,cancelled:false}) } ]
catch(error){if(!sender.isDestroyed())sender.send('terminal:exit',{operationId,exitCode:1,cancelled:false});throw error} const allowedServices = new Set(['web', 'php', 'db', 'node', 'adminer', 'redis', 'mailpit'])
function launchTerminal(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { detached: true, stdio: 'ignore' })
child.once('error', reject)
child.once('spawn', () => {
child.unref()
resolve()
})
})
} }
export function registerProjectsIpc():void{ async function startProject(
ipcMain.handle('projects:list',()=>listProjects()); ipcMain.handle('projects:describe',(_e,name:string)=>describeProject(name)) operationId: string,
ipcMain.handle('projects:start',(e,id:string,name:string)=>startProject(id,name,e.sender)) name: string,
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})}) sender: WebContents,
ipcMain.handle('projects:restart',(e,id:string,name:string)=>startProject(id,name,e.sender,true)) forceRecreate = false
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})}) ): Promise<void> {
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})}) const root = await getProjectRoot(name)
ipcMain.handle('projects:openTerminal',async(_e,name:string)=>{const root=await getProjectRoot(name);const candidates: Array<[string,string[]]>=process.platform==='linux'?[['ptyxis',['--new-window','--working-directory',root,'--title',`Aurora · ${name}`]],['x-terminal-emulator',['--new-window','--working-directory',root]],['gnome-terminal',[`--working-directory=${root}`]],['kgx',['--working-directory',root]],['konsole',['--workdir',root]]]:[];for(const [cmd,args] of candidates){try{await launchTerminal(cmd,args);return}catch{/* try the next supported terminal */}}throw new Error('No supported terminal application was found.')}) await updateEnvironment(root, {})
ipcMain.handle('projects:delete',async(e,id:string,name:string,approot:string,deleteFiles:boolean)=>{ const native = await getNativeProjectDefinition(name)
let root=approot
try { root=await getProjectRoot(name) } catch { /* use renderer-provided root for stale entries */ }
try { await runProjectLifecycleHooks(root,'projectRemove',id,e.sender) } catch (error) { console.warn(`Aurora module cleanup for '${name}' failed:`, error) }
try { try {
await runCommandStreamed(id,'docker',composeArgs(root,'down','-v','--remove-orphans'),e.sender,{cwd:root}) if (native) {
if (!sender.isDestroyed())
sender.send('terminal:data', {
operationId,
stream: 'stdout',
chunk: 'Starting Aurora Native database, PHP-FPM, and nginx…\n'
})
await startNativeProject(native)
} else {
await ensureRouter()
await runCommandStreamed(
operationId,
'docker',
composeArgs(
root,
'up',
'-d',
'--build',
...(forceRecreate ? ['--force-recreate'] : []),
'--remove-orphans'
),
sender,
{ cwd: root, emitExit: false }
)
}
await runProjectLifecycleHooks(root, 'projectStart', operationId, sender)
if (!sender.isDestroyed())
sender.send('terminal:exit', { operationId, exitCode: 0, cancelled: false })
} catch (error) { } catch (error) {
// A malformed/missing compose file must never make a project undeletable. if (!sender.isDestroyed())
console.warn(`Aurora cleanup for '${name}' skipped:`, error) sender.send('terminal:exit', { operationId, exitCode: 1, cancelled: false })
throw error
} }
await unregisterProject(name,deleteFiles) }
}) export function registerProjectsIpc(): void {
ipcMain.handle('projects:trustCA',async()=>{ await trustAuroraCA(); await ensureRouter() }) ipcMain.handle('projects:list', () => listProjects())
ipcMain.handle('projects:updateEnvironment',async(_e,_id:string,_name:string,root:string,updates:EnvironmentUpdate)=>{ ipcMain.handle('projects:describe', (_e, name: string) => describeProject(name))
await updateEnvironment(root,updates) ipcMain.handle('projects:start', (e, id: string, name: string) =>
}) startProject(id, name, e.sender)
)
ipcMain.handle('projects:stop', async (e, id: string, name: string) => {
const native = await getNativeProjectDefinition(name)
if (native) {
await stopNativeProject(native)
if (!e.sender.isDestroyed())
e.sender.send('terminal:exit', { operationId: id, exitCode: 0, cancelled: false })
return
}
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 native = await getNativeProjectDefinition(name)
if (native) await stopNativeProject(native)
return startProject(id, name, e.sender, true)
})
ipcMain.handle(
'projects:restartService',
async (e, id: string, name: string, service: string) => {
if (!allowedServices.has(service)) throw new Error('Invalid service')
const native = await getNativeProjectDefinition(name)
if (native) {
await stopNativeProject(native)
return startProject(id, name, e.sender)
}
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 native = await getNativeProjectDefinition(name)
if (native) {
const php = nativeServiceSpecs(native)
.find((spec) => spec.id.endsWith(':php'))
?.command.replace(/php-fpm$/, 'php')
if (!php) throw new Error('Native PHP executable not found.')
return runCommandStreamed(id, php, ['-i'], e.sender, { cwd: native.root })
}
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'
? [
[
'ptyxis',
['--new-window', '--working-directory', root, '--title', `Aurora · ${name}`]
],
['x-terminal-emulator', ['--new-window', '--working-directory', root]],
['gnome-terminal', [`--working-directory=${root}`]],
['kgx', ['--working-directory', root]],
['konsole', ['--workdir', root]]
]
: []
for (const [cmd, args] of candidates) {
try {
await launchTerminal(cmd, args)
return
} catch {
/* try the next supported terminal */
}
}
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 runProjectLifecycleHooks(root, 'projectRemove', id, e.sender)
} catch (error) {
console.warn(`Aurora module cleanup for '${name}' failed:`, error)
}
try {
const native = await getNativeProjectDefinition(name).catch(() => null)
if (native) await stopNativeProject(native)
else
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: EnvironmentUpdate) => {
await updateEnvironment(root, updates)
}
)
} }
+149 -37
View File
@@ -6,77 +6,174 @@ import type { WebContents } from 'electron'
import type { AuroraModuleLifecycleHook } from '../shared/types' import type { AuroraModuleLifecycleHook } from '../shared/types'
import { getModuleManifest, moduleDirectory } from './moduleRegistry' import { getModuleManifest, moduleDirectory } from './moduleRegistry'
import { runCommandStreamed } from './commandRunner' import { runCommandStreamed } from './commandRunner'
import { AURORA_ENV, ensureRouter, getProjectConfig, projectUrls, setProjectModuleMetadata } from './auroraEngine' import {
AURORA_ENV,
ensureRouter,
getNativeProjectDefinition,
getProjectConfig,
projectUrls,
setProjectModuleMetadata
} from './auroraEngine'
import { startNativeProject } from './native/nativeProject'
import { runtimeRoot } from './nativeRuntime'
import { saveSiteCredentials } from './ipc/secrets' import { saveSiteCredentials } from './ipc/secrets'
type Settings = Record<string, string | number | boolean> type Settings = Record<string, string | number | boolean>
type ExternalHook = (context: Record<string, unknown>) => Promise<void> | void type ExternalHook = (context: Record<string, unknown>) => Promise<void> | void
type LoadedModule = Partial<Record<AuroraModuleLifecycleHook, ExternalHook>> type LoadedModule = Partial<Record<AuroraModuleLifecycleHook, ExternalHook>>
type HookOptions = { directory?: string; projectName?: string; settings?: Settings; toolId?: string; operationId?: string; sender?: WebContents } type HookOptions = {
directory?: string
projectName?: string
settings?: Settings
toolId?: string
operationId?: string
sender?: WebContents
}
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
const CORE_MODULE_IDS = new Set(['adminer', 'redis', 'mailpit']) const CORE_MODULE_IDS = new Set(['adminer', 'redis', 'mailpit'])
function sendExit(sender: WebContents, operationId: string, exitCode: number): void { function sendExit(sender: WebContents, operationId: string, exitCode: number): void {
if (!sender.isDestroyed()) sender.send('terminal:exit', { operationId, exitCode, cancelled: false }) if (!sender.isDestroyed())
sender.send('terminal:exit', { operationId, exitCode, cancelled: false })
} }
function loadModule(moduleId: string, main: string): LoadedModule { function loadModule(moduleId: string, main: string): LoadedModule {
const root = resolve(moduleDirectory(), moduleId) const root = resolve(moduleDirectory(), moduleId)
const entry = resolve(root, main) const entry = resolve(root, main)
if (entry !== root && !entry.startsWith(`${root}${sep}`)) throw new Error('Unsafe module main entry') if (entry !== root && !entry.startsWith(`${root}${sep}`))
throw new Error('Unsafe module main entry')
return createRequire(join(dirname(entry), 'loader.cjs'))(entry) as LoadedModule return createRequire(join(dirname(entry), 'loader.cjs'))(entry) as LoadedModule
} }
export async function runModuleLifecycleHook(moduleId: string, hook: AuroraModuleLifecycleHook, options: HookOptions = {}): Promise<boolean> { export async function runModuleLifecycleHook(
moduleId: string,
hook: AuroraModuleLifecycleHook,
options: HookOptions = {}
): Promise<boolean> {
const manifest = await getModuleManifest(moduleId) const manifest = await getModuleManifest(moduleId)
if (!manifest.main) return false if (!manifest.main) return false
const handler = loadModule(moduleId, manifest.main)[hook] const handler = loadModule(moduleId, manifest.main)[hook]
if (typeof handler !== 'function') return false if (typeof handler !== 'function') return false
const directory = options.directory const directory = options.directory
const urls = options.projectName ? projectUrls(options.projectName) : undefined
const projectConfig = directory ? await getProjectConfig(directory) : undefined const projectConfig = directory ? await getProjectConfig(directory) : undefined
const nativeDefinition = options.projectName
? await getNativeProjectDefinition(options.projectName)
: null
const urls =
projectConfig?.runtimeEngine === 'native' && projectConfig.nativePorts
? {
http: `http://127.0.0.1:${projectConfig.nativePorts.http}`,
https: `http://127.0.0.1:${projectConfig.nativePorts.http}`
}
: options.projectName
? projectUrls(options.projectName)
: undefined
const run = async (label: string, command: string, args: string[]): Promise<void> => { const run = async (label: string, command: string, args: string[]): Promise<void> => {
if (options.sender && options.operationId) { if (options.sender && options.operationId) {
if (!options.sender.isDestroyed()) options.sender.send('terminal:data', { operationId: options.operationId, stream: 'stdout', chunk: `\n[${manifest.name}] ${label}\n` }) if (!options.sender.isDestroyed())
return runCommandStreamed(options.operationId, command, args, options.sender, { cwd: directory ?? resolve(moduleDirectory(), moduleId), emitExit: false }) options.sender.send('terminal:data', {
operationId: options.operationId,
stream: 'stdout',
chunk: `\n[${manifest.name}] ${label}\n`
})
return runCommandStreamed(options.operationId, command, args, options.sender, {
cwd: directory ?? resolve(moduleDirectory(), moduleId),
emitExit: false
})
} }
await execFileAsync(command, args, { cwd: directory ?? resolve(moduleDirectory(), moduleId), env: AURORA_ENV }) await execFileAsync(command, args, {
cwd: directory ?? resolve(moduleDirectory(), moduleId),
env: AURORA_ENV
})
} }
await handler(Object.freeze({ await handler(
moduleId, Object.freeze({
hook, moduleId,
directory, hook,
projectName: options.projectName, directory,
toolId: options.toolId, projectName: options.projectName,
settings: Object.freeze({ ...(options.settings ?? {}) }), toolId: options.toolId,
environment: projectConfig ? Object.freeze({ php: projectConfig.php, node: projectConfig.node, webserver: projectConfig.webserver, database: projectConfig.database, databaseVersion: projectConfig.databaseVersion, docroot: projectConfig.docroot }) : undefined, settings: Object.freeze({ ...(options.settings ?? {}) }),
urls: urls ? Object.freeze(urls) : undefined, environment: projectConfig
run, ? Object.freeze({
ensureRouter, php: projectConfig.php,
setProjectMetadata: async (metadata: Settings) => { node: projectConfig.node,
if (!directory) throw new Error('Project metadata is unavailable outside a project lifecycle hook') webserver: projectConfig.webserver,
await setProjectModuleMetadata(directory, metadata) database: projectConfig.database,
}, databaseVersion: projectConfig.databaseVersion,
saveCredentials: async (credentials: { platform: string; adminUrl: string; username: string; password: string; email: string }) => { docroot: projectConfig.docroot,
if (!directory) throw new Error('Project credentials are unavailable outside a project lifecycle hook') runtimeEngine: projectConfig.runtimeEngine ?? 'container'
await saveSiteCredentials(directory, credentials) })
} : undefined,
})) urls: urls ? Object.freeze(urls) : undefined,
native: nativeDefinition
? Object.freeze({
php: join(runtimeRoot(), 'bin', 'php'),
wp: join(runtimeRoot(), 'bin', 'wp'),
databaseHost: '127.0.0.1',
databasePort: nativeDefinition.ports.database,
start: () => startNativeProject(nativeDefinition)
})
: undefined,
run,
ensureRouter,
setProjectMetadata: async (metadata: Settings) => {
if (!directory)
throw new Error('Project metadata is unavailable outside a project lifecycle hook')
await setProjectModuleMetadata(directory, metadata)
},
saveCredentials: async (credentials: {
platform: string
adminUrl: string
username: string
password: string
email: string
}) => {
if (!directory)
throw new Error('Project credentials are unavailable outside a project lifecycle hook')
await saveSiteCredentials(directory, credentials)
}
})
)
return true return true
} }
export async function runProjectLifecycleHooks(directory: string, hook: 'projectStart' | 'projectRemove', operationId: string, sender: WebContents): Promise<void> { export async function runProjectLifecycleHooks(
directory: string,
hook: 'projectStart' | 'projectRemove',
operationId: string,
sender: WebContents
): Promise<void> {
const config = await getProjectConfig(directory) const config = await getProjectConfig(directory)
for (const moduleId of config.modules) { for (const moduleId of config.modules) {
if (CORE_MODULE_IDS.has(moduleId)) continue if (CORE_MODULE_IDS.has(moduleId)) continue
await runModuleLifecycleHook(moduleId, hook, { directory, projectName: config.name, settings: config.moduleSettings?.[moduleId], operationId, sender }) await runModuleLifecycleHook(moduleId, hook, {
directory,
projectName: config.name,
settings: config.moduleSettings?.[moduleId],
operationId,
sender
})
} }
} }
export async function runModuleProjectCreate(moduleId: string, operationId: string, directory: string, projectName: string, settings: Settings, sender: WebContents): Promise<void> { export async function runModuleProjectCreate(
moduleId: string,
operationId: string,
directory: string,
projectName: string,
settings: Settings,
sender: WebContents
): Promise<void> {
try { try {
await runModuleLifecycleHook(moduleId, 'projectCreate', { directory, projectName, settings, operationId, sender }) await runModuleLifecycleHook(moduleId, 'projectCreate', {
directory,
projectName,
settings,
operationId,
sender
})
sendExit(sender, operationId, 0) sendExit(sender, operationId, 0)
} catch (error) { } catch (error) {
sendExit(sender, operationId, 1) sendExit(sender, operationId, 1)
@@ -84,13 +181,28 @@ export async function runModuleProjectCreate(moduleId: string, operationId: stri
} }
} }
export async function runModuleProjectTool(moduleId: string, toolId: string, operationId: string, directory: string, sender: WebContents): Promise<void> { export async function runModuleProjectTool(
moduleId: string,
toolId: string,
operationId: string,
directory: string,
sender: WebContents
): Promise<void> {
const manifest = await getModuleManifest(moduleId) const manifest = await getModuleManifest(moduleId)
if (!manifest.project?.tools?.some((tool) => tool.id === toolId)) throw new Error(`Unknown module tool '${toolId}'`) if (!manifest.project?.tools?.some((tool) => tool.id === toolId))
throw new Error(`Unknown module tool '${toolId}'`)
const config = await getProjectConfig(directory) const config = await getProjectConfig(directory)
if (!config.modules.includes(moduleId)) throw new Error(`Module '${moduleId}' is not installed in this project`) if (!config.modules.includes(moduleId))
throw new Error(`Module '${moduleId}' is not installed in this project`)
try { try {
const handled = await runModuleLifecycleHook(moduleId, 'projectTool', { directory, projectName: config.name, settings: config.moduleSettings?.[moduleId], toolId, operationId, sender }) const handled = await runModuleLifecycleHook(moduleId, 'projectTool', {
directory,
projectName: config.name,
settings: config.moduleSettings?.[moduleId],
toolId,
operationId,
sender
})
if (!handled) throw new Error(`Module '${moduleId}' does not implement projectTool`) if (!handled) throw new Error(`Module '${moduleId}' does not implement projectTool`)
sendExit(sender, operationId, 0) sendExit(sender, operationId, 0)
} catch (error) { } catch (error) {
+101 -1
View File
@@ -1,5 +1,18 @@
import { execFile } from 'child_process'
import { createRequire } from 'module'
import { mkdtemp, readFile, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { promisify } from 'util'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { renderMariaDbConfig, renderNginxConfig, renderPhpFpmConfig } from './nativeProject' import {
renderMariaDbConfig,
renderNginxConfig,
renderPhpFpmConfig,
startNativeProject,
stopNativeProject
} from './nativeProject'
import { allocateNativePorts } from './portAllocator'
const project = { const project = {
name: 'demo', name: 'demo',
@@ -7,6 +20,7 @@ const project = {
docroot: 'public', docroot: 'public',
ports: { http: 41001, php: 41002, database: 41003, node: 41004 } ports: { http: 41001, php: 41002, database: 41003, node: 41004 }
} }
const execFileAsync = promisify(execFile)
describe('native project configuration', () => { describe('native project configuration', () => {
it('isolates PHP-FPM on its allocated loopback port', () => it('isolates PHP-FPM on its allocated loopback port', () =>
@@ -25,3 +39,89 @@ describe('native project configuration', () => {
expect(config).toContain('/.aurora/native/data/mariadb') expect(config).toContain('/.aurora/native/data/mariadb')
}) })
}) })
it.runIf(Boolean(process.env.AURORA_NATIVE_SMOKE_ROOT))(
'serves PHP through the complete native stack',
async () => {
const root = await mkdtemp(join(tmpdir(), 'aurora-native-project-'))
await writeFile(join(root, 'index.php'), '<?php echo "aurora-native-ok";')
const definition = {
name: `smoke-${Date.now()}`,
root,
docroot: '',
ports: await allocateNativePorts(),
installedRuntimeRoot: process.env.AURORA_NATIVE_SMOKE_ROOT
}
try {
await startNativeProject(definition)
const response = await fetch(`http://127.0.0.1:${definition.ports.http}`)
expect(await response.text()).toBe('aurora-native-ok')
} finally {
await stopNativeProject(definition)
}
},
45000
)
it.runIf(Boolean(process.env.AURORA_NATIVE_WORDPRESS_SMOKE_ROOT))(
'provisions WordPress with the bundled native runtime',
async () => {
const runtime = process.env.AURORA_NATIVE_WORDPRESS_SMOKE_ROOT!
const root = await mkdtemp(join(tmpdir(), 'aurora-native-wordpress-'))
const definition = {
name: `wordpress-${Date.now()}`,
root,
docroot: '',
ports: await allocateNativePorts(),
installedRuntimeRoot: runtime
}
const wordpress = createRequire(import.meta.url)(
'../../../packages/aurora-module-wordpress/main/index.cjs'
) as { projectCreate: (context: Record<string, unknown>) => Promise<void> }
try {
await wordpress.projectCreate({
moduleId: 'wordpress',
directory: root,
projectName: definition.name,
settings: {
title: 'Aurora native smoke',
admin_user: 'aurora-admin',
admin_password: 'aurora-native-test-password',
admin_email: '[email protected]',
locale: 'en_US',
multisite: 'none',
wp_debug: false
},
environment: { runtimeEngine: 'native' },
urls: {
http: `http://127.0.0.1:${definition.ports.http}`,
https: `http://127.0.0.1:${definition.ports.http}`
},
native: {
php: join(runtime, 'bin', 'php'),
wp: join(runtime, 'bin', 'wp'),
databasePort: definition.ports.database,
start: () => startNativeProject(definition)
},
run: async (_label: string, command: string, args: string[]) => {
await execFileAsync(command, args, {
cwd: root,
env: process.env,
maxBuffer: 32 * 1024 * 1024
})
},
setProjectMetadata: async () => undefined,
saveCredentials: async () => undefined
})
expect(await readFile(join(root, 'wp-config.php'), 'utf8')).toContain(
`127.0.0.1:${definition.ports.database}`
)
const response = await fetch(`http://127.0.0.1:${definition.ports.http}`)
expect(response.ok).toBe(true)
expect(await response.text()).toContain('Aurora native smoke')
} finally {
await stopNativeProject(definition)
}
},
120000
)
+38 -6
View File
@@ -14,6 +14,11 @@ export interface NativeProjectDefinition {
root: string root: string
docroot: string docroot: string
ports: AuroraNativePorts ports: AuroraNativePorts
installedRuntimeRoot?: string
}
function installedRoot(project: NativeProjectDefinition): string {
return project.installedRuntimeRoot ?? runtimeRoot()
} }
function nativeDirectory(root: string): string { function nativeDirectory(root: string): string {
@@ -53,6 +58,11 @@ error_log "${quoteNginx(join(directory, 'logs', 'nginx.log'))}" info;
events { worker_connections 256; } events { worker_connections 256; }
http { http {
access_log "${quoteNginx(join(directory, 'logs', 'nginx-access.log'))}"; access_log "${quoteNginx(join(directory, 'logs', 'nginx-access.log'))}";
client_body_temp_path "${quoteNginx(join(directory, 'tmp', 'nginx-client'))}";
proxy_temp_path "${quoteNginx(join(directory, 'tmp', 'nginx-proxy'))}";
fastcgi_temp_path "${quoteNginx(join(directory, 'tmp', 'nginx-fastcgi'))}";
uwsgi_temp_path "${quoteNginx(join(directory, 'tmp', 'nginx-uwsgi'))}";
scgi_temp_path "${quoteNginx(join(directory, 'tmp', 'nginx-scgi'))}";
server { server {
listen 127.0.0.1:${project.ports.http}; listen 127.0.0.1:${project.ports.http};
server_name localhost; server_name localhost;
@@ -65,9 +75,17 @@ http {
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param SCRIPT_NAME $fastcgi_script_name; fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_param REQUEST_METHOD $request_method; fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param QUERY_STRING $query_string; fastcgi_param QUERY_STRING $query_string;
fastcgi_param CONTENT_TYPE $content_type; fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length; fastcgi_param CONTENT_LENGTH $content_length;
fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_param SERVER_PROTOCOL $server_protocol;
fastcgi_param SERVER_NAME $server_name;
fastcgi_param SERVER_PORT $server_port;
fastcgi_param HTTP_HOST $http_host;
fastcgi_param REMOTE_ADDR $remote_addr;
fastcgi_param REMOTE_PORT $remote_port;
} }
} }
} }
@@ -94,18 +112,32 @@ skip-name-resolve
export async function provisionNativeProject(project: NativeProjectDefinition): Promise<void> { export async function provisionNativeProject(project: NativeProjectDefinition): Promise<void> {
const directory = nativeDirectory(project.root) const directory = nativeDirectory(project.root)
for (const child of ['config', 'data/mariadb', 'logs', 'pids', 'tmp']) for (const child of [
'config',
'data/mariadb',
'logs',
'pids',
'tmp',
'tmp/nginx-client',
'tmp/nginx-proxy',
'tmp/nginx-fastcgi',
'tmp/nginx-uwsgi',
'tmp/nginx-scgi'
])
await mkdir(join(directory, child), { recursive: true }) await mkdir(join(directory, child), { recursive: true })
await Promise.all([ await Promise.all([
writeFile(join(directory, 'config', 'php-fpm.conf'), renderPhpFpmConfig(project)), writeFile(join(directory, 'config', 'php-fpm.conf'), renderPhpFpmConfig(project)),
writeFile(join(directory, 'config', 'nginx.conf'), renderNginxConfig(project)), writeFile(join(directory, 'config', 'nginx.conf'), renderNginxConfig(project)),
writeFile(join(directory, 'config', 'mariadb.cnf'), renderMariaDbConfig(project)) writeFile(
join(directory, 'config', 'mariadb.cnf'),
renderMariaDbConfig(project, installedRoot(project))
)
]) ])
try { try {
await access(join(directory, 'data', 'mariadb', 'mysql')) await access(join(directory, 'data', 'mariadb', 'mysql'))
} catch { } catch {
await execFileAsync( await execFileAsync(
join(runtimeRoot(), 'bin', 'mariadb-install-db'), join(installedRoot(project), 'bin', 'mariadb-install-db'),
[ [
'--no-defaults', '--no-defaults',
`--datadir=${join(directory, 'data', 'mariadb')}`, `--datadir=${join(directory, 'data', 'mariadb')}`,
@@ -129,19 +161,19 @@ export function nativeServiceSpecs(project: NativeProjectDefinition): NativeServ
return [ return [
{ {
...common('database'), ...common('database'),
command: join(runtimeRoot(), 'bin', 'mariadbd'), command: join(installedRoot(project), 'bin', 'mariadbd'),
args: [`--defaults-file=${join(directory, 'config', 'mariadb.cnf')}`], args: [`--defaults-file=${join(directory, 'config', 'mariadb.cnf')}`],
ready: { port: project.ports.database, timeoutMs: 30000 } ready: { port: project.ports.database, timeoutMs: 30000 }
}, },
{ {
...common('php'), ...common('php'),
command: join(runtimeRoot(), 'bin', 'php-fpm'), command: join(installedRoot(project), 'bin', 'php-fpm'),
args: ['--nodaemonize', '--fpm-config', join(directory, 'config', 'php-fpm.conf')], args: ['--nodaemonize', '--fpm-config', join(directory, 'config', 'php-fpm.conf')],
ready: { port: project.ports.php } ready: { port: project.ports.php }
}, },
{ {
...common('web'), ...common('web'),
command: join(runtimeRoot(), 'bin', 'nginx'), command: join(installedRoot(project), 'bin', 'nginx'),
args: ['-c', join(directory, 'config', 'nginx.conf'), '-p', `${directory}/`], args: ['-c', join(directory, 'config', 'nginx.conf'), '-p', `${directory}/`],
ready: { port: project.ports.http } ready: { port: project.ports.http }
} }
@@ -51,7 +51,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
const [redis, setRedis] = useState(false) const [redis, setRedis] = useState(false)
const [mailpit, setMailpit] = useState(false) const [mailpit, setMailpit] = useState(false)
const [xdebug, setXdebug] = useState(false) const [xdebug, setXdebug] = useState(false)
const [runtimeEngine] = useState<'container' | 'native'>('container') const [runtimeEngine, setRuntimeEngine] = useState<'container' | 'native'>('container')
const createProject = useCreateProject() const createProject = useCreateProject()
const selectProject = useAppStore((s) => s.selectProject) const selectProject = useAppStore((s) => s.selectProject)
@@ -60,13 +60,32 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
const { data: runtimeStatus } = useRuntimeStatus() const { data: runtimeStatus } = useRuntimeStatus()
const applicationModules = moduleRegistry.filter((module) => module.category === 'application') const applicationModules = moduleRegistry.filter((module) => module.category === 'application')
const selectedModule = applicationModules.find((module) => module.id === projectType) const selectedModule = applicationModules.find((module) => module.id === projectType)
const getTypeLabel = (type: string): string => applicationModules.find((module) => module.id === type)?.name ?? 'project' const getTypeLabel = (type: string): string =>
applicationModules.find((module) => module.id === type)?.name ?? 'project'
const trimmedName = projectName.trim() const trimmedName = projectName.trim()
const nameValid = trimmedName.length > 0 && isValidProjectName(trimmedName) const nameValid = trimmedName.length > 0 && isValidProjectName(trimmedName)
const canContinue = directory !== null && nameValid && selectedModule !== undefined const canContinue = directory !== null && nameValid && selectedModule !== undefined
const canSubmit = canContinue && setupValid && !isSubmitting const canSubmit = canContinue && setupValid && !isSubmitting
function selectRuntimeEngine(engine: 'container' | 'native'): void {
if (engine === 'native' && !runtimeStatus?.native.available) return
setRuntimeEngine(engine)
if (engine === 'native') {
const nativePhp = runtimeStatus?.native.components.find(
(component) => component.id === 'php'
)?.version
if (nativePhp) setPhpVersion(nativePhp.split('.').slice(0, 2).join('.'))
setWebServer('nginx')
setDatabase('mariadb')
setDatabaseVersion('11.8')
setAdminer(false)
setRedis(false)
setMailpit(false)
setXdebug(false)
}
}
async function handlePickDirectory(): Promise<void> { async function handlePickDirectory(): Promise<void> {
const picked = await window.api.create.pickDirectory() const picked = await window.api.create.pickDirectory()
if (!picked) return if (!picked) return
@@ -82,7 +101,24 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
const name = projectName.trim() const name = projectName.trim()
setIsSubmitting(true) setIsSubmitting(true)
try { try {
await createProject.mutateAsync({ directory, projectName: name, projectType, docroot, stack: { runtimeEngine, phpVersion, nodeVersion, webServer, database, databaseVersion, adminer, redis, mailpit, xdebug } }) await createProject.mutateAsync({
directory,
projectName: name,
projectType,
docroot,
stack: {
runtimeEngine,
phpVersion,
nodeVersion,
webServer,
database,
databaseVersion,
adminer,
redis,
mailpit,
xdebug
}
})
} catch { } catch {
setIsSubmitting(false) setIsSubmitting(false)
return return
@@ -180,138 +216,285 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
<div className="p-5"> <div className="p-5">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{step === 'site' ? ( {step === 'site' ? (
<> <>
<div> <div>
<label className={labelClass}>Project folder</label> <label className={labelClass}>Project folder</label>
<button <button
type="button" type="button"
onClick={handlePickDirectory} onClick={handlePickDirectory}
className={clsx( className={clsx(
'group flex w-full items-center gap-3 rounded-xl border border-dashed px-3 py-3 text-left text-sm transition', 'group flex w-full items-center gap-3 rounded-xl border border-dashed px-3 py-3 text-left text-sm transition',
directory 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-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' : '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>
)} )}
>
<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">
{applicationModules.map((module) => ({ value: module.id, label: module.name, defaults: module.defaults, creation: module.creation })).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)
setPhpVersion(t.creation?.phpVersions?.[0] ?? '8.4')
if (!docroot.trim() && t.defaults?.docroot) setDocroot(t.defaults.docroot)
}}
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>
)
})}
{applicationModules.length === 0 && <div className="col-span-full rounded-xl border border-dashed border-amber-300 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-400/30 dark:bg-amber-400/10 dark:text-amber-100"><p className="font-semibold">No application modules installed</p><p className="mt-1">Close this window and use <strong>Modules</strong> at the bottom of the sidebar to install one.</p></div>}
</div> </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>
<div className="mb-3"> <label className={labelClass}>Project type</label>
<p className="text-sm font-semibold">Development stack</p> <div className="grid gap-2 sm:grid-cols-2">
<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> {applicationModules
</div> .map((module) => ({
<div className="mb-4 grid gap-2 sm:grid-cols-2"> value: module.id,
<div className="rounded-xl border border-cyan-300 bg-cyan-50 p-3 text-cyan-950 dark:border-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-100"><div className="flex items-center gap-2 text-sm font-semibold"><Container size={16}/> Container engine</div><p className="mt-1 text-xs text-cyan-800/80 dark:text-cyan-100/70">Current compatible engine · {runtimeStatus?.container.available ? runtimeStatus.container.provider : 'not detected'}</p></div> label: module.name,
<div aria-disabled="true" className="rounded-xl border border-neutral-200 bg-neutral-100/70 p-3 opacity-70 dark:border-white/10 dark:bg-white/[0.03]"><div className="flex items-center gap-2 text-sm font-semibold"><Cpu size={16}/> Aurora Native</div><p className="mt-1 text-xs text-neutral-500">{runtimeStatus?.native.available ? `Runtime ${runtimeStatus.native.runtimeVersion} detected · provisioning checks pending` : 'Runtime bundle not installed yet'}</p></div> defaults: module.defaults,
</div> creation: module.creation
<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)}>{(selectedModule?.creation?.phpVersions ?? ['8.2','8.3','8.4','8.5']).map(v=><option key={v}>{v}</option>)}</select></div> .map((t) => {
<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> const Icon = TYPE_ICONS[t.value] ?? Boxes
<div><label className={labelClass}>Web server</label><select className={fieldClass} value={webServer} onChange={(e)=>setWebServer(e.target.value as 'nginx'|'apache')}><option value="nginx">nginx</option><option value="apache">Apache</option></select></div> const isSelected = projectType === t.value
<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'|'mysql'|'postgres');setDatabaseVersion(version)}}>{selectedModule?.creation?.databases?.includes('mariadb') !== false && <><option value="mariadb:11.8">MariaDB 11.8</option><option value="mariadb:10.11">MariaDB 10.11</option></>}{selectedModule?.creation?.databases?.includes('mysql') !== false && <><option value="mysql:8.4">MySQL 8.4</option><option value="mysql:8.0">MySQL 8.0</option></>}{selectedModule?.creation?.databases?.includes('postgres') !== false && <><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"> return (
{[['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>)} <button
key={t.value}
type="button"
onClick={() => {
setProjectType(t.value)
setPhpVersion(t.creation?.phpVersions?.[0] ?? '8.4')
if (!docroot.trim() && t.defaults?.docroot)
setDocroot(t.defaults.docroot)
}}
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>
)
})}
{applicationModules.length === 0 && (
<div className="col-span-full rounded-xl border border-dashed border-amber-300 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-400/30 dark:bg-amber-400/10 dark:text-amber-100">
<p className="font-semibold">No application modules installed</p>
<p className="mt-1">
Close this window and use <strong>Modules</strong> at the bottom of the
sidebar to install one.
</p>
</div>
)}
</div> </div>
</div> </div>
</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]">
<label className={labelClass}>Docroot (optional)</label> <div className="mb-3">
<input <p className="text-sm font-semibold">Development stack</p>
type="text" <p className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
value={docroot} Aurora core owns the runtime; the application is a module layered on top.
onChange={(e) => setDocroot(e.target.value)} </p>
placeholder="e.g. web, public — leave blank for project root" </div>
className={fieldClass} <div className="mb-4 grid gap-2 sm:grid-cols-2">
/> <button
</div> type="button"
</> onClick={() => selectRuntimeEngine('container')}
) : selectedModule ? ( className={
<ExternalModuleSetup ref={setupRef} module={selectedModule} projectName={projectName.trim()} onValidityChange={setSetupValid} /> runtimeEngine === 'container'
) : ( ? 'rounded-xl border border-cyan-400 bg-cyan-50 p-3 text-left text-cyan-950 ring-2 ring-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-100'
<GenericSetup : 'rounded-xl border border-neutral-200 bg-white p-3 text-left dark:border-white/10 dark:bg-white/[0.03]'
ref={setupRef} }
projectName={projectName.trim()} >
onValidityChange={setSetupValid} <div className="flex items-center gap-2 text-sm font-semibold">
/> <Container size={16} /> Container engine
)} </div>
<p className="mt-1 text-xs text-neutral-500">
{runtimeStatus?.container.available
? runtimeStatus.container.provider
: 'not detected'}
</p>
</button>
<button
type="button"
disabled={!runtimeStatus?.native.available}
onClick={() => selectRuntimeEngine('native')}
className={
runtimeEngine === 'native'
? 'rounded-xl border border-cyan-400 bg-cyan-50 p-3 text-left text-cyan-950 ring-2 ring-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-100'
: 'rounded-xl border border-neutral-200 bg-white p-3 text-left disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-white/[0.03]'
}
>
<div className="flex items-center gap-2 text-sm font-semibold">
<Cpu size={16} /> Aurora Native
</div>
<p className="mt-1 text-xs text-neutral-500">
{runtimeStatus?.native.available
? `Runtime ${runtimeStatus.native.runtimeVersion} · no Docker required`
: 'Install the runtime from Settings first'}
</p>
</button>
</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)}
>
{(
selectedModule?.creation?.phpVersions ?? ['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
disabled={runtimeEngine === 'native'}
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}>Web server</label>
<select
disabled={runtimeEngine === 'native'}
className={fieldClass}
value={webServer}
onChange={(e) => setWebServer(e.target.value as 'nginx' | 'apache')}
>
<option value="nginx">nginx</option>
<option value="apache">Apache</option>
</select>
</div>
<div>
<label className={labelClass}>Database</label>
<select
disabled={runtimeEngine === 'native'}
className={fieldClass}
value={`${database}:${databaseVersion}`}
onChange={(e) => {
const [kind, version] = e.target.value.split(':')
setDatabase(kind as 'mariadb' | 'mysql' | 'postgres')
setDatabaseVersion(version)
}}
>
{selectedModule?.creation?.databases?.includes('mariadb') !== false && (
<>
<option value="mariadb:11.8">MariaDB 11.8</option>
<option value="mariadb:10.11">MariaDB 10.11</option>
</>
)}
{selectedModule?.creation?.databases?.includes('mysql') !== false && (
<>
<option value="mysql:8.4">MySQL 8.4</option>
<option value="mysql:8.0">MySQL 8.0</option>
</>
)}
{selectedModule?.creation?.databases?.includes('postgres') !== false && (
<>
<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"
disabled={runtimeEngine === 'native'}
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>
</>
) : selectedModule ? (
<ExternalModuleSetup
ref={setupRef}
module={selectedModule}
projectName={projectName.trim()}
onValidityChange={setSetupValid}
/>
) : (
<GenericSetup
ref={setupRef}
projectName={projectName.trim()}
onValidityChange={setSetupValid}
/>
)}
</div> </div>
</div> </div>