feat: add daemon service management

This commit is contained in:
Peter Steinberger
2026-01-07 21:37:05 +01:00
parent 7aeb6d5921
commit 391a3d6eaf
17 changed files with 1264 additions and 78 deletions

305
src/daemon/inspect.ts Normal file
View File

@@ -0,0 +1,305 @@
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,
GATEWAY_SYSTEMD_SERVICE_NAME,
GATEWAY_WINDOWS_TASK_NAME,
LEGACY_GATEWAY_LAUNCH_AGENT_LABELS,
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
LEGACY_GATEWAY_WINDOWS_TASK_NAMES,
} from "./constants.js";
export type ExtraGatewayService = {
platform: "darwin" | "linux" | "win32";
label: string;
detail: string;
scope: "user" | "system";
};
export type FindExtraGatewayServicesOptions = {
deep?: boolean;
};
const EXTRA_MARKERS = ["clawdbot", "clawdis", "gateway-daemon"];
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 containsMarker(content: string): boolean {
const lower = content.toLowerCase();
return EXTRA_MARKERS.some((marker) => lower.includes(marker));
}
function tryExtractPlistLabel(contents: string): string | null {
const match = contents.match(
/<key>Label<\/key>\s*<string>([\s\S]*?)<\/string>/i,
);
if (!match) return null;
return match[1]?.trim() || null;
}
function isIgnoredLaunchdLabel(label: string): boolean {
return (
label === GATEWAY_LAUNCH_AGENT_LABEL ||
LEGACY_GATEWAY_LAUNCH_AGENT_LABELS.includes(label)
);
}
function isIgnoredSystemdName(name: string): boolean {
return (
name === GATEWAY_SYSTEMD_SERVICE_NAME ||
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES.includes(name)
);
}
async function scanLaunchdDir(params: {
dir: string;
scope: "user" | "system";
}): Promise<ExtraGatewayService[]> {
const results: ExtraGatewayService[] = [];
let entries: string[] = [];
try {
entries = await fs.readdir(params.dir);
} catch {
return results;
}
for (const entry of entries) {
if (!entry.endsWith(".plist")) continue;
const labelFromName = entry.replace(/\.plist$/, "");
if (isIgnoredLaunchdLabel(labelFromName)) continue;
const fullPath = path.join(params.dir, entry);
let contents = "";
try {
contents = await fs.readFile(fullPath, "utf8");
} catch {
continue;
}
if (!containsMarker(contents)) continue;
const label = tryExtractPlistLabel(contents) ?? labelFromName;
if (isIgnoredLaunchdLabel(label)) continue;
results.push({
platform: "darwin",
label,
detail: `plist: ${fullPath}`,
scope: params.scope,
});
}
return results;
}
async function scanSystemdDir(params: {
dir: string;
scope: "user" | "system";
}): Promise<ExtraGatewayService[]> {
const results: ExtraGatewayService[] = [];
let entries: string[] = [];
try {
entries = await fs.readdir(params.dir);
} catch {
return results;
}
for (const entry of entries) {
if (!entry.endsWith(".service")) continue;
const name = entry.replace(/\.service$/, "");
if (isIgnoredSystemdName(name)) continue;
const fullPath = path.join(params.dir, entry);
let contents = "";
try {
contents = await fs.readFile(fullPath, "utf8");
} catch {
continue;
}
if (!containsMarker(contents)) continue;
results.push({
platform: "linux",
label: entry,
detail: `unit: ${fullPath}`,
scope: params.scope,
});
}
return results;
}
type ScheduledTaskInfo = {
name: string;
taskToRun?: string;
};
function parseSchtasksList(output: string): ScheduledTaskInfo[] {
const tasks: ScheduledTaskInfo[] = [];
let current: ScheduledTaskInfo | null = null;
for (const rawLine of output.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) {
if (current) {
tasks.push(current);
current = null;
}
continue;
}
const idx = line.indexOf(":");
if (idx <= 0) continue;
const key = line.slice(0, idx).trim().toLowerCase();
const value = line.slice(idx + 1).trim();
if (!value) continue;
if (key === "taskname") {
if (current) tasks.push(current);
current = { name: value };
continue;
}
if (!current) continue;
if (key === "task to run") {
current.taskToRun = value;
}
}
if (current) tasks.push(current);
return tasks;
}
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,
};
}
}
export async function findExtraGatewayServices(
env: Record<string, string | undefined>,
opts: FindExtraGatewayServicesOptions = {},
): Promise<ExtraGatewayService[]> {
const results: ExtraGatewayService[] = [];
const seen = new Set<string>();
const push = (svc: ExtraGatewayService) => {
const key = `${svc.platform}:${svc.label}:${svc.detail}:${svc.scope}`;
if (seen.has(key)) return;
seen.add(key);
results.push(svc);
};
if (process.platform === "darwin") {
try {
const home = resolveHomeDir(env);
const userDir = path.join(home, "Library", "LaunchAgents");
for (const svc of await scanLaunchdDir({
dir: userDir,
scope: "user",
})) {
push(svc);
}
if (opts.deep) {
for (const svc of await scanLaunchdDir({
dir: path.join(path.sep, "Library", "LaunchAgents"),
scope: "system",
})) {
push(svc);
}
for (const svc of await scanLaunchdDir({
dir: path.join(path.sep, "Library", "LaunchDaemons"),
scope: "system",
})) {
push(svc);
}
}
} catch {
return results;
}
return results;
}
if (process.platform === "linux") {
try {
const home = resolveHomeDir(env);
const userDir = path.join(home, ".config", "systemd", "user");
for (const svc of await scanSystemdDir({
dir: userDir,
scope: "user",
})) {
push(svc);
}
if (opts.deep) {
for (const dir of [
"/etc/systemd/system",
"/usr/lib/systemd/system",
"/lib/systemd/system",
]) {
for (const svc of await scanSystemdDir({
dir,
scope: "system",
})) {
push(svc);
}
}
}
} catch {
return results;
}
return results;
}
if (process.platform === "win32") {
if (!opts.deep) return results;
const res = await execSchtasks(["/Query", "/FO", "LIST", "/V"]);
if (res.code !== 0) return results;
const tasks = parseSchtasksList(res.stdout);
for (const task of tasks) {
const name = task.name.trim();
if (!name) continue;
if (name === GATEWAY_WINDOWS_TASK_NAME) continue;
if (LEGACY_GATEWAY_WINDOWS_TASK_NAMES.includes(name)) continue;
const lowerName = name.toLowerCase();
const lowerCommand = task.taskToRun?.toLowerCase() ?? "";
const matches = EXTRA_MARKERS.some(
(marker) => lowerName.includes(marker) || lowerCommand.includes(marker),
);
if (!matches) continue;
push({
platform: "win32",
label: name,
detail: task.taskToRun ? `task: ${name}, run: ${task.taskToRun}` : name,
scope: "system",
});
}
return results;
}
return results;
}