cli: gateway subcommands, drop ipc probes

This commit is contained in:
Peter Steinberger
2025-12-09 20:26:38 +00:00
parent 8265829105
commit e84ed61339
5 changed files with 88 additions and 61 deletions

52
src/cli/ports.ts Normal file
View File

@@ -0,0 +1,52 @@
import { execFileSync } from "node:child_process";
export type PortProcess = { pid: number; command?: string };
export 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;
}
export 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");
}
if (status === 1) return []; // no listeners
throw err instanceof Error ? err : new Error(String(err));
}
}
export 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;
}

View File

@@ -1,5 +1,3 @@
import { execFileSync } from "node:child_process";
import chalk from "chalk"; import chalk from "chalk";
import { Command } from "commander"; import { Command } from "commander";
import { agentCommand } from "../commands/agent.js"; import { agentCommand } from "../commands/agent.js";
@@ -17,58 +15,12 @@ import { defaultRuntime } from "../runtime.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import { startWebChatServer } from "../webchat/server.js"; import { startWebChatServer } from "../webchat/server.js";
import { createDefaultDeps } from "./deps.js"; import { createDefaultDeps } from "./deps.js";
import {
export type PortProcess = { pid: number; command?: string }; forceFreePort,
listPortListeners,
export function parseLsofOutput(output: string): PortProcess[] { PortProcess,
const lines = output.split(/\r?\n/).filter(Boolean); parseLsofOutput,
const results: PortProcess[] = []; } from "./ports.js";
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;
}
export 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));
}
}
export 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() { export function buildProgram() {
const program = new Command(); const program = new Command();
@@ -386,6 +338,8 @@ Examples:
// Only spawn if there is clearly no listener. // Only spawn if there is clearly no listener.
const url = new URL(opts.url ?? "ws://127.0.0.1:18789"); const url = new URL(opts.url ?? "ws://127.0.0.1:18789");
const port = Number(url.port || 18789); const port = Number(url.port || 18789);
const listeners = listPortListeners(port);
if (listeners.length > 0) throw err;
await startGatewayServer(port); await startGatewayServer(port);
return await attempt(); return await attempt();
} }
@@ -554,6 +508,11 @@ Examples:
.option("--json", "Output JSON instead of text", false) .option("--json", "Output JSON instead of text", false)
.option("--timeout <ms>", "Connection timeout in milliseconds", "10000") .option("--timeout <ms>", "Connection timeout in milliseconds", "10000")
.option("--verbose", "Verbose logging", false) .option("--verbose", "Verbose logging", false)
.option(
"--probe",
"Also attempt a live Baileys connect (can conflict if gateway is already connected)",
false,
)
.action(async (opts) => { .action(async (opts) => {
setVerbose(Boolean(opts.verbose)); setVerbose(Boolean(opts.verbose));
const timeout = opts.timeout const timeout = opts.timeout
@@ -568,7 +527,11 @@ Examples:
} }
try { try {
await healthCommand( await healthCommand(
{ json: Boolean(opts.json), timeoutMs: timeout }, {
json: Boolean(opts.json),
timeoutMs: timeout,
probe: Boolean(opts.probe),
},
defaultRuntime, defaultRuntime,
); );
} catch (err) { } catch (err) {

View File

@@ -191,6 +191,7 @@ async function probeTelegram(
export async function getHealthSnapshot( export async function getHealthSnapshot(
timeoutMs?: number, timeoutMs?: number,
opts?: { probe?: boolean },
): Promise<HealthSummary> { ): Promise<HealthSummary> {
const cfg = loadConfig(); const cfg = loadConfig();
const linked = await webAuthExists(); const linked = await webAuthExists();
@@ -210,7 +211,8 @@ export async function getHealthSnapshot(
const start = Date.now(); const start = Date.now();
const cappedTimeout = Math.max(1000, timeoutMs ?? DEFAULT_TIMEOUT_MS); const cappedTimeout = Math.max(1000, timeoutMs ?? DEFAULT_TIMEOUT_MS);
const connect = linked ? await probeWebConnect(cappedTimeout) : undefined; const connect =
linked && opts?.probe ? await probeWebConnect(cappedTimeout) : undefined;
const telegramToken = const telegramToken =
process.env.TELEGRAM_BOT_TOKEN ?? cfg.telegram?.botToken ?? ""; process.env.TELEGRAM_BOT_TOKEN ?? cfg.telegram?.botToken ?? "";
@@ -237,10 +239,12 @@ export async function getHealthSnapshot(
} }
export async function healthCommand( export async function healthCommand(
opts: { json?: boolean; timeoutMs?: number }, opts: { json?: boolean; timeoutMs?: number; probe?: boolean },
runtime: RuntimeEnv, runtime: RuntimeEnv,
) { ) {
const summary = await getHealthSnapshot(opts.timeoutMs); const summary = await getHealthSnapshot(opts.timeoutMs, {
probe: opts.probe,
});
const fatal = const fatal =
!summary.web.linked || !summary.web.linked ||
(summary.web.connect && !summary.web.connect.ok) || (summary.web.connect && !summary.web.connect.ok) ||

View File

@@ -1,4 +1,5 @@
import type { CliDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.js";
import { listPortListeners } from "../cli/ports.js";
import { info, success } from "../globals.js"; import { info, success } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
@@ -78,8 +79,15 @@ export async function sendCommand(
result = await sendViaGateway(); result = await sendViaGateway();
} catch (err) { } catch (err) {
if (!opts.spawnGateway) throw err; if (!opts.spawnGateway) throw err;
await startGatewayServer(18789); // Only spawn when nothing is listening.
result = await sendViaGateway(); try {
const listeners = listPortListeners(18789);
if (listeners.length > 0) throw err;
await startGatewayServer(18789);
result = await sendViaGateway();
} catch {
throw err;
}
} }
runtime.log( runtime.log(

View File

@@ -404,7 +404,7 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
presenceVersion += 1; presenceVersion += 1;
const snapshot = buildSnapshot(); const snapshot = buildSnapshot();
// Fill health asynchronously for snapshot // Fill health asynchronously for snapshot
const health = await getHealthSnapshot(); const health = await getHealthSnapshot(undefined, { probe: false });
snapshot.health = health; snapshot.health = health;
snapshot.stateVersion.health = ++healthVersion; snapshot.stateVersion.health = ++healthVersion;
const helloOk = { const helloOk = {