From 30f19f994bfc4ee0727dc175a5ec0dad30cdf067 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 31 Mar 2026 06:39:27 +0900 Subject: [PATCH] refactor: split setup service rendering and verify probes --- setup/service-installers.ts | 306 ++++++++++++++++++++++ setup/service-renderers.ts | 139 ++++++++++ setup/service.test.ts | 2 +- setup/service.ts | 465 +--------------------------------- setup/verify-services.test.ts | 106 ++++++++ setup/verify-services.ts | 104 ++++++++ setup/verify.ts | 91 +------ 7 files changed, 662 insertions(+), 551 deletions(-) create mode 100644 setup/service-installers.ts create mode 100644 setup/service-renderers.ts create mode 100644 setup/verify-services.test.ts create mode 100644 setup/verify-services.ts diff --git a/setup/service-installers.ts b/setup/service-installers.ts new file mode 100644 index 0000000..47a4401 --- /dev/null +++ b/setup/service-installers.ts @@ -0,0 +1,306 @@ +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import { logger } from '../src/logger.js'; +import { getServiceManager, isRoot } from './platform.js'; +import { STACK_RESTART_UNIT_NAME } from './restart-stack.js'; +import type { ServiceDef } from './service-defs.js'; +import { + buildLaunchdPlist, + buildStackRestartSystemdUnit, + buildSystemdUnit, +} from './service-renderers.js'; +import { emitStatus } from './status.js'; + +export function setupLaunchd( + def: ServiceDef, + projectRoot: string, + nodePath: string, + homeDir: string, +): void { + const plistPath = path.join( + homeDir, + 'Library', + 'LaunchAgents', + `${def.launchdLabel}.plist`, + ); + fs.mkdirSync(path.dirname(plistPath), { recursive: true }); + + const plist = buildLaunchdPlist(def, projectRoot, nodePath, homeDir); + + fs.writeFileSync(plistPath, plist); + logger.info({ plistPath, service: def.name }, 'Wrote launchd plist'); + + try { + execSync(`launchctl load ${JSON.stringify(plistPath)}`, { + stdio: 'ignore', + }); + logger.info({ service: def.name }, 'launchctl load succeeded'); + } catch { + logger.warn( + { service: def.name }, + 'launchctl load failed (may already be loaded)', + ); + } + + let serviceLoaded = false; + try { + const output = execSync('launchctl list', { encoding: 'utf-8' }); + serviceLoaded = output.includes(def.launchdLabel); + } catch { + // launchctl list failed + } + + emitStatus('SETUP_SERVICE', { + SERVICE_NAME: def.name, + SERVICE_TYPE: 'launchd', + NODE_PATH: nodePath, + PROJECT_PATH: projectRoot, + PLIST_PATH: plistPath, + SERVICE_LOADED: serviceLoaded, + STATUS: 'success', + LOG: 'logs/setup.log', + }); +} + +export function setupLinux( + serviceDefs: ServiceDef[], + projectRoot: string, + nodePath: string, + homeDir: string, +): void { + const serviceManager = getServiceManager(); + + if (serviceManager === 'systemd') { + setupSystemdAll(serviceDefs, projectRoot, nodePath, homeDir); + } else { + for (const def of serviceDefs) { + setupNohupFallback(def, projectRoot, nodePath, homeDir); + } + } +} + +/** + * Kill any orphaned ejclaw node processes left from previous runs or debugging. + * Prevents connection conflicts when two instances connect to the same channel simultaneously. + */ +function killOrphanedProcesses(projectRoot: string): void { + try { + execSync(`pkill -f '${projectRoot}/dist/index\\.js' || true`, { + stdio: 'ignore', + }); + logger.info('Stopped any orphaned ejclaw processes'); + } catch { + // pkill not available or no orphans + } +} + +function setupSystemdAll( + serviceDefs: ServiceDef[], + projectRoot: string, + nodePath: string, + homeDir: string, +): void { + const runningAsRoot = isRoot(); + const systemctlPrefix = runningAsRoot ? 'systemctl' : 'systemctl --user'; + + if (!runningAsRoot) { + try { + execSync('systemctl --user daemon-reload', { stdio: 'pipe' }); + } catch { + logger.warn( + 'systemd user session not available — falling back to nohup wrapper', + ); + for (const def of serviceDefs) { + setupNohupFallback(def, projectRoot, nodePath, homeDir); + } + return; + } + } + + killOrphanedProcesses(projectRoot); + + for (const def of serviceDefs) { + setupSystemdUnit(def, projectRoot, nodePath, homeDir, runningAsRoot); + } + setupSystemdStackRestartUnit(projectRoot, nodePath, homeDir, runningAsRoot); + + try { + execSync(`${systemctlPrefix} daemon-reload`, { stdio: 'ignore' }); + } catch (err) { + logger.error({ err }, 'systemctl daemon-reload failed'); + } + + for (const def of serviceDefs) { + try { + execSync(`${systemctlPrefix} enable ${def.name}`, { stdio: 'ignore' }); + } catch (err) { + logger.error({ err, service: def.name }, 'systemctl enable failed'); + } + + try { + execSync(`${systemctlPrefix} start ${def.name}`, { stdio: 'ignore' }); + } catch (err) { + logger.error({ err, service: def.name }, 'systemctl start failed'); + } + + let serviceLoaded = false; + try { + execSync(`${systemctlPrefix} is-active ${def.name}`, { + stdio: 'ignore', + }); + serviceLoaded = true; + } catch { + // Not active + } + + emitStatus('SETUP_SERVICE', { + SERVICE_NAME: def.name, + SERVICE_TYPE: runningAsRoot ? 'systemd-system' : 'systemd-user', + NODE_PATH: nodePath, + PROJECT_PATH: projectRoot, + UNIT_PATH: getUnitPath(def.name, homeDir, runningAsRoot), + SERVICE_LOADED: serviceLoaded, + STATUS: 'success', + LOG: 'logs/setup.log', + }); + } +} + +function getUnitPath( + serviceName: string, + homeDir: string, + runningAsRoot: boolean, +): string { + if (runningAsRoot) { + return `/etc/systemd/system/${serviceName}.service`; + } + return path.join( + homeDir, + '.config', + 'systemd', + 'user', + `${serviceName}.service`, + ); +} + +function setupSystemdUnit( + def: ServiceDef, + projectRoot: string, + nodePath: string, + homeDir: string, + runningAsRoot: boolean, +): void { + const unitPath = getUnitPath(def.name, homeDir, runningAsRoot); + fs.mkdirSync(path.dirname(unitPath), { recursive: true }); + + const unit = buildSystemdUnit( + def, + projectRoot, + nodePath, + homeDir, + runningAsRoot, + ); + + fs.writeFileSync(unitPath, unit); + logger.info({ unitPath, service: def.name }, 'Wrote systemd unit'); +} + +function setupSystemdStackRestartUnit( + projectRoot: string, + nodePath: string, + homeDir: string, + runningAsRoot: boolean, +): void { + const unitPath = getUnitPath( + STACK_RESTART_UNIT_NAME.replace(/\.service$/, ''), + homeDir, + runningAsRoot, + ); + fs.mkdirSync(path.dirname(unitPath), { recursive: true }); + fs.writeFileSync( + unitPath, + buildStackRestartSystemdUnit(projectRoot, nodePath, homeDir), + ); + logger.info( + { unitPath, service: STACK_RESTART_UNIT_NAME }, + 'Wrote stack restart systemd unit', + ); +} + +function setupNohupFallback( + def: ServiceDef, + projectRoot: string, + nodePath: string, + _homeDir: string, +): void { + logger.warn( + { service: def.name }, + 'No systemd detected — generating nohup wrapper script', + ); + + const wrapperPath = path.join(projectRoot, `start-${def.name}.sh`); + const pidFile = path.join(projectRoot, `${def.name}.pid`); + + const exportLines: string[] = []; + if (def.environmentFile) { + exportLines.push(`# Load environment file`); + exportLines.push(`set -a`); + exportLines.push(`source ${JSON.stringify(def.environmentFile)}`); + exportLines.push(`set +a`); + exportLines.push(''); + } + if (def.extraEnv) { + for (const [k, v] of Object.entries(def.extraEnv)) { + exportLines.push(`export ${k}=${JSON.stringify(v)}`); + } + exportLines.push(''); + } + + const lines = [ + '#!/bin/bash', + `# start-${def.name}.sh — Start ${def.description} without systemd`, + `# To stop: kill \\$(cat ${pidFile})`, + '', + 'set -euo pipefail', + '', + `cd ${JSON.stringify(projectRoot)}`, + '', + ...exportLines, + '# Stop existing instance if running', + `if [ -f ${JSON.stringify(pidFile)} ]; then`, + ` OLD_PID=$(cat ${JSON.stringify(pidFile)} 2>/dev/null || echo "")`, + ' if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then', + ` echo "Stopping existing ${def.name} (PID $OLD_PID)..."`, + ' kill "$OLD_PID" 2>/dev/null || true', + ' sleep 2', + ' fi', + 'fi', + '', + `echo "Starting ${def.description}..."`, + `nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot + '/dist/index.js')} \\`, + ` >> ${JSON.stringify(projectRoot + '/logs/' + def.logName + '.log')} \\`, + ` 2>> ${JSON.stringify(projectRoot + '/logs/' + def.logName + '.error.log')} &`, + '', + `echo $! > ${JSON.stringify(pidFile)}`, + `echo "${def.name} started (PID $!)"`, + `echo "Logs: tail -f ${projectRoot}/logs/${def.logName}.log"`, + ]; + const wrapper = lines.join('\n') + '\n'; + + fs.writeFileSync(wrapperPath, wrapper, { mode: 0o755 }); + logger.info({ wrapperPath, service: def.name }, 'Wrote nohup wrapper script'); + + emitStatus('SETUP_SERVICE', { + SERVICE_NAME: def.name, + SERVICE_TYPE: 'nohup', + NODE_PATH: nodePath, + PROJECT_PATH: projectRoot, + WRAPPER_PATH: wrapperPath, + SERVICE_LOADED: false, + FALLBACK: 'wsl_no_systemd', + STATUS: 'success', + LOG: 'logs/setup.log', + }); +} diff --git a/setup/service-renderers.ts b/setup/service-renderers.ts new file mode 100644 index 0000000..b12bfef --- /dev/null +++ b/setup/service-renderers.ts @@ -0,0 +1,139 @@ +import path from 'path'; + +import type { ServiceDef } from './service-defs.js'; + +export function buildRuntimePathEnv(nodePath: string, homeDir: string): string { + return `${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin:${homeDir}/.npm-global/bin`; +} + +function buildLaunchdEnvironmentEntries( + nodePath: string, + homeDir: string, + extraEnv?: Record, +): string[] { + const envEntries = [ + ` PATH`, + ` ${buildRuntimePathEnv(nodePath, homeDir)}`, + ` HOME`, + ` ${homeDir}`, + ]; + if (extraEnv) { + for (const [k, v] of Object.entries(extraEnv)) { + envEntries.push(` ${k}`); + envEntries.push(` ${v}`); + } + } + return envEntries; +} + +function buildSystemdEnvironmentLines( + nodePath: string, + homeDir: string, + extraEnv?: Record, +): string[] { + const envLines = [ + `Environment=HOME=${homeDir}`, + `Environment=PATH=${buildRuntimePathEnv(nodePath, homeDir)}`, + ]; + if (extraEnv) { + for (const [k, v] of Object.entries(extraEnv)) { + envLines.push(`Environment=${k}=${v}`); + } + } + return envLines; +} + +export function buildLaunchdPlist( + def: ServiceDef, + projectRoot: string, + nodePath: string, + homeDir: string, +): string { + const envEntries = buildLaunchdEnvironmentEntries( + nodePath, + homeDir, + def.extraEnv, + ); + + return ` + + + + Label + ${def.launchdLabel} + ProgramArguments + + ${nodePath} + ${projectRoot}/dist/index.js + + WorkingDirectory + ${projectRoot} + RunAtLoad + + KeepAlive + + EnvironmentVariables + +${envEntries.join('\n')} + + StandardOutPath + ${projectRoot}/logs/${def.logName}.log + StandardErrorPath + ${projectRoot}/logs/${def.logName}.error.log + +`; +} + +export function buildSystemdUnit( + def: ServiceDef, + projectRoot: string, + nodePath: string, + homeDir: string, + runningAsRoot: boolean, +): string { + const envLines = buildSystemdEnvironmentLines( + nodePath, + homeDir, + def.extraEnv, + ); + + const envFileLine = def.environmentFile + ? `EnvironmentFile=${def.environmentFile}\n` + : ''; + + return `[Unit] +Description=${def.description} +After=network.target + +[Service] +${envFileLine}Type=simple +ExecStart=${nodePath} ${projectRoot}/dist/index.js +WorkingDirectory=${projectRoot} +Restart=always +RestartSec=5 +${envLines.join('\n')} +StandardOutput=append:${projectRoot}/logs/${def.logName}.log +StandardError=append:${projectRoot}/logs/${def.logName}.error.log + +[Install] +WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; +} + +export function buildStackRestartSystemdUnit( + projectRoot: string, + nodePath: string, + homeDir: string, +): string { + const envLines = buildSystemdEnvironmentLines(nodePath, homeDir); + + return `[Unit] +Description=EJClaw Stack Restart Orchestrator +After=network.target + +[Service] +Type=oneshot +WorkingDirectory=${projectRoot} +${envLines.join('\n')} +ExecStart=${nodePath} ${projectRoot}/setup/restart-stack.ts --direct +`; +} diff --git a/setup/service.test.ts b/setup/service.test.ts index ae49e0f..89a62d2 100644 --- a/setup/service.test.ts +++ b/setup/service.test.ts @@ -8,7 +8,7 @@ import { buildRuntimePathEnv, buildStackRestartSystemdUnit, buildSystemdUnit, -} from './service.js'; +} from './service-renderers.js'; import { getServiceDefs, type ServiceDef } from './service-defs.js'; /** diff --git a/setup/service.ts b/setup/service.ts index 07d1077..97bde08 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -13,14 +13,9 @@ import os from 'os'; import path from 'path'; import { logger } from '../src/logger.js'; -import { - getPlatform, - getNodePath, - getServiceManager, - isRoot, -} from './platform.js'; -import { STACK_RESTART_UNIT_NAME } from './restart-stack.js'; -import { getServiceDefs, type ServiceDef } from './service-defs.js'; +import { getPlatform, getNodePath } from './platform.js'; +import { getServiceDefs } from './service-defs.js'; +import { setupLaunchd, setupLinux } from './service-installers.js'; import { emitStatus } from './status.js'; /* ------------------------------------------------------------------ */ @@ -86,457 +81,3 @@ export async function run(_args: string[]): Promise { process.exit(1); } } - -export function buildRuntimePathEnv(nodePath: string, homeDir: string): string { - return `${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin:${homeDir}/.npm-global/bin`; -} - -function buildLaunchdEnvironmentEntries( - nodePath: string, - homeDir: string, - extraEnv?: Record, -): string[] { - const envEntries = [ - ` PATH`, - ` ${buildRuntimePathEnv(nodePath, homeDir)}`, - ` HOME`, - ` ${homeDir}`, - ]; - if (extraEnv) { - for (const [k, v] of Object.entries(extraEnv)) { - envEntries.push(` ${k}`); - envEntries.push(` ${v}`); - } - } - return envEntries; -} - -function buildSystemdEnvironmentLines( - nodePath: string, - homeDir: string, - extraEnv?: Record, -): string[] { - const envLines = [ - `Environment=HOME=${homeDir}`, - `Environment=PATH=${buildRuntimePathEnv(nodePath, homeDir)}`, - ]; - if (extraEnv) { - for (const [k, v] of Object.entries(extraEnv)) { - envLines.push(`Environment=${k}=${v}`); - } - } - return envLines; -} - -/* ------------------------------------------------------------------ */ -/* macOS (launchd) */ -/* ------------------------------------------------------------------ */ - -function setupLaunchd( - def: ServiceDef, - projectRoot: string, - nodePath: string, - homeDir: string, -): void { - const plistPath = path.join( - homeDir, - 'Library', - 'LaunchAgents', - `${def.launchdLabel}.plist`, - ); - fs.mkdirSync(path.dirname(plistPath), { recursive: true }); - - const plist = buildLaunchdPlist(def, projectRoot, nodePath, homeDir); - - fs.writeFileSync(plistPath, plist); - logger.info({ plistPath, service: def.name }, 'Wrote launchd plist'); - - try { - execSync(`launchctl load ${JSON.stringify(plistPath)}`, { - stdio: 'ignore', - }); - logger.info({ service: def.name }, 'launchctl load succeeded'); - } catch { - logger.warn( - { service: def.name }, - 'launchctl load failed (may already be loaded)', - ); - } - - // Verify - let serviceLoaded = false; - try { - const output = execSync('launchctl list', { encoding: 'utf-8' }); - serviceLoaded = output.includes(def.launchdLabel); - } catch { - // launchctl list failed - } - - emitStatus('SETUP_SERVICE', { - SERVICE_NAME: def.name, - SERVICE_TYPE: 'launchd', - NODE_PATH: nodePath, - PROJECT_PATH: projectRoot, - PLIST_PATH: plistPath, - SERVICE_LOADED: serviceLoaded, - STATUS: 'success', - LOG: 'logs/setup.log', - }); -} - -export function buildLaunchdPlist( - def: ServiceDef, - projectRoot: string, - nodePath: string, - homeDir: string, -): string { - const envEntries = buildLaunchdEnvironmentEntries( - nodePath, - homeDir, - def.extraEnv, - ); - - return ` - - - - Label - ${def.launchdLabel} - ProgramArguments - - ${nodePath} - ${projectRoot}/dist/index.js - - WorkingDirectory - ${projectRoot} - RunAtLoad - - KeepAlive - - EnvironmentVariables - -${envEntries.join('\n')} - - StandardOutPath - ${projectRoot}/logs/${def.logName}.log - StandardErrorPath - ${projectRoot}/logs/${def.logName}.error.log - -`; -} - -/* ------------------------------------------------------------------ */ -/* Linux */ -/* ------------------------------------------------------------------ */ - -function setupLinux( - serviceDefs: ServiceDef[], - projectRoot: string, - nodePath: string, - homeDir: string, -): void { - const serviceManager = getServiceManager(); - - if (serviceManager === 'systemd') { - setupSystemdAll(serviceDefs, projectRoot, nodePath, homeDir); - } else { - // WSL without systemd or other Linux without systemd - for (const def of serviceDefs) { - setupNohupFallback(def, projectRoot, nodePath, homeDir); - } - } -} - -/** - * Kill any orphaned ejclaw node processes left from previous runs or debugging. - * Prevents connection conflicts when two instances connect to the same channel simultaneously. - */ -function killOrphanedProcesses(projectRoot: string): void { - try { - execSync(`pkill -f '${projectRoot}/dist/index\\.js' || true`, { - stdio: 'ignore', - }); - logger.info('Stopped any orphaned ejclaw processes'); - } catch { - // pkill not available or no orphans - } -} - -/* ------------------------------------------------------------------ */ -/* systemd */ -/* ------------------------------------------------------------------ */ - -function setupSystemdAll( - serviceDefs: ServiceDef[], - projectRoot: string, - nodePath: string, - homeDir: string, -): void { - const runningAsRoot = isRoot(); - const systemctlPrefix = runningAsRoot ? 'systemctl' : 'systemctl --user'; - - // Pre-flight: verify user-level systemd session is available - if (!runningAsRoot) { - try { - execSync('systemctl --user daemon-reload', { stdio: 'pipe' }); - } catch { - logger.warn( - 'systemd user session not available — falling back to nohup wrapper', - ); - for (const def of serviceDefs) { - setupNohupFallback(def, projectRoot, nodePath, homeDir); - } - return; - } - } - - // Kill orphaned processes once before installing any services - killOrphanedProcesses(projectRoot); - - // Install each service - for (const def of serviceDefs) { - setupSystemdUnit(def, projectRoot, nodePath, homeDir, runningAsRoot); - } - setupSystemdStackRestartUnit(projectRoot, nodePath, homeDir, runningAsRoot); - - // Reload daemon once after all units are written - try { - execSync(`${systemctlPrefix} daemon-reload`, { stdio: 'ignore' }); - } catch (err) { - logger.error({ err }, 'systemctl daemon-reload failed'); - } - - // Enable and start each service - for (const def of serviceDefs) { - try { - execSync(`${systemctlPrefix} enable ${def.name}`, { stdio: 'ignore' }); - } catch (err) { - logger.error({ err, service: def.name }, 'systemctl enable failed'); - } - - try { - execSync(`${systemctlPrefix} start ${def.name}`, { stdio: 'ignore' }); - } catch (err) { - logger.error({ err, service: def.name }, 'systemctl start failed'); - } - - // Verify - let serviceLoaded = false; - try { - execSync(`${systemctlPrefix} is-active ${def.name}`, { - stdio: 'ignore', - }); - serviceLoaded = true; - } catch { - // Not active - } - - emitStatus('SETUP_SERVICE', { - SERVICE_NAME: def.name, - SERVICE_TYPE: runningAsRoot ? 'systemd-system' : 'systemd-user', - NODE_PATH: nodePath, - PROJECT_PATH: projectRoot, - UNIT_PATH: getUnitPath(def.name, homeDir, runningAsRoot), - SERVICE_LOADED: serviceLoaded, - STATUS: 'success', - LOG: 'logs/setup.log', - }); - } -} - -function getUnitPath( - serviceName: string, - homeDir: string, - runningAsRoot: boolean, -): string { - if (runningAsRoot) { - return `/etc/systemd/system/${serviceName}.service`; - } - return path.join( - homeDir, - '.config', - 'systemd', - 'user', - `${serviceName}.service`, - ); -} - -function setupSystemdUnit( - def: ServiceDef, - projectRoot: string, - nodePath: string, - homeDir: string, - runningAsRoot: boolean, -): void { - const unitPath = getUnitPath(def.name, homeDir, runningAsRoot); - fs.mkdirSync(path.dirname(unitPath), { recursive: true }); - - const unit = buildSystemdUnit( - def, - projectRoot, - nodePath, - homeDir, - runningAsRoot, - ); - - fs.writeFileSync(unitPath, unit); - logger.info({ unitPath, service: def.name }, 'Wrote systemd unit'); -} - -export function buildSystemdUnit( - def: ServiceDef, - projectRoot: string, - nodePath: string, - homeDir: string, - runningAsRoot: boolean, -): string { - const envLines = buildSystemdEnvironmentLines( - nodePath, - homeDir, - def.extraEnv, - ); - - // EnvironmentFile line (optional — for codex service loading .env.codex) - const envFileLine = def.environmentFile - ? `EnvironmentFile=${def.environmentFile}\n` - : ''; - - return `[Unit] -Description=${def.description} -After=network.target - -[Service] -${envFileLine}Type=simple -ExecStart=${nodePath} ${projectRoot}/dist/index.js -WorkingDirectory=${projectRoot} -Restart=always -RestartSec=5 -${envLines.join('\n')} -StandardOutput=append:${projectRoot}/logs/${def.logName}.log -StandardError=append:${projectRoot}/logs/${def.logName}.error.log - -[Install] -WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; -} - -export function buildStackRestartSystemdUnit( - projectRoot: string, - nodePath: string, - homeDir: string, -): string { - const envLines = buildSystemdEnvironmentLines(nodePath, homeDir); - - return `[Unit] -Description=EJClaw Stack Restart Orchestrator -After=network.target - -[Service] -Type=oneshot -WorkingDirectory=${projectRoot} -${envLines.join('\n')} -ExecStart=${nodePath} ${projectRoot}/setup/restart-stack.ts --direct -`; -} - -function setupSystemdStackRestartUnit( - projectRoot: string, - nodePath: string, - homeDir: string, - runningAsRoot: boolean, -): void { - const unitPath = getUnitPath( - STACK_RESTART_UNIT_NAME.replace(/\.service$/, ''), - homeDir, - runningAsRoot, - ); - fs.mkdirSync(path.dirname(unitPath), { recursive: true }); - fs.writeFileSync( - unitPath, - buildStackRestartSystemdUnit(projectRoot, nodePath, homeDir), - ); - logger.info( - { unitPath, service: STACK_RESTART_UNIT_NAME }, - 'Wrote stack restart systemd unit', - ); -} - -/* ------------------------------------------------------------------ */ -/* nohup fallback (WSL / no systemd) */ -/* ------------------------------------------------------------------ */ - -function setupNohupFallback( - def: ServiceDef, - projectRoot: string, - nodePath: string, - homeDir: string, -): void { - logger.warn( - { service: def.name }, - 'No systemd detected — generating nohup wrapper script', - ); - - const wrapperPath = path.join(projectRoot, `start-${def.name}.sh`); - const pidFile = path.join(projectRoot, `${def.name}.pid`); - - // Build export lines for extra env - const exportLines: string[] = []; - if (def.environmentFile) { - exportLines.push(`# Load environment file`); - exportLines.push(`set -a`); - exportLines.push(`source ${JSON.stringify(def.environmentFile)}`); - exportLines.push(`set +a`); - exportLines.push(''); - } - if (def.extraEnv) { - for (const [k, v] of Object.entries(def.extraEnv)) { - exportLines.push(`export ${k}=${JSON.stringify(v)}`); - } - exportLines.push(''); - } - - const lines = [ - '#!/bin/bash', - `# start-${def.name}.sh — Start ${def.description} without systemd`, - `# To stop: kill \\$(cat ${pidFile})`, - '', - 'set -euo pipefail', - '', - `cd ${JSON.stringify(projectRoot)}`, - '', - ...exportLines, - '# Stop existing instance if running', - `if [ -f ${JSON.stringify(pidFile)} ]; then`, - ` OLD_PID=$(cat ${JSON.stringify(pidFile)} 2>/dev/null || echo "")`, - ' if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then', - ` echo "Stopping existing ${def.name} (PID $OLD_PID)..."`, - ' kill "$OLD_PID" 2>/dev/null || true', - ' sleep 2', - ' fi', - 'fi', - '', - `echo "Starting ${def.description}..."`, - `nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot + '/dist/index.js')} \\`, - ` >> ${JSON.stringify(projectRoot + '/logs/' + def.logName + '.log')} \\`, - ` 2>> ${JSON.stringify(projectRoot + '/logs/' + def.logName + '.error.log')} &`, - '', - `echo $! > ${JSON.stringify(pidFile)}`, - `echo "${def.name} started (PID $!)"`, - `echo "Logs: tail -f ${projectRoot}/logs/${def.logName}.log"`, - ]; - const wrapper = lines.join('\n') + '\n'; - - fs.writeFileSync(wrapperPath, wrapper, { mode: 0o755 }); - logger.info({ wrapperPath, service: def.name }, 'Wrote nohup wrapper script'); - - emitStatus('SETUP_SERVICE', { - SERVICE_NAME: def.name, - SERVICE_TYPE: 'nohup', - NODE_PATH: nodePath, - PROJECT_PATH: projectRoot, - WRAPPER_PATH: wrapperPath, - SERVICE_LOADED: false, - FALLBACK: 'wsl_no_systemd', - STATUS: 'success', - LOG: 'logs/setup.log', - }); -} diff --git a/setup/verify-services.test.ts b/setup/verify-services.test.ts new file mode 100644 index 0000000..7688350 --- /dev/null +++ b/setup/verify-services.test.ts @@ -0,0 +1,106 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { execSyncMock, isRootMock } = vi.hoisted(() => ({ + execSyncMock: vi.fn(), + isRootMock: vi.fn(() => false), +})); + +vi.mock('child_process', () => ({ + execSync: execSyncMock, +})); + +vi.mock('./platform.js', () => ({ + isRoot: isRootMock, +})); + +import { + checkLaunchdService, + checkNohupService, + checkSystemdService, + getServiceChecks, +} from './verify-services.js'; +import type { ServiceDef } from './service-defs.js'; + +describe('verify service checks', () => { + afterEach(() => { + execSyncMock.mockReset(); + isRootMock.mockReset(); + isRootMock.mockReturnValue(false); + vi.restoreAllMocks(); + }); + + it('treats launchd entries with a PID as running', () => { + execSyncMock.mockReturnValue('123\t0\tcom.ejclaw\n'); + + expect(checkLaunchdService('com.ejclaw')).toBe('running'); + }); + + it('treats launchd entries without a PID as stopped', () => { + execSyncMock.mockReturnValue('-\t0\tcom.ejclaw\n'); + + expect(checkLaunchdService('com.ejclaw')).toBe('stopped'); + }); + + it('checks systemd user services with the user prefix', () => { + isRootMock.mockReturnValue(false); + execSyncMock.mockReturnValue(undefined); + + expect(checkSystemdService('ejclaw')).toBe('running'); + expect(execSyncMock).toHaveBeenCalledWith('systemctl --user is-active ejclaw', { + stdio: 'ignore', + }); + }); + + it('treats known but inactive systemd services as stopped', () => { + execSyncMock + .mockImplementationOnce(() => { + throw new Error('inactive'); + }) + .mockReturnValueOnce('ejclaw.service enabled\n'); + + expect(checkSystemdService('ejclaw')).toBe('stopped'); + }); + + it('treats a live nohup PID as running', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-')); + const pidFile = path.join(tempRoot, 'ejclaw.pid'); + fs.writeFileSync(pidFile, '12345\n'); + + const killSpy = vi + .spyOn(process, 'kill') + .mockImplementation((_pid: number, _signal?: number | NodeJS.Signals) => true); + + expect(checkNohupService(tempRoot, 'ejclaw')).toBe('running'); + + killSpy.mockRestore(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('builds per-service status checks from service definitions', () => { + const defs: ServiceDef[] = [ + { + kind: 'primary', + name: 'ejclaw', + description: 'EJClaw', + launchdLabel: 'com.ejclaw', + logName: 'ejclaw', + }, + { + kind: 'codex', + name: 'ejclaw-codex', + description: 'Codex', + launchdLabel: 'com.ejclaw.codex', + logName: 'ejclaw-codex', + }, + ]; + execSyncMock.mockReturnValue('123\t0\tcom.ejclaw\n'); + + expect(getServiceChecks(defs, '/tmp/ejclaw', 'launchd')).toEqual([ + { name: 'ejclaw', status: 'running' }, + { name: 'ejclaw-codex', status: 'not_found' }, + ]); + }); +}); diff --git a/setup/verify-services.ts b/setup/verify-services.ts new file mode 100644 index 0000000..c5fabc1 --- /dev/null +++ b/setup/verify-services.ts @@ -0,0 +1,104 @@ +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import { isRoot } from './platform.js'; +import type { ServiceDef } from './service-defs.js'; + +export type ServiceStatus = + | 'running' + | 'stopped' + | 'not_found' + | 'not_configured'; + +export interface ServiceCheck { + name: string; + status: ServiceStatus; +} + +export function checkLaunchdService(label: string): ServiceStatus { + try { + const output = execSync('launchctl list', { encoding: 'utf-8' }); + if (output.includes(label)) { + const line = output.split('\n').find((candidate) => candidate.includes(label)); + if (line) { + const pidField = line.trim().split(/\s+/)[0]; + return pidField !== '-' && pidField ? 'running' : 'stopped'; + } + } + } catch { + // launchctl not available + } + return 'not_found'; +} + +export function checkSystemdService(name: string): ServiceStatus { + const prefix = isRoot() ? 'systemctl' : 'systemctl --user'; + try { + execSync(`${prefix} is-active ${name}`, { stdio: 'ignore' }); + return 'running'; + } catch { + try { + const output = execSync(`${prefix} list-unit-files`, { + encoding: 'utf-8', + }); + if (output.includes(name)) { + return 'stopped'; + } + } catch { + // systemctl not available + } + } + return 'not_found'; +} + +export function checkNohupService( + projectRoot: string, + serviceName: string, +): ServiceStatus { + const pidFile = path.join(projectRoot, `${serviceName}.pid`); + if (fs.existsSync(pidFile)) { + try { + const raw = fs.readFileSync(pidFile, 'utf-8').trim(); + const pid = Number(raw); + if (raw && Number.isInteger(pid) && pid > 0) { + process.kill(pid, 0); + return 'running'; + } + } catch { + return 'stopped'; + } + } + return 'not_found'; +} + +export function checkService( + projectRoot: string, + serviceManager: 'launchd' | 'systemd' | 'none', + serviceName: string, + launchdLabel: string, +): ServiceStatus { + if (serviceManager === 'launchd') { + return checkLaunchdService(launchdLabel); + } + if (serviceManager === 'systemd') { + return checkSystemdService(serviceName); + } + return checkNohupService(projectRoot, serviceName); +} + +export function getServiceChecks( + serviceDefs: ServiceDef[], + projectRoot: string, + serviceManager: 'launchd' | 'systemd' | 'none', +): ServiceCheck[] { + return serviceDefs.map((def) => ({ + name: def.name, + status: checkService( + projectRoot, + serviceManager, + def.name, + def.launchdLabel, + ), + })); +} diff --git a/setup/verify.ts b/setup/verify.ts index f490457..fcd229c 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -9,7 +9,6 @@ * * Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks. */ -import { execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; @@ -18,91 +17,10 @@ import { Database } from 'bun:sqlite'; import { STORE_DIR } from '../src/config.js'; import { readEnvFile } from '../src/env.js'; import { logger } from '../src/logger.js'; -import { getPlatform, getServiceManager, isRoot } from './platform.js'; +import { getServiceManager } from './platform.js'; import { getServiceDefs } from './service-defs.js'; import { emitStatus } from './status.js'; - -/* ------------------------------------------------------------------ */ -/* Types */ -/* ------------------------------------------------------------------ */ - -type ServiceStatus = 'running' | 'stopped' | 'not_found' | 'not_configured'; - -interface ServiceCheck { - name: string; - status: ServiceStatus; -} - -/* ------------------------------------------------------------------ */ -/* Service status checks */ -/* ------------------------------------------------------------------ */ - -function checkLaunchdService(label: string): ServiceStatus { - try { - const output = execSync('launchctl list', { encoding: 'utf-8' }); - if (output.includes(label)) { - const line = output.split('\n').find((l) => l.includes(label)); - if (line) { - const pidField = line.trim().split(/\s+/)[0]; - return pidField !== '-' && pidField ? 'running' : 'stopped'; - } - } - } catch { - // launchctl not available - } - return 'not_found'; -} - -function checkSystemdService(name: string): ServiceStatus { - const prefix = isRoot() ? 'systemctl' : 'systemctl --user'; - try { - execSync(`${prefix} is-active ${name}`, { stdio: 'ignore' }); - return 'running'; - } catch { - try { - const output = execSync(`${prefix} list-unit-files`, { - encoding: 'utf-8', - }); - if (output.includes(name)) { - return 'stopped'; - } - } catch { - // systemctl not available - } - } - return 'not_found'; -} - -function checkNohupService( - projectRoot: string, - serviceName: string, -): ServiceStatus { - const pidFile = path.join(projectRoot, `${serviceName}.pid`); - if (fs.existsSync(pidFile)) { - try { - const raw = fs.readFileSync(pidFile, 'utf-8').trim(); - const pid = Number(raw); - if (raw && Number.isInteger(pid) && pid > 0) { - process.kill(pid, 0); - return 'running'; - } - } catch { - return 'stopped'; - } - } - return 'not_found'; -} - -function checkService( - projectRoot: string, - mgr: ReturnType, - serviceName: string, - launchdLabel: string, -): ServiceStatus { - if (mgr === 'launchd') return checkLaunchdService(launchdLabel); - if (mgr === 'systemd') return checkSystemdService(serviceName); - return checkNohupService(projectRoot, serviceName); -} +import { getServiceChecks } from './verify-services.js'; /* ------------------------------------------------------------------ */ /* Main */ @@ -116,10 +34,7 @@ export async function run(_args: string[]): Promise { // 1. Check service statuses const serviceDefs = getServiceDefs(projectRoot); - const services: ServiceCheck[] = serviceDefs.map((def) => ({ - name: def.name, - status: checkService(projectRoot, mgr, def.name, def.launchdLabel), - })); + const services = getServiceChecks(serviceDefs, projectRoot, mgr); for (const svc of services) { logger.info({ service: svc.name, status: svc.status }, 'Service status');