feat: add daemon service management
This commit is contained in:
134
src/cli/daemon-cli.coverage.test.ts
Normal file
134
src/cli/daemon-cli.coverage.test.ts
Normal 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
466
src/cli/daemon-cli.ts
Normal 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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user