import { buildNodeInstallPlan } from "../../commands/node-daemon-install-helpers.js"; import { DEFAULT_NODE_DAEMON_RUNTIME, isNodeDaemonRuntime, } from "../../commands/node-daemon-runtime.js"; import { resolveNodeLaunchAgentLabel, resolveNodeSystemdServiceName, resolveNodeWindowsTaskName, } from "../../daemon/constants.js"; import { resolveGatewayLogPaths } from "../../daemon/launchd.js"; import { resolveNodeService } from "../../daemon/node-service.js"; import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js"; import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js"; import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js"; import { resolveIsNixMode } from "../../config/paths.js"; import { isWSL } from "../../infra/wsl.js"; import { loadNodeHostConfig } from "../../node-host/config.js"; import { defaultRuntime } from "../../runtime.js"; import { colorize, isRich, theme } from "../../terminal/theme.js"; import { formatCliCommand } from "../command-format.js"; import { buildDaemonServiceSnapshot, createNullWriter, emitDaemonActionJson, } from "../daemon-cli/response.js"; import { formatRuntimeStatus, parsePort } from "../daemon-cli/shared.js"; type NodeDaemonInstallOptions = { host?: string; port?: string | number; tls?: boolean; tlsFingerprint?: string; nodeId?: string; displayName?: string; runtime?: string; force?: boolean; json?: boolean; }; type NodeDaemonLifecycleOptions = { json?: boolean; }; type NodeDaemonStatusOptions = { json?: boolean; }; function renderNodeServiceStartHints(): string[] { const base = [formatCliCommand("moltbot node install"), formatCliCommand("moltbot node start")]; switch (process.platform) { case "darwin": return [ ...base, `launchctl bootstrap gui/$UID ~/Library/LaunchAgents/${resolveNodeLaunchAgentLabel()}.plist`, ]; case "linux": return [...base, `systemctl --user start ${resolveNodeSystemdServiceName()}.service`]; case "win32": return [...base, `schtasks /Run /TN "${resolveNodeWindowsTaskName()}"`]; default: return base; } } function buildNodeRuntimeHints(env: NodeJS.ProcessEnv = process.env): string[] { if (process.platform === "darwin") { const logs = resolveGatewayLogPaths(env); return [ `Launchd stdout (if installed): ${logs.stdoutPath}`, `Launchd stderr (if installed): ${logs.stderrPath}`, ]; } if (process.platform === "linux") { const unit = resolveNodeSystemdServiceName(); return [`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`]; } if (process.platform === "win32") { const task = resolveNodeWindowsTaskName(); return [`Logs: schtasks /Query /TN "${task}" /V /FO LIST`]; } return []; } function resolveNodeDefaults( opts: NodeDaemonInstallOptions, config: Awaited>, ) { const host = opts.host?.trim() || config?.gateway?.host || "127.0.0.1"; const portOverride = parsePort(opts.port); if (opts.port !== undefined && portOverride === null) { return { host, port: null }; } const port = portOverride ?? config?.gateway?.port ?? 18789; return { host, port }; } export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) { const json = Boolean(opts.json); const warnings: string[] = []; const stdout = json ? createNullWriter() : process.stdout; const emit = (payload: { ok: boolean; result?: string; message?: string; error?: string; service?: { label: string; loaded: boolean; loadedText: string; notLoadedText: string; }; hints?: string[]; warnings?: string[]; }) => { if (!json) return; emitDaemonActionJson({ action: "install", ...payload }); }; const fail = (message: string, hints?: string[]) => { if (json) { emit({ ok: false, error: message, hints, warnings: warnings.length ? warnings : undefined, }); } else { defaultRuntime.error(message); if (hints?.length) { for (const hint of hints) defaultRuntime.log(`Tip: ${hint}`); } } defaultRuntime.exit(1); }; if (resolveIsNixMode(process.env)) { fail("Nix mode detected; service install is disabled."); return; } const config = await loadNodeHostConfig(); const { host, port } = resolveNodeDefaults(opts, config); if (!Number.isFinite(port ?? NaN) || (port ?? 0) <= 0) { fail("Invalid port"); return; } const runtimeRaw = opts.runtime ? String(opts.runtime) : DEFAULT_NODE_DAEMON_RUNTIME; if (!isNodeDaemonRuntime(runtimeRaw)) { fail('Invalid --runtime (use "node" or "bun")'); return; } const service = resolveNodeService(); let loaded = false; try { loaded = await service.isLoaded({ env: process.env }); } catch (err) { fail(`Node service check failed: ${String(err)}`); return; } if (loaded && !opts.force) { emit({ ok: true, result: "already-installed", message: `Node service already ${service.loadedText}.`, service: buildDaemonServiceSnapshot(service, loaded), warnings: warnings.length ? warnings : undefined, }); if (!json) { defaultRuntime.log(`Node service already ${service.loadedText}.`); defaultRuntime.log(`Reinstall with: ${formatCliCommand("moltbot node install --force")}`); } return; } const tlsFingerprint = opts.tlsFingerprint?.trim() || config?.gateway?.tlsFingerprint; const tls = Boolean(opts.tls) || Boolean(tlsFingerprint) || Boolean(config?.gateway?.tls); const { programArguments, workingDirectory, environment, description } = await buildNodeInstallPlan({ env: process.env, host, port: port ?? 18789, tls, tlsFingerprint: tlsFingerprint || undefined, nodeId: opts.nodeId, displayName: opts.displayName, runtime: runtimeRaw, warn: (message) => { if (json) warnings.push(message); else defaultRuntime.log(message); }, }); try { await service.install({ env: process.env, stdout, programArguments, workingDirectory, environment, description, }); } catch (err) { fail(`Node install failed: ${String(err)}`); return; } let installed = true; try { installed = await service.isLoaded({ env: process.env }); } catch { installed = true; } emit({ ok: true, result: "installed", service: buildDaemonServiceSnapshot(service, installed), warnings: warnings.length ? warnings : undefined, }); } export async function runNodeDaemonUninstall(opts: NodeDaemonLifecycleOptions = {}) { const json = Boolean(opts.json); const stdout = json ? createNullWriter() : process.stdout; const emit = (payload: { ok: boolean; result?: string; message?: string; error?: string; service?: { label: string; loaded: boolean; loadedText: string; notLoadedText: string; }; }) => { if (!json) return; emitDaemonActionJson({ action: "uninstall", ...payload }); }; const fail = (message: string) => { if (json) emit({ ok: false, error: message }); else defaultRuntime.error(message); defaultRuntime.exit(1); }; if (resolveIsNixMode(process.env)) { fail("Nix mode detected; service uninstall is disabled."); return; } const service = resolveNodeService(); try { await service.uninstall({ env: process.env, stdout }); } catch (err) { fail(`Node uninstall failed: ${String(err)}`); return; } let loaded = false; try { loaded = await service.isLoaded({ env: process.env }); } catch { loaded = false; } emit({ ok: true, result: "uninstalled", service: buildDaemonServiceSnapshot(service, loaded), }); } export async function runNodeDaemonStart(opts: NodeDaemonLifecycleOptions = {}) { const json = Boolean(opts.json); const stdout = json ? createNullWriter() : process.stdout; const emit = (payload: { ok: boolean; result?: string; message?: string; error?: string; hints?: string[]; service?: { label: string; loaded: boolean; loadedText: string; notLoadedText: string; }; }) => { if (!json) return; emitDaemonActionJson({ action: "start", ...payload }); }; const fail = (message: string, hints?: string[]) => { if (json) emit({ ok: false, error: message, hints }); else defaultRuntime.error(message); defaultRuntime.exit(1); }; const service = resolveNodeService(); let loaded = false; try { loaded = await service.isLoaded({ env: process.env }); } catch (err) { fail(`Node service check failed: ${String(err)}`); return; } if (!loaded) { let hints = renderNodeServiceStartHints(); if (process.platform === "linux") { const systemdAvailable = await isSystemdUserServiceAvailable().catch(() => false); if (!systemdAvailable) { hints = [...hints, ...renderSystemdUnavailableHints({ wsl: await isWSL() })]; } } emit({ ok: true, result: "not-loaded", message: `Node service ${service.notLoadedText}.`, hints, service: buildDaemonServiceSnapshot(service, loaded), }); if (!json) { defaultRuntime.log(`Node service ${service.notLoadedText}.`); for (const hint of hints) { defaultRuntime.log(`Start with: ${hint}`); } } return; } try { await service.restart({ env: process.env, stdout }); } catch (err) { const hints = renderNodeServiceStartHints(); fail(`Node start failed: ${String(err)}`, hints); return; } let started = true; try { started = await service.isLoaded({ env: process.env }); } catch { started = true; } emit({ ok: true, result: "started", service: buildDaemonServiceSnapshot(service, started), }); } export async function runNodeDaemonRestart(opts: NodeDaemonLifecycleOptions = {}) { const json = Boolean(opts.json); const stdout = json ? createNullWriter() : process.stdout; const emit = (payload: { ok: boolean; result?: string; message?: string; error?: string; hints?: string[]; service?: { label: string; loaded: boolean; loadedText: string; notLoadedText: string; }; }) => { if (!json) return; emitDaemonActionJson({ action: "restart", ...payload }); }; const fail = (message: string, hints?: string[]) => { if (json) emit({ ok: false, error: message, hints }); else defaultRuntime.error(message); defaultRuntime.exit(1); }; const service = resolveNodeService(); let loaded = false; try { loaded = await service.isLoaded({ env: process.env }); } catch (err) { fail(`Node service check failed: ${String(err)}`); return; } if (!loaded) { let hints = renderNodeServiceStartHints(); if (process.platform === "linux") { const systemdAvailable = await isSystemdUserServiceAvailable().catch(() => false); if (!systemdAvailable) { hints = [...hints, ...renderSystemdUnavailableHints({ wsl: await isWSL() })]; } } emit({ ok: true, result: "not-loaded", message: `Node service ${service.notLoadedText}.`, hints, service: buildDaemonServiceSnapshot(service, loaded), }); if (!json) { defaultRuntime.log(`Node service ${service.notLoadedText}.`); for (const hint of hints) { defaultRuntime.log(`Start with: ${hint}`); } } return; } try { await service.restart({ env: process.env, stdout }); } catch (err) { const hints = renderNodeServiceStartHints(); fail(`Node restart failed: ${String(err)}`, hints); return; } let restarted = true; try { restarted = await service.isLoaded({ env: process.env }); } catch { restarted = true; } emit({ ok: true, result: "restarted", service: buildDaemonServiceSnapshot(service, restarted), }); } export async function runNodeDaemonStop(opts: NodeDaemonLifecycleOptions = {}) { const json = Boolean(opts.json); const stdout = json ? createNullWriter() : process.stdout; const emit = (payload: { ok: boolean; result?: string; message?: string; error?: string; service?: { label: string; loaded: boolean; loadedText: string; notLoadedText: string; }; }) => { if (!json) return; emitDaemonActionJson({ action: "stop", ...payload }); }; const fail = (message: string) => { if (json) emit({ ok: false, error: message }); else defaultRuntime.error(message); defaultRuntime.exit(1); }; const service = resolveNodeService(); let loaded = false; try { loaded = await service.isLoaded({ env: process.env }); } catch (err) { fail(`Node service check failed: ${String(err)}`); return; } if (!loaded) { emit({ ok: true, result: "not-loaded", message: `Node service ${service.notLoadedText}.`, service: buildDaemonServiceSnapshot(service, loaded), }); if (!json) { defaultRuntime.log(`Node service ${service.notLoadedText}.`); } return; } try { await service.stop({ env: process.env, stdout }); } catch (err) { fail(`Node stop failed: ${String(err)}`); return; } let stopped = false; try { stopped = await service.isLoaded({ env: process.env }); } catch { stopped = false; } emit({ ok: true, result: "stopped", service: buildDaemonServiceSnapshot(service, stopped), }); } export async function runNodeDaemonStatus(opts: NodeDaemonStatusOptions = {}) { const json = Boolean(opts.json); const service = resolveNodeService(); const [loaded, command, runtime] = await Promise.all([ service.isLoaded({ env: process.env }).catch(() => false), service.readCommand(process.env).catch(() => null), service .readRuntime(process.env) .catch((err): GatewayServiceRuntime => ({ status: "unknown", detail: String(err) })), ]); const payload = { service: { ...buildDaemonServiceSnapshot(service, loaded), command, runtime, }, }; if (json) { defaultRuntime.log(JSON.stringify(payload, null, 2)); return; } const rich = isRich(); const label = (value: string) => colorize(rich, theme.muted, value); const accent = (value: string) => colorize(rich, theme.accent, value); const infoText = (value: string) => colorize(rich, theme.info, value); const okText = (value: string) => colorize(rich, theme.success, value); const warnText = (value: string) => colorize(rich, theme.warn, value); const errorText = (value: string) => colorize(rich, theme.error, value); const serviceStatus = loaded ? okText(service.loadedText) : warnText(service.notLoadedText); defaultRuntime.log(`${label("Service:")} ${accent(service.label)} (${serviceStatus})`); if (command?.programArguments?.length) { defaultRuntime.log(`${label("Command:")} ${infoText(command.programArguments.join(" "))}`); } if (command?.sourcePath) { defaultRuntime.log(`${label("Service file:")} ${infoText(command.sourcePath)}`); } if (command?.workingDirectory) { defaultRuntime.log(`${label("Working dir:")} ${infoText(command.workingDirectory)}`); } const runtimeLine = formatRuntimeStatus(runtime); if (runtimeLine) { const runtimeStatus = runtime?.status ?? "unknown"; const runtimeColor = runtimeStatus === "running" ? theme.success : runtimeStatus === "stopped" ? theme.error : runtimeStatus === "unknown" ? theme.muted : theme.warn; defaultRuntime.log(`${label("Runtime:")} ${colorize(rich, runtimeColor, runtimeLine)}`); } if (!loaded) { defaultRuntime.log(""); for (const hint of renderNodeServiceStartHints()) { defaultRuntime.log(`${warnText("Start with:")} ${infoText(hint)}`); } return; } const baseEnv = { ...(process.env as Record), ...(command?.environment ?? undefined), }; const hintEnv = { ...baseEnv, CLAWDBOT_LOG_PREFIX: baseEnv.CLAWDBOT_LOG_PREFIX ?? "node", } as NodeJS.ProcessEnv; if (runtime?.missingUnit) { defaultRuntime.error(errorText("Service unit not found.")); for (const hint of buildNodeRuntimeHints(hintEnv)) { defaultRuntime.error(errorText(hint)); } return; } if (runtime?.status === "stopped") { defaultRuntime.error(errorText("Service is loaded but not running.")); for (const hint of buildNodeRuntimeHints(hintEnv)) { defaultRuntime.error(errorText(hint)); } } }