CLI: add gateway --force option

This commit is contained in:
Peter Steinberger
2025-12-09 16:28:26 +00:00
parent e0ea7be499
commit 6afcf43ff2
6 changed files with 113 additions and 17 deletions

View File

@@ -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)

View File

@@ -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:<port>` (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 <value>` or set `CLAWDIS_GATEWAY_TOKEN` to require clients to send `hello.auth.token`.
## Remote access

View File

@@ -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<PortProcess> = {};
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 <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);
}

View File

@@ -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

View File

@@ -10,41 +10,41 @@ const DEFAULT_WEBCHAT_PORT = 18788;
export async function buildProviderSummary(
cfg?: WarelayConfig,
): Promise<string> {
): Promise<string[]> {
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 {

View File

@@ -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",