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)
|
# Talk directly to the agent (no WhatsApp send)
|
||||||
clawdis agent --to +1234567890 --message "Ship checklist" --thinking high
|
clawdis agent --to +1234567890 --message "Ship checklist" --thinking high
|
||||||
|
|
||||||
# Start the relay
|
# Start the gateway (WebSocket control plane)
|
||||||
clawdis relay --verbose
|
clawdis gateway --port 18789 --verbose
|
||||||
|
|
||||||
|
# If the port is busy, force-kill listeners then start
|
||||||
|
clawdis gateway --force
|
||||||
```
|
```
|
||||||
|
|
||||||
## macOS Companion App (Clawdis.app)
|
## macOS Companion App (Clawdis.app)
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ Last updated: 2025-12-09
|
|||||||
pnpm clawdis gateway --port 18789
|
pnpm clawdis gateway --port 18789
|
||||||
# for full debug/trace logs in stdio:
|
# for full debug/trace logs in stdio:
|
||||||
pnpm clawdis gateway --port 18789 --verbose
|
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).
|
- 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.
|
- 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.
|
- 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`.
|
- Optional shared secret: pass `--token <value>` or set `CLAWDIS_GATEWAY_TOKEN` to require clients to send `hello.auth.token`.
|
||||||
|
|
||||||
## Remote access
|
## Remote access
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
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";
|
||||||
@@ -15,6 +17,58 @@ 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";
|
||||||
|
|
||||||
|
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() {
|
export function buildProgram() {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
const PROGRAM_VERSION = VERSION;
|
const PROGRAM_VERSION = VERSION;
|
||||||
@@ -62,7 +116,14 @@ export function buildProgram() {
|
|||||||
'clawdis send --to +15555550123 --message "Hi" --json',
|
'clawdis send --to +15555550123 --message "Hi" --json',
|
||||||
"Send via your web session and print JSON result.",
|
"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 gw:status", "Fetch Gateway status over WS."],
|
||||||
[
|
[
|
||||||
'clawdis agent --to +15555550123 --message "Run summary" --deliver',
|
'clawdis agent --to +15555550123 --message "Run summary" --deliver',
|
||||||
@@ -227,6 +288,11 @@ Examples:
|
|||||||
"--token <token>",
|
"--token <token>",
|
||||||
"Shared token required in hello.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)",
|
"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)
|
.option("--verbose", "Verbose logging to stdout/stderr", false)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
@@ -235,6 +301,27 @@ Examples:
|
|||||||
defaultRuntime.error("Invalid port");
|
defaultRuntime.error("Invalid port");
|
||||||
defaultRuntime.exit(1);
|
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) {
|
if (opts.token) {
|
||||||
process.env.CLAWDIS_GATEWAY_TOKEN = String(opts.token);
|
process.env.CLAWDIS_GATEWAY_TOKEN = String(opts.token);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export type SessionStatus = {
|
|||||||
export type StatusSummary = {
|
export type StatusSummary = {
|
||||||
web: { linked: boolean; authAgeMs: number | null };
|
web: { linked: boolean; authAgeMs: number | null };
|
||||||
heartbeatSeconds: number;
|
heartbeatSeconds: number;
|
||||||
providerSummary: string;
|
providerSummary: string[];
|
||||||
queuedSystemEvents: string[];
|
queuedSystemEvents: string[];
|
||||||
sessions: {
|
sessions: {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -209,7 +209,10 @@ export async function statusCommand(
|
|||||||
if (summary.web.linked) {
|
if (summary.web.linked) {
|
||||||
logWebSelfId(runtime, true);
|
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) {
|
if (health) {
|
||||||
const waLine = health.web.connect
|
const waLine = health.web.connect
|
||||||
? health.web.connect.ok
|
? health.web.connect.ok
|
||||||
|
|||||||
@@ -10,41 +10,41 @@ const DEFAULT_WEBCHAT_PORT = 18788;
|
|||||||
|
|
||||||
export async function buildProviderSummary(
|
export async function buildProviderSummary(
|
||||||
cfg?: WarelayConfig,
|
cfg?: WarelayConfig,
|
||||||
): Promise<string> {
|
): Promise<string[]> {
|
||||||
const effective = cfg ?? loadConfig();
|
const effective = cfg ?? loadConfig();
|
||||||
const parts: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
||||||
const webLinked = await webAuthExists();
|
const webLinked = await webAuthExists();
|
||||||
const authAgeMs = getWebAuthAgeMs();
|
const authAgeMs = getWebAuthAgeMs();
|
||||||
const authAge = authAgeMs === null ? "unknown" : formatAge(authAgeMs);
|
const authAge = authAgeMs === null ? "unknown" : formatAge(authAgeMs);
|
||||||
const { e164 } = readWebSelfId();
|
const { e164 } = readWebSelfId();
|
||||||
parts.push(
|
lines.push(
|
||||||
webLinked
|
webLinked
|
||||||
? `WhatsApp web linked${e164 ? ` as ${e164}` : ""} (auth ${authAge})`
|
? `WhatsApp: linked${e164 ? ` as ${e164}` : ""} (auth ${authAge})`
|
||||||
: "WhatsApp web not linked",
|
: "WhatsApp: not linked",
|
||||||
);
|
);
|
||||||
|
|
||||||
const telegramToken =
|
const telegramToken =
|
||||||
process.env.TELEGRAM_BOT_TOKEN ?? effective.telegram?.botToken;
|
process.env.TELEGRAM_BOT_TOKEN ?? effective.telegram?.botToken;
|
||||||
parts.push(
|
lines.push(
|
||||||
telegramToken ? "Telegram bot configured" : "Telegram bot not configured",
|
telegramToken ? "Telegram: configured" : "Telegram: not configured",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (effective.webchat?.enabled === false) {
|
if (effective.webchat?.enabled === false) {
|
||||||
parts.push("WebChat disabled");
|
lines.push("WebChat: disabled");
|
||||||
} else {
|
} else {
|
||||||
const port = effective.webchat?.port ?? DEFAULT_WEBCHAT_PORT;
|
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
|
const allowFrom = effective.inbound?.allowFrom?.length
|
||||||
? effective.inbound.allowFrom.map(normalizeE164).filter(Boolean)
|
? effective.inbound.allowFrom.map(normalizeE164).filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
if (allowFrom.length) {
|
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 {
|
export function formatAge(ms: number): string {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ vi.mock("../commands/status.js", () => ({
|
|||||||
getStatusSummary: vi.fn(async () => ({
|
getStatusSummary: vi.fn(async () => ({
|
||||||
web: { linked: true, authAgeMs: 0 },
|
web: { linked: true, authAgeMs: 0 },
|
||||||
heartbeatSeconds: 60,
|
heartbeatSeconds: 60,
|
||||||
providerSummary: "ok",
|
providerSummary: ["ok"],
|
||||||
queuedSystemEvents: [],
|
queuedSystemEvents: [],
|
||||||
sessions: {
|
sessions: {
|
||||||
path: "/tmp/sessions.json",
|
path: "/tmp/sessions.json",
|
||||||
|
|||||||
Reference in New Issue
Block a user