import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; export const WEBUI_SERVICE_NAME = "sproutclaw-webui.service"; const SYSTEM_UNIT_PATH = `/etc/systemd/system/${WEBUI_SERVICE_NAME}`; export const DEFAULT_WEBUI_PORT = 19133; export interface SystemdServiceConfig { extensionDir: string; repoRoot: string; agentDir: string; nodeBin: string; tsxCli: string; } let systemdReady = false; let systemdDisabledReason: string | null = null; function runSystemctl(args: string[]): { ok: boolean; output: string } { const result = spawnSync("systemctl", args, { encoding: "utf8" }); const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); return { ok: result.status === 0, output }; } function isSystemctlAvailable(): boolean { return spawnSync("systemctl", ["--version"], { encoding: "utf8" }).status === 0; } function isServiceRegistered(): boolean { const result = runSystemctl(["list-unit-files", WEBUI_SERVICE_NAME, "--no-legend"]); return result.ok && result.output.length > 0 && !result.output.includes("0 unit files"); } function envFilePath(extensionDir: string): string { return join(extensionDir, "data", "webui-service.env"); } function runScriptPath(extensionDir: string): string { return join(extensionDir, "scripts", "webui-run.sh"); } function writeIfChanged(path: string, content: string): boolean { if (existsSync(path)) { try { if (readFileSync(path, "utf8") === content) return false; } catch { // Rewrite unreadable files below. } } writeFileSync(path, content, "utf8"); return true; } function buildEnvContent(config: SystemdServiceConfig, port: number): string { return [ `REPO_ROOT=${config.repoRoot}`, `EXTENSION_DIR=${config.extensionDir}`, `NODE_BIN=${config.nodeBin}`, `TSX_CLI=${config.tsxCli}`, `PI_CODING_AGENT_DIR=${config.agentDir}`, `PORT=${port}`, ].join("\n"); } function buildRunScript(): string { return `#!/usr/bin/env bash set -euo pipefail : "\${REPO_ROOT:?REPO_ROOT required}" : "\${EXTENSION_DIR:?EXTENSION_DIR required}" : "\${NODE_BIN:?NODE_BIN required}" : "\${TSX_CLI:?TSX_CLI required}" : "\${PI_CODING_AGENT_DIR:?PI_CODING_AGENT_DIR required}" PORT="\${PORT:-19133}" cd "\$REPO_ROOT" exec "\$NODE_BIN" "\$TSX_CLI" "\$EXTENSION_DIR/backend/main.ts" --port "\$PORT" `; } function buildUnitFile(config: SystemdServiceConfig): string { const envFile = envFilePath(config.extensionDir); const runScript = runScriptPath(config.extensionDir); return `[Unit] Description=SproutClaw WebUI HTTP Server After=network-online.target Wants=network-online.target [Service] Type=simple EnvironmentFile=-${envFile} ExecStart=${runScript} WorkingDirectory=${config.repoRoot} Restart=on-failure RestartSec=5 KillMode=mixed TimeoutStopSec=15 [Install] WantedBy=multi-user.target `; } export function isSystemdManaged(): boolean { return systemdReady; } export function getSystemdDisabledReason(): string | null { return systemdDisabledReason; } export function syncSystemdPort(config: SystemdServiceConfig, port: number): void { mkdirSync(join(config.extensionDir, "data"), { recursive: true }); writeIfChanged(envFilePath(config.extensionDir), `${buildEnvContent(config, port)}\n`); } export function ensureSystemdService( config: SystemdServiceConfig, port = DEFAULT_WEBUI_PORT, ): boolean { if (!isSystemctlAvailable()) { systemdDisabledReason = "systemctl 不可用"; systemdReady = false; return false; } try { if (!existsSync(config.nodeBin)) { throw new Error(`Node 未找到: ${config.nodeBin}`); } if (!existsSync(config.tsxCli)) { throw new Error(`tsx 未找到: ${config.tsxCli}`); } mkdirSync(join(config.extensionDir, "data"), { recursive: true }); mkdirSync(join(config.extensionDir, "scripts"), { recursive: true }); const envChanged = writeIfChanged(envFilePath(config.extensionDir), `${buildEnvContent(config, port)}\n`); const scriptChanged = writeIfChanged(runScriptPath(config.extensionDir), buildRunScript()); chmodSync(runScriptPath(config.extensionDir), 0o755); const unitChanged = writeIfChanged(SYSTEM_UNIT_PATH, buildUnitFile(config)); if (unitChanged || scriptChanged || envChanged || !isServiceRegistered()) { runSystemctl(["daemon-reload"]); } if (!isServiceRegistered()) { const enable = runSystemctl(["enable", WEBUI_SERVICE_NAME]); if (!enable.ok) { throw new Error(enable.output || "systemctl enable 失败"); } } systemdReady = true; systemdDisabledReason = null; return true; } catch (err) { systemdReady = false; systemdDisabledReason = err instanceof Error ? err.message : String(err); return false; } } export function systemdControl(action: "start" | "stop" | "restart"): { ok: boolean; output: string } { if (!systemdReady) { return { ok: false, output: systemdDisabledReason ?? "systemd 未就绪" }; } return runSystemctl([action, WEBUI_SERVICE_NAME]); }