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

View File

@@ -0,0 +1,134 @@
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
const callGateway = vi.fn(async () => ({ ok: true }));
const resolveGatewayProgramArguments = vi.fn(async () => ({
programArguments: ["/bin/node", "cli", "gateway-daemon", "--port", "18789"],
}));
const serviceInstall = vi.fn().mockResolvedValue(undefined);
const serviceUninstall = vi.fn().mockResolvedValue(undefined);
const serviceStop = vi.fn().mockResolvedValue(undefined);
const serviceRestart = vi.fn().mockResolvedValue(undefined);
const serviceIsLoaded = vi.fn().mockResolvedValue(false);
const serviceReadCommand = vi.fn().mockResolvedValue(null);
const findExtraGatewayServices = vi.fn(async () => []);
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const defaultRuntime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (msg: string) => runtimeErrors.push(msg),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGateway(opts),
}));
vi.mock("../daemon/program-args.js", () => ({
resolveGatewayProgramArguments: (opts: unknown) =>
resolveGatewayProgramArguments(opts),
}));
vi.mock("../daemon/service.js", () => ({
resolveGatewayService: () => ({
label: "LaunchAgent",
loadedText: "loaded",
notLoadedText: "not loaded",
install: serviceInstall,
uninstall: serviceUninstall,
stop: serviceStop,
restart: serviceRestart,
isLoaded: serviceIsLoaded,
readCommand: serviceReadCommand,
}),
}));
vi.mock("../daemon/legacy.js", () => ({
findLegacyGatewayServices: () => [],
}));
vi.mock("../daemon/inspect.js", () => ({
findExtraGatewayServices: (env: unknown, opts?: unknown) =>
findExtraGatewayServices(env, opts),
}));
vi.mock("../runtime.js", () => ({
defaultRuntime,
}));
vi.mock("./deps.js", () => ({
createDefaultDeps: () => {},
}));
describe("daemon-cli coverage", () => {
it("probes gateway status by default", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGateway.mockClear();
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "status"], { from: "user" });
expect(callGateway).toHaveBeenCalledTimes(1);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({ method: "status" }),
);
expect(findExtraGatewayServices).toHaveBeenCalled();
});
it("passes deep scan flag for daemon status", async () => {
findExtraGatewayServices.mockClear();
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "status", "--deep"], { from: "user" });
expect(findExtraGatewayServices).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ deep: true }),
);
});
it("installs the daemon when requested", async () => {
serviceIsLoaded.mockResolvedValueOnce(false);
serviceInstall.mockClear();
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "install", "--port", "18789"], {
from: "user",
});
expect(serviceInstall).toHaveBeenCalledTimes(1);
});
it("starts and stops the daemon via service helpers", async () => {
serviceRestart.mockClear();
serviceStop.mockClear();
serviceIsLoaded.mockResolvedValue(true);
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "start"], { from: "user" });
await program.parseAsync(["daemon", "stop"], { from: "user" });
expect(serviceRestart).toHaveBeenCalledTimes(1);
expect(serviceStop).toHaveBeenCalledTimes(1);
});
});

466
src/cli/daemon-cli.ts Normal file
View File

