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
+6
View File
@@ -21,6 +21,9 @@
- Application choices in New Project come only from the installed-module registry.
- Project actions and metadata summaries are declarative module contributions. Core no longer contains WordPress-specific admin or multisite presentation.
- The `moduleMetadata` capability is the versioned bridge used by trusted module lifecycle hooks.
- The Module API now executes `projectCreate`, `projectStart`, `projectRemove`, `packageUninstall`, and manifest-declared project tools through one guarded runtime context.
- Lifecycle command output is streamed into Aurora's copyable diagnostic terminal and emits one final operation result.
- `templates/aurora-module` and `npm run module:new -- <id> "Display Name"` provide the reusable starting point for new modules.
- Alpha 23's legacy `wordpressMultisite` configuration value is read only by a compatibility adapter and exposed as generic module metadata. It is not used to identify the application.
## WordPress module
@@ -44,6 +47,7 @@ Commands completed successfully:
npm run typecheck
npm run test:run
npm run build
npm run build:modules
npx electron-builder --linux AppImage
```
@@ -62,6 +66,8 @@ Automated result: 6 test files and 27 tests passed. Coverage includes:
- Application appearing after install and disappearing after uninstall
- Existing-project missing-module state
- Validation of the independently packaged WordPress contract
- Production compilation of the lifecycle IPC, preload, and renderer tool-action path
- Generation of the embedded WordPress `.pac` catalog with the lifecycle-aware author tooling present
Live project smoke result for project `24`:
+1
View File
@@ -20,6 +20,7 @@
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"build:modules": "node scripts/package-modules.cjs",
"module:new": "node scripts/create-module.cjs",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
+20
View File
@@ -0,0 +1,20 @@
'use strict'
/* eslint-disable @typescript-eslint/no-require-imports */
const { cpSync, existsSync, readFileSync, writeFileSync } = require('fs')
const { join, resolve } = require('path')
const id = String(process.argv[2] || '').toLowerCase()
if (!/^[a-z][a-z0-9-]{1,63}$/.test(id)) {
throw new Error('Usage: npm run module:new -- <module-id> [Display Name]')
}
const root = resolve(__dirname, '..')
const destination = join(root, 'packages', `aurora-module-${id}`)
if (existsSync(destination)) throw new Error(`Module already exists: ${destination}`)
cpSync(join(root, 'templates', 'aurora-module'), destination, { recursive: true })
const manifestPath = join(destination, 'manifest.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
manifest.id = id
manifest.name = process.argv[3] || id.split('-').map((part) => part[0].toUpperCase() + part.slice(1)).join(' ')
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
process.stdout.write(`Created ${manifest.name} at ${destination}\n`)
+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
+19
View File
@@ -0,0 +1,19 @@
# Aurora module template
Create a module with:
```sh
npm run module:new -- my-module "My Module"
```
Edit `manifest.json` and `main/index.cjs`, then package every module in `packages/`:
```sh
npm run build:modules
```
The resulting `.pac` file is written to `dist/module-catalog/`.
Lifecycle exports are optional. `projectCreate` runs during initial provisioning, `projectStart` after the containers start, `projectRemove` before project-scoped removal, and `packageUninstall` before the installed package is deleted. A manifest entry in `project.tools` calls `projectTool(context)` with its `id` in `context.toolId`.
The context provides `moduleId`, `hook`, `directory`, `projectName`, `settings`, `urls`, and `toolId`. It also provides `run(label, command, args)`, `ensureRouter()`, `setProjectMetadata(values)`, and `saveCredentials(values)`. Project-only values and helpers are unavailable to `packageUninstall`.
+23
View File
@@ -0,0 +1,23 @@
'use strict'
exports.projectCreate = async function projectCreate(context) {
await context.run('Provision application', 'docker', ['compose', '-f', `${context.directory}/.aurora/compose.yaml`, 'up', '-d', '--build', '--remove-orphans'])
}
exports.projectStart = async function projectStart() {
// Optional: migrations, health checks, or other work after containers start.
}
exports.projectRemove = async function projectRemove() {
// Optional: remove project-scoped resources before the module/project is removed.
}
exports.packageUninstall = async function packageUninstall() {
// Optional: remove global resources before the installed .pac package is deleted.
}
exports.projectTool = async function projectTool(context) {
if (context.toolId === 'example-action') {
await context.run('Example action', 'docker', ['compose', '-f', `${context.directory}/.aurora/compose.yaml`, 'ps'])
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"id": "example-module",
"name": "Example Module",
"version": "1.0.0",
"category": "application",
"description": "Describe what this Aurora module installs.",
"main": "main/index.cjs",
"aurora": { "core": "2.0.0-alpha.24", "moduleApi": "1.0.0" },
"dependencies": [],
"conflicts": [],
"defaults": { "docroot": "" },
"settings": [],
"creation": {
"databases": ["mariadb", "mysql", "postgres"],
"setup": []
},
"project": {
"tools": [{ "id": "example-action", "label": "Run example action" }]
}
}