feat: add external module architecture
This commit is contained in:
+27
-20
@@ -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 */ }
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user