@@ -0,0 +1,466 @@
import path from "node:path";
import type { Command } from "commander";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
isGatewayDaemonRuntime,
} from "../commands/daemon-runtime.js";
import { loadConfig, resolveGatewayPort } from "../config/config.js";
import { resolveIsNixMode } from "../config/paths.js";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
GATEWAY_SYSTEMD_SERVICE_NAME,
GATEWAY_WINDOWS_TASK_NAME,
} from "../daemon/constants.js";
import {
type FindExtraGatewayServicesOptions,
findExtraGatewayServices,
} from "../daemon/inspect.js";
import { findLegacyGatewayServices } from "../daemon/legacy.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolveGatewayService } from "../daemon/service.js";
import { callGateway } from "../gateway/call.js";
import { defaultRuntime } from "../runtime.js";
import { createDefaultDeps } from "./deps.js";
type DaemonStatus = {
service: {
label: string;
loaded: boolean;
loadedText: string;
notLoadedText: string;
command?: {
programArguments: string[];
workingDirectory?: string;
} | null;
};
rpc?: {
ok: boolean;
error?: string;
};
legacyServices: Array<{ label: string; detail: string }>;
extraServices: Array<{ label: string; detail: string; scope: string }>;
};
export type GatewayRpcOpts = {
url?: string;
token?: string;
password?: string;
timeout?: string;
};
export type DaemonStatusOptions = {
rpc: GatewayRpcOpts;
probe: boolean;
json: boolean;
} & FindExtraGatewayServicesOptions;
export type DaemonInstallOptions = {
port?: string | number;
runtime?: string;
token?: string;
};
function parsePort(raw: unknown): number | null {
if (raw === undefined || raw === null) return null;
const value =
typeof raw === "string"
? raw
: typeof raw === "number" || typeof raw === "bigint"
? raw.toString()
: null;
if (value === null) return null;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return parsed;
}
async function probeGatewayStatus(opts: GatewayRpcOpts) {
try {
await callGateway({
url: opts.url,
token: opts.token,
password: opts.password,
method: "status",
timeoutMs: Number(opts.timeout ?? 10_000),
clientName: "cli",
mode: "cli",
});
return { ok: true } as const;
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
} as const;
}
}
function renderGatewayServiceStartHints(): string[] {
switch (process.platform) {
case "darwin":
return [
`launchctl bootstrap gui/$UID ~/Library/LaunchAgents/${GATEWAY_LAUNCH_AGENT_LABEL}.plist`,
];
case "linux":
return [`systemctl --user start ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`];
case "win32":
return [`schtasks /Run /TN "${GATEWAY_WINDOWS_TASK_NAME}"`];
default:
return [];
}
}
function renderGatewayServiceCleanupHints(): string[] {
switch (process.platform) {
case "darwin":
return [
`launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
`rm ~/Library/LaunchAgents/${GATEWAY_LAUNCH_AGENT_LABEL}.plist`,
];
case "linux":
return [
`systemctl --user disable --now ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`,
`rm ~/.config/systemd/user/${GATEWAY_SYSTEMD_SERVICE_NAME}.service`,
];
case "win32":
return [`schtasks /Delete /TN "${GATEWAY_WINDOWS_TASK_NAME}" /F`];
default:
return [];
}
}
async function gatherDaemonStatus(opts: {
rpc: GatewayRpcOpts;
probe: boolean;
deep?: boolean;
}): Promise<DaemonStatus> {
const service = resolveGatewayService();
const [loaded, command] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false),
service.readCommand(process.env).catch(() => null),
]);
const legacyServices = await findLegacyGatewayServices(process.env);
const extraServices = await findExtraGatewayServices(process.env, {
deep: opts.deep,
});
const rpc = opts.probe ? await probeGatewayStatus(opts.rpc) : undefined;
return {
service: {
label: service.label,
loaded,
loadedText: service.loadedText,
notLoadedText: service.notLoadedText,
command,
},
rpc,
legacyServices,
extraServices,
};
}
function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
if (opts.json) {
defaultRuntime.log(JSON.stringify(status, null, 2));
return;
}
const { service, rpc, legacyServices, extraServices } = status;
defaultRuntime.log(
`Service: ${service.label} (${service.loaded ? service.loadedText : service.notLoadedText})`,
);
if (service.command?.programArguments?.length) {
defaultRuntime.log(
`Command: ${service.command.programArguments.join(" ")}`,
);
}
if (service.command?.workingDirectory) {
defaultRuntime.log(`Working dir: ${service.command.workingDirectory}`);
}
if (rpc) {
if (rpc.ok) {
defaultRuntime.log("RPC probe: ok");
} else {
defaultRuntime.error(`RPC probe: failed (${rpc.error})`);
}
}
if (legacyServices.length > 0) {
defaultRuntime.error("Legacy Clawdis services detected:");
for (const svc of legacyServices) {
defaultRuntime.error(`- ${svc.label} (${svc.detail})`);
}
defaultRuntime.error("Cleanup: clawdbot doctor");
}
if (extraServices.length > 0) {
defaultRuntime.error("Other gateway-like services detected (best effort):");
for (const svc of extraServices) {
defaultRuntime.error(`- ${svc.label} (${svc.scope}, ${svc.detail})`);
}
for (const hint of renderGatewayServiceCleanupHints()) {
defaultRuntime.error(`Cleanup hint: ${hint}`);
}
}
if (legacyServices.length > 0 || extraServices.length > 0) {
defaultRuntime.error(
"Recommendation: run a single gateway per machine. One gateway supports multiple agents.",
);
defaultRuntime.error(
"If you need multiple gateways, isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).",
);
}
}
export async function runDaemonStatus(opts: DaemonStatusOptions) {
try {
const status = await gatherDaemonStatus({
rpc: opts.rpc,
probe: Boolean(opts.probe),
deep: Boolean(opts.deep),
});
printDaemonStatus(status, { json: Boolean(opts.json) });
} catch (err) {
defaultRuntime.error(`Daemon status failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}
export async function runDaemonInstall(opts: DaemonInstallOptions) {
if (resolveIsNixMode(process.env)) {
defaultRuntime.error("Nix mode detected; daemon install is disabled.");
defaultRuntime.exit(1);
return;
}
const cfg = loadConfig();
const portOverride = parsePort(opts.port);
if (opts.port !== undefined && portOverride === null) {
defaultRuntime.error("Invalid port");
defaultRuntime.exit(1);
return;
}
const port = portOverride ?? resolveGatewayPort(cfg);
if (!Number.isFinite(port) || port <= 0) {
defaultRuntime.error("Invalid port");
defaultRuntime.exit(1);
return;
}
const runtimeRaw = opts.runtime
? String(opts.runtime)
: DEFAULT_GATEWAY_DAEMON_RUNTIME;
if (!isGatewayDaemonRuntime(runtimeRaw)) {
defaultRuntime.error('Invalid --runtime (use "node" or "bun")');
defaultRuntime.exit(1);
return;
}
const service = resolveGatewayService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
return;
}
if (loaded) {
defaultRuntime.log(`Gateway service already ${service.loadedText}.`);
return;
}
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: runtimeRaw,
});
const environment: Record<string, string | undefined> = {
PATH: process.env.PATH,
CLAWDBOT_GATEWAY_TOKEN:
opts.token ||
cfg.gateway?.auth?.token ||
process.env.CLAWDBOT_GATEWAY_TOKEN,
CLAWDBOT_LAUNCHD_LABEL:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
};
try {
await service.install({
env: process.env,
stdout: process.stdout,
programArguments,
workingDirectory,
environment,
});
} catch (err) {
defaultRuntime.error(`Gateway install failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}
export async function runDaemonUninstall() {
if (resolveIsNixMode(process.env)) {
defaultRuntime.error("Nix mode detected; daemon uninstall is disabled.");
defaultRuntime.exit(1);
return;
}
const service = resolveGatewayService();
try {
await service.uninstall({ env: process.env, stdout: process.stdout });
} catch (err) {
defaultRuntime.error(`Gateway uninstall failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}
export async function runDaemonStart() {
const service = resolveGatewayService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
return;
}
if (!loaded) {
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
for (const hint of renderGatewayServiceStartHints()) {
defaultRuntime.log(`Start with: ${hint}`);
}
return;
}
try {
await service.restart({ stdout: process.stdout });
} catch (err) {
defaultRuntime.error(`Gateway start failed: ${String(err)}`);
for (const hint of renderGatewayServiceStartHints()) {
defaultRuntime.error(`Start with: ${hint}`);
}
defaultRuntime.exit(1);
}
}
export async function runDaemonStop() {
const service = resolveGatewayService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
return;
}
if (!loaded) {
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
return;
}
try {
await service.stop({ stdout: process.stdout });
} catch (err) {
defaultRuntime.error(`Gateway stop failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}
export async function runDaemonRestart() {
const service = resolveGatewayService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
return;
}
if (!loaded) {
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
for (const hint of renderGatewayServiceStartHints()) {
defaultRuntime.log(`Start with: ${hint}`);
}
return;
}
try {
await service.restart({ stdout: process.stdout });
} catch (err) {
defaultRuntime.error(`Gateway restart failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}
export function registerDaemonCli(program: Command) {
const daemon = program
.command("daemon")
.description(
"Manage the Gateway daemon service (launchd/systemd/schtasks)",
);
daemon
.command("status")
.description("Show daemon install status + probe the Gateway")
.option(
"--url <url>",
"Gateway WebSocket URL (defaults to config/remote/local)",
)
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (password auth)")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--no-probe", "Skip RPC probe")
.option("--deep", "Scan system-level services", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStatus({
rpc: opts,
probe: Boolean(opts.probe),
deep: Boolean(opts.deep),
json: Boolean(opts.json),
});
});
daemon
.command("install")
.description("Install the Gateway service (launchd/systemd/schtasks)")
.option("--port <port>", "Gateway port")
.option("--runtime <runtime>", "Daemon runtime (node|bun). Default: node")
.option("--token <token>", "Gateway token (token auth)")
.action(async (opts) => {
await runDaemonInstall(opts);
});
daemon
.command("uninstall")
.description("Uninstall the Gateway service (launchd/systemd/schtasks)")
.action(async () => {
await runDaemonUninstall();
});
daemon
.command("start")
.description("Start the Gateway service (launchd/systemd/schtasks)")
.action(async () => {
await runDaemonStart();
});
daemon
.command("stop")
.description("Stop the Gateway service (launchd/systemd/schtasks)")
.action(async () => {
await runDaemonStop();
});
daemon
.command("restart")
.description("Restart the Gateway service (launchd/systemd/schtasks)")
.action(async () => {
await runDaemonRestart();
});
// Build default deps (parity with other commands).
void createDefaultDeps();
}

