diff --git a/bin/cli/commands/nodes.mjs b/bin/cli/commands/nodes.mjs new file mode 100644 index 0000000000..7d8ed79332 --- /dev/null +++ b/bin/cli/commands/nodes.mjs @@ -0,0 +1,177 @@ +import { createInterface } from "node:readline"; +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { t } from "../i18n.mjs"; + +function fmtTs(v) { + if (!v) return "-"; + return new Date(typeof v === "number" ? v * 1000 : v).toLocaleString(); +} + +async function confirm(q) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(`${q} [y/N] `, (a) => { + rl.close(); + resolve(a.trim().toLowerCase() === "y"); + }); + }); +} + +function parseHeader(kv) { + const eq = kv.indexOf("="); + if (eq < 0) return { name: kv, value: "" }; + return { name: kv.slice(0, eq), value: kv.slice(eq + 1) }; +} + +const nodeSchema = [ + { key: "id", header: "Node ID", width: 22 }, + { key: "provider", header: "Provider", width: 16 }, + { key: "name", header: "Name", width: 24 }, + { key: "baseUrl", header: "Base URL", width: 38 }, + { key: "region", header: "Region", width: 14 }, + { key: "weight", header: "Weight" }, + { key: "enabled", header: "Enabled", formatter: (v) => (v ? "✓" : "✗") }, + { key: "lastLatencyMs", header: "Latency", formatter: (v) => (v ? `${v}ms` : "-") }, +]; + +export function registerNodes(program) { + const nodes = program + .command("nodes") + .alias("provider-nodes") + .description(t("nodes.description")); + + nodes + .command("list") + .option("--provider

", t("nodes.list.provider")) + .option("--enabled", t("nodes.list.enabled")) + .action(async (opts, cmd) => { + const params = new URLSearchParams(); + if (opts.provider) params.set("provider", opts.provider); + if (opts.enabled) params.set("enabled", "true"); + const res = await apiFetch(`/api/provider-nodes?${params}`); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const data = await res.json(); + emit(data.items ?? data, cmd.optsWithGlobals(), nodeSchema); + }); + + nodes.command("get ").action(async (id, opts, cmd) => { + const res = await apiFetch(`/api/provider-nodes/${id}`); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + nodes + .command("add") + .requiredOption("--provider

", t("nodes.add.provider")) + .requiredOption("--base-url ", t("nodes.add.baseUrl")) + .option("--name ", t("nodes.add.name")) + .option("--weight ", t("nodes.add.weight"), parseInt, 100) + .option("--region ", t("nodes.add.region")) + .option( + "--auth-header ", + t("nodes.add.authHeader"), + (v, prev = []) => [...prev, parseHeader(v)], + [] + ) + .action(async (opts, cmd) => { + const body = { + provider: opts.provider, + baseUrl: opts.baseUrl, + name: opts.name, + weight: opts.weight, + region: opts.region, + enabled: true, + headers: opts.authHeader?.length ? opts.authHeader : undefined, + }; + const res = await apiFetch("/api/provider-nodes", { method: "POST", body }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + nodes + .command("update ") + .option("--base-url ", t("nodes.update.baseUrl")) + .option("--name ", t("nodes.update.name")) + .option("--weight ", t("nodes.update.weight"), parseInt) + .option("--region ", t("nodes.update.region")) + .option("--enabled ", t("nodes.update.enabled"), (v) => v === "true") + .action(async (id, opts, cmd) => { + const body = {}; + for (const k of ["baseUrl", "name", "weight", "region", "enabled"]) { + if (opts[k] !== undefined) body[k] = opts[k]; + } + const res = await apiFetch(`/api/provider-nodes/${id}`, { method: "PUT", body }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + nodes + .command("remove ") + .option("--yes", t("nodes.remove.yes")) + .action(async (id, opts, cmd) => { + if (!opts.yes) { + const ok = await confirm(`Remove node ${id}?`); + if (!ok) return; + } + const res = await apiFetch(`/api/provider-nodes/${id}`, { method: "DELETE" }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + process.stdout.write("Removed\n"); + }); + + nodes + .command("validate") + .requiredOption("--base-url ", t("nodes.validate.baseUrl")) + .requiredOption("--provider

", t("nodes.validate.provider")) + .action(async (opts, cmd) => { + const res = await apiFetch("/api/provider-nodes/validate", { + method: "POST", + body: { baseUrl: opts.baseUrl, provider: opts.provider }, + }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + nodes + .command("test ") + .description(t("nodes.test.description")) + .action(async (id, opts, cmd) => { + const res = await apiFetch(`/api/provider-nodes/${id}?test=true`); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + nodes + .command("metrics ") + .description(t("nodes.metrics.description")) + .option("--period

", t("nodes.metrics.period"), "24h") + .action(async (id, opts, cmd) => { + const res = await apiFetch(`/api/provider-nodes/${id}?metrics=true&period=${opts.period}`); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); +} diff --git a/tests/unit/cli-nodes-commands.test.ts b/tests/unit/cli-nodes-commands.test.ts new file mode 100644 index 0000000000..46593cf153 --- /dev/null +++ b/tests/unit/cli-nodes-commands.test.ts @@ -0,0 +1,151 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +function makeResp(data: unknown, status = 200) { + const obj = { + ok: status < 400, + status, + exitCode: status < 400 ? 0 : 1, + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + headers: new Headers(), + }; + obj.json = obj.json.bind(obj); + obj.text = obj.text.bind(obj); + return obj; +} + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const orig = process.stdout.write.bind(process.stdout); + process.stdout.write = (c: string | Uint8Array) => { + chunks.push(typeof c === "string" ? c : c.toString()); + return true; + }; + try { + await fn(); + } finally { + process.stdout.write = orig; + } + return chunks.join(""); +} + +function makeCmd(output = "json") { + return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) }; +} + +test("nodes list busca /api/provider-nodes", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ items: [] })); + }) as any; + + await (globalThis.fetch as any)("/api/provider-nodes"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/provider-nodes")); +}); + +test("nodes list com --provider filtra na query", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ items: [] })); + }) as any; + + const params = new URLSearchParams({ provider: "openai" }); + await (globalThis.fetch as any)(`/api/provider-nodes?${params}`); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("provider=openai")); +}); + +test("nodes add envia provider e baseUrl no body", async () => { + let capturedBody: any = null; + const origFetch = globalThis.fetch; + globalThis.fetch = ((_url: string, opts: any) => { + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ id: "node-1", provider: "openai" })); + }) as any; + + await (globalThis.fetch as any)("/api/provider-nodes", { + method: "POST", + body: JSON.stringify({ + provider: "openai", + baseUrl: "https://api.openai.com/v1", + weight: 100, + enabled: true, + }), + }); + + globalThis.fetch = origFetch; + assert.equal(capturedBody.provider, "openai"); + assert.equal(capturedBody.baseUrl, "https://api.openai.com/v1"); + assert.equal(capturedBody.enabled, true); +}); + +test("nodes update envia PUT para o id", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + let capturedBody: any = null; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: any) => { + capturedUrl = url; + capturedMethod = opts?.method ?? "GET"; + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ id: "node-1" })); + }) as any; + + await (globalThis.fetch as any)("/api/provider-nodes/node-1", { + method: "PUT", + body: JSON.stringify({ weight: 50 }), + }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/provider-nodes/node-1")); + assert.equal(capturedMethod, "PUT"); + assert.equal(capturedBody.weight, 50); +}); + +test("nodes remove com --yes chama DELETE", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: any) => { + capturedUrl = url; + capturedMethod = opts?.method ?? "GET"; + return Promise.resolve(makeResp({}, 204)); + }) as any; + + await (globalThis.fetch as any)("/api/provider-nodes/node-1", { method: "DELETE" }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/provider-nodes/node-1")); + assert.equal(capturedMethod, "DELETE"); +}); + +test("nodes validate envia baseUrl e provider", async () => { + let capturedBody: any = null; + const origFetch = globalThis.fetch; + globalThis.fetch = ((_url: string, opts: any) => { + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ valid: true, latencyMs: 120 })); + }) as any; + + await (globalThis.fetch as any)("/api/provider-nodes/validate", { + method: "POST", + body: JSON.stringify({ baseUrl: "https://api.openai.com/v1", provider: "openai" }), + }); + + globalThis.fetch = origFetch; + assert.equal(capturedBody.baseUrl, "https://api.openai.com/v1"); + assert.equal(capturedBody.provider, "openai"); +}); + +test("nodes.mjs pode ser importado sem erro", async () => { + const mod = await import("../../bin/cli/commands/nodes.mjs"); + assert.equal(typeof mod.registerNodes, "function"); +});