2026-08-13 14:32:54 -05:00
import { app } from 'electron'
import { execFile } from 'child_process'
import { promisify } from 'util'
import { mkdir , readFile , writeFile , access , rm , readdir } from 'fs/promises'
import { join } from 'path'
import type { AuroraProjectDetail , AuroraProjectSummary , AuroraInstalledModule , AuroraModuleManifest , AuroraStackOptions } from '../shared/types'
2026-08-14 12:08:21 -05:00
import { CORE_VERSION , MODULE_API_VERSION , getModuleManifest , getModuleRegistry } from './moduleRegistry'
2026-08-13 14:32:54 -05:00
const execFileAsync = promisify ( execFile )
const EXTRA_PATH_DIRS = [ '/opt/homebrew/bin' , '/usr/local/bin' , '/opt/local/bin' ]
export const AURORA_ENV = { ... process . env , PATH : [... EXTRA_PATH_DIRS , process . env . PATH ]. join ( ':' ) }
type AuroraConfig = {
name : string
type : string
docroot : string
php : string
node : string
2026-08-14 13:38:50 -05:00
webserver : 'nginx' | 'apache'
2026-08-14 13:47:25 -05:00
database : 'mariadb' | 'mysql' | 'postgres'
2026-08-13 14:32:54 -05:00
databaseVersion : string
modules : string []
moduleSettings? : Record < string , Record < string , string | number | boolean >>
primaryProtocol ?: 'http' | 'https'
wordpressMultisite ?: 'none' | 'subdirectory' | 'subdomain'
2026-08-14 12:08:21 -05:00
moduleMetadata? : Record < string , string | number | boolean >
2026-08-13 14:32:54 -05:00
xdebug? : boolean
}
type Registry = { projects : Record < string , string > }
const configDir = ( root : string ) : string => join ( root , '.aurora' )
const configPath = ( root : string ) : string => join ( configDir ( root ), 'config.json' )
const composePath = ( root : string ) : string => join ( configDir ( root ), 'compose.yaml' )
const phpDockerfilePath = ( root : string ) : string => join ( configDir ( root ), 'Dockerfile.php' )
2026-08-14 13:38:50 -05:00
const apacheConfigPath = ( root : string ) : string => join ( configDir ( root ), 'httpd.conf' )
2026-08-14 13:26:14 -05:00
const adminerAutoLoginPath = ( root : string ) : string => join ( configDir ( root ), 'adminer-auto-login.php' )
2026-08-13 14:32:54 -05:00
const registryPath = () : string => join ( app . getPath ( 'userData' ), 'projects.json' )
const routerDir = () : string => join ( app . getPath ( 'userData' ), 'router' )
const routerConfigPath = () : string => join ( routerDir (), 'dynamic.yaml' )
const certDir = () : string => join ( app . getPath ( 'userData' ), 'certificates' )
const caDir = () : string => join ( certDir (), 'ca' )
const projectsCertDir = () : string => join ( certDir (), 'projects' )
const caKeyPath = () : string => join ( caDir (), 'aurora-root-ca.key' )
const caCertPath = () : string => join ( caDir (), 'aurora-root-ca.crt' )
const projectCertPath = ( name : string ) : string => join ( projectsCertDir (), ` ${ safeName ( name ) } .crt` )
const projectKeyPath = ( name : string ) : string => join ( projectsCertDir (), ` ${ safeName ( name ) } .key` )
const safeName = ( name : string ) : string => name . toLowerCase (). replace ( /[^a-z0-9_-]+/g , '-' ). replace ( /^-+|-+$/g , '' ) || 'project'
const projectHost = ( name : string ) : string => ` ${ safeName ( name ) } .aurora.localhost`
export const projectUrls = ( name : string ) => ({ http : `http:// ${ projectHost ( name ) } ` , https : `https:// ${ projectHost ( name ) } ` })
async function loadRegistry () : Promise < Registry > {
try { return JSON . parse ( await readFile ( registryPath (), 'utf8' )) as Registry } catch { return { projects : {} } }
}
async function saveRegistry ( registry : Registry ) : Promise < void > {
await mkdir ( app . getPath ( 'userData' ), { recursive : true })
await writeFile ( registryPath (), JSON . stringify ( registry , null , 2 ))
}
async function readConfig ( root : string ) : Promise < AuroraConfig > {
return JSON . parse ( await readFile ( configPath ( root ), 'utf8' )) as AuroraConfig
}
async function writeConfig ( root : string , config : AuroraConfig ) : Promise < void > {
await mkdir ( configDir ( root ), { recursive : true })
await writeFile ( configPath ( root ), JSON . stringify ( config , null , 2 ))
2026-08-14 13:26:14 -05:00
await writeAdminerAutoLogin ( root , config )
2026-08-13 14:32:54 -05:00
await writeFile ( composePath ( root ), await renderCompose ( config ))
}
2026-08-14 13:26:14 -05:00
async function writeAdminerAutoLogin ( root : string , config : AuroraConfig ) : Promise < void > {
const driver = config . database === 'postgres' ? 'pgsql' : 'server'
await writeFile ( adminerAutoLoginPath ( root ), `<?php
// Generated by Aurora Core. Adminer is exposed only through the project's
// localhost route; credentials stay in this server-side container bootstrap.
if (empty($_GET['username']) && empty($_POST['auth'])) {
$_POST['auth'] = [
'driver' => ' ${ 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; }
};
` )
}
2026-08-13 14:32:54 -05:00
function indent ( lines : string , spaces = 4 ) : string {
const pad = ' ' . repeat ( spaces )
return lines . split ( '\n' ). map (( line ) => line ? pad + line : line ). join ( '\n' )
}
async function renderModuleService ( module : AuroraModuleManifest , config : AuroraConfig ) : Promise < string > {
if ( ! module . compose ) return ''
const { service , image , ports = [], dependsOn = [] } = module . compose
const settings = config . moduleSettings ? .[ module . id ] ?? {}
const lines = [ ` ${ service } :` , ` image: ${ image } ` ]
if ( module . id === 'redis' && settings . persistence !== false ) {
lines . push ( ' volumes:' , ' - redis_data:/data' )
}
if ( ports . length ) {
lines . push ( ' ports:' )
for ( const port of ports ) lines . push ( ` - "127.0.0.1:: ${ port } "` )
}
if ( dependsOn . length ) lines . push ( ' depends_on:' , ... dependsOn . map (( dep ) => ` - ${ dep } ` ))
if ( module . id === 'mailpit' ) lines . push ( ' networks:' , ' default:' , ' aurora-router:' , ' aliases:' , ` - aurora- ${ safeName ( config . name ) } -mailpit` )
return indent ( lines . join ( '\n' ), 2 )
}
async function renderCompose ( c : AuroraConfig ) : Promise < string > {
const db = c . database === 'postgres'
? ` db: \ n image: postgres: ${ c . databaseVersion } \ n environment: \ n POSTGRES_DB: db \ n POSTGRES_USER: db \ n POSTGRES_PASSWORD: db \ n volumes: \ n - db_data:/var/lib/postgresql/data \ n healthcheck: \ n test: [ \ "CMD-SHELL \ ", \ "pg_isready -U db -d db \ "] \ n interval: 3s \ n timeout: 3s \ n retries: 20`
2026-08-14 13:47:25 -05:00
: 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`
2026-08-14 12:08:21 -05:00
const coreServices : Record < string , AuroraModuleManifest > = {
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 )))
2026-08-13 14:32:54 -05:00
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:' )
2026-08-14 13:26:14 -05:00
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` : ''
2026-08-14 13:38:50 -05:00
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`
2026-08-13 14:32:54 -05:00
}
async function writePhpDockerfile ( root : string , xdebug = false ) : Promise < void > {
await writeFile ( phpDockerfilePath ( root ), `ARG PHP_VERSION=8.4
FROM php: ${ '${PHP_VERSION}' } -fpm-alpine
RUN apk add --no-cache icu-dev libzip-dev libpng-dev libjpeg-turbo-dev freetype-dev oniguruma-dev postgresql-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
2026-08-14 15:54:42 -05:00
&& docker-php-ext-install -j2 mysqli pdo_mysql pdo_pgsql intl zip gd mbstring
2026-08-13 14:32:54 -05:00
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
${ xdebug ? 'RUN apk add --no-cache $PHPIZE_DEPS linux-headers && pecl install xdebug && docker-php-ext-enable xdebug' : '' }
` )
}
async function writeNginx ( root : string , docroot : string ) : Promise < void > {
const webroot = docroot ? `/var/www/html/ ${ docroot } ` : '/var/www/html'
await writeFile ( join ( configDir ( root ), 'nginx.conf' ), `map $http_x_forwarded_proto $aurora_https {
default off;
https on;
}
server {
listen 80;
server_name _;
root ${ webroot } ;
index index.php index.html;
location / { try_files $uri $uri/ /index.php?$query_string; }
location ~ \ .php$ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param HTTPS $aurora_https; fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto; fastcgi_param HTTP_X_FORWARDED_HOST $http_x_forwarded_host; fastcgi_pass php:9000; }
}
` )
}
2026-08-14 13:38:50 -05:00
async function writeApache ( root : string , docroot : string ) : Promise < void > {
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
User daemon
Group daemon
ServerName localhost
DocumentRoot " ${ webroot } "
DirectoryIndex index.php index.html
<Directory " ${ webroot } ">
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]
</Directory>
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 < void > {
if ( config . webserver === 'apache' ) await writeApache ( root , config . docroot )
else await writeNginx ( root , config . docroot )
}
2026-08-13 14:32:54 -05:00
export async function createProject ( root : string , name : string , type : string , docroot : string , stack? : Partial < AuroraStackOptions >) : Promise < void > {
await mkdir ( root , { recursive : true })
2026-08-14 12:08:21 -05:00
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 ]
2026-08-13 14:32:54 -05:00
if ( stack ? . adminer !== false ) modules . push ( 'adminer' )
if ( stack ? . redis ) modules . push ( 'redis' )
if ( stack ? . mailpit ) modules . push ( 'mailpit' )
2026-08-14 13:38:50 -05:00
const config : AuroraConfig = { name , type : normalizedType , docroot : defaultDocroot , php : stack?.phpVersion || '8.4' , 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 }
await writeConfig ( root , config ); await writeWebServerConfig ( root , config ); await writePhpDockerfile ( root , config . xdebug )
2026-08-13 14:32:54 -05:00
const reg = await loadRegistry (); reg . projects [ name ] = root ; await saveRegistry ( reg )
}
export async function unregisterProject ( name : string , deleteFiles : boolean ) : Promise < void > {
const reg = await loadRegistry (); const root = reg . projects [ name ]; delete reg . projects [ name ]; await saveRegistry ( reg )
if ( deleteFiles && root ) await rm ( root , { recursive : true , force : true })
}
async function composeJson ( root : string ) : Promise < any [] > {
try { const { stdout } = await execFileAsync ( 'docker' , [ 'compose' , '-f' , composePath ( root ), 'ps' , '--format' , 'json' ], { env : AURORA_ENV , maxBuffer : 8 * 1024 * 1024 }); return stdout . trim (). split ( '\n' ). filter ( Boolean ). map ( x => JSON . parse ( x )) } catch { return [] }
}
export async function listProjects () : Promise < AuroraProjectSummary [] > {
2026-08-14 12:08:21 -05:00
const reg = await loadRegistry (); const out : AuroraProjectSummary [] = []; const availableModules = new Set (( await getModuleRegistry ()). map (( module ) => module . id ))
2026-08-13 14:32:54 -05:00
for ( const [ name , root ] of Object . entries ( reg . projects )) {
try { await access ( configPath ( root )); const c = await readConfig ( root ); const ps = await composeJson ( root ); const running = ps . some ( p => p . State === 'running' ); const urls = projectUrls ( c . name ); const primary = c . primaryProtocol === 'http' ? urls.http : urls.https
2026-08-14 12:08:21 -05:00
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 })
2026-08-13 14:32:54 -05:00
} catch { /* stale registry entry */ }
} return out
}
export async function describeProject ( name : string ) : Promise < AuroraProjectDetail > {
const reg = await loadRegistry (); const root = reg . projects [ name ]; if ( ! root ) throw new Error ( `Aurora project ' ${ name } ' not found` )
const c = await readConfig ( root ); const ps = await composeJson ( root ); const running = ps . some ( p => p . State === 'running' ); const currentRouterStatus = await routerStatus (); const urlSet = projectUrls ( c . name ); const primary = c . primaryProtocol === 'http' ? urlSet.http : urlSet.https
const services : Record < string , any > = {}; for ( const p of ps ) services [ p . Service ] = { short_name :p.Service , full_name :p.Name , status :p.State , image :p.Image , exposed_ports : '' , host_ports : '' , host_ports_mapping : []}
2026-08-14 12:08:21 -05:00
const legacyMultisite = c . wordpressMultisite ?? 'none'
const moduleMultisite = String ( c . moduleMetadata ? . multisite ?? legacyMultisite ) as 'none' | 'subdirectory' | 'subdomain'
2026-08-14 13:38:50 -05:00
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 (), wordpress_multisite :moduleMultisite , wordpress_network_admin_url :moduleMultisite !== 'none' ? ` ${ primary . replace ( /\/$/ , '' ) } /wp-admin/network/` : undefined , adminer_url :c.modules.includes ( 'adminer' ) ? `https://adminer. ${ projectHost ( c . name ) } ` : undefined , services , xdebug_enabled :c.xdebug === true }
2026-08-13 14:32:54 -05:00
}
2026-08-14 13:38:50 -05:00
export async function updateEnvironment ( root :string , updates : { phpVersion? :string ; nodeVersion? :string ; webserverType? :string ; database? :string ; xdebugEnabled? :boolean ; primaryProtocol ?: 'http' | 'https' }) : Promise < void >{
2026-08-13 14:32:54 -05:00
const c = await readConfig ( root )
if ( updates . phpVersion ) c . php = updates . phpVersion
if ( updates . nodeVersion ) c . node = updates . nodeVersion
2026-08-14 13:38:50 -05:00
if ( updates . webserverType === 'nginx' || updates . webserverType === 'nginx-fpm' ) c . webserver = 'nginx'
if ( updates . webserverType === 'apache' || updates . webserverType === 'apache-fpm' ) c . webserver = 'apache'
2026-08-13 14:32:54 -05:00
if ( typeof updates . xdebugEnabled === 'boolean' ) c . xdebug = updates . xdebugEnabled
2026-08-14 13:47:25 -05:00
if ( updates . database ){ const [ kind , version ] = updates . database . split ( ':' ); if ( kind === 'mariadb' || kind === 'mysql' || kind === 'postgres' ){ c . database = kind ; c . databaseVersion = version || c . databaseVersion }}
2026-08-13 14:32:54 -05:00
if ( updates . primaryProtocol === 'http' || updates . primaryProtocol === 'https' ) c . primaryProtocol = updates . primaryProtocol
2026-08-14 13:38:50 -05:00
await writeConfig ( root , c ); await writeWebServerConfig ( root , c ); await writePhpDockerfile ( root , c . xdebug === true )
2026-08-13 14:32:54 -05:00
}
export async function getProjectConfig ( root : string ) : Promise < AuroraConfig > { return readConfig ( root ) }
export async function setWordpressMultisite ( root : string , mode : 'none' | 'subdirectory' | 'subdomain' ) : Promise < void > {
const config = await readConfig ( root )
config . wordpressMultisite = mode
await writeConfig ( root , config )
}
2026-08-14 12:08:21 -05:00
export async function setProjectModuleMetadata ( root : string , metadata : Record < string , string | number | boolean >) : Promise < void > {
const config = await readConfig ( root )
config . moduleMetadata = { ... config . moduleMetadata , ... metadata }
await writeConfig ( root , config )
}
2026-08-13 14:32:54 -05:00
export async function routerStatus () : Promise < 'running' | 'provider-error' | 'stopped' > {
try {
const { stdout } = await execFileAsync ( 'docker' , [ 'inspect' , '-f' , '{{.State.Running}}' , 'aurora-router' ], { env : AURORA_ENV })
if ( stdout . trim () !== 'true' ) return 'stopped'
const { stdout : logs = '' , stderr : logErrors = '' } = await execFileAsync ( 'docker' , [ 'logs' , '--tail' , '40' , 'aurora-router' ], { env : AURORA_ENV , maxBuffer : 2 * 1024 * 1024 })
const recentLogs = ` ${ logs } \ n ${ logErrors } `
if ( recentLogs . includes ( 'Error while building configuration' ) || recentLogs . includes ( 'field not found, node:' )) return 'provider-error'
return 'running'
} catch { return 'stopped' }
}
export async function routerRunning () : Promise < boolean > {
return ( await routerStatus ()) === 'running'
}
export async function listModules () : Promise < AuroraModuleManifest [] > {
return getModuleRegistry ()
}
export async function listInstalledModules ( name : string ) : Promise < AuroraInstalledModule [] > {
const reg = await loadRegistry ()
const root = reg . projects [ name ]
if ( ! root ) throw new Error ( `Aurora project ' ${ name } ' not found` )
const config = await readConfig ( root )
const manifests = await Promise . all ( config . modules . map (( id ) => getModuleManifest ( id )))
return manifests . map (( module ) => ({ id : module.id , name : module.name , version : module.version , category : module.category }))
}
export async function setModule (
name : string ,
moduleId : string ,
install : boolean ,
settings : Record < string , string | number | boolean > = {}
) : Promise < void > {
const reg = await loadRegistry ()
const root = reg . projects [ name ]
if ( ! root ) throw new Error ( `Aurora project ' ${ name } ' not found` )
const config = await readConfig ( root )
const module = await getModuleManifest ( moduleId )
config . moduleSettings ??= {}
if ( install ) {
const conflict = module . conflicts . find (( id ) => config . modules . includes ( id ))
if ( conflict ) throw new Error ( ` ${ module . name } conflicts with the installed ' ${ conflict } ' application module.` )
for ( const dependency of module . dependencies ) {
if ( ! config . modules . includes ( dependency )) config . modules . push ( dependency )
}
if ( ! config . modules . includes ( moduleId )) config . modules . push ( moduleId )
config . moduleSettings [ moduleId ] = Object . fromEntries ( module . settings . map (( item ) => [ item . id , item . default ]))
Object . assign ( config . moduleSettings [ moduleId ], settings )
if ( module . category === 'application' ) {
config . type = moduleId
if ( module . defaults ? . docroot !== undefined ) config . docroot = module . defaults . docroot
}
} else {
const dependents = ( await getModuleRegistry ()). filter (( item ) => item . dependencies . includes ( moduleId ) && config . modules . includes ( item . id ))
if ( dependents . length ) throw new Error ( `Cannot remove ${ module . name } ; required by ${ dependents . map (( item ) => item . name ). join ( ', ' ) } .` )
config . modules = config . modules . filter (( id ) => id !== moduleId )
delete config . moduleSettings [ moduleId ]
if ( config . type === moduleId ) config . type = 'generic'
}
await writeConfig ( root , config )
2026-08-14 13:38:50 -05:00
await writeWebServerConfig ( root , config )
2026-08-13 14:32:54 -05:00
await writePhpDockerfile ( root , config . xdebug === true )
}
2026-08-14 12:08:21 -05:00
export async function scaffoldApplicationModule ( _name : string , moduleId : string ) : Promise < void > {
2026-08-13 14:32:54 -05:00
const module = await getModuleManifest ( moduleId )
if ( module . category !== 'application' ) return
}
export async function getProjectRoot ( name :string ) : Promise < string >{ const r = await loadRegistry (); if ( ! r . projects [ name ]) throw new Error ( `Project ${ name } not found` ); return r . projects [ name ]}
2026-08-14 13:47:25 -05:00
export async function getProjectConfigByRoot ( root : string ) : Promise < { database : 'mariadb' | 'mysql' | 'postgres' ; databaseVersion : string ; name : string } > {
2026-08-13 14:32:54 -05:00
const config = await readConfig ( root )
return { database : config.database , databaseVersion : config.databaseVersion , name : config.name }
}
async function ensureCertificateAuthority () : Promise < void > {
await mkdir ( caDir (), { recursive : true })
try { await access ( caKeyPath ()); await access ( caCertPath ()); return } catch { /* create below */ }
await execFileAsync ( 'openssl' , [ 'req' , '-x509' , '-newkey' , 'rsa:3072' , '-sha256' , '-days' , '3650' , '-nodes' , '-keyout' , caKeyPath (), '-out' , caCertPath (), '-subj' , '/CN=Aurora Dockside Local Development CA' , '-addext' , 'basicConstraints=critical,CA:TRUE' , '-addext' , 'keyUsage=critical,keyCertSign,cRLSign' ], { env : AURORA_ENV , maxBuffer : 8 * 1024 * 1024 })
}
async function ensureProjectCertificate ( name : string ) : Promise < void > {
await ensureCertificateAuthority ()
await mkdir ( projectsCertDir (), { recursive : true })
const cert = projectCertPath ( name ); const key = projectKeyPath ( name )
try { await access ( cert ); await access ( key ); return } catch { /* create below */ }
const host = projectHost ( name )
const csr = join ( projectsCertDir (), ` ${ safeName ( name ) } .csr` )
await execFileAsync ( 'openssl' , [ 'req' , '-new' , '-newkey' , 'rsa:2048' , '-nodes' , '-keyout' , key , '-out' , csr , '-subj' , `/CN= ${ host } ` , '-addext' , `subjectAltName=DNS: ${ host } ,DNS:*. ${ host } ` ], { env : AURORA_ENV , maxBuffer : 8 * 1024 * 1024 })
await execFileAsync ( 'openssl' , [ 'x509' , '-req' , '-in' , csr , '-CA' , caCertPath (), '-CAkey' , caKeyPath (), '-CAcreateserial' , '-out' , cert , '-days' , '825' , '-sha256' , '-copy_extensions' , 'copy' ], { env : AURORA_ENV , maxBuffer : 8 * 1024 * 1024 })
await rm ( csr , { force : true })
}
export async function certificateStatus ( name : string ) : Promise < 'generated' | 'missing' > {
try { await access ( projectCertPath ( name )); await access ( projectKeyPath ( name )); return 'generated' } catch { return 'missing' }
}
export async function caTrustStatus () : Promise < 'trusted' | 'not-trusted' | 'unknown' > {
if ( process . platform !== 'linux' ) return 'unknown'
try { await access ( '/usr/local/share/ca-certificates/aurora-dockside-local-ca.crt' ); return 'trusted' } catch { return 'not-trusted' }
}
const firefoxRoots = () : string [] => {
const home = process . env . HOME || app . getPath ( 'home' )
return [ join ( home , '.mozilla' , 'firefox' ), join ( home , 'snap' , 'firefox' , 'common' , '.mozilla' , 'firefox' )]
}
async function pathExists ( path : string ) : Promise < boolean > {
try { await access ( path ); return true } catch { return false }
}
async function firefoxProfiles () : Promise < string [] > {
const profiles : string [] = []
for ( const root of firefoxRoots ()) {
// Prefer the active/default profile declared by Firefox itself.
try {
const ini = await readFile ( join ( root , 'profiles.ini' ), 'utf8' )
const sections = ini . split ( /^\s*\[/m ). map (( section , index ) => index === 0 ? section : '[' + section )
for ( const section of sections ) {
if ( ! /^\[Profile\d+\]/m . test ( section ) || ! /^Default=1\s*$/m . test ( section )) continue
const match = section . match ( /^Path=(.+)\s*$/m )
if ( ! match ) continue
const profile = join ( root , match [ 1 ]. trim ())
if ( await pathExists ( join ( profile , 'cert9.db' ))) profiles . push ( profile )
}
} catch { /* no profiles.ini */ }
// Fall back to every NSS profile, but never add duplicates.
try {
for ( const entry of await readdir ( root , { withFileTypes : true })) {
if ( ! entry . isDirectory ()) continue
const dir = join ( root , entry . name )
if ( ! profiles . includes ( dir ) && await pathExists ( join ( dir , 'cert9.db' ))) profiles . push ( dir )
}
} catch { /* Firefox root does not exist */ }
}
return profiles
}
async function hasCertutil () : Promise < boolean > {
try { await execFileAsync ( 'certutil' ,[ '-L' , '-d' , 'sql:/dev/null' ],{ env :AURORA_ENV , maxBuffer :1024 * 1024 }); return true } catch ( e :any ) {
return e ? . code !== 'ENOENT'
}
}
async function nssHasTrustedAuroraCA ( db : string ) : Promise < boolean > {
try {
const { stdout } = await execFileAsync ( 'certutil' ,[ '-L' , '-d' , `sql: ${ db } ` ],{ env :AURORA_ENV , maxBuffer :1024 * 1024 })
const line = stdout . split ( /\r?\n/ ). find (( value ) => value . includes ( 'Aurora Dockside Local Development CA' ))
return Boolean ( line && /\bC,,\s*$/ . test ( line . trim ()))
} catch { return false }
}
export async function firefoxTrustStatus () : Promise < 'trusted' | 'not-trusted' | 'unavailable' | 'unknown' > {
if ( process . platform !== 'linux' ) return 'unknown'
if ( ! ( await hasCertutil ())) return 'unavailable'
const profiles = await firefoxProfiles ()
if ( ! profiles . length ) return 'unknown'
for ( const profile of profiles ) if ( ! ( await nssHasTrustedAuroraCA ( profile ))) return 'not-trusted'
return 'trusted'
}
const chromiumNssDb = () : string => join ( process . env . HOME || app . getPath ( 'home' ), '.pki' , 'nssdb' )
async function chromiumTrustTargetExists () : Promise < boolean > {
const db = chromiumNssDb ()
if ( await pathExists ( join ( db , 'cert9.db' ))) return true
for ( const command of [ 'google-chrome' , 'google-chrome-stable' , 'chromium' , 'chromium-browser' , 'brave-browser' , 'microsoft-edge' , 'vivaldi' ]) {
try { await execFileAsync ( 'which' ,[ command ],{ env :AURORA_ENV , maxBuffer :1024 * 1024 }); return true } catch { /* try next */ }
}
return false
}
export async function chromiumTrustStatus () : Promise < 'trusted' | 'not-trusted' | 'unavailable' | 'unknown' > {
if ( process . platform !== 'linux' ) return 'unknown'
if ( ! ( await chromiumTrustTargetExists ())) return 'unknown'
if ( ! ( await hasCertutil ())) return 'unavailable'
return await nssHasTrustedAuroraCA ( chromiumNssDb ()) ? 'trusted' : 'not-trusted'
}
async function installIntoNssDb ( db : string ) : Promise < void > {
await mkdir ( db , { recursive : true })
if ( ! ( await pathExists ( join ( db , 'cert9.db' )))) {
await execFileAsync ( 'certutil' ,[ '-N' , '--empty-password' , '-d' , `sql: ${ db } ` ],{ env :AURORA_ENV , maxBuffer :1024 * 1024 })
}
try { await execFileAsync ( 'certutil' ,[ '-D' , '-d' , `sql: ${ db } ` , '-n' , 'Aurora Dockside Local Development CA' ],{ env :AURORA_ENV , maxBuffer :1024 * 1024 }) } catch { /* absent is fine */ }
await execFileAsync ( 'certutil' ,[ '-A' , '-d' , `sql: ${ db } ` , '-n' , 'Aurora Dockside Local Development CA' , '-t' , 'C,,' , '-i' , caCertPath ()],{ env :AURORA_ENV , maxBuffer :1024 * 1024 })
if ( ! ( await nssHasTrustedAuroraCA ( db ))) throw new Error ( `Aurora CA import verification failed for NSS database: ${ db } ` )
}
export async function trustAuroraCA () : Promise < void > {
await ensureCertificateAuthority ()
if ( process . platform !== 'linux' ) throw new Error ( 'Automatic CA trust is currently implemented for Linux only.' )
const script = `install -m 0644 " ${ caCertPath (). replace ( /"/g , '\\"' ) } " /usr/local/share/ca-certificates/aurora-dockside-local-ca.crt && update-ca-certificates`
await execFileAsync ( 'pkexec' , [ 'sh' , '-c' , script ], { env : AURORA_ENV , maxBuffer : 8 * 1024 * 1024 })
if ( ! ( await hasCertutil ())) {
await execFileAsync ( 'pkexec' ,[ 'sh' , '-c' , 'apt-get update && apt-get install -y libnss3-tools' ],{ env :AURORA_ENV , maxBuffer :32 * 1024 * 1024 })
}
// These are the exact stores verified on Ubuntu: Snap/native Firefox profiles
// and the shared Chromium NSS database at ~/.pki/nssdb.
const profiles = await firefoxProfiles ()
for ( const profile of profiles ) await installIntoNssDb ( profile )
if ( await chromiumTrustTargetExists ()) await installIntoNssDb ( chromiumNssDb ())
const firefox = await firefoxTrustStatus ()
const chromium = await chromiumTrustStatus ()
if ( profiles . length && firefox !== 'trusted' ) throw new Error ( 'Aurora CA could not be verified in the active Firefox NSS trust store.' )
if ( await chromiumTrustTargetExists () && chromium !== 'trusted' ) throw new Error ( 'Aurora CA could not be verified in ~/.pki/nssdb for Chromium-family browsers.' )
}
async function writeRouterConfig () : Promise < void > {
const registry = await loadRegistry ()
const routers : string [] = []
const services : string [] = []
for ( const [ name , root ] of Object . entries ( registry . projects )) {
try {
const config = await readConfig ( root )
await ensureProjectCertificate ( config . name || name )
const safe = safeName ( config . name || name )
const host = projectHost ( config . name || name )
2026-08-14 12:08:21 -05:00
const rule = config . moduleMetadata ? . routingWildcard === true
2026-08-13 14:32:54 -05:00
? `Host(\` ${ host } \`) || HostRegexp(\`^[a-z0-9-]+\\. ${ host . replace ( /\./g , '\\.' ) } $\`)`
: `Host(\` ${ host } \`)`
routers . push (
` aurora- ${ safe } -http: \ n rule: ' ${ rule } ' \ n entryPoints: [web] \ n service: aurora- ${ safe } ` ,
` aurora- ${ safe } -https: \ n rule: ' ${ rule } ' \ n entryPoints: [websecure] \ n service: aurora- ${ safe } \ n tls: {}`
)
services . push ( ` aurora- ${ safe } : \ n loadBalancer: \ n servers: \ n - url: http://aurora- ${ safe } -web:80` )
if ( config . modules . includes ( 'adminer' )) routers . push (
` aurora- ${ safe } -adminer-http: \ n rule: 'Host(\`adminer. ${ host } \`)' \ n entryPoints: [web] \ n service: aurora- ${ safe } -adminer` ,
` aurora- ${ safe } -adminer-https: \ n rule: 'Host(\`adminer. ${ host } \`)' \ n entryPoints: [websecure] \ n service: aurora- ${ safe } -adminer \ n tls: {}`
)
if ( config . modules . includes ( 'adminer' )) services . push ( ` aurora- ${ safe } -adminer: \ n loadBalancer: \ n servers: \ n - url: http://aurora- ${ safe } -adminer:8080` )
if ( config . modules . includes ( 'mailpit' )) {
routers . push (
` aurora- ${ safe } -mailpit-http: \ n rule: 'Host(\`mail. ${ host } \`)' \ n entryPoints: [web] \ n service: aurora- ${ safe } -mailpit` ,
` aurora- ${ safe } -mailpit-https: \ n rule: 'Host(\`mail. ${ host } \`)' \ n entryPoints: [websecure] \ n service: aurora- ${ safe } -mailpit \ n tls: {}`
)
services . push ( ` aurora- ${ safe } -mailpit: \ n loadBalancer: \ n servers: \ n - url: http://aurora- ${ safe } -mailpit:8025` )
}
} catch { /* ignore stale registry entries */ }
}
const tlsCerts : string [] = []
for ( const [ name , root ] of Object . entries ( registry . projects )) {
try { const config = await readConfig ( root ); const safe = safeName ( config . name || name ); tlsCerts . push ( ` - certFile: /etc/traefik/certs/ ${ safe } .crt \ n keyFile: /etc/traefik/certs/ ${ safe } .key` ) } catch { /* stale */ }
}
const dynamic = `http: \ n routers: \ n ${ routers . length ? routers . join ( '\n' ) : ' {}' } \ n services: \ n ${ services . length ? services . join ( '\n' ) : ' {}' } \ n ${ tlsCerts . length ? `tls: \ n certificates: \ n ${ tlsCerts . join ( '\n' ) } \ n` : '' } `
await mkdir ( routerDir (), { recursive : true })
await writeFile ( routerConfigPath (), dynamic )
}
export async function ensureRouter () : Promise < void > {
try { await execFileAsync ( 'docker' , [ 'network' , 'inspect' , 'aurora-router' ], { env : AURORA_ENV }) }
catch { await execFileAsync ( 'docker' , [ 'network' , 'create' , 'aurora-router' ], { env : AURORA_ENV }) }
await writeRouterConfig ()
try {
const { stdout } = await execFileAsync ( 'docker' , [ 'inspect' , '-f' , '{{.State.Running}} {{json .Config.Cmd}}' , 'aurora-router' ], { env : AURORA_ENV })
const fileProvider = stdout . includes ( '--providers.file.directory=/etc/traefik/dynamic' )
if ( stdout . trimStart (). startsWith ( 'true' ) && fileProvider ) return
await execFileAsync ( 'docker' , [ 'rm' , '-f' , 'aurora-router' ], { env : AURORA_ENV }). catch (() => undefined )
} catch { /* router does not exist yet */ }
await execFileAsync ( 'docker' , [
'run' , '-d' , '--name' , 'aurora-router' , '--restart' , 'unless-stopped' ,
'--network' , 'aurora-router' ,
'-p' , '127.0.0.1:80:80' , '-p' , '127.0.0.1:443:443' ,
'-v' , ` ${ routerDir () } :/etc/traefik/dynamic:ro` ,
'-v' , ` ${ projectsCertDir () } :/etc/traefik/certs:ro` ,
'traefik:v3.5' ,
'--providers.file.directory=/etc/traefik/dynamic' , '--providers.file.watch=true' ,
'--entrypoints.web.address=:80' , '--entrypoints.websecure.address=:443' ,
'--api.dashboard=false' , '--log.level=INFO'
], { env : AURORA_ENV , maxBuffer : 8 * 1024 * 1024 })
}
export async function powerOffProjects () : Promise < void > {
const registry = await loadRegistry ()
await Promise . allSettled ( Object . values ( registry . projects ). map ( async ( root ) => {
try {
await access ( composePath ( root ))
await execFileAsync ( 'docker' , [ 'compose' , '-f' , composePath ( root ), 'down' ], { env : AURORA_ENV , cwd : root })
} catch { /* stale project or Docker unavailable */ }
}))
}
2026-08-14 14:00:07 -05:00
export interface RuntimeImageRefreshResult {
checkedProjects : number
refreshedProjects : number
failures : string []
}
export async function refreshRuntimeImages () : Promise < RuntimeImageRefreshResult > {
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 ))
2026-08-14 15:54:42 -05:00
const config = await readConfig ( root )
2026-08-14 14:00:07 -05:00
// 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 }
)
2026-08-14 15:54:42 -05:00
// 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.
2026-08-14 14:00:07 -05:00
await execFileAsync (
'docker' ,
2026-08-14 15:54:42 -05:00
[ 'pull' , `php: ${ config . php } -fpm-alpine` ],
{ env : AURORA_ENV , maxBuffer : 32 * 1024 * 1024 }
2026-08-14 14:00:07 -05:00
)
refreshedProjects += 1
} catch ( error ) {
failures . push ( ` ${ root } : ${ error instanceof Error ? error.message : String ( error ) } ` )
}
}
return { checkedProjects : roots.length , refreshedProjects , failures }
}