From 6afcf43ff23943e54b96029d917d272b9ccb6330 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 9 Dec 2025 16:28:26 +0000 Subject: [PATCH] CLI: add gateway --force option --- README.md | 7 ++- docs/gateway.md | 3 ++ src/cli/program.ts | 89 ++++++++++++++++++++++++++++++++++- src/commands/status.ts | 7 ++- src/infra/provider-summary.ts | 22 ++++----- src/rpc/loop.test.ts | 2 +- 6 files changed, 113 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d146e0ac6..e9d74cd5e 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,11 @@ clawdis send --to +1234567890 --message "Hello from the CLAWDIS!" # Talk directly to the agent (no WhatsApp send) clawdis agent --to +1234567890 --message "Ship checklist" --thinking high -# Start the relay -clawdis relay --verbose +# Start the gateway (WebSocket control plane) +clawdis gateway --port 18789 --verbose + +# If the port is busy, force-kill listeners then start +clawdis gateway --force ``` ## macOS Companion App (Clawdis.app) diff --git a/docs/gateway.md b/docs/gateway.md index dd121e2e1..b28545c66 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -12,10 +12,13 @@ Last updated: 2025-12-09 pnpm clawdis gateway --port 18789 # for full debug/trace logs in stdio: pnpm clawdis gateway --port 18789 --verbose +# if the port is busy, terminate listeners then start: +pnpm clawdis gateway --force ``` - Binds WebSocket control plane to `127.0.0.1:` (default 18789). - Logs to stdout; use launchd/systemd to keep it alive and rotate logs. - Pass `--verbose` to mirror debug logging from the log file into stdio when troubleshooting. +- `--force` uses `lsof` to find listeners on the chosen port, sends SIGTERM, logs what it killed, then starts the gateway (fails fast if `lsof` is missing). - Optional shared secret: pass `--token ` or set `CLAWDIS_GATEWAY_TOKEN` to require clients to send `hello.auth.token`. ## Remote access diff --git a/src/cli/program.ts b/src/cli/program.ts index ccb02bb10..43eefb05a 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -1,3 +1,5 @@ +import { execFileSync } from "node:child_process"; + import chalk from "chalk"; import { Command } from "commander"; import { agentCommand } from "../commands/agent.js"; @@ -15,6 +17,58 @@ import { VERSION } from "../version.js"; import { startWebChatServer } from "../webchat/server.js"; import { createDefaultDeps } from "./deps.js"; +type PortProcess = { pid: number; command?: string }; + +function parseLsofOutput(output: string): PortProcess[] { + const lines = output.split(/\r?\n/).filter(Boolean); + const results: PortProcess[] = []; + let current: Partial = {}; + for (const line of lines) { + if (line.startsWith("p")) { + if (current.pid) results.push(current as PortProcess); + current = { pid: Number.parseInt(line.slice(1), 10) }; + } else if (line.startsWith("c")) { + current.command = line.slice(1); + } + } + if (current.pid) results.push(current as PortProcess); + return results; +} + +function listPortListeners(port: number): PortProcess[] { + try { + const out = execFileSync( + "lsof", + ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-FpFc"], + { encoding: "utf-8" }, + ); + return parseLsofOutput(out); + } catch (err: unknown) { + const status = (err as { status?: number }).status; + const code = (err as { code?: string }).code; + if (code === "ENOENT") { + throw new Error("lsof not found; required for --force"); + } + // lsof returns exit status 1 when no processes match + if (status === 1) return []; + throw err instanceof Error ? err : new Error(String(err)); + } +} + +function forceFreePort(port: number): PortProcess[] { + const listeners = listPortListeners(port); + for (const proc of listeners) { + try { + process.kill(proc.pid, "SIGTERM"); + } catch (err) { + throw new Error( + `failed to kill pid ${proc.pid}${proc.command ? ` (${proc.command})` : ""}: ${String(err)}`, + ); + } + } + return listeners; +} + export function buildProgram() { const program = new Command(); const PROGRAM_VERSION = VERSION; @@ -62,7 +116,14 @@ export function buildProgram() { 'clawdis send --to +15555550123 --message "Hi" --json', "Send via your web session and print JSON result.", ], - ["clawdis gateway --port 18789", "Run the WebSocket Gateway locally."], + [ + "clawdis gateway --port 18789", + "Run the WebSocket Gateway locally.", + ], + [ + "clawdis gateway --force", + "Kill anything bound to the default gateway port, then start it.", + ], ["clawdis gw:status", "Fetch Gateway status over WS."], [ 'clawdis agent --to +15555550123 --message "Run summary" --deliver', @@ -227,6 +288,11 @@ Examples: "--token ", "Shared token required in hello.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)", ) + .option( + "--force", + "Kill any existing listener on the target port before starting", + false, + ) .option("--verbose", "Verbose logging to stdout/stderr", false) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); @@ -235,6 +301,27 @@ Examples: defaultRuntime.error("Invalid port"); defaultRuntime.exit(1); } + if (opts.force) { + try { + const killed = forceFreePort(port); + if (killed.length === 0) { + defaultRuntime.log(info(`Force: no listeners on port ${port}`)); + } else { + for (const proc of killed) { + defaultRuntime.log( + info( + `Force: killed pid ${proc.pid}${proc.command ? ` (${proc.command})` : ""} on port ${port}`, + ), + ); + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } catch (err) { + defaultRuntime.error(`Force: ${String(err)}`); + defaultRuntime.exit(1); + return; + } + } if (opts.token) { process.env.CLAWDIS_GATEWAY_TOKEN = String(opts.token); } diff --git a/src/commands/status.ts b/src/commands/status.ts index dd42f2f78..1e69d70f5 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -42,7 +42,7 @@ export type SessionStatus = { export type StatusSummary = { web: { linked: boolean; authAgeMs: number | null }; heartbeatSeconds: number; - providerSummary: string; + providerSummary: string[]; queuedSystemEvents: string[]; sessions: { path: string; @@ -209,7 +209,10 @@ export async function statusCommand( if (summary.web.linked) { logWebSelfId(runtime, true); } - runtime.log(info(`System: ${summary.providerSummary}`)); + runtime.log(info("System:")); + for (const line of summary.providerSummary) { + runtime.log(info(` ${line}`)); + } if (health) { const waLine = health.web.connect ? health.web.connect.ok diff --git a/src/infra/provider-summary.ts b/src/infra/provider-summary.ts index 83caa5ed6..47e358c96 100644 --- a/src/infra/provider-summary.ts +++ b/src/infra/provider-summary.ts @@ -10,41 +10,41 @@ const DEFAULT_WEBCHAT_PORT = 18788; export async function buildProviderSummary( cfg?: WarelayConfig, -): Promise { +): Promise { const effective = cfg ?? loadConfig(); - const parts: string[] = []; + const lines: string[] = []; const webLinked = await webAuthExists(); const authAgeMs = getWebAuthAgeMs(); const authAge = authAgeMs === null ? "unknown" : formatAge(authAgeMs); const { e164 } = readWebSelfId(); - parts.push( + lines.push( webLinked - ? `WhatsApp web linked${e164 ? ` as ${e164}` : ""} (auth ${authAge})` - : "WhatsApp web not linked", + ? `WhatsApp: linked${e164 ? ` as ${e164}` : ""} (auth ${authAge})` + : "WhatsApp: not linked", ); const telegramToken = process.env.TELEGRAM_BOT_TOKEN ?? effective.telegram?.botToken; - parts.push( - telegramToken ? "Telegram bot configured" : "Telegram bot not configured", + lines.push( + telegramToken ? "Telegram: configured" : "Telegram: not configured", ); if (effective.webchat?.enabled === false) { - parts.push("WebChat disabled"); + lines.push("WebChat: disabled"); } else { const port = effective.webchat?.port ?? DEFAULT_WEBCHAT_PORT; - parts.push(`WebChat enabled (port ${port})`); + lines.push(`WebChat: enabled (port ${port})`); } const allowFrom = effective.inbound?.allowFrom?.length ? effective.inbound.allowFrom.map(normalizeE164).filter(Boolean) : []; if (allowFrom.length) { - parts.push(`AllowFrom: ${allowFrom.join(", ")}`); + lines.push(`AllowFrom: ${allowFrom.join(", ")}`); } - return `System status: ${parts.join("; ")}`; + return lines; } export function formatAge(ms: number): string { diff --git a/src/rpc/loop.test.ts b/src/rpc/loop.test.ts index eab474352..342f966ec 100644 --- a/src/rpc/loop.test.ts +++ b/src/rpc/loop.test.ts @@ -12,7 +12,7 @@ vi.mock("../commands/status.js", () => ({ getStatusSummary: vi.fn(async () => ({ web: { linked: true, authAgeMs: 0 }, heartbeatSeconds: 60, - providerSummary: "ok", + providerSummary: ["ok"], queuedSystemEvents: [], sessions: { path: "/tmp/sessions.json",