Add native service supervisor and runtime packaging
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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`)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user