Add native service supervisor and runtime packaging

This commit is contained in:
reaper
2026-08-22 03:02:24 -05:00
parent 854ae3a2ad
commit 2bd9babab2
13 changed files with 483 additions and 26 deletions
+2
View File
@@ -10,6 +10,8 @@ Aurora Native is a per-platform PHP development engine that does not require Doc
Each signed runtime bundle contains a `runtime.json` manifest plus versioned executables for PHP, web servers, databases, Node.js, and application tooling. Bundles install below Aurora's user-data directory and never modify system PHP or database installations. Each signed runtime bundle contains a `runtime.json` manifest plus versioned executables for PHP, web servers, databases, Node.js, and application tooling. Bundles install below Aurora's user-data directory and never modify system PHP or database installations.
Runtime archives are created from a staging directory with `npm run build:native-runtime -- <staging-directory> <output.tar.gz>`. The packager resolves every executable inside the staging root, calculates its SHA-256 checksum, writes the immutable `runtime.json`, excludes the build template, and creates the distributable archive. Dockside independently verifies those checksums before declaring a runtime available.
## Isolation model ## Isolation model
Every project receives reserved loopback ports, generated service configuration, isolated database data, logs, PID files, and environment variables below `.aurora/native`. A shared Aurora router owns friendly HTTPS project hostnames. Project files remain directly accessible on the host. Every project receives reserved loopback ports, generated service configuration, isolated database data, logs, PID files, and environment variables below `.aurora/native`. A shared Aurora router owns friendly HTTPS project hostnames. Project files remain directly accessible on the host.
+1
View File
@@ -21,6 +21,7 @@
"build": "npm run typecheck && electron-vite build", "build": "npm run typecheck && electron-vite build",
"build:modules": "node scripts/package-modules.cjs", "build:modules": "node scripts/package-modules.cjs",
"build:connector": "node scripts/package-connector.cjs", "build:connector": "node scripts/package-connector.cjs",
"build:native-runtime": "node scripts/package-native-runtime.cjs",
"module:new": "node scripts/create-module.cjs", "module:new": "node scripts/create-module.cjs",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir", "build:unpack": "npm run build && electron-builder --dir",
+63
View File
@@ -0,0 +1,63 @@
'use strict'
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
const { createHash } = require('crypto')
const { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } = require('fs')
const { dirname, isAbsolute, join, resolve, sep } = require('path')
const { spawnSync } = require('child_process')
function fail(message) {
throw new Error(message)
}
const [, , sourceArg, outputArg] = process.argv
if (!sourceArg || !outputArg)
fail('Usage: node scripts/package-native-runtime.cjs <staging-directory> <output.tar.gz>')
const source = resolve(sourceArg)
const output = resolve(outputArg)
const templatePath = join(source, 'runtime.template.json')
if (!existsSync(templatePath)) fail(`Missing ${templatePath}`)
const template = JSON.parse(readFileSync(templatePath, 'utf8'))
if (
template.schema !== 1 ||
!Array.isArray(template.components) ||
template.components.length === 0
)
fail('Invalid runtime template')
const components = template.components.map((component) => {
if (
!component.executable ||
isAbsolute(component.executable) ||
component.executable.split(/[\\/]/).includes('..')
)
fail(`Unsafe executable for ${component.id}`)
const executable = resolve(source, component.executable)
if (executable !== source && !executable.startsWith(`${source}${sep}`))
fail(`Executable leaves staging directory for ${component.id}`)
if (!existsSync(executable))
fail(`Missing executable for ${component.id}: ${component.executable}`)
const sha256 = createHash('sha256').update(readFileSync(executable)).digest('hex')
return {
id: component.id,
version: component.version,
executable: component.executable.replaceAll('\\', '/'),
sha256
}
})
const manifest = {
schema: 1,
runtimeVersion: template.runtimeVersion,
platform: template.platform,
arch: template.arch,
components
}
writeFileSync(join(source, 'runtime.json'), JSON.stringify(manifest, null, 2) + '\n')
mkdirSync(dirname(output), { recursive: true })
rmSync(output, { force: true })
const archive = spawnSync(
'tar',
['-czf', output, '--exclude=runtime.template.json', '-C', source, '.'],
{ stdio: 'inherit' }
)
if (archive.error) throw archive.error
if (archive.status !== 0) fail(`tar failed with exit code ${archive.status}`)
process.stdout.write(`${output}\n`)
+9 -4
View File
@@ -3,7 +3,8 @@ import { execFile } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import { mkdir, readFile, writeFile, access, rm, readdir } from 'fs/promises' import { mkdir, readFile, writeFile, access, rm, readdir } from 'fs/promises'
import { join } from 'path' import { join } from 'path'
import type { AuroraProjectDetail, AuroraProjectSummary, AuroraInstalledModule, AuroraModuleManifest, AuroraStackOptions } 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 { CORE_VERSION, MODULE_API_VERSION, getModuleManifest, getModuleRegistry } from './moduleRegistry'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
@@ -25,6 +26,8 @@ export type AuroraConfig = {
wordpressMultisite?: 'none' | 'subdirectory' | 'subdomain' wordpressMultisite?: 'none' | 'subdirectory' | 'subdomain'
moduleMetadata?: Record<string, string | number | boolean> moduleMetadata?: Record<string, string | number | boolean>
xdebug?: boolean xdebug?: boolean
runtimeEngine?: AuroraRuntimeEngine
nativePorts?: AuroraNativePorts
} }
type Registry = { projects: Record<string, string> } type Registry = { projects: Record<string, string> }
@@ -213,7 +216,9 @@ export async function createProject(root: string, name: string, type: string, do
if (stack?.mailpit) modules.push('mailpit') if (stack?.mailpit) modules.push('mailpit')
const phpVersion = stack?.phpVersion || application.creation?.phpVersions?.[0] || '8.4' 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 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 } 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) await writeConfig(root, config); await writeWebServerConfig(root, config); await writePhpDockerfile(root, config.xdebug)
const reg = await loadRegistry(); reg.projects[name] = root; await saveRegistry(reg) const reg = await loadRegistry(); reg.projects[name] = root; await saveRegistry(reg)
} }
@@ -229,7 +234,7 @@ export async function listProjects(): Promise<AuroraProjectSummary[]> {
for (const [name, root] of Object.entries(reg.projects)) { 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 = 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
const moduleAvailable = availableModules.has(c.type) 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 }) 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 */ } } catch { /* stale registry entry */ }
} return out } return out
} }
@@ -238,7 +243,7 @@ export async function describeProject(name: string): Promise<AuroraProjectDetail
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 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<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:[]} 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:[]}
const moduleMetadata = { ...(c.wordpressMultisite ? { multisite: c.wordpressMultisite } : {}), ...c.moduleMetadata } 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 } 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 }
} }
export async function updateEnvironment(root:string, updates:{phpVersion?:string;nodeVersion?:string;webserverType?:string;database?:string;xdebugEnabled?:boolean;primaryProtocol?:'http'|'https'}):Promise<void>{ export async function updateEnvironment(root:string, updates:{phpVersion?:string;nodeVersion?:string;webserverType?:string;database?:string;xdebugEnabled?:boolean;primaryProtocol?:'http'|'https'}):Promise<void>{
const c=await readConfig(root) const c=await readConfig(root)
+18
View File
@@ -0,0 +1,18 @@
import { createServer } from 'net'
import { describe, expect, it } from 'vitest'
import { allocateNativePorts } from './portAllocator'
describe('native port allocator', () => {
it('allocates four unique loopback ports and releases the reservations', async () => {
const ports = await allocateNativePorts()
const values = Object.values(ports)
expect(new Set(values).size).toBe(4)
expect(values.every((port) => Number.isInteger(port) && port > 0)).toBe(true)
const server = createServer()
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(ports.http, '127.0.0.1', () => resolve())
})
await new Promise<void>((resolve) => server.close(() => resolve()))
})
})
+44
View File
@@ -0,0 +1,44 @@
import { createServer, type Server } from 'net'
export interface AuroraNativePorts {
http: number
php: number
database: number
node: number
}
async function reserveOne(host = '127.0.0.1'): Promise<{ port: number; server: Server }> {
return new Promise((resolve, reject) => {
const server = createServer()
server.unref()
server.once('error', reject)
server.listen(0, host, () => {
const address = server.address()
if (!address || typeof address === 'string') {
server.close()
reject(new Error('Aurora could not reserve a local service port.'))
return
}
resolve({ port: address.port, server })
})
})
}
function close(server: Server): Promise<void> {
return new Promise((resolve) => server.close(() => resolve()))
}
export async function allocateNativePorts(): Promise<AuroraNativePorts> {
const reservations: Array<{ port: number; server: Server }> = []
try {
for (let index = 0; index < 4; index += 1) reservations.push(await reserveOne())
return {
http: reservations[0].port,
php: reservations[1].port,
database: reservations[2].port,
node: reservations[3].port
}
} finally {
await Promise.all(reservations.map(({ server }) => close(server)))
}
}
+33
View File
@@ -0,0 +1,33 @@
import { mkdtemp, readFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, expect, it } from 'vitest'
import { allocateNativePorts } from './portAllocator'
import { NativeProcessSupervisor } from './processSupervisor'
describe('native process supervisor', () => {
it('starts, health-checks, logs, and stops a native service', async () => {
const root = await mkdtemp(join(tmpdir(), 'aurora-native-supervisor-'))
const port = (await allocateNativePorts()).http
const spec = {
id: 'test-http',
command: process.execPath,
args: [
'-e',
`const s=require('net').createServer(c=>c.end('ok'));s.listen(${port},'127.0.0.1');console.log('ready')`
],
cwd: root,
logPath: join(root, 'service.log'),
pidPath: join(root, 'service.pid.json'),
ready: { port, timeoutMs: 5000 }
}
const supervisor = new NativeProcessSupervisor()
const state = await supervisor.start(spec)
expect(state.status).toBe('running')
expect(await supervisor.status(spec)).toBe('running')
await new Promise((resolve) => setTimeout(resolve, 50))
expect(await readFile(spec.logPath, 'utf8')).toContain('ready')
await supervisor.stop(spec)
expect(await supervisor.status(spec)).toBe('stopped')
})
})
+173
View File
@@ -0,0 +1,173 @@
import { spawn, type ChildProcess } from 'child_process'
import { createConnection } from 'net'
import { mkdir, readFile, rm, writeFile } from 'fs/promises'
import { dirname } from 'path'
export interface NativeServiceSpec {
id: string
command: string
args: string[]
cwd: string
env?: Record<string, string>
logPath: string
pidPath: string
ready?: { host?: string; port: number; timeoutMs?: number }
}
export interface NativeServiceState {
id: string
pid: number
startedAt: string
status: 'starting' | 'running'
}
type LogListener = (service: string, stream: 'stdout' | 'stderr', chunk: string) => void
function processExists(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
async function waitForPort(
host: string,
port: number,
timeoutMs: number,
child: ChildProcess
): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`Service exited before port ${port} became ready.`)
const connected = await new Promise<boolean>((resolve) => {
const socket = createConnection({ host, port })
socket.setTimeout(400)
socket.once('connect', () => {
socket.destroy()
resolve(true)
})
const failed = (): void => {
socket.destroy()
resolve(false)
}
socket.once('error', failed)
socket.once('timeout', failed)
})
if (connected) return
await new Promise((resolve) => setTimeout(resolve, 100))
}
throw new Error(`Service did not become ready on ${host}:${port} within ${timeoutMs}ms.`)
}
async function terminate(pid: number, timeoutMs = 8000): Promise<void> {
if (!processExists(pid)) return
try {
process.kill(pid, 'SIGTERM')
} catch {
return
}
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (!processExists(pid)) return
await new Promise((resolve) => setTimeout(resolve, 100))
}
try {
process.kill(pid, 'SIGKILL')
} catch {
/* already stopped */
}
}
export class NativeProcessSupervisor {
private readonly children = new Map<string, ChildProcess>()
private readonly listeners = new Set<LogListener>()
onLog(listener: LogListener): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
private emit(service: string, stream: 'stdout' | 'stderr', chunk: string): void {
for (const listener of this.listeners) listener(service, stream, chunk)
}
async start(spec: NativeServiceSpec): Promise<NativeServiceState> {
const existing = await this.readState(spec)
if (existing && processExists(existing.pid)) return { ...existing, status: 'running' }
await mkdir(dirname(spec.logPath), { recursive: true })
await mkdir(dirname(spec.pidPath), { recursive: true })
const child = spawn(spec.command, spec.args, {
cwd: spec.cwd,
env: { ...process.env, ...spec.env },
stdio: ['ignore', 'pipe', 'pipe'],
detached: false
})
if (!child.pid) throw new Error(`Unable to start native service '${spec.id}'.`)
this.children.set(spec.id, child)
let log = ''
const collect =
(stream: 'stdout' | 'stderr') =>
(data: Buffer): void => {
const chunk = data.toString()
log += chunk
if (log.length > 1024 * 1024) log = log.slice(-1024 * 1024)
void writeFile(spec.logPath, log)
this.emit(spec.id, stream, chunk)
}
child.stdout?.on('data', collect('stdout'))
child.stderr?.on('data', collect('stderr'))
const state: NativeServiceState = {
id: spec.id,
pid: child.pid,
startedAt: new Date().toISOString(),
status: 'starting'
}
await writeFile(spec.pidPath, JSON.stringify(state, null, 2) + '\n')
try {
if (spec.ready)
await waitForPort(
spec.ready.host ?? '127.0.0.1',
spec.ready.port,
spec.ready.timeoutMs ?? 15000,
child
)
const running = { ...state, status: 'running' as const }
await writeFile(spec.pidPath, JSON.stringify(running, null, 2) + '\n')
child.once('exit', () => {
this.children.delete(spec.id)
void rm(spec.pidPath, { force: true })
})
return running
} catch (error) {
await terminate(child.pid)
this.children.delete(spec.id)
await rm(spec.pidPath, { force: true })
throw error
}
}
async stop(spec: Pick<NativeServiceSpec, 'id' | 'pidPath'>): Promise<void> {
const state = await this.readState(spec)
if (state) await terminate(state.pid)
this.children.delete(spec.id)
await rm(spec.pidPath, { force: true })
}
async status(spec: Pick<NativeServiceSpec, 'id' | 'pidPath'>): Promise<'running' | 'stopped'> {
const state = await this.readState(spec)
return state && processExists(state.pid) ? 'running' : 'stopped'
}
private async readState(
spec: Pick<NativeServiceSpec, 'id' | 'pidPath'>
): Promise<NativeServiceState | null> {
try {
const state = JSON.parse(await readFile(spec.pidPath, 'utf8')) as NativeServiceState
return state.id === spec.id && Number.isInteger(state.pid) && state.pid > 0 ? state : null
} catch {
return null
}
}
}
+25 -4
View File
@@ -1,10 +1,31 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { validateNativeRuntimeManifest } from './nativeRuntime' import { validateNativeRuntimeManifest } from './nativeRuntime'
const valid = { schema: 1, runtimeVersion: '0.1.0', platform: 'linux', arch: 'x64', components: [{ id: 'php', version: '8.4.12', executable: 'php/8.4/bin/php', sha256: 'a'.repeat(64) }] } const valid = {
schema: 1,
runtimeVersion: '0.1.0',
platform: 'linux',
arch: 'x64',
components: [
{ id: 'php', version: '8.4.12', executable: 'php/8.4/bin/php', sha256: 'a'.repeat(64) }
]
}
describe('Aurora Native runtime manifest', () => { describe('Aurora Native runtime manifest', () => {
it('accepts a signed-component-shaped platform manifest', () => expect(validateNativeRuntimeManifest(valid)).toMatchObject(valid)) it('accepts a signed-component-shaped platform manifest', () =>
it('rejects executable paths that escape the runtime', () => expect(() => validateNativeRuntimeManifest({ ...valid, components: [{ ...valid.components[0], executable: '../php' }] })).toThrow(/Unsafe executable path/)) expect(validateNativeRuntimeManifest(valid)).toMatchObject(valid))
it('rejects malformed component checksums', () => expect(() => validateNativeRuntimeManifest({ ...valid, components: [{ ...valid.components[0], sha256: 'bad' }] })).toThrow(/checksum/)) it('rejects executable paths that escape the runtime', () =>
expect(() =>
validateNativeRuntimeManifest({
...valid,
components: [{ ...valid.components[0], executable: '../php' }]
})
).toThrow(/Unsafe executable path/))
it('rejects malformed component checksums', () =>
expect(() =>
validateNativeRuntimeManifest({
...valid,
components: [{ ...valid.components[0], sha256: 'bad' }]
})
).toThrow(/checksum/))
}) })
+92 -17
View File
@@ -1,5 +1,7 @@
import { app } from 'electron' import { app } from 'electron'
import { execFile } from 'child_process' import { execFile } from 'child_process'
import { createHash } from 'crypto'
import { createReadStream } from 'fs'
import { access, readFile } from 'fs/promises' import { access, readFile } from 'fs/promises'
import { isAbsolute, join, resolve, sep } from 'path' import { isAbsolute, join, resolve, sep } from 'path'
import { promisify } from 'util' import { promisify } from 'util'
@@ -8,47 +10,118 @@ import type { AuroraNativeRuntimeManifest, AuroraRuntimeStatus } from '../shared
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
const supportedPlatforms = new Set(['linux', 'darwin', 'win32']) const supportedPlatforms = new Set(['linux', 'darwin', 'win32'])
const supportedArchitectures = new Set(['x64', 'arm64']) const supportedArchitectures = new Set(['x64', 'arm64'])
const componentIds = new Set(['php', 'nginx', 'apache', 'mariadb', 'mysql', 'postgres', 'node', 'composer', 'wp-cli', 'drush']) const componentIds = new Set([
'php',
'nginx',
'apache',
'mariadb',
'mysql',
'postgres',
'node',
'composer',
'wp-cli',
'drush'
])
export function validateNativeRuntimeManifest(value: unknown): AuroraNativeRuntimeManifest { export function validateNativeRuntimeManifest(value: unknown): AuroraNativeRuntimeManifest {
if (!value || typeof value !== 'object') throw new Error('Runtime manifest must be an object.') if (!value || typeof value !== 'object') throw new Error('Runtime manifest must be an object.')
const manifest = value as Record<string, unknown> const manifest = value as Record<string, unknown>
if (manifest.schema !== 1) throw new Error('Unsupported native runtime manifest schema.') if (manifest.schema !== 1) throw new Error('Unsupported native runtime manifest schema.')
if (typeof manifest.runtimeVersion !== 'string' || !/^\d+\.\d+\.\d+(?:[-+][a-zA-Z0-9.-]+)?$/.test(manifest.runtimeVersion)) throw new Error('Invalid native runtime version.') if (
if (typeof manifest.platform !== 'string' || !supportedPlatforms.has(manifest.platform)) throw new Error('Unsupported native runtime platform.') typeof manifest.runtimeVersion !== 'string' ||
if (typeof manifest.arch !== 'string' || !supportedArchitectures.has(manifest.arch)) throw new Error('Unsupported native runtime architecture.') !/^\d+\.\d+\.\d+(?:[-+][a-zA-Z0-9.-]+)?$/.test(manifest.runtimeVersion)
if (!Array.isArray(manifest.components) || manifest.components.length === 0) throw new Error('Native runtime manifest has no components.') )
throw new Error('Invalid native runtime version.')
if (typeof manifest.platform !== 'string' || !supportedPlatforms.has(manifest.platform))
throw new Error('Unsupported native runtime platform.')
if (typeof manifest.arch !== 'string' || !supportedArchitectures.has(manifest.arch))
throw new Error('Unsupported native runtime architecture.')
if (!Array.isArray(manifest.components) || manifest.components.length === 0)
throw new Error('Native runtime manifest has no components.')
const components = manifest.components.map((item) => { const components = manifest.components.map((item) => {
if (!item || typeof item !== 'object') throw new Error('Invalid native runtime component.') if (!item || typeof item !== 'object') throw new Error('Invalid native runtime component.')
const component = item as Record<string, unknown> const component = item as Record<string, unknown>
if (typeof component.id !== 'string' || !componentIds.has(component.id)) throw new Error('Unknown native runtime component.') if (typeof component.id !== 'string' || !componentIds.has(component.id))
if (typeof component.version !== 'string' || !component.version.trim()) throw new Error(`Missing version for ${component.id}.`) throw new Error('Unknown native runtime component.')
if (typeof component.executable !== 'string' || !component.executable || isAbsolute(component.executable) || component.executable.split(/[\\/]/).includes('..')) throw new Error(`Unsafe executable path for ${component.id}.`) if (typeof component.version !== 'string' || !component.version.trim())
if (typeof component.sha256 !== 'string' || !/^[a-f0-9]{64}$/i.test(component.sha256)) throw new Error(`Invalid checksum for ${component.id}.`) throw new Error(`Missing version for ${component.id}.`)
if (
typeof component.executable !== 'string' ||
!component.executable ||
isAbsolute(component.executable) ||
component.executable.split(/[\\/]/).includes('..')
)
throw new Error(`Unsafe executable path for ${component.id}.`)
if (typeof component.sha256 !== 'string' || !/^[a-f0-9]{64}$/i.test(component.sha256))
throw new Error(`Invalid checksum for ${component.id}.`)
return component as unknown as AuroraNativeRuntimeManifest['components'][number] return component as unknown as AuroraNativeRuntimeManifest['components'][number]
}) })
return { schema: 1, runtimeVersion: manifest.runtimeVersion, platform: manifest.platform as AuroraNativeRuntimeManifest['platform'], arch: manifest.arch as AuroraNativeRuntimeManifest['arch'], components } return {
schema: 1,
runtimeVersion: manifest.runtimeVersion,
platform: manifest.platform as AuroraNativeRuntimeManifest['platform'],
arch: manifest.arch as AuroraNativeRuntimeManifest['arch'],
components
}
} }
function runtimeRoot(): string { function runtimeRoot(): string {
return join(app.getPath('userData'), 'runtimes', `${process.platform}-${process.arch}`) return join(app.getPath('userData'), 'runtimes', `${process.platform}-${process.arch}`)
} }
async function sha256(path: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = createHash('sha256')
const stream = createReadStream(path)
stream.once('error', reject)
stream.on('data', (chunk) => hash.update(chunk))
stream.once('end', () => resolve(hash.digest('hex')))
})
}
async function nativeStatus(): Promise<AuroraRuntimeStatus['native']> { async function nativeStatus(): Promise<AuroraRuntimeStatus['native']> {
if (!supportedPlatforms.has(process.platform) || !supportedArchitectures.has(process.arch)) return { available: false, platform: process.platform, arch: process.arch, components: [], reason: 'This platform does not have an Aurora Native runtime target yet.' } if (!supportedPlatforms.has(process.platform) || !supportedArchitectures.has(process.arch))
return {
available: false,
platform: process.platform,
arch: process.arch,
components: [],
reason: 'This platform does not have an Aurora Native runtime target yet.'
}
const root = runtimeRoot() const root = runtimeRoot()
try { try {
const manifest = validateNativeRuntimeManifest(JSON.parse(await readFile(join(root, 'runtime.json'), 'utf8'))) const manifest = validateNativeRuntimeManifest(
if (manifest.platform !== process.platform || manifest.arch !== process.arch) throw new Error('The installed runtime targets a different platform.') JSON.parse(await readFile(join(root, 'runtime.json'), 'utf8'))
)
if (manifest.platform !== process.platform || manifest.arch !== process.arch)
throw new Error('The installed runtime targets a different platform.')
const canonicalRoot = resolve(root) const canonicalRoot = resolve(root)
for (const component of manifest.components) { for (const component of manifest.components) {
const executable = resolve(root, component.executable) const executable = resolve(root, component.executable)
if (executable !== canonicalRoot && !executable.startsWith(`${canonicalRoot}${sep}`)) throw new Error(`Unsafe executable path for ${component.id}.`) if (executable !== canonicalRoot && !executable.startsWith(`${canonicalRoot}${sep}`))
throw new Error(`Unsafe executable path for ${component.id}.`)
await access(executable) await access(executable)
if ((await sha256(executable)) !== component.sha256.toLowerCase())
throw new Error(`Checksum verification failed for ${component.id}.`)
}
return {
available: true,
platform: process.platform,
arch: process.arch,
runtimeVersion: manifest.runtimeVersion,
components: manifest.components
} }
return { available: true, platform: process.platform, arch: process.arch, runtimeVersion: manifest.runtimeVersion, components: manifest.components }
} catch (error) { } catch (error) {
return { available: false, platform: process.platform, arch: process.arch, components: [], reason: error instanceof Error && !error.message.includes('ENOENT') ? error.message : 'Aurora Native runtime is not installed yet.' } return {
available: false,
platform: process.platform,
arch: process.arch,
components: [],
reason:
error instanceof Error && !error.message.includes('ENOENT')
? error.message
: 'Aurora Native runtime is not installed yet.'
}
} }
} }
@@ -57,7 +130,9 @@ async function containerStatus(): Promise<AuroraRuntimeStatus['container']> {
try { try {
const { stdout } = await execFileAsync(provider, ['--version'], { timeout: 5000 }) const { stdout } = await execFileAsync(provider, ['--version'], { timeout: 5000 })
return { available: true, provider, version: stdout.trim() } return { available: true, provider, version: stdout.trim() }
} catch { /* try next provider */ } } catch {
/* try next provider */
}
} }
return { available: false, provider: null, reason: 'Docker or Podman was not detected.' } return { available: false, provider: null, reason: 'Docker or Podman was not detected.' }
} }
@@ -8,6 +8,8 @@ import {
FolderOpen, FolderOpen,
Globe2, Globe2,
Sparkles, Sparkles,
Container,
Cpu,
X X
} from 'lucide-react' } from 'lucide-react'
import { useCreateProject } from '../../hooks/useCreateProject' import { useCreateProject } from '../../hooks/useCreateProject'
@@ -18,6 +20,7 @@ import { ExternalModuleSetup } from './types/ExternalModuleSetup'
import type { TypeSetupHandle } from './types/shared' import type { TypeSetupHandle } from './types/shared'
import { isValidProjectName, slugifyProjectName } from './projectName' import { isValidProjectName, slugifyProjectName } from './projectName'
import docksideIcon from '../../assets/dockside-icon.png' import docksideIcon from '../../assets/dockside-icon.png'
import { useRuntimeStatus } from '../../hooks/useRuntime'
type Step = 'site' | 'setup' type Step = 'site' | 'setup'
@@ -48,11 +51,13 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
const [redis, setRedis] = useState(false) const [redis, setRedis] = useState(false)
const [mailpit, setMailpit] = useState(false) const [mailpit, setMailpit] = useState(false)
const [xdebug, setXdebug] = useState(false) const [xdebug, setXdebug] = useState(false)
const [runtimeEngine] = useState<'container' | 'native'>('container')
const createProject = useCreateProject() const createProject = useCreateProject()
const selectProject = useAppStore((s) => s.selectProject) const selectProject = useAppStore((s) => s.selectProject)
const setupRef = useRef<TypeSetupHandle>(null) const setupRef = useRef<TypeSetupHandle>(null)
const { data: moduleRegistry = [] } = useModuleRegistry() const { data: moduleRegistry = [] } = useModuleRegistry()
const { data: runtimeStatus } = useRuntimeStatus()
const applicationModules = moduleRegistry.filter((module) => module.category === 'application') const applicationModules = moduleRegistry.filter((module) => module.category === 'application')
const selectedModule = applicationModules.find((module) => module.id === projectType) const selectedModule = applicationModules.find((module) => module.id === projectType)
const getTypeLabel = (type: string): string => applicationModules.find((module) => module.id === type)?.name ?? 'project' const getTypeLabel = (type: string): string => applicationModules.find((module) => module.id === type)?.name ?? 'project'
@@ -77,7 +82,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
const name = projectName.trim() const name = projectName.trim()
setIsSubmitting(true) setIsSubmitting(true)
try { try {
await createProject.mutateAsync({ directory, projectName: name, projectType, docroot, stack: { phpVersion, nodeVersion, webServer, database, databaseVersion, adminer, redis, mailpit, xdebug } }) await createProject.mutateAsync({ directory, projectName: name, projectType, docroot, stack: { runtimeEngine, phpVersion, nodeVersion, webServer, database, databaseVersion, adminer, redis, mailpit, xdebug } })
} catch { } catch {
setIsSubmitting(false) setIsSubmitting(false)
return return
@@ -272,6 +277,10 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
<p className="text-sm font-semibold">Development stack</p> <p className="text-sm font-semibold">Development stack</p>
<p className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">Aurora core owns the runtime; the application is a module layered on top.</p> <p className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">Aurora core owns the runtime; the application is a module layered on top.</p>
</div> </div>
<div className="mb-4 grid gap-2 sm:grid-cols-2">
<div className="rounded-xl border border-cyan-300 bg-cyan-50 p-3 text-cyan-950 dark:border-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-100"><div className="flex items-center gap-2 text-sm font-semibold"><Container size={16}/> Container engine</div><p className="mt-1 text-xs text-cyan-800/80 dark:text-cyan-100/70">Current compatible engine · {runtimeStatus?.container.available ? runtimeStatus.container.provider : 'not detected'}</p></div>
<div aria-disabled="true" className="rounded-xl border border-neutral-200 bg-neutral-100/70 p-3 opacity-70 dark:border-white/10 dark:bg-white/[0.03]"><div className="flex items-center gap-2 text-sm font-semibold"><Cpu size={16}/> Aurora Native</div><p className="mt-1 text-xs text-neutral-500">{runtimeStatus?.native.available ? `Runtime ${runtimeStatus.native.runtimeVersion} detected · provisioning checks pending` : 'Runtime bundle not installed yet'}</p></div>
</div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div><label className={labelClass}>PHP</label><select className={fieldClass} value={phpVersion} onChange={(e)=>setPhpVersion(e.target.value)}>{(selectedModule?.creation?.phpVersions ?? ['8.2','8.3','8.4','8.5']).map(v=><option key={v}>{v}</option>)}</select></div> <div><label className={labelClass}>PHP</label><select className={fieldClass} value={phpVersion} onChange={(e)=>setPhpVersion(e.target.value)}>{(selectedModule?.creation?.phpVersions ?? ['8.2','8.3','8.4','8.5']).map(v=><option key={v}>{v}</option>)}</select></div>
<div><label className={labelClass}>Node.js</label><select className={fieldClass} value={nodeVersion} onChange={(e)=>setNodeVersion(e.target.value)}>{['20','22','24'].map(v=><option key={v}>{v}</option>)}</select></div> <div><label className={labelClass}>Node.js</label><select className={fieldClass} value={nodeVersion} onChange={(e)=>setNodeVersion(e.target.value)}>{['20','22','24'].map(v=><option key={v}>{v}</option>)}</select></div>
+10
View File
@@ -0,0 +1,10 @@
import { useQuery, type UseQueryResult } from '@tanstack/react-query'
import type { AuroraRuntimeStatus } from '@shared/types'
export function useRuntimeStatus(): UseQueryResult<AuroraRuntimeStatus, Error> {
return useQuery({
queryKey: ['runtime', 'status'],
queryFn: () => window.api.runtime.status(),
staleTime: 30000
})
}
+3
View File
@@ -37,6 +37,8 @@ export interface AuroraProjectSummary {
mutagen_status?: string mutagen_status?: string
module_available?: boolean module_available?: boolean
missing_module_id?: string missing_module_id?: string
runtime_engine?: AuroraRuntimeEngine
native_ports?: { http: number; php: number; database: number; node: number }
} }
export interface AuroraServiceHostPortMapping { export interface AuroraServiceHostPortMapping {
@@ -129,6 +131,7 @@ export interface AuroraRemoteSiteStatus {
} }
export interface AuroraStackOptions { export interface AuroraStackOptions {
runtimeEngine: AuroraRuntimeEngine
phpVersion: string phpVersion: string
nodeVersion: string nodeVersion: string
webServer: 'nginx' | 'apache' webServer: 'nginx' | 'apache'