1210 lines
51 KiB
JavaScript
1210 lines
51 KiB
JavaScript
import "./paths-BDd7_JUB.js";
|
|
import { c as resolveDefaultAgentId, n as resolveAgentConfig } from "./agent-scope-CrgUOY3f.js";
|
|
import { L as isRich, R as theme, c as defaultRuntime } from "./subsystem-46MXi6Ip.js";
|
|
import { _ as shortenHomePath, g as shortenHomeInString } from "./utils-Dg0Xbl6w.js";
|
|
import "./exec-CTo4hK94.js";
|
|
import "./model-selection-Cp1maz7B.js";
|
|
import "./github-copilot-token-VZsS4013.js";
|
|
import "./boolean-CE7i9tBR.js";
|
|
import "./env-DOcCob95.js";
|
|
import { h as parseDurationMs, r as loadConfig } from "./config-BAN8QfW5.js";
|
|
import "./manifest-registry-BFpLJJDB.js";
|
|
import "./message-channel-BD3kuDks.js";
|
|
import "./client-i9GLCujO.js";
|
|
import { i as randomIdempotencyKey } from "./call-BxBOsbXG.js";
|
|
import { t as formatDocsLink } from "./links-C9fyAH-V.js";
|
|
import { r as runCommandWithRuntime } from "./cli-utils-CukoNm8O.js";
|
|
import "./progress-uNDQDtGB.js";
|
|
import { o as maxAsk, p as resolveExecApprovalsFromFile, s as minSecurity } from "./exec-approvals-CK-Umdr3.js";
|
|
import { a as canvasSnapshotTempPath, c as parseCameraClipPayload, d as buildNodeShellCommand, i as parseEnvPairs, l as parseCameraSnapPayload, n as screenRecordTempPath, o as parseCanvasSnapshotPayload, r as writeScreenRecordToFile, s as cameraTempPath, t as parseScreenRecordPayload, u as writeBase64ToFile } from "./nodes-screen-Ln98EX_f.js";
|
|
import { t as parseTimeoutMs } from "./parse-timeout-CmBmmqZZ.js";
|
|
import { t as renderTable } from "./table-DuNe7Qes.js";
|
|
import { a as formatAge, c as parsePairingList, i as unauthorizedHintForMessage, n as nodesCallOpts, o as formatPermissions, r as resolveNodeId, s as parseNodeList, t as callGatewayCli } from "./rpc-CFQD0JNY.js";
|
|
import path from "node:path";
|
|
import fs from "node:fs/promises";
|
|
|
|
//#region src/cli/nodes-cli/cli-utils.ts
|
|
function getNodesTheme() {
|
|
const rich = isRich();
|
|
const color = (fn) => (value) => rich ? fn(value) : value;
|
|
return {
|
|
rich,
|
|
heading: color(theme.heading),
|
|
ok: color(theme.success),
|
|
warn: color(theme.warn),
|
|
muted: color(theme.muted),
|
|
error: color(theme.error)
|
|
};
|
|
}
|
|
function runNodesCommand(label, action) {
|
|
return runCommandWithRuntime(defaultRuntime, action, (err) => {
|
|
const message = String(err);
|
|
const { error, warn } = getNodesTheme();
|
|
defaultRuntime.error(error(`nodes ${label} failed: ${message}`));
|
|
const hint = unauthorizedHintForMessage(message);
|
|
if (hint) defaultRuntime.error(warn(hint));
|
|
defaultRuntime.exit(1);
|
|
});
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/cli/nodes-cli/register.camera.ts
|
|
const parseFacing = (value) => {
|
|
const v = String(value ?? "").trim().toLowerCase();
|
|
if (v === "front" || v === "back") return v;
|
|
throw new Error(`invalid facing: ${value} (expected front|back)`);
|
|
};
|
|
function registerNodesCameraCommands(nodes) {
|
|
const camera = nodes.command("camera").description("Capture camera media from a paired node");
|
|
nodesCallOpts(camera.command("list").description("List available cameras on a node").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").action(async (opts) => {
|
|
await runNodesCommand("camera list", async () => {
|
|
const raw = await callGatewayCli("node.invoke", opts, {
|
|
nodeId: await resolveNodeId(opts, String(opts.node ?? "")),
|
|
command: "camera.list",
|
|
params: {},
|
|
idempotencyKey: randomIdempotencyKey()
|
|
});
|
|
const res = typeof raw === "object" && raw !== null ? raw : {};
|
|
const payload = typeof res.payload === "object" && res.payload !== null ? res.payload : {};
|
|
const devices = Array.isArray(payload.devices) ? payload.devices : [];
|
|
if (opts.json) {
|
|
defaultRuntime.log(JSON.stringify(devices, null, 2));
|
|
return;
|
|
}
|
|
if (devices.length === 0) {
|
|
const { muted } = getNodesTheme();
|
|
defaultRuntime.log(muted("No cameras reported."));
|
|
return;
|
|
}
|
|
const { heading, muted } = getNodesTheme();
|
|
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
|
const rows = devices.map((device) => ({
|
|
Name: typeof device.name === "string" ? device.name : "Unknown Camera",
|
|
Position: typeof device.position === "string" ? device.position : muted("unspecified"),
|
|
ID: typeof device.id === "string" ? device.id : ""
|
|
}));
|
|
defaultRuntime.log(heading("Cameras"));
|
|
defaultRuntime.log(renderTable({
|
|
width: tableWidth,
|
|
columns: [
|
|
{
|
|
key: "Name",
|
|
header: "Name",
|
|
minWidth: 14,
|
|
flex: true
|
|
},
|
|
{
|
|
key: "Position",
|
|
header: "Position",
|
|
minWidth: 10
|
|
},
|
|
{
|
|
key: "ID",
|
|
header: "ID",
|
|
minWidth: 10,
|
|
flex: true
|
|
}
|
|
],
|
|
rows
|
|
}).trimEnd());
|
|
});
|
|
}), { timeoutMs: 6e4 });
|
|
nodesCallOpts(camera.command("snap").description("Capture a photo from a node camera (prints MEDIA:<path>)").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").option("--facing <front|back|both>", "Camera facing", "both").option("--device-id <id>", "Camera device id (from nodes camera list)").option("--max-width <px>", "Max width in px (optional)").option("--quality <0-1>", "JPEG quality (default 0.9)").option("--delay-ms <ms>", "Delay before capture in ms (macOS default 2000)").option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 20000)", "20000").action(async (opts) => {
|
|
await runNodesCommand("camera snap", async () => {
|
|
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
|
const facingOpt = String(opts.facing ?? "both").trim().toLowerCase();
|
|
const facings = facingOpt === "both" ? ["front", "back"] : facingOpt === "front" || facingOpt === "back" ? [facingOpt] : (() => {
|
|
throw new Error(`invalid facing: ${String(opts.facing)} (expected front|back|both)`);
|
|
})();
|
|
const maxWidth = opts.maxWidth ? Number.parseInt(String(opts.maxWidth), 10) : void 0;
|
|
const quality = opts.quality ? Number.parseFloat(String(opts.quality)) : void 0;
|
|
const delayMs = opts.delayMs ? Number.parseInt(String(opts.delayMs), 10) : void 0;
|
|
const deviceId = opts.deviceId ? String(opts.deviceId).trim() : void 0;
|
|
const timeoutMs = opts.invokeTimeout ? Number.parseInt(String(opts.invokeTimeout), 10) : void 0;
|
|
const results = [];
|
|
for (const facing of facings) {
|
|
const invokeParams = {
|
|
nodeId,
|
|
command: "camera.snap",
|
|
params: {
|
|
facing,
|
|
maxWidth: Number.isFinite(maxWidth) ? maxWidth : void 0,
|
|
quality: Number.isFinite(quality) ? quality : void 0,
|
|
format: "jpg",
|
|
delayMs: Number.isFinite(delayMs) ? delayMs : void 0,
|
|
deviceId: deviceId || void 0
|
|
},
|
|
idempotencyKey: randomIdempotencyKey()
|
|
};
|
|
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) invokeParams.timeoutMs = timeoutMs;
|
|
const raw = await callGatewayCli("node.invoke", opts, invokeParams);
|
|
const payload = parseCameraSnapPayload((typeof raw === "object" && raw !== null ? raw : {}).payload);
|
|
const filePath = cameraTempPath({
|
|
kind: "snap",
|
|
facing,
|
|
ext: payload.format === "jpeg" ? "jpg" : payload.format
|
|
});
|
|
await writeBase64ToFile(filePath, payload.base64);
|
|
results.push({
|
|
facing,
|
|
path: filePath,
|
|
width: payload.width,
|
|
height: payload.height
|
|
});
|
|
}
|
|
if (opts.json) {
|
|
defaultRuntime.log(JSON.stringify({ files: results }, null, 2));
|
|
return;
|
|
}
|
|
defaultRuntime.log(results.map((r) => `MEDIA:${shortenHomePath(r.path)}`).join("\n"));
|
|
});
|
|
}), { timeoutMs: 6e4 });
|
|
nodesCallOpts(camera.command("clip").description("Capture a short video clip from a node camera (prints MEDIA:<path>)").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").option("--facing <front|back>", "Camera facing", "front").option("--device-id <id>", "Camera device id (from nodes camera list)").option("--duration <ms|10s|1m>", "Duration (default 3000ms; supports ms/s/m, e.g. 10s)", "3000").option("--no-audio", "Disable audio capture").option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 90000)", "90000").action(async (opts) => {
|
|
await runNodesCommand("camera clip", async () => {
|
|
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
|
const facing = parseFacing(String(opts.facing ?? "front"));
|
|
const durationMs = parseDurationMs(String(opts.duration ?? "3000"));
|
|
const includeAudio = opts.audio !== false;
|
|
const timeoutMs = opts.invokeTimeout ? Number.parseInt(String(opts.invokeTimeout), 10) : void 0;
|
|
const deviceId = opts.deviceId ? String(opts.deviceId).trim() : void 0;
|
|
const invokeParams = {
|
|
nodeId,
|
|
command: "camera.clip",
|
|
params: {
|
|
facing,
|
|
durationMs: Number.isFinite(durationMs) ? durationMs : void 0,
|
|
includeAudio,
|
|
format: "mp4",
|
|
deviceId: deviceId || void 0
|
|
},
|
|
idempotencyKey: randomIdempotencyKey()
|
|
};
|
|
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) invokeParams.timeoutMs = timeoutMs;
|
|
const raw = await callGatewayCli("node.invoke", opts, invokeParams);
|
|
const payload = parseCameraClipPayload((typeof raw === "object" && raw !== null ? raw : {}).payload);
|
|
const filePath = cameraTempPath({
|
|
kind: "clip",
|
|
facing,
|
|
ext: payload.format
|
|
});
|
|
await writeBase64ToFile(filePath, payload.base64);
|
|
if (opts.json) {
|
|
defaultRuntime.log(JSON.stringify({ file: {
|
|
facing,
|
|
path: filePath,
|
|
durationMs: payload.durationMs,
|
|
hasAudio: payload.hasAudio
|
|
} }, null, 2));
|
|
return;
|
|
}
|
|
defaultRuntime.log(`MEDIA:${shortenHomePath(filePath)}`);
|
|
});
|
|
}), { timeoutMs: 9e4 });
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/cli/nodes-cli/a2ui-jsonl.ts
|
|
const A2UI_ACTION_KEYS = [
|
|
"beginRendering",
|
|
"surfaceUpdate",
|
|
"dataModelUpdate",
|
|
"deleteSurface",
|
|
"createSurface"
|
|
];
|
|
function buildA2UITextJsonl(text) {
|
|
const surfaceId = "main";
|
|
const rootId = "root";
|
|
const textId = "text";
|
|
return [{ surfaceUpdate: {
|
|
surfaceId,
|
|
components: [{
|
|
id: rootId,
|
|
component: { Column: { children: { explicitList: [textId] } } }
|
|
}, {
|
|
id: textId,
|
|
component: { Text: {
|
|
text: { literalString: text },
|
|
usageHint: "body"
|
|
} }
|
|
}]
|
|
} }, { beginRendering: {
|
|
surfaceId,
|
|
root: rootId
|
|
} }].map((payload) => JSON.stringify(payload)).join("\n");
|
|
}
|
|
function validateA2UIJsonl(jsonl) {
|
|
const lines = jsonl.split(/\r?\n/);
|
|
const errors = [];
|
|
let sawV08 = false;
|
|
let sawV09 = false;
|
|
let messageCount = 0;
|
|
lines.forEach((line, idx) => {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) return;
|
|
messageCount += 1;
|
|
let obj;
|
|
try {
|
|
obj = JSON.parse(trimmed);
|
|
} 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;
|
|
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- ")}`);
|
|
return {
|
|
version: sawV09 ? "v0.9" : "v0.8",
|
|
messageCount
|
|
};
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/cli/nodes-cli/register.canvas.ts
|
|
async function invokeCanvas(opts, command, params) {
|
|
const invokeParams = {
|
|
nodeId: await resolveNodeId(opts, String(opts.node ?? "")),
|
|
command,
|
|
params,
|
|
idempotencyKey: randomIdempotencyKey()
|
|
};
|
|
const timeoutMs = parseTimeoutMs(opts.invokeTimeout);
|
|
if (typeof timeoutMs === "number") invokeParams.timeoutMs = timeoutMs;
|
|
return await callGatewayCli("node.invoke", opts, invokeParams);
|
|
}
|
|
function registerNodesCanvasCommands(nodes) {
|
|
const canvas = nodes.command("canvas").description("Capture or render canvas content from a paired node");
|
|
nodesCallOpts(canvas.command("snapshot").description("Capture a canvas snapshot (prints MEDIA:<path>)").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").option("--format <png|jpg|jpeg>", "Image format", "jpg").option("--max-width <px>", "Max width in px (optional)").option("--quality <0-1>", "JPEG quality (optional)").option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 20000)", "20000").action(async (opts) => {
|
|
await runNodesCommand("canvas snapshot", async () => {
|
|
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
|
const formatOpt = String(opts.format ?? "jpg").trim().toLowerCase();
|
|
const formatForParams = formatOpt === "jpg" ? "jpeg" : formatOpt === "jpeg" ? "jpeg" : "png";
|
|
if (formatForParams !== "png" && formatForParams !== "jpeg") throw new Error(`invalid format: ${String(opts.format)} (expected png|jpg|jpeg)`);
|
|
const maxWidth = opts.maxWidth ? Number.parseInt(String(opts.maxWidth), 10) : void 0;
|
|
const quality = opts.quality ? Number.parseFloat(String(opts.quality)) : void 0;
|
|
const timeoutMs = opts.invokeTimeout ? Number.parseInt(String(opts.invokeTimeout), 10) : void 0;
|
|
const invokeParams = {
|
|
nodeId,
|
|
command: "canvas.snapshot",
|
|
params: {
|
|
format: formatForParams,
|
|
maxWidth: Number.isFinite(maxWidth) ? maxWidth : void 0,
|
|
quality: Number.isFinite(quality) ? quality : void 0
|
|
},
|
|
idempotencyKey: randomIdempotencyKey()
|
|
};
|
|
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) invokeParams.timeoutMs = timeoutMs;
|
|
const raw = await callGatewayCli("node.invoke", opts, invokeParams);
|
|
const payload = parseCanvasSnapshotPayload((typeof raw === "object" && raw !== null ? raw : {}).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,
|
|
format: payload.format
|
|
} }, null, 2));
|
|
return;
|
|
}
|
|
defaultRuntime.log(`MEDIA:${shortenHomePath(filePath)}`);
|
|
});
|
|
}), { timeoutMs: 6e4 });
|
|
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) => {
|
|
await runNodesCommand("canvas present", async () => {
|
|
const placement = {
|
|
x: opts.x ? Number.parseFloat(opts.x) : void 0,
|
|
y: opts.y ? Number.parseFloat(opts.y) : void 0,
|
|
width: opts.width ? Number.parseFloat(opts.width) : void 0,
|
|
height: opts.height ? Number.parseFloat(opts.height) : void 0
|
|
};
|
|
const params = {};
|
|
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) {
|
|
const { ok } = getNodesTheme();
|
|
defaultRuntime.log(ok("canvas present ok"));
|
|
}
|
|
});
|
|
}));
|
|
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) => {
|
|
await runNodesCommand("canvas hide", async () => {
|
|
await invokeCanvas(opts, "canvas.hide", void 0);
|
|
if (!opts.json) {
|
|
const { ok } = getNodesTheme();
|
|
defaultRuntime.log(ok("canvas hide ok"));
|
|
}
|
|
});
|
|
}));
|
|
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, opts) => {
|
|
await runNodesCommand("canvas navigate", async () => {
|
|
await invokeCanvas(opts, "canvas.navigate", { url });
|
|
if (!opts.json) {
|
|
const { ok } = getNodesTheme();
|
|
defaultRuntime.log(ok("canvas navigate ok"));
|
|
}
|
|
});
|
|
}));
|
|
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, opts) => {
|
|
await runNodesCommand("canvas eval", async () => {
|
|
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.payload : void 0;
|
|
if (payload?.result) defaultRuntime.log(payload.result);
|
|
else {
|
|
const { ok } = getNodesTheme();
|
|
defaultRuntime.log(ok("canvas eval ok"));
|
|
}
|
|
});
|
|
}));
|
|
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) => {
|
|
await runNodesCommand("canvas a2ui push", async () => {
|
|
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). OpenClaw currently supports v0.8 only.");
|
|
await invokeCanvas(opts, "canvas.a2ui.pushJSONL", { jsonl });
|
|
if (!opts.json) {
|
|
const { ok } = getNodesTheme();
|
|
defaultRuntime.log(ok(`canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`));
|
|
}
|
|
});
|
|
}));
|
|
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) => {
|
|
await runNodesCommand("canvas a2ui reset", async () => {
|
|
await invokeCanvas(opts, "canvas.a2ui.reset", void 0);
|
|
if (!opts.json) {
|
|
const { ok } = getNodesTheme();
|
|
defaultRuntime.log(ok("canvas a2ui reset ok"));
|
|
}
|
|
});
|
|
}));
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/cli/nodes-cli/register.invoke.ts
|
|
function normalizeExecSecurity(value) {
|
|
const normalized = value?.trim().toLowerCase();
|
|
if (normalized === "deny" || normalized === "allowlist" || normalized === "full") return normalized;
|
|
return null;
|
|
}
|
|
function normalizeExecAsk(value) {
|
|
const normalized = value?.trim().toLowerCase();
|
|
if (normalized === "off" || normalized === "on-miss" || normalized === "always") return normalized;
|
|
return null;
|
|
}
|
|
function mergePathPrepend(existing, prepend) {
|
|
if (prepend.length === 0) return existing;
|
|
const partsExisting = (existing ?? "").split(path.delimiter).map((part) => part.trim()).filter(Boolean);
|
|
const merged = [];
|
|
const seen = /* @__PURE__ */ new Set();
|
|
for (const part of [...prepend, ...partsExisting]) {
|
|
if (seen.has(part)) continue;
|
|
seen.add(part);
|
|
merged.push(part);
|
|
}
|
|
return merged.join(path.delimiter);
|
|
}
|
|
function applyPathPrepend(env, prepend, options) {
|
|
if (!Array.isArray(prepend) || prepend.length === 0) return;
|
|
if (options?.requireExisting && !env.PATH) return;
|
|
const merged = mergePathPrepend(env.PATH, prepend);
|
|
if (merged) env.PATH = merged;
|
|
}
|
|
function resolveExecDefaults(cfg, agentId) {
|
|
const globalExec = cfg?.tools?.exec;
|
|
if (!agentId) return globalExec ? {
|
|
security: globalExec.security,
|
|
ask: globalExec.ask,
|
|
node: globalExec.node,
|
|
pathPrepend: globalExec.pathPrepend,
|
|
safeBins: globalExec.safeBins
|
|
} : void 0;
|
|
const agentExec = resolveAgentConfig(cfg, agentId)?.tools?.exec;
|
|
return {
|
|
security: agentExec?.security ?? globalExec?.security,
|
|
ask: agentExec?.ask ?? globalExec?.ask,
|
|
node: agentExec?.node ?? globalExec?.node,
|
|
pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend,
|
|
safeBins: agentExec?.safeBins ?? globalExec?.safeBins
|
|
};
|
|
}
|
|
async function resolveNodePlatform(opts, nodeId) {
|
|
try {
|
|
const match = parseNodeList(await callGatewayCli("node.list", opts, {})).find((node) => node.nodeId === nodeId);
|
|
return typeof match?.platform === "string" ? match.platform : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
function registerNodesInvokeCommands(nodes) {
|
|
nodesCallOpts(nodes.command("invoke").description("Invoke a command on a paired node").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").requiredOption("--command <command>", "Command (e.g. canvas.eval)").option("--params <json>", "JSON object string for params", "{}").option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 15000)", "15000").option("--idempotency-key <key>", "Idempotency key (optional)").action(async (opts) => {
|
|
await runNodesCommand("invoke", async () => {
|
|
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
|
const command = String(opts.command ?? "").trim();
|
|
if (!nodeId || !command) {
|
|
const { error } = getNodesTheme();
|
|
defaultRuntime.error(error("--node and --command required"));
|
|
defaultRuntime.exit(1);
|
|
return;
|
|
}
|
|
const params = JSON.parse(String(opts.params ?? "{}"));
|
|
const timeoutMs = opts.invokeTimeout ? Number.parseInt(String(opts.invokeTimeout), 10) : void 0;
|
|
const invokeParams = {
|
|
nodeId,
|
|
command,
|
|
params,
|
|
idempotencyKey: String(opts.idempotencyKey ?? randomIdempotencyKey())
|
|
};
|
|
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) invokeParams.timeoutMs = timeoutMs;
|
|
const result = await callGatewayCli("node.invoke", opts, invokeParams);
|
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
|
});
|
|
}), { timeoutMs: 3e4 });
|
|
nodesCallOpts(nodes.command("run").description("Run a shell command on a node (mac only)").option("--node <idOrNameOrIp>", "Node id, name, or IP").option("--cwd <path>", "Working directory").option("--env <key=val>", "Environment override (repeatable)", (value, prev = []) => [...prev, value]).option("--raw <command>", "Run a raw shell command string (sh -lc / cmd.exe /c)").option("--agent <id>", "Agent id (default: configured default agent)").option("--ask <mode>", "Exec ask mode (off|on-miss|always)").option("--security <mode>", "Exec security mode (deny|allowlist|full)").option("--command-timeout <ms>", "Command timeout (ms)").option("--needs-screen-recording", "Require screen recording permission").option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 30000)", "30000").argument("[command...]", "Command and args").action(async (command, opts) => {
|
|
await runNodesCommand("run", async () => {
|
|
const cfg = loadConfig();
|
|
const agentId = opts.agent?.trim() || resolveDefaultAgentId(cfg);
|
|
const execDefaults = resolveExecDefaults(cfg, agentId);
|
|
const raw = typeof opts.raw === "string" ? opts.raw.trim() : "";
|
|
if (raw && Array.isArray(command) && command.length > 0) throw new Error("use --raw or argv, not both");
|
|
if (!raw && (!Array.isArray(command) || command.length === 0)) throw new Error("command required");
|
|
const nodeQuery = String(opts.node ?? "").trim() || execDefaults?.node?.trim() || "";
|
|
if (!nodeQuery) throw new Error("node required (set --node or tools.exec.node)");
|
|
const nodeId = await resolveNodeId(opts, nodeQuery);
|
|
const env = parseEnvPairs(opts.env);
|
|
const timeoutMs = parseTimeoutMs(opts.commandTimeout);
|
|
const invokeTimeout = parseTimeoutMs(opts.invokeTimeout);
|
|
let argv = Array.isArray(command) ? command : [];
|
|
let rawCommand;
|
|
if (raw) {
|
|
rawCommand = raw;
|
|
const platform = await resolveNodePlatform(opts, nodeId);
|
|
argv = buildNodeShellCommand(rawCommand, platform ?? void 0);
|
|
}
|
|
const nodeEnv = env ? { ...env } : void 0;
|
|
if (nodeEnv) applyPathPrepend(nodeEnv, execDefaults?.pathPrepend, { requireExisting: true });
|
|
let approvedByAsk = false;
|
|
let approvalDecision = null;
|
|
const configuredSecurity = normalizeExecSecurity(execDefaults?.security) ?? "allowlist";
|
|
const requestedSecurity = normalizeExecSecurity(opts.security);
|
|
if (opts.security && !requestedSecurity) throw new Error("invalid --security (use deny|allowlist|full)");
|
|
const configuredAsk = normalizeExecAsk(execDefaults?.ask) ?? "on-miss";
|
|
const requestedAsk = normalizeExecAsk(opts.ask);
|
|
if (opts.ask && !requestedAsk) throw new Error("invalid --ask (use off|on-miss|always)");
|
|
const security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
|
|
const ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);
|
|
const approvalsSnapshot = await callGatewayCli("exec.approvals.node.get", opts, { nodeId });
|
|
const approvalsFile = approvalsSnapshot && typeof approvalsSnapshot === "object" ? approvalsSnapshot.file : void 0;
|
|
if (!approvalsFile || typeof approvalsFile !== "object") throw new Error("exec approvals unavailable");
|
|
const approvals = resolveExecApprovalsFromFile({
|
|
file: approvalsFile,
|
|
agentId,
|
|
overrides: {
|
|
security,
|
|
ask
|
|
}
|
|
});
|
|
const hostSecurity = minSecurity(security, approvals.agent.security);
|
|
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
|
const askFallback = approvals.agent.askFallback;
|
|
if (hostSecurity === "deny") throw new Error("exec denied: host=node security=deny");
|
|
if (hostAsk === "always" || hostAsk === "on-miss") {
|
|
const decisionResult = await callGatewayCli("exec.approval.request", opts, {
|
|
command: rawCommand ?? argv.join(" "),
|
|
cwd: opts.cwd,
|
|
host: "node",
|
|
security: hostSecurity,
|
|
ask: hostAsk,
|
|
agentId,
|
|
resolvedPath: void 0,
|
|
sessionKey: void 0,
|
|
timeoutMs: 12e4
|
|
});
|
|
const decision = decisionResult && typeof decisionResult === "object" ? decisionResult.decision ?? null : null;
|
|
if (decision === "deny") throw new Error("exec denied: user denied");
|
|
if (!decision) if (askFallback === "full") {
|
|
approvedByAsk = true;
|
|
approvalDecision = "allow-once";
|
|
} else if (askFallback === "allowlist") {} else throw new Error("exec denied: approval required (approval UI not available)");
|
|
if (decision === "allow-once") {
|
|
approvedByAsk = true;
|
|
approvalDecision = "allow-once";
|
|
}
|
|
if (decision === "allow-always") {
|
|
approvedByAsk = true;
|
|
approvalDecision = "allow-always";
|
|
}
|
|
}
|
|
const invokeParams = {
|
|
nodeId,
|
|
command: "system.run",
|
|
params: {
|
|
command: argv,
|
|
cwd: opts.cwd,
|
|
env: nodeEnv,
|
|
timeoutMs,
|
|
needsScreenRecording: opts.needsScreenRecording === true
|
|
},
|
|
idempotencyKey: String(opts.idempotencyKey ?? randomIdempotencyKey())
|
|
};
|
|
if (agentId) invokeParams.params.agentId = agentId;
|
|
if (rawCommand) invokeParams.params.rawCommand = rawCommand;
|
|
invokeParams.params.approved = approvedByAsk;
|
|
if (approvalDecision) invokeParams.params.approvalDecision = approvalDecision;
|
|
if (invokeTimeout !== void 0) invokeParams.timeoutMs = invokeTimeout;
|
|
const result = await callGatewayCli("node.invoke", opts, invokeParams);
|
|
if (opts.json) {
|
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
|
return;
|
|
}
|
|
const payload = typeof result === "object" && result !== null ? result.payload : void 0;
|
|
const stdout = typeof payload?.stdout === "string" ? payload.stdout : "";
|
|
const stderr = typeof payload?.stderr === "string" ? payload.stderr : "";
|
|
const exitCode = typeof payload?.exitCode === "number" ? payload.exitCode : null;
|
|
const timedOut = payload?.timedOut === true;
|
|
const success = payload?.success === true;
|
|
if (stdout) process.stdout.write(stdout);
|
|
if (stderr) process.stderr.write(stderr);
|
|
if (timedOut) {
|
|
const { error } = getNodesTheme();
|
|
defaultRuntime.error(error("run timed out"));
|
|
defaultRuntime.exit(1);
|
|
return;
|
|
}
|
|
if (exitCode !== null && exitCode !== 0) {
|
|
const hint = unauthorizedHintForMessage(`${stderr}\n${stdout}`);
|
|
if (hint) {
|
|
const { warn } = getNodesTheme();
|
|
defaultRuntime.error(warn(hint));
|
|
}
|
|
}
|
|
if (exitCode !== null && exitCode !== 0 && !success) {
|
|
const { error } = getNodesTheme();
|
|
defaultRuntime.error(error(`run exit ${exitCode}`));
|
|
defaultRuntime.exit(1);
|
|
return;
|
|
}
|
|
});
|
|
}), { timeoutMs: 35e3 });
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/cli/nodes-cli/register.location.ts
|
|
function registerNodesLocationCommands(nodes) {
|
|
nodesCallOpts(nodes.command("location").description("Fetch location from a paired node").command("get").description("Fetch the current location from a node").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").option("--max-age <ms>", "Use cached location newer than this (ms)").option("--accuracy <coarse|balanced|precise>", "Desired accuracy (default: balanced/precise depending on node setting)").option("--location-timeout <ms>", "Location fix timeout (ms)", "10000").option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 20000)", "20000").action(async (opts) => {
|
|
await runNodesCommand("location get", async () => {
|
|
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
|
const maxAgeMs = opts.maxAge ? Number.parseInt(String(opts.maxAge), 10) : void 0;
|
|
const desiredAccuracyRaw = typeof opts.accuracy === "string" ? opts.accuracy.trim().toLowerCase() : void 0;
|
|
const desiredAccuracy = desiredAccuracyRaw === "coarse" || desiredAccuracyRaw === "balanced" || desiredAccuracyRaw === "precise" ? desiredAccuracyRaw : void 0;
|
|
const timeoutMs = opts.locationTimeout ? Number.parseInt(String(opts.locationTimeout), 10) : void 0;
|
|
const invokeTimeoutMs = opts.invokeTimeout ? Number.parseInt(String(opts.invokeTimeout), 10) : void 0;
|
|
const invokeParams = {
|
|
nodeId,
|
|
command: "location.get",
|
|
params: {
|
|
maxAgeMs: Number.isFinite(maxAgeMs) ? maxAgeMs : void 0,
|
|
desiredAccuracy,
|
|
timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : void 0
|
|
},
|
|
idempotencyKey: randomIdempotencyKey()
|
|
};
|
|
if (typeof invokeTimeoutMs === "number" && Number.isFinite(invokeTimeoutMs)) invokeParams.timeoutMs = invokeTimeoutMs;
|
|
const raw = await callGatewayCli("node.invoke", opts, invokeParams);
|
|
const res = typeof raw === "object" && raw !== null ? raw : {};
|
|
const payload = res.payload && typeof res.payload === "object" ? res.payload : {};
|
|
if (opts.json) {
|
|
defaultRuntime.log(JSON.stringify(payload, null, 2));
|
|
return;
|
|
}
|
|
const lat = payload.lat;
|
|
const lon = payload.lon;
|
|
const acc = payload.accuracyMeters;
|
|
if (typeof lat === "number" && typeof lon === "number") {
|
|
const accText = typeof acc === "number" ? ` ±${acc.toFixed(1)}m` : "";
|
|
defaultRuntime.log(`${lat},${lon}${accText}`);
|
|
return;
|
|
}
|
|
defaultRuntime.log(JSON.stringify(payload));
|
|
});
|
|
}), { timeoutMs: 3e4 });
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/cli/nodes-cli/register.notify.ts
|
|
function registerNodesNotifyCommand(nodes) {
|
|
nodesCallOpts(nodes.command("notify").description("Send a local notification on a node (mac only)").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").option("--title <text>", "Notification title").option("--body <text>", "Notification body").option("--sound <name>", "Notification sound").option("--priority <passive|active|timeSensitive>", "Notification priority").option("--delivery <system|overlay|auto>", "Delivery mode", "system").option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 15000)", "15000").action(async (opts) => {
|
|
await runNodesCommand("notify", async () => {
|
|
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
|
const title = String(opts.title ?? "").trim();
|
|
const body = String(opts.body ?? "").trim();
|
|
if (!title && !body) throw new Error("missing --title or --body");
|
|
const invokeTimeout = opts.invokeTimeout ? Number.parseInt(String(opts.invokeTimeout), 10) : void 0;
|
|
const invokeParams = {
|
|
nodeId,
|
|
command: "system.notify",
|
|
params: {
|
|
title,
|
|
body,
|
|
sound: opts.sound,
|
|
priority: opts.priority,
|
|
delivery: opts.delivery
|
|
},
|
|
idempotencyKey: String(opts.idempotencyKey ?? randomIdempotencyKey())
|
|
};
|
|
if (typeof invokeTimeout === "number" && Number.isFinite(invokeTimeout)) invokeParams.timeoutMs = invokeTimeout;
|
|
const result = await callGatewayCli("node.invoke", opts, invokeParams);
|
|
if (opts.json) {
|
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
|
return;
|
|
}
|
|
const { ok } = getNodesTheme();
|
|
defaultRuntime.log(ok("notify ok"));
|
|
});
|
|
}));
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/cli/nodes-cli/register.pairing.ts
|
|
function registerNodesPairingCommands(nodes) {
|
|
nodesCallOpts(nodes.command("pending").description("List pending pairing requests").action(async (opts) => {
|
|
await runNodesCommand("pending", async () => {
|
|
const { pending } = parsePairingList(await callGatewayCli("node.pair.list", opts, {}));
|
|
if (opts.json) {
|
|
defaultRuntime.log(JSON.stringify(pending, null, 2));
|
|
return;
|
|
}
|
|
if (pending.length === 0) {
|
|
const { muted } = getNodesTheme();
|
|
defaultRuntime.log(muted("No pending pairing requests."));
|
|
return;
|
|
}
|
|
const { heading, warn, muted } = getNodesTheme();
|
|
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
|
const now = Date.now();
|
|
const rows = pending.map((r) => ({
|
|
Request: r.requestId,
|
|
Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId,
|
|
IP: r.remoteIp ?? "",
|
|
Requested: typeof r.ts === "number" ? `${formatAge(Math.max(0, now - r.ts))} ago` : muted("unknown"),
|
|
Repair: r.isRepair ? warn("yes") : ""
|
|
}));
|
|
defaultRuntime.log(heading("Pending"));
|
|
defaultRuntime.log(renderTable({
|
|
width: tableWidth,
|
|
columns: [
|
|
{
|
|
key: "Request",
|
|
header: "Request",
|
|
minWidth: 8
|
|
},
|
|
{
|
|
key: "Node",
|
|
header: "Node",
|
|
minWidth: 14,
|
|
flex: true
|
|
},
|
|
{
|
|
key: "IP",
|
|
header: "IP",
|
|
minWidth: 10
|
|
},
|
|
{
|
|
key: "Requested",
|
|
header: "Requested",
|
|
minWidth: 12
|
|
},
|
|
{
|
|
key: "Repair",
|
|
header: "Repair",
|
|
minWidth: 6
|
|
}
|
|
],
|
|
rows
|
|
}).trimEnd());
|
|
});
|
|
}));
|
|
nodesCallOpts(nodes.command("approve").description("Approve a pending pairing request").argument("<requestId>", "Pending request id").action(async (requestId, opts) => {
|
|
await runNodesCommand("approve", async () => {
|
|
const result = await callGatewayCli("node.pair.approve", opts, { requestId });
|
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
|
});
|
|
}));
|
|
nodesCallOpts(nodes.command("reject").description("Reject a pending pairing request").argument("<requestId>", "Pending request id").action(async (requestId, opts) => {
|
|
await runNodesCommand("reject", async () => {
|
|
const result = await callGatewayCli("node.pair.reject", opts, { requestId });
|
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
|
});
|
|
}));
|
|
nodesCallOpts(nodes.command("rename").description("Rename a paired node (display name override)").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").requiredOption("--name <displayName>", "New display name").action(async (opts) => {
|
|
await runNodesCommand("rename", async () => {
|
|
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
|
const name = String(opts.name ?? "").trim();
|
|
if (!nodeId || !name) {
|
|
defaultRuntime.error("--node and --name required");
|
|
defaultRuntime.exit(1);
|
|
return;
|
|
}
|
|
const result = await callGatewayCli("node.rename", opts, {
|
|
nodeId,
|
|
displayName: name
|
|
});
|
|
if (opts.json) {
|
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
|
return;
|
|
}
|
|
const { ok } = getNodesTheme();
|
|
defaultRuntime.log(ok(`node rename ok: ${nodeId} -> ${name}`));
|
|
});
|
|
}));
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/cli/nodes-cli/register.screen.ts
|
|
function registerNodesScreenCommands(nodes) {
|
|
nodesCallOpts(nodes.command("screen").description("Capture screen recordings from a paired node").command("record").description("Capture a short screen recording from a node (prints MEDIA:<path>)").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").option("--screen <index>", "Screen index (0 = primary)", "0").option("--duration <ms|10s>", "Clip duration (ms or 10s)", "10000").option("--fps <fps>", "Frames per second", "10").option("--no-audio", "Disable microphone audio capture").option("--out <path>", "Output path").option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 120000)", "120000").action(async (opts) => {
|
|
await runNodesCommand("screen record", async () => {
|
|
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
|
const durationMs = parseDurationMs(opts.duration ?? "");
|
|
const screenIndex = Number.parseInt(String(opts.screen ?? "0"), 10);
|
|
const fps = Number.parseFloat(String(opts.fps ?? "10"));
|
|
const timeoutMs = opts.invokeTimeout ? Number.parseInt(String(opts.invokeTimeout), 10) : void 0;
|
|
const invokeParams = {
|
|
nodeId,
|
|
command: "screen.record",
|
|
params: {
|
|
durationMs: Number.isFinite(durationMs) ? durationMs : void 0,
|
|
screenIndex: Number.isFinite(screenIndex) ? screenIndex : void 0,
|
|
fps: Number.isFinite(fps) ? fps : void 0,
|
|
format: "mp4",
|
|
includeAudio: opts.audio !== false
|
|
},
|
|
idempotencyKey: randomIdempotencyKey()
|
|
};
|
|
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) invokeParams.timeoutMs = timeoutMs;
|
|
const raw = await callGatewayCli("node.invoke", opts, invokeParams);
|
|
const parsed = parseScreenRecordPayload((typeof raw === "object" && raw !== null ? raw : {}).payload);
|
|
const written = await writeScreenRecordToFile(opts.out ?? screenRecordTempPath({ ext: parsed.format || "mp4" }), parsed.base64);
|
|
if (opts.json) {
|
|
defaultRuntime.log(JSON.stringify({ file: {
|
|
path: written.path,
|
|
durationMs: parsed.durationMs,
|
|
fps: parsed.fps,
|
|
screenIndex: parsed.screenIndex,
|
|
hasAudio: parsed.hasAudio
|
|
} }, null, 2));
|
|
return;
|
|
}
|
|
defaultRuntime.log(`MEDIA:${shortenHomePath(written.path)}`);
|
|
});
|
|
}), { timeoutMs: 18e4 });
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/cli/nodes-cli/register.status.ts
|
|
function formatVersionLabel(raw) {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return raw;
|
|
if (trimmed.toLowerCase().startsWith("v")) return trimmed;
|
|
return /^\d/.test(trimmed) ? `v${trimmed}` : trimmed;
|
|
}
|
|
function resolveNodeVersions(node) {
|
|
const core = node.coreVersion?.trim() || void 0;
|
|
const ui = node.uiVersion?.trim() || void 0;
|
|
if (core || ui) return {
|
|
core,
|
|
ui
|
|
};
|
|
const legacy = node.version?.trim();
|
|
if (!legacy) return {
|
|
core: void 0,
|
|
ui: void 0
|
|
};
|
|
const platform = node.platform?.trim().toLowerCase() ?? "";
|
|
return platform === "darwin" || platform === "linux" || platform === "win32" || platform === "windows" ? {
|
|
core: legacy,
|
|
ui: void 0
|
|
} : {
|
|
core: void 0,
|
|
ui: legacy
|
|
};
|
|
}
|
|
function formatNodeVersions(node) {
|
|
const { core, ui } = resolveNodeVersions(node);
|
|
const parts = [];
|
|
if (core) parts.push(`core ${formatVersionLabel(core)}`);
|
|
if (ui) parts.push(`ui ${formatVersionLabel(ui)}`);
|
|
return parts.length > 0 ? parts.join(" · ") : null;
|
|
}
|
|
function formatPathEnv(raw) {
|
|
if (typeof raw !== "string") return null;
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return null;
|
|
const parts = trimmed.split(":").filter(Boolean);
|
|
return shortenHomeInString(parts.length <= 3 ? trimmed : `${parts.slice(0, 2).join(":")}:…:${parts.slice(-1)[0]}`);
|
|
}
|
|
function parseSinceMs(raw, label) {
|
|
if (raw === void 0 || raw === null) return;
|
|
const value = typeof raw === "string" ? raw.trim() : typeof raw === "number" ? String(raw).trim() : null;
|
|
if (value === null) {
|
|
defaultRuntime.error(`${label}: invalid duration value`);
|
|
defaultRuntime.exit(1);
|
|
return;
|
|
}
|
|
if (!value) return;
|
|
try {
|
|
return parseDurationMs(value);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
defaultRuntime.error(`${label}: ${message}`);
|
|
defaultRuntime.exit(1);
|
|
return;
|
|
}
|
|
}
|
|
function registerNodesStatusCommands(nodes) {
|
|
nodesCallOpts(nodes.command("status").description("List known nodes with connection status and capabilities").option("--connected", "Only show connected nodes").option("--last-connected <duration>", "Only show nodes connected within duration (e.g. 24h)").action(async (opts) => {
|
|
await runNodesCommand("status", async () => {
|
|
const connectedOnly = Boolean(opts.connected);
|
|
const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected");
|
|
const result = await callGatewayCli("node.list", opts, {});
|
|
const obj = typeof result === "object" && result !== null ? result : {};
|
|
const { ok, warn, muted } = getNodesTheme();
|
|
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
|
const now = Date.now();
|
|
const nodes = parseNodeList(result);
|
|
const lastConnectedById = sinceMs !== void 0 ? new Map(parsePairingList(await callGatewayCli("node.pair.list", opts, {})).paired.map((entry) => [entry.nodeId, entry])) : null;
|
|
const filtered = nodes.filter((n) => {
|
|
if (connectedOnly && !n.connected) return false;
|
|
if (sinceMs !== void 0) {
|
|
const paired = lastConnectedById?.get(n.nodeId);
|
|
const lastConnectedAtMs = typeof paired?.lastConnectedAtMs === "number" ? paired.lastConnectedAtMs : typeof n.connectedAtMs === "number" ? n.connectedAtMs : void 0;
|
|
if (typeof lastConnectedAtMs !== "number") return false;
|
|
if (now - lastConnectedAtMs > sinceMs) return false;
|
|
}
|
|
return true;
|
|
});
|
|
if (opts.json) {
|
|
const ts = typeof obj.ts === "number" ? obj.ts : Date.now();
|
|
defaultRuntime.log(JSON.stringify({
|
|
...obj,
|
|
ts,
|
|
nodes: filtered
|
|
}, null, 2));
|
|
return;
|
|
}
|
|
const pairedCount = filtered.filter((n) => Boolean(n.paired)).length;
|
|
const connectedCount = filtered.filter((n) => Boolean(n.connected)).length;
|
|
const filteredLabel = filtered.length !== nodes.length ? ` (of ${nodes.length})` : "";
|
|
defaultRuntime.log(`Known: ${filtered.length}${filteredLabel} · Paired: ${pairedCount} · Connected: ${connectedCount}`);
|
|
if (filtered.length === 0) return;
|
|
const rows = filtered.map((n) => {
|
|
const name = n.displayName?.trim() ? n.displayName.trim() : n.nodeId;
|
|
const perms = formatPermissions(n.permissions);
|
|
const versions = formatNodeVersions(n);
|
|
const pathEnv = formatPathEnv(n.pathEnv);
|
|
const detailParts = [
|
|
n.deviceFamily ? `device: ${n.deviceFamily}` : null,
|
|
n.modelIdentifier ? `hw: ${n.modelIdentifier}` : null,
|
|
perms ? `perms: ${perms}` : null,
|
|
versions,
|
|
pathEnv ? `path: ${pathEnv}` : null
|
|
].filter(Boolean);
|
|
const caps = Array.isArray(n.caps) ? n.caps.map(String).filter(Boolean).toSorted().join(", ") : "?";
|
|
const paired = n.paired ? ok("paired") : warn("unpaired");
|
|
const connected = n.connected ? ok("connected") : muted("disconnected");
|
|
const since = typeof n.connectedAtMs === "number" ? ` (${formatAge(Math.max(0, now - n.connectedAtMs))} ago)` : "";
|
|
return {
|
|
Node: name,
|
|
ID: n.nodeId,
|
|
IP: n.remoteIp ?? "",
|
|
Detail: detailParts.join(" · "),
|
|
Status: `${paired} · ${connected}${since}`,
|
|
Caps: caps
|
|
};
|
|
});
|
|
defaultRuntime.log(renderTable({
|
|
width: tableWidth,
|
|
columns: [
|
|
{
|
|
key: "Node",
|
|
header: "Node",
|
|
minWidth: 14,
|
|
flex: true
|
|
},
|
|
{
|
|
key: "ID",
|
|
header: "ID",
|
|
minWidth: 10
|
|
},
|
|
{
|
|
key: "IP",
|
|
header: "IP",
|
|
minWidth: 10
|
|
},
|
|
{
|
|
key: "Detail",
|
|
header: "Detail",
|
|
minWidth: 18,
|
|
flex: true
|
|
},
|
|
{
|
|
key: "Status",
|
|
header: "Status",
|
|
minWidth: 18
|
|
},
|
|
{
|
|
key: "Caps",
|
|
header: "Caps",
|
|
minWidth: 12,
|
|
flex: true
|
|
}
|
|
],
|
|
rows
|
|
}).trimEnd());
|
|
});
|
|
}));
|
|
nodesCallOpts(nodes.command("describe").description("Describe a node (capabilities + supported invoke commands)").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").action(async (opts) => {
|
|
await runNodesCommand("describe", async () => {
|
|
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
|
const result = await callGatewayCli("node.describe", opts, { nodeId });
|
|
if (opts.json) {
|
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
|
return;
|
|
}
|
|
const obj = typeof result === "object" && result !== null ? result : {};
|
|
const displayName = typeof obj.displayName === "string" ? obj.displayName : nodeId;
|
|
const connected = Boolean(obj.connected);
|
|
const paired = Boolean(obj.paired);
|
|
const caps = Array.isArray(obj.caps) ? obj.caps.map(String).filter(Boolean).toSorted() : null;
|
|
const commands = Array.isArray(obj.commands) ? obj.commands.map(String).filter(Boolean).toSorted() : [];
|
|
const perms = formatPermissions(obj.permissions);
|
|
const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null;
|
|
const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null;
|
|
const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null;
|
|
const pathEnv = typeof obj.pathEnv === "string" ? obj.pathEnv : null;
|
|
const versions = formatNodeVersions(obj);
|
|
const { heading, ok, warn, muted } = getNodesTheme();
|
|
const status = `${paired ? ok("paired") : warn("unpaired")} · ${connected ? ok("connected") : muted("disconnected")}`;
|
|
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
|
const rows = [
|
|
{
|
|
Field: "ID",
|
|
Value: nodeId
|
|
},
|
|
displayName ? {
|
|
Field: "Name",
|
|
Value: displayName
|
|
} : null,
|
|
ip ? {
|
|
Field: "IP",
|
|
Value: ip
|
|
} : null,
|
|
family ? {
|
|
Field: "Device",
|
|
Value: family
|
|
} : null,
|
|
model ? {
|
|
Field: "Model",
|
|
Value: model
|
|
} : null,
|
|
perms ? {
|
|
Field: "Perms",
|
|
Value: perms
|
|
} : null,
|
|
versions ? {
|
|
Field: "Version",
|
|
Value: versions
|
|
} : null,
|
|
pathEnv ? {
|
|
Field: "PATH",
|
|
Value: pathEnv
|
|
} : null,
|
|
{
|
|
Field: "Status",
|
|
Value: status
|
|
},
|
|
{
|
|
Field: "Caps",
|
|
Value: caps ? caps.join(", ") : "?"
|
|
}
|
|
].filter(Boolean);
|
|
defaultRuntime.log(heading("Node"));
|
|
defaultRuntime.log(renderTable({
|
|
width: tableWidth,
|
|
columns: [{
|
|
key: "Field",
|
|
header: "Field",
|
|
minWidth: 8
|
|
}, {
|
|
key: "Value",
|
|
header: "Value",
|
|
minWidth: 24,
|
|
flex: true
|
|
}],
|
|
rows
|
|
}).trimEnd());
|
|
defaultRuntime.log("");
|
|
defaultRuntime.log(heading("Commands"));
|
|
if (commands.length === 0) {
|
|
defaultRuntime.log(muted("- (none reported)"));
|
|
return;
|
|
}
|
|
for (const c of commands) defaultRuntime.log(`- ${c}`);
|
|
});
|
|
}));
|
|
nodesCallOpts(nodes.command("list").description("List pending and paired nodes").option("--connected", "Only show connected nodes").option("--last-connected <duration>", "Only show nodes connected within duration (e.g. 24h)").action(async (opts) => {
|
|
await runNodesCommand("list", async () => {
|
|
const connectedOnly = Boolean(opts.connected);
|
|
const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected");
|
|
const { pending, paired } = parsePairingList(await callGatewayCli("node.pair.list", opts, {}));
|
|
const { heading, muted, warn } = getNodesTheme();
|
|
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
|
const now = Date.now();
|
|
const hasFilters = connectedOnly || sinceMs !== void 0;
|
|
const pendingRows = hasFilters ? [] : pending;
|
|
const connectedById = hasFilters ? new Map(parseNodeList(await callGatewayCli("node.list", opts, {})).map((node) => [node.nodeId, node])) : null;
|
|
const filteredPaired = paired.filter((node) => {
|
|
if (connectedOnly) {
|
|
if (!(connectedById?.get(node.nodeId))?.connected) return false;
|
|
}
|
|
if (sinceMs !== void 0) {
|
|
const live = connectedById?.get(node.nodeId);
|
|
const lastConnectedAtMs = typeof node.lastConnectedAtMs === "number" ? node.lastConnectedAtMs : typeof live?.connectedAtMs === "number" ? live.connectedAtMs : void 0;
|
|
if (typeof lastConnectedAtMs !== "number") return false;
|
|
if (now - lastConnectedAtMs > sinceMs) return false;
|
|
}
|
|
return true;
|
|
});
|
|
const filteredLabel = hasFilters && filteredPaired.length !== paired.length ? ` (of ${paired.length})` : "";
|
|
defaultRuntime.log(`Pending: ${pendingRows.length} · Paired: ${filteredPaired.length}${filteredLabel}`);
|
|
if (opts.json) {
|
|
defaultRuntime.log(JSON.stringify({
|
|
pending: pendingRows,
|
|
paired: filteredPaired
|
|
}, null, 2));
|
|
return;
|
|
}
|
|
if (pendingRows.length > 0) {
|
|
const pendingRowsRendered = pendingRows.map((r) => ({
|
|
Request: r.requestId,
|
|
Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId,
|
|
IP: r.remoteIp ?? "",
|
|
Requested: typeof r.ts === "number" ? `${formatAge(Math.max(0, now - r.ts))} ago` : muted("unknown"),
|
|
Repair: r.isRepair ? warn("yes") : ""
|
|
}));
|
|
defaultRuntime.log("");
|
|
defaultRuntime.log(heading("Pending"));
|
|
defaultRuntime.log(renderTable({
|
|
width: tableWidth,
|
|
columns: [
|
|
{
|
|
key: "Request",
|
|
header: "Request",
|
|
minWidth: 8
|
|
},
|
|
{
|
|
key: "Node",
|
|
header: "Node",
|
|
minWidth: 14,
|
|
flex: true
|
|
},
|
|
{
|
|
key: "IP",
|
|
header: "IP",
|
|
minWidth: 10
|
|
},
|
|
{
|
|
key: "Requested",
|
|
header: "Requested",
|
|
minWidth: 12
|
|
},
|
|
{
|
|
key: "Repair",
|
|
header: "Repair",
|
|
minWidth: 6
|
|
}
|
|
],
|
|
rows: pendingRowsRendered
|
|
}).trimEnd());
|
|
}
|
|
if (filteredPaired.length > 0) {
|
|
const pairedRows = filteredPaired.map((n) => {
|
|
const live = connectedById?.get(n.nodeId);
|
|
const lastConnectedAtMs = typeof n.lastConnectedAtMs === "number" ? n.lastConnectedAtMs : typeof live?.connectedAtMs === "number" ? live.connectedAtMs : void 0;
|
|
return {
|
|
Node: n.displayName?.trim() ? n.displayName.trim() : n.nodeId,
|
|
Id: n.nodeId,
|
|
IP: n.remoteIp ?? "",
|
|
LastConnect: typeof lastConnectedAtMs === "number" ? `${formatAge(Math.max(0, now - lastConnectedAtMs))} ago` : muted("unknown")
|
|
};
|
|
});
|
|
defaultRuntime.log("");
|
|
defaultRuntime.log(heading("Paired"));
|
|
defaultRuntime.log(renderTable({
|
|
width: tableWidth,
|
|
columns: [
|
|
{
|
|
key: "Node",
|
|
header: "Node",
|
|
minWidth: 14,
|
|
flex: true
|
|
},
|
|
{
|
|
key: "Id",
|
|
header: "ID",
|
|
minWidth: 10
|
|
},
|
|
{
|
|
key: "IP",
|
|
header: "IP",
|
|
minWidth: 10
|
|
},
|
|
{
|
|
key: "LastConnect",
|
|
header: "Last Connect",
|
|
minWidth: 14
|
|
}
|
|
],
|
|
rows: pairedRows
|
|
}).trimEnd());
|
|
}
|
|
});
|
|
}));
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/cli/nodes-cli/register.ts
|
|
function registerNodesCli(program) {
|
|
const nodes = program.command("nodes").description("Manage gateway-owned node pairing").addHelpText("after", () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/nodes", "docs.openclaw.ai/cli/nodes")}\n`);
|
|
registerNodesStatusCommands(nodes);
|
|
registerNodesPairingCommands(nodes);
|
|
registerNodesInvokeCommands(nodes);
|
|
registerNodesNotifyCommand(nodes);
|
|
registerNodesCanvasCommands(nodes);
|
|
registerNodesCameraCommands(nodes);
|
|
registerNodesScreenCommands(nodes);
|
|
registerNodesLocationCommands(nodes);
|
|
}
|
|
|
|
//#endregion
|
|
export { registerNodesCli }; |