feat: fold gateway service commands into gateway

This commit is contained in:
Peter Steinberger
2026-01-21 17:45:06 +00:00
parent 6f58d508b8
commit 9e22f019db
27 changed files with 166 additions and 88 deletions

View File

@@ -43,7 +43,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
};
if (resolveIsNixMode(process.env)) {
fail("Nix mode detected; daemon install is disabled.");
fail("Nix mode detected; service install is disabled.");
return;
}
@@ -84,7 +84,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
if (!json) {
defaultRuntime.log(`Gateway service already ${service.loadedText}.`);
defaultRuntime.log(
`Reinstall with: ${formatCliCommand("clawdbot daemon install --force")}`,
`Reinstall with: ${formatCliCommand("clawdbot gateway install --force")}`,
);
}
return;

View File

@@ -33,7 +33,7 @@ export async function runDaemonUninstall(opts: DaemonLifecycleOptions = {}) {
};
if (resolveIsNixMode(process.env)) {
fail("Nix mode detected; daemon uninstall is disabled.");
fail("Nix mode detected; service uninstall is disabled.");
return;
}
@@ -200,7 +200,7 @@ export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) {
}
/**
* Restart the gateway daemon service.
* Restart the gateway service service.
* @returns `true` if restart succeeded, `false` if the service was not loaded.
* Throws/exits on check or restart failures.
*/

View File

@@ -14,16 +14,16 @@ import {
export function registerDaemonCli(program: Command) {
const daemon = program
.command("daemon")
.description("Manage the Gateway daemon service (launchd/systemd/schtasks)")
.description("Manage the Gateway service (launchd/systemd/schtasks)")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/daemon", "docs.clawd.bot/cli/daemon")}\n`,
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/gateway", "docs.clawd.bot/cli/gateway")}\n`,
);
daemon
.command("status")
.description("Show daemon install status + probe the Gateway")
.description("Show service 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)")

View File

@@ -123,7 +123,7 @@ export function renderRuntimeHints(
}
})();
if (runtime.missingUnit) {
hints.push(`Service not installed. Run: ${formatCliCommand("clawdbot daemon install", env)}`);
hints.push(`Service not installed. Run: ${formatCliCommand("clawdbot gateway install", env)}`);
if (fileLog) hints.push(`File logs: ${fileLog}`);
return hints;
}
@@ -146,7 +146,7 @@ export function renderRuntimeHints(
export function renderGatewayServiceStartHints(env: NodeJS.ProcessEnv = process.env): string[] {
const base = [
formatCliCommand("clawdbot daemon install", env),
formatCliCommand("clawdbot gateway install", env),
formatCliCommand("clawdbot gateway", env),
];
const profile = env.CLAWDBOT_PROFILE;

View File

@@ -60,7 +60,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
}
const daemonEnvLines = safeDaemonEnv(service.command?.environment);
if (daemonEnvLines.length > 0) {
defaultRuntime.log(`${label("Daemon env:")} ${daemonEnvLines.join(" ")}`);
defaultRuntime.log(`${label("Service env:")} ${daemonEnvLines.join(" ")}`);
}
spacer();
@@ -89,11 +89,11 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
}
if (status.config.daemon) {
const daemonCfg = `${status.config.daemon.path}${status.config.daemon.exists ? "" : " (missing)"}${status.config.daemon.valid ? "" : " (invalid)"}`;
defaultRuntime.log(`${label("Config (daemon):")} ${infoText(daemonCfg)}`);
defaultRuntime.log(`${label("Config (service):")} ${infoText(daemonCfg)}`);
if (!status.config.daemon.valid && status.config.daemon.issues?.length) {
for (const issue of status.config.daemon.issues.slice(0, 5)) {
defaultRuntime.error(
`${errorText("Daemon config issue:")} ${issue.path || "<root>"}: ${issue.message}`,
`${errorText("Service config issue:")} ${issue.path || "<root>"}: ${issue.message}`,
);
}
}
@@ -101,12 +101,12 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
if (status.config.mismatch) {
defaultRuntime.error(
errorText(
"Root cause: CLI and daemon are using different config paths (likely a profile/state-dir mismatch).",
"Root cause: CLI and service are using different config paths (likely a profile/state-dir mismatch).",
),
);
defaultRuntime.error(
errorText(
`Fix: rerun \`${formatCliCommand("clawdbot daemon install --force")}\` from the same --profile / CLAWDBOT_STATE_DIR you expect.`,
`Fix: rerun \`${formatCliCommand("clawdbot gateway install --force")}\` from the same --profile / CLAWDBOT_STATE_DIR you expect.`,
),
);
}
@@ -209,7 +209,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
),
);
defaultRuntime.error(
errorText(`Then reinstall: ${formatCliCommand("clawdbot daemon install")}`),
errorText(`Then reinstall: ${formatCliCommand("clawdbot gateway install")}`),
);
spacer();
}

View File

