/** * Step: service — Generate and load service manager config. * Replaces 08-setup-service.sh * * Fixes: Root→system systemd, WSL nohup fallback, no `|| true` swallowing errors. */ import { execSync } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { logger } from '../src/logger.js'; import { getPlatform, getNodePath, getServiceManager, hasSystemd, isRoot, isWSL, } from './platform.js'; import { emitStatus } from './status.js'; const EJCLAW_SERVICE_NAME = 'ejclaw'; const EJCLAW_LAUNCHD_LABEL = 'com.ejclaw'; export async function run(_args: string[]): Promise { const projectRoot = process.cwd(); const platform = getPlatform(); const nodePath = getNodePath(); const homeDir = os.homedir(); logger.info({ platform, nodePath, projectRoot }, 'Setting up service'); // Build first logger.info('Building TypeScript'); try { execSync('npm run build', { cwd: projectRoot, stdio: ['ignore', 'pipe', 'pipe'], }); logger.info('Build succeeded'); } catch { logger.error('Build failed'); emitStatus('SETUP_SERVICE', { SERVICE_TYPE: 'unknown', NODE_PATH: nodePath, PROJECT_PATH: projectRoot, STATUS: 'failed', ERROR: 'build_failed', LOG: 'logs/setup.log', }); process.exit(1); } fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true }); if (platform === 'macos') { setupLaunchd(projectRoot, nodePath, homeDir); } else if (platform === 'linux') { setupLinux(projectRoot, nodePath, homeDir); } else { emitStatus('SETUP_SERVICE', { SERVICE_TYPE: 'unknown', NODE_PATH: nodePath, PROJECT_PATH: projectRoot, STATUS: 'failed', ERROR: 'unsupported_platform', LOG: 'logs/setup.log', }); process.exit(1); } } function setupLaunchd( projectRoot: string, nodePath: string, homeDir: string, ): void { const plistPath = path.join( homeDir, 'Library', 'LaunchAgents', `${EJCLAW_LAUNCHD_LABEL}.plist`, ); fs.mkdirSync(path.dirname(plistPath), { recursive: true }); const plist = ` Label ${EJCLAW_LAUNCHD_LABEL} ProgramArguments ${nodePath} ${projectRoot}/dist/index.js WorkingDirectory ${projectRoot} RunAtLoad KeepAlive EnvironmentVariables PATH ${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin:${homeDir}/.npm-global/bin HOME ${homeDir} StandardOutPath ${projectRoot}/logs/ejclaw.log StandardErrorPath ${projectRoot}/logs/ejclaw.error.log `; fs.writeFileSync(plistPath, plist); logger.info({ plistPath }, 'Wrote launchd plist'); try { execSync(`launchctl load ${JSON.stringify(plistPath)}`, { stdio: 'ignore', }); logger.info('launchctl load succeeded'); } catch { logger.warn('launchctl load failed (may already be loaded)'); } // Verify let serviceLoaded = false; try { const output = execSync('launchctl list', { encoding: 'utf-8' }); serviceLoaded = output.includes(EJCLAW_LAUNCHD_LABEL); } catch { // launchctl list failed } emitStatus('SETUP_SERVICE', { SERVICE_TYPE: 'launchd', NODE_PATH: nodePath, PROJECT_PATH: projectRoot, PLIST_PATH: plistPath, SERVICE_LOADED: serviceLoaded, STATUS: 'success', LOG: 'logs/setup.log', }); } function setupLinux( projectRoot: string, nodePath: string, homeDir: string, ): void { const serviceManager = getServiceManager(); if (serviceManager === 'systemd') { setupSystemd(projectRoot, nodePath, homeDir); } else { // WSL without systemd or other Linux without systemd setupNohupFallback(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 setupSystemd( projectRoot: string, nodePath: string, homeDir: string, ): void { const runningAsRoot = isRoot(); // Root uses system-level service, non-root uses user-level let unitPath: string; let systemctlPrefix: string; if (runningAsRoot) { unitPath = `/etc/systemd/system/${EJCLAW_SERVICE_NAME}.service`; systemctlPrefix = 'systemctl'; logger.info('Running as root — installing system-level systemd unit'); } else { // Check if user-level systemd session is available try { execSync('systemctl --user daemon-reload', { stdio: 'pipe' }); } catch { logger.warn( 'systemd user session not available — falling back to nohup wrapper', ); setupNohupFallback(projectRoot, nodePath, homeDir); return; } const unitDir = path.join(homeDir, '.config', 'systemd', 'user'); fs.mkdirSync(unitDir, { recursive: true }); unitPath = path.join(unitDir, `${EJCLAW_SERVICE_NAME}.service`); systemctlPrefix = 'systemctl --user'; } const unit = `[Unit] Description=EJClaw Personal Assistant After=network.target [Service] Type=simple ExecStart=${nodePath} ${projectRoot}/dist/index.js WorkingDirectory=${projectRoot} Restart=always RestartSec=5 Environment=HOME=${homeDir} Environment=PATH=${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin:${homeDir}/.npm-global/bin StandardOutput=append:${projectRoot}/logs/ejclaw.log StandardError=append:${projectRoot}/logs/ejclaw.error.log [Install] WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; fs.writeFileSync(unitPath, unit); logger.info({ unitPath }, 'Wrote systemd unit'); // Kill orphaned ejclaw processes to avoid channel connection conflicts killOrphanedProcesses(projectRoot); // Enable and start try { execSync(`${systemctlPrefix} daemon-reload`, { stdio: 'ignore' }); } catch (err) { logger.error({ err }, 'systemctl daemon-reload failed'); } try { execSync(`${systemctlPrefix} enable ${EJCLAW_SERVICE_NAME}`, { stdio: 'ignore', }); } catch (err) { logger.error({ err }, 'systemctl enable failed'); } try { execSync(`${systemctlPrefix} start ${EJCLAW_SERVICE_NAME}`, { stdio: 'ignore', }); } catch (err) { logger.error({ err }, 'systemctl start failed'); } // Verify let serviceLoaded = false; try { execSync(`${systemctlPrefix} is-active ${EJCLAW_SERVICE_NAME}`, { stdio: 'ignore', }); serviceLoaded = true; } catch { // Not active } emitStatus('SETUP_SERVICE', { SERVICE_TYPE: runningAsRoot ? 'systemd-system' : 'systemd-user', NODE_PATH: nodePath, PROJECT_PATH: projectRoot, UNIT_PATH: unitPath, SERVICE_LOADED: serviceLoaded, STATUS: 'success', LOG: 'logs/setup.log', }); } function setupNohupFallback( projectRoot: string, nodePath: string, homeDir: string, ): void { logger.warn('No systemd detected — generating nohup wrapper script'); const wrapperPath = path.join(projectRoot, 'start-ejclaw.sh'); const pidFile = path.join(projectRoot, 'ejclaw.pid'); const lines = [ '#!/bin/bash', '# start-ejclaw.sh — Start EJClaw without systemd', `# To stop: kill \\$(cat ${pidFile})`, '', 'set -euo pipefail', '', `cd ${JSON.stringify(projectRoot)}`, '', '# 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 EJClaw (PID $OLD_PID)..."', ' kill "$OLD_PID" 2>/dev/null || true', ' sleep 2', ' fi', 'fi', '', 'echo "Starting EJClaw..."', `nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot + '/dist/index.js')} \\`, ` >> ${JSON.stringify(projectRoot + '/logs/ejclaw.log')} \\`, ` 2>> ${JSON.stringify(projectRoot + '/logs/ejclaw.error.log')} &`, '', `echo $! > ${JSON.stringify(pidFile)}`, 'echo "EJClaw started (PID $!)"', `echo "Logs: tail -f ${projectRoot}/logs/ejclaw.log"`, ]; const wrapper = lines.join('\n') + '\n'; fs.writeFileSync(wrapperPath, wrapper, { mode: 0o755 }); logger.info({ wrapperPath }, 'Wrote nohup wrapper script'); emitStatus('SETUP_SERVICE', { 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', }); }