feat: add onboarding wizard

This commit is contained in:
Peter Steinberger
2026-01-01 17:57:57 +01:00
parent d83ea305b5
commit 35b66e5ad1
16 changed files with 1759 additions and 7 deletions

3
src/daemon/constants.ts Normal file
View File

@@ -0,0 +1,3 @@
export const GATEWAY_LAUNCH_AGENT_LABEL = "com.steipete.clawdis.gateway";
export const GATEWAY_SYSTEMD_SERVICE_NAME = "clawdis-gateway";
export const GATEWAY_WINDOWS_TASK_NAME = "Clawdis Gateway";

282
src/daemon/launchd.ts Normal file
View File

@@ -0,0 +1,282 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "./constants.js";
const execFileAsync = promisify(execFile);
function resolveHomeDir(env: Record<string, string | undefined>): string {
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
if (!home) throw new Error("Missing HOME");
return home;
}
export function resolveLaunchAgentPlistPath(
env: Record<string, string | undefined>,
): string {
const home = resolveHomeDir(env);
return path.join(
home,
"Library",
"LaunchAgents",
`${GATEWAY_LAUNCH_AGENT_LABEL}.plist`,
);
}
export function resolveGatewayLogPaths(env: Record<string, string | undefined>): {
logDir: string;
stdoutPath: string;
stderrPath: string;
} {
const home = resolveHomeDir(env);
const logDir = path.join(home, ".clawdis", "logs");
return {
logDir,
stdoutPath: path.join(logDir, "gateway.log"),
stderrPath: path.join(logDir, "gateway.err.log"),
};
}
function plistEscape(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
function plistUnescape(value: string): string {
return value
.replaceAll("&apos;", "'")
.replaceAll("&quot;", '"')
.replaceAll("&gt;", ">")
.replaceAll("&lt;", "<")
.replaceAll("&amp;", "&");
}
function renderEnvDict(
env: Record<string, string | undefined> | undefined,
): string {
if (!env) return "";
const entries = Object.entries(env).filter(
([, value]) => typeof value === "string" && value.trim(),
);
if (entries.length === 0) return "";
const items = entries
.map(
([key, value]) => `
<key>${plistEscape(key)}</key>
<string>${plistEscape(value?.trim() ?? "")}</string>`,
)
.join("");
return `
<key>EnvironmentVariables</key>
<dict>${items}
</dict>`;
}
export async function readLaunchAgentProgramArguments(
env: Record<string, string | undefined>,
): Promise<{ programArguments: string[]; workingDirectory?: string } | null> {
const plistPath = resolveLaunchAgentPlistPath(env);
try {
const plist = await fs.readFile(plistPath, "utf8");
const programMatch = plist.match(
/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/i,
);
if (!programMatch) return null;
const args = Array.from(
programMatch[1].matchAll(/<string>([\s\S]*?)<\/string>/gi),
).map((match) => plistUnescape(match[1] ?? "").trim());
const workingDirMatch = plist.match(
/<key>WorkingDirectory<\/key>\s*<string>([\s\S]*?)<\/string>/i,
);
const workingDirectory = workingDirMatch
? plistUnescape(workingDirMatch[1] ?? "").trim()
: "";
return {
programArguments: args.filter(Boolean),
...(workingDirectory ? { workingDirectory } : {}),
};
} catch {
return null;
}
}
export function buildLaunchAgentPlist({
label = GATEWAY_LAUNCH_AGENT_LABEL,
programArguments,
workingDirectory,
stdoutPath,
stderrPath,
environment,
}: {
label?: string;
programArguments: string[];
workingDirectory?: string;
stdoutPath: string;
stderrPath: string;
environment?: Record<string, string | undefined>;
}): string {
const argsXml = programArguments
.map((arg) => `\n <string>${plistEscape(arg)}</string>`)
.join("");
const workingDirXml = workingDirectory
? `
<key>WorkingDirectory</key>
<string>${plistEscape(workingDirectory)}</string>`
: "";
const envXml = renderEnvDict(environment);
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${plistEscape(label)}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ProgramArguments</key>
<array>${argsXml}
</array>
${workingDirXml}
<key>StandardOutPath</key>
<string>${plistEscape(stdoutPath)}</string>
<key>StandardErrorPath</key>
<string>${plistEscape(stderrPath)}</string>${envXml}
</dict>
</plist>
`;
}
async function execLaunchctl(
args: string[],
): Promise<{ stdout: string; stderr: string; code: number }> {
try {
const { stdout, stderr } = await execFileAsync("launchctl", args, {
encoding: "utf8",
});
return { stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), code: 0 };
} catch (error) {
const e = error as {
stdout?: unknown;
stderr?: unknown;
code?: unknown;
message?: unknown;
};
return {
stdout: typeof e.stdout === "string" ? e.stdout : "",
stderr:
typeof e.stderr === "string"
? e.stderr
: typeof e.message === "string"
? e.message
: "",
code: typeof e.code === "number" ? e.code : 1,
};
}
}
function resolveGuiDomain(): string {
if (typeof process.getuid !== "function") return "gui/501";
return `gui/${process.getuid()}`;
}
export async function isLaunchAgentLoaded(): Promise<boolean> {
const domain = resolveGuiDomain();
const label = GATEWAY_LAUNCH_AGENT_LABEL;
const res = await execLaunchctl(["print", `${domain}/${label}`]);
return res.code === 0;
}
export async function uninstallLaunchAgent({
env,
stdout,
}: {
env: Record<string, string | undefined>;
stdout: NodeJS.WritableStream;
}): Promise<void> {
const domain = resolveGuiDomain();
const plistPath = resolveLaunchAgentPlistPath(env);
await execLaunchctl(["bootout", domain, plistPath]);
await execLaunchctl(["unload", plistPath]);
try {
await fs.access(plistPath);
} catch {
stdout.write(`LaunchAgent not found at ${plistPath}\n`);
return;
}
const home = resolveHomeDir(env);
const trashDir = path.join(home, ".Trash");
const dest = path.join(trashDir, `${GATEWAY_LAUNCH_AGENT_LABEL}.plist`);
try {
await fs.mkdir(trashDir, { recursive: true });
await fs.rename(plistPath, dest);
stdout.write(`Moved LaunchAgent to Trash: ${dest}\n`);
} catch {
stdout.write(`LaunchAgent remains at ${plistPath} (could not move)\n`);
}
}
export async function installLaunchAgent({
env,
stdout,
programArguments,
workingDirectory,
environment,
}: {
env: Record<string, string | undefined>;
stdout: NodeJS.WritableStream;
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string | undefined>;
}): Promise<{ plistPath: string }> {
const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env);
await fs.mkdir(logDir, { recursive: true });
const plistPath = resolveLaunchAgentPlistPath(env);
await fs.mkdir(path.dirname(plistPath), { recursive: true });
const plist = buildLaunchAgentPlist({
programArguments,
workingDirectory,
stdoutPath,
stderrPath,
environment,
});
await fs.writeFile(plistPath, plist, "utf8");
const domain = resolveGuiDomain();
await execLaunchctl(["bootout", domain, plistPath]);
await execLaunchctl(["unload", plistPath]);
const boot = await execLaunchctl(["bootstrap", domain, plistPath]);
if (boot.code !== 0) {
throw new Error(`launchctl bootstrap failed: ${boot.stderr || boot.stdout}`.trim());
}
await execLaunchctl(["enable", `${domain}/${GATEWAY_LAUNCH_AGENT_LABEL}`]);
await execLaunchctl(["kickstart", "-k", `${domain}/${GATEWAY_LAUNCH_AGENT_LABEL}`]);
stdout.write(`Installed LaunchAgent: ${plistPath}\n`);
stdout.write(`Logs: ${stdoutPath}\n`);
return { plistPath };
}
export async function restartLaunchAgent({
stdout,
}: {
stdout: NodeJS.WritableStream;
}): Promise<void> {
const domain = resolveGuiDomain();
const label = GATEWAY_LAUNCH_AGENT_LABEL;
const res = await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]);
if (res.code !== 0) {
throw new Error(`launchctl kickstart failed: ${res.stderr || res.stdout}`.trim());
}
stdout.write(`Restarted LaunchAgent: ${domain}/${label}\n`);
}

View File

@@ -0,0 +1,93 @@
import fs from "node:fs/promises";
import path from "node:path";
type GatewayProgramArgs = {
programArguments: string[];
workingDirectory?: string;
};
function isNodeRuntime(execPath: string): boolean {
const base = path.basename(execPath).toLowerCase();
return base === "node" || base === "node.exe";
}
async function resolveCliEntrypointPathForService(): Promise<string> {
const argv1 = process.argv[1];
if (!argv1) throw new Error("Unable to resolve CLI entrypoint path");
const normalized = path.resolve(argv1);
const looksLikeDist = /[/\\]dist[/\\].+\.(cjs|js|mjs)$/.test(normalized);
if (looksLikeDist) {
await fs.access(normalized);
return normalized;
}
const distCandidates = [
path.resolve(path.dirname(normalized), "..", "dist", "index.js"),
path.resolve(path.dirname(normalized), "..", "dist", "index.mjs"),
path.resolve(path.dirname(normalized), "dist", "index.js"),
path.resolve(path.dirname(normalized), "dist", "index.mjs"),
];
for (const candidate of distCandidates) {
try {
await fs.access(candidate);
return candidate;
} catch {
// keep going
}
}
throw new Error(
`Cannot find built CLI at ${distCandidates.join(" or ")}. Run "pnpm build" first, or use dev mode.`,
);
}
function resolveRepoRootForDev(): string {
const argv1 = process.argv[1];
if (!argv1) throw new Error("Unable to resolve repo root");
const normalized = path.resolve(argv1);
const parts = normalized.split(path.sep);
const srcIndex = parts.lastIndexOf("src");
if (srcIndex === -1) {
throw new Error("Dev mode requires running from repo (src/index.ts)");
}
return parts.slice(0, srcIndex).join(path.sep);
}
async function resolveTsxCliPath(repoRoot: string): Promise<string> {
const candidate = path.join(repoRoot, "node_modules", "tsx", "dist", "cli.mjs");
await fs.access(candidate);
return candidate;
}
export async function resolveGatewayProgramArguments(params: {
port: number;
dev?: boolean;
}): Promise<GatewayProgramArgs> {
const gatewayArgs = ["gateway-daemon", "--port", String(params.port)];
const nodePath = process.execPath;
if (!params.dev) {
try {
const cliEntrypointPath = await resolveCliEntrypointPathForService();
return {
programArguments: [nodePath, cliEntrypointPath, ...gatewayArgs],
};
} catch (error) {
if (!isNodeRuntime(nodePath)) {
return { programArguments: [nodePath, ...gatewayArgs] };
}
throw error;
}
}
const repoRoot = resolveRepoRootForDev();
const tsxCliPath = await resolveTsxCliPath(repoRoot);
const devCliPath = path.join(repoRoot, "src", "index.ts");
await fs.access(devCliPath);
return {
programArguments: [nodePath, tsxCliPath, devCliPath, ...gatewayArgs],
workingDirectory: repoRoot,
};
}

233
src/daemon/schtasks.ts Normal file
View File

@@ -0,0 +1,233 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { GATEWAY_WINDOWS_TASK_NAME } from "./constants.js";
const execFileAsync = promisify(execFile);
function resolveHomeDir(env: Record<string, string | undefined>): string {
const home = env.USERPROFILE?.trim() || env.HOME?.trim();
if (!home) throw new Error("Missing HOME");
return home;
}
function resolveTaskScriptPath(env: Record<string, string | undefined>): string {
const home = resolveHomeDir(env);
return path.join(home, ".clawdis", "gateway.cmd");
}
function quoteCmdArg(value: string): string {
if (!/[ \t"]/g.test(value)) return value;
return `"${value.replace(/"/g, '\\"')}"`;
}
function parseCommandLine(value: string): string[] {
const args: string[] = [];
let current = "";
let inQuotes = false;
let escapeNext = false;
for (const char of value) {
if (escapeNext) {
current += char;
escapeNext = false;
continue;
}
if (char === "\\") {
escapeNext = true;
continue;
}
if (char === '"') {
inQuotes = !inQuotes;
continue;
}
if (!inQuotes && /\s/.test(char)) {
if (current) {
args.push(current);
current = "";
}
continue;
}
current += char;
}
if (current) args.push(current);
return args;
}
export async function readScheduledTaskCommand(
env: Record<string, string | undefined>,
): Promise<{ programArguments: string[]; workingDirectory?: string } | null> {
const scriptPath = resolveTaskScriptPath(env);
try {
const content = await fs.readFile(scriptPath, "utf8");
let workingDirectory = "";
let commandLine = "";
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
if (line.startsWith("@echo")) continue;
if (line.toLowerCase().startsWith("rem ")) continue;
if (line.toLowerCase().startsWith("set ")) continue;
if (line.toLowerCase().startsWith("cd /d ")) {
workingDirectory = line.slice("cd /d ".length).trim().replace(/^"|"$/g, "");
continue;
}
commandLine = line;
break;
}
if (!commandLine) return null;
return {
programArguments: parseCommandLine(commandLine),
...(workingDirectory ? { workingDirectory } : {}),
};
} catch {
return null;
}
}
function buildTaskScript({
programArguments,
workingDirectory,
environment,
}: {
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string | undefined>;
}): string {
const lines: string[] = ["@echo off"];
if (workingDirectory) {
lines.push(`cd /d ${quoteCmdArg(workingDirectory)}`);
}
if (environment) {
for (const [key, value] of Object.entries(environment)) {
if (!value) continue;
lines.push(`set ${key}=${value}`);
}
}
const command = programArguments.map(quoteCmdArg).join(" ");
lines.push(command);
return `${lines.join("\r\n")}\r\n`;
}
async function execSchtasks(
args: string[],
): Promise<{ stdout: string; stderr: string; code: number }> {
try {
const { stdout, stderr } = await execFileAsync("schtasks", args, {
encoding: "utf8",
windowsHide: true,
});
return { stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), code: 0 };
} catch (error) {
const e = error as {
stdout?: unknown;
stderr?: unknown;
code?: unknown;
message?: unknown;
};
return {
stdout: typeof e.stdout === "string" ? e.stdout : "",
stderr:
typeof e.stderr === "string"
? e.stderr
: typeof e.message === "string"
? e.message
: "",
code: typeof e.code === "number" ? e.code : 1,
};
}
}
async function assertSchtasksAvailable() {
const res = await execSchtasks(["/Query"]);
if (res.code === 0) return;
const detail = res.stderr || res.stdout;
throw new Error(`schtasks unavailable: ${detail || "unknown error"}`.trim());
}
export async function installScheduledTask({
env,
stdout,
programArguments,
workingDirectory,
environment,
}: {
env: Record<string, string | undefined>;
stdout: NodeJS.WritableStream;
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string | undefined>;
}): Promise<{ scriptPath: string }> {
await assertSchtasksAvailable();
const scriptPath = resolveTaskScriptPath(env);
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
const script = buildTaskScript({
programArguments,
workingDirectory,
environment,
});
await fs.writeFile(scriptPath, script, "utf8");
const quotedScript = quoteCmdArg(scriptPath);
const create = await execSchtasks([
"/Create",
"/F",
"/SC",
"ONLOGON",
"/RL",
"LIMITED",
"/TN",
GATEWAY_WINDOWS_TASK_NAME,
"/TR",
quotedScript,
]);
if (create.code !== 0) {
throw new Error(`schtasks create failed: ${create.stderr || create.stdout}`.trim());
}
await execSchtasks(["/Run", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
stdout.write(`Installed Scheduled Task: ${GATEWAY_WINDOWS_TASK_NAME}\n`);
stdout.write(`Task script: ${scriptPath}\n`);
return { scriptPath };
}
export async function uninstallScheduledTask({
env,
stdout,
}: {
env: Record<string, string | undefined>;
stdout: NodeJS.WritableStream;
}): Promise<void> {
await assertSchtasksAvailable();
await execSchtasks(["/Delete", "/F", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
const scriptPath = resolveTaskScriptPath(env);
try {
await fs.unlink(scriptPath);
stdout.write(`Removed task script: ${scriptPath}\n`);
} catch {
stdout.write(`Task script not found at ${scriptPath}\n`);
}
}
export async function restartScheduledTask({
stdout,
}: {
stdout: NodeJS.WritableStream;
}): Promise<void> {
await assertSchtasksAvailable();
await execSchtasks(["/End", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
const res = await execSchtasks(["/Run", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
if (res.code !== 0) {
throw new Error(`schtasks run failed: ${res.stderr || res.stdout}`.trim());
}
stdout.write(`Restarted Scheduled Task: ${GATEWAY_WINDOWS_TASK_NAME}\n`);
}
export async function isScheduledTaskInstalled(): Promise<boolean> {
await assertSchtasksAvailable();
const res = await execSchtasks(["/Query", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
return res.code === 0;
}

106
src/daemon/service.ts Normal file
View File

@@ -0,0 +1,106 @@
import {
installLaunchAgent,
isLaunchAgentLoaded,
readLaunchAgentProgramArguments,
restartLaunchAgent,
uninstallLaunchAgent,
} from "./launchd.js";
import {
installScheduledTask,
isScheduledTaskInstalled,
readScheduledTaskCommand,
restartScheduledTask,
uninstallScheduledTask,
} from "./schtasks.js";
import {
installSystemdService,
isSystemdServiceEnabled,
readSystemdServiceExecStart,
restartSystemdService,
uninstallSystemdService,
} from "./systemd.js";
export type GatewayServiceInstallArgs = {
env: Record<string, string | undefined>;
stdout: NodeJS.WritableStream;
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string | undefined>;
};
export type GatewayService = {
label: string;
loadedText: string;
notLoadedText: string;
install: (args: GatewayServiceInstallArgs) => Promise<void>;
uninstall: (args: {
env: Record<string, string | undefined>;
stdout: NodeJS.WritableStream;
}) => Promise<void>;
restart: (args: { stdout: NodeJS.WritableStream }) => Promise<void>;
isLoaded: (args: { env: Record<string, string | undefined> }) => Promise<boolean>;
readCommand: (
env: Record<string, string | undefined>,
) => Promise<{ programArguments: string[]; workingDirectory?: string } | null>;
};
export function resolveGatewayService(): GatewayService {
if (process.platform === "darwin") {
return {
label: "LaunchAgent",
loadedText: "loaded",
notLoadedText: "not loaded",
install: async (args) => {
await installLaunchAgent(args);
},
uninstall: async (args) => {
await uninstallLaunchAgent(args);
},
restart: async (args) => {
await restartLaunchAgent(args);
},
isLoaded: async () => isLaunchAgentLoaded(),
readCommand: readLaunchAgentProgramArguments,
};
}
if (process.platform === "linux") {
return {
label: "systemd",
loadedText: "enabled",
notLoadedText: "disabled",
install: async (args) => {
await installSystemdService(args);
},
uninstall: async (args) => {
await uninstallSystemdService(args);
},
restart: async (args) => {
await restartSystemdService(args);
},
isLoaded: async () => isSystemdServiceEnabled(),
readCommand: readSystemdServiceExecStart,
};
}
if (process.platform === "win32") {
return {
label: "Scheduled Task",
loadedText: "registered",
notLoadedText: "missing",
install: async (args) => {
await installScheduledTask(args);
},
uninstall: async (args) => {
await uninstallScheduledTask(args);
},
restart: async (args) => {
await restartScheduledTask(args);
},
isLoaded: async () => isScheduledTaskInstalled(),
readCommand: readScheduledTaskCommand,
};
}
throw new Error(`Gateway service install not supported on ${process.platform}`);
}

256
src/daemon/systemd.ts Normal file
View File

@@ -0,0 +1,256 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { GATEWAY_SYSTEMD_SERVICE_NAME } from "./constants.js";
const execFileAsync = promisify(execFile);
function resolveHomeDir(env: Record<string, string | undefined>): string {
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
if (!home) throw new Error("Missing HOME");
return home;
}
function resolveSystemdUnitPath(env: Record<string, string | undefined>): string {
const home = resolveHomeDir(env);
return path.join(
home,
".config",
"systemd",
"user",
`${GATEWAY_SYSTEMD_SERVICE_NAME}.service`,
);
}
function systemdEscapeArg(value: string): string {
if (!/[\s"\\]/.test(value)) return value;
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
function renderEnvLines(
env: Record<string, string | undefined> | undefined,
): string[] {
if (!env) return [];
const entries = Object.entries(env).filter(
([, value]) => typeof value === "string" && value.trim(),
);
if (entries.length === 0) return [];
return entries.map(
([key, value]) =>
`Environment=${systemdEscapeArg(`${key}=${value?.trim() ?? ""}`)}`,
);
}
function buildSystemdUnit({
programArguments,
workingDirectory,
environment,
}: {
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string | undefined>;
}): string {
const execStart = programArguments.map(systemdEscapeArg).join(" ");
const workingDirLine = workingDirectory
? `WorkingDirectory=${systemdEscapeArg(workingDirectory)}`
: null;
const envLines = renderEnvLines(environment);
return [
"[Unit]",
"Description=Clawdis Gateway",
"",
"[Service]",
`ExecStart=${execStart}`,
"Restart=always",
workingDirLine,
...envLines,
"",
"[Install]",
"WantedBy=default.target",
"",
]
.filter((line) => line !== null)
.join("\n");
}
function parseSystemdExecStart(value: string): string[] {
const args: string[] = [];
let current = "";
let inQuotes = false;
let escapeNext = false;
for (const char of value) {
if (escapeNext) {
current += char;
escapeNext = false;
continue;
}
if (char === "\\") {
escapeNext = true;
continue;
}
if (char === '"') {
inQuotes = !inQuotes;
continue;
}
if (!inQuotes && /\s/.test(char)) {
if (current) {
args.push(current);
current = "";
}
continue;
}
current += char;
}
if (current) args.push(current);
return args;
}
export async function readSystemdServiceExecStart(
env: Record<string, string | undefined>,
): Promise<{ programArguments: string[]; workingDirectory?: string } | null> {
const unitPath = resolveSystemdUnitPath(env);
try {
const content = await fs.readFile(unitPath, "utf8");
let execStart = "";
let workingDirectory = "";
for (const rawLine of content.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
if (line.startsWith("ExecStart=")) {
execStart = line.slice("ExecStart=".length).trim();
} else if (line.startsWith("WorkingDirectory=")) {
workingDirectory = line.slice("WorkingDirectory=".length).trim();
}
}
if (!execStart) return null;
const programArguments = parseSystemdExecStart(execStart);
return {
programArguments,
...(workingDirectory ? { workingDirectory } : {}),
};
} catch {
return null;
}
}
async function execSystemctl(
args: string[],
): Promise<{ stdout: string; stderr: string; code: number }> {
try {
const { stdout, stderr } = await execFileAsync("systemctl", args, {
encoding: "utf8",
});
return { stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), code: 0 };
} catch (error) {
const e = error as {
stdout?: unknown;
stderr?: unknown;
code?: unknown;
message?: unknown;
};
return {
stdout: typeof e.stdout === "string" ? e.stdout : "",
stderr:
typeof e.stderr === "string"
? e.stderr
: typeof e.message === "string"
? e.message
: "",
code: typeof e.code === "number" ? e.code : 1,
};
}
}
async function assertSystemdAvailable() {
const res = await execSystemctl(["--user", "status"]);
if (res.code === 0) return;
const detail = res.stderr || res.stdout;
if (detail.toLowerCase().includes("not found")) {
throw new Error("systemctl not available; systemd user services are required on Linux.");
}
throw new Error(`systemctl --user unavailable: ${detail || "unknown error"}`.trim());
}
export async function installSystemdService({
env,
stdout,
programArguments,
workingDirectory,
environment,
}: {
env: Record<string, string | undefined>;
stdout: NodeJS.WritableStream;
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string | undefined>;
}): Promise<{ unitPath: string }> {
await assertSystemdAvailable();
const unitPath = resolveSystemdUnitPath(env);
await fs.mkdir(path.dirname(unitPath), { recursive: true });
const unit = buildSystemdUnit({ programArguments, workingDirectory, environment });
await fs.writeFile(unitPath, unit, "utf8");
const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
const reload = await execSystemctl(["--user", "daemon-reload"]);
if (reload.code !== 0) {
throw new Error(`systemctl daemon-reload failed: ${reload.stderr || reload.stdout}`.trim());
}
const enable = await execSystemctl(["--user", "enable", unitName]);
if (enable.code !== 0) {
throw new Error(`systemctl enable failed: ${enable.stderr || enable.stdout}`.trim());
}
const restart = await execSystemctl(["--user", "restart", unitName]);
if (restart.code !== 0) {
throw new Error(`systemctl restart failed: ${restart.stderr || restart.stdout}`.trim());
}
stdout.write(`Installed systemd service: ${unitPath}\n`);
return { unitPath };
}
export async function uninstallSystemdService({
env,
stdout,
}: {
env: Record<string, string | undefined>;
stdout: NodeJS.WritableStream;
}): Promise<void> {
await assertSystemdAvailable();
const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
await execSystemctl(["--user", "disable", "--now", unitName]);
const unitPath = resolveSystemdUnitPath(env);
try {
await fs.unlink(unitPath);
stdout.write(`Removed systemd service: ${unitPath}\n`);
} catch {
stdout.write(`Systemd service not found at ${unitPath}\n`);
}
}
export async function restartSystemdService({
stdout,
}: {
stdout: NodeJS.WritableStream;
}): Promise<void> {
await assertSystemdAvailable();
const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
const res = await execSystemctl(["--user", "restart", unitName]);
if (res.code !== 0) {
throw new Error(`systemctl restart failed: ${res.stderr || res.stdout}`.trim());
}
stdout.write(`Restarted systemd service: ${unitName}\n`);
}
export async function isSystemdServiceEnabled(): Promise<boolean> {
await assertSystemdAvailable();
const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
const res = await execSystemctl(["--user", "is-enabled", unitName]);
return res.code === 0;
}