Spaces:
Configuration error
Configuration error
| import type { Command } from "commander"; | |
| import { callGateway } from "../gateway/call.js"; | |
| import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; | |
| import { defaultRuntime } from "../runtime.js"; | |
| import { renderTable } from "../terminal/table.js"; | |
| import { theme } from "../terminal/theme.js"; | |
| import { withProgress } from "./progress.js"; | |
| type DevicesRpcOpts = { | |
| url?: string; | |
| token?: string; | |
| password?: string; | |
| timeout?: string; | |
| json?: boolean; | |
| device?: string; | |
| role?: string; | |
| scope?: string[]; | |
| }; | |
| type DeviceTokenSummary = { | |
| role: string; | |
| scopes?: string[]; | |
| revokedAtMs?: number; | |
| }; | |
| type PendingDevice = { | |
| requestId: string; | |
| deviceId: string; | |
| displayName?: string; | |
| role?: string; | |
| remoteIp?: string; | |
| isRepair?: boolean; | |
| ts?: number; | |
| }; | |
| type PairedDevice = { | |
| deviceId: string; | |
| displayName?: string; | |
| roles?: string[]; | |
| scopes?: string[]; | |
| remoteIp?: string; | |
| tokens?: DeviceTokenSummary[]; | |
| createdAtMs?: number; | |
| approvedAtMs?: number; | |
| }; | |
| type DevicePairingList = { | |
| pending?: PendingDevice[]; | |
| paired?: PairedDevice[]; | |
| }; | |
| function formatAge(msAgo: number) { | |
| const s = Math.max(0, Math.floor(msAgo / 1000)); | |
| if (s < 60) return `${s}s`; | |
| const m = Math.floor(s / 60); | |
| if (m < 60) return `${m}m`; | |
| const h = Math.floor(m / 60); | |
| if (h < 24) return `${h}h`; | |
| const d = Math.floor(h / 24); | |
| return `${d}d`; | |
| } | |
| const devicesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) => | |
| cmd | |
| .option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)") | |
| .option("--token <token>", "Gateway token (if required)") | |
| .option("--password <password>", "Gateway password (password auth)") | |
| .option("--timeout <ms>", "Timeout in ms", String(defaults?.timeoutMs ?? 10_000)) | |
| .option("--json", "Output JSON", false); | |
| const callGatewayCli = async (method: string, opts: DevicesRpcOpts, params?: unknown) => | |
| withProgress( | |
| { | |
| label: `Devices ${method}`, | |
| indeterminate: true, | |
| enabled: opts.json !== true, | |
| }, | |
| async () => | |
| await callGateway({ | |
| url: opts.url, | |
| token: opts.token, | |
| password: opts.password, | |
| method, | |
| params, | |
| timeoutMs: Number(opts.timeout ?? 10_000), | |
| clientName: GATEWAY_CLIENT_NAMES.CLI, | |
| mode: GATEWAY_CLIENT_MODES.CLI, | |
| }), | |
| ); | |
| function parseDevicePairingList(value: unknown): DevicePairingList { | |
| const obj = typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {}; | |
| return { | |
| pending: Array.isArray(obj.pending) ? (obj.pending as PendingDevice[]) : [], | |
| paired: Array.isArray(obj.paired) ? (obj.paired as PairedDevice[]) : [], | |
| }; | |
| } | |
| function formatTokenSummary(tokens: DeviceTokenSummary[] | undefined) { | |
| if (!tokens || tokens.length === 0) return "none"; | |
| const parts = tokens | |
| .map((t) => `${t.role}${t.revokedAtMs ? " (revoked)" : ""}`) | |
| .sort((a, b) => a.localeCompare(b)); | |
| return parts.join(", "); | |
| } | |
| export function registerDevicesCli(program: Command) { | |
| const devices = program.command("devices").description("Device pairing and auth tokens"); | |
| devicesCallOpts( | |
| devices | |
| .command("list") | |
| .description("List pending and paired devices") | |
| .action(async (opts: DevicesRpcOpts) => { | |
| const result = await callGatewayCli("device.pair.list", opts, {}); | |
| const list = parseDevicePairingList(result); | |
| if (opts.json) { | |
| defaultRuntime.log(JSON.stringify(list, null, 2)); | |
| return; | |
| } | |
| if (list.pending?.length) { | |
| const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); | |
| defaultRuntime.log( | |
| `${theme.heading("Pending")} ${theme.muted(`(${list.pending.length})`)}`, | |
| ); | |
| defaultRuntime.log( | |
| renderTable({ | |
| width: tableWidth, | |
| columns: [ | |
| { key: "Request", header: "Request", minWidth: 10 }, | |
| { key: "Device", header: "Device", minWidth: 16, flex: true }, | |
| { key: "Role", header: "Role", minWidth: 8 }, | |
| { key: "IP", header: "IP", minWidth: 12 }, | |
| { key: "Age", header: "Age", minWidth: 8 }, | |
| { key: "Flags", header: "Flags", minWidth: 8 }, | |
| ], | |
| rows: list.pending.map((req) => ({ | |
| Request: req.requestId, | |
| Device: req.displayName || req.deviceId, | |
| Role: req.role ?? "", | |
| IP: req.remoteIp ?? "", | |
| Age: typeof req.ts === "number" ? `${formatAge(Date.now() - req.ts)} ago` : "", | |
| Flags: req.isRepair ? "repair" : "", | |
| })), | |
| }).trimEnd(), | |
| ); | |
| } | |
| if (list.paired?.length) { | |
| const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); | |
| defaultRuntime.log( | |
| `${theme.heading("Paired")} ${theme.muted(`(${list.paired.length})`)}`, | |
| ); | |
| defaultRuntime.log( | |
| renderTable({ | |
| width: tableWidth, | |
| columns: [ | |
| { key: "Device", header: "Device", minWidth: 16, flex: true }, | |
| { key: "Roles", header: "Roles", minWidth: 12, flex: true }, | |
| { key: "Scopes", header: "Scopes", minWidth: 12, flex: true }, | |
| { key: "Tokens", header: "Tokens", minWidth: 12, flex: true }, | |
| { key: "IP", header: "IP", minWidth: 12 }, | |
| ], | |
| rows: list.paired.map((device) => ({ | |
| Device: device.displayName || device.deviceId, | |
| Roles: device.roles?.length ? device.roles.join(", ") : "", | |
| Scopes: device.scopes?.length ? device.scopes.join(", ") : "", | |
| Tokens: formatTokenSummary(device.tokens), | |
| IP: device.remoteIp ?? "", | |
| })), | |
| }).trimEnd(), | |
| ); | |
| } | |
| if (!list.pending?.length && !list.paired?.length) { | |
| defaultRuntime.log(theme.muted("No device pairing entries.")); | |
| } | |
| }), | |
| ); | |
| devicesCallOpts( | |
| devices | |
| .command("approve") | |
| .description("Approve a pending device pairing request") | |
| .argument("<requestId>", "Pending request id") | |
| .action(async (requestId: string, opts: DevicesRpcOpts) => { | |
| const result = await callGatewayCli("device.pair.approve", opts, { requestId }); | |
| if (opts.json) { | |
| defaultRuntime.log(JSON.stringify(result, null, 2)); | |
| return; | |
| } | |
| const deviceId = (result as { device?: { deviceId?: string } })?.device?.deviceId; | |
| defaultRuntime.log(`${theme.success("Approved")} ${theme.command(deviceId ?? "ok")}`); | |
| }), | |
| ); | |
| devicesCallOpts( | |
| devices | |
| .command("reject") | |
| .description("Reject a pending device pairing request") | |
| .argument("<requestId>", "Pending request id") | |
| .action(async (requestId: string, opts: DevicesRpcOpts) => { | |
| const result = await callGatewayCli("device.pair.reject", opts, { requestId }); | |
| if (opts.json) { | |
| defaultRuntime.log(JSON.stringify(result, null, 2)); | |
| return; | |
| } | |
| const deviceId = (result as { deviceId?: string })?.deviceId; | |
| defaultRuntime.log(`${theme.warn("Rejected")} ${theme.command(deviceId ?? "ok")}`); | |
| }), | |
| ); | |
| devicesCallOpts( | |
| devices | |
| .command("rotate") | |
| .description("Rotate a device token for a role") | |
| .requiredOption("--device <id>", "Device id") | |
| .requiredOption("--role <role>", "Role name") | |
| .option("--scope <scope...>", "Scopes to attach to the token (repeatable)") | |
| .action(async (opts: DevicesRpcOpts) => { | |
| const deviceId = String(opts.device ?? "").trim(); | |
| const role = String(opts.role ?? "").trim(); | |
| if (!deviceId || !role) { | |
| defaultRuntime.error("--device and --role required"); | |
| defaultRuntime.exit(1); | |
| return; | |
| } | |
| const result = await callGatewayCli("device.token.rotate", opts, { | |
| deviceId, | |
| role, | |
| scopes: Array.isArray(opts.scope) ? opts.scope : undefined, | |
| }); | |
| defaultRuntime.log(JSON.stringify(result, null, 2)); | |
| }), | |
| ); | |
| devicesCallOpts( | |
| devices | |
| .command("revoke") | |
| .description("Revoke a device token for a role") | |
| .requiredOption("--device <id>", "Device id") | |
| .requiredOption("--role <role>", "Role name") | |
| .action(async (opts: DevicesRpcOpts) => { | |
| const deviceId = String(opts.device ?? "").trim(); | |
| const role = String(opts.role ?? "").trim(); | |
| if (!deviceId || !role) { | |
| defaultRuntime.error("--device and --role required"); | |
| defaultRuntime.exit(1); | |
| return; | |
| } | |
| const result = await callGatewayCli("device.token.revoke", opts, { | |
| deviceId, | |
| role, | |
| }); | |
| defaultRuntime.log(JSON.stringify(result, null, 2)); | |
| }), | |
| ); | |
| } | |