From 2bd9babab22a935a27d3e18d891fa65faa996ff9 Mon Sep 17 00:00:00 2001 From: reaper Date: Sat, 22 Aug 2026 03:02:24 -0500 Subject: [PATCH] Add native service supervisor and runtime packaging --- docs/AURORA_NATIVE_RUNTIME.md | 2 + package.json | 1 + scripts/package-native-runtime.cjs | 63 +++++++ src/main/auroraEngine.ts | 13 +- src/main/native/portAllocator.test.ts | 18 ++ src/main/native/portAllocator.ts | 44 +++++ src/main/native/processSupervisor.test.ts | 33 ++++ src/main/native/processSupervisor.ts | 173 ++++++++++++++++++ src/main/nativeRuntime.test.ts | 29 ++- src/main/nativeRuntime.ts | 109 +++++++++-- .../components/create/CreateProjectModal.tsx | 11 +- src/renderer/src/hooks/useRuntime.ts | 10 + src/shared/types.ts | 3 + 13 files changed, 483 insertions(+), 26 deletions(-) create mode 100644 scripts/package-native-runtime.cjs create mode 100644 src/main/native/portAllocator.test.ts create mode 100644 src/main/native/portAllocator.ts create mode 100644 src/main/native/processSupervisor.test.ts create mode 100644 src/main/native/processSupervisor.ts create mode 100644 src/renderer/src/hooks/useRuntime.ts diff --git a/docs/AURORA_NATIVE_RUNTIME.md b/docs/AURORA_NATIVE_RUNTIME.md index 58bb83a..8150ff8 100644 --- a/docs/AURORA_NATIVE_RUNTIME.md +++ b/docs/AURORA_NATIVE_RUNTIME.md @@ -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. +Runtime archives are created from a staging directory with `npm run build:native-runtime -- `. The packager resolves every executable inside the staging root, calculates its SHA-256 checksum, writes the immutable `runtime.json`, excludes the build template, and creates the distributable archive. Dockside independently verifies those checksums before declaring a runtime available. + ## 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. diff --git a/package.json b/package.json index 20d47ed..40aa352 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "build": "npm run typecheck && electron-vite build", "build:modules": "node scripts/package-modules.cjs", "build:connector": "node scripts/package-connector.cjs", + "build:native-runtime": "node scripts/package-native-runtime.cjs", "module:new": "node scripts/create-module.cjs", "postinstall": "electron-builder install-app-deps", "build:unpack": "npm run build && electron-builder --dir", diff --git a/scripts/package-native-runtime.cjs b/scripts/package-native-runtime.cjs new file mode 100644 index 0000000..6709dc3 --- /dev/null +++ b/scripts/package-native-runtime.cjs @@ -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 ') +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`) diff --git a/src/main/auroraEngine.ts b/src/main/auroraEngine.ts index f74a0f2..a51f7c1 100644 --- a/src/main/auroraEngine.ts +++ b/src/main/auroraEngine.ts @@ -3,7 +3,8 @@ 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' +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' const execFileAsync = promisify(execFile) @@ -25,6 +26,8 @@ export type AuroraConfig = { wordpressMultisite?: 'none' | 'subdirectory' | 'subdomain' moduleMetadata?: Record xdebug?: boolean + runtimeEngine?: AuroraRuntimeEngine + nativePorts?: AuroraNativePorts } type Registry = { projects: Record } @@ -213,7 +216,9 @@ export async function createProject(root: string, name: string, type: string, do 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 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) const reg = await loadRegistry(); reg.projects[name] = root; await saveRegistry(reg) } @@ -229,7 +234,7 @@ export async function listProjects(): Promise { 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 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 */ } } return out } @@ -238,7 +243,7 @@ export async function describeProject(name: string): Promise 0 && ps.every(p=>p.State==='running'); const currentRouterStatus = await routerStatus(); const urlSet=projectUrls(c.name); const primary=c.primaryProtocol === 'http' ? urlSet.http : urlSet.https const services: Record = {}; for (const p of ps) services[p.Service]={short_name:p.Service,full_name:p.Name,status:p.State,image:p.Image,exposed_ports:'',host_ports:'',host_ports_mapping:[]} const moduleMetadata = { ...(c.wordpressMultisite ? { multisite: c.wordpressMultisite } : {}), ...c.moduleMetadata } - return { name,status:running?'running':'stopped',status_desc:running?'Running':'Stopped',type:c.type,approot:root,shortroot:root,docroot:c.docroot,primary_url:primary,httpurl:urlSet.http,httpsurl:urlSet.https,mutagen_enabled:false,database_type:c.database,database_version:c.databaseVersion,dbinfo:{database_type:c.database,database_version:c.databaseVersion,dbPort:c.database==='postgres'?'5432':'3306',dbname:'db',host:'db',password:'db',published_port:0,username:'db'},hostname:projectHost(c.name),hostnames:[projectHost(c.name)],httpURLs:[urlSet.http],httpsURLs:[urlSet.https],urls:[urlSet.http,urlSet.https],php_version:c.php,nodejs_version:c.node,webserver_type:c.webserver,router:'file',router_status:currentRouterStatus,certificate_status:await certificateStatus(c.name),ca_trust_status:await caTrustStatus(),firefox_trust_status:await firefoxTrustStatus(),chromium_trust_status:await chromiumTrustStatus(),module_metadata:moduleMetadata,adminer_url:c.modules.includes('adminer')?`https://adminer.${projectHost(c.name)}`:undefined,services,xdebug_enabled:c.xdebug===true } + 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{ const c=await readConfig(root) diff --git a/src/main/native/portAllocator.test.ts b/src/main/native/portAllocator.test.ts new file mode 100644 index 0000000..8daa3fb --- /dev/null +++ b/src/main/native/portAllocator.test.ts @@ -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((resolve, reject) => { + server.once('error', reject) + server.listen(ports.http, '127.0.0.1', () => resolve()) + }) + await new Promise((resolve) => server.close(() => resolve())) + }) +}) diff --git a/src/main/native/portAllocator.ts b/src/main/native/portAllocator.ts new file mode 100644 index 0000000..8ddf9e8 --- /dev/null +++ b/src/main/native/portAllocator.ts @@ -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 { + return new Promise((resolve) => server.close(() => resolve())) +} + +export async function allocateNativePorts(): Promise { + 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))) + } +} diff --git a/src/main/native/processSupervisor.test.ts b/src/main/native/processSupervisor.test.ts new file mode 100644 index 0000000..08d3f2d --- /dev/null +++ b/src/main/native/processSupervisor.test.ts @@ -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') + }) +}) diff --git a/src/main/native/processSupervisor.ts b/src/main/native/processSupervisor.ts new file mode 100644 index 0000000..9f7ec55 --- /dev/null +++ b/src/main/native/processSupervisor.ts @@ -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 + 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 { + 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((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 { + 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() + private readonly listeners = new Set() + + 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 { + 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): Promise { + 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): Promise<'running' | 'stopped'> { + const state = await this.readState(spec) + return state && processExists(state.pid) ? 'running' : 'stopped' + } + + private async readState( + spec: Pick + ): Promise { + 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 + } + } +} diff --git a/src/main/nativeRuntime.test.ts b/src/main/nativeRuntime.test.ts index 7ecfb13..f9243d9 100644 --- a/src/main/nativeRuntime.test.ts +++ b/src/main/nativeRuntime.test.ts @@ -1,10 +1,31 @@ import { describe, expect, it } from 'vitest' 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', () => { - it('accepts a signed-component-shaped platform manifest', () => expect(validateNativeRuntimeManifest(valid)).toMatchObject(valid)) - 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/)) + it('accepts a signed-component-shaped platform manifest', () => + expect(validateNativeRuntimeManifest(valid)).toMatchObject(valid)) + 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/)) }) diff --git a/src/main/nativeRuntime.ts b/src/main/nativeRuntime.ts index da8d59d..680e6e0 100644 --- a/src/main/nativeRuntime.ts +++ b/src/main/nativeRuntime.ts @@ -1,5 +1,7 @@ import { app } from 'electron' import { execFile } from 'child_process' +import { createHash } from 'crypto' +import { createReadStream } from 'fs' import { access, readFile } from 'fs/promises' import { isAbsolute, join, resolve, sep } from 'path' import { promisify } from 'util' @@ -8,47 +10,118 @@ import type { AuroraNativeRuntimeManifest, AuroraRuntimeStatus } from '../shared const execFileAsync = promisify(execFile) const supportedPlatforms = new Set(['linux', 'darwin', 'win32']) 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 { if (!value || typeof value !== 'object') throw new Error('Runtime manifest must be an object.') const manifest = value as Record 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 (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.') + if ( + typeof manifest.runtimeVersion !== 'string' || + !/^\d+\.\d+\.\d+(?:[-+][a-zA-Z0-9.-]+)?$/.test(manifest.runtimeVersion) + ) + 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) => { if (!item || typeof item !== 'object') throw new Error('Invalid native runtime component.') const component = item as Record - if (typeof component.id !== 'string' || !componentIds.has(component.id)) throw new Error('Unknown native runtime component.') - if (typeof component.version !== 'string' || !component.version.trim()) 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}.`) + if (typeof component.id !== 'string' || !componentIds.has(component.id)) + throw new Error('Unknown native runtime component.') + if (typeof component.version !== 'string' || !component.version.trim()) + 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 { 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 { return join(app.getPath('userData'), 'runtimes', `${process.platform}-${process.arch}`) } +async function sha256(path: string): Promise { + 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 { - 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() try { - const manifest = validateNativeRuntimeManifest(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 manifest = validateNativeRuntimeManifest( + 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) for (const component of manifest.components) { 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) + 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) { - 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 { try { const { stdout } = await execFileAsync(provider, ['--version'], { timeout: 5000 }) 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.' } } diff --git a/src/renderer/src/components/create/CreateProjectModal.tsx b/src/renderer/src/components/create/CreateProjectModal.tsx index ade76c2..c10e5a5 100644 --- a/src/renderer/src/components/create/CreateProjectModal.tsx +++ b/src/renderer/src/components/create/CreateProjectModal.tsx @@ -8,6 +8,8 @@ import { FolderOpen, Globe2, Sparkles, + Container, + Cpu, X } from 'lucide-react' import { useCreateProject } from '../../hooks/useCreateProject' @@ -18,6 +20,7 @@ import { ExternalModuleSetup } from './types/ExternalModuleSetup' import type { TypeSetupHandle } from './types/shared' import { isValidProjectName, slugifyProjectName } from './projectName' import docksideIcon from '../../assets/dockside-icon.png' +import { useRuntimeStatus } from '../../hooks/useRuntime' type Step = 'site' | 'setup' @@ -48,11 +51,13 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React. const [redis, setRedis] = useState(false) const [mailpit, setMailpit] = useState(false) const [xdebug, setXdebug] = useState(false) + const [runtimeEngine] = useState<'container' | 'native'>('container') const createProject = useCreateProject() const selectProject = useAppStore((s) => s.selectProject) const setupRef = useRef(null) const { data: moduleRegistry = [] } = useModuleRegistry() + const { data: runtimeStatus } = useRuntimeStatus() const applicationModules = moduleRegistry.filter((module) => module.category === 'application') const selectedModule = applicationModules.find((module) => module.id === projectType) const getTypeLabel = (type: string): string => applicationModules.find((module) => module.id === type)?.name ?? 'project' @@ -77,7 +82,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React. const name = projectName.trim() setIsSubmitting(true) 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 { setIsSubmitting(false) return @@ -272,6 +277,10 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.

