feat: complete external module lifecycle SDK
This commit is contained in:
@@ -21,6 +21,9 @@
|
|||||||
- Application choices in New Project come only from the installed-module registry.
|
- 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.
|
- 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 `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.
|
- 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
|
## WordPress module
|
||||||
@@ -44,6 +47,7 @@ Commands completed successfully:
|
|||||||
npm run typecheck
|
npm run typecheck
|
||||||
npm run test:run
|
npm run test:run
|
||||||
npm run build
|
npm run build
|
||||||
|
npm run build:modules
|
||||||
npx electron-builder --linux AppImage
|
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
|
- Application appearing after install and disappearing after uninstall
|
||||||
- Existing-project missing-module state
|
- Existing-project missing-module state
|
||||||
- Validation of the independently packaged WordPress contract
|
- 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`:
|
Live project smoke result for project `24`:
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
"build": "npm run typecheck && electron-vite build",
|
"build": "npm run typecheck && electron-vite build",
|
||||||
"build:modules": "node scripts/package-modules.cjs",
|
"build:modules": "node scripts/package-modules.cjs",
|
||||||
|
"module:new": "node scripts/create-module.cjs",
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"build:unpack": "npm run build && electron-builder --dir",
|
"build:unpack": "npm run build && electron-builder --dir",
|
||||||
"build:win": "npm run build && electron-builder --win",
|
"build:win": "npm run build && electron-builder --win",
|
||||||
|
|||||||
@@ -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`)
|
||||||
@@ -10,7 +10,7 @@ const execFileAsync = promisify(execFile)
|
|||||||
const EXTRA_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/opt/local/bin']
|
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(':') }
|
export const AURORA_ENV = { ...process.env, PATH: [...EXTRA_PATH_DIRS, process.env.PATH].join(':') }
|
||||||
|
|
||||||
type AuroraConfig = {
|
export type AuroraConfig = {
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
docroot: string
|
docroot: string
|
||||||
|
|||||||
+22
-17
@@ -1,7 +1,8 @@
|
|||||||
import { dialog, ipcMain } from 'electron'
|
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 { runCommandStreamed } from '../commandRunner'
|
||||||
import { getAvailableModulePackages, installModulePackage, uninstallModulePackage } from '../moduleRegistry'
|
import { getAvailableModulePackages, installModulePackage, uninstallModulePackage } from '../moduleRegistry'
|
||||||
|
import { runModuleLifecycleHook, runModuleProjectTool } from '../moduleRuntime'
|
||||||
|
|
||||||
export function registerModulesIpc(): void {
|
export function registerModulesIpc(): void {
|
||||||
ipcMain.handle('modules:listRegistry', () => listModules())
|
ipcMain.handle('modules:listRegistry', () => listModules())
|
||||||
@@ -15,7 +16,10 @@ export function registerModulesIpc(): void {
|
|||||||
return installModulePackage(picked.filePaths[0])
|
return installModulePackage(picked.filePaths[0])
|
||||||
})
|
})
|
||||||
ipcMain.handle('modules:installPackage', (_event, source: string) => installModulePackage(source))
|
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:listInstalled', (_event, name: string) => listInstalledModules(name))
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'modules:install',
|
'modules:install',
|
||||||
@@ -29,27 +33,28 @@ export function registerModulesIpc(): void {
|
|||||||
await setModule(name, moduleId, true, settings)
|
await setModule(name, moduleId, true, settings)
|
||||||
await scaffoldApplicationModule(name, moduleId)
|
await scaffoldApplicationModule(name, moduleId)
|
||||||
const root = await getProjectRoot(name)
|
const root = await getProjectRoot(name)
|
||||||
return runCommandStreamed(
|
try {
|
||||||
operationId,
|
await runCommandStreamed(operationId, 'docker', ['compose', '-f', `${root}/.aurora/compose.yaml`, 'up', '-d', '--remove-orphans'], event.sender, { cwd: root, emitExit: false })
|
||||||
'docker',
|
await runModuleLifecycleHook(moduleId, 'projectCreate', { directory: root, projectName: name, settings, operationId, sender: event.sender })
|
||||||
['compose', '-f', `${root}/.aurora/compose.yaml`, 'up', '-d', '--remove-orphans'],
|
if (!event.sender.isDestroyed()) event.sender.send('terminal:exit', { operationId, exitCode: 0, cancelled: false })
|
||||||
event.sender,
|
} catch (error) { if (!event.sender.isDestroyed()) event.sender.send('terminal:exit', { operationId, exitCode: 1, cancelled: false }); throw error }
|
||||||
{ cwd: root }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'modules:remove',
|
'modules:remove',
|
||||||
async (event, operationId: string, name: string, moduleId: string) => {
|
async (event, operationId: string, name: string, moduleId: string) => {
|
||||||
await setModule(name, moduleId, false)
|
|
||||||
const root = await getProjectRoot(name)
|
const root = await getProjectRoot(name)
|
||||||
return runCommandStreamed(
|
try {
|
||||||
operationId,
|
const config = await getProjectConfig(root)
|
||||||
'docker',
|
await runModuleLifecycleHook(moduleId, 'projectRemove', { directory: root, projectName: name, settings: config.moduleSettings?.[moduleId], operationId, sender: event.sender })
|
||||||
['compose', '-f', `${root}/.aurora/compose.yaml`, 'up', '-d', '--remove-orphans'],
|
await setModule(name, moduleId, false)
|
||||||
event.sender,
|
await runCommandStreamed(operationId, 'docker', ['compose', '-f', `${root}/.aurora/compose.yaml`, 'up', '-d', '--remove-orphans'], event.sender, { cwd: root, emitExit: false })
|
||||||
{ cwd: root }
|
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 { spawn } from 'child_process'
|
||||||
import { listProjects, describeProject, getProjectRoot, unregisterProject, updateEnvironment, ensureRouter, trustAuroraCA } from '../auroraEngine'
|
import { listProjects, describeProject, getProjectRoot, unregisterProject, updateEnvironment, ensureRouter, trustAuroraCA } from '../auroraEngine'
|
||||||
import { runCommandStreamed } from '../commandRunner'
|
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'])
|
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()})})}
|
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{
|
export function registerProjectsIpc():void{
|
||||||
ipcMain.handle('projects:list',()=>listProjects()); ipcMain.handle('projects:describe',(_e,name:string)=>describeProject(name))
|
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: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: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: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)=>{
|
ipcMain.handle('projects:delete',async(e,id:string,name:string,approot:string,deleteFiles:boolean)=>{
|
||||||
let root=approot
|
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 {
|
try {
|
||||||
await runCommandStreamed(id,'docker',composeArgs(root,'down','-v','--remove-orphans'),e.sender,{cwd:root})
|
await runCommandStreamed(id,'docker',composeArgs(root,'down','-v','--remove-orphans'),e.sender,{cwd:root})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -25,7 +33,7 @@ export function registerProjectsIpc():void{
|
|||||||
await unregisterProject(name,deleteFiles)
|
await unregisterProject(name,deleteFiles)
|
||||||
})
|
})
|
||||||
ipcMain.handle('projects:trustCA',async()=>{ await trustAuroraCA(); await ensureRouter() })
|
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)
|
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 (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
|
return value as AuroraModuleManifest
|
||||||
}
|
}
|
||||||
|
|||||||
+84
-24
@@ -1,38 +1,98 @@
|
|||||||
|
import { execFile } from 'child_process'
|
||||||
import { createRequire } from 'module'
|
import { createRequire } from 'module'
|
||||||
|
import { promisify } from 'util'
|
||||||
import { dirname, join, resolve, sep } from 'path'
|
import { dirname, join, resolve, sep } from 'path'
|
||||||
import type { WebContents } from 'electron'
|
import type { WebContents } from 'electron'
|
||||||
|
import type { AuroraModuleLifecycleHook } from '../shared/types'
|
||||||
import { getModuleManifest, moduleDirectory } from './moduleRegistry'
|
import { getModuleManifest, moduleDirectory } from './moduleRegistry'
|
||||||
import { runCommandStreamed } from './commandRunner'
|
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'
|
import { saveSiteCredentials } from './ipc/secrets'
|
||||||
|
|
||||||
type Settings = Record<string, string | number | boolean>
|
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> {
|
export async function runModuleProjectCreate(moduleId: string, operationId: string, directory: string, projectName: string, settings: Settings, sender: WebContents): Promise<void> {
|
||||||
const manifest = await getModuleManifest(moduleId)
|
try {
|
||||||
if (!manifest.main) return
|
await runModuleLifecycleHook(moduleId, 'projectCreate', { directory, projectName, settings, operationId, sender })
|
||||||
const root = resolve(moduleDirectory(), moduleId)
|
sendExit(sender, operationId, 0)
|
||||||
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 })
|
|
||||||
} catch (error) {
|
} 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
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ const api = {
|
|||||||
ipcRenderer.invoke('modules:pickAndInstallPackage'),
|
ipcRenderer.invoke('modules:pickAndInstallPackage'),
|
||||||
installPackage: (source: string): Promise<AuroraModuleInstallResult> =>
|
installPackage: (source: string): Promise<AuroraModuleInstallResult> =>
|
||||||
ipcRenderer.invoke('modules:installPackage', source),
|
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: (
|
install: (
|
||||||
operationId: string,
|
operationId: string,
|
||||||
name: string,
|
name: string,
|
||||||
@@ -98,7 +98,9 @@ const api = {
|
|||||||
settings: Record<string, string | number | boolean>
|
settings: Record<string, string | number | boolean>
|
||||||
): Promise<void> => ipcRenderer.invoke('modules:install', operationId, name, moduleId, settings),
|
): Promise<void> => ipcRenderer.invoke('modules:install', operationId, name, moduleId, settings),
|
||||||
remove: (operationId: string, name: string, moduleId: string): Promise<void> =>
|
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: {
|
logs: {
|
||||||
start: (operationId: string, name: string, service: string): Promise<void> =>
|
start: (operationId: string, name: string, service: string): Promise<void> =>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Globe2,
|
Globe2,
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
Radio
|
Radio
|
||||||
|
,Wrench
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { AuroraSiteCredentials, EnvironmentUpdate } from '@shared/types'
|
import type { AuroraSiteCredentials, EnvironmentUpdate } from '@shared/types'
|
||||||
import {
|
import {
|
||||||
@@ -37,7 +38,7 @@ import { DeveloperServices } from './DeveloperServices'
|
|||||||
import { DeleteProjectModal } from './DeleteProjectModal'
|
import { DeleteProjectModal } from './DeleteProjectModal'
|
||||||
import { LogViewer } from '../logs/LogViewer'
|
import { LogViewer } from '../logs/LogViewer'
|
||||||
import { useAppStore } from '../../stores/appStore'
|
import { useAppStore } from '../../stores/appStore'
|
||||||
import { useModuleRegistry } from '../../hooks/useModules'
|
import { useModuleRegistry, useRunModuleTool } from '../../hooks/useModules'
|
||||||
|
|
||||||
const NODE_VERSIONS = ['20', '22', '24']
|
const NODE_VERSIONS = ['20', '22', '24']
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
|||||||
const restartProject = useRestartProject()
|
const restartProject = useRestartProject()
|
||||||
const deleteProject = useDeleteProject()
|
const deleteProject = useDeleteProject()
|
||||||
const updateEnvironment = useUpdateEnvironment()
|
const updateEnvironment = useUpdateEnvironment()
|
||||||
|
const runModuleTool = useRunModuleTool(name)
|
||||||
const selectProject = useAppStore((s) => s.selectProject)
|
const selectProject = useAppStore((s) => s.selectProject)
|
||||||
const [isLogsOpen, setIsLogsOpen] = useState(false)
|
const [isLogsOpen, setIsLogsOpen] = useState(false)
|
||||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false)
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false)
|
||||||
@@ -112,7 +114,8 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
|||||||
startProject.isPending ||
|
startProject.isPending ||
|
||||||
stopProject.isPending ||
|
stopProject.isPending ||
|
||||||
restartProject.isPending ||
|
restartProject.isPending ||
|
||||||
deleteProject.isPending
|
deleteProject.isPending ||
|
||||||
|
runModuleTool.isPending
|
||||||
|
|
||||||
const isEnvUpdating = updateEnvironment.isPending || restartProject.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}
|
<Globe2 size={14} /> {action.label}
|
||||||
</a>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
|
|||||||
@@ -35,11 +35,15 @@ export function useInstallAvailableModule(): UseMutationResult<void, Error, stri
|
|||||||
export function useUninstallModulePackage(): UseMutationResult<void, Error, string> {
|
export function useUninstallModulePackage(): UseMutationResult<void, Error, string> {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return useMutation({
|
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'] })
|
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> {
|
export function useInstalledModules(name: string): UseQueryResult<AuroraInstalledModule[], Error> {
|
||||||
return useQuery({ queryKey: installedModulesKey(name), queryFn: () => window.api.modules.listInstalled(name) })
|
return useQuery({ queryKey: installedModulesKey(name), queryFn: () => window.api.modules.listInstalled(name) })
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -161,7 +161,7 @@ export interface AuroraModuleManifest {
|
|||||||
labels: Record<string, string>
|
labels: Record<string, string>
|
||||||
fallback?: string
|
fallback?: string
|
||||||
}
|
}
|
||||||
tools?: Array<{ id: string; label: string; hook: AuroraModuleLifecycleHook }>
|
tools?: Array<{ id: string; label: string }>
|
||||||
}
|
}
|
||||||
compose?: {
|
compose?: {
|
||||||
service: string
|
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 {
|
export interface AuroraModuleCommand {
|
||||||
command: string
|
command: string
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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'])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user