import { app } from 'electron' import { execFile } from 'child_process' import { promisify } from 'util' import { mkdir, readFile, writeFile, access, rm, readdir } from 'fs/promises' import { join } from 'path' import type { AuroraProjectDetail, AuroraProjectSummary, AuroraInstalledModule, AuroraModuleManifest, AuroraStackOptions, AuroraRuntimeEngine } from '../shared/types' import type { AuroraNativePorts } from './native/portAllocator' 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'] export const AURORA_ENV = { ...process.env, PATH: [...EXTRA_PATH_DIRS, process.env.PATH].join(':') } export type AuroraConfig = { name: string type: string docroot: string php: string node: string webserver: 'nginx' | 'apache' database: 'mariadb' | 'mysql' | 'postgres' databaseVersion: string modules: string[] moduleSettings?: Record> primaryProtocol?: 'http' | 'https' wordpressMultisite?: 'none' | 'subdirectory' | 'subdomain' moduleMetadata?: Record xdebug?: boolean runtimeEngine?: AuroraRuntimeEngine nativePorts?: AuroraNativePorts } type Registry = { projects: Record } const configDir = (root: string): string => join(root, '.aurora') const configPath = (root: string): string => join(configDir(root), 'config.json') const composePath = (root: string): string => join(configDir(root), 'compose.yaml') const phpDockerfilePath = (root: string): string => join(configDir(root), 'Dockerfile.php') const apacheConfigPath = (root: string): string => join(configDir(root), 'httpd.conf') 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') const certDir = (): string => join(app.getPath('userData'), 'certificates') const caDir = (): string => join(certDir(), 'ca') const projectsCertDir = (): string => join(certDir(), 'projects') const caKeyPath = (): string => join(caDir(), 'aurora-root-ca.key') const caCertPath = (): string => join(caDir(), 'aurora-root-ca.crt') const projectCertPath = (name: string): string => join(projectsCertDir(), `${safeName(name)}.crt`) const projectKeyPath = (name: string): string => join(projectsCertDir(), `${safeName(name)}.key`) const safeName = (name: string): string => name .toLowerCase() .replace(/[^a-z0-9_-]+/g, '-') .replace(/^-+|-+$/g, '') || 'project' const projectHost = (name: string): string => `${safeName(name)}.aurora.localhost` export const projectUrls = (name: string): { http: 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: {} } } } async function saveRegistry(registry: Registry): Promise { await mkdir(app.getPath('userData'), { recursive: true }) await writeFile(registryPath(), JSON.stringify(registry, null, 2)) } async function readConfig(root: string): Promise { return JSON.parse(await readFile(configPath(root), 'utf8')) as AuroraConfig } async function writeConfig(root: string, config: AuroraConfig): Promise { await mkdir(configDir(root), { recursive: true }) await writeFile(configPath(root), JSON.stringify(config, null, 2)) 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), ` '${driver}', 'server' => 'db', 'username' => 'db', 'password' => 'db', 'db' => 'db', 'permanent' => '1', ]; } return new class { public function credentials() { return ['db', 'db', 'db']; } public function database() { return 'db'; } public function login($login, $password) { return true; } }; ` ) } function indent(lines: string, spaces = 4): string { const pad = ' '.repeat(spaces) return lines .split('\n') .map((line) => (line ? pad + line : line)) .join('\n') } async function renderModuleService( module: AuroraModuleManifest, config: AuroraConfig ): Promise { if (!module.compose) return '' const { service, image, ports = [], dependsOn = [] } = module.compose const settings = config.moduleSettings?.[module.id] ?? {} const lines = [`${service}:`, ` image: ${image}`] if (module.id === 'redis' && settings.persistence !== false) { lines.push(' volumes:', ' - redis_data:/data') } if (ports.length) { lines.push(' ports:') for (const port of ports) lines.push(` - "127.0.0.1::${port}"`) } if (dependsOn.length) lines.push(' depends_on:', ...dependsOn.map((dep) => ` - ${dep}`)) if (module.id === 'mailpit') lines.push( ' networks:', ' default:', ' aurora-router:', ' aliases:', ` - aurora-${safeName(config.name)}-mailpit` ) return indent(lines.join('\n'), 2) } async function renderCompose(c: AuroraConfig): Promise { 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] } } } 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` : '' 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' 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 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 { default off; https on; } server { listen 80; server_name _; root ${webroot}; index index.php index.html; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param HTTPS $aurora_https; fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto; fastcgi_param HTTP_X_FORWARDED_HOST $http_x_forwarded_host; fastcgi_pass php:9000; } } ` ) } 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" Listen 80 LoadModule mpm_event_module modules/mod_mpm_event.so LoadModule authn_core_module modules/mod_authn_core.so LoadModule authz_core_module modules/mod_authz_core.so LoadModule dir_module modules/mod_dir.so LoadModule mime_module modules/mod_mime.so LoadModule proxy_module modules/mod_proxy.so LoadModule proxy_fcgi_module modules/mod_proxy_fcgi.so LoadModule rewrite_module modules/mod_rewrite.so LoadModule unixd_module modules/mod_unixd.so LoadModule log_config_module modules/mod_log_config.so LoadModule setenvif_module modules/mod_setenvif.so User daemon Group daemon ServerName localhost DocumentRoot "${webroot}" DirectoryIndex index.php index.html SetEnvIf X-Forwarded-Proto "^https$" HTTPS=on SetEnvIf X-Forwarded-Port "^443$" SERVER_PORT=443 Options Indexes FollowSymLinks AllowOverride All Require all granted RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^ index.php [QSA,L] 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 { if (config.webserver === 'apache') await writeApache(root, config.docroot) else await writeNginx(root, config.docroot) } 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`) const normalizedType = application.id const defaultDocroot = docroot || application.defaults?.docroot || '' const modules = [normalizedType] if (stack?.adminer !== false) modules.push('adminer') 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}`) const runtimeEngine = stack?.runtimeEngine ?? 'container' 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) 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 [] } } export async function listProjects(): Promise { 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 = 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 } 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 = 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 getProjectConfig(root: string): Promise { return readConfig(root) } export async function setProjectModuleMetadata( root: string, metadata: Record ): Promise { const config = await readConfig(root) config.moduleMetadata = { ...config.moduleMetadata, ...metadata } await writeConfig(root, config) } export async function routerStatus(): Promise<'running' | 'provider-error' | 'stopped'> { try { const { stdout } = await execFileAsync( 'docker', ['inspect', '-f', '{{.State.Running}}', 'aurora-router'], { env: AURORA_ENV } ) if (stdout.trim() !== 'true') return 'stopped' const { stdout: logs = '', stderr: logErrors = '' } = await execFileAsync( 'docker', ['logs', '--tail', '40', 'aurora-router'], { env: AURORA_ENV, maxBuffer: 2 * 1024 * 1024 } ) const recentLogs = `${logs}\n${logErrors}` if ( recentLogs.includes('Error while building configuration') || recentLogs.includes('field not found, node:') ) return 'provider-error' return 'running' } catch { return 'stopped' } } export async function routerRunning(): Promise { return (await routerStatus()) === 'running' } export async function listModules(): Promise { return getModuleRegistry() } export async function listInstalledModules(name: string): Promise { const reg = await loadRegistry() const root = reg.projects[name] if (!root) throw new Error(`Aurora project '${name}' not found`) const config = await readConfig(root) const manifests = await Promise.all(config.modules.map((id) => getModuleManifest(id))) return manifests.map((module) => ({ id: module.id, name: module.name, version: module.version, category: module.category })) } export async function setModule( name: string, moduleId: string, install: boolean, settings: Record = {} ): Promise { const reg = await loadRegistry() const root = reg.projects[name] if (!root) throw new Error(`Aurora project '${name}' not found`) const config = await readConfig(root) const module = await getModuleManifest(moduleId) config.moduleSettings ??= {} if (install) { const conflict = module.conflicts.find((id) => config.modules.includes(id)) if (conflict) throw new Error( `${module.name} conflicts with the installed '${conflict}' application module.` ) for (const dependency of module.dependencies) { if (!config.modules.includes(dependency)) config.modules.push(dependency) } if (!config.modules.includes(moduleId)) config.modules.push(moduleId) config.moduleSettings[moduleId] = Object.fromEntries( module.settings.map((item) => [item.id, item.default]) ) Object.assign(config.moduleSettings[moduleId], settings) if (module.category === 'application') { config.type = moduleId if (module.defaults?.docroot !== undefined) config.docroot = module.defaults.docroot } } else { const dependents = (await getModuleRegistry()).filter( (item) => item.dependencies.includes(moduleId) && config.modules.includes(item.id) ) if (dependents.length) throw new Error( `Cannot remove ${module.name}; required by ${dependents.map((item) => item.name).join(', ')}.` ) config.modules = config.modules.filter((id) => id !== moduleId) delete config.moduleSettings[moduleId] if (config.type === moduleId) config.type = 'generic' } await writeConfig(root, config) await writeWebServerConfig(root, config) await writePhpDockerfile(root, config.xdebug === true) } export async function scaffoldApplicationModule(_name: string, moduleId: string): Promise { const module = await getModuleManifest(moduleId) 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 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 } ) } 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 host = projectHost(name) const csr = join(projectsCertDir(), `${safeName(name)}.csr`) await execFileAsync( 'openssl', [ 'req', '-new', '-newkey', 'rsa:2048', '-nodes', '-keyout', key, '-out', csr, '-subj', `/CN=${host}`, '-addext', `subjectAltName=DNS:${host},DNS:*.${host}` ], { env: AURORA_ENV, maxBuffer: 8 * 1024 * 1024 } ) await execFileAsync( 'openssl', [ 'x509', '-req', '-in', csr, '-CA', caCertPath(), '-CAkey', caKeyPath(), '-CAcreateserial', '-out', cert, '-days', '825', '-sha256', '-copy_extensions', 'copy' ], { env: AURORA_ENV, maxBuffer: 8 * 1024 * 1024 } ) await rm(csr, { force: true }) } export async function certificateStatus(name: string): Promise<'generated' | 'missing'> { try { await access(projectCertPath(name)) await access(projectKeyPath(name)) return 'generated' } catch { return 'missing' } } export async function caTrustStatus(): Promise<'trusted' | 'not-trusted' | 'unknown'> { if (process.platform !== 'linux') return 'unknown' try { await access('/usr/local/share/ca-certificates/aurora-dockside-local-ca.crt') return 'trusted' } catch { return 'not-trusted' } } const firefoxRoots = (): string[] => { const home = process.env.HOME || app.getPath('home') return [ join(home, '.mozilla', 'firefox'), join(home, 'snap', 'firefox', 'common', '.mozilla', 'firefox') ] } async function pathExists(path: string): Promise { try { await access(path) return true } catch { return false } } async function firefoxProfiles(): Promise { const profiles: string[] = [] for (const root of firefoxRoots()) { // Prefer the active/default profile declared by Firefox itself. try { const ini = await readFile(join(root, 'profiles.ini'), 'utf8') const sections = ini .split(/^\s*\[/m) .map((section, index) => (index === 0 ? section : '[' + section)) for (const section of sections) { if (!/^\[Profile\d+\]/m.test(section) || !/^Default=1\s*$/m.test(section)) continue const match = section.match(/^Path=(.+)\s*$/m) if (!match) continue const profile = join(root, match[1].trim()) if (await pathExists(join(profile, 'cert9.db'))) profiles.push(profile) } } catch { /* no profiles.ini */ } // Fall back to every NSS profile, but never add duplicates. try { for (const entry of await readdir(root, { withFileTypes: true })) { if (!entry.isDirectory()) continue const dir = join(root, entry.name) if (!profiles.includes(dir) && (await pathExists(join(dir, 'cert9.db')))) profiles.push(dir) } } catch { /* Firefox root does not exist */ } } return profiles } async function hasCertutil(): Promise { 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')) return Boolean(line && /\bC,,\s*$/.test(line.trim())) } catch { return false } } export async function firefoxTrustStatus(): Promise< 'trusted' | 'not-trusted' | 'unavailable' | 'unknown' > { if (process.platform !== 'linux') return 'unknown' if (!(await hasCertutil())) return 'unavailable' const profiles = await firefoxProfiles() if (!profiles.length) return 'unknown' for (const profile of profiles) if (!(await nssHasTrustedAuroraCA(profile))) return 'not-trusted' return 'trusted' } const chromiumNssDb = (): string => join(process.env.HOME || app.getPath('home'), '.pki', 'nssdb') async function chromiumTrustTargetExists(): Promise { const db = chromiumNssDb() if (await pathExists(join(db, 'cert9.db'))) return true for (const command of [ 'google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'brave-browser', 'microsoft-edge', 'vivaldi' ]) { try { await execFileAsync('which', [command], { env: AURORA_ENV, maxBuffer: 1024 * 1024 }) return true } catch { /* try next */ } } return false } export async function chromiumTrustStatus(): Promise< 'trusted' | 'not-trusted' | 'unavailable' | 'unknown' > { if (process.platform !== 'linux') return 'unknown' if (!(await chromiumTrustTargetExists())) return 'unknown' if (!(await hasCertutil())) return 'unavailable' return (await nssHasTrustedAuroraCA(chromiumNssDb())) ? 'trusted' : 'not-trusted' } async function installIntoNssDb(db: string): Promise { await mkdir(db, { recursive: true }) if (!(await pathExists(join(db, 'cert9.db')))) { await execFileAsync('certutil', ['-N', '--empty-password', '-d', `sql:${db}`], { env: AURORA_ENV, maxBuffer: 1024 * 1024 }) } try { await execFileAsync( 'certutil', ['-D', '-d', `sql:${db}`, '-n', 'Aurora Dockside Local Development CA'], { env: AURORA_ENV, maxBuffer: 1024 * 1024 } ) } catch { /* absent is fine */ } await execFileAsync( 'certutil', [ '-A', '-d', `sql:${db}`, '-n', 'Aurora Dockside Local Development CA', '-t', 'C,,', '-i', caCertPath() ], { env: AURORA_ENV, maxBuffer: 1024 * 1024 } ) if (!(await nssHasTrustedAuroraCA(db))) throw new Error(`Aurora CA import verification failed for NSS database: ${db}`) } export async function trustAuroraCA(): Promise { await ensureCertificateAuthority() if (process.platform !== 'linux') throw new Error('Automatic CA trust is currently implemented for Linux only.') const script = `install -m 0644 "${caCertPath().replace(/"/g, '\\"')}" /usr/local/share/ca-certificates/aurora-dockside-local-ca.crt && update-ca-certificates` await execFileAsync('pkexec', ['sh', '-c', script], { env: AURORA_ENV, maxBuffer: 8 * 1024 * 1024 }) if (!(await hasCertutil())) { await execFileAsync( 'pkexec', ['sh', '-c', 'apt-get update && apt-get install -y libnss3-tools'], { env: AURORA_ENV, maxBuffer: 32 * 1024 * 1024 } ) } // These are the exact stores verified on Ubuntu: Snap/native Firefox profiles // and the shared Chromium NSS database at ~/.pki/nssdb. const profiles = await firefoxProfiles() for (const profile of profiles) await installIntoNssDb(profile) if (await chromiumTrustTargetExists()) await installIntoNssDb(chromiumNssDb()) const firefox = await firefoxTrustStatus() const chromium = await chromiumTrustStatus() if (profiles.length && firefox !== 'trusted') throw new Error('Aurora CA could not be verified in the active Firefox NSS trust store.') if ((await chromiumTrustTargetExists()) && chromium !== 'trusted') throw new Error('Aurora CA could not be verified in ~/.pki/nssdb for Chromium-family browsers.') } async function writeRouterConfig(): Promise { const registry = await loadRegistry() const routers: string[] = [] const services: string[] = [] for (const [name, root] of Object.entries(registry.projects)) { try { const config = await readConfig(root) await ensureProjectCertificate(config.name || name) const safe = safeName(config.name || name) const host = projectHost(config.name || name) const rule = config.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: {}` ) if (config.modules.includes('adminer')) services.push( ` aurora-${safe}-adminer:\n loadBalancer:\n servers:\n - url: http://aurora-${safe}-adminer:8080` ) if (config.modules.includes('mailpit')) { routers.push( ` aurora-${safe}-mailpit-http:\n rule: 'Host(\`mail.${host}\`)'\n entryPoints: [web]\n service: aurora-${safe}-mailpit`, ` aurora-${safe}-mailpit-https:\n rule: 'Host(\`mail.${host}\`)'\n entryPoints: [websecure]\n service: aurora-${safe}-mailpit\n tls: {}` ) services.push( ` aurora-${safe}-mailpit:\n loadBalancer:\n servers:\n - url: http://aurora-${safe}-mailpit:8025` ) } } catch { /* ignore stale registry entries */ } } const tlsCerts: string[] = [] for (const [name, root] of Object.entries(registry.projects)) { try { const config = await readConfig(root) const safe = safeName(config.name || name) tlsCerts.push( ` - certFile: /etc/traefik/certs/${safe}.crt\n keyFile: /etc/traefik/certs/${safe}.key` ) } catch { /* stale */ } } const dynamic = `http:\n routers:\n${routers.length ? routers.join('\n') : ' {}'}\n services:\n${services.length ? services.join('\n') : ' {}'}\n${tlsCerts.length ? `tls:\n certificates:\n${tlsCerts.join('\n')}\n` : ''}` await mkdir(routerDir(), { recursive: true }) await writeFile(routerConfigPath(), dynamic) } export async function ensureRouter(): Promise { try { await execFileAsync('docker', ['network', 'inspect', 'aurora-router'], { env: AURORA_ENV }) } catch { await execFileAsync('docker', ['network', 'create', 'aurora-router'], { env: AURORA_ENV }) } await writeRouterConfig() try { const { stdout } = await execFileAsync( 'docker', ['inspect', '-f', '{{.State.Running}} {{json .Config.Cmd}}', 'aurora-router'], { env: AURORA_ENV } ) const fileProvider = stdout.includes('--providers.file.directory=/etc/traefik/dynamic') if (stdout.trimStart().startsWith('true') && fileProvider) return await execFileAsync('docker', ['rm', '-f', 'aurora-router'], { env: AURORA_ENV }).catch( () => undefined ) } catch { /* router does not exist yet */ } await execFileAsync( 'docker', [ 'run', '-d', '--name', 'aurora-router', '--restart', 'unless-stopped', '--network', 'aurora-router', '-p', '127.0.0.1:80:80', '-p', '127.0.0.1:443:443', '-v', `${routerDir()}:/etc/traefik/dynamic:ro`, '-v', `${projectsCertDir()}:/etc/traefik/certs:ro`, 'traefik:v3.5', '--providers.file.directory=/etc/traefik/dynamic', '--providers.file.watch=true', '--entrypoints.web.address=:80', '--entrypoints.websecure.address=:443', '--api.dashboard=false', '--log.level=INFO' ], { env: AURORA_ENV, maxBuffer: 8 * 1024 * 1024 } ) } export async function powerOffProjects(): Promise { const registry = await loadRegistry() 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 { checkedProjects: number refreshedProjects: number failures: string[] } export async function refreshRuntimeImages(): Promise { const registry = await loadRegistry() const roots = [...new Set(Object.values(registry.projects))] const failures: string[] = [] let refreshedProjects = 0 // Refresh the shared router image without replacing a currently running // router. The new revision is used the next time it is recreated. try { await execFileAsync('docker', ['pull', 'traefik:v3.5'], { env: AURORA_ENV, maxBuffer: 16 * 1024 * 1024 }) } catch (error) { failures.push(`router: ${error instanceof Error ? error.message : String(error)}`) } for (const root of roots) { try { await access(composePath(root)) const config = await readConfig(root) // Pulls Node, nginx/Apache, MySQL/MariaDB/PostgreSQL, Adminer, Redis, // Mailpit, and module-provided images. Running containers are not // restarted, so an update cannot interrupt current work. await execFileAsync( 'docker', ['compose', '-f', composePath(root), 'pull', '--ignore-buildable', '--policy', 'always'], { cwd: root, env: AURORA_ENV, maxBuffer: 32 * 1024 * 1024 } ) // PHP is built locally by Aurora. Refresh only its selected upstream // 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 }) refreshedProjects += 1 } catch (error) { failures.push(`${root}: ${error instanceof Error ? error.message : String(error)}`) } } return { checkedProjects: roots.length, refreshedProjects, failures } }