Some checks failed
CI / build-check-test (push) Has been cancelled
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>
169 lines
4.9 KiB
TypeScript
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]);
|
|
}
|