feat: add daemon service management
This commit is contained in:
@@ -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 <subcommand>`
|
||||
Gateway RPC helpers (use `--url`, `--token`, `--password`, `--timeout`, `--expect-final` for each).
|
||||
|
||||
@@ -372,8 +391,12 @@ Subcommands:
|
||||
- `gateway wake --text <text> [--mode now|next-heartbeat]`
|
||||
- `gateway send --to <jidOrPhone> --message <text> [--media-url <url>] [--gif-playback] [--idempotency-key <key>]`
|
||||
- `gateway agent --message <text> [--to <jidOrPhone>] [--session-id <id>] [--thinking <level>] [--deliver] [--timeout-seconds <n>] [--idempotency-key <key>]`
|
||||
- `gateway install`
|
||||
- `gateway uninstall`
|
||||
- `gateway start`
|
||||
- `gateway stop`
|
||||
- `gateway restart`
|
||||
- `gateway daemon status` (alias for `clawdbot daemon status`)
|
||||
|
||||
## Models
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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**
|
||||
|
||||
40
docs/platforms/index.md
Normal file
40
docs/platforms/index.md
Normal file
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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);
|
||||
|
||||
@@ -211,7 +211,7 @@ function setWhatsAppAllowFrom(
|
||||
function setMessagesResponsePrefix(
|
||||
cfg: ClawdbotConfig,
|
||||
responsePrefix?: string,
|
||||
) {
|
||||
): ClawdbotConfig {
|
||||
return {
|
||||
...cfg,
|
||||
messages: {
|
||||
|
||||
305
src/daemon/inspect.ts
Normal file
305
src/daemon/inspect.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user