diff --git a/docs/cli/index.md b/docs/cli/index.md index a12dbcf2c..0de6e00ff 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -362,6 +362,25 @@ Options: ### `gateway-daemon` Run the Gateway as a long-lived daemon (same options as `gateway`, minus `--allow-unconfigured` and `--force`). +### `daemon` +Manage the Gateway service (launchd/systemd/schtasks). + +Subcommands: +- `daemon status` (probes the Gateway RPC by default) +- `daemon install` (service install) +- `daemon uninstall` +- `daemon start` +- `daemon stop` +- `daemon restart` + +Notes: +- `daemon status` uses the same URL/token defaults as `gateway status` unless you pass `--url/--token/--password`. +- `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting. +- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). +- `daemon install` defaults to Node runtime; use `--runtime bun` only when WhatsApp is disabled. +- `daemon install` options: `--port`, `--runtime`, `--token`. +- `gateway install|uninstall|start|stop|restart` remain as service aliases; `daemon` is the dedicated manager. + ### `gateway ` Gateway RPC helpers (use `--url`, `--token`, `--password`, `--timeout`, `--expect-final` for each). @@ -372,8 +391,12 @@ Subcommands: - `gateway wake --text [--mode now|next-heartbeat]` - `gateway send --to --message [--media-url ] [--gif-playback] [--idempotency-key ]` - `gateway agent --message [--to ] [--session-id ] [--thinking ] [--deliver] [--timeout-seconds ] [--idempotency-key ]` +- `gateway install` +- `gateway uninstall` +- `gateway start` - `gateway stop` - `gateway restart` +- `gateway daemon status` (alias for `clawdbot daemon status`) ## Models diff --git a/docs/docs.json b/docs/docs.json index de617e251..b6ce5c4cd 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -646,7 +646,17 @@ { "group": "Platforms", "pages": [ + "platforms", "platforms/macos", + "platforms/ios", + "platforms/android", + "platforms/windows", + "platforms/linux" + ] + }, + { + "group": "macOS Companion App", + "pages": [ "platforms/mac/dev-setup", "platforms/mac/menu-bar", "platforms/mac/voicewake", @@ -664,11 +674,7 @@ "platforms/mac/bun", "platforms/mac/xpc", "platforms/mac/skills", - "platforms/mac/peekaboo", - "platforms/ios", - "platforms/android", - "platforms/windows", - "platforms/linux" + "platforms/mac/peekaboo" ] }, { diff --git a/docs/gateway/index.md b/docs/gateway/index.md index f025dff31..416ee4682 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -157,6 +157,25 @@ See also: [`docs/presence.md`](/concepts/presence) for how presence is produced/ - On failure, launchd restarts; fatal misconfig should keep exiting so the operator notices. - LaunchAgents are per-user and require a logged-in session; for headless setups use a custom LaunchDaemon (not shipped). +## Daemon management (CLI) + +Use the CLI daemon manager for install/start/stop/restart/status: + +```bash +clawdbot daemon status +clawdbot daemon install +clawdbot daemon stop +clawdbot daemon restart +``` + +Notes: +- `daemon status` probes the Gateway RPC by default (same URL/token defaults as `gateway status`). +- `daemon status --deep` adds system-level scans (LaunchDaemons/system units). +- `gateway install|uninstall|start|stop|restart` remain supported as aliases; `daemon` is the dedicated manager. +- `gateway daemon status` is an alias for `clawdbot daemon status`. +- If other gateway-like services are detected, the CLI warns. We recommend **one gateway per machine**; one gateway can host multiple agents. + - Cleanup: `clawdbot daemon uninstall` (current service) and `clawdbot doctor` (legacy migrations). + Bundled mac app: - Clawdbot.app can bundle a bun-compiled gateway binary and install a per-user LaunchAgent labeled `com.clawdbot.gateway`. - To stop it cleanly, use `clawdbot gateway stop` (or `launchctl bootout gui/$UID/com.clawdbot.gateway`). diff --git a/docs/platforms/android.md b/docs/platforms/android.md index 56beab345..9e274da0a 100644 --- a/docs/platforms/android.md +++ b/docs/platforms/android.md @@ -8,6 +8,15 @@ read_when: # Android App (Node) +## Support snapshot +- Role: companion node app (Android does not host the Gateway). +- Gateway required: yes (run it on macOS, Linux, or Windows via WSL2). +- Install: [Getting Started](/start/getting-started) + [Pairing](/gateway/pairing). +- Gateway: [Runbook](/gateway) + [Configuration](/gateway/configuration). + +## System control +System control (launchd/systemd) lives on the Gateway host. See [Gateway](/gateway). + ## Connection Runbook Android node app ⇄ (mDNS/NSD + TCP bridge) ⇄ **Gateway bridge** ⇄ (loopback WS) ⇄ **Gateway** diff --git a/docs/platforms/index.md b/docs/platforms/index.md new file mode 100644 index 000000000..9d388140f --- /dev/null +++ b/docs/platforms/index.md @@ -0,0 +1,40 @@ +--- +summary: "Platform support overview (Gateway + companion apps)" +read_when: + - Looking for OS support or install paths + - Deciding where to run the Gateway +--- +# Platforms + +Clawdbot core is written in TypeScript, so the CLI + Gateway run anywhere Node or Bun runs. + +Companion apps exist for macOS (menu bar app) and mobile nodes (iOS/Android). Windows and +Linux companion apps are planned, but the core Gateway is fully supported today. + +## Choose your OS + +- macOS: [macOS](/platforms/macos) +- iOS: [iOS](/platforms/ios) +- Android: [Android](/platforms/android) +- Windows: [Windows](/platforms/windows) +- Linux: [Linux](/platforms/linux) + +## Common links + +- Install guide: [Getting Started](/start/getting-started) +- Gateway runbook: [Gateway](/gateway) +- Gateway configuration: [Configuration](/gateway/configuration) +- Service status: `clawdbot daemon status` + +## Gateway service install (CLI) + +Use one of these (all supported): + +- Wizard (recommended): `clawdbot onboard --install-daemon` +- Direct: `clawdbot daemon install` (alias: `clawdbot gateway install`) +- Configure flow: `clawdbot configure` → select **Gateway daemon** +- Repair/migrate: `clawdbot doctor` (offers to install or fix the service) + +The service target depends on OS: +- macOS: LaunchAgent (`com.clawdbot.gateway`) +- Linux/WSL2: systemd user service diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index 09cb80ce4..939d5c044 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -12,6 +12,15 @@ read_when: Status: prototype implemented (internal) · Date: 2025-12-13 +## Support snapshot +- Role: companion node app (iOS does not host the Gateway). +- Gateway required: yes (run it on macOS, Linux, or Windows via WSL2). +- Install: [Getting Started](/start/getting-started) + [Pairing](/gateway/pairing). +- Gateway: [Runbook](/gateway) + [Configuration](/gateway/configuration). + +## System control +System control (launchd/systemd) lives on the Gateway host. See [Gateway](/gateway). + ## Connection Runbook This is the practical “how do I connect the iOS node” guide: diff --git a/docs/platforms/linux.md b/docs/platforms/linux.md index b5e27e4cb..78348d698 100644 --- a/docs/platforms/linux.md +++ b/docs/platforms/linux.md @@ -1,11 +1,80 @@ --- -summary: "Linux app status + contribution call" +summary: "Linux support + companion app status" read_when: - Looking for Linux companion app status - Planning platform coverage or contributions --- # Linux App -Clawdbot core is fully supported on Linux. The core is written in TypeScript, so it runs anywhere Node runs. +Clawdbot core is fully supported on Linux. The core is written in TypeScript, so it runs anywhere Node or Bun runs. We do not have a Linux companion app yet. It is planned, and we would love contributions to make it happen. + +## Install +- [Getting Started](/start/getting-started) +- [Install & updates](/install/updating) +- Optional flows: [Bun](/install/bun), [Nix](/install/nix), [Docker](/install/docker) + +## Gateway +- [Gateway runbook](/gateway) +- [Configuration](/gateway/configuration) + +## Gateway service install (CLI) + +Use one of these: + +``` +clawdbot onboard --install-daemon +``` + +Or: + +``` +clawdbot daemon install +``` + +Or: + +``` +clawdbot gateway install +``` + +Or: + +``` +clawdbot configure +``` + +Select **Gateway daemon** when prompted. + +Repair/migrate: + +``` +clawdbot doctor +``` + +## System control (systemd user unit) +Full unit example lives in the [Gateway runbook](/gateway). Minimal setup: + +Create `~/.config/systemd/user/clawdbot-gateway.service`: + +``` +[Unit] +Description=Clawdbot Gateway +After=network-online.target +Wants=network-online.target + +[Service] +ExecStart=/usr/local/bin/clawdbot gateway --port 18789 +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target +``` + +Enable it: + +``` +systemctl --user enable --now clawdbot-gateway.service +``` diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index bac5c1539..a1daa37cc 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -8,6 +8,23 @@ read_when: Author: steipete · Status: draft spec · Date: 2025-12-20 +## Support snapshot +- Core Gateway: supported (TypeScript on Node/Bun). +- Companion app: macOS menu bar app with permissions + node bridge. +- Install: [Getting Started](/start/getting-started) or [Install & updates](/install/updating). +- Gateway: [Runbook](/gateway) + [Configuration](/gateway/configuration). + +## System control (launchd) +If you run the bundled macOS app, it installs a per-user LaunchAgent labeled `com.clawdbot.gateway`. +CLI-only installs can use `clawdbot onboard --install-daemon`, `clawdbot daemon install`, or `clawdbot configure` → **Gateway daemon**. + +```bash +launchctl kickstart -k gui/$UID/com.clawdbot.gateway +launchctl bootout gui/$UID/com.clawdbot.gateway +``` + +Details: [Gateway runbook](/gateway) and [Bundled bun Gateway](/platforms/mac/bun). + ## Purpose - Single macOS menu-bar app named **Clawdbot** that: - Shows native notifications for Clawdbot/clawdbot events. diff --git a/docs/platforms/windows.md b/docs/platforms/windows.md index 67ad766c0..b97906295 100644 --- a/docs/platforms/windows.md +++ b/docs/platforms/windows.md @@ -1,5 +1,5 @@ --- -summary: "Windows (WSL2) setup + companion app status" +summary: "Windows (WSL2) support + companion app status" read_when: - Installing Clawdbot on Windows - Looking for Windows companion app status @@ -7,14 +7,55 @@ read_when: --- # Windows (WSL2) -Clawdbot runs on Windows **via WSL2** (Ubuntu recommended). WSL2 is **strongly -recommended**; native Windows installs are untested and more problematic. Use -WSL2 and follow the Linux flow inside it. +Clawdbot core is supported on Windows **via WSL2** (Ubuntu recommended). The +CLI + Gateway run inside Linux, which keeps the runtime consistent. Native +Windows installs are untested and more problematic. + +## Install +- [Getting Started](/start/getting-started) (use inside WSL) +- [Install & updates](/install/updating) +- Official WSL2 guide (Microsoft): https://learn.microsoft.com/windows/wsl/install + +## Gateway +- [Gateway runbook](/gateway) +- [Configuration](/gateway/configuration) + +## Gateway service install (CLI) + +Inside WSL2: + +``` +clawdbot onboard --install-daemon +``` + +Or: + +``` +clawdbot daemon install +``` + +Or: + +``` +clawdbot gateway install +``` + +Or: + +``` +clawdbot configure +``` + +Select **Gateway daemon** when prompted. + +Repair/migrate: + +``` +clawdbot doctor +``` ## How to install this correctly -Start here (official WSL2 guide): https://learn.microsoft.com/windows/wsl/install - ### 1) Install WSL2 + Ubuntu Open PowerShell (Admin): diff --git a/docs/start/hubs.md b/docs/start/hubs.md index 9706700ec..58b9209b7 100644 --- a/docs/start/hubs.md +++ b/docs/start/hubs.md @@ -114,7 +114,16 @@ Use these hubs to discover every page, including deep dives and reference docs t ## Platforms -- [macOS app overview](https://docs.clawd.bot/platforms/macos) +- [Platforms overview](https://docs.clawd.bot/platforms) +- [macOS](https://docs.clawd.bot/platforms/macos) +- [iOS](https://docs.clawd.bot/platforms/ios) +- [Android](https://docs.clawd.bot/platforms/android) +- [Windows (WSL2)](https://docs.clawd.bot/platforms/windows) +- [Linux](https://docs.clawd.bot/platforms/linux) +- [Web surfaces](https://docs.clawd.bot/web) + +## macOS companion app (internals) + - [macOS dev setup](https://docs.clawd.bot/platforms/mac/dev-setup) - [macOS menu bar](https://docs.clawd.bot/platforms/mac/menu-bar) - [macOS voice wake](https://docs.clawd.bot/platforms/mac/voicewake) @@ -133,11 +142,6 @@ Use these hubs to discover every page, including deep dives and reference docs t - [macOS XPC](https://docs.clawd.bot/platforms/mac/xpc) - [macOS skills](https://docs.clawd.bot/platforms/mac/skills) - [macOS Peekaboo plan](https://docs.clawd.bot/platforms/mac/peekaboo) -- [iOS node](https://docs.clawd.bot/platforms/ios) -- [Android node](https://docs.clawd.bot/platforms/android) -- [Windows (WSL2)](https://docs.clawd.bot/platforms/windows) -- [Linux app](https://docs.clawd.bot/platforms/linux) -- [Web surfaces](https://docs.clawd.bot/web) ## Workspace + templates diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts new file mode 100644 index 000000000..1c8fedb5d --- /dev/null +++ b/src/cli/daemon-cli.coverage.test.ts @@ -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); + }); +}); diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts new file mode 100644 index 000000000..92532726b --- /dev/null +++ b/src/cli/daemon-cli.ts @@ -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 { + 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 = { + 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 ", + "Gateway WebSocket URL (defaults to config/remote/local)", + ) + .option("--token ", "Gateway token (if required)") + .option("--password ", "Gateway password (password auth)") + .option("--timeout ", "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 ", "Gateway port") + .option("--runtime ", "Daemon runtime (node|bun). Default: node") + .option("--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(); +} diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts index c4a134a6d..9bdef0027 100644 --- a/src/cli/gateway-cli.coverage.test.ts +++ b/src/cli/gateway-cli.coverage.test.ts @@ -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; diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 6ac33db34..72e5badd2 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -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 ", "Gateway port") + .option("--runtime ", "Daemon runtime (node|bun). Default: node") + .option("--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 ", + "Gateway WebSocket URL (defaults to config/remote/local)", + ) + .option("--token ", "Gateway token (if required)") + .option("--password ", "Gateway password (password auth)") + .option("--timeout ", "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). diff --git a/src/cli/program.ts b/src/cli/program.ts index 3ff9dbc73..196c506e2 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -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); diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index 0c032942f..9113cd4ea 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -211,7 +211,7 @@ function setWhatsAppAllowFrom( function setMessagesResponsePrefix( cfg: ClawdbotConfig, responsePrefix?: string, -) { +): ClawdbotConfig { return { ...cfg, messages: { diff --git a/src/daemon/inspect.ts b/src/daemon/inspect.ts new file mode 100644 index 000000000..3f8ce4c97 --- /dev/null +++ b/src/daemon/inspect.ts @@ -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 { + 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( + /Label<\/key>\s*([\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 { + 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 { + 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, + opts: FindExtraGatewayServicesOptions = {}, +): Promise { + const results: ExtraGatewayService[] = []; + const seen = new Set(); + 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; +}