feat: add external module architecture

This commit is contained in:
reaper
2026-08-14 12:08:21 -05:00
parent 133a3d428d
commit 31e6150ecd
31 changed files with 389 additions and 985 deletions
+27 -20
View File
@@ -4,7 +4,7 @@ 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 { getModuleManifest, getModuleRegistry } from './moduleRegistry'
import { CORE_VERSION, MODULE_API_VERSION, getModuleManifest, getModuleRegistry } from './moduleRegistry'
const execFileAsync = promisify(execFile)
const EXTRA_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/opt/local/bin']
@@ -23,6 +23,7 @@ type AuroraConfig = {
moduleSettings?: Record<string, Record<string, string | number | boolean>>
primaryProtocol?: 'http' | 'https'
wordpressMultisite?: 'none' | 'subdirectory' | 'subdomain'
moduleMetadata?: Record<string, string | number | boolean>
xdebug?: boolean
}
@@ -87,7 +88,11 @@ async function renderCompose(c: AuroraConfig): Promise<string> {
const db = c.database === 'postgres'
? ` db:\n image: postgres:${c.databaseVersion}\n environment:\n POSTGRES_DB: db\n POSTGRES_USER: db\n POSTGRES_PASSWORD: db\n volumes:\n - db_data:/var/lib/postgresql/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U db -d db\"]\n interval: 3s\n timeout: 3s\n retries: 20`
: ` db:\n image: mariadb:${c.databaseVersion}\n environment:\n MARIADB_DATABASE: db\n MARIADB_USER: db\n MARIADB_PASSWORD: db\n MARIADB_ROOT_PASSWORD: root\n volumes:\n - db_data:/var/lib/mysql\n healthcheck:\n test: [\"CMD\", \"healthcheck.sh\", \"--connect\", \"--innodb_initialized\"]\n interval: 3s\n timeout: 3s\n retries: 20`
const manifests = await Promise.all(c.modules.filter((id) => id !== 'adminer').map((id) => getModuleManifest(id)))
const coreServices: Record<string, AuroraModuleManifest> = {
redis: { id: 'redis', name: 'Redis', version: '1.0.0', category: 'service', description: '', dependencies: [], conflicts: [], settings: [], aurora: { core: CORE_VERSION, moduleApi: MODULE_API_VERSION }, compose: { service: 'redis', image: 'redis:8-alpine', ports: [6379] } },
mailpit: { id: 'mailpit', name: 'Mailpit', version: '1.0.0', category: 'tool', description: '', dependencies: [], conflicts: [], settings: [], aurora: { core: CORE_VERSION, moduleApi: MODULE_API_VERSION }, compose: { service: 'mailpit', image: 'axllent/mailpit:latest', ports: [8025, 1025] } }
}
const manifests = await Promise.all(c.modules.filter((id) => id !== 'adminer').map((id) => coreServices[id] ?? getModuleManifest(id)))
const extras = (await Promise.all(manifests.map((m) => renderModuleService(m, c)))).filter(Boolean)
const volumes = [' db_data:']
if (c.modules.includes('redis') && c.moduleSettings?.redis?.persistence !== false) volumes.push(' redis_data:')
@@ -124,16 +129,18 @@ server {
export async function createProject(root: string, name: string, type: string, docroot: string, stack?: Partial<AuroraStackOptions>): Promise<void> {
await mkdir(root, { recursive: true })
const normalizedType = type || 'generic'
const defaultDocroot = docroot || (normalizedType === 'laravel' ? 'public' : normalizedType === 'drupal' ? 'web' : '')
const modules = normalizedType === 'generic' || normalizedType === 'php' ? [] : [normalizedType]
if (!type) throw new Error('Install and select an application module before creating a project')
const application = await getModuleManifest(type)
if (application.category !== 'application') throw new Error(`Module '${type}' cannot create application projects`)
const normalizedType = application.id
const defaultDocroot = docroot || application.defaults?.docroot || ''
const modules = [normalizedType]
if (stack?.adminer !== false) modules.push('adminer')
if (stack?.redis) modules.push('redis')
if (stack?.mailpit) modules.push('mailpit')
const config: AuroraConfig = { name, type: normalizedType, docroot: defaultDocroot, php: stack?.phpVersion || '8.4', node: stack?.nodeVersion || '24', webserver: 'nginx', database: stack?.database || 'mariadb', databaseVersion: stack?.databaseVersion || '11.8', modules, moduleSettings: {}, primaryProtocol: 'https', xdebug: stack?.xdebug === true }
await writeConfig(root, config); await writeNginx(root, defaultDocroot); await writePhpDockerfile(root, config.xdebug)
const reg = await loadRegistry(); reg.projects[name] = root; await saveRegistry(reg)
if (normalizedType === 'generic' || normalizedType === 'php') await writeFile(join(root, 'index.php'), `<?php echo '<h1>${name}</h1><p>Aurora Dockside is running.</p>';`)
}
export async function unregisterProject(name: string, deleteFiles: boolean): Promise<void> {
const reg = await loadRegistry(); const root = reg.projects[name]; delete reg.projects[name]; await saveRegistry(reg)
@@ -143,10 +150,11 @@ async function composeJson(root: string): Promise<any[]> {
try { const { stdout } = await execFileAsync('docker', ['compose', '-f', composePath(root), 'ps', '--format', 'json'], { env: AURORA_ENV, maxBuffer: 8*1024*1024 }); return stdout.trim().split('\n').filter(Boolean).map(x => JSON.parse(x)) } catch { return [] }
}
export async function listProjects(): Promise<AuroraProjectSummary[]> {
const reg = await loadRegistry(); const out: AuroraProjectSummary[] = []
const reg = await loadRegistry(); const out: AuroraProjectSummary[] = []; const availableModules = new Set((await getModuleRegistry()).map((module) => module.id))
for (const [name, root] of Object.entries(reg.projects)) {
try { await access(configPath(root)); const c = await readConfig(root); const ps = await composeJson(root); const running = ps.some(p => p.State === 'running'); const urls = projectUrls(c.name); const primary = c.primaryProtocol === 'http' ? urls.http : urls.https
out.push({ name, status: running?'running':'stopped', status_desc: running?'Running':'Stopped', type:c.type, approot:root, shortroot:root, docroot:c.docroot, primary_url:primary, httpurl:urls.http, httpsurl:urls.https, mutagen_enabled:false })
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 })
} catch { /* stale registry entry */ }
} return out
}
@@ -154,7 +162,9 @@ export async function describeProject(name: string): Promise<AuroraProjectDetail
const reg = await loadRegistry(); const root = reg.projects[name]; if (!root) throw new Error(`Aurora project '${name}' not found`)
const c = await readConfig(root); const ps = await composeJson(root); const running = ps.some(p=>p.State==='running'); const currentRouterStatus = await routerStatus(); const urlSet=projectUrls(c.name); const primary=c.primaryProtocol === 'http' ? urlSet.http : urlSet.https
const services: Record<string, any> = {}; for (const p of ps) services[p.Service]={short_name:p.Service,full_name:p.Name,status:p.State,image:p.Image,exposed_ports:'',host_ports:'',host_ports_mapping:[]}
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:'nginx',router:'file',router_status:currentRouterStatus,certificate_status:await certificateStatus(c.name),ca_trust_status:await caTrustStatus(),firefox_trust_status:await firefoxTrustStatus(),chromium_trust_status:await chromiumTrustStatus(),wordpress_multisite:c.type==='wordpress'?(c.wordpressMultisite??'none'):undefined,wordpress_network_admin_url:c.type==='wordpress'&&c.wordpressMultisite&&c.wordpressMultisite!=='none'?`${primary.replace(/\/$/,'')}/wp-admin/network/`:undefined,adminer_url:c.modules.includes('adminer')?`https://adminer.${projectHost(c.name)}`:undefined,services,xdebug_enabled:c.xdebug===true }
const legacyMultisite = c.wordpressMultisite ?? 'none'
const moduleMultisite = String(c.moduleMetadata?.multisite ?? legacyMultisite) as 'none' | 'subdirectory' | 'subdomain'
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:'nginx',router:'file',router_status:currentRouterStatus,certificate_status:await certificateStatus(c.name),ca_trust_status:await caTrustStatus(),firefox_trust_status:await firefoxTrustStatus(),chromium_trust_status:await chromiumTrustStatus(),wordpress_multisite:moduleMultisite,wordpress_network_admin_url:moduleMultisite!=='none'?`${primary.replace(/\/$/,'')}/wp-admin/network/`:undefined,adminer_url:c.modules.includes('adminer')?`https://adminer.${projectHost(c.name)}`:undefined,services,xdebug_enabled:c.xdebug===true }
}
export async function updateEnvironment(root:string, updates:{phpVersion?:string;nodeVersion?:string;database?:string;xdebugEnabled?:boolean;primaryProtocol?:'http'|'https'}):Promise<void>{
const c=await readConfig(root)
@@ -174,6 +184,12 @@ export async function setWordpressMultisite(root: string, mode: 'none' | 'subdir
await writeConfig(root, config)
}
export async function setProjectModuleMetadata(root: string, metadata: Record<string, string | number | boolean>): Promise<void> {
const config = await readConfig(root)
config.moduleMetadata = { ...config.moduleMetadata, ...metadata }
await writeConfig(root, config)
}
export async function routerStatus(): Promise<'running' | 'provider-error' | 'stopped'> {
try {
const { stdout } = await execFileAsync('docker', ['inspect', '-f', '{{.State.Running}}', 'aurora-router'], { env: AURORA_ENV })
@@ -240,17 +256,9 @@ export async function setModule(
await writePhpDockerfile(root, config.xdebug === true)
}
export async function scaffoldApplicationModule(name: string, moduleId: string): Promise<void> {
const root = await getProjectRoot(name)
export async function scaffoldApplicationModule(_name: string, moduleId: string): Promise<void> {
const module = await getModuleManifest(moduleId)
if (module.category !== 'application') return
if (moduleId === 'wordpress') {
await execFileAsync('docker', ['run', '--rm', '-v', `${root}:/app`, '-w', '/app', 'wordpress:cli', 'core', 'download', '--skip-content', '--force', '--allow-root'], { env: AURORA_ENV, maxBuffer: 16 * 1024 * 1024 })
return
}
const packageName = moduleId === 'laravel' ? 'laravel/laravel' : moduleId === 'drupal' ? 'drupal/recommended-project' : null
if (!packageName) return
await execFileAsync('docker', ['run', '--rm', '-v', `${root}:/app`, 'composer:2', 'sh', '-lc', `rm -rf /tmp/aurora-app && composer create-project ${packageName} /tmp/aurora-app --no-interaction && cp -a /tmp/aurora-app/. /app/`], { env: AURORA_ENV, maxBuffer: 32 * 1024 * 1024 })
}
export async function getProjectRoot(name:string):Promise<string>{const r=await loadRegistry();if(!r.projects[name])throw new Error(`Project ${name} not found`);return r.projects[name]}
@@ -411,7 +419,7 @@ async function writeRouterConfig(): Promise<void> {
await ensureProjectCertificate(config.name || name)
const safe = safeName(config.name || name)
const host = projectHost(config.name || name)
const rule = config.type === 'wordpress' && config.wordpressMultisite === 'subdomain'
const rule = config.moduleMetadata?.routingWildcard === true
? `Host(\`${host}\`) || HostRegexp(\`^[a-z0-9-]+\\.${host.replace(/\./g, '\\.')}$\`)`
: `Host(\`${host}\`)`
routers.push(
@@ -477,4 +485,3 @@ export async function powerOffProjects(): Promise<void> {
} catch { /* stale project or Docker unavailable */ }
}))
}
-2
View File
@@ -10,7 +10,6 @@ import { registerLogsIpc } from './ipc/logs'
import { registerCreateIpc } from './ipc/create'
import { registerWindowIpc } from './ipc/window'
import { registerSecretsIpc } from './ipc/secrets'
import { registerWordpressIpc } from './ipc/wordpress'
import { killAllRunningCommands, powerOffAllProjects } from './commandRunner'
function createWindow(): void {
@@ -68,7 +67,6 @@ app.whenReady().then(() => {
registerCreateIpc()
registerWindowIpc()
registerSecretsIpc()
registerWordpressIpc()
createWindow()
+6 -207
View File
@@ -1,214 +1,13 @@
import { dialog, ipcMain } from 'electron'
import { createProject, getProjectConfigByRoot } from '../auroraEngine'
import { runCommandStreamed } from '../commandRunner'
import { execFile } from 'child_process'
import { promisify } from 'util'
import { AURORA_ENV, ensureRouter, projectUrls, setWordpressMultisite } from '../auroraEngine'
import { saveSiteCredentials } from './secrets'
import { createProject } from '../auroraEngine'
import { runModuleProjectCreate } from '../moduleRuntime'
import type { AuroraStackOptions } from '../../shared/types'
const execFileAsync = promisify(execFile)
const safeName = (name: string): string =>
name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'project'
async function hostUserArgs(): Promise<string[]> {
if (process.platform === 'win32') return []
try {
const [{ stdout: uid }, { stdout: gid }] = await Promise.all([
execFileAsync('id', ['-u'], { env: AURORA_ENV }),
execFileAsync('id', ['-g'], { env: AURORA_ENV })
])
return ['--user', `${uid.trim()}:${gid.trim()}`]
} catch {
return []
}
}
async function wordpressBaseArgs(directory: string, network = false): Promise<string[]> {
const userArgs = await hostUserArgs()
const config = await getProjectConfigByRoot(directory)
return [
'run', '--rm',
...userArgs,
'-e', 'HOME=/tmp',
'-e', 'WP_CLI_CACHE_DIR=/tmp/wp-cli-cache',
...(network ? ['--network', `aurora-${safeName(config.name)}_default`] : []),
'-v', `${directory}:/app`,
'-w', '/app',
'--entrypoint', 'php',
'wordpress:cli',
'-d', 'memory_limit=512M',
'/usr/local/bin/wp'
]
}
async function currentProjectUrl(directory: string): Promise<string> {
const config = await getProjectConfigByRoot(directory)
await ensureRouter()
// Aurora provisions WordPress with HTTPS as its canonical URL. WP-CLI writes
// the URL directly and does not need to make a browser-trusted TLS request.
return projectUrls(config.name).https
}
export function registerCreateIpc(): void {
ipcMain.handle('create:pickDirectory', async () => {
const r = await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] })
return r.canceled ? null : r.filePaths[0]
const result = await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] })
return result.canceled ? null : result.filePaths[0]
})
ipcMain.handle('create:configure', async (_e, _id: string, directory: string, name: string, type: string, docroot: string, stack?: Partial<AuroraStackOptions>) =>
createProject(directory, name, type, docroot, stack)
)
ipcMain.handle('create:downloadWordpress', async (e, id: string, directory: string, locale: string) => {
const args = await wordpressBaseArgs(directory)
return runCommandStreamed(
id,
'docker',
[...args, 'core', 'download', `--locale=${locale || 'en_US'}`, '--force'],
e.sender,
{ cwd: directory }
)
})
ipcMain.handle(
'create:setupWordpress',
async (
e,
id: string,
directory: string,
_siteUrl: string,
title: string,
user: string,
password: string,
email: string,
multisite: 'none' | 'subdirectory' | 'subdomain'
) => {
const base = await wordpressBaseArgs(directory, true)
const siteUrl = await currentProjectUrl(directory)
// wp-config.php is generated against Docker's internal database hostname.
await runCommandStreamed(
`${id}-config`,
'docker',
[...base, 'config', 'create', '--dbname=db', '--dbuser=db', '--dbpass=db', '--dbhost=db:3306', '--skip-check', '--force'],
e.sender,
{ cwd: directory }
)
// Always establish a known-good single-site installation first. Multisite
// is a conversion step, not an alternate bootstrap path. This gives us a
// populated database to verify before attempting network conversion.
await runCommandStreamed(
`${id}-install`,
'docker',
[
...base,
'core', 'install',
`--url=${siteUrl}`,
`--title=${title}`,
`--admin_user=${user}`,
`--admin_password=${password}`,
`--admin_email=${email}`,
'--skip-email'
],
e.sender,
{ cwd: directory }
)
// Hard gate #1: never continue from an empty/partial WordPress database.
await runCommandStreamed(
`${id}-verify-install`,
'docker',
[...base, 'core', 'is-installed'],
e.sender,
{ cwd: directory }
)
if (multisite !== 'none') {
const multisiteArgs = multisite === 'subdomain' ? ['--subdomains'] : []
await runCommandStreamed(
`${id}-multisite-convert`,
'docker',
[
...base,
'core', 'multisite-convert',
`--title=${title}`,
...multisiteArgs
],
e.sender,
{ cwd: directory }
)
// Hard gate #2: a multisite project is not ready unless WP-CLI can see
// the network installation. Any non-zero exit propagates to Dockside.
await runCommandStreamed(
`${id}-verify-network`,
'docker',
[...base, 'core', 'is-installed', '--network'],
e.sender,
{ cwd: directory }
)
// Verify the network table itself as a second independent sanity check.
await runCommandStreamed(
`${id}-verify-network-db`,
'docker',
[...base, 'db', 'query', "SHOW TABLES LIKE 'wp_blogs';", '--skip-column-names'],
e.sender,
{ cwd: directory }
)
}
await setWordpressMultisite(directory, multisite)
await ensureRouter()
// Development-friendly defaults. Failure here should fail creation so the
// user never gets a project Aurora claims is fully provisioned when it is not.
await runCommandStreamed(
`${id}-defaults`,
'docker',
[
...base,
'rewrite', 'structure', '/%postname%/', '--hard'
],
e.sender,
{ cwd: directory }
)
await runCommandStreamed(
`${id}-debug`,
'docker',
[...base, 'config', 'set', 'WP_DEBUG', 'true', '--raw'],
e.sender,
{ cwd: directory }
)
await runCommandStreamed(
`${id}-env`,
'docker',
[...base, 'config', 'set', 'WP_ENVIRONMENT_TYPE', 'local'],
e.sender,
{ cwd: directory }
)
// Store the original development login locally after every provisioning
// gate has succeeded. WordPress stores only a password hash, so Aurora
// must retain the original password if it is to display it later.
await saveSiteCredentials(directory, {
platform: 'wordpress',
adminUrl: `${siteUrl.replace(/\/$/, '')}/wp-admin/`,
username: user,
password,
email
})
}
)
ipcMain.handle('create:downloadDrupal', async (e, id: string, directory: string) =>
runCommandStreamed(id, 'docker', ['run', '--rm', '-v', `${directory}:/app`, '-w', '/app', 'composer:2', 'create-project', 'drupal/recommended-project', '.', '--no-interaction'], e.sender, { cwd: directory })
)
ipcMain.handle('create:requireDrush', async (e, id: string, directory: string) =>
runCommandStreamed(id, 'docker', ['run', '--rm', '-v', `${directory}:/app`, '-w', '/app', 'composer:2', 'require', 'drush/drush', '--no-interaction'], e.sender, { cwd: directory })
)
ipcMain.handle('create:setupDrupal', async () => {})
ipcMain.handle('create:configure', (_event, _id: string, directory: string, name: string, type: string, docroot: string, stack?: Partial<AuroraStackOptions>) => createProject(directory, name, type, docroot, stack))
ipcMain.handle('create:runModuleProjectCreate', (event, id: string, moduleId: string, directory: string, name: string, settings: Record<string, string | number | boolean>) => runModuleProjectCreate(moduleId, id, directory, name, settings, event.sender))
}
+9 -1
View File
@@ -1,9 +1,17 @@
import { ipcMain } from 'electron'
import { dialog, ipcMain } from 'electron'
import { getProjectRoot, listInstalledModules, listModules, scaffoldApplicationModule, setModule } from '../auroraEngine'
import { runCommandStreamed } from '../commandRunner'
import { installModulePackage, uninstallModulePackage } from '../moduleRegistry'
export function registerModulesIpc(): void {
ipcMain.handle('modules:listRegistry', () => listModules())
ipcMain.handle('modules:pickAndInstallPackage', async () => {
const picked = await dialog.showOpenDialog({ properties: ['openDirectory'] })
if (picked.canceled || !picked.filePaths[0]) return null
return installModulePackage(picked.filePaths[0])
})
ipcMain.handle('modules:installPackage', (_event, source: string) => installModulePackage(source))
ipcMain.handle('modules:uninstallPackage', (_event, id: string) => uninstallModulePackage(id))
ipcMain.handle('modules:listInstalled', (_event, name: string) => listInstalledModules(name))
ipcMain.handle(
'modules:install',
+1 -15
View File
@@ -1,10 +1,7 @@
import { ipcMain } from 'electron'
import { spawn } from 'child_process'
import { listProjects, describeProject, getProjectRoot, unregisterProject, updateEnvironment, ensureRouter, getProjectConfig, projectUrls, AURORA_ENV, trustAuroraCA } from '../auroraEngine'
import { listProjects, describeProject, getProjectRoot, unregisterProject, updateEnvironment, ensureRouter, trustAuroraCA } from '../auroraEngine'
import { runCommandStreamed } from '../commandRunner'
import { execFile } from 'child_process'
import { promisify } from 'util'
const execFileAsync = promisify(execFile)
const composeArgs=(root:string,...args:string[])=>['compose','-f',`${root}/.aurora/compose.yaml`,...args]
const allowedServices = new Set(['web','php','db','node','adminer','redis','mailpit'])
export function registerProjectsIpc():void{
@@ -29,16 +26,5 @@ export function registerProjectsIpc():void{
ipcMain.handle('projects:trustCA',async()=>{ await trustAuroraCA(); await ensureRouter() })
ipcMain.handle('projects:updateEnvironment',async(_e,_id:string,_name:string,root:string,updates:any)=>{
await updateEnvironment(root,updates)
if (updates.primaryProtocol) {
const config = await getProjectConfig(root)
if (config.type === 'wordpress') {
const url = projectUrls(config.name)[updates.primaryProtocol === 'http' ? 'http' : 'https']
const network = `aurora-${config.name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'project'}_default`
// Keep WordPress' canonical home/siteurl aligned with Dockside's primary protocol.
// WP-CLI talks to MariaDB over the project network; it does not need to trust local TLS.
await execFileAsync('docker', ['run','--rm','--network',network,'-e','HOME=/tmp','-v',`${root}:/app`,'-w','/app','wordpress:cli','option','update','home',url,'--allow-root'], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
await execFileAsync('docker', ['run','--rm','--network',network,'-e','HOME=/tmp','-v',`${root}:/app`,'-w','/app','wordpress:cli','option','update','siteurl',url,'--allow-root'], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
}
}
})
}
-55
View File
@@ -1,55 +0,0 @@
import { ipcMain } from 'electron'
import { runCommandStreamed } from '../commandRunner'
import { getProjectConfig, getProjectRoot } from '../auroraEngine'
const safeName = (name: string): string =>
name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'project'
function wpDockerArgs(name: string, root: string, wpArgs: string[]): string[] {
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
const gid = typeof process.getgid === 'function' ? process.getgid() : undefined
const userArgs = uid !== undefined && gid !== undefined ? ['--user', `${uid}:${gid}`] : []
return [
'run', '--rm',
...userArgs,
'--network', `aurora-${safeName(name)}_default`,
'-e', 'HOME=/tmp',
'-e', 'WP_CLI_CACHE_DIR=/tmp/wp-cli-cache',
'-v', `${root}:/app`,
'-w', '/app',
'wordpress:cli',
...wpArgs
]
}
export function registerWordpressIpc(): void {
ipcMain.handle('wordpress:run', async (event, operationId: string, name: string, action: string, payload?: string) => {
const root = await getProjectRoot(name)
const config = await getProjectConfig(root)
if (config.type !== 'wordpress') throw new Error(`Project '${name}' is not a WordPress project`)
let args: string[]
switch (action) {
case 'cache-flush': args = ['cache', 'flush']; break
case 'rewrite-flush': args = ['rewrite', 'flush']; break
case 'maintenance-on': args = ['maintenance-mode', 'activate']; break
case 'maintenance-off': args = ['maintenance-mode', 'deactivate']; break
case 'debug-on': args = ['config', 'set', 'WP_DEBUG', 'true', '--raw']; break
case 'debug-off': args = ['config', 'set', 'WP_DEBUG', 'false', '--raw']; break
case 'core-version': args = ['core', 'version']; break
case 'plugin-list': args = ['plugin', 'list']; break
case 'theme-list': args = ['theme', 'list']; break
case 'search-replace': {
const [from, to] = (payload ?? '').split('\n')
if (!from || !to) throw new Error('Search and replacement values are required')
args = ['search-replace', from, to, '--all-tables-with-prefix', '--precise', '--report-changed-only']
if (config.wordpressMultisite && config.wordpressMultisite !== 'none') args.push('--network')
break
}
default: throw new Error(`Unknown WordPress action '${action}'`)
}
return runCommandStreamed(operationId, 'docker', wpDockerArgs(name, root, args), event.sender, { cwd: root })
})
}
+36
View File
@@ -0,0 +1,36 @@
import { mkdtemp, mkdir, symlink, writeFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({ app: { getPath: () => '/unused' } }))
import { getModuleRegistry, installModulePackage, uninstallModulePackage, validateModuleManifest } from './moduleRegistry'
const roots: string[] = []
async function temp(prefix: string): Promise<string> { const root = await mkdtemp(join(tmpdir(), prefix)); roots.push(root); return root }
const manifest = { id: 'sample-app', name: 'Sample', version: '1.0.0', category: 'application', description: 'test', aurora: { core: '2.0.0-alpha.24', moduleApi: '1.0.0' }, dependencies: [], conflicts: [], settings: [] }
async function packageDir(value = manifest): Promise<string> { const root = await temp('aurora-package-'); await writeFile(join(root, 'manifest.json'), JSON.stringify(value)); return root }
afterEach(async () => { const { rm } = await import('fs/promises'); await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) })
describe('external module registry', () => {
it('starts empty', async () => expect(await getModuleRegistry(await temp('aurora-user-'))).toEqual([]))
it('installs a valid local package and refreshes after install', async () => {
const userData = await temp('aurora-user-'); const source = await packageDir()
await installModulePackage(source, userData)
expect((await getModuleRegistry(userData)).map((item) => item.id)).toEqual(['sample-app'])
})
it('rejects invalid and incompatible manifests', () => {
expect(() => validateModuleManifest({ ...manifest, id: '../escape' })).toThrow(/module id/)
expect(() => validateModuleManifest({ ...manifest, aurora: { core: '9.0.0', moduleApi: '1.0.0' } })).toThrow(/requires Aurora Core/)
})
it('rejects symbolic links in package paths', async () => {
const userData = await temp('aurora-user-'); const source = await packageDir(); await mkdir(join(source, 'main')); await symlink('/tmp', join(source, 'main', 'escape'))
await expect(installModulePackage(source, userData)).rejects.toThrow(/symbolic links/)
})
it('refreshes after uninstall without touching source', async () => {
const userData = await temp('aurora-user-'); const source = await packageDir(); await installModulePackage(source, userData); await uninstallModulePackage('sample-app', userData)
expect(await getModuleRegistry(userData)).toEqual([])
expect(await import('fs/promises').then(({ stat }) => stat(join(source, 'manifest.json')))).toBeTruthy()
})
})
+93 -12
View File
@@ -1,24 +1,105 @@
import { app } from 'electron'
import { readFile, readdir } from 'fs/promises'
import { join } from 'path'
import type { AuroraModuleManifest } from '../shared/types'
import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat } from 'fs/promises'
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path'
import type { AuroraModuleInstallResult, AuroraModuleManifest, AuroraModuleSetting } from '../shared/types'
export const CORE_VERSION = '2.0.0-alpha.24'
export const MODULE_API_VERSION = '1.0.0'
let cache: AuroraModuleManifest[] | null = null
function moduleDirectory(): string {
return join(app.getAppPath(), 'resources', 'modules')
export function moduleDirectory(userData = app.getPath('userData')): string { return join(userData, 'modules') }
function validVersion(value: unknown): value is string { return typeof value === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value) }
function compatible(range: string, version: string): boolean {
if (range === '*' || range === version) return true
const major = version.split('.')[0]
return range === `^${major}.0.0` || range.split(/\s+/).includes(version)
}
function validateSettings(value: unknown, field: string): asserts value is AuroraModuleSetting[] {
if (!Array.isArray(value)) throw new Error(`${field} must be an array`)
for (const item of value) {
if (!item || typeof item !== 'object') throw new Error(`${field} entries must be objects`)
const setting = item as Record<string, unknown>
if (typeof setting.id !== 'string' || !/^[a-z][a-z0-9_-]*$/.test(setting.id)) throw new Error(`Invalid ${field} id`)
if (typeof setting.label !== 'string' || !['boolean', 'select', 'text', 'number'].includes(String(setting.type))) throw new Error(`Invalid ${field} entry '${setting.id}'`)
if (setting.type === 'select' && (!Array.isArray(setting.options) || !setting.options.every((x) => typeof x === 'string'))) throw new Error(`Select setting '${setting.id}' requires string options`)
}
}
export async function getModuleRegistry(): Promise<AuroraModuleManifest[]> {
if (cache) return cache
const dir = moduleDirectory()
const files = (await readdir(dir)).filter((file) => file.endsWith('.json')).sort()
cache = await Promise.all(files.map(async (file) => JSON.parse(await readFile(join(dir, file), 'utf8')) as AuroraModuleManifest))
return cache
export function validateModuleManifest(value: unknown): AuroraModuleManifest {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Module manifest must be an object')
const m = value as Record<string, unknown>
if (typeof m.id !== 'string' || !/^[a-z][a-z0-9-]{1,63}$/.test(m.id)) throw new Error('Invalid module id')
if (typeof m.name !== 'string' || !m.name.trim()) throw new Error('Invalid module name')
if (!validVersion(m.version)) throw new Error('Invalid module version')
if (!['application', 'service', 'tool'].includes(String(m.category))) throw new Error('Invalid module category')
if (typeof m.description !== 'string') throw new Error('Invalid module description')
for (const field of ['dependencies', 'conflicts'] as const) if (!Array.isArray(m[field]) || !(m[field] as unknown[]).every((x) => typeof x === 'string')) throw new Error(`${field} must be a string array`)
validateSettings(m.settings, 'settings')
const aurora = m.aurora as Record<string, unknown> | undefined
if (!aurora || typeof aurora.core !== 'string' || typeof aurora.moduleApi !== 'string') throw new Error('Manifest must declare aurora.core and aurora.moduleApi')
if (!compatible(aurora.core, CORE_VERSION)) throw new Error(`Module requires Aurora Core '${aurora.core}', running '${CORE_VERSION}'`)
if (!compatible(aurora.moduleApi, MODULE_API_VERSION)) throw new Error(`Module API '${aurora.moduleApi}' is incompatible with '${MODULE_API_VERSION}'`)
if (m.creation && typeof m.creation === 'object') validateSettings((m.creation as Record<string, unknown>).setup ?? [], 'creation.setup')
return value as AuroraModuleManifest
}
async function assertSafePackageTree(root: string): Promise<void> {
const rootReal = await realpath(root)
async function visit(directory: string): Promise<void> {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name)
const info = await lstat(path)
if (info.isSymbolicLink()) throw new Error(`Module packages may not contain symbolic links: ${relative(root, path)}`)
const resolved = await realpath(path)
if (resolved !== rootReal && !resolved.startsWith(`${rootReal}${sep}`)) throw new Error('Unsafe module package path')
if (entry.isDirectory()) await visit(path)
}
}
await visit(root)
}
export async function getModuleRegistry(userData?: string): Promise<AuroraModuleManifest[]> {
if (!userData && cache) return cache
const dir = moduleDirectory(userData)
await mkdir(dir, { recursive: true })
const manifests: AuroraModuleManifest[] = []
for (const entry of (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
if (!entry.isDirectory()) continue
try { manifests.push(validateModuleManifest(JSON.parse(await readFile(join(dir, entry.name, 'manifest.json'), 'utf8')))) } catch { /* omit damaged installations */ }
}
if (!userData) cache = manifests
return manifests
}
export async function getModuleManifest(id: string): Promise<AuroraModuleManifest> {
const module = (await getModuleRegistry()).find((item) => item.id === id)
if (!module) throw new Error(`Unknown Aurora module '${id}'`)
if (!module) throw new Error(`Required Aurora module '${id}' is not installed`)
return module
}
export function invalidateModuleRegistry(): void { cache = null }
export async function installModulePackage(source: string, userData?: string): Promise<AuroraModuleInstallResult> {
if (!isAbsolute(source)) throw new Error('Module package path must be absolute')
if (!(await stat(source)).isDirectory()) throw new Error('Select an unpacked local module directory')
await assertSafePackageTree(source)
const manifest = validateModuleManifest(JSON.parse(await readFile(join(source, 'manifest.json'), 'utf8')))
const modules = moduleDirectory(userData)
await mkdir(modules, { recursive: true })
const destination = resolve(modules, manifest.id)
if (dirname(destination) !== resolve(modules) || basename(destination) !== manifest.id) throw new Error('Unsafe module destination')
const staging = join(modules, `.${manifest.id}-${process.pid}-${Date.now()}`)
await cp(source, staging, { recursive: true, errorOnExist: true })
await rm(destination, { recursive: true, force: true })
await rename(staging, destination)
invalidateModuleRegistry()
return { manifest, installedPath: destination }
}
export async function uninstallModulePackage(id: string, userData?: string): Promise<void> {
if (!/^[a-z][a-z0-9-]{1,63}$/.test(id)) throw new Error('Invalid module id')
const modules = resolve(moduleDirectory(userData))
const destination = resolve(modules, id)
if (dirname(destination) !== modules) throw new Error('Unsafe module destination')
await rm(destination, { recursive: true, force: true })
invalidateModuleRegistry()
}
+33
View File
@@ -0,0 +1,33 @@
import { createRequire } from 'module'
import { dirname, join, resolve, sep } from 'path'
import type { WebContents } from 'electron'
import { getModuleManifest, moduleDirectory } from './moduleRegistry'
import { runCommandStreamed } from './commandRunner'
import { ensureRouter, getProjectConfigByRoot, projectUrls, setProjectModuleMetadata } from './auroraEngine'
import { saveSiteCredentials } from './ipc/secrets'
type Settings = Record<string, string | number | boolean>
type ExternalHook = (context: Record<string, unknown>) => Promise<void>
export async function runModuleProjectCreate(moduleId: string, operationId: string, directory: string, projectName: string, settings: Settings, sender: WebContents): Promise<void> {
const manifest = await getModuleManifest(moduleId)
if (!manifest.main) return
const root = resolve(moduleDirectory(), moduleId)
const entry = resolve(root, manifest.main)
if (entry !== root && !entry.startsWith(`${root}${sep}`)) throw new Error('Unsafe module main entry')
const loaded = createRequire(join(dirname(entry), 'loader.cjs'))(entry) as { projectCreate?: ExternalHook }
if (typeof loaded.projectCreate !== 'function') return
const config = await getProjectConfigByRoot(directory)
const urls = projectUrls(config.name)
await loaded.projectCreate(Object.freeze({
moduleId,
directory,
projectName,
settings: Object.freeze({ ...settings }),
urls: Object.freeze(urls),
run: (suffix: string, command: string, args: string[]) => runCommandStreamed(`${operationId}-${suffix}`, command, args, sender, { cwd: directory }),
ensureRouter,
setProjectMetadata: (metadata: Record<string, string | number | boolean>) => setProjectModuleMetadata(directory, metadata),
saveCredentials: (credentials: { platform: string; adminUrl: string; username: string; password: string; email: string }) => saveSiteCredentials(directory, credentials)
}))
}