Development stack

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

+
+
Container engine

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

+
Aurora Native

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

+
diff --git a/src/renderer/src/hooks/useRuntime.ts b/src/renderer/src/hooks/useRuntime.ts new file mode 100644 index 0000000..c1deb22 --- /dev/null +++ b/src/renderer/src/hooks/useRuntime.ts @@ -0,0 +1,10 @@ +import { useQuery, type UseQueryResult } from '@tanstack/react-query' +import type { AuroraRuntimeStatus } from '@shared/types' + +export function useRuntimeStatus(): UseQueryResult { + return useQuery({ + queryKey: ['runtime', 'status'], + queryFn: () => window.api.runtime.status(), + staleTime: 30000 + }) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index ad43cfd..95e1279 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -37,6 +37,8 @@ export interface AuroraProjectSummary { mutagen_status?: string module_available?: boolean missing_module_id?: string + runtime_engine?: AuroraRuntimeEngine + native_ports?: { http: number; php: number; database: number; node: number } } export interface AuroraServiceHostPortMapping { @@ -129,6 +131,7 @@ export interface AuroraRemoteSiteStatus { } export interface AuroraStackOptions { + runtimeEngine: AuroraRuntimeEngine phpVersion: string nodeVersion: string webServer: 'nginx' | 'apache'