diff --git a/bin/cli/commands/radar.mjs b/bin/cli/commands/radar.mjs new file mode 100644 index 0000000000..1455efebba --- /dev/null +++ b/bin/cli/commands/radar.mjs @@ -0,0 +1,76 @@ +import { apiFetch } from "../api.mjs"; +import { t } from "../i18n.mjs"; +import { emit } from "../output.mjs"; + +const statusSchema = [ + { key: "feed", header: "Feed" }, + { key: "available", header: "Available" }, + { key: "version", header: "Version" }, + { key: "tier", header: "Tier" }, + { key: "fetchedAt", header: "Fetched" }, +]; + +const syncSchema = [ + { key: "feed", header: "Feed" }, + { key: "status", header: "Status" }, + { key: "version", header: "Version" }, + { key: "reason", header: "Reason" }, +]; + +function exitCodeFor(response) { + return Number.isInteger(response.exitCode) ? response.exitCode : response.status === 401 ? 4 : 1; +} + +export async function runRadarStatusCommand(opts = {}) { + const response = await apiFetch("/api/radar/status", { acceptNotOk: true }); + if (!response.ok) return exitCodeFor(response); + const data = await response.json(); + if (opts.output === "json") { + emit(data, opts); + return 0; + } + const rows = Object.entries(data.feeds ?? {}).map(([feed, value]) => ({ + feed, + ...(value && typeof value === "object" ? value : { available: false }), + })); + emit(rows, opts, statusSchema); + return 0; +} + +export async function runRadarSyncCommand(opts = {}) { + const response = await apiFetch("/api/radar/sync-all", { + method: "POST", + body: {}, + acceptNotOk: true, + }); + if (!response.ok) return exitCodeFor(response); + const data = await response.json(); + if (opts.output === "json") { + emit(data, opts); + return 0; + } + const rows = Object.entries(data).map(([feed, value]) => ({ + feed, + ...(value && typeof value === "object" ? value : { status: "error" }), + })); + emit(rows, opts, syncSchema); + return 0; +} + +export function registerRadar(program) { + const radar = program.command("radar").description(t("radar.description")); + radar + .command("status") + .description(t("radar.status")) + .action(async (_opts, command) => { + const code = await runRadarStatusCommand(command.optsWithGlobals()); + if (code !== 0) process.exitCode = code; + }); + radar + .command("sync") + .description(t("radar.sync")) + .action(async (_opts, command) => { + const code = await runRadarSyncCommand(command.optsWithGlobals()); + if (code !== 0) process.exitCode = code; + }); +} diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 84c71bdf06..53dd2787e8 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -78,6 +78,7 @@ import { registerTokens } from "./tokens.mjs"; import { registerConfigure } from "./configure.mjs"; import { registerApiCommands } from "../api-commands/registry.mjs"; import { registerPlugin } from "./plugin.mjs"; +import { registerRadar } from "./radar.mjs"; export function registerCommands(program) { registerMemory(program); @@ -161,4 +162,5 @@ export function registerCommands(program) { registerConfigure(program); registerApiCommands(program); registerPlugin(program); + registerRadar(program); } diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index d0d4808e16..0cfef17b65 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -921,6 +921,11 @@ "model": "Filter by model" } }, + "radar": { + "description": "Inspect and synchronize the local Radar catalog feeds", + "status": "Show local Radar settings and feed cache status", + "sync": "Synchronize catalog, referrals, offers, and Intel through the local server" + }, "resilience": { "description": "Inspect and manage resilience mechanisms", "status": { diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index a951491d63..204ce1f4b7 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -918,6 +918,11 @@ "model": "Filtrar por model" } }, + "radar": { + "description": "Inspecionar e sincronizar os feeds locais do catálogo Radar", + "status": "Mostrar configurações locais e estado dos caches do Radar", + "sync": "Sincronizar catálogo, indicações, ofertas e Intel pelo servidor local" + }, "resilience": { "description": "Inspecionar e gerenciar mecanismos de resiliência", "status": { diff --git a/tests/unit/cli-radar-commands.test.ts b/tests/unit/cli-radar-commands.test.ts new file mode 100644 index 0000000000..ca37a389de --- /dev/null +++ b/tests/unit/cli-radar-commands.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +function makeResponse(data: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(), + json: async () => data, + text: async () => JSON.stringify(data), + } as Response; +} + +async function captureStdout(fn: () => Promise): Promise<{ output: string; code: number }> { + const chunks: string[] = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + if (typeof chunk === "string") chunks.push(chunk); + return true; + }) as typeof process.stdout.write; + try { + const code = await fn(); + return { output: chunks.join(""), code }; + } finally { + process.stdout.write = original; + } +} + +test("radar status is GET-only, read-only, and prints no secret", async () => { + const originalFetch = globalThis.fetch; + let method = "GET"; + let url = ""; + globalThis.fetch = (async (input, init) => { + url = String(input); + method = init?.method ?? "GET"; + return makeResponse({ + settings: { optIn: true, hasSupporterKey: true }, + feeds: { catalog: { available: true, version: "2026.08.09.1", tier: "live" } }, + }); + }) as typeof fetch; + try { + const { runRadarStatusCommand } = await import("../../bin/cli/commands/radar.mjs"); + const result = await captureStdout(() => runRadarStatusCommand({ output: "json" })); + assert.equal(result.code, 0); + assert.match(url, /\/api\/radar\/status$/); + assert.equal(method, "GET"); + assert.ok(!result.output.includes("omr_")); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("radar sync posts only to the local aggregate route and prints per-feed results", async () => { + const originalFetch = globalThis.fetch; + let method = ""; + let url = ""; + globalThis.fetch = (async (input, init) => { + url = String(input); + method = init?.method ?? "GET"; + return makeResponse({ + catalog: { status: "updated", version: "2026.08.09.1" }, + referrals: { status: "stale" }, + offers: { status: "no_key" }, + intel: { status: "no_key" }, + }); + }) as typeof fetch; + try { + const { runRadarSyncCommand } = await import("../../bin/cli/commands/radar.mjs"); + const result = await captureStdout(() => runRadarSyncCommand({ output: "json" })); + assert.equal(result.code, 0); + assert.match(url, /\/api\/radar\/sync-all$/); + assert.equal(method, "POST"); + const parsed = JSON.parse(result.output) as Record; + assert.deepEqual(Object.keys(parsed).sort(), ["catalog", "intel", "offers", "referrals"]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("CLI registry exposes nested radar status and sync commands with EN/PT strings", async () => { + const { createProgram } = await import("../../bin/cli/program.mjs"); + const program = createProgram(); + const radar = program.commands.find((command) => command.name() === "radar"); + assert.ok(radar); + assert.deepEqual(radar.commands.map((command) => command.name()).sort(), ["status", "sync"]); + + for (const locale of ["en", "pt-BR"]) { + const messages = JSON.parse( + fs.readFileSync(path.resolve(process.cwd(), `bin/cli/locales/${locale}.json`), "utf8") + ) as { radar?: Record }; + assert.equal(typeof messages.radar?.description, "string"); + assert.equal(typeof messages.radar?.status, "string"); + assert.equal(typeof messages.radar?.sync, "string"); + } +});