CLI: add gateway --force option
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user