feat: complete external module lifecycle SDK
This commit is contained in:
@@ -10,7 +10,7 @@ const execFileAsync = promisify(execFile)
|
||||
const EXTRA_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/opt/local/bin']
|
||||
export const AURORA_ENV = { ...process.env, PATH: [...EXTRA_PATH_DIRS, process.env.PATH].join(':') }
|
||||
|
||||
type AuroraConfig = {
|
||||
export type AuroraConfig = {
|
||||
name: string
|
||||
type: string
|
||||
docroot: string
|
||||
|
||||
+22
-17
@@ -1,7 +1,8 @@
|
||||
import { dialog, ipcMain } from 'electron'
|
||||
import { getProjectRoot, listInstalledModules, listModules, scaffoldApplicationModule, setModule } from '../auroraEngine'
|
||||
import { getProjectConfig, getProjectRoot, listInstalledModules, listModules, scaffoldApplicationModule, setModule } from '../auroraEngine'
|
||||
import { runCommandStreamed } from '../commandRunner'
|
||||
import { getAvailableModulePackages, installModulePackage, uninstallModulePackage } from '../moduleRegistry'
|
||||
import { runModuleLifecycleHook, runModuleProjectTool } from '../moduleRuntime'
|
||||
|
||||
export function registerModulesIpc(): void {
|
||||
ipcMain.handle('modules:listRegistry', () => listModules())
|
||||
@@ -15,7 +16,10 @@ export function registerModulesIpc(): void {
|
||||
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:uninstallPackage', async (event, operationId: string, id: string) => {
|
||||
try { await runModuleLifecycleHook(id, 'packageUninstall', { operationId, sender: event.sender }); await uninstallModulePackage(id); if (!event.sender.isDestroyed()) event.sender.send('terminal:exit', { operationId, exitCode: 0, cancelled: false }) }
|
||||
catch (error) { if (!event.sender.isDestroyed()) event.sender.send('terminal:exit', { operationId, exitCode: 1, cancelled: false }); throw error }
|
||||
})
|
||||
ipcMain.handle('modules:listInstalled', (_event, name: string) => listInstalledModules(name))
|
||||
ipcMain.handle(
|
||||
'modules:install',
|
||||
@@ -29,27 +33,28 @@ export function registerModulesIpc(): void {
|
||||
await setModule(name, moduleId, true, settings)
|
||||
await scaffoldApplicationModule(name, moduleId)
|
||||
const root = await getProjectRoot(name)
|
||||
return runCommandStreamed(
|
||||
operationId,
|
||||
'docker',
|
||||
['compose', '-f', `${root}/.aurora/compose.yaml`, 'up', '-d', '--remove-orphans'],
|
||||
event.sender,
|
||||
{ cwd: root }
|
||||
)
|
||||
try {
|
||||
await runCommandStreamed(operationId, 'docker', ['compose', '-f', `${root}/.aurora/compose.yaml`, 'up', '-d', '--remove-orphans'], event.sender, { cwd: root, emitExit: false })
|
||||
await runModuleLifecycleHook(moduleId, 'projectCreate', { directory: root, projectName: name, settings, operationId, sender: event.sender })
|
||||
if (!event.sender.isDestroyed()) event.sender.send('terminal:exit', { operationId, exitCode: 0, cancelled: false })
|
||||
} catch (error) { if (!event.sender.isDestroyed()) event.sender.send('terminal:exit', { operationId, exitCode: 1, cancelled: false }); throw error }
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'modules:remove',
|
||||
async (event, operationId: string, name: string, moduleId: string) => {
|
||||
await setModule(name, moduleId, false)
|
||||
const root = await getProjectRoot(name)
|
||||
return runCommandStreamed(
|
||||
operationId,
|
||||
'docker',
|
||||
['compose', '-f', `${root}/.aurora/compose.yaml`, 'up', '-d', '--remove-orphans'],
|
||||
event.sender,
|
||||
{ cwd: root }
|
||||
)
|
||||
try {
|
||||
const config = await getProjectConfig(root)
|
||||
await runModuleLifecycleHook(moduleId, 'projectRemove', { directory: root, projectName: name, settings: config.moduleSettings?.[moduleId], operationId, sender: event.sender })
|
||||
await setModule(name, moduleId, false)
|
||||
await runCommandStreamed(operationId, 'docker', ['compose', '-f', `${root}/.aurora/compose.yaml`, 'up', '-d', '--remove-orphans'], event.sender, { cwd: root, emitExit: false })
|
||||
if (!event.sender.isDestroyed()) event.sender.send('terminal:exit', { operationId, exitCode: 0, cancelled: false })
|
||||
} catch (error) { if (!event.sender.isDestroyed()) event.sender.send('terminal:exit', { operationId, exitCode: 1, cancelled: false }); throw error }
|
||||
}
|
||||
)
|
||||
ipcMain.handle('modules:runProjectTool', async (event, operationId: string, name: string, moduleId: string, toolId: string) => {
|
||||
const root = await getProjectRoot(name)
|
||||
return runModuleProjectTool(moduleId, toolId, operationId, root, event.sender)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { ipcMain, type WebContents } from 'electron'
|
||||
import { spawn } from 'child_process'
|
||||
import { listProjects, describeProject, getProjectRoot, unregisterProject, updateEnvironment, ensureRouter, trustAuroraCA } from '../auroraEngine'
|
||||
import { runCommandStreamed } from '../commandRunner'
|
||||
const composeArgs=(root:string,...args:string[])=>['compose','-f',`${root}/.aurora/compose.yaml`,...args]
|
||||
import { runProjectLifecycleHooks } from '../moduleRuntime'
|
||||
import type { EnvironmentUpdate } from '../../shared/types'
|
||||
const composeArgs=(root:string,...args:string[]):string[]=>['compose','-f',`${root}/.aurora/compose.yaml`,...args]
|
||||
const allowedServices = new Set(['web','php','db','node','adminer','redis','mailpit'])
|
||||
function launchTerminal(command:string,args:string[]):Promise<void>{return new Promise((resolve,reject)=>{const child=spawn(command,args,{detached:true,stdio:'ignore'});child.once('error',reject);child.once('spawn',()=>{child.unref();resolve()})})}
|
||||
async function startProject(operationId:string,name:string,sender:WebContents,forceRecreate=false):Promise<void>{
|
||||
const root=await getProjectRoot(name); await updateEnvironment(root,{}); await ensureRouter()
|
||||
try { await runCommandStreamed(operationId,'docker',composeArgs(root,'up','-d','--build',...(forceRecreate?['--force-recreate']:[]),'--remove-orphans'),sender,{cwd:root,emitExit:false}); await runProjectLifecycleHooks(root,'projectStart',operationId,sender); if(!sender.isDestroyed())sender.send('terminal:exit',{operationId,exitCode:0,cancelled:false}) }
|
||||
catch(error){if(!sender.isDestroyed())sender.send('terminal:exit',{operationId,exitCode:1,cancelled:false});throw error}
|
||||
}
|
||||
export function registerProjectsIpc():void{
|
||||
ipcMain.handle('projects:list',()=>listProjects()); ipcMain.handle('projects:describe',(_e,name:string)=>describeProject(name))
|
||||
ipcMain.handle('projects:start',async(e,id:string,name:string)=>{const root=await getProjectRoot(name);await updateEnvironment(root,{});await ensureRouter();return runCommandStreamed(id,'docker',composeArgs(root,'up','-d','--build','--remove-orphans'),e.sender,{cwd:root})})
|
||||
ipcMain.handle('projects:start',(e,id:string,name:string)=>startProject(id,name,e.sender))
|
||||
ipcMain.handle('projects:stop',async(e,id:string,name:string)=>{const root=await getProjectRoot(name);return runCommandStreamed(id,'docker',composeArgs(root,'down'),e.sender,{cwd:root})})
|
||||
ipcMain.handle('projects:restart',async(e,id:string,name:string)=>{const root=await getProjectRoot(name);await updateEnvironment(root,{});await ensureRouter();return runCommandStreamed(id,'docker',composeArgs(root,'up','-d','--build','--force-recreate','--remove-orphans'),e.sender,{cwd:root})})
|
||||
ipcMain.handle('projects:restart',(e,id:string,name:string)=>startProject(id,name,e.sender,true))
|
||||
ipcMain.handle('projects:restartService',async(e,id:string,name:string,service:string)=>{if(!allowedServices.has(service))throw new Error('Invalid service');const root=await getProjectRoot(name);return runCommandStreamed(id,'docker',composeArgs(root,'restart',service),e.sender,{cwd:root})})
|
||||
ipcMain.handle('projects:phpInfo',async(e,id:string,name:string)=>{const root=await getProjectRoot(name);return runCommandStreamed(id,'docker',composeArgs(root,'exec','-T','php','php','-i'),e.sender,{cwd:root})})
|
||||
ipcMain.handle('projects:openTerminal',async(_e,name:string)=>{const root=await getProjectRoot(name);const candidates: Array<[string,string[]]>=process.platform==='linux'?[['ptyxis',['--new-window','--working-directory',root,'--title',`Aurora · ${name}`]],['x-terminal-emulator',['--new-window','--working-directory',root]],['gnome-terminal',[`--working-directory=${root}`]],['kgx',['--working-directory',root]],['konsole',['--workdir',root]]]:[];for(const [cmd,args] of candidates){try{await launchTerminal(cmd,args);return}catch{}}throw new Error('No supported terminal application was found.')})
|
||||
ipcMain.handle('projects:openTerminal',async(_e,name:string)=>{const root=await getProjectRoot(name);const candidates: Array<[string,string[]]>=process.platform==='linux'?[['ptyxis',['--new-window','--working-directory',root,'--title',`Aurora · ${name}`]],['x-terminal-emulator',['--new-window','--working-directory',root]],['gnome-terminal',[`--working-directory=${root}`]],['kgx',['--working-directory',root]],['konsole',['--workdir',root]]]:[];for(const [cmd,args] of candidates){try{await launchTerminal(cmd,args);return}catch{/* try the next supported terminal */}}throw new Error('No supported terminal application was found.')})
|
||||
ipcMain.handle('projects:delete',async(e,id:string,name:string,approot:string,deleteFiles:boolean)=>{
|
||||
let root=approot
|
||||
try { root=await getProjectRoot(name) } catch { /* use renderer-provided root for stale entries */ }
|
||||
try { root=await getProjectRoot(name) } catch { /* use renderer-provided root for stale entries */ }
|
||||
try { await runProjectLifecycleHooks(root,'projectRemove',id,e.sender) } catch (error) { console.warn(`Aurora module cleanup for '${name}' failed:`, error) }
|
||||
try {
|
||||
await runCommandStreamed(id,'docker',composeArgs(root,'down','-v','--remove-orphans'),e.sender,{cwd:root})
|
||||
} catch (error) {
|
||||
@@ -25,7 +33,7 @@ export function registerProjectsIpc():void{
|
||||
await unregisterProject(name,deleteFiles)
|
||||
})
|
||||
ipcMain.handle('projects:trustCA',async()=>{ await trustAuroraCA(); await ensureRouter() })
|
||||
ipcMain.handle('projects:updateEnvironment',async(_e,_id:string,_name:string,root:string,updates:any)=>{
|
||||
ipcMain.handle('projects:updateEnvironment',async(_e,_id:string,_name:string,root:string,updates:EnvironmentUpdate)=>{
|
||||
await updateEnvironment(root,updates)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@ export function validateModuleManifest(value: unknown): AuroraModuleManifest {
|
||||
if (action.hiddenValues !== undefined && !Array.isArray(action.hiddenValues)) throw new Error('Invalid project action hiddenValues')
|
||||
}
|
||||
}
|
||||
if (project.tools !== undefined) {
|
||||
if (!Array.isArray(project.tools)) throw new Error('project.tools must be an array')
|
||||
for (const toolValue of project.tools) {
|
||||
const tool = toolValue as Record<string, unknown>
|
||||
if (!tool || typeof tool !== 'object' || typeof tool.id !== 'string' || !/^[a-z][a-z0-9_-]*$/.test(tool.id) || typeof tool.label !== 'string' || !tool.label.trim()) throw new Error('Invalid project tool')
|
||||
}
|
||||
}
|
||||
}
|
||||
return value as AuroraModuleManifest
|
||||
}
|
||||
|
||||
+84
-24
@@ -1,38 +1,98 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { createRequire } from 'module'
|
||||
import { promisify } from 'util'
|
||||
import { dirname, join, resolve, sep } from 'path'
|
||||
import type { WebContents } from 'electron'
|
||||
import type { AuroraModuleLifecycleHook } from '../shared/types'
|
||||
import { getModuleManifest, moduleDirectory } from './moduleRegistry'
|
||||
import { runCommandStreamed } from './commandRunner'
|
||||
import { ensureRouter, getProjectConfigByRoot, projectUrls, setProjectModuleMetadata } from './auroraEngine'
|
||||
import { AURORA_ENV, ensureRouter, getProjectConfig, projectUrls, setProjectModuleMetadata } from './auroraEngine'
|
||||
import { saveSiteCredentials } from './ipc/secrets'
|
||||
|
||||
type Settings = Record<string, string | number | boolean>
|
||||
type ExternalHook = (context: Record<string, unknown>) => Promise<void>
|
||||
type ExternalHook = (context: Record<string, unknown>) => Promise<void> | void
|
||||
type LoadedModule = Partial<Record<AuroraModuleLifecycleHook, ExternalHook>>
|
||||
type HookOptions = { directory?: string; projectName?: string; settings?: Settings; toolId?: string; operationId?: string; sender?: WebContents }
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const CORE_MODULE_IDS = new Set(['adminer', 'redis', 'mailpit'])
|
||||
|
||||
function sendExit(sender: WebContents, operationId: string, exitCode: number): void {
|
||||
if (!sender.isDestroyed()) sender.send('terminal:exit', { operationId, exitCode, cancelled: false })
|
||||
}
|
||||
|
||||
function loadModule(moduleId: string, main: string): LoadedModule {
|
||||
const root = resolve(moduleDirectory(), moduleId)
|
||||
const entry = resolve(root, main)
|
||||
if (entry !== root && !entry.startsWith(`${root}${sep}`)) throw new Error('Unsafe module main entry')
|
||||
return createRequire(join(dirname(entry), 'loader.cjs'))(entry) as LoadedModule
|
||||
}
|
||||
|
||||
export async function runModuleLifecycleHook(moduleId: string, hook: AuroraModuleLifecycleHook, options: HookOptions = {}): Promise<boolean> {
|
||||
const manifest = await getModuleManifest(moduleId)
|
||||
if (!manifest.main) return false
|
||||
const handler = loadModule(moduleId, manifest.main)[hook]
|
||||
if (typeof handler !== 'function') return false
|
||||
const directory = options.directory
|
||||
const urls = options.projectName ? projectUrls(options.projectName) : undefined
|
||||
const run = async (label: string, command: string, args: string[]): Promise<void> => {
|
||||
if (options.sender && options.operationId) {
|
||||
if (!options.sender.isDestroyed()) options.sender.send('terminal:data', { operationId: options.operationId, stream: 'stdout', chunk: `\n[${manifest.name}] ${label}\n` })
|
||||
return runCommandStreamed(options.operationId, command, args, options.sender, { cwd: directory ?? resolve(moduleDirectory(), moduleId), emitExit: false })
|
||||
}
|
||||
await execFileAsync(command, args, { cwd: directory ?? resolve(moduleDirectory(), moduleId), env: AURORA_ENV })
|
||||
}
|
||||
await handler(Object.freeze({
|
||||
moduleId,
|
||||
hook,
|
||||
directory,
|
||||
projectName: options.projectName,
|
||||
toolId: options.toolId,
|
||||
settings: Object.freeze({ ...(options.settings ?? {}) }),
|
||||
urls: urls ? Object.freeze(urls) : undefined,
|
||||
run,
|
||||
ensureRouter,
|
||||
setProjectMetadata: async (metadata: Settings) => {
|
||||
if (!directory) throw new Error('Project metadata is unavailable outside a project lifecycle hook')
|
||||
await setProjectModuleMetadata(directory, metadata)
|
||||
},
|
||||
saveCredentials: async (credentials: { platform: string; adminUrl: string; username: string; password: string; email: string }) => {
|
||||
if (!directory) throw new Error('Project credentials are unavailable outside a project lifecycle hook')
|
||||
await saveSiteCredentials(directory, credentials)
|
||||
}
|
||||
}))
|
||||
return true
|
||||
}
|
||||
|
||||
export async function runProjectLifecycleHooks(directory: string, hook: 'projectStart' | 'projectRemove', operationId: string, sender: WebContents): Promise<void> {
|
||||
const config = await getProjectConfig(directory)
|
||||
for (const moduleId of config.modules) {
|
||||
if (CORE_MODULE_IDS.has(moduleId)) continue
|
||||
await runModuleLifecycleHook(moduleId, hook, { directory, projectName: config.name, settings: config.moduleSettings?.[moduleId], operationId, sender })
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
try { 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, command, args, sender, { cwd: directory, emitExit: false }),
|
||||
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)
|
||||
}))
|
||||
if (!sender.isDestroyed()) sender.send('terminal:exit', { operationId, exitCode: 0, cancelled: false })
|
||||
try {
|
||||
await runModuleLifecycleHook(moduleId, 'projectCreate', { directory, projectName, settings, operationId, sender })
|
||||
sendExit(sender, operationId, 0)
|
||||
} catch (error) {
|
||||
if (!sender.isDestroyed()) sender.send('terminal:exit', { operationId, exitCode: 1, cancelled: false })
|
||||
sendExit(sender, operationId, 1)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function runModuleProjectTool(moduleId: string, toolId: string, operationId: string, directory: string, sender: WebContents): Promise<void> {
|
||||
const manifest = await getModuleManifest(moduleId)
|
||||
if (!manifest.project?.tools?.some((tool) => tool.id === toolId)) throw new Error(`Unknown module tool '${toolId}'`)
|
||||
const config = await getProjectConfig(directory)
|
||||
if (!config.modules.includes(moduleId)) throw new Error(`Module '${moduleId}' is not installed in this project`)
|
||||
try {
|
||||
const handled = await runModuleLifecycleHook(moduleId, 'projectTool', { directory, projectName: config.name, settings: config.moduleSettings?.[moduleId], toolId, operationId, sender })
|
||||
if (!handled) throw new Error(`Module '${moduleId}' does not implement projectTool`)
|
||||
sendExit(sender, operationId, 0)
|
||||
} catch (error) {
|
||||
sendExit(sender, operationId, 1)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user