Files
sproutclaw/.pi/agent/extensions/webui/systemd/service.ts
root cf5edd6394
Some checks failed
CI / build-check-test (push) Has been cancelled
feat(sproutclaw): modularize webui, extensions, and agent config layout
Restructure local extensions into per-feature directories, split WebUI
into backend modules with slash commands and systemd support, and track
prompts/skills under .pi/agent for portable Gitea deployment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 16:57:08 +08:00

169 lines
4.9 KiB
TypeScript

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]);
}