From 8356a6949ac2d06194ca9cefac68fd6bc6d6eec2 Mon Sep 17 00:00:00 2001 From: reaper Date: Sat, 22 Aug 2026 04:11:44 -0500 Subject: [PATCH] feat: run WordPress projects on native runtime --- docs/AURORA_NATIVE_RUNTIME.md | 15 +- .../aurora-module-wordpress/main/index.cjs | 130 ++- runtime-build/linux-x64/Dockerfile | 6 +- runtime-build/linux-x64/stage-runtime.sh | 23 +- scripts/build-native-linux-x64.cjs | 3 +- scripts/smoke-native-runtime.cjs | 15 + src/main/auroraEngine.ts | 881 +++++++++++++++--- src/main/ipc/projects.ts | 219 ++++- src/main/moduleRuntime.ts | 186 +++- src/main/native/nativeProject.test.ts | 102 +- src/main/native/nativeProject.ts | 44 +- .../components/create/CreateProjectModal.tsx | 441 ++++++--- 12 files changed, 1687 insertions(+), 378 deletions(-) diff --git a/docs/AURORA_NATIVE_RUNTIME.md b/docs/AURORA_NATIVE_RUNTIME.md index 053b3e3..767a60c 100644 --- a/docs/AURORA_NATIVE_RUNTIME.md +++ b/docs/AURORA_NATIVE_RUNTIME.md @@ -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 -- `. 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. ## 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 1. Runtime manifest, platform detection, checksum verification, and engine abstraction. 2. Native process supervisor and loopback port allocator. -3. Linux x64 bundle with PHP 8.4, nginx, and MariaDB 11.8. -4. WordPress provisioning, lifecycle, logs, database import/export, and Adminer. -5. macOS arm64/x64 and Windows x64 bundles. -6. Additional PHP/database versions, Apache, Drupal, Node.js, and developer services. +3. Linux x64 bundle with PHP 8.5, nginx, MariaDB 11.8, and WP-CLI. +4. Native project lifecycle and single-site WordPress provisioning. +5. Native logs, database import/export, and database administration. +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 diff --git a/packages/aurora-module-wordpress/main/index.cjs b/packages/aurora-module-wordpress/main/index.cjs index 534c16a..bf521e0 100644 --- a/packages/aurora-module-wordpress/main/index.cjs +++ b/packages/aurora-module-wordpress/main/index.cjs @@ -1,35 +1,125 @@ 'use strict' 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) { const uid = typeof process.getuid === 'function' ? process.getuid() : 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) { const s = context.settings const title = String(s.title || '').trim() || context.projectName - const base = wpArgs(context, false) - const networkBase = wpArgs(context, true) - const siteUrl = context.urls.https - await context.ensureRouter() - await context.run('start', 'docker', ['compose', '-f', `${context.directory}/.aurora/compose.yaml`, 'up', '-d', '--build', '--remove-orphans']) - await context.run('download', 'docker', [...base, 'core', 'download', `--locale=${s.locale || 'en_US'}`, '--force']) - await context.run('config', 'docker', [...networkBase, 'config', 'create', '--dbname=db', '--dbuser=db', '--dbpass=db', '--dbhost=db:3306', '--skip-check', '--force']) - 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.run('verify-install', 'docker', [...networkBase, 'core', 'is-installed']) - if (s.multisite !== 'none') { - await context.run('multisite-convert', 'docker', [...networkBase, 'core', 'multisite-convert', `--title=${title}`, ...(s.multisite === 'subdomain' ? ['--subdomains'] : [])]) - await context.run('verify-network', 'docker', [...networkBase, 'core', 'is-installed', '--network']) - await context.run('verify-network-db', 'docker', [...networkBase, 'db', 'query', "SHOW TABLES LIKE 'wp_blogs';", '--skip-column-names']) + const native = context.environment.runtimeEngine === 'native' + const base = native ? [`--path=${context.directory}`] : wpArgs(context, false) + const networkBase = native ? base : wpArgs(context, true) + const command = native ? context.native.wp : 'docker' + const siteUrl = native ? context.urls.http : context.urls.https + const wp = (label, args) => context.run(label, command, [...(native ? [] : networkBase), ...args]) + if (native) { + await context.native.start() + const port = Number(context.native.databasePort) + 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('database', context.native.php, ['-r', bootstrap]) + } else { + 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('permalinks', 'docker', [...networkBase, 'rewrite', 'structure', '/%postname%/', '--hard']) - await context.run('debug', 'docker', [...networkBase, 'config', 'set', 'WP_DEBUG', String(Boolean(s.wp_debug)), '--raw']) - await context.run('environment', 'docker', [...networkBase, '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) }) + await context.run('download', command, [ + ...base, + 'core', + 'download', + `--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) + }) } diff --git a/runtime-build/linux-x64/Dockerfile b/runtime-build/linux-x64/Dockerfile index 04eb05d..c7d4188 100644 --- a/runtime-build/linux-x64/Dockerfile +++ b/runtime-build/linux-x64/Dockerfile @@ -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 \ 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 RUN chmod +x /usr/local/bin/stage-runtime && /usr/local/bin/stage-runtime diff --git a/runtime-build/linux-x64/stage-runtime.sh b/runtime-build/linux-x64/stage-runtime.sh index 65597e9..6780064 100644 --- a/runtime-build/linux-x64/stage-runtime.sh +++ b/runtime-build/linux-x64/stage-runtime.sh @@ -63,6 +63,18 @@ EOF chmod +x "$stage/bin/$name" 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" < "$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;') nginx_version=$(nginx -v 2>&1 | sed 's#nginx version: nginx/##') mariadb_version=$(mariadbd --version | sed -n 's/.* Ver \([^ -]*\).*/\1/p') @@ -103,7 +123,8 @@ cat > "$stage/runtime.template.json" < 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) { @@ -40,6 +46,15 @@ for (const [name, command, args, version] of checks) { 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 temporaryDirectory = mkdtempSync(join(tmpdir(), 'aurora-native-mariadb-tmp-')) try { diff --git a/src/main/auroraEngine.ts b/src/main/auroraEngine.ts index a51f7c1..f335293 100644 --- a/src/main/auroraEngine.ts +++ b/src/main/auroraEngine.ts @@ -3,9 +3,29 @@ 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, AuroraRuntimeEngine } from '../shared/types' +import type { + AuroraProjectDetail, + AuroraProjectSummary, + AuroraInstalledModule, + AuroraModuleManifest, + AuroraStackOptions, + AuroraRuntimeEngine +} from '../shared/types' import type { AuroraNativePorts } from './native/portAllocator' -import { CORE_VERSION, MODULE_API_VERSION, getModuleManifest, getModuleRegistry } from './moduleRegistry' +import { allocateNativePorts } from './native/portAllocator' +import { + nativeProjectStatus, + provisionNativeProject, + stopNativeProject, + type NativeProjectDefinition +} from './native/nativeProject' +import { getRuntimeStatus } from './nativeRuntime' +import { + CORE_VERSION, + MODULE_API_VERSION, + getModuleManifest, + getModuleRegistry +} from './moduleRegistry' const execFileAsync = promisify(execFile) const EXTRA_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/opt/local/bin'] @@ -36,7 +56,8 @@ 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 apacheConfigPath = (root: string): string => join(configDir(root), 'httpd.conf') -const adminerAutoLoginPath = (root: string): string => join(configDir(root), 'adminer-auto-login.php') +const adminerAutoLoginPath = (root: string): string => + join(configDir(root), 'adminer-auto-login.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') @@ -47,12 +68,64 @@ 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 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)}` }) +export const projectUrls = (name: string): { http: string; https: string } => ({ + http: `http://${projectHost(name)}`, + https: `https://${projectHost(name)}` +}) + +function nativeDefinition(root: string, config: AuroraConfig): NativeProjectDefinition { + if (!config.nativePorts) + throw new Error(`Native project '${config.name}' has no allocated ports.`) + return { name: config.name, root, docroot: config.docroot, ports: config.nativePorts } +} + +function urlsForConfig(config: AuroraConfig): { http: string; https: string } { + if (config.runtimeEngine === 'native' && config.nativePorts) + return { + http: `http://127.0.0.1:${config.nativePorts.http}`, + https: `http://127.0.0.1:${config.nativePorts.http}` + } + return projectUrls(config.name) +} + +async function assertNativeStack( + stack: Partial, + phpVersion: string +): Promise { + const status = await getRuntimeStatus() + if (!status.native.available) + throw new Error(status.native.reason || 'Aurora Native runtime is not installed.') + const versions = new Map( + status.native.components.map((component) => [component.id, component.version]) + ) + if (!versions.get('php')?.startsWith(`${phpVersion}.`)) + throw new Error(`Installed native runtime does not provide PHP ${phpVersion}.`) + if (stack.webServer === 'apache') + throw new Error( + 'The first Aurora Native runtime supports nginx; Apache will be added as a separate runtime component.' + ) + if (stack.database && stack.database !== 'mariadb') + throw new Error('The first Aurora Native runtime supports MariaDB 11.8 only.') + if (!versions.get('mariadb')?.startsWith('11.8.')) + throw new Error('Installed native runtime does not provide MariaDB 11.8.') + if (!versions.has('nginx') || !versions.has('wp-cli')) + throw new Error('Installed native runtime is missing nginx or WP-CLI.') + if (stack.adminer !== false || stack.redis || stack.mailpit || stack.xdebug) + throw new Error('Adminer, Redis, Mailpit, and Xdebug are not enabled for Aurora Native yet.') +} async function loadRegistry(): Promise { - try { return JSON.parse(await readFile(registryPath(), 'utf8')) as Registry } catch { return { projects: {} } } + try { + return JSON.parse(await readFile(registryPath(), 'utf8')) as Registry + } catch { + return { projects: {} } + } } async function saveRegistry(registry: Registry): Promise { await mkdir(app.getPath('userData'), { recursive: true }) @@ -64,13 +137,17 @@ async function readConfig(root: string): Promise { async function writeConfig(root: string, config: AuroraConfig): Promise { await mkdir(configDir(root), { recursive: true }) await writeFile(configPath(root), JSON.stringify(config, null, 2)) - await writeAdminerAutoLogin(root, config) - await writeFile(composePath(root), await renderCompose(config)) + if ((config.runtimeEngine ?? 'container') === 'container') { + await writeAdminerAutoLogin(root, config) + await writeFile(composePath(root), await renderCompose(config)) + } } async function writeAdminerAutoLogin(root: string, config: AuroraConfig): Promise { const driver = config.database === 'postgres' ? 'pgsql' : 'server' - await writeFile(adminerAutoLoginPath(root), ` line ? pad + line : line).join('\n') + return lines + .split('\n') + .map((line) => (line ? pad + line : line)) + .join('\n') } -async function renderModuleService(module: AuroraModuleManifest, config: AuroraConfig): Promise { +async function renderModuleService( + module: AuroraModuleManifest, + config: AuroraConfig +): Promise { if (!module.compose) return '' const { service, image, ports = [], dependsOn = [] } = module.compose const settings = config.moduleSettings?.[module.id] ?? {} @@ -109,43 +193,90 @@ async function renderModuleService(module: AuroraModuleManifest, config: AuroraC 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`) + 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 { - 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` - : c.database === 'mysql' - ? ` db:\n image: mysql:${c.databaseVersion}\n environment:\n MYSQL_DATABASE: db\n MYSQL_USER: db\n MYSQL_PASSWORD: db\n MYSQL_ROOT_PASSWORD: root\n volumes:\n - db_data:/var/lib/mysql\n healthcheck:\n test: [\"CMD-SHELL\", \"mysqladmin ping -h localhost -uroot -proot --silent\"]\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 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` + : c.database === 'mysql' + ? ` db:\n image: mysql:${c.databaseVersion}\n environment:\n MYSQL_DATABASE: db\n MYSQL_USER: db\n MYSQL_PASSWORD: db\n MYSQL_ROOT_PASSWORD: root\n volumes:\n - db_data:/var/lib/mysql\n healthcheck:\n test: [\"CMD-SHELL\", \"mysqladmin ping -h localhost -uroot -proot --silent\"]\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 coreServices: Record = { - redis: { id: 'redis', name: 'Redis', version: '1.0.0', category: 'service', description: '', dependencies: [], conflicts: [], settings: [], aurora: { core: CORE_VERSION, moduleApi: MODULE_API_VERSION }, compose: { service: 'redis', image: 'redis:8-alpine', ports: [6379] } }, - mailpit: { id: 'mailpit', name: 'Mailpit', version: '1.0.0', category: 'tool', description: '', dependencies: [], conflicts: [], settings: [], aurora: { core: CORE_VERSION, moduleApi: MODULE_API_VERSION }, compose: { service: 'mailpit', image: 'axllent/mailpit:latest', ports: [8025, 1025] } } + redis: { + id: 'redis', + name: 'Redis', + version: '1.0.0', + category: 'service', + description: '', + dependencies: [], + conflicts: [], + settings: [], + aurora: { core: CORE_VERSION, moduleApi: MODULE_API_VERSION }, + compose: { service: 'redis', image: 'redis:8-alpine', ports: [6379] } + }, + mailpit: { + id: 'mailpit', + name: 'Mailpit', + version: '1.0.0', + category: 'tool', + description: '', + dependencies: [], + conflicts: [], + settings: [], + aurora: { core: CORE_VERSION, moduleApi: MODULE_API_VERSION }, + compose: { service: 'mailpit', image: 'axllent/mailpit:latest', ports: [8025, 1025] } + } } - const manifests = await Promise.all(c.modules.filter((id) => id !== 'adminer').map((id) => coreServices[id] ?? getModuleManifest(id))) - const extras = (await Promise.all(manifests.map((m) => renderModuleService(m, c)))).filter(Boolean) + const manifests = await Promise.all( + c.modules + .filter((id) => id !== 'adminer') + .map((id) => coreServices[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 volumes:\n - ./adminer-auto-login.php:/var/www/html/plugins-enabled/aurora-auto-login.php:ro\n networks:\n default:\n aurora-router:\n aliases:\n - aurora-${safeName(c.name)}-adminer\n depends_on:\n db:\n condition: service_healthy` : '' + 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 volumes:\n - ./adminer-auto-login.php:/var/www/html/plugins-enabled/aurora-auto-login.php:ro\n networks:\n default:\n aurora-router:\n aliases:\n - aurora-${safeName(c.name)}-adminer\n depends_on:\n db:\n condition: service_healthy` + : '' const webImage = c.webserver === 'apache' ? 'httpd:2.4-alpine' : 'nginx:1.29-alpine' - const webConfigMount = c.webserver === 'apache' ? ' - ./httpd.conf:/usr/local/apache2/conf/httpd.conf:ro' : ' - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro' + const webConfigMount = + c.webserver === 'apache' + ? ' - ./httpd.conf:/usr/local/apache2/conf/httpd.conf:ro' + : ' - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro' return `name: aurora-${safeName(c.name)}\nservices:\n web:\n image: ${webImage}\n working_dir: /var/www/html\n volumes:\n - ../:/var/www/html\n${webConfigMount}\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 { - await writeFile(phpDockerfilePath(root), `ARG PHP_VERSION=8.4 + 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 curl-dev libxml2-dev \ && docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-install -j2 mysqli pdo_mysql pdo_pgsql intl zip gd mbstring curl dom 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 { const webroot = docroot ? `/var/www/html/${docroot}` : '/var/www/html' - await writeFile(join(configDir(root), 'nginx.conf'), `map $http_x_forwarded_proto $aurora_https { + await writeFile( + join(configDir(root), 'nginx.conf'), + `map $http_x_forwarded_proto $aurora_https { default off; https on; } @@ -157,12 +288,15 @@ server { 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; } } -`) +` + ) } async function writeApache(root: string, docroot: string): Promise { const webroot = docroot ? `/var/www/html/${docroot}` : '/var/www/html' - await writeFile(apacheConfigPath(root), `ServerRoot "/usr/local/apache2" + await writeFile( + apacheConfigPath(root), + `ServerRoot "/usr/local/apache2" Listen 80 LoadModule mpm_event_module modules/mod_mpm_event.so LoadModule authn_core_module modules/mod_authn_core.so @@ -195,7 +329,8 @@ ProxyPassMatch ^/(.*\\.php(?:/.*)?)$ fcgi://php:9000/var/www/html/$1 ErrorLog /proc/self/fd/2 LogFormat "%h %l %u %t \\"%r\\" %>s %b" combined CustomLog /proc/self/fd/1 combined -`) +` + ) } async function writeWebServerConfig(root: string, config: AuroraConfig): Promise { @@ -203,11 +338,18 @@ async function writeWebServerConfig(root: string, config: AuroraConfig): Promise else await writeNginx(root, config.docroot) } -export async function createProject(root: string, name: string, type: string, docroot: string, stack?: Partial): Promise { +export async function createProject( + root: string, + name: string, + type: string, + docroot: string, + stack?: Partial +): Promise { await mkdir(root, { recursive: true }) if (!type) throw new Error('Install and select an application module before creating a project') const application = await getModuleManifest(type) - if (application.category !== 'application') throw new Error(`Module '${type}' cannot create application projects`) + if (application.category !== 'application') + throw new Error(`Module '${type}' cannot create application projects`) const normalizedType = application.id const defaultDocroot = docroot || application.defaults?.docroot || '' const modules = [normalizedType] @@ -215,51 +357,232 @@ export async function createProject(root: string, name: string, type: string, do if (stack?.redis) modules.push('redis') if (stack?.mailpit) modules.push('mailpit') const phpVersion = stack?.phpVersion || application.creation?.phpVersions?.[0] || '8.4' - if (application.creation?.phpVersions?.length && !application.creation.phpVersions.includes(phpVersion)) throw new Error(`${application.name} does not support PHP ${phpVersion}`) + if ( + application.creation?.phpVersions?.length && + !application.creation.phpVersions.includes(phpVersion) + ) + throw new Error(`${application.name} does not support PHP ${phpVersion}`) const runtimeEngine = stack?.runtimeEngine ?? 'container' - if (runtimeEngine === 'native') throw new Error('Aurora Native project creation is locked until the platform runtime passes application provisioning checks.') - const config: AuroraConfig = { name, type: normalizedType, docroot: defaultDocroot, php: phpVersion, node: stack?.nodeVersion || '24', webserver: stack?.webServer || 'nginx', database: stack?.database || 'mariadb', databaseVersion: stack?.databaseVersion || '11.8', modules, moduleSettings: {}, primaryProtocol: 'https', xdebug: stack?.xdebug === true, runtimeEngine } - await writeConfig(root, config); await writeWebServerConfig(root, config); await writePhpDockerfile(root, config.xdebug) - const reg = await loadRegistry(); reg.projects[name] = root; await saveRegistry(reg) + if (runtimeEngine === 'native') await assertNativeStack(stack ?? {}, phpVersion) + const config: AuroraConfig = { + name, + type: normalizedType, + docroot: defaultDocroot, + php: phpVersion, + node: stack?.nodeVersion || '24', + webserver: stack?.webServer || 'nginx', + database: stack?.database || 'mariadb', + databaseVersion: stack?.databaseVersion || '11.8', + modules, + moduleSettings: {}, + primaryProtocol: runtimeEngine === 'native' ? 'http' : 'https', + xdebug: stack?.xdebug === true, + runtimeEngine, + nativePorts: runtimeEngine === 'native' ? await allocateNativePorts() : undefined + } + await writeConfig(root, config) + await writeWebServerConfig(root, config) + await writePhpDockerfile(root, config.xdebug) + if (runtimeEngine === 'native') await provisionNativeProject(nativeDefinition(root, config)) + const reg = await loadRegistry() + reg.projects[name] = root + await saveRegistry(reg) } export async function unregisterProject(name: string, deleteFiles: boolean): Promise { - const reg = await loadRegistry(); const root = reg.projects[name]; delete reg.projects[name]; await saveRegistry(reg) + 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 { - try { const { stdout } = await execFileAsync('docker', ['compose', '-f', composePath(root), 'ps', '--all', '--format', 'json'], { env: AURORA_ENV, maxBuffer: 8*1024*1024 }); return stdout.trim().split('\n').filter(Boolean).map(x => JSON.parse(x)) } catch { return [] } + try { + const { stdout } = await execFileAsync( + 'docker', + ['compose', '-f', composePath(root), 'ps', '--all', '--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 { - const reg = await loadRegistry(); const out: AuroraProjectSummary[] = []; const availableModules = new Set((await getModuleRegistry()).map((module) => module.id)) + const reg = await loadRegistry() + const out: AuroraProjectSummary[] = [] + const availableModules = new Set((await getModuleRegistry()).map((module) => module.id)) 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.length > 0 && ps.every(p => p.State === 'running'); const urls = projectUrls(c.name); const primary = c.primaryProtocol === 'http' ? urls.http : urls.https + try { + await access(configPath(root)) + const c = await readConfig(root) + const ps = c.runtimeEngine === 'native' ? [] : await composeJson(root) + const nativeStatus = + c.runtimeEngine === 'native' ? await nativeProjectStatus(nativeDefinition(root, c)) : null + const running = + nativeStatus?.running ?? (ps.length > 0 && ps.every((p) => p.State === 'running')) + const urls = urlsForConfig(c) + const primary = c.primaryProtocol === 'http' ? urls.http : urls.https const moduleAvailable = availableModules.has(c.type) - out.push({ name, status: running?'running':'stopped', status_desc: moduleAvailable ? (running?'Running':'Stopped') : `Missing application module: ${c.type}`, type:c.type, approot:root, shortroot:root, docroot:c.docroot, primary_url:primary, httpurl:urls.http, httpsurl:urls.https, mutagen_enabled:false, module_available: moduleAvailable, missing_module_id: moduleAvailable ? undefined : c.type, runtime_engine: c.runtimeEngine ?? 'container', native_ports: c.nativePorts }) - } catch { /* stale registry entry */ } - } return out + out.push({ + name, + status: running ? 'running' : 'stopped', + status_desc: moduleAvailable + ? running + ? 'Running' + : 'Stopped' + : `Missing application module: ${c.type}`, + type: c.type, + approot: root, + shortroot: root, + docroot: c.docroot, + primary_url: primary, + httpurl: urls.http, + httpsurl: urls.https, + mutagen_enabled: false, + module_available: moduleAvailable, + missing_module_id: moduleAvailable ? undefined : c.type, + runtime_engine: c.runtimeEngine ?? 'container', + native_ports: c.nativePorts + }) + } catch { + /* stale registry entry */ + } + } + return out } export async function describeProject(name: string): Promise { - 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.length > 0 && ps.every(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 = {}; 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:[]} - const moduleMetadata = { ...(c.wordpressMultisite ? { multisite: c.wordpressMultisite } : {}), ...c.moduleMetadata } - 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:c.webserver,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(),module_metadata:moduleMetadata,adminer_url:c.modules.includes('adminer')?`https://adminer.${projectHost(c.name)}`:undefined,services,xdebug_enabled:c.xdebug===true,runtime_engine:c.runtimeEngine??'container',native_ports:c.nativePorts } + 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 = c.runtimeEngine === 'native' ? [] : await composeJson(root) + const nativeStatus = + c.runtimeEngine === 'native' ? await nativeProjectStatus(nativeDefinition(root, c)) : null + const running = nativeStatus?.running ?? (ps.length > 0 && ps.every((p) => p.State === 'running')) + const currentRouterStatus = + c.runtimeEngine === 'native' ? (running ? 'running' : 'stopped') : await routerStatus() + const urlSet = urlsForConfig(c) + const primary = c.primaryProtocol === 'http' ? urlSet.http : urlSet.https + const services: Record = {} + 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: [] + } + if (nativeStatus) + for (const [service, status] of Object.entries(nativeStatus.services)) + services[service] = { + short_name: service, + full_name: `${c.name}:${service}`, + status, + image: 'Aurora Native', + exposed_ports: '', + host_ports: '', + host_ports_mapping: [] + } + const moduleMetadata = { + ...(c.wordpressMultisite ? { multisite: c.wordpressMultisite } : {}), + ...c.moduleMetadata + } + 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: c.runtimeEngine === 'native' ? '127.0.0.1' : 'db', + password: 'db', + published_port: c.nativePorts?.database ?? 0, + username: 'db' + }, + hostname: c.runtimeEngine === 'native' ? '127.0.0.1' : projectHost(c.name), + hostnames: [c.runtimeEngine === 'native' ? '127.0.0.1' : projectHost(c.name)], + httpURLs: [urlSet.http], + httpsURLs: c.runtimeEngine === 'native' ? [] : [urlSet.https], + urls: c.runtimeEngine === 'native' ? [urlSet.http] : [urlSet.http, urlSet.https], + php_version: c.php, + nodejs_version: c.node, + webserver_type: c.webserver, + router: c.runtimeEngine === 'native' ? 'native' : 'file', + router_status: currentRouterStatus, + certificate_status: c.runtimeEngine === 'native' ? 'missing' : await certificateStatus(c.name), + ca_trust_status: c.runtimeEngine === 'native' ? 'unknown' : await caTrustStatus(), + firefox_trust_status: c.runtimeEngine === 'native' ? 'unknown' : await firefoxTrustStatus(), + chromium_trust_status: c.runtimeEngine === 'native' ? 'unknown' : await chromiumTrustStatus(), + module_metadata: moduleMetadata, + adminer_url: + c.runtimeEngine === 'container' && c.modules.includes('adminer') + ? `https://adminer.${projectHost(c.name)}` + : undefined, + services, + xdebug_enabled: c.xdebug === true, + runtime_engine: c.runtimeEngine ?? 'container', + native_ports: c.nativePorts + } } -export async function updateEnvironment(root:string, updates:{phpVersion?:string;nodeVersion?:string;webserverType?:string;database?:string;xdebugEnabled?:boolean;primaryProtocol?:'http'|'https'}):Promise{ - const c=await readConfig(root) - if(updates.phpVersion)c.php=updates.phpVersion - if(updates.nodeVersion)c.node=updates.nodeVersion - if(updates.webserverType === 'nginx' || updates.webserverType === 'nginx-fpm') c.webserver='nginx' - if(updates.webserverType === 'apache' || updates.webserverType === 'apache-fpm') c.webserver='apache' - if(typeof updates.xdebugEnabled === 'boolean') c.xdebug=updates.xdebugEnabled - if(updates.database){const [kind,version]=updates.database.split(':'); if(kind==='mariadb'||kind==='mysql'||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 writeWebServerConfig(root,c); await writePhpDockerfile(root, c.xdebug === true) +export async function updateEnvironment( + root: string, + updates: { + phpVersion?: string + nodeVersion?: string + webserverType?: string + database?: string + xdebugEnabled?: boolean + primaryProtocol?: 'http' | 'https' + } +): Promise { + const c = await readConfig(root) + if (updates.phpVersion) c.php = updates.phpVersion + if (updates.nodeVersion) c.node = updates.nodeVersion + if (updates.webserverType === 'nginx' || updates.webserverType === 'nginx-fpm') + c.webserver = 'nginx' + if (updates.webserverType === 'apache' || updates.webserverType === 'apache-fpm') + c.webserver = 'apache' + if (typeof updates.xdebugEnabled === 'boolean') c.xdebug = updates.xdebugEnabled + if (updates.database) { + const [kind, version] = updates.database.split(':') + if (kind === 'mariadb' || kind === 'mysql' || 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 writeWebServerConfig(root, c) + await writePhpDockerfile(root, c.xdebug === true) } -export async function getProjectConfig(root: string): Promise { return readConfig(root) } +export async function getProjectConfig(root: string): Promise { + return readConfig(root) +} -export async function setProjectModuleMetadata(root: string, metadata: Record): Promise { +export async function setProjectModuleMetadata( + root: string, + metadata: Record +): Promise { const config = await readConfig(root) config.moduleMetadata = { ...config.moduleMetadata, ...metadata } await writeConfig(root, config) @@ -267,13 +590,27 @@ export async function setProjectModuleMetadata(root: string, metadata: Record { try { - const { stdout } = await execFileAsync('docker', ['inspect', '-f', '{{.State.Running}}', 'aurora-router'], { env: AURORA_ENV }) + 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 { 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' + if ( + recentLogs.includes('Error while building configuration') || + recentLogs.includes('field not found, node:') + ) + return 'provider-error' return 'running' - } catch { return 'stopped' } + } catch { + return 'stopped' + } } export async function routerRunning(): Promise { @@ -290,7 +627,12 @@ export async function listInstalledModules(name: string): Promise getModuleManifest(id))) - return manifests.map((module) => ({ id: module.id, name: module.name, version: module.version, category: module.category })) + return manifests.map((module) => ({ + id: module.id, + name: module.name, + version: module.version, + category: module.category + })) } export async function setModule( @@ -308,20 +650,30 @@ export async function setModule( 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.`) + 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])) + 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(', ')}.`) + 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' @@ -336,49 +688,155 @@ export async function scaffoldApplicationModule(_name: string, moduleId: string) if (module.category !== 'application') return } -export async function getProjectRoot(name:string):Promise{const r=await loadRegistry();if(!r.projects[name])throw new Error(`Project ${name} not found`);return r.projects[name]} +export async function getProjectRoot(name: string): Promise { + 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' | 'mysql' | 'postgres'; databaseVersion: string; name: string }> { +export async function getNativeProjectDefinition( + name: string +): Promise { + const root = await getProjectRoot(name) + const config = await readConfig(root) + return config.runtimeEngine === 'native' ? nativeDefinition(root, config) : null +} + +export async function getProjectConfigByRoot( + root: string +): Promise<{ database: 'mariadb' | 'mysql' | 'postgres'; databaseVersion: string; name: string }> { const config = await readConfig(root) return { database: config.database, databaseVersion: config.databaseVersion, name: config.name } } - - async function ensureCertificateAuthority(): Promise { 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 }) + 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 { 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 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 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' } + 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' } + 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')] + return [ + join(home, '.mozilla', 'firefox'), + join(home, 'snap', 'firefox', 'common', '.mozilla', 'firefox') + ] } async function pathExists(path: string): Promise { - try { await access(path); return true } catch { return false } + try { + await access(path) + return true + } catch { + return false + } } async function firefoxProfiles(): Promise { @@ -387,7 +845,9 @@ async function firefoxProfiles(): Promise { // 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) + 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) @@ -395,35 +855,54 @@ async function firefoxProfiles(): Promise { const profile = join(root, match[1].trim()) if (await pathExists(join(profile, 'cert9.db'))) profiles.push(profile) } - } catch { /* no profiles.ini */ } + } 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) + if (!profiles.includes(dir) && (await pathExists(join(dir, 'cert9.db')))) profiles.push(dir) } - } catch { /* Firefox root does not exist */ } + } catch { + /* Firefox root does not exist */ + } } return profiles } async function hasCertutil(): Promise { - try { await execFileAsync('certutil',['-L','-d','sql:/dev/null'],{env:AURORA_ENV,maxBuffer:1024*1024}); return true } catch (e:any) { + 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 { 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')) + 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 } + } catch { + return false + } } -export async function firefoxTrustStatus(): Promise<'trusted' | 'not-trusted' | 'unavailable' | 'unknown'> { +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() @@ -437,38 +916,87 @@ const chromiumNssDb = (): string => join(process.env.HOME || app.getPath('home') async function chromiumTrustTargetExists(): Promise { 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 */ } + 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'> { +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' + return (await nssHasTrustedAuroraCA(chromiumNssDb())) ? 'trusted' : 'not-trusted' } async function installIntoNssDb(db: string): Promise { 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}) + 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}`) + 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 { await ensureCertificateAuthority() - if (process.platform !== 'linux') throw new Error('Automatic CA trust is currently implemented for Linux only.') + 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 }) + 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}) + 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 @@ -480,8 +1008,10 @@ export async function trustAuroraCA(): Promise { 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.') + 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 { @@ -494,31 +1024,50 @@ async function writeRouterConfig(): Promise { await ensureProjectCertificate(config.name || name) const safe = safeName(config.name || name) const host = projectHost(config.name || name) - const rule = config.moduleMetadata?.routingWildcard === true - ? `Host(\`${host}\`) || HostRegexp(\`^[a-z0-9-]+\\.${host.replace(/\./g, '\\.')}$\`)` - : `Host(\`${host}\`)` + const rule = + config.moduleMetadata?.routingWildcard === true + ? `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: {}` + services.push( + ` aurora-${safe}:\n loadBalancer:\n servers:\n - url: http://aurora-${safe}-web:80` ) - 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('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`) + services.push( + ` aurora-${safe}-mailpit:\n loadBalancer:\n servers:\n - url: http://aurora-${safe}-mailpit:8025` + ) } - } catch { /* ignore stale registry entries */ } + } 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 */ } + 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 }) @@ -526,39 +1075,80 @@ async function writeRouterConfig(): Promise { } export async function ensureRouter(): Promise { - try { await execFileAsync('docker', ['network', 'inspect', 'aurora-router'], { env: AURORA_ENV }) } - catch { await execFileAsync('docker', ['network', 'create', 'aurora-router'], { env: AURORA_ENV }) } + 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 { 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', ['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 }) + 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 { 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 */ } - })) + await Promise.allSettled( + Object.values(registry.projects).map(async (root) => { + try { + const config = await readConfig(root) + if (config.runtimeEngine === 'native') { + await stopNativeProject(nativeDefinition(root, config)) + return + } + await access(composePath(root)) + await execFileAsync('docker', ['compose', '-f', composePath(root), 'down'], { + env: AURORA_ENV, + cwd: root + }) + } catch { + /* stale project or Docker unavailable */ + } + }) + ) } export interface RuntimeImageRefreshResult { @@ -600,11 +1190,10 @@ export async function refreshRuntimeImages(): Promise // base here; compiling in the background can collide with a user-started // project build. Docker will consume the refreshed base on the next // normal project build. - await execFileAsync( - 'docker', - ['pull', `php:${config.php}-fpm-alpine`], - { env: AURORA_ENV, maxBuffer: 32 * 1024 * 1024 } - ) + await execFileAsync('docker', ['pull', `php:${config.php}-fpm-alpine`], { + env: AURORA_ENV, + maxBuffer: 32 * 1024 * 1024 + }) refreshedProjects += 1 } catch (error) { failures.push(`${root}: ${error instanceof Error ? error.message : String(error)}`) diff --git a/src/main/ipc/projects.ts b/src/main/ipc/projects.ts index 8165cb6..0e10b88 100644 --- a/src/main/ipc/projects.ts +++ b/src/main/ipc/projects.ts @@ -1,39 +1,200 @@ import { ipcMain, type WebContents } from 'electron' 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 { runProjectLifecycleHooks } from '../moduleRuntime' import type { EnvironmentUpdate } from '../../shared/types' -const composeArgs=(root:string,...args:string[]):string[]=>['compose','-f',`${root}/.aurora/compose.yaml`,...args] -const allowedServices = new Set(['web','php','db','node','adminer','redis','mailpit']) -function launchTerminal(command:string,args:string[]):Promise{return new Promise((resolve,reject)=>{const child=spawn(command,args,{detached:true,stdio:'ignore'});child.once('error',reject);child.once('spawn',()=>{child.unref();resolve()})})} -async function startProject(operationId:string,name:string,sender:WebContents,forceRecreate=false):Promise{ - const root=await getProjectRoot(name); await updateEnvironment(root,{}); await ensureRouter() - 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 composeArgs = (root: string, ...args: string[]): string[] => [ + 'compose', + '-f', + `${root}/.aurora/compose.yaml`, + ...args +] +const allowedServices = new Set(['web', 'php', 'db', 'node', 'adminer', 'redis', 'mailpit']) +function launchTerminal(command: string, args: string[]): Promise { + 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{ - ipcMain.handle('projects:list',()=>listProjects()); ipcMain.handle('projects:describe',(_e,name:string)=>describeProject(name)) - 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 root=await getProjectRoot(name);return runCommandStreamed(id,'docker',composeArgs(root,'down'),e.sender,{cwd:root})}) - ipcMain.handle('projects:restart',(e,id:string,name:string)=>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 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'?[['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) } +async function startProject( + operationId: string, + name: string, + sender: WebContents, + forceRecreate = false +): Promise { + const root = await getProjectRoot(name) + await updateEnvironment(root, {}) + const native = await getNativeProjectDefinition(name) 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) { - // A malformed/missing compose file must never make a project undeletable. - console.warn(`Aurora cleanup for '${name}' skipped:`, error) + if (!sender.isDestroyed()) + sender.send('terminal:exit', { operationId, exitCode: 1, cancelled: false }) + throw 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) -}) +} +export function registerProjectsIpc(): void { + ipcMain.handle('projects:list', () => listProjects()) + ipcMain.handle('projects:describe', (_e, name: string) => describeProject(name)) + 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) + } + ) } diff --git a/src/main/moduleRuntime.ts b/src/main/moduleRuntime.ts index d566f46..b821883 100644 --- a/src/main/moduleRuntime.ts +++ b/src/main/moduleRuntime.ts @@ -6,77 +6,174 @@ import type { WebContents } from 'electron' import type { AuroraModuleLifecycleHook } from '../shared/types' import { getModuleManifest, moduleDirectory } from './moduleRegistry' 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' type Settings = Record type ExternalHook = (context: Record) => Promise | void type LoadedModule = Partial> -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 CORE_MODULE_IDS = new Set(['adminer', 'redis', 'mailpit']) 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 { const root = resolve(moduleDirectory(), moduleId) 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 } -export async function runModuleLifecycleHook(moduleId: string, hook: AuroraModuleLifecycleHook, options: HookOptions = {}): Promise { +export async function runModuleLifecycleHook( + moduleId: string, + hook: AuroraModuleLifecycleHook, + options: HookOptions = {} +): Promise { const manifest = await getModuleManifest(moduleId) if (!manifest.main) return false const handler = loadModule(moduleId, manifest.main)[hook] if (typeof handler !== 'function') return false const directory = options.directory - const urls = options.projectName ? projectUrls(options.projectName) : 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 => { 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` }) - return runCommandStreamed(options.operationId, command, args, options.sender, { cwd: directory ?? resolve(moduleDirectory(), moduleId), emitExit: false }) + if (!options.sender.isDestroyed()) + 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({ - moduleId, - hook, - directory, - projectName: options.projectName, - toolId: options.toolId, - settings: Object.freeze({ ...(options.settings ?? {}) }), - environment: projectConfig ? Object.freeze({ php: projectConfig.php, node: projectConfig.node, webserver: projectConfig.webserver, database: projectConfig.database, databaseVersion: projectConfig.databaseVersion, docroot: projectConfig.docroot }) : undefined, - urls: urls ? Object.freeze(urls) : 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) - } - })) + await handler( + Object.freeze({ + moduleId, + hook, + directory, + projectName: options.projectName, + toolId: options.toolId, + settings: Object.freeze({ ...(options.settings ?? {}) }), + environment: projectConfig + ? Object.freeze({ + php: projectConfig.php, + node: projectConfig.node, + webserver: projectConfig.webserver, + database: projectConfig.database, + databaseVersion: projectConfig.databaseVersion, + docroot: projectConfig.docroot, + runtimeEngine: projectConfig.runtimeEngine ?? 'container' + }) + : 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 } -export async function runProjectLifecycleHooks(directory: string, hook: 'projectStart' | 'projectRemove', operationId: string, sender: WebContents): Promise { +export async function runProjectLifecycleHooks( + directory: string, + hook: 'projectStart' | 'projectRemove', + operationId: string, + sender: WebContents +): Promise { const config = await getProjectConfig(directory) for (const moduleId of config.modules) { 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 { +export async function runModuleProjectCreate( + moduleId: string, + operationId: string, + directory: string, + projectName: string, + settings: Settings, + sender: WebContents +): Promise { try { - await runModuleLifecycleHook(moduleId, 'projectCreate', { directory, projectName, settings, operationId, sender }) + await runModuleLifecycleHook(moduleId, 'projectCreate', { + directory, + projectName, + settings, + operationId, + sender + }) sendExit(sender, operationId, 0) } catch (error) { 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 { +export async function runModuleProjectTool( + moduleId: string, + toolId: string, + operationId: string, + directory: string, + sender: WebContents +): Promise { 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) - 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 { - 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`) sendExit(sender, operationId, 0) } catch (error) { diff --git a/src/main/native/nativeProject.test.ts b/src/main/native/nativeProject.test.ts index 8f14122..5d31701 100644 --- a/src/main/native/nativeProject.test.ts +++ b/src/main/native/nativeProject.test.ts @@ -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 { renderMariaDbConfig, renderNginxConfig, renderPhpFpmConfig } from './nativeProject' +import { + renderMariaDbConfig, + renderNginxConfig, + renderPhpFpmConfig, + startNativeProject, + stopNativeProject +} from './nativeProject' +import { allocateNativePorts } from './portAllocator' const project = { name: 'demo', @@ -7,6 +20,7 @@ const project = { docroot: 'public', ports: { http: 41001, php: 41002, database: 41003, node: 41004 } } +const execFileAsync = promisify(execFile) describe('native project configuration', () => { it('isolates PHP-FPM on its allocated loopback port', () => @@ -25,3 +39,89 @@ describe('native project configuration', () => { 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'), ' { + 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) => Promise } + 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: 'smoke@aurora.local', + 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 +) diff --git a/src/main/native/nativeProject.ts b/src/main/native/nativeProject.ts index d22776f..040e3e6 100644 --- a/src/main/native/nativeProject.ts +++ b/src/main/native/nativeProject.ts @@ -14,6 +14,11 @@ export interface NativeProjectDefinition { root: string docroot: string ports: AuroraNativePorts + installedRuntimeRoot?: string +} + +function installedRoot(project: NativeProjectDefinition): string { + return project.installedRuntimeRoot ?? runtimeRoot() } function nativeDirectory(root: string): string { @@ -53,6 +58,11 @@ error_log "${quoteNginx(join(directory, 'logs', 'nginx.log'))}" info; events { worker_connections 256; } http { 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 { listen 127.0.0.1:${project.ports.http}; server_name localhost; @@ -65,9 +75,17 @@ http { fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param SCRIPT_NAME $fastcgi_script_name; fastcgi_param REQUEST_METHOD $request_method; + fastcgi_param REQUEST_URI $request_uri; fastcgi_param QUERY_STRING $query_string; fastcgi_param CONTENT_TYPE $content_type; 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 { 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 Promise.all([ writeFile(join(directory, 'config', 'php-fpm.conf'), renderPhpFpmConfig(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 { await access(join(directory, 'data', 'mariadb', 'mysql')) } catch { await execFileAsync( - join(runtimeRoot(), 'bin', 'mariadb-install-db'), + join(installedRoot(project), 'bin', 'mariadb-install-db'), [ '--no-defaults', `--datadir=${join(directory, 'data', 'mariadb')}`, @@ -129,19 +161,19 @@ export function nativeServiceSpecs(project: NativeProjectDefinition): NativeServ return [ { ...common('database'), - command: join(runtimeRoot(), 'bin', 'mariadbd'), + command: join(installedRoot(project), 'bin', 'mariadbd'), args: [`--defaults-file=${join(directory, 'config', 'mariadb.cnf')}`], ready: { port: project.ports.database, timeoutMs: 30000 } }, { ...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')], ready: { port: project.ports.php } }, { ...common('web'), - command: join(runtimeRoot(), 'bin', 'nginx'), + command: join(installedRoot(project), 'bin', 'nginx'), args: ['-c', join(directory, 'config', 'nginx.conf'), '-p', `${directory}/`], ready: { port: project.ports.http } } diff --git a/src/renderer/src/components/create/CreateProjectModal.tsx b/src/renderer/src/components/create/CreateProjectModal.tsx index c10e5a5..3eb2e27 100644 --- a/src/renderer/src/components/create/CreateProjectModal.tsx +++ b/src/renderer/src/components/create/CreateProjectModal.tsx @@ -51,7 +51,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React. const [redis, setRedis] = useState(false) const [mailpit, setMailpit] = useState(false) const [xdebug, setXdebug] = useState(false) - const [runtimeEngine] = useState<'container' | 'native'>('container') + const [runtimeEngine, setRuntimeEngine] = useState<'container' | 'native'>('container') const createProject = useCreateProject() const selectProject = useAppStore((s) => s.selectProject) @@ -60,13 +60,32 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React. const { data: runtimeStatus } = useRuntimeStatus() const applicationModules = moduleRegistry.filter((module) => module.category === 'application') 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 nameValid = trimmedName.length > 0 && isValidProjectName(trimmedName) const canContinue = directory !== null && nameValid && selectedModule !== undefined 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 { const picked = await window.api.create.pickDirectory() if (!picked) return @@ -82,7 +101,24 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React. const name = projectName.trim() setIsSubmitting(true) 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 { setIsSubmitting(false) return @@ -180,138 +216,285 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
- {step === 'site' ? ( - <> -
- - +
+ +
+ + setProjectName(e.target.value)} + placeholder="my-project" + className={fieldClass} + /> + {trimmedName.length > 0 && !nameValid && ( +

+ Use only letters, numbers, and hyphens — no spaces (e.g. " + {slugifyProjectName(trimmedName) || 'my-project'}"). +

)} - > - - - - - - {directory ?? 'Choose a folder…'} - - - This becomes the project root. - - - {directory && } - -
- -
- - setProjectName(e.target.value)} - placeholder="my-project" - className={fieldClass} - /> - {trimmedName.length > 0 && !nameValid && ( -

- Use only letters, numbers, and hyphens — no spaces (e.g. " - {slugifyProjectName(trimmedName) || 'my-project'}"). -

- )} -
- -
- -
- {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 ( - - ) - })} - {applicationModules.length === 0 &&

No application modules installed

Close this window and use Modules at the bottom of the sidebar to install one.

}
-
-
-
-

