diff --git a/src/main/commandRunner.ts b/src/main/commandRunner.ts index 527ab4b..c0cd9c9 100644 --- a/src/main/commandRunner.ts +++ b/src/main/commandRunner.ts @@ -4,7 +4,7 @@ import { AURORA_ENV, powerOffProjects } from './auroraEngine' const running = new Map(); const cancelledIds = new Set() const ANSI_PATTERN = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g const stripAnsi=(s:string)=>s.replace(ANSI_PATTERN,'') -export function runCommandStreamed(operationId:string, command:string, args:string[], sender:WebContents, options:{cwd?:string}={}):Promise{return new Promise((resolve,reject)=>{const child=spawn(command,args,{env:AURORA_ENV,cwd:options.cwd,stdio:['ignore','pipe','pipe']});running.set(operationId,child);const tail:string[]=[];const forward=(stream:'stdout'|'stderr')=>(data:Buffer)=>{const chunk=stripAnsi(data.toString());tail.push(chunk);if(tail.length>20)tail.shift();if(!sender.isDestroyed())sender.send('terminal:data',{operationId,stream,chunk})};child.stdout.on('data',forward('stdout'));child.stderr.on('data',forward('stderr'));child.on('error',e=>{running.delete(operationId);sender.send('terminal:exit',{operationId,exitCode:null,cancelled:false});reject(e)});child.on('close',code=>{running.delete(operationId);const cancelled=cancelledIds.delete(operationId);if(!sender.isDestroyed())sender.send('terminal:exit',{operationId,exitCode:code,cancelled});if(cancelled)reject(new Error('Command cancelled'));else if(code===0)resolve();else reject(new Error(tail.join('').trim()||`${command} exited with code ${code}`))})})} +export function runCommandStreamed(operationId:string, command:string, args:string[], sender:WebContents, options:{cwd?:string;emitExit?:boolean}={}):Promise{return new Promise((resolve,reject)=>{const child=spawn(command,args,{env:AURORA_ENV,cwd:options.cwd,stdio:['ignore','pipe','pipe']});running.set(operationId,child);const tail:string[]=[];const forward=(stream:'stdout'|'stderr')=>(data:Buffer)=>{const chunk=stripAnsi(data.toString());tail.push(chunk);if(tail.length>20)tail.shift();if(!sender.isDestroyed())sender.send('terminal:data',{operationId,stream,chunk})};child.stdout.on('data',forward('stdout'));child.stderr.on('data',forward('stderr'));child.on('error',e=>{running.delete(operationId);if(options.emitExit!==false&&!sender.isDestroyed())sender.send('terminal:exit',{operationId,exitCode:null,cancelled:false});reject(e)});child.on('close',code=>{running.delete(operationId);const cancelled=cancelledIds.delete(operationId);if(options.emitExit!==false&&!sender.isDestroyed())sender.send('terminal:exit',{operationId,exitCode:code,cancelled});if(cancelled)reject(new Error('Command cancelled'));else if(code===0)resolve();else reject(new Error(tail.join('').trim()||`${command} exited with code ${code}`))})})} export function cancelCommand(id:string):boolean{const c=running.get(id);if(!c)return false;cancelledIds.add(id);c.kill();return true} export function startLogStream(operationId:string, command:string,args:string[],sender:WebContents,options:{cwd?:string}={}):void{const child=spawn(command,args,{env:AURORA_ENV,cwd:options.cwd,stdio:['ignore','pipe','pipe']});running.set(operationId,child);const f=(stream:'stdout'|'stderr')=>(d:Buffer)=>{if(!sender.isDestroyed())sender.send('logs:data',{operationId,stream,chunk:stripAnsi(d.toString())})};child.stdout.on('data',f('stdout'));child.stderr.on('data',f('stderr'));const done=()=>{running.delete(operationId);if(!sender.isDestroyed())sender.send('logs:exit',{operationId})};child.on('close',done);child.on('error',done)} export function killAllRunningCommands():void{for(const c of running.values())c.kill();running.clear()} diff --git a/src/main/moduleRuntime.ts b/src/main/moduleRuntime.ts index f05413f..1147d09 100644 --- a/src/main/moduleRuntime.ts +++ b/src/main/moduleRuntime.ts @@ -19,15 +19,20 @@ export async function runModuleProjectCreate(moduleId: string, operationId: stri if (typeof loaded.projectCreate !== 'function') return const config = await getProjectConfigByRoot(directory) const urls = projectUrls(config.name) - await loaded.projectCreate(Object.freeze({ + 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}-${suffix}`, command, args, sender, { cwd: directory }), + 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 }) + } catch (error) { + if (!sender.isDestroyed()) sender.send('terminal:exit', { operationId, exitCode: 1, cancelled: false }) + throw error + } } diff --git a/src/renderer/src/components/create/types/ExternalModuleSetup.tsx b/src/renderer/src/components/create/types/ExternalModuleSetup.tsx index abb445b..fe86e38 100644 --- a/src/renderer/src/components/create/types/ExternalModuleSetup.tsx +++ b/src/renderer/src/components/create/types/ExternalModuleSetup.tsx @@ -2,6 +2,8 @@ import { forwardRef, useEffect, useImperativeHandle, useState } from 'react' import { ChevronDown, ChevronUp, Eye, EyeOff, Globe2, KeyRound, Mail, RefreshCw, Settings2, Type, UserRound } from 'lucide-react' import type { AuroraModuleManifest, AuroraModuleSetting } from '@shared/types' import type { TypeSetupHandle, TypeSetupProps } from './shared' +import { useTerminalStore } from '../../../stores/terminalStore' +import { useStatusStore } from '../../../stores/statusStore' const inputClass = 'w-full rounded-lg border border-neutral-300 bg-white/80 px-3 py-2 text-sm shadow-sm transition placeholder:text-neutral-400 focus:border-cyan-400 focus:outline-none focus:ring-2 focus:ring-cyan-400/15 dark:border-white/10 dark:bg-neutral-950/70 dark:placeholder:text-neutral-600' const labelClass = 'mb-1.5 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400' @@ -21,7 +23,13 @@ export const ExternalModuleSetup = forwardRef>(new Set()) const valid = fields.every((field) => !field.required || String(values[field.id] ?? '').trim().length > 0) useEffect(() => onValidityChange(valid), [valid, onValidityChange]) - useImperativeHandle(ref, () => ({ runPostCreate: async ({ directory }) => window.api.create.runModuleProjectCreate(crypto.randomUUID(), module.id, directory, projectName, values) }), [module.id, projectName, values]) + useImperativeHandle(ref, () => ({ runPostCreate: async ({ directory }) => { + const operationId = crypto.randomUUID() + const label = `Install ${module.name} for ${projectName}` + useTerminalStore.getState().startOperation(operationId, label) + useStatusStore.getState().begin(operationId, label) + await window.api.create.runModuleProjectCreate(operationId, module.id, directory, projectName, values) + } }), [module.id, module.name, projectName, values]) function fieldControl(field: AuroraModuleSetting): React.JSX.Element { if (field.type === 'boolean') return diff --git a/src/renderer/src/hooks/useCreateProject.ts b/src/renderer/src/hooks/useCreateProject.ts index 513bf7f..a5f6fca 100644 --- a/src/renderer/src/hooks/useCreateProject.ts +++ b/src/renderer/src/hooks/useCreateProject.ts @@ -1,6 +1,5 @@ import type { AuroraStackOptions } from '@shared/types' import { useMutation, useQueryClient, type UseMutationResult } from '@tanstack/react-query' -import { useTerminalStore } from '../stores/terminalStore' import { useStatusStore } from '../stores/statusStore' export interface CreateProjectInput { directory: string; projectName: string; projectType: string; docroot: string; stack?: Partial } @@ -10,9 +9,9 @@ export function useCreateProject(): UseMutationResult { const operationId = crypto.randomUUID() - useTerminalStore.getState().startOperation(operationId, `Create project ${projectName}`) useStatusStore.getState().begin(operationId, `Create project ${projectName}`) - await window.api.create.configure(operationId, directory, projectName, projectType, docroot, stack) + try { await window.api.create.configure(operationId, directory, projectName, projectType, docroot, stack) } + finally { if (useStatusStore.getState().operationId === operationId) useStatusStore.getState().end() } }, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }) })