import * as Path from 'node:path'; import { findConfigFile, findProjectDir, getServiceConfigByName, type ServiceConfig } from '../configFile.ts'; import { findProcessesByCommandNameAndProjectDir } from '../database/processTable.ts'; import { getStateDirectory } from '../dirs.ts'; import { ProcessStartFailedError, UsageError } from '../errors.ts'; import { handleKillCommand } from '../kill-command.ts'; import { launchWithLogCollector } from '../log-collector/launchWithLogCollector.ts'; import { LogIterator } from '../logs/LogIterator.ts'; import { saveProcessLog } from '../logs/processLogs.ts'; import { ProcessLogType } from '../logs/ProcessLogType.ts'; import { debugLog } from '../debug.ts'; export interface RunOptions { commandName: string; projectDir: string; consoleOutputFormat: 'pretty' | 'json'; shell?: string; root?: string; enableStdin?: boolean; checkStart?: boolean; } export interface StartResult { projectDir: string; serviceName: string; } function isValidRootPath(p: string): boolean { if (Path.isAbsolute(p)) { return true; } const normalized = Path.normalize(p); if (normalized.startsWith('..')) { return false; } return true; } /* startOneService Launches a single service as a subprocess. */ export async function startOneService(req: RunOptions): Promise { const { projectDir } = req; if (!projectDir) { throw new Error('startOneService: projectDir is required'); } let serviceConfig: ServiceConfig; const startTime = Date.now(); function getTimeSinceStart() { return `${Date.now() - startTime}ms`; } debugLog('[startOneService] starting: ' + JSON.stringify(req)); if (req.shell) { // Transient process - use provided shell/root if (!req.commandName) { throw new UsageError('Command name is required'); } // Validate root if provided if (req.root && !isValidRootPath(req.root)) { throw new UsageError( `Invalid root path: "${req.root}". Root must be an absolute path or a relative path within the project.` ); } serviceConfig = { name: req.commandName, shell: req.shell, root: req.root, enableStdin: req.enableStdin, }; } else { // Config-based process const found = getServiceConfigByName(req.commandName); serviceConfig = found.serviceConfig; } // If checkStart is true, skip starting if already running if (req.checkStart) { const existingProcesses = findProcessesByCommandNameAndProjectDir(serviceConfig.name, projectDir); const runningProcesses = existingProcesses.filter(p => p.killed_at === null); if (runningProcesses.length > 0) { console.log(`[Service '${serviceConfig.name}' is already running]`); return { projectDir, serviceName: serviceConfig.name }; } } // Kill any existing processes for this service await handleKillCommand({ projectDir, commandNames: [serviceConfig.name], quietFailure: true }); debugLog('[startOneService] killed existing processes, timeSinceStart=' + getTimeSinceStart()); const logIterator = new LogIterator({ commandNames: [serviceConfig.name], projectDir, }); logIterator.resetToLatestLogMessage(); const initialLogPosition = logIterator.copy(); // Save the 'process has started' log. saveProcessLog({ command_name: serviceConfig.name, project_dir: projectDir, log_type: ProcessLogType.process_start_initiated, }); debugLog('[startOneService] launching with log collector for name=' + serviceConfig.name + ', projectDir=' + projectDir + ', timeSinceStart=' + getTimeSinceStart()); // Check if the project is configured to use the Rust log collector let logCollector: 'node' | 'rust' | undefined; try { const configResult = findConfigFile(projectDir); logCollector = configResult?.config?.logCollector; } catch { // No config file, use default } const databasePath = Path.join(getStateDirectory(), 'candle.db'); await launchWithLogCollector({ commandName: serviceConfig.name, projectDir, shell: serviceConfig.shell, root: serviceConfig.root, enableStdin: serviceConfig.enableStdin, databasePath, }, { logCollector }); debugLog('[startOneService] waiting for process start logs, timeSinceStart=' + getTimeSinceStart()); // Watch the logs for either 'process_started' or 'process_start_failed' const waitForSuccess = (async () => { for await (const log of logIterator.it()) { debugLog('[startOneService] saw log while waiting: ' + JSON.stringify(log)); if (log.log_type === ProcessLogType.process_started) { // success break; } if (log.log_type === ProcessLogType.process_start_failed) { const recentLogs = initialLogPosition.getNextLogs(); throw new ProcessStartFailedError({ commandName: serviceConfig.name, recentLogs }); } } })(); // Wait on the logs with a timeout await Promise.race([ waitForSuccess, new Promise((resolve, reject) => { setTimeout(() => { reject(new Error('Process failed to start (timed out while waiting)')); }, 10000); }), ]); debugLog('[startOneService] finished startup, timeSinceStart=' + getTimeSinceStart()); let launchDir = projectDir; if (serviceConfig.root) { launchDir = Path.isAbsolute(serviceConfig.root) ? serviceConfig.root : Path.join(projectDir, serviceConfig.root); } console.log( `[Started process '${serviceConfig.name}' (\`${serviceConfig.shell}\`) in directory: '${launchDir}']` ); return { projectDir, serviceName: serviceConfig.name, }; }