From f42c722026ab590dc4d6bddb63b0cd1e34f4bb0f Mon Sep 17 00:00:00 2001 From: reaper Date: Fri, 14 Aug 2026 20:50:08 -0500 Subject: [PATCH] feat: complete external module lifecycle SDK --- docs/ALPHA24_COMPLETION_REPORT.md | 6 + package.json | 1 + scripts/create-module.cjs | 20 ++++ src/main/auroraEngine.ts | 2 +- src/main/ipc/modules.ts | 39 ++++--- src/main/ipc/projects.ts | 22 ++-- src/main/moduleRegistry.ts | 7 ++ src/main/moduleRuntime.ts | 108 ++++++++++++++---- src/preload/index.ts | 6 +- .../src/components/projects/ProjectDetail.tsx | 12 +- src/renderer/src/hooks/useModules.ts | 6 +- src/shared/types.ts | 4 +- templates/aurora-module/README.md | 19 +++ templates/aurora-module/main/index.cjs | 23 ++++ templates/aurora-module/manifest.json | 20 ++++ 15 files changed, 239 insertions(+), 56 deletions(-) create mode 100644 scripts/create-module.cjs create mode 100644 templates/aurora-module/README.md create mode 100644 templates/aurora-module/main/index.cjs create mode 100644 templates/aurora-module/manifest.json diff --git a/docs/ALPHA24_COMPLETION_REPORT.md b/docs/ALPHA24_COMPLETION_REPORT.md index 5e8ee89..bb39a09 100644 --- a/docs/ALPHA24_COMPLETION_REPORT.md +++ b/docs/ALPHA24_COMPLETION_REPORT.md @@ -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 -- "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`: diff --git a/package.json b/package.json index 50f0938..2ef6b02 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/create-module.cjs b/scripts/create-module.cjs new file mode 100644 index 0000000..2973c4a --- /dev/null +++ b/scripts/create-module.cjs @@ -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 -- [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`) diff --git a/src/main/auroraEngine.ts b/src/main/auroraEngine.ts index 746d0d9..41d7bd9 100644 --- a/src/main/auroraEngine.ts +++ b/src/main/auroraEngine.ts @@ -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 diff --git a/src/main/ipc/modules.ts b/src/main/ipc/modules.ts index fd1a997..7551ad0 100644 --- a/src/main/ipc/modules.ts +++ b/src/main/ipc/modules.ts @@ -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) + }) } diff --git a/src/main/ipc/projects.ts b/src/main/ipc/projects.ts index 715b7d0..8165cb6 100644 --- a/src/main/ipc/projects.ts +++ b/src/main/ipc/projects.ts @@ -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{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{ + 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) }) } diff --git a/src/main/moduleRegistry.ts b/src/main/moduleRegistry.ts index d85db06..59c7c32 100644 --- a/src/main/moduleRegistry.ts +++ b/src/main/moduleRegistry.ts @@ -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 + 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 } diff --git a/src/main/moduleRuntime.ts b/src/main/moduleRuntime.ts index 1147d09..9d3a312 100644 --- a/src/main/moduleRuntime.ts +++ b/src/main/moduleRuntime.ts @@ -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 -type ExternalHook = (context: Record) => Promise +type ExternalHook = (context: Record) => Promise | void +type LoadedModule = Partial> +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 { + 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 => { + 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 { + 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 { - 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) => 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 { + 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 } } diff --git a/src/preload/index.ts b/src/preload/index.ts index cf2668f..c0cc567 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -90,7 +90,7 @@ const api = { ipcRenderer.invoke('modules:pickAndInstallPackage'), installPackage: (source: string): Promise => ipcRenderer.invoke('modules:installPackage', source), - uninstallPackage: (id: string): Promise => ipcRenderer.invoke('modules:uninstallPackage', id), + uninstallPackage: (operationId: string, id: string): Promise => ipcRenderer.invoke('modules:uninstallPackage', operationId, id), install: ( operationId: string, name: string, @@ -98,7 +98,9 @@ const api = { settings: Record ): Promise => ipcRenderer.invoke('modules:install', operationId, name, moduleId, settings), remove: (operationId: string, name: string, moduleId: string): Promise => - ipcRenderer.invoke('modules:remove', operationId, name, moduleId) + ipcRenderer.invoke('modules:remove', operationId, name, moduleId), + runProjectTool: (operationId: string, name: string, moduleId: string, toolId: string): Promise => + ipcRenderer.invoke('modules:runProjectTool', operationId, name, moduleId, toolId) }, logs: { start: (operationId: string, name: string, service: string): Promise => diff --git a/src/renderer/src/components/projects/ProjectDetail.tsx b/src/renderer/src/components/projects/ProjectDetail.tsx index a1f42ae..8fefa47 100644 --- a/src/renderer/src/components/projects/ProjectDetail.tsx +++ b/src/renderer/src/components/projects/ProjectDetail.tsx @@ -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 { {action.label} ))} + {applicationManifest?.project?.tools?.map((tool) => ( + + ))}