refactor: simplify cli commands
This commit is contained in:
@@ -1,166 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const callGateway = vi.fn(
|
||||
async (opts: { method?: string; params?: { command?: string } }) => {
|
||||
if (opts.method === "node.list") {
|
||||
return {
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "mac-1",
|
||||
displayName: "Mac",
|
||||
platform: "macos",
|
||||
caps: ["canvas"],
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (opts.method === "node.invoke") {
|
||||
if (opts.params?.command === "canvas.eval") {
|
||||
return { payload: { result: "ok" } };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
|
||||
const randomIdempotencyKey = vi.fn(() => "rk_test");
|
||||
|
||||
const runtimeLogs: string[] = [];
|
||||
const runtimeErrors: string[] = [];
|
||||
const defaultRuntime = {
|
||||
log: (msg: string) => runtimeLogs.push(msg),
|
||||
error: (msg: string) => runtimeErrors.push(msg),
|
||||
exit: (code: number) => {
|
||||
throw new Error(`__exit__:${code}`);
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => callGateway(opts as { method?: string }),
|
||||
randomIdempotencyKey: () => randomIdempotencyKey(),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime,
|
||||
}));
|
||||
|
||||
describe("canvas-cli coverage", () => {
|
||||
it("invokes canvas.present with placement and target", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
callGateway.mockClear();
|
||||
randomIdempotencyKey.mockClear();
|
||||
|
||||
const { registerCanvasCli } = await import("./canvas-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerCanvasCli(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"canvas",
|
||||
"present",
|
||||
"--node",
|
||||
"mac-1",
|
||||
"--target",
|
||||
"https://example.com",
|
||||
"--x",
|
||||
"10",
|
||||
"--y",
|
||||
"20",
|
||||
"--width",
|
||||
"800",
|
||||
"--height",
|
||||
"600",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
const invoke = callGateway.mock.calls.find(
|
||||
(call) => call[0]?.method === "node.invoke",
|
||||
)?.[0];
|
||||
|
||||
expect(invoke).toBeTruthy();
|
||||
expect(invoke?.params?.command).toBe("canvas.present");
|
||||
expect(invoke?.params?.idempotencyKey).toBe("rk_test");
|
||||
expect(invoke?.params?.params).toEqual({
|
||||
url: "https://example.com",
|
||||
placement: { x: 10, y: 20, width: 800, height: 600 },
|
||||
});
|
||||
});
|
||||
|
||||
it("prints canvas.eval result", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
callGateway.mockClear();
|
||||
|
||||
const { registerCanvasCli } = await import("./canvas-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerCanvasCli(program);
|
||||
|
||||
await program.parseAsync(["canvas", "eval", "1+1"], { from: "user" });
|
||||
|
||||
expect(runtimeErrors).toHaveLength(0);
|
||||
expect(runtimeLogs.join("\n")).toContain("ok");
|
||||
});
|
||||
|
||||
it("pushes A2UI text payload", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
callGateway.mockClear();
|
||||
|
||||
const { registerCanvasCli } = await import("./canvas-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerCanvasCli(program);
|
||||
|
||||
await program.parseAsync(
|
||||
["canvas", "a2ui", "push", "--node", "mac-1", "--text", "Hello A2UI"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
const invoke = callGateway.mock.calls.find(
|
||||
(call) => call[0]?.method === "node.invoke",
|
||||
)?.[0];
|
||||
|
||||
expect(invoke?.params?.command).toBe("canvas.a2ui.pushJSONL");
|
||||
expect(invoke?.params?.params?.jsonl).toContain("Hello A2UI");
|
||||
});
|
||||
|
||||
it("rejects invalid A2UI JSONL", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
callGateway.mockClear();
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock("node:fs/promises", () => ({
|
||||
default: { readFile: vi.fn(async () => "{broken") },
|
||||
}));
|
||||
|
||||
const { registerCanvasCli } = await import("./canvas-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerCanvasCli(program);
|
||||
|
||||
await expect(
|
||||
program.parseAsync(
|
||||
[
|
||||
"canvas",
|
||||
"a2ui",
|
||||
"push",
|
||||
"--node",
|
||||
"mac-1",
|
||||
"--jsonl",
|
||||
"/tmp/a2ui.jsonl",
|
||||
],
|
||||
{ from: "user" },
|
||||
),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(runtimeErrors.join("\n")).toContain("Invalid A2UI JSONL");
|
||||
});
|
||||
});
|
||||
@@ -1,544 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import type { Command } from "commander";
|
||||
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { writeBase64ToFile } from "./nodes-camera.js";
|
||||
import {
|
||||
canvasSnapshotTempPath,
|
||||
parseCanvasSnapshotPayload,
|
||||
} from "./nodes-canvas.js";
|
||||
import { withProgress } from "./progress.js";
|
||||
|
||||
type CanvasOpts = {
|
||||
url?: string;
|
||||
token?: string;
|
||||
timeout?: string;
|
||||
json?: boolean;
|
||||
node?: string;
|
||||
target?: string;
|
||||
x?: string;
|
||||
y?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
js?: string;
|
||||
jsonl?: string;
|
||||
text?: string;
|
||||
format?: string;
|
||||
maxWidth?: string;
|
||||
quality?: string;
|
||||
};
|
||||
|
||||
type NodeListNode = {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
remoteIp?: string;
|
||||
caps?: string[];
|
||||
connected?: boolean;
|
||||
};
|
||||
|
||||
type PendingRequest = {
|
||||
requestId: string;
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
remoteIp?: string;
|
||||
};
|
||||
|
||||
type PairedNode = {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
remoteIp?: string;
|
||||
};
|
||||
|
||||
type PairingList = {
|
||||
pending: PendingRequest[];
|
||||
paired: PairedNode[];
|
||||
};
|
||||
|
||||
const A2UI_ACTION_KEYS = [
|
||||
"beginRendering",
|
||||
"surfaceUpdate",
|
||||
"dataModelUpdate",
|
||||
"deleteSurface",
|
||||
"createSurface",
|
||||
] as const;
|
||||
|
||||
type A2UIVersion = "v0.8" | "v0.9";
|
||||
|
||||
const canvasCallOpts = (cmd: Command) =>
|
||||
cmd
|
||||
.option(
|
||||
"--url <url>",
|
||||
"Gateway WebSocket URL (defaults to gateway.remote.url when configured)",
|
||||
)
|
||||
.option("--token <token>", "Gateway token (if required)")
|
||||
.option("--timeout <ms>", "Timeout in ms", "10000")
|
||||
.option("--json", "Output JSON", false);
|
||||
|
||||
const callGatewayCli = async (
|
||||
method: string,
|
||||
opts: CanvasOpts,
|
||||
params?: unknown,
|
||||
) =>
|
||||
withProgress(
|
||||
{
|
||||
label: `Canvas ${method}`,
|
||||
indeterminate: true,
|
||||
enabled: opts.json !== true,
|
||||
},
|
||||
async () =>
|
||||
await callGateway({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
method,
|
||||
params,
|
||||
timeoutMs: Number(opts.timeout ?? 10_000),
|
||||
clientName: "cli",
|
||||
mode: "cli",
|
||||
}),
|
||||
);
|
||||
|
||||
function parseNodeList(value: unknown): NodeListNode[] {
|
||||
const obj =
|
||||
typeof value === "object" && value !== null
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
return Array.isArray(obj.nodes) ? (obj.nodes as NodeListNode[]) : [];
|
||||
}
|
||||
|
||||
function parsePairingList(value: unknown): PairingList {
|
||||
const obj =
|
||||
typeof value === "object" && value !== null
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
const pending = Array.isArray(obj.pending)
|
||||
? (obj.pending as PendingRequest[])
|
||||
: [];
|
||||
const paired = Array.isArray(obj.paired) ? (obj.paired as PairedNode[]) : [];
|
||||
return { pending, paired };
|
||||
}
|
||||
|
||||
function normalizeNodeKey(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "");
|
||||
}
|
||||
|
||||
function buildA2UITextJsonl(text: string) {
|
||||
const surfaceId = "main";
|
||||
const rootId = "root";
|
||||
const textId = "text";
|
||||
const payloads = [
|
||||
{
|
||||
surfaceUpdate: {
|
||||
surfaceId,
|
||||
components: [
|
||||
{
|
||||
id: rootId,
|
||||
component: { Column: { children: { explicitList: [textId] } } },
|
||||
},
|
||||
{
|
||||
id: textId,
|
||||
component: {
|
||||
Text: { text: { literalString: text }, usageHint: "body" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ beginRendering: { surfaceId, root: rootId } },
|
||||
];
|
||||
return payloads.map((payload) => JSON.stringify(payload)).join("\n");
|
||||
}
|
||||
|
||||
function validateA2UIJsonl(jsonl: string) {
|
||||
const lines = jsonl.split(/\r?\n/);
|
||||
const errors: string[] = [];
|
||||
let sawV08 = false;
|
||||
let sawV09 = false;
|
||||
let messageCount = 0;
|
||||
|
||||
lines.forEach((line, idx) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
messageCount += 1;
|
||||
let obj: unknown;
|
||||
try {
|
||||
obj = JSON.parse(trimmed) as unknown;
|
||||
} catch (err) {
|
||||
errors.push(`line ${idx + 1}: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
||||
errors.push(`line ${idx + 1}: expected JSON object`);
|
||||
return;
|
||||
}
|
||||
const record = obj as Record<string, unknown>;
|
||||
const actionKeys = A2UI_ACTION_KEYS.filter((key) => key in record);
|
||||
if (actionKeys.length !== 1) {
|
||||
errors.push(
|
||||
`line ${idx + 1}: expected exactly one action key (${A2UI_ACTION_KEYS.join(
|
||||
", ",
|
||||
)})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (actionKeys[0] === "createSurface") {
|
||||
sawV09 = true;
|
||||
} else {
|
||||
sawV08 = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (messageCount === 0) {
|
||||
errors.push("no JSONL messages found");
|
||||
}
|
||||
if (sawV08 && sawV09) {
|
||||
errors.push("mixed A2UI v0.8 and v0.9 messages in one file");
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Invalid A2UI JSONL:\n- ${errors.join("\n- ")}`);
|
||||
}
|
||||
|
||||
const version: A2UIVersion = sawV09 ? "v0.9" : "v0.8";
|
||||
return { version, messageCount };
|
||||
}
|
||||
|
||||
async function loadNodes(opts: CanvasOpts): Promise<NodeListNode[]> {
|
||||
try {
|
||||
const res = (await callGatewayCli("node.list", opts, {})) as unknown;
|
||||
return parseNodeList(res);
|
||||
} catch {
|
||||
const res = (await callGatewayCli("node.pair.list", opts, {})) as unknown;
|
||||
const { paired } = parsePairingList(res);
|
||||
return paired.map((n) => ({
|
||||
nodeId: n.nodeId,
|
||||
displayName: n.displayName,
|
||||
remoteIp: n.remoteIp,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function pickDefaultNode(nodes: NodeListNode[]): NodeListNode | null {
|
||||
const withCanvas = nodes.filter((n) =>
|
||||
Array.isArray(n.caps) ? n.caps.includes("canvas") : true,
|
||||
);
|
||||
if (withCanvas.length === 0) return null;
|
||||
|
||||
const connected = withCanvas.filter((n) => n.connected);
|
||||
const candidates = connected.length > 0 ? connected : withCanvas;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
const local = candidates.filter(
|
||||
(n) =>
|
||||
n.platform?.toLowerCase().startsWith("mac") &&
|
||||
typeof n.nodeId === "string" &&
|
||||
n.nodeId.startsWith("mac-"),
|
||||
);
|
||||
if (local.length === 1) return local[0];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveNodeId(opts: CanvasOpts, query?: string) {
|
||||
const nodes = await loadNodes(opts);
|
||||
const q = String(query ?? "").trim();
|
||||
if (!q) {
|
||||
const picked = pickDefaultNode(nodes);
|
||||
if (picked) return picked.nodeId;
|
||||
throw new Error(
|
||||
"node required (use --node or ensure only one connected node is available)",
|
||||
);
|
||||
}
|
||||
|
||||
const qNorm = normalizeNodeKey(q);
|
||||
const matches = nodes.filter((n) => {
|
||||
if (n.nodeId === q) return true;
|
||||
if (typeof n.remoteIp === "string" && n.remoteIp === q) return true;
|
||||
const name = typeof n.displayName === "string" ? n.displayName : "";
|
||||
if (name && normalizeNodeKey(name) === qNorm) return true;
|
||||
if (q.length >= 6 && n.nodeId.startsWith(q)) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (matches.length === 1) return matches[0].nodeId;
|
||||
if (matches.length === 0) {
|
||||
const known = nodes
|
||||
.map((n) => n.displayName || n.remoteIp || n.nodeId)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
throw new Error(`unknown node: ${q}${known ? ` (known: ${known})` : ""}`);
|
||||
}
|
||||
throw new Error(
|
||||
`ambiguous node: ${q} (matches: ${matches
|
||||
.map((n) => n.displayName || n.remoteIp || n.nodeId)
|
||||
.join(", ")})`,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeFormat(format: string) {
|
||||
const trimmed = format.trim().toLowerCase();
|
||||
if (trimmed === "jpg") return "jpeg";
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function registerCanvasCli(program: Command) {
|
||||
const canvas = program
|
||||
.command("canvas")
|
||||
.description("Control node canvases (present/navigate/eval/snapshot/a2ui)");
|
||||
|
||||
const invokeCanvas = async (
|
||||
opts: CanvasOpts,
|
||||
command: string,
|
||||
params?: Record<string, unknown>,
|
||||
) => {
|
||||
const nodeId = await resolveNodeId(opts, opts.node);
|
||||
await callGatewayCli("node.invoke", opts, {
|
||||
nodeId,
|
||||
command,
|
||||
params,
|
||||
idempotencyKey: randomIdempotencyKey(),
|
||||
});
|
||||
};
|
||||
|
||||
canvasCallOpts(
|
||||
canvas
|
||||
.command("snapshot")
|
||||
.description("Capture a canvas snapshot (prints MEDIA:<path>)")
|
||||
.option("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--format <png|jpg>", "Output format", "png")
|
||||
.option("--max-width <px>", "Max width (px)")
|
||||
.option("--quality <0-1>", "JPEG quality (default 0.82)")
|
||||
.action(async (opts: CanvasOpts) => {
|
||||
try {
|
||||
const nodeId = await resolveNodeId(opts, opts.node);
|
||||
const format = normalizeFormat(String(opts.format ?? "png"));
|
||||
if (format !== "png" && format !== "jpeg") {
|
||||
throw new Error("invalid format (use png or jpg)");
|
||||
}
|
||||
const maxWidth = opts.maxWidth
|
||||
? Number.parseInt(String(opts.maxWidth), 10)
|
||||
: undefined;
|
||||
const quality = opts.quality
|
||||
? Number.parseFloat(String(opts.quality))
|
||||
: undefined;
|
||||
|
||||
const raw = (await callGatewayCli("node.invoke", opts, {
|
||||
nodeId,
|
||||
command: "canvas.snapshot",
|
||||
params: {
|
||||
format,
|
||||
maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined,
|
||||
quality: Number.isFinite(quality) ? quality : undefined,
|
||||
},
|
||||
idempotencyKey: randomIdempotencyKey(),
|
||||
})) as unknown;
|
||||
|
||||
const res =
|
||||
typeof raw === "object" && raw !== null
|
||||
? (raw as { payload?: unknown })
|
||||
: {};
|
||||
const payload = parseCanvasSnapshotPayload(res.payload);
|
||||
const filePath = canvasSnapshotTempPath({
|
||||
ext: payload.format === "jpeg" ? "jpg" : payload.format,
|
||||
});
|
||||
await writeBase64ToFile(filePath, payload.base64);
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
file: {
|
||||
path: filePath,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`MEDIA:${filePath}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`canvas snapshot failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
canvasCallOpts(
|
||||
canvas
|
||||
.command("present")
|
||||
.description("Show the canvas (optionally with a target URL/path)")
|
||||
.option("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--target <urlOrPath>", "Target URL/path (optional)")
|
||||
.option("--x <px>", "Placement x coordinate")
|
||||
.option("--y <px>", "Placement y coordinate")
|
||||
.option("--width <px>", "Placement width")
|
||||
.option("--height <px>", "Placement height")
|
||||
.action(async (opts: CanvasOpts) => {
|
||||
try {
|
||||
const placement = {
|
||||
x: opts.x ? Number.parseFloat(opts.x) : undefined,
|
||||
y: opts.y ? Number.parseFloat(opts.y) : undefined,
|
||||
width: opts.width ? Number.parseFloat(opts.width) : undefined,
|
||||
height: opts.height ? Number.parseFloat(opts.height) : undefined,
|
||||
};
|
||||
const params: Record<string, unknown> = {};
|
||||
if (opts.target) params.url = String(opts.target);
|
||||
if (
|
||||
Number.isFinite(placement.x) ||
|
||||
Number.isFinite(placement.y) ||
|
||||
Number.isFinite(placement.width) ||
|
||||
Number.isFinite(placement.height)
|
||||
) {
|
||||
params.placement = placement;
|
||||
}
|
||||
await invokeCanvas(opts, "canvas.present", params);
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log("canvas present ok");
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`canvas present failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
canvasCallOpts(
|
||||
canvas
|
||||
.command("hide")
|
||||
.description("Hide the canvas")
|
||||
.option("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.action(async (opts: CanvasOpts) => {
|
||||
try {
|
||||
await invokeCanvas(opts, "canvas.hide", undefined);
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log("canvas hide ok");
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`canvas hide failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
canvasCallOpts(
|
||||
canvas
|
||||
.command("navigate")
|
||||
.description("Navigate the canvas to a URL")
|
||||
.argument("<url>", "Target URL/path")
|
||||
.option("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.action(async (url: string, opts: CanvasOpts) => {
|
||||
try {
|
||||
await invokeCanvas(opts, "canvas.navigate", { url });
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log("canvas navigate ok");
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`canvas navigate failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
canvasCallOpts(
|
||||
canvas
|
||||
.command("eval")
|
||||
.description("Evaluate JavaScript in the canvas")
|
||||
.argument("[js]", "JavaScript to evaluate")
|
||||
.option("--js <code>", "JavaScript to evaluate")
|
||||
.option("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.action(async (jsArg: string | undefined, opts: CanvasOpts) => {
|
||||
try {
|
||||
const js = opts.js ?? jsArg;
|
||||
if (!js) throw new Error("missing --js or <js>");
|
||||
const nodeId = await resolveNodeId(opts, opts.node);
|
||||
const raw = (await callGatewayCli("node.invoke", opts, {
|
||||
nodeId,
|
||||
command: "canvas.eval",
|
||||
params: { javaScript: js },
|
||||
idempotencyKey: randomIdempotencyKey(),
|
||||
})) as unknown;
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(raw, null, 2));
|
||||
return;
|
||||
}
|
||||
const payload =
|
||||
typeof raw === "object" && raw !== null
|
||||
? (raw as { payload?: { result?: string } }).payload
|
||||
: undefined;
|
||||
if (payload?.result) {
|
||||
defaultRuntime.log(payload.result);
|
||||
} else {
|
||||
defaultRuntime.log("canvas eval ok");
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`canvas eval failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const a2ui = canvas
|
||||
.command("a2ui")
|
||||
.description("Render A2UI content on the canvas");
|
||||
|
||||
canvasCallOpts(
|
||||
a2ui
|
||||
.command("push")
|
||||
.description("Push A2UI JSONL to the canvas")
|
||||
.option("--jsonl <path>", "Path to JSONL payload")
|
||||
.option("--text <text>", "Render a quick A2UI text payload")
|
||||
.option("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.action(async (opts: CanvasOpts) => {
|
||||
try {
|
||||
const hasJsonl = Boolean(opts.jsonl);
|
||||
const hasText = typeof opts.text === "string";
|
||||
if (hasJsonl === hasText) {
|
||||
throw new Error("provide exactly one of --jsonl or --text");
|
||||
}
|
||||
|
||||
const jsonl = hasText
|
||||
? buildA2UITextJsonl(String(opts.text ?? ""))
|
||||
: await fs.readFile(String(opts.jsonl), "utf8");
|
||||
const { version, messageCount } = validateA2UIJsonl(jsonl);
|
||||
if (version === "v0.9") {
|
||||
throw new Error(
|
||||
"Detected A2UI v0.9 JSONL (createSurface). Clawdbot currently supports v0.8 only.",
|
||||
);
|
||||
}
|
||||
await invokeCanvas(opts, "canvas.a2ui.pushJSONL", { jsonl });
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log(
|
||||
`canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`canvas a2ui push failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
canvasCallOpts(
|
||||
a2ui
|
||||
.command("reset")
|
||||
.description("Reset A2UI renderer state")
|
||||
.option("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.action(async (opts: CanvasOpts) => {
|
||||
try {
|
||||
await invokeCanvas(opts, "canvas.a2ui.reset", undefined);
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log("canvas a2ui reset ok");
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`canvas a2ui reset failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const callGateway = vi.fn(async () => ({ ok: true }));
|
||||
const resolveGatewayProgramArguments = vi.fn(async () => ({
|
||||
programArguments: ["/bin/node", "cli", "gateway-daemon", "--port", "18789"],
|
||||
programArguments: ["/bin/node", "cli", "gateway", "--port", "18789"],
|
||||
}));
|
||||
const serviceInstall = vi.fn().mockResolvedValue(undefined);
|
||||
const serviceUninstall = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -2,21 +2,15 @@ import { Command } from "commander";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const callGateway = vi.fn(async () => ({ ok: true }));
|
||||
const randomIdempotencyKey = vi.fn(() => "rk_test");
|
||||
const startGatewayServer = vi.fn(async () => ({
|
||||
close: vi.fn(async () => {}),
|
||||
}));
|
||||
const setVerbose = vi.fn();
|
||||
const createDefaultDeps = vi.fn();
|
||||
const forceFreePortAndWait = vi.fn(async () => ({
|
||||
killed: [],
|
||||
waitedMs: 0,
|
||||
escalatedToSigkill: false,
|
||||
}));
|
||||
const serviceInstall = vi.fn().mockResolvedValue(undefined);
|
||||
const serviceStop = vi.fn().mockResolvedValue(undefined);
|
||||
const serviceUninstall = vi.fn().mockResolvedValue(undefined);
|
||||
const serviceRestart = vi.fn().mockResolvedValue(undefined);
|
||||
const serviceIsLoaded = vi.fn().mockResolvedValue(true);
|
||||
|
||||
const runtimeLogs: string[] = [];
|
||||
@@ -53,7 +47,7 @@ async function withEnvOverride<T>(
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => callGateway(opts),
|
||||
randomIdempotencyKey: () => randomIdempotencyKey(),
|
||||
randomIdempotencyKey: () => "rk_test",
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/server.js", () => ({
|
||||
@@ -71,10 +65,6 @@ vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime,
|
||||
}));
|
||||
|
||||
vi.mock("./deps.js", () => ({
|
||||
createDefaultDeps: () => createDefaultDeps(),
|
||||
}));
|
||||
|
||||
vi.mock("./ports.js", () => ({
|
||||
forceFreePortAndWait: (port: number) => forceFreePortAndWait(port),
|
||||
}));
|
||||
@@ -84,10 +74,10 @@ vi.mock("../daemon/service.js", () => ({
|
||||
label: "LaunchAgent",
|
||||
loadedText: "loaded",
|
||||
notLoadedText: "not loaded",
|
||||
install: serviceInstall,
|
||||
uninstall: serviceUninstall,
|
||||
stop: serviceStop,
|
||||
restart: serviceRestart,
|
||||
install: vi.fn(),
|
||||
uninstall: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
restart: vi.fn(),
|
||||
isLoaded: serviceIsLoaded,
|
||||
readCommand: vi.fn(),
|
||||
readRuntime: vi.fn().mockResolvedValue({ status: "running" }),
|
||||
@@ -96,12 +86,12 @@ vi.mock("../daemon/service.js", () => ({
|
||||
|
||||
vi.mock("../daemon/program-args.js", () => ({
|
||||
resolveGatewayProgramArguments: async () => ({
|
||||
programArguments: ["/bin/node", "cli", "gateway-daemon", "--port", "18789"],
|
||||
programArguments: ["/bin/node", "cli", "gateway", "--port", "18789"],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("gateway-cli coverage", () => {
|
||||
it("registers call/health/status/send/agent commands and routes to callGateway", async () => {
|
||||
it("registers call/health/status commands and routes to callGateway", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
callGateway.mockClear();
|
||||
@@ -141,66 +131,6 @@ describe("gateway-cli coverage", () => {
|
||||
expect(runtimeErrors.join("\n")).toContain("Gateway call failed:");
|
||||
});
|
||||
|
||||
it("fills idempotency keys for send/agent when missing", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
callGateway.mockClear();
|
||||
randomIdempotencyKey.mockClear();
|
||||
|
||||
const { registerGatewayCli } = await import("./gateway-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerGatewayCli(program);
|
||||
|
||||
await program.parseAsync(
|
||||
["gateway", "send", "--to", "+1555", "--message", "hi"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
await program.parseAsync(
|
||||
["gateway", "agent", "--message", "hello", "--deliver"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(randomIdempotencyKey).toHaveBeenCalled();
|
||||
const callArgs = callGateway.mock.calls.map((c) => c[0]) as Array<{
|
||||
method: string;
|
||||
params?: { idempotencyKey?: string };
|
||||
expectFinal?: boolean;
|
||||
}>;
|
||||
expect(callArgs.some((c) => c.method === "send")).toBe(true);
|
||||
expect(
|
||||
callArgs.some((c) => c.method === "agent" && c.expectFinal === true),
|
||||
).toBe(true);
|
||||
expect(callArgs.every((c) => c.params?.idempotencyKey === "rk_test")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes gifPlayback for gateway send when flag set", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
callGateway.mockClear();
|
||||
randomIdempotencyKey.mockClear();
|
||||
|
||||
const { registerGatewayCli } = await import("./gateway-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerGatewayCli(program);
|
||||
|
||||
await program.parseAsync(
|
||||
["gateway", "send", "--to", "+1555", "--message", "hi", "--gif-playback"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(callGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "send",
|
||||
params: expect.objectContaining({ gifPlayback: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("validates gateway ports and handles force/start errors", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
@@ -254,49 +184,6 @@ describe("gateway-cli coverage", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("supports gateway stop/restart via service helper", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
serviceStop.mockClear();
|
||||
serviceRestart.mockClear();
|
||||
serviceIsLoaded.mockResolvedValue(true);
|
||||
|
||||
const { registerGatewayCli } = await import("./gateway-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerGatewayCli(program);
|
||||
|
||||
await program.parseAsync(["gateway", "stop"], { from: "user" });
|
||||
await program.parseAsync(["gateway", "restart"], { from: "user" });
|
||||
|
||||
expect(serviceStop).toHaveBeenCalledTimes(1);
|
||||
expect(serviceRestart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("supports gateway install/uninstall/start via daemon helpers", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
serviceInstall.mockClear();
|
||||
serviceUninstall.mockClear();
|
||||
serviceRestart.mockClear();
|
||||
serviceIsLoaded.mockResolvedValueOnce(false);
|
||||
|
||||
const { registerGatewayCli } = await import("./gateway-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerGatewayCli(program);
|
||||
|
||||
await program.parseAsync(["gateway", "install", "--port", "18789"], {
|
||||
from: "user",
|
||||
});
|
||||
await program.parseAsync(["gateway", "uninstall"], { from: "user" });
|
||||
await program.parseAsync(["gateway", "start"], { from: "user" });
|
||||
|
||||
expect(serviceInstall).toHaveBeenCalledTimes(1);
|
||||
expect(serviceUninstall).toHaveBeenCalledTimes(1);
|
||||
expect(serviceRestart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prints stop hints on GatewayLockError when service is loaded", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
@@ -320,7 +207,7 @@ describe("gateway-cli coverage", () => {
|
||||
|
||||
expect(startGatewayServer).toHaveBeenCalled();
|
||||
expect(runtimeErrors.join("\n")).toContain("Gateway failed to start:");
|
||||
expect(runtimeErrors.join("\n")).toContain("clawdbot gateway stop");
|
||||
expect(runtimeErrors.join("\n")).toContain("clawdbot daemon stop");
|
||||
});
|
||||
|
||||
it("uses env/config port when --port is omitted", async () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
GATEWAY_WINDOWS_TASK_NAME,
|
||||
} from "../daemon/constants.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { startGatewayServer } from "../gateway/server.js";
|
||||
import {
|
||||
type GatewayWsLogStyle,
|
||||
@@ -23,15 +23,6 @@ import { GatewayLockError } from "../infra/gateway-lock.js";
|
||||
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import {
|
||||
runDaemonInstall,
|
||||
runDaemonRestart,
|
||||
runDaemonStart,
|
||||
runDaemonStatus,
|
||||
runDaemonStop,
|
||||
runDaemonUninstall,
|
||||
} from "./daemon-cli.js";
|
||||
import { createDefaultDeps } from "./deps.js";
|
||||
import { forceFreePortAndWait } from "./ports.js";
|
||||
import { withProgress } from "./progress.js";
|
||||
|
||||
@@ -83,21 +74,21 @@ function renderGatewayServiceStopHints(): string[] {
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return [
|
||||
"Tip: clawdbot gateway stop",
|
||||
"Tip: clawdbot daemon stop",
|
||||
`Or: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
|
||||
];
|
||||
case "linux":
|
||||
return [
|
||||
"Tip: clawdbot gateway stop",
|
||||
"Tip: clawdbot daemon stop",
|
||||
`Or: systemctl --user stop ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`,
|
||||
];
|
||||
case "win32":
|
||||
return [
|
||||
"Tip: clawdbot gateway stop",
|
||||
"Tip: clawdbot daemon stop",
|
||||
`Or: schtasks /End /TN "${GATEWAY_WINDOWS_TASK_NAME}"`,
|
||||
];
|
||||
default:
|
||||
return ["Tip: clawdbot gateway stop"];
|
||||
return ["Tip: clawdbot daemon stop"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,170 +224,6 @@ const callGatewayCli = async (
|
||||
);
|
||||
|
||||
export function registerGatewayCli(program: Command) {
|
||||
program
|
||||
.command("gateway-daemon")
|
||||
.description("Run the WebSocket Gateway as a long-lived daemon")
|
||||
.option("--port <port>", "Port for the gateway WebSocket")
|
||||
.option(
|
||||
"--bind <mode>",
|
||||
'Bind mode ("loopback"|"tailnet"|"lan"|"auto"). Defaults to config gateway.bind (or loopback).',
|
||||
)
|
||||
.option(
|
||||
"--token <token>",
|
||||
"Shared token required in connect.params.auth.token (default: CLAWDBOT_GATEWAY_TOKEN env if set)",
|
||||
)
|
||||
.option("--auth <mode>", 'Gateway auth mode ("token"|"password")')
|
||||
.option("--password <password>", "Password for auth mode=password")
|
||||
.option(
|
||||
"--tailscale <mode>",
|
||||
'Tailscale exposure mode ("off"|"serve"|"funnel")',
|
||||
)
|
||||
.option(
|
||||
"--tailscale-reset-on-exit",
|
||||
"Reset Tailscale serve/funnel configuration on shutdown",
|
||||
false,
|
||||
)
|
||||
.option("--verbose", "Verbose logging to stdout/stderr", false)
|
||||
.option(
|
||||
"--ws-log <style>",
|
||||
'WebSocket log style ("auto"|"full"|"compact")',
|
||||
"auto",
|
||||
)
|
||||
.option("--compact", 'Alias for "--ws-log compact"', false)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const wsLogRaw = (opts.compact ? "compact" : opts.wsLog) as
|
||||
| string
|
||||
| undefined;
|
||||
const wsLogStyle: GatewayWsLogStyle =
|
||||
wsLogRaw === "compact"
|
||||
? "compact"
|
||||
: wsLogRaw === "full"
|
||||
? "full"
|
||||
: "auto";
|
||||
if (
|
||||
wsLogRaw !== undefined &&
|
||||
wsLogRaw !== "auto" &&
|
||||
wsLogRaw !== "compact" &&
|
||||
wsLogRaw !== "full"
|
||||
) {
|
||||
defaultRuntime.error(
|
||||
'Invalid --ws-log (use "auto", "full", "compact")',
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
setGatewayWsLogStyle(wsLogStyle);
|
||||
|
||||
const cfg = loadConfig();
|
||||
const portOverride = parsePort(opts.port);
|
||||
if (opts.port !== undefined && portOverride === null) {
|
||||
defaultRuntime.error("Invalid port");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const port = portOverride ?? resolveGatewayPort(cfg);
|
||||
if (!Number.isFinite(port) || port <= 0) {
|
||||
defaultRuntime.error("Invalid port");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (opts.token) {
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = String(opts.token);
|
||||
}
|
||||
const authModeRaw = opts.auth ? String(opts.auth) : undefined;
|
||||
const authMode =
|
||||
authModeRaw === "token" || authModeRaw === "password"
|
||||
? authModeRaw
|
||||
: null;
|
||||
if (authModeRaw && !authMode) {
|
||||
defaultRuntime.error('Invalid --auth (use "token" or "password")');
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const tailscaleRaw = opts.tailscale ? String(opts.tailscale) : undefined;
|
||||
const tailscaleMode =
|
||||
tailscaleRaw === "off" ||
|
||||
tailscaleRaw === "serve" ||
|
||||
tailscaleRaw === "funnel"
|
||||
? tailscaleRaw
|
||||
: null;
|
||||
if (tailscaleRaw && !tailscaleMode) {
|
||||
defaultRuntime.error(
|
||||
'Invalid --tailscale (use "off", "serve", or "funnel")',
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const bindRaw = String(opts.bind ?? cfg.gateway?.bind ?? "loopback");
|
||||
const bind =
|
||||
bindRaw === "loopback" ||
|
||||
bindRaw === "tailnet" ||
|
||||
bindRaw === "lan" ||
|
||||
bindRaw === "auto"
|
||||
? bindRaw
|
||||
: null;
|
||||
if (!bind) {
|
||||
defaultRuntime.error(
|
||||
'Invalid --bind (use "loopback", "tailnet", "lan", or "auto")',
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await runGatewayLoop({
|
||||
runtime: defaultRuntime,
|
||||
start: async () =>
|
||||
await startGatewayServer(port, {
|
||||
bind,
|
||||
auth:
|
||||
authMode || opts.password || authModeRaw
|
||||
? {
|
||||
mode: authMode ?? undefined,
|
||||
password: opts.password
|
||||
? String(opts.password)
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
tailscale:
|
||||
tailscaleMode || opts.tailscaleResetOnExit
|
||||
? {
|
||||
mode: tailscaleMode ?? undefined,
|
||||
resetOnExit: Boolean(opts.tailscaleResetOnExit),
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof GatewayLockError ||
|
||||
(err &&
|
||||
typeof err === "object" &&
|
||||
(err as { name?: string }).name === "GatewayLockError")
|
||||
) {
|
||||
const errMessage = describeUnknownError(err);
|
||||
defaultRuntime.error(
|
||||
`Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot gateway stop`,
|
||||
);
|
||||
try {
|
||||
const diagnostics = await inspectPortUsage(port);
|
||||
if (diagnostics.status === "busy") {
|
||||
for (const line of formatPortDiagnostics(diagnostics)) {
|
||||
defaultRuntime.error(line);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore diagnostics failures
|
||||
}
|
||||
await maybeExplainGatewayServiceStop();
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
defaultRuntime.error(`Gateway failed to start: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const gateway = program
|
||||
.command("gateway")
|
||||
.description("Run the WebSocket Gateway")
|
||||
@@ -596,7 +423,7 @@ export function registerGatewayCli(program: Command) {
|
||||
) {
|
||||
const errMessage = describeUnknownError(err);
|
||||
defaultRuntime.error(
|
||||
`Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot gateway stop`,
|
||||
`Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot daemon stop`,
|
||||
);
|
||||
try {
|
||||
const diagnostics = await inspectPortUsage(port);
|
||||
@@ -617,69 +444,13 @@ export function registerGatewayCli(program: Command) {
|
||||
}
|
||||
});
|
||||
|
||||
gateway
|
||||
.command("install")
|
||||
.description(
|
||||
"Install the Gateway service (alias for `clawdbot daemon install`)",
|
||||
)
|
||||
.option("--port <port>", "Gateway port")
|
||||
.option("--runtime <runtime>", "Daemon runtime (node|bun). Default: node")
|
||||
.option("--token <token>", "Gateway token (token auth)")
|
||||
.action(async (opts) => {
|
||||
await runDaemonInstall(opts);
|
||||
});
|
||||
|
||||
gateway
|
||||
.command("uninstall")
|
||||
.description(
|
||||
"Uninstall the Gateway service (alias for `clawdbot daemon uninstall`)",
|
||||
)
|
||||
.action(async () => {
|
||||
await runDaemonUninstall();
|
||||
});
|
||||
|
||||
gateway
|
||||
.command("start")
|
||||
.description(
|
||||
"Start the Gateway service (alias for `clawdbot daemon start`)",
|
||||
)
|
||||
.action(async () => {
|
||||
await runDaemonStart();
|
||||
});
|
||||
|
||||
const gatewayDaemon = gateway
|
||||
.command("daemon")
|
||||
.description("Daemon helpers (alias for `clawdbot daemon`)");
|
||||
|
||||
gatewayDaemon
|
||||
.command("status")
|
||||
.description("Show daemon install status + probe the Gateway")
|
||||
.option(
|
||||
"--url <url>",
|
||||
"Gateway WebSocket URL (defaults to config/remote/local)",
|
||||
)
|
||||
.option("--token <token>", "Gateway token (if required)")
|
||||
.option("--password <password>", "Gateway password (password auth)")
|
||||
.option("--timeout <ms>", "Timeout in ms", "10000")
|
||||
.option("--no-probe", "Skip RPC probe")
|
||||
.option("--deep", "Scan system-level services", false)
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runDaemonStatus({
|
||||
rpc: opts,
|
||||
probe: Boolean(opts.probe),
|
||||
deep: Boolean(opts.deep),
|
||||
json: Boolean(opts.json),
|
||||
});
|
||||
});
|
||||
|
||||
gatewayCallOpts(
|
||||
gateway
|
||||
.command("call")
|
||||
.description("Call a Gateway method and print JSON")
|
||||
.argument(
|
||||
"<method>",
|
||||
"Method name (health/status/system-presence/send/agent/cron.*)",
|
||||
"Method name (health/status/system-presence/cron.*)",
|
||||
)
|
||||
.option("--params <json>", "JSON object string for params", "{}")
|
||||
.action(async (method, opts) => {
|
||||
@@ -724,108 +495,4 @@ export function registerGatewayCli(program: Command) {
|
||||
}),
|
||||
);
|
||||
|
||||
gatewayCallOpts(
|
||||
gateway
|
||||
.command("wake")
|
||||
.description("Enqueue a system event and optionally trigger a heartbeat")
|
||||
.requiredOption("--text <text>", "System event text")
|
||||
.option(
|
||||
"--mode <mode>",
|
||||
"Wake mode (now|next-heartbeat)",
|
||||
"next-heartbeat",
|
||||
)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const result = await callGatewayCli("wake", opts, {
|
||||
mode: opts.mode,
|
||||
text: opts.text,
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
gatewayCallOpts(
|
||||
gateway
|
||||
.command("send")
|
||||
.description("Send a message via the Gateway")
|
||||
.requiredOption("--to <jidOrPhone>", "Destination (E.164 or jid)")
|
||||
.requiredOption("--message <text>", "Message text")
|
||||
.option("--media-url <url>", "Optional media URL")
|
||||
.option("--gif-playback", "Treat video media as GIF playback", false)
|
||||
.option("--idempotency-key <key>", "Idempotency key")
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const idempotencyKey = opts.idempotencyKey ?? randomIdempotencyKey();
|
||||
const result = await callGatewayCli("send", opts, {
|
||||
to: opts.to,
|
||||
message: opts.message,
|
||||
mediaUrl: opts.mediaUrl,
|
||||
gifPlayback: opts.gifPlayback,
|
||||
idempotencyKey,
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
gatewayCallOpts(
|
||||
gateway
|
||||
.command("agent")
|
||||
.description("Run an agent turn via the Gateway (waits for final)")
|
||||
.requiredOption("--message <text>", "User message")
|
||||
.option("--to <jidOrPhone>", "Destination")
|
||||
.option("--session-id <id>", "Session id")
|
||||
.option("--thinking <level>", "Thinking level")
|
||||
.option("--deliver", "Deliver response", false)
|
||||
.option("--timeout-seconds <n>", "Agent timeout seconds")
|
||||
.option("--idempotency-key <key>", "Idempotency key")
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const idempotencyKey = opts.idempotencyKey ?? randomIdempotencyKey();
|
||||
const result = await callGatewayCli(
|
||||
"agent",
|
||||
{ ...opts, expectFinal: true },
|
||||
{
|
||||
message: opts.message,
|
||||
to: opts.to,
|
||||
sessionId: opts.sessionId,
|
||||
thinking: opts.thinking,
|
||||
deliver: Boolean(opts.deliver),
|
||||
timeout: opts.timeoutSeconds
|
||||
? Number.parseInt(String(opts.timeoutSeconds), 10)
|
||||
: undefined,
|
||||
idempotencyKey,
|
||||
},
|
||||
);
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
gateway
|
||||
.command("stop")
|
||||
.description("Stop the Gateway service (launchd/systemd/schtasks)")
|
||||
.action(async () => {
|
||||
await runDaemonStop();
|
||||
});
|
||||
|
||||
gateway
|
||||
.command("restart")
|
||||
.description("Restart the Gateway service (launchd/systemd/schtasks)")
|
||||
.action(async () => {
|
||||
await runDaemonRestart();
|
||||
});
|
||||
|
||||
// Build default deps (keeps parity with other commands; future-proofing).
|
||||
void createDefaultDeps();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { Command } from "commander";
|
||||
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@@ -31,6 +32,14 @@ type NodesRpcOpts = {
|
||||
params?: string;
|
||||
invokeTimeout?: string;
|
||||
idempotencyKey?: string;
|
||||
target?: string;
|
||||
x?: string;
|
||||
y?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
js?: string;
|
||||
jsonl?: string;
|
||||
text?: string;
|
||||
cwd?: string;
|
||||
env?: string[];
|
||||
commandTimeout?: string;
|
||||
@@ -99,6 +108,16 @@ type PairingList = {
|
||||
paired: PairedNode[];
|
||||
};
|
||||
|
||||
const A2UI_ACTION_KEYS = [
|
||||
"beginRendering",
|
||||
"surfaceUpdate",
|
||||
"dataModelUpdate",
|
||||
"deleteSurface",
|
||||
"createSurface",
|
||||
] as const;
|
||||
|
||||
type A2UIVersion = "v0.8" | "v0.9";
|
||||
|
||||
const nodesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) =>
|
||||
cmd
|
||||
.option(
|
||||
@@ -249,6 +268,86 @@ async function resolveNodeId(opts: NodesRpcOpts, query: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildA2UITextJsonl(text: string) {
|
||||
const surfaceId = "main";
|
||||
const rootId = "root";
|
||||
const textId = "text";
|
||||
const payloads = [
|
||||
{
|
||||
surfaceUpdate: {
|
||||
surfaceId,
|
||||
components: [
|
||||
{
|
||||
id: rootId,
|
||||
component: { Column: { children: { explicitList: [textId] } } },
|
||||
},
|
||||
{
|
||||
id: textId,
|
||||
component: {
|
||||
Text: { text: { literalString: text }, usageHint: "body" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ beginRendering: { surfaceId, root: rootId } },
|
||||
];
|
||||
return payloads.map((payload) => JSON.stringify(payload)).join("\n");
|
||||
}
|
||||
|
||||
function validateA2UIJsonl(jsonl: string) {
|
||||
const lines = jsonl.split(/\r?\n/);
|
||||
const errors: string[] = [];
|
||||
let sawV08 = false;
|
||||
let sawV09 = false;
|
||||
let messageCount = 0;
|
||||
|
||||
lines.forEach((line, idx) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
messageCount += 1;
|
||||
let obj: unknown;
|
||||
try {
|
||||
obj = JSON.parse(trimmed) as unknown;
|
||||
} catch (err) {
|
||||
errors.push(`line ${idx + 1}: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
||||
errors.push(`line ${idx + 1}: expected JSON object`);
|
||||
return;
|
||||
}
|
||||
const record = obj as Record<string, unknown>;
|
||||
const actionKeys = A2UI_ACTION_KEYS.filter((key) => key in record);
|
||||
if (actionKeys.length !== 1) {
|
||||
errors.push(
|
||||
`line ${idx + 1}: expected exactly one action key (${A2UI_ACTION_KEYS.join(
|
||||
", ",
|
||||
)})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (actionKeys[0] === "createSurface") {
|
||||
sawV09 = true;
|
||||
} else {
|
||||
sawV08 = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (messageCount === 0) {
|
||||
errors.push("no JSONL messages found");
|
||||
}
|
||||
if (sawV08 && sawV09) {
|
||||
errors.push("mixed A2UI v0.8 and v0.9 messages in one file");
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Invalid A2UI JSONL:\n- ${errors.join("\n- ")}`);
|
||||
}
|
||||
|
||||
const version: A2UIVersion = sawV09 ? "v0.9" : "v0.8";
|
||||
return { version, messageCount };
|
||||
}
|
||||
|
||||
export function registerNodesCli(program: Command) {
|
||||
const nodes = program
|
||||
.command("nodes")
|
||||
@@ -750,6 +849,25 @@ export function registerNodesCli(program: Command) {
|
||||
.command("canvas")
|
||||
.description("Capture or render canvas content from a paired node");
|
||||
|
||||
const invokeCanvas = async (
|
||||
opts: NodesRpcOpts,
|
||||
command: string,
|
||||
params?: Record<string, unknown>,
|
||||
) => {
|
||||
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
||||
const invokeParams: Record<string, unknown> = {
|
||||
nodeId,
|
||||
command,
|
||||
params,
|
||||
idempotencyKey: randomIdempotencyKey(),
|
||||
};
|
||||
const timeoutMs = parseTimeoutMs(opts.invokeTimeout);
|
||||
if (typeof timeoutMs === "number") {
|
||||
invokeParams.timeoutMs = timeoutMs;
|
||||
}
|
||||
return await callGatewayCli("node.invoke", opts, invokeParams);
|
||||
};
|
||||
|
||||
nodesCallOpts(
|
||||
canvas
|
||||
.command("snapshot")
|
||||
@@ -840,6 +958,181 @@ export function registerNodesCli(program: Command) {
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
canvas
|
||||
.command("present")
|
||||
.description("Show the canvas (optionally with a target URL/path)")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--target <urlOrPath>", "Target URL/path (optional)")
|
||||
.option("--x <px>", "Placement x coordinate")
|
||||
.option("--y <px>", "Placement y coordinate")
|
||||
.option("--width <px>", "Placement width")
|
||||
.option("--height <px>", "Placement height")
|
||||
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const placement = {
|
||||
x: opts.x ? Number.parseFloat(opts.x) : undefined,
|
||||
y: opts.y ? Number.parseFloat(opts.y) : undefined,
|
||||
width: opts.width ? Number.parseFloat(opts.width) : undefined,
|
||||
height: opts.height ? Number.parseFloat(opts.height) : undefined,
|
||||
};
|
||||
const params: Record<string, unknown> = {};
|
||||
if (opts.target) params.url = String(opts.target);
|
||||
if (
|
||||
Number.isFinite(placement.x) ||
|
||||
Number.isFinite(placement.y) ||
|
||||
Number.isFinite(placement.width) ||
|
||||
Number.isFinite(placement.height)
|
||||
) {
|
||||
params.placement = placement;
|
||||
}
|
||||
await invokeCanvas(opts, "canvas.present", params);
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log("canvas present ok");
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes canvas present failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
canvas
|
||||
.command("hide")
|
||||
.description("Hide the canvas")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
await invokeCanvas(opts, "canvas.hide", undefined);
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log("canvas hide ok");
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes canvas hide failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
canvas
|
||||
.command("navigate")
|
||||
.description("Navigate the canvas to a URL")
|
||||
.argument("<url>", "Target URL/path")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
|
||||
.action(async (url: string, opts: NodesRpcOpts) => {
|
||||
try {
|
||||
await invokeCanvas(opts, "canvas.navigate", { url });
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log("canvas navigate ok");
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes canvas navigate failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
canvas
|
||||
.command("eval")
|
||||
.description("Evaluate JavaScript in the canvas")
|
||||
.argument("[js]", "JavaScript to evaluate")
|
||||
.option("--js <code>", "JavaScript to evaluate")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
|
||||
.action(async (jsArg: string | undefined, opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const js = opts.js ?? jsArg;
|
||||
if (!js) throw new Error("missing --js or <js>");
|
||||
const raw = await invokeCanvas(opts, "canvas.eval", {
|
||||
javaScript: js,
|
||||
});
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(raw, null, 2));
|
||||
return;
|
||||
}
|
||||
const payload =
|
||||
typeof raw === "object" && raw !== null
|
||||
? (raw as { payload?: { result?: string } }).payload
|
||||
: undefined;
|
||||
if (payload?.result) {
|
||||
defaultRuntime.log(payload.result);
|
||||
} else {
|
||||
defaultRuntime.log("canvas eval ok");
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes canvas eval failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const a2ui = canvas
|
||||
.command("a2ui")
|
||||
.description("Render A2UI content on the canvas");
|
||||
|
||||
nodesCallOpts(
|
||||
a2ui
|
||||
.command("push")
|
||||
.description("Push A2UI JSONL to the canvas")
|
||||
.option("--jsonl <path>", "Path to JSONL payload")
|
||||
.option("--text <text>", "Render a quick A2UI text payload")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const hasJsonl = Boolean(opts.jsonl);
|
||||
const hasText = typeof opts.text === "string";
|
||||
if (hasJsonl === hasText) {
|
||||
throw new Error("provide exactly one of --jsonl or --text");
|
||||
}
|
||||
|
||||
const jsonl = hasText
|
||||
? buildA2UITextJsonl(String(opts.text ?? ""))
|
||||
: await fs.readFile(String(opts.jsonl), "utf8");
|
||||
const { version, messageCount } = validateA2UIJsonl(jsonl);
|
||||
if (version === "v0.9") {
|
||||
throw new Error(
|
||||
"Detected A2UI v0.9 JSONL (createSurface). Clawdbot currently supports v0.8 only.",
|
||||
);
|
||||
}
|
||||
await invokeCanvas(opts, "canvas.a2ui.pushJSONL", { jsonl });
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log(
|
||||
`canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes canvas a2ui push failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
a2ui
|
||||
.command("reset")
|
||||
.description("Reset A2UI renderer state")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
await invokeCanvas(opts, "canvas.a2ui.reset", undefined);
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log("canvas a2ui reset ok");
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes canvas a2ui reset failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
camera
|
||||
.command("list")
|
||||
|
||||
@@ -644,44 +644,6 @@ describe("cli program", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("runs canvas snapshot and prints MEDIA path", async () => {
|
||||
callGateway
|
||||
.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "mac-1",
|
||||
displayName: "Mac Node",
|
||||
platform: "macos",
|
||||
connected: true,
|
||||
caps: ["canvas"],
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
nodeId: "mac-1",
|
||||
command: "canvas.snapshot",
|
||||
payload: { format: "png", base64: "aGk=" },
|
||||
});
|
||||
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(["canvas", "snapshot", "--format", "png"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
const out = String(runtime.log.mock.calls[0]?.[0] ?? "");
|
||||
const mediaPath = out.replace(/^MEDIA:/, "").trim();
|
||||
expect(mediaPath).toMatch(/clawdbot-canvas-snapshot-.*\.png$/);
|
||||
|
||||
try {
|
||||
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi");
|
||||
} finally {
|
||||
await fs.unlink(mediaPath).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("fails nodes camera snap on invalid facing", async () => {
|
||||
callGateway.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
|
||||
@@ -14,7 +14,6 @@ import { sendCommand } from "../commands/send.js";
|
||||
import { sessionsCommand } from "../commands/sessions.js";
|
||||
import { setupCommand } from "../commands/setup.js";
|
||||
import { statusCommand } from "../commands/status.js";
|
||||
import { updateCommand } from "../commands/update.js";
|
||||
import {
|
||||
isNixMode,
|
||||
loadConfig,
|
||||
@@ -31,7 +30,6 @@ import { VERSION } from "../version.js";
|
||||
import { resolveWhatsAppAccount } from "../web/accounts.js";
|
||||
import { emitCliBanner, formatCliBannerLine } from "./banner.js";
|
||||
import { registerBrowserCli } from "./browser-cli.js";
|
||||
import { registerCanvasCli } from "./canvas-cli.js";
|
||||
import { hasExplicitOptions } from "./command-options.js";
|
||||
import { registerCronCli } from "./cron-cli.js";
|
||||
import { registerDaemonCli } from "./daemon-cli.js";
|
||||
@@ -45,7 +43,6 @@ import { registerNodesCli } from "./nodes-cli.js";
|
||||
import { registerPairingCli } from "./pairing-cli.js";
|
||||
import { forceFreePort } from "./ports.js";
|
||||
import { registerProvidersCli } from "./providers-cli.js";
|
||||
import { registerTelegramCli } from "./telegram-cli.js";
|
||||
import { registerTuiCli } from "./tui-cli.js";
|
||||
|
||||
export { forceFreePort };
|
||||
@@ -345,17 +342,6 @@ export function buildProgram() {
|
||||
});
|
||||
|
||||
program
|
||||
.command("update")
|
||||
.description("Audit and modernize the local configuration")
|
||||
.action(async () => {
|
||||
try {
|
||||
await updateCommand(defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("login")
|
||||
.description("Link your personal WhatsApp via QR (web provider)")
|
||||
@@ -633,7 +619,6 @@ Examples:
|
||||
}
|
||||
});
|
||||
|
||||
registerCanvasCli(program);
|
||||
registerDaemonCli(program);
|
||||
registerGatewayCli(program);
|
||||
registerModelsCli(program);
|
||||
@@ -645,7 +630,6 @@ Examples:
|
||||
registerHooksCli(program);
|
||||
registerPairingCli(program);
|
||||
registerProvidersCli(program);
|
||||
registerTelegramCli(program);
|
||||
|
||||
program
|
||||
.command("status")
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
approveTelegramPairingCode,
|
||||
listTelegramPairingRequests,
|
||||
} from "../telegram/pairing-store.js";
|
||||
import { sendMessageTelegram } from "../telegram/send.js";
|
||||
import { resolveTelegramToken } from "../telegram/token.js";
|
||||
|
||||
export function registerTelegramCli(program: Command) {
|
||||
const telegram = program
|
||||
.command("telegram")
|
||||
.description("Telegram helpers (pairing, allowlists)");
|
||||
|
||||
const pairing = telegram
|
||||
.command("pairing")
|
||||
.description("Secure DM pairing (approve inbound requests)");
|
||||
|
||||
pairing
|
||||
.command("list")
|
||||
.description("List pending Telegram pairing requests")
|
||||
.option("--json", "Print JSON", false)
|
||||
.action(async (opts) => {
|
||||
const requests = await listTelegramPairingRequests();
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ requests }, null, 2));
|
||||
return;
|
||||
}
|
||||
if (requests.length === 0) {
|
||||
console.log("No pending Telegram pairing requests.");
|
||||
return;
|
||||
}
|
||||
for (const r of requests) {
|
||||
const name = [r.firstName, r.lastName].filter(Boolean).join(" ").trim();
|
||||
const username = r.username ? `@${r.username}` : "";
|
||||
const who = [name, username].filter(Boolean).join(" ").trim();
|
||||
console.log(
|
||||
`${r.code} chatId=${r.chatId}${who ? ` ${who}` : ""} ${r.createdAt}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
pairing
|
||||
.command("approve")
|
||||
.description("Approve a pairing code and allow that chatId")
|
||||
.argument("<code>", "Pairing code (shown to the requester)")
|
||||
.option("--no-notify", "Do not notify the requester on Telegram")
|
||||
.action(async (code, opts) => {
|
||||
const approved = await approveTelegramPairingCode({ code: String(code) });
|
||||
if (!approved) {
|
||||
throw new Error(`No pending pairing request found for code: ${code}`);
|
||||
}
|
||||
|
||||
console.log(`Approved Telegram chatId ${approved.chatId}.`);
|
||||
|
||||
if (opts.notify === false) return;
|
||||
const cfg = loadConfig();
|
||||
const { token } = resolveTelegramToken(cfg);
|
||||
if (!token) {
|
||||
console.log(
|
||||
"Telegram token not configured; skipping requester notification.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
await sendMessageTelegram(
|
||||
approved.chatId,
|
||||
"✅ Clawdbot access approved. Send a message to start chatting.",
|
||||
{ token },
|
||||
).catch((err) => {
|
||||
console.log(`Failed to notify requester: ${String(err)}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user