feat: complete external module lifecycle SDK

This commit is contained in:
reaper
2026-08-14 20:50:08 -05:00
parent d3849acdd3
commit f42c722026
15 changed files with 239 additions and 56 deletions
+1 -1
View File
@@ -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
View File
@@ -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)
})
}
+15 -7
View File
@@ -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)
})
}
+7
View File
@@ -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
View File
@@ -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
}
}
+4 -2
View File
@@ -90,7 +90,7 @@ const api = {
ipcRenderer.invoke('modules:pickAndInstallPackage'),
installPackage: (source: string): Promise<AuroraModuleInstallResult> =>
ipcRenderer.invoke('modules:installPackage', source),
uninstallPackage: (id: string): Promise<void> => ipcRenderer.invoke('modules:uninstallPackage', id),
uninstallPackage: (operationId: string, id: string): Promise<void> => ipcRenderer.invoke('modules:uninstallPackage', operationId, id),
install: (
operationId: string,
name: string,
@@ -98,7 +98,9 @@ const api = {
settings: Record<string, string | number | boolean>
): Promise<void> => ipcRenderer.invoke('modules:install', operationId, name, moduleId, settings),
remove: (operationId: string, name: string, moduleId: string): Promise<void> =>
ipcRenderer.invoke('modules:remove', operationId, name, moduleId)
ipcRenderer.invoke('modules:remove', operationId, name, moduleId),
runProjectTool: (operationId: string, name: string, moduleId: string, toolId: string): Promise<void> =>
ipcRenderer.invoke('modules:runProjectTool', operationId, name, moduleId, toolId)
},
logs: {
start: (operationId: string, name: string, service: string): Promise<void> =>
@@ -20,6 +20,7 @@ import {
Globe2,
LockKeyhole,
Radio
,Wrench
} from 'lucide-react'
import type { AuroraSiteCredentials, EnvironmentUpdate } from '@shared/types'
import {
@@ -37,7 +38,7 @@ import { DeveloperServices } from './DeveloperServices'
import { DeleteProjectModal } from './DeleteProjectModal'
import { LogViewer } from '../logs/LogViewer'
import { useAppStore } from '../../stores/appStore'
import { useModuleRegistry } from '../../hooks/useModules'
import { useModuleRegistry, useRunModuleTool } from '../../hooks/useModules'
const NODE_VERSIONS = ['20', '22', '24']
@@ -85,6 +86,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
const restartProject = useRestartProject()
const deleteProject = useDeleteProject()
const updateEnvironment = useUpdateEnvironment()
const runModuleTool = useRunModuleTool(name)
const selectProject = useAppStore((s) => s.selectProject)
const [isLogsOpen, setIsLogsOpen] = useState(false)
const [isDeleteOpen, setIsDeleteOpen] = useState(false)
@@ -112,7 +114,8 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
startProject.isPending ||
stopProject.isPending ||
restartProject.isPending ||
deleteProject.isPending
deleteProject.isPending ||
runModuleTool.isPending
const isEnvUpdating = updateEnvironment.isPending || restartProject.isPending
@@ -259,6 +262,11 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
<Globe2 size={14} /> {action.label}
</a>
))}
{applicationManifest?.project?.tools?.map((tool) => (
<button key={tool.id} type="button" disabled={isBusy} onClick={() => runModuleTool.mutate({ moduleId: applicationManifest.id, toolId: tool.id, label: tool.label })} className="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white/80 px-3 py-1.5 text-sm font-medium text-neutral-700 shadow-sm transition hover:border-cyan-200 hover:bg-cyan-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/[0.15]">
<Wrench size={14} /> {tool.label}
</button>
))}
<button
type="button"
disabled={isBusy}
+5 -1
View File
@@ -35,11 +35,15 @@ export function useInstallAvailableModule(): UseMutationResult<void, Error, stri
export function useUninstallModulePackage(): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id) => window.api.modules.uninstallPackage(id),
mutationFn: (id) => window.api.modules.uninstallPackage(beginOperation(`Uninstall ${id}`), id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['modules', 'registry'] })
})
}
export function useRunModuleTool(name: string): UseMutationResult<void, Error, { moduleId: string; toolId: string; label: string }> {
return useMutation({ mutationFn: ({ moduleId, toolId, label }) => window.api.modules.runProjectTool(beginOperation(label), name, moduleId, toolId) })
}
export function useInstalledModules(name: string): UseQueryResult<AuroraInstalledModule[], Error> {
return useQuery({ queryKey: installedModulesKey(name), queryFn: () => window.api.modules.listInstalled(name) })
}
+2 -2
View File
@@ -161,7 +161,7 @@ export interface AuroraModuleManifest {
labels: Record<string, string>
fallback?: string
}
tools?: Array<{ id: string; label: string; hook: AuroraModuleLifecycleHook }>
tools?: Array<{ id: string; label: string }>
}
compose?: {
service: string
@@ -171,7 +171,7 @@ export interface AuroraModuleManifest {
}
}
export type AuroraModuleLifecycleHook = 'projectCreate' | 'projectStart' | 'projectRemove' | 'packageUninstall'
export type AuroraModuleLifecycleHook = 'projectCreate' | 'projectStart' | 'projectRemove' | 'packageUninstall' | 'projectTool'
export interface AuroraModuleCommand {
command: string