melbot / src /cli /nodes-cli /register.status.ts
amos-fernandes's picture
Upload 4501 files
3a65265 verified
import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js";
import { formatAge, formatPermissions, parseNodeList, parsePairingList } from "./format.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
import { renderTable } from "../../terminal/table.js";
import { parseDurationMs } from "../parse-duration.js";
import { shortenHomeInString } from "../../utils.js";
function formatVersionLabel(raw: string) {
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: {
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
}) {
const core = node.coreVersion?.trim() || undefined;
const ui = node.uiVersion?.trim() || undefined;
if (core || ui) return { core, ui };
const legacy = node.version?.trim();
if (!legacy) return { core: undefined, ui: undefined };
const platform = node.platform?.trim().toLowerCase() ?? "";
const headless =
platform === "darwin" || platform === "linux" || platform === "win32" || platform === "windows";
return headless ? { core: legacy, ui: undefined } : { core: undefined, ui: legacy };
}
function formatNodeVersions(node: {
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
}) {
const { core, ui } = resolveNodeVersions(node);
const parts: string[] = [];
if (core) parts.push(`core ${formatVersionLabel(core)}`);
if (ui) parts.push(`ui ${formatVersionLabel(ui)}`);
return parts.length > 0 ? parts.join(" · ") : null;
}
function formatPathEnv(raw?: string): string | null {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
if (!trimmed) return null;
const parts = trimmed.split(":").filter(Boolean);
const display =
parts.length <= 3 ? trimmed : `${parts.slice(0, 2).join(":")}:…:${parts.slice(-1)[0]}`;
return shortenHomeInString(display);
}
function parseSinceMs(raw: unknown, label: string): number | undefined {
if (raw === undefined || raw === null) return undefined;
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 undefined;
}
if (!value) return undefined;
try {
return parseDurationMs(value);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
defaultRuntime.error(`${label}: ${message}`);
defaultRuntime.exit(1);
return undefined;
}
}
export function registerNodesStatusCommands(nodes: Command) {
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: NodesRpcOpts) => {
await runNodesCommand("status", async () => {
const connectedOnly = Boolean(opts.connected);
const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected");
const result = (await callGatewayCli("node.list", opts, {})) as unknown;
const obj =
typeof result === "object" && result !== null
? (result as Record<string, unknown>)
: {};
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 !== undefined
? 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 !== undefined) {
const paired = lastConnectedById?.get(n.nodeId);
const lastConnectedAtMs =
typeof paired?.lastConnectedAtMs === "number"
? paired.lastConnectedAtMs
: typeof n.connectedAtMs === "number"
? n.connectedAtMs
: undefined;
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) as string[];
const caps = Array.isArray(n.caps)
? n.caps.map(String).filter(Boolean).sort().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: NodesRpcOpts) => {
await runNodesCommand("describe", async () => {
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
const result = (await callGatewayCli("node.describe", opts, {
nodeId,
})) as unknown;
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const obj =
typeof result === "object" && result !== null
? (result as Record<string, unknown>)
: {};
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).sort() : null;
const commands = Array.isArray(obj.commands)
? obj.commands.map(String).filter(Boolean).sort()
: [];
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 as {
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
},
);
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) as Array<{ Field: string; Value: string }>;
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: NodesRpcOpts) => {
await runNodesCommand("list", async () => {
const connectedOnly = Boolean(opts.connected);
const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected");
const result = (await callGatewayCli("node.pair.list", opts, {})) as unknown;
const { pending, paired } = parsePairingList(result);
const { heading, muted, warn } = getNodesTheme();
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const now = Date.now();
const hasFilters = connectedOnly || sinceMs !== undefined;
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) {
const live = connectedById?.get(node.nodeId);
if (!live?.connected) return false;
}
if (sinceMs !== undefined) {
const live = connectedById?.get(node.nodeId);
const lastConnectedAtMs =
typeof node.lastConnectedAtMs === "number"
? node.lastConnectedAtMs
: typeof live?.connectedAtMs === "number"
? live.connectedAtMs
: undefined;
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
: undefined;
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(),
);
}
});
}),
);
}