View File

@@ -13,7 +13,9 @@ const forceFreePortAndWait = vi.fn(async () => ({
waitedMs: 0,
escalatedToSigkill: false,
}));
const serviceInstall = vi.fn().mockResolvedValue(undefined);
const serviceStop = vi.fn().mockResolvedValue(undefined);
const serviceUninstall = vi.fn().mockResolvedValue(undefined);
const serviceRestart = vi.fn().mockResolvedValue(undefined);
const serviceIsLoaded = vi.fn().mockResolvedValue(true);
@@ -82,8 +84,8 @@ vi.mock("../daemon/service.js", () => ({
label: "LaunchAgent",
loadedText: "loaded",
notLoadedText: "not loaded",
install: vi.fn(),
uninstall: vi.fn(),
install: serviceInstall,
uninstall: serviceUninstall,
stop: serviceStop,
restart: serviceRestart,
isLoaded: serviceIsLoaded,
@@ -91,6 +93,12 @@ vi.mock("../daemon/service.js", () => ({
}),
}));
vi.mock("../daemon/program-args.js", () => ({
resolveGatewayProgramArguments: async () => ({
programArguments: ["/bin/node", "cli", "gateway-daemon", "--port", "18789"],
}),
}));
describe("gateway-cli coverage", () => {
it("registers call/health/status/send/agent commands and routes to callGateway", async () => {
runtimeLogs.length = 0;
@@ -264,6 +272,30 @@ describe("gateway-cli coverage", () => {
expect(serviceRestart).toHaveBeenCalledTimes(1);
});
it("supports gateway install/uninstall/start via daemon helpers", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
serviceInstall.mockClear();
serviceUninstall.mockClear();
serviceRestart.mockClear();
serviceIsLoaded.mockResolvedValueOnce(false);
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await program.parseAsync(["gateway", "install", "--port", "18789"], {
from: "user",
});
await program.parseAsync(["gateway", "uninstall"], { from: "user" });
await program.parseAsync(["gateway", "start"], { from: "user" });
expect(serviceInstall).toHaveBeenCalledTimes(1);
expect(serviceUninstall).toHaveBeenCalledTimes(1);
expect(serviceRestart).toHaveBeenCalledTimes(1);
});
it("prints stop hints on GatewayLockError when service is loaded", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;

View File

@@ -22,6 +22,14 @@ import { setVerbose } from "../globals.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import { createSubsystemLogger } from "../logging.js";
import { defaultRuntime } from "../runtime.js";
import {
runDaemonInstall,
runDaemonRestart,
runDaemonStart,
runDaemonStatus,
runDaemonStop,
runDaemonUninstall,
} from "./daemon-cli.js";
import { createDefaultDeps } from "./deps.js";
import { forceFreePortAndWait } from "./ports.js";
@@ -91,21 +99,6 @@ function renderGatewayServiceStopHints(): string[] {
}
}
function renderGatewayServiceStartHints(): string[] {
switch (process.platform) {
case "darwin":
return [
`launchctl bootstrap gui/$UID ~/Library/LaunchAgents/${GATEWAY_LAUNCH_AGENT_LABEL}.plist`,
];
case "linux":
return [`systemctl --user start ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`];
case "win32":
return [`schtasks /Run /TN "${GATEWAY_WINDOWS_TASK_NAME}"`];
default:
return [];
}
}
async function maybeExplainGatewayServiceStop() {
const service = resolveGatewayService();
let loaded: boolean | null = null;
@@ -594,6 +587,62 @@ export function registerGatewayCli(program: Command) {
}
});
gateway
.command("install")
.description(
"Install the Gateway service (alias for `clawdbot daemon install`)",
)
.option("--port <port>", "Gateway port")
.option("--runtime <runtime>", "Daemon runtime (node|bun). Default: node")
.option("--token <token>", "Gateway token (token auth)")
.action(async (opts) => {
await runDaemonInstall(opts);
});
gateway
.command("uninstall")
.description(
"Uninstall the Gateway service (alias for `clawdbot daemon uninstall`)",
)
.action(async () => {
await runDaemonUninstall();
});
gateway
.command("start")
.description(
"Start the Gateway service (alias for `clawdbot daemon start`)",
)
.action(async () => {
await runDaemonStart();
});
const gatewayDaemon = gateway
.command("daemon")
.description("Daemon helpers (alias for `clawdbot daemon`)");
gatewayDaemon
.command("status")
.description("Show daemon install status + probe the Gateway")
.option(
"--url <url>",
"Gateway WebSocket URL (defaults to config/remote/local)",
)
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (password auth)")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--no-probe", "Skip RPC probe")
.option("--deep", "Scan system-level services", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStatus({
rpc: opts,
probe: Boolean(opts.probe),
deep: Boolean(opts.deep),
json: Boolean(opts.json),
});
});
gatewayCallOpts(
gateway
.command("call")
@@ -737,53 +786,14 @@ export function registerGatewayCli(program: Command) {
.command("stop")
.description("Stop the Gateway service (launchd/systemd/schtasks)")
.action(async () => {
const service = resolveGatewayService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
return;
}
if (!loaded) {
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
return;
}
try {
await service.stop({ stdout: process.stdout });
} catch (err) {
defaultRuntime.error(`Gateway stop failed: ${String(err)}`);
defaultRuntime.exit(1);
}
await runDaemonStop();
});
gateway
.command("restart")
.description("Restart the Gateway service (launchd/systemd/schtasks)")
.action(async () => {
const service = resolveGatewayService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
return;
}
if (!loaded) {
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
for (const hint of renderGatewayServiceStartHints()) {
defaultRuntime.log(`Start with: ${hint}`);
}
return;
}
try {
await service.restart({ stdout: process.stdout });
} catch (err) {
defaultRuntime.error(`Gateway restart failed: ${String(err)}`);
defaultRuntime.exit(1);
}
await runDaemonRestart();
});
// Build default deps (keeps parity with other commands; future-proofing).

View File

@@ -32,6 +32,7 @@ import { resolveWhatsAppAccount } from "../web/accounts.js";
import { registerBrowserCli } from "./browser-cli.js";
import { registerCanvasCli } from "./canvas-cli.js";
import { registerCronCli } from "./cron-cli.js";
import { registerDaemonCli } from "./daemon-cli.js";
import { createDefaultDeps } from "./deps.js";
import { registerDnsCli } from "./dns-cli.js";
import { registerDocsCli } from "./docs-cli.js";
@@ -624,6 +625,7 @@ Examples:
});
registerCanvasCli(program);
registerDaemonCli(program);
registerGatewayCli(program);
registerModelsCli(program);
registerNodesCli(program);

View File

@@ -211,7 +211,7 @@ function setWhatsAppAllowFrom(
function setMessagesResponsePrefix(
cfg: ClawdbotConfig,
responsePrefix?: string,
) {
): ClawdbotConfig {
return {
...cfg,
messages: {

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