Development stack

-

Aurora core owns the runtime; the application is a module layered on top.

-
-
-
Container engine

Current compatible engine · {runtimeStatus?.container.available ? runtimeStatus.container.provider : 'not detected'}

-
Aurora Native

{runtimeStatus?.native.available ? `Runtime ${runtimeStatus.native.runtimeVersion} detected · provisioning checks pending` : 'Runtime bundle not installed yet'}

-
-
-
-
-
-
-
- {[['Adminer',adminer,setAdminer],['Redis',redis,setRedis],['Mailpit',mailpit,setMailpit],['Xdebug',xdebug,setXdebug]].map(([label,value,setter])=>)} +
+ +
+ {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 ( + + ) + })} + {applicationModules.length === 0 && ( +
+

No application modules installed

+

+ Close this window and use Modules at the bottom of the + sidebar to install one. +

+
+ )}
-
-
- - setDocroot(e.target.value)} - placeholder="e.g. web, public — leave blank for project root" - className={fieldClass} - /> -
- - ) : selectedModule ? ( - - ) : ( - - )} +
+
+

Development stack

+

+ Aurora core owns the runtime; the application is a module layered on top. +

+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {[ + ['Adminer', adminer, setAdminer], + ['Redis', redis, setRedis], + ['Mailpit', mailpit, setMailpit], + ['Xdebug', xdebug, setXdebug] + ].map(([label, value, setter]) => ( + + ))} +
+
+
+ +
+ + setDocroot(e.target.value)} + placeholder="e.g. web, public — leave blank for project root" + className={fieldClass} + /> +
+ + ) : selectedModule ? ( + + ) : ( + + )}