diff --git a/bin/cli/commands/keys.mjs b/bin/cli/commands/keys.mjs index 42789bcccc..9993e65a70 100644 --- a/bin/cli/commands/keys.mjs +++ b/bin/cli/commands/keys.mjs @@ -3,6 +3,7 @@ import { ensureProviderSchema, getProviderApiKey, listProviderConnections, + removeProviderConnectionByProvider, upsertApiKeyProviderConnection, } from "../provider-store.mjs"; import { openOmniRouteDb } from "../sqlite.mjs"; @@ -28,8 +29,8 @@ export function registerKeys(program) { keys .command("add [apiKey]") - .description("Add or update an API key for a provider") - .option("--stdin", "Read API key from stdin instead of argument") + .description(t("keys.addDescription")) + .option("--stdin", t("keys.stdinOpt")) .action(async (provider, apiKey, opts, cmd) => { const globalOpts = cmd.parent.optsWithGlobals(); const exitCode = await runKeysAddCommand(provider, apiKey, { ...opts, ...globalOpts }); @@ -38,8 +39,8 @@ export function registerKeys(program) { keys .command("list") - .description("List all configured API keys") - .option("--json", "Output as JSON") + .description(t("keys.listDescription")) + .option("--json", t("common.jsonOpt")) .action(async (opts, cmd) => { const globalOpts = cmd.parent.optsWithGlobals(); const exitCode = await runKeysListCommand({ ...opts, ...globalOpts }); @@ -48,8 +49,8 @@ export function registerKeys(program) { keys .command("remove ") - .description("Remove an API key for a provider") - .option("--yes", "Skip confirmation prompt") + .description(t("keys.removeDescription")) + .option("--yes", t("common.yesOpt")) .action(async (provider, opts, cmd) => { const globalOpts = cmd.parent.optsWithGlobals(); const exitCode = await runKeysRemoveCommand(provider, { ...opts, ...globalOpts }); @@ -58,8 +59,8 @@ export function registerKeys(program) { keys .command("regenerate ") - .description("Regenerate an OmniRoute API key (management keys)") - .option("--yes", "Skip confirmation prompt") + .description(t("keys.regenerateDescription")) + .option("--yes", t("common.yesOpt")) .action(async (id, opts, cmd) => { const globalOpts = cmd.parent.optsWithGlobals(); const exitCode = await runKeysRegenerateCommand(id, { ...opts, ...globalOpts }); @@ -68,8 +69,8 @@ export function registerKeys(program) { keys .command("revoke ") - .description("Revoke an OmniRoute API key") - .option("--yes", "Skip confirmation prompt") + .description(t("keys.revokeDescription")) + .option("--yes", t("common.yesOpt")) .action(async (id, opts, cmd) => { const globalOpts = cmd.parent.optsWithGlobals(); const exitCode = await runKeysRevokeCommand(id, { ...opts, ...globalOpts }); @@ -78,7 +79,7 @@ export function registerKeys(program) { keys .command("reveal ") - .description("Reveal the unmasked API key value") + .description(t("keys.revealDescription")) .action(async (id, opts, cmd) => { const globalOpts = cmd.parent.optsWithGlobals(); const exitCode = await runKeysRevealCommand(id, { ...opts, ...globalOpts }); @@ -87,18 +88,65 @@ export function registerKeys(program) { keys .command("usage ") - .description("Show recent usage for an API key") - .option("--limit ", "Number of recent requests", "20") + .description(t("keys.usageDescription")) + .option("--limit ", t("keys.usageLimitOpt"), "20") .action(async (id, opts, cmd) => { const globalOpts = cmd.parent.optsWithGlobals(); const exitCode = await runKeysUsageCommand(id, { ...opts, ...globalOpts }); if (exitCode !== 0) process.exit(exitCode); }); + + const policy = keys.command("policy").description(t("keys.policy.title")); + + policy + .command("show ") + .description(t("keys.policy.showDescription")) + .action(async (id, opts, cmd) => { + const globalOpts = cmd.parent.parent.optsWithGlobals(); + const exitCode = await runKeysPolicyShowCommand(id, { ...opts, ...globalOpts }); + if (exitCode !== 0) process.exit(exitCode); + }); + + policy + .command("set ") + .description(t("keys.policy.setDescription")) + .option("--rate-limit ", t("keys.policy.rateLimitOpt"), parseInt) + .option("--max-cost ", t("keys.policy.maxCostOpt"), parseFloat) + .option("--allowed-models ", t("keys.policy.allowedModelsOpt")) + .action(async (id, opts, cmd) => { + const globalOpts = cmd.parent.parent.optsWithGlobals(); + const exitCode = await runKeysPolicySetCommand(id, { ...opts, ...globalOpts }); + if (exitCode !== 0) process.exit(exitCode); + }); + + const expiration = keys.command("expiration").description(t("keys.expiration.title")); + + expiration + .command("list") + .description(t("keys.expiration.listDescription")) + .option("--days ", t("keys.expiration.daysOpt"), "30") + .option("--json", t("common.jsonOpt")) + .action(async (opts, cmd) => { + const globalOpts = cmd.parent.parent.optsWithGlobals(); + const exitCode = await runKeysExpirationListCommand({ ...opts, ...globalOpts }); + if (exitCode !== 0) process.exit(exitCode); + }); + + keys + .command("rotate ") + .description(t("keys.rotateDescription")) + .option("--grace-period ", t("keys.graceOpt"), "60000") + .option("--yes", t("common.yesOpt")) + .action(async (id, opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runKeysRotateCommand(id, { ...opts, ...globalOpts }); + if (exitCode !== 0) process.exit(exitCode); + }); } export async function runKeysAddCommand(provider, apiKey, opts = {}) { if (!provider) { - console.error("Provider is required. Usage: omniroute keys add [apiKey]"); + console.error(t("keys.providerRequired")); return 1; } @@ -106,24 +154,23 @@ export async function runKeysAddCommand(provider, apiKey, opts = {}) { if (opts.stdin) { key = await readStdin(); if (!key) { - console.error("No API key provided via stdin."); + console.error(t("keys.stdinEmpty")); return 1; } } if (!key) { - console.error("API key is required. Usage: omniroute keys add "); + console.error(t("keys.keyRequired")); return 1; } const providerLower = provider.toLowerCase(); const validIds = getValidProviderIds(); if (validIds && !validIds.has(providerLower)) { - console.error(`Unknown provider: ${providerLower}`); + console.error(t("keys.unknownProvider", { provider: providerLower })); return 1; } - // Server-first const serverUp = await isServerUp(); if (serverUp) { try { @@ -139,7 +186,6 @@ export async function runKeysAddCommand(provider, apiKey, opts = {}) { } catch {} } - // DB fallback const { db } = await openOmniRouteDb(); try { const existing = listProviderConnections(db).find( @@ -158,53 +204,72 @@ export async function runKeysAddCommand(provider, apiKey, opts = {}) { } export async function runKeysListCommand(opts = {}) { + const serverUp = await isServerUp(); + + if (serverUp) { + try { + const res = await apiFetch("/api/v1/providers/keys", { retry: false, acceptNotOk: true }); + if (res.ok) { + const data = await res.json(); + const connections = data.keys || data.connections || data.items || data; + if (Array.isArray(connections)) { + return _printKeysList(connections, opts); + } + } + } catch {} + } + const { db } = await openOmniRouteDb(); try { ensureProviderSchema(db); const connections = listProviderConnections(db).filter( (c) => c.authType === "apikey" && c.apiKey ); - - if (opts.json) { - const rows = connections.map((c) => ({ - id: c.id, - provider: c.provider, - name: c.name, - isActive: c.isActive, - maskedKey: maskKey(c.apiKey), - })); - console.log(JSON.stringify({ keys: rows }, null, 2)); - return 0; - } - - printHeading(t("keys.title")); - if (connections.length === 0) { - console.log(t("keys.noKeys")); - return 0; - } - - for (const c of connections) { - let raw = ""; - try { - raw = getProviderApiKey(c); - } catch { - raw = c.apiKey || ""; - } - const masked = maskKey(raw); - const status = c.isActive ? "\x1b[32m● enabled\x1b[0m" : "\x1b[33m○ disabled\x1b[0m"; - console.log(` ${c.provider.padEnd(20)} ${masked.padEnd(22)} ${status}`); - } - - console.log(`\n${t("keys.listed", { count: connections.length })}`); - return 0; + return _printKeysList(connections, opts); } finally { db.close(); } } +function _printKeysList(connections, opts) { + if (opts.json || opts.output === "json") { + const rows = connections.map((c) => ({ + id: c.id, + provider: c.provider, + name: c.name, + isActive: c.isActive !== false, + maskedKey: maskKey(c.apiKey || c.maskedKey || ""), + })); + console.log(JSON.stringify({ keys: rows }, null, 2)); + return 0; + } + + printHeading(t("keys.title")); + if (connections.length === 0) { + console.log(t("keys.noKeys")); + return 0; + } + + for (const c of connections) { + let masked = c.maskedKey || ""; + if (!masked && c.apiKey) { + try { + masked = maskKey(getProviderApiKey(c)); + } catch { + masked = maskKey(c.apiKey); + } + } + const status = c.isActive !== false ? "\x1b[32m● enabled\x1b[0m" : "\x1b[33m○ disabled\x1b[0m"; + console.log(` ${(c.provider || "").padEnd(20)} ${masked.padEnd(22)} ${status}`); + } + + console.log(`\n${t("keys.listed", { count: connections.length })}`); + return 0; +} + export async function runKeysRemoveCommand(provider, opts = {}) { if (!provider) { - console.error("Provider is required. Usage: omniroute keys remove "); + console.error(t("keys.providerRequired")); return 1; } @@ -223,16 +288,25 @@ export async function runKeysRemoveCommand(provider, opts = {}) { } } + const serverUp = await isServerUp(); + if (serverUp) { + try { + const res = await apiFetch(`/api/v1/providers/keys/${encodeURIComponent(providerLower)}`, { + method: "DELETE", + retry: false, + acceptNotOk: true, + }); + if (res.ok) { + console.log(t("keys.removed")); + return 0; + } + } catch {} + } + const { db } = await openOmniRouteDb(); try { - ensureProviderSchema(db); - const result = db - .prepare( - "DELETE FROM provider_connections WHERE provider = ? AND (auth_type = 'apikey' OR (api_key IS NOT NULL AND api_key != ''))" - ) - .run(providerLower); - - if (result.changes > 0) { + const changes = removeProviderConnectionByProvider(db, providerLower); + if (changes > 0) { console.log(t("keys.removed")); return 0; } @@ -256,10 +330,12 @@ export async function runKeysRegenerateCommand(id, opts = {}) { if (!opts.yes) { const readline = await import("node:readline"); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - const answer = await new Promise((r) => rl.question(`Regenerate key ${id}? [y/N] `, r)); + const answer = await new Promise((r) => + rl.question(t("keys.confirmRegenerate", { id }) + " [y/N] ", r) + ); rl.close(); if (!/^y(es)?$/i.test(answer)) { - console.log("Aborted."); + console.log(t("common.cancelled")); return 0; } } @@ -268,11 +344,11 @@ export async function runKeysRegenerateCommand(id, opts = {}) { ...opts, }); if (!res.ok) { - console.error(`Error: HTTP ${res.status}`); + console.error(t("common.error", { message: `HTTP ${res.status}` })); return 1; } const data = await res.json(); - console.log(`Regenerated. New key: ${data.key || data.apiKey || "(see dashboard)"}`); + console.log(t("keys.regenerated", { key: data.key || data.apiKey || "(see dashboard)" })); return 0; } @@ -280,10 +356,12 @@ export async function runKeysRevokeCommand(id, opts = {}) { if (!opts.yes) { const readline = await import("node:readline"); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - const answer = await new Promise((r) => rl.question(`Revoke key ${id}? [y/N] `, r)); + const answer = await new Promise((r) => + rl.question(t("keys.confirmRevoke", { id }) + " [y/N] ", r) + ); rl.close(); if (!/^y(es)?$/i.test(answer)) { - console.log("Aborted."); + console.log(t("common.cancelled")); return 0; } } @@ -292,22 +370,20 @@ export async function runKeysRevokeCommand(id, opts = {}) { ...opts, }); if (!res.ok) { - console.error(`Error: HTTP ${res.status}`); + console.error(t("common.error", { message: `HTTP ${res.status}` })); return 1; } - console.log(`Key ${id} revoked.`); + console.log(t("keys.revoked", { id })); return 0; } export async function runKeysRevealCommand(id, opts = {}) { - process.stderr.write( - "⚠ This will display the full unmasked key. Ensure your screen is private.\n" - ); + process.stderr.write(t("keys.revealWarning") + "\n"); const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/reveal`, { ...opts, }); if (!res.ok) { - console.error(`Error: HTTP ${res.status}`); + console.error(t("common.error", { message: `HTTP ${res.status}` })); return 1; } const data = await res.json(); @@ -322,13 +398,13 @@ export async function runKeysUsageCommand(id, opts = {}) { { ...opts } ); if (!res.ok) { - console.error(`Error: HTTP ${res.status}`); + console.error(t("common.error", { message: `HTTP ${res.status}` })); return 1; } const data = await res.json(); const rows = data.usage || data.requests || data.items || []; if (rows.length === 0) { - console.log("No usage data found."); + console.log(t("keys.noUsage")); return 0; } for (const r of rows) { @@ -339,3 +415,110 @@ export async function runKeysUsageCommand(id, opts = {}) { } return 0; } + +export async function runKeysPolicyShowCommand(id, opts = {}) { + const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/policy`, { + ...opts, + acceptNotOk: true, + }); + if (!res.ok) { + console.error(t("common.error", { message: `HTTP ${res.status}` })); + return 1; + } + const data = await res.json(); + if (opts.output === "json" || opts.json) { + console.log(JSON.stringify(data, null, 2)); + return 0; + } + console.log(t("keys.policy.title") + ` (${id}):`); + console.log(` rate_limit: ${data.rateLimit ?? data.rate_limit ?? "(unset)"}`); + console.log(` max_cost: ${data.maxCost ?? data.max_cost ?? "(unset)"}`); + console.log( + ` allowed_models: ${(data.allowedModels ?? data.allowed_models ?? []).join(", ") || "(all)"}` + ); + return 0; +} + +export async function runKeysPolicySetCommand(id, opts = {}) { + const body = {}; + if (opts.rateLimit != null) body.rateLimit = Number(opts.rateLimit); + if (opts.maxCost != null) body.maxCost = Number(opts.maxCost); + if (opts.allowedModels) body.allowedModels = opts.allowedModels.split(",").map((s) => s.trim()); + + if (Object.keys(body).length === 0) { + console.error(t("keys.policy.nothingToSet")); + return 1; + } + + const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/policy`, { + method: "PATCH", + body, + ...opts, + acceptNotOk: true, + }); + if (!res.ok) { + console.error(t("common.error", { message: `HTTP ${res.status}` })); + return 1; + } + console.log(t("keys.policy.updated")); + return 0; +} + +export async function runKeysExpirationListCommand(opts = {}) { + const days = Number(opts.days || 30); + const res = await apiFetch(`/api/v1/registered-keys?expiring=true&days=${days}`, { + ...opts, + acceptNotOk: true, + }); + if (!res.ok) { + console.error(t("common.error", { message: `HTTP ${res.status}` })); + return 1; + } + const data = await res.json(); + const rows = data.keys || data.items || data; + if (!Array.isArray(rows) || rows.length === 0) { + console.log(t("keys.expiration.none", { days })); + return 0; + } + if (opts.json || opts.output === "json") { + console.log(JSON.stringify(rows, null, 2)); + return 0; + } + console.log(t("keys.expiration.listTitle", { days })); + for (const k of rows) { + const exp = k.expiresAt || k.expires_at || "(unknown)"; + console.log(` ${(k.id || "").padEnd(24)} ${(k.name || "").padEnd(20)} expires: ${exp}`); + } + return 0; +} + +export async function runKeysRotateCommand(id, opts = {}) { + if (!opts.yes) { + const readline = await import("node:readline"); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((r) => + rl.question(t("keys.confirmRotate", { id }) + " [y/N] ", r) + ); + rl.close(); + if (!/^y(es)?$/i.test(answer)) { + console.log(t("common.cancelled")); + return 0; + } + } + + const gracePeriod = Number(opts.gracePeriod || 60000); + const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/rotate`, { + method: "POST", + body: { gracePeriodMs: gracePeriod }, + ...opts, + acceptNotOk: true, + }); + if (!res.ok) { + console.error(t("common.error", { message: `HTTP ${res.status}` })); + return 1; + } + const data = await res.json(); + const newId = data.newKeyId || data.id || "(see dashboard)"; + console.log(t("keys.rotated", { id, newId })); + return 0; +} diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index 978e7a4997..ed131361a1 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -10,7 +10,9 @@ "no": "no", "confirm": "Are you sure? (yes/no)", "dryRun": "[dry-run] would {action}", - "cancelled": "Cancelled." + "cancelled": "Cancelled.", + "jsonOpt": "Output as JSON", + "yesOpt": "Skip confirmation prompt" }, "setup": { "title": "OmniRoute Setup", @@ -62,11 +64,51 @@ }, "keys": { "title": "API Keys", + "addDescription": "Add or update an API key for a provider", + "listDescription": "List all configured API keys", + "removeDescription": "Remove an API key for a provider", + "regenerateDescription": "Regenerate an OmniRoute API key", + "revokeDescription": "Revoke an OmniRoute API key", + "revealDescription": "Reveal the unmasked API key value", + "usageDescription": "Show recent usage for an API key", + "usageLimitOpt": "Number of recent requests", + "rotateDescription": "Generate a new key and invalidate the old one", + "graceOpt": "Grace period (ms) before the old key is invalidated", + "stdinOpt": "Read API key from stdin instead of argument", "added": "Key added for {provider}.", "removed": "Key removed.", "listed": "{count} key(s).", "noKeys": "No keys configured.", - "confirmRemove": "Remove key {id}?" + "noUsage": "No usage data found.", + "confirmRemove": "Remove key {id}?", + "confirmRegenerate": "Regenerate key {id}?", + "confirmRevoke": "Revoke key {id}?", + "confirmRotate": "Rotate key {id}? (old key will be invalidated after grace period)", + "regenerated": "Regenerated. New key: {key}", + "revoked": "Key {id} revoked.", + "rotated": "Key {id} rotated. New key ID: {newId}", + "revealWarning": "⚠ This will display the full unmasked key. Ensure your screen is private.", + "providerRequired": "Provider is required.", + "keyRequired": "API key is required.", + "stdinEmpty": "No API key provided via stdin.", + "unknownProvider": "Unknown provider: {provider}", + "policy": { + "title": "Key Policy", + "showDescription": "Show rate/cost policy for a key", + "setDescription": "Set rate/cost policy for a key", + "rateLimitOpt": "Max requests per minute", + "maxCostOpt": "Max cost per day (USD)", + "allowedModelsOpt": "Comma-separated list of allowed models", + "nothingToSet": "No policy fields provided. Use --rate-limit, --max-cost, or --allowed-models.", + "updated": "Policy updated." + }, + "expiration": { + "title": "Key Expiration", + "listDescription": "List keys expiring soon", + "daysOpt": "Show keys expiring within N days", + "none": "No keys expiring within {days} days.", + "listTitle": "Keys expiring within {days} days:" + } }, "stream": { "description": "Stream a chat response with SSE inspection modes", diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index 35ebc3f8fb..8e697d6d09 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -10,7 +10,9 @@ "no": "não", "confirm": "Tem certeza? (sim/não)", "dryRun": "[simulação] faria: {action}", - "cancelled": "Cancelado." + "cancelled": "Cancelado.", + "jsonOpt": "Saída em JSON", + "yesOpt": "Pular confirmação" }, "setup": { "title": "Configuração do OmniRoute", @@ -62,11 +64,51 @@ }, "keys": { "title": "Chaves de API", + "addDescription": "Adicionar ou atualizar uma chave de API para um provedor", + "listDescription": "Listar todas as chaves de API configuradas", + "removeDescription": "Remover uma chave de API de um provedor", + "regenerateDescription": "Regenerar uma chave de API do OmniRoute", + "revokeDescription": "Revogar uma chave de API do OmniRoute", + "revealDescription": "Revelar o valor completo da chave (sem máscara)", + "usageDescription": "Exibir uso recente de uma chave de API", + "usageLimitOpt": "Número de requisições recentes", + "rotateDescription": "Gerar nova chave e invalidar a antiga", + "graceOpt": "Período de carência (ms) antes de invalidar a chave antiga", + "stdinOpt": "Ler chave de API via stdin", "added": "Chave adicionada para {provider}.", "removed": "Chave removida.", "listed": "{count} chave(s).", "noKeys": "Nenhuma chave configurada.", - "confirmRemove": "Remover chave {id}?" + "noUsage": "Nenhum dado de uso encontrado.", + "confirmRemove": "Remover chave {id}?", + "confirmRegenerate": "Regenerar chave {id}?", + "confirmRevoke": "Revogar chave {id}?", + "confirmRotate": "Rotacionar chave {id}? (a chave antiga será invalidada após o período de carência)", + "regenerated": "Chave regenerada. Nova chave: {key}", + "revoked": "Chave {id} revogada.", + "rotated": "Chave {id} rotacionada. Novo ID: {newId}", + "revealWarning": "⚠ Isso exibirá a chave completa sem máscara. Certifique-se de que sua tela está privada.", + "providerRequired": "Provedor é obrigatório.", + "keyRequired": "Chave de API é obrigatória.", + "stdinEmpty": "Nenhuma chave fornecida via stdin.", + "unknownProvider": "Provedor desconhecido: {provider}", + "policy": { + "title": "Política da Chave", + "showDescription": "Exibir política de taxa/custo de uma chave", + "setDescription": "Definir política de taxa/custo de uma chave", + "rateLimitOpt": "Máximo de requisições por minuto", + "maxCostOpt": "Custo máximo por dia (USD)", + "allowedModelsOpt": "Lista de modelos permitidos (separados por vírgula)", + "nothingToSet": "Nenhum campo de política fornecido. Use --rate-limit, --max-cost ou --allowed-models.", + "updated": "Política atualizada." + }, + "expiration": { + "title": "Expiração de Chaves", + "listDescription": "Listar chaves prestes a expirar", + "daysOpt": "Exibir chaves que expiram em N dias", + "none": "Nenhuma chave expirando nos próximos {days} dias.", + "listTitle": "Chaves que expiram nos próximos {days} dias:" + } }, "stream": { "description": "Transmitir resposta de chat com modos de inspeção SSE", diff --git a/bin/cli/provider-store.mjs b/bin/cli/provider-store.mjs index 219861d45e..ac76666d39 100644 --- a/bin/cli/provider-store.mjs +++ b/bin/cli/provider-store.mjs @@ -241,6 +241,16 @@ export function upsertApiKeyProviderConnection(db, input) { return connection; } +export function removeProviderConnectionByProvider(db, provider) { + ensureProviderSchema(db); + const result = db + .prepare( + "DELETE FROM provider_connections WHERE provider = ? AND (auth_type = 'apikey' OR (api_key IS NOT NULL AND api_key != ''))" + ) + .run(provider); + return result.changes; +} + export function updateProviderTestResult(db, connectionId, result) { ensureProviderSchema(db); const now = new Date().toISOString(); diff --git a/tests/unit/cli-expanded-commands.test.ts b/tests/unit/cli-expanded-commands.test.ts index 84d725d115..cb92fe8afa 100644 --- a/tests/unit/cli-expanded-commands.test.ts +++ b/tests/unit/cli-expanded-commands.test.ts @@ -27,6 +27,28 @@ test("keys.mjs exporta novos comandos", async () => { assert.equal(typeof mod.runKeysRevokeCommand, "function"); assert.equal(typeof mod.runKeysRevealCommand, "function"); assert.equal(typeof mod.runKeysUsageCommand, "function"); + assert.equal(typeof mod.runKeysPolicyShowCommand, "function"); + assert.equal(typeof mod.runKeysPolicySetCommand, "function"); + assert.equal(typeof mod.runKeysExpirationListCommand, "function"); + assert.equal(typeof mod.runKeysRotateCommand, "function"); + assert.equal(typeof mod.runKeysListCommand, "function"); + assert.equal(typeof mod.runKeysRemoveCommand, "function"); +}); + +test("keys.mjs — runKeysListCommand sem server retorna 0 ou 1", async () => { + const { runKeysListCommand } = await import("../../bin/cli/commands/keys.mjs"); + let code = 0; + try { + code = await runKeysListCommand({ json: true }); + } catch { + code = 1; + } + assert.ok(code === 0 || code === 1); +}); + +test("provider-store.mjs exporta removeProviderConnectionByProvider", async () => { + const mod = await import("../../bin/cli/provider-store.mjs"); + assert.equal(typeof mod.removeProviderConnectionByProvider, "function"); }); test("health components com alertsOnly=true não lança", async () => {