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

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