feat: add daemon service management
This commit is contained in:
@@ -362,6 +362,25 @@ Options:
|
|||||||
### `gateway-daemon`
|
### `gateway-daemon`
|
||||||
Run the Gateway as a long-lived daemon (same options as `gateway`, minus `--allow-unconfigured` and `--force`).
|
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 <subcommand>`
|
||||||
Gateway RPC helpers (use `--url`, `--token`, `--password`, `--timeout`, `--expect-final` for each).
|
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 wake --text <text> [--mode now|next-heartbeat]`
|
||||||
- `gateway send --to <jidOrPhone> --message <text> [--media-url <url>] [--gif-playback] [--idempotency-key <key>]`
|
- `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 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 stop`
|
||||||
- `gateway restart`
|
- `gateway restart`
|
||||||
|
- `gateway daemon status` (alias for `clawdbot daemon status`)
|
||||||
|
|
||||||
## Models
|
## Models
|
||||||
|
|
||||||
|
|||||||
@@ -646,7 +646,17 @@
|
|||||||
{
|
{
|
||||||
"group": "Platforms",
|
"group": "Platforms",
|
||||||
"pages": [
|
"pages": [
|
||||||
|
"platforms",
|
||||||
"platforms/macos",
|
"platforms/macos",
|
||||||
|
"platforms/ios",
|
||||||
|
"platforms/android",
|
||||||
|
"platforms/windows",
|
||||||
|
"platforms/linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "macOS Companion App",
|
||||||
|
"pages": [
|
||||||
"platforms/mac/dev-setup",
|
"platforms/mac/dev-setup",
|
||||||
"platforms/mac/menu-bar",
|
"platforms/mac/menu-bar",
|
||||||
"platforms/mac/voicewake",
|
"platforms/mac/voicewake",
|
||||||
@@ -664,11 +674,7 @@
|
|||||||
"platforms/mac/bun",
|
"platforms/mac/bun",
|
||||||
"platforms/mac/xpc",
|
"platforms/mac/xpc",
|
||||||
"platforms/mac/skills",
|
"platforms/mac/skills",
|
||||||
"platforms/mac/peekaboo",
|
"platforms/mac/peekaboo"
|
||||||
"platforms/ios",
|
|
||||||
"platforms/android",
|
|
||||||
"platforms/windows",
|
|
||||||
"platforms/linux"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
- 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).
|
- 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:
|
Bundled mac app:
|
||||||
- Clawdbot.app can bundle a bun-compiled gateway binary and install a per-user LaunchAgent labeled `com.clawdbot.gateway`.
|
- 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`).
|
- 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)
|
# 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
|
## Connection Runbook
|
||||||
|
|
||||||
Android node app ⇄ (mDNS/NSD + TCP bridge) ⇄ **Gateway bridge** ⇄ (loopback WS) ⇄ **Gateway**
|
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
|
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
|
## Connection Runbook
|
||||||
|
|
||||||
This is the practical “how do I connect the iOS node” guide:
|
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:
|
read_when:
|
||||||
- Looking for Linux companion app status
|
- Looking for Linux companion app status
|
||||||
- Planning platform coverage or contributions
|
- Planning platform coverage or contributions
|
||||||
---
|
---
|
||||||
# Linux App
|
# 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.
|
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
|
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
|
## Purpose
|
||||||
- Single macOS menu-bar app named **Clawdbot** that:
|
- Single macOS menu-bar app named **Clawdbot** that:
|
||||||
- Shows native notifications for Clawdbot/clawdbot events.
|
- 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:
|
read_when:
|
||||||
- Installing Clawdbot on Windows
|
- Installing Clawdbot on Windows
|
||||||
- Looking for Windows companion app status
|
- Looking for Windows companion app status
|
||||||
@@ -7,14 +7,55 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Windows (WSL2)
|
# Windows (WSL2)
|
||||||
|
|
||||||
Clawdbot runs on Windows **via WSL2** (Ubuntu recommended). WSL2 is **strongly
|
Clawdbot core is supported on Windows **via WSL2** (Ubuntu recommended). The
|
||||||
recommended**; native Windows installs are untested and more problematic. Use
|
CLI + Gateway run inside Linux, which keeps the runtime consistent. Native
|
||||||
WSL2 and follow the Linux flow inside it.
|
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
|
## How to install this correctly
|
||||||
|
|
||||||
Start here (official WSL2 guide): https://learn.microsoft.com/windows/wsl/install
|
|
||||||
|
|
||||||
### 1) Install WSL2 + Ubuntu
|
### 1) Install WSL2 + Ubuntu
|
||||||
|
|
||||||
Open PowerShell (Admin):
|
Open PowerShell (Admin):
|
||||||
|
|||||||
@@ -114,7 +114,16 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
|||||||
|
|
||||||
## Platforms
|
## 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 dev setup](https://docs.clawd.bot/platforms/mac/dev-setup)
|
||||||
- [macOS menu bar](https://docs.clawd.bot/platforms/mac/menu-bar)
|
- [macOS menu bar](https://docs.clawd.bot/platforms/mac/menu-bar)
|
||||||
- [macOS voice wake](https://docs.clawd.bot/platforms/mac/voicewake)
|
- [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 XPC](https://docs.clawd.bot/platforms/mac/xpc)
|
||||||
- [macOS skills](https://docs.clawd.bot/platforms/mac/skills)
|
- [macOS skills](https://docs.clawd.bot/platforms/mac/skills)
|
||||||
- [macOS Peekaboo plan](https://docs.clawd.bot/platforms/mac/peekaboo)
|
- [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
|
## 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,
|
waitedMs: 0,
|
||||||
escalatedToSigkill: false,
|
escalatedToSigkill: false,
|
||||||
}));
|
}));
|
||||||
|
const serviceInstall = vi.fn().mockResolvedValue(undefined);
|
||||||
const serviceStop = vi.fn().mockResolvedValue(undefined);
|
const serviceStop = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const serviceUninstall = vi.fn().mockResolvedValue(undefined);
|
||||||
const serviceRestart = vi.fn().mockResolvedValue(undefined);
|
const serviceRestart = vi.fn().mockResolvedValue(undefined);
|
||||||
const serviceIsLoaded = vi.fn().mockResolvedValue(true);
|
const serviceIsLoaded = vi.fn().mockResolvedValue(true);
|
||||||
|
|
||||||
@@ -82,8 +84,8 @@ vi.mock("../daemon/service.js", () => ({
|
|||||||
label: "LaunchAgent",
|
label: "LaunchAgent",
|
||||||
loadedText: "loaded",
|
loadedText: "loaded",
|
||||||
notLoadedText: "not loaded",
|
notLoadedText: "not loaded",
|
||||||
install: vi.fn(),
|
install: serviceInstall,
|
||||||
uninstall: vi.fn(),
|
uninstall: serviceUninstall,
|
||||||
stop: serviceStop,
|
stop: serviceStop,
|
||||||
restart: serviceRestart,
|
restart: serviceRestart,
|
||||||
isLoaded: serviceIsLoaded,
|
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", () => {
|
describe("gateway-cli coverage", () => {
|
||||||
it("registers call/health/status/send/agent commands and routes to callGateway", async () => {
|
it("registers call/health/status/send/agent commands and routes to callGateway", async () => {
|
||||||
runtimeLogs.length = 0;
|
runtimeLogs.length = 0;
|
||||||
@@ -264,6 +272,30 @@ describe("gateway-cli coverage", () => {
|
|||||||
expect(serviceRestart).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("prints stop hints on GatewayLockError when service is loaded", async () => {
|
||||||
runtimeLogs.length = 0;
|
runtimeLogs.length = 0;
|
||||||
runtimeErrors.length = 0;
|
runtimeErrors.length = 0;
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ import { setVerbose } from "../globals.js";
|
|||||||
import { GatewayLockError } from "../infra/gateway-lock.js";
|
import { GatewayLockError } from "../infra/gateway-lock.js";
|
||||||
import { createSubsystemLogger } from "../logging.js";
|
import { createSubsystemLogger } from "../logging.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
import {
|
||||||
|
runDaemonInstall,
|
||||||
|
runDaemonRestart,
|
||||||
|
runDaemonStart,
|
||||||
|
runDaemonStatus,
|
||||||
|
runDaemonStop,
|
||||||
|
runDaemonUninstall,
|
||||||
|
} from "./daemon-cli.js";
|
||||||
import { createDefaultDeps } from "./deps.js";
|
import { createDefaultDeps } from "./deps.js";
|
||||||
import { forceFreePortAndWait } from "./ports.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() {
|
async function maybeExplainGatewayServiceStop() {
|
||||||
const service = resolveGatewayService();
|
const service = resolveGatewayService();
|
||||||
let loaded: boolean | null = null;
|
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(
|
gatewayCallOpts(
|
||||||
gateway
|
gateway
|
||||||
.command("call")
|
.command("call")
|
||||||
@@ -737,53 +786,14 @@ export function registerGatewayCli(program: Command) {
|
|||||||
.command("stop")
|
.command("stop")
|
||||||
.description("Stop the Gateway service (launchd/systemd/schtasks)")
|
.description("Stop the Gateway service (launchd/systemd/schtasks)")
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
const service = resolveGatewayService();
|
await runDaemonStop();
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
gateway
|
gateway
|
||||||
.command("restart")
|
.command("restart")
|
||||||
.description("Restart the Gateway service (launchd/systemd/schtasks)")
|
.description("Restart the Gateway service (launchd/systemd/schtasks)")
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
const service = resolveGatewayService();
|
await runDaemonRestart();
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build default deps (keeps parity with other commands; future-proofing).
|
// 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 { registerBrowserCli } from "./browser-cli.js";
|
||||||
import { registerCanvasCli } from "./canvas-cli.js";
|
import { registerCanvasCli } from "./canvas-cli.js";
|
||||||
import { registerCronCli } from "./cron-cli.js";
|
import { registerCronCli } from "./cron-cli.js";
|
||||||
|
import { registerDaemonCli } from "./daemon-cli.js";
|
||||||
import { createDefaultDeps } from "./deps.js";
|
import { createDefaultDeps } from "./deps.js";
|
||||||
import { registerDnsCli } from "./dns-cli.js";
|
import { registerDnsCli } from "./dns-cli.js";
|
||||||
import { registerDocsCli } from "./docs-cli.js";
|
import { registerDocsCli } from "./docs-cli.js";
|
||||||
@@ -624,6 +625,7 @@ Examples:
|
|||||||
});
|
});
|
||||||
|
|
||||||
registerCanvasCli(program);
|
registerCanvasCli(program);
|
||||||
|
registerDaemonCli(program);
|
||||||
registerGatewayCli(program);
|
registerGatewayCli(program);
|
||||||
registerModelsCli(program);
|
registerModelsCli(program);
|
||||||
registerNodesCli(program);
|
registerNodesCli(program);
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ function setWhatsAppAllowFrom(
|
|||||||
function setMessagesResponsePrefix(
|
function setMessagesResponsePrefix(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
responsePrefix?: string,
|
responsePrefix?: string,
|
||||||
) {
|
): ClawdbotConfig {
|
||||||
return {
|
return {
|
||||||
...cfg,
|
...cfg,
|
||||||
messages: {
|
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