@@ -14,7 +14,7 @@ export async function runDaemonStatus(opts: DaemonStatusOptions) {
printDaemonStatus(status, { json: Boolean(opts.json) });
} catch (err) {
const rich = isRich();
defaultRuntime.error(colorize(rich, theme.error, `Daemon status failed: ${String(err)}`));
defaultRuntime.error(colorize(rich, theme.error, `Gateway status failed: ${String(err)}`));
defaultRuntime.exit(1);
}
}

View File

@@ -118,7 +118,7 @@ describe("gateway-cli coverage", () => {
expect(runtimeLogs.join("\n")).toContain('"ok": true');
}, 30_000);
it("registers gateway status and routes to gatewayStatusCommand", async () => {
it("registers gateway probe and routes to gatewayStatusCommand", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
gatewayStatusCommand.mockClear();
@@ -128,7 +128,7 @@ describe("gateway-cli coverage", () => {
program.exitOverride();
registerGatewayCli(program);
await program.parseAsync(["gateway", "status", "--json"], { from: "user" });
await program.parseAsync(["gateway", "probe", "--json"], { from: "user" });
expect(gatewayStatusCommand).toHaveBeenCalledTimes(1);
}, 30_000);
@@ -311,7 +311,7 @@ describe("gateway-cli coverage", () => {
expect(startGatewayServer).toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain("Gateway failed to start:");
expect(runtimeErrors.join("\n")).toContain("clawdbot daemon stop");
expect(runtimeErrors.join("\n")).toContain("clawdbot gateway stop");
});
it("uses env/config port when --port is omitted", async () => {

View File

@@ -8,6 +8,14 @@ import { formatDocsLink } from "../../terminal/links.js";
import { colorize, isRich, theme } from "../../terminal/theme.js";
import { withProgress } from "../progress.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import {
runDaemonInstall,
runDaemonRestart,
runDaemonStart,
runDaemonStatus,
runDaemonStop,
runDaemonUninstall,
} from "../daemon-cli.js";
import { callGatewayCli, gatewayCallOpts } from "./call.js";
import type { GatewayDiscoverOpts } from "./discover.js";
import {
@@ -62,13 +70,73 @@ export function registerGatewayCli(program: Command) {
),
);
// Back-compat: legacy launchd plists used gateway-daemon; keep hidden alias.
addGatewayRunCommand(
program
.command("gateway-daemon", { hidden: true })
.description("Run the WebSocket Gateway as a long-lived daemon"),
gateway.command("run").description("Run the WebSocket Gateway (foreground)"),
);
gateway
.command("status")
.description("Show gateway service 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),
});
});
gateway
.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)")
.option("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonInstall(opts);
});
gateway
.command("uninstall")
.description("Uninstall the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonUninstall(opts);
});
gateway
.command("start")
.description("Start the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStart(opts);
});
gateway
.command("stop")
.description("Stop the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStop(opts);
});
gateway
.command("restart")
.description("Restart the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonRestart(opts);
});
gatewayCallOpts(
gateway
.command("call")
@@ -121,7 +189,7 @@ export function registerGatewayCli(program: Command) {
);
gateway
.command("status")
.command("probe")
.description("Show gateway reachability + discovery + health + status summary (local + remote)")
.option("--url <url>", "Explicit Gateway WebSocket URL (still probes localhost)")
.option("--ssh <target>", "SSH target for remote gateway tunnel (user@host or user@host:port)")

View File

@@ -278,7 +278,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
) {
const errMessage = describeUnknownError(err);
defaultRuntime.error(
`Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: ${formatCliCommand("clawdbot daemon stop")}`,
`Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: ${formatCliCommand("clawdbot gateway stop")}`,
);
try {
const diagnostics = await inspectPortUsage(port);

View File

@@ -68,21 +68,21 @@ export function renderGatewayServiceStopHints(env: NodeJS.ProcessEnv = process.e
switch (process.platform) {
case "darwin":
return [
`Tip: ${formatCliCommand("clawdbot daemon stop")}`,
`Tip: ${formatCliCommand("clawdbot gateway stop")}`,
`Or: launchctl bootout gui/$UID/${resolveGatewayLaunchAgentLabel(profile)}`,
];
case "linux":
return [
`Tip: ${formatCliCommand("clawdbot daemon stop")}`,
`Tip: ${formatCliCommand("clawdbot gateway stop")}`,
`Or: systemctl --user stop ${resolveGatewaySystemdServiceName(profile)}.service`,
];
case "win32":
return [
`Tip: ${formatCliCommand("clawdbot daemon stop")}`,
`Tip: ${formatCliCommand("clawdbot gateway stop")}`,
`Or: schtasks /End /TN "${resolveGatewayWindowsTaskName(profile)}"`,
];
default:
return [`Tip: ${formatCliCommand("clawdbot daemon stop")}`];
return [`Tip: ${formatCliCommand("clawdbot gateway stop")}`];
}
}

View File

@@ -4,7 +4,19 @@ import { colorize, isRich, theme } from "../../terminal/theme.js";
import type { RuntimeEnv } from "../../runtime.js";
import { formatCliCommand } from "../command-format.js";
const ALLOWED_INVALID_COMMANDS = new Set(["doctor", "logs", "health", "help", "status", "service"]);
const ALLOWED_INVALID_COMMANDS = new Set(["doctor", "logs", "health", "help", "status"]);
const ALLOWED_INVALID_GATEWAY_SUBCOMMANDS = new Set([
"status",
"probe",
"health",
"discover",
"call",
"install",
"uninstall",
"start",
"stop",
"restart",
]);
let didRunDoctorConfigFlow = false;
function formatConfigIssues(issues: Array<{ path: string; message: string }>): string[] {
@@ -25,7 +37,13 @@ export async function ensureConfigReady(params: {
const snapshot = await readConfigFileSnapshot();
const commandName = params.commandPath?.[0];
const allowInvalid = commandName ? ALLOWED_INVALID_COMMANDS.has(commandName) : false;
const subcommandName = params.commandPath?.[1];
const allowInvalid = commandName
? ALLOWED_INVALID_COMMANDS.has(commandName) ||
(commandName === "gateway" &&
subcommandName &&
ALLOWED_INVALID_GATEWAY_SUBCOMMANDS.has(subcommandName))
: false;
const issues = snapshot.exists && !snapshot.valid ? formatConfigIssues(snapshot.issues) : [];
const legacyIssues =
snapshot.legacyIssues.length > 0

View File

@@ -36,14 +36,6 @@ const entries: SubCliEntry[] = [
mod.registerAcpCli(program);
},
},
{
name: "daemon",
description: "Manage the gateway daemon",
register: async (program) => {
const mod = await import("../daemon-cli.js");
mod.registerDaemonCli(program);
},
},
{
name: "gateway",
description: "Gateway control",

View File

@@ -785,7 +785,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
if (result.reason === "not-git-install") {
defaultRuntime.log(
theme.warn(
`Skipped: this Clawdbot install isn't a git checkout, and the package manager couldn't be detected. Update via your package manager, then run \`${formatCliCommand("clawdbot doctor")}\` and \`${formatCliCommand("clawdbot daemon restart")}\`.`,
`Skipped: this Clawdbot install isn't a git checkout, and the package manager couldn't be detected. Update via your package manager, then run \`${formatCliCommand("clawdbot doctor")}\` and \`${formatCliCommand("clawdbot gateway restart")}\`.`,
),
);
defaultRuntime.log(
@@ -877,11 +877,11 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
defaultRuntime.log(theme.warn("Skipping plugin updates: config is invalid."));
}
// Restart daemon if requested
// Restart service if requested
if (opts.restart) {
if (!opts.json) {
defaultRuntime.log("");
defaultRuntime.log(theme.heading("Restarting daemon..."));
defaultRuntime.log(theme.heading("Restarting service..."));
}
try {
const { runDaemonRestart } = await import("./daemon-cli.js");
@@ -905,7 +905,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
defaultRuntime.log(theme.warn(`Daemon restart failed: ${String(err)}`));
defaultRuntime.log(
theme.muted(
`You may need to restart the daemon manually: ${formatCliCommand("clawdbot daemon restart")}`,
`You may need to restart the service manually: ${formatCliCommand("clawdbot gateway restart")}`,
),
);
}
@@ -915,13 +915,13 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
if (result.mode === "npm" || result.mode === "pnpm") {
defaultRuntime.log(
theme.muted(
`Tip: Run \`${formatCliCommand("clawdbot doctor")}\`, then \`${formatCliCommand("clawdbot daemon restart")}\` to apply updates to a running gateway.`,
`Tip: Run \`${formatCliCommand("clawdbot doctor")}\`, then \`${formatCliCommand("clawdbot gateway restart")}\` to apply updates to a running gateway.`,
),
);
} else {
defaultRuntime.log(
theme.muted(
`Tip: Run \`${formatCliCommand("clawdbot daemon restart")}\` to apply updates to a running gateway.`,
`Tip: Run \`${formatCliCommand("clawdbot gateway restart")}\` to apply updates to a running gateway.`,
),
);
}
@@ -937,7 +937,7 @@ export function registerUpdateCli(program: Command) {
.command("update")
.description("Update Clawdbot to the latest version")
.option("--json", "Output result as JSON", false)
.option("--restart", "Restart the gateway daemon after a successful update", false)
.option("--restart", "Restart the gateway service after a successful update", false)
.option("--channel <stable|beta|dev>", "Persist update channel (git + npm)")
.option("--tag <dist-tag|version>", "Override npm dist-tag or version for this update")
.option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)")
@@ -948,7 +948,7 @@ export function registerUpdateCli(program: Command) {
["clawdbot update --channel beta", "Switch to beta channel (git + npm)"],
["clawdbot update --channel dev", "Switch to dev channel (git + npm)"],
["clawdbot update --tag beta", "One-off update to a dist-tag or version"],
["clawdbot update --restart", "Update and restart the daemon"],
["clawdbot update --restart", "Update and restart the service"],
["clawdbot update --json", "Output result as JSON"],
["clawdbot update --yes", "Non-interactive (accept downgrade prompts)"],
["clawdbot --update", "Shorthand for clawdbot update"],