From 8c48abc40c19b329e094829d55cc04cb3e40e299 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 15 May 2026 01:51:07 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20fase=204.1=20=E2=80=94=20comando?= =?UTF-8?q?=20oauth=20com=20fluxos=20browser/import/social/device?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/cli/commands/oauth.mjs | 249 ++++++++++++++++++++++++++ bin/cli/commands/registry.mjs | 8 + bin/cli/locales/en.json | 149 +++++++++++++++ bin/cli/locales/pt-BR.json | 149 +++++++++++++++ tests/unit/cli-oauth-commands.test.ts | 203 +++++++++++++++++++++ 5 files changed, 758 insertions(+) create mode 100644 bin/cli/commands/oauth.mjs create mode 100644 tests/unit/cli-oauth-commands.test.ts diff --git a/bin/cli/commands/oauth.mjs b/bin/cli/commands/oauth.mjs new file mode 100644 index 0000000000..44f785eb8f --- /dev/null +++ b/bin/cli/commands/oauth.mjs @@ -0,0 +1,249 @@ +import { setTimeout as sleep } from "node:timers/promises"; +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { t } from "../i18n.mjs"; + +const PROVIDERS_WITH_OAUTH = [ + { id: "gemini", name: "Google Gemini", flow: "browser" }, + { id: "antigravity", name: "Antigravity", flow: "browser" }, + { id: "windsurf", name: "Windsurf", flow: "browser" }, + { id: "qwen", name: "Qwen Code", flow: "browser" }, + { id: "cursor", name: "Cursor", flow: "import" }, + { id: "zed", name: "Zed", flow: "import" }, + { id: "kiro", name: "Amazon Kiro", flow: "social" }, + { id: "claude-code", name: "Claude Code (OAuth)", flow: "device" }, + { id: "codex", name: "OpenAI Codex (OAuth)", flow: "device" }, + { id: "copilot", name: "GitHub Copilot", flow: "device" }, +]; + +const oauthProviderSchema = [ + { key: "id", header: "Provider ID", width: 16 }, + { key: "name", header: "Name", width: 28 }, + { key: "flow", header: "Flow", width: 10 }, +]; + +const connectionSchema = [ + { key: "id", header: "Connection ID", width: 22 }, + { key: "provider", header: "Provider", width: 16 }, + { key: "name", header: "Name", width: 24 }, + { key: "isActive", header: "Active", formatter: (v) => (v ? "✓" : "✗") }, + { key: "testStatus", header: "Status", width: 12 }, +]; + +async function openBrowser(url) { + try { + const { default: open } = await import("open"); + await open(url); + } catch { + // open package not available, ignore silently + } +} + +async function pollStatus(endpoint, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await sleep(2000); + const res = await apiFetch(endpoint); + if (!res.ok) continue; + const data = await res.json(); + if (data.status === "complete" || data.status === "completed") return data; + if (data.status === "error" || data.status === "failed") { + process.stderr.write(`OAuth failed: ${data.error ?? data.message ?? "unknown"}\n`); + process.exit(1); + } + } + process.stderr.write("Timeout waiting for OAuth callback\n"); + process.exit(124); +} + +async function runBrowserFlow(def, opts) { + const startRes = await apiFetch(`/api/oauth/${def.id}/start`, { method: "POST" }); + if (!startRes.ok) { + process.stderr.write(`Failed to start OAuth for ${def.id}: ${startRes.status}\n`); + process.exit(1); + } + const start = await startRes.json(); + const url = start.authorizeUrl ?? start.url; + process.stdout.write(`\nOpen this URL to authorize:\n ${url}\n\n`); + if (opts.browser !== false) await openBrowser(url); + process.stderr.write("Waiting for authorization... (Ctrl+C to cancel)\n"); + const result = await pollStatus( + `/api/oauth/${def.id}/status?state=${encodeURIComponent(start.state ?? "")}`, + opts.timeout ?? 300000 + ); + process.stdout.write( + `Authorized: ${result.email ?? result.userId ?? result.account ?? "connected"}\n` + ); +} + +async function runImportFlow(def, opts) { + const endpoint = opts.importFromSystem + ? `/api/oauth/${def.id}/auto-import` + : `/api/oauth/${def.id}/import`; + const res = await apiFetch(endpoint, { method: "POST" }); + if (!res.ok) { + process.stderr.write(`Import failed: ${res.status}\n`); + process.exit(1); + } + const data = await res.json(); + process.stdout.write(`Imported ${data.count ?? 0} connection(s) from ${def.name}\n`); +} + +async function runSocialFlow(def, opts) { + let social = opts.social; + if (!social) { + process.stderr.write("--social required for kiro\n"); + process.exit(2); + } + const startRes = await apiFetch(`/api/oauth/${def.id}/social-authorize`, { + method: "POST", + body: { social }, + }); + if (!startRes.ok) { + process.stderr.write(`Failed: ${startRes.status}\n`); + process.exit(1); + } + const start = await startRes.json(); + const url = start.authorizeUrl ?? start.url; + process.stdout.write(`\nOpen this URL:\n ${url}\n\n`); + if (opts.browser !== false) await openBrowser(url); + process.stderr.write("Waiting for social authorization...\n"); + const result = await pollStatus( + `/api/oauth/${def.id}/social-exchange?state=${encodeURIComponent(start.state ?? "")}`, + opts.timeout ?? 300000 + ); + process.stdout.write(`Authorized: ${result.email ?? result.userId ?? "connected"}\n`); +} + +async function runDeviceFlow(def, opts) { + const providerKey = def.id === "claude-code" ? "command-code" : def.id; + const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, { method: "POST" }); + if (!startRes.ok) { + process.stderr.write(`Failed to start device flow: ${startRes.status}\n`); + process.exit(1); + } + const start = await startRes.json(); + process.stdout.write( + `\nDevice code: ${start.userCode ?? start.user_code ?? ""}\nVisit: ${start.verificationUri ?? start.verification_uri}\n\n` + ); + if (opts.browser !== false) + await openBrowser(start.verificationUri ?? start.verification_uri ?? ""); + process.stderr.write("Waiting for device authorization...\n"); + const deadline = Date.now() + (opts.timeout ?? 300000); + const intervalMs = (start.intervalMs ?? start.interval ?? 5) * 1000; + while (Date.now() < deadline) { + await sleep(intervalMs); + const statusRes = await apiFetch( + `/api/providers/${providerKey}/auth/status?state=${encodeURIComponent(start.state ?? "")}` + ); + if (!statusRes.ok) continue; + const status = await statusRes.json(); + if (status.status === "complete" || status.status === "authorized") { + await apiFetch(`/api/providers/${providerKey}/auth/apply`, { + method: "POST", + body: { state: start.state }, + }); + process.stdout.write(`Authorized: ${status.account ?? status.email ?? "connected"}\n`); + return; + } + if (status.status === "error") { + process.stderr.write(`Device auth failed: ${status.error}\n`); + process.exit(1); + } + } + process.stderr.write("Timeout\n"); + process.exit(124); +} + +export async function runOAuthStart(opts, cmd) { + const def = PROVIDERS_WITH_OAUTH.find((p) => p.id === opts.provider); + if (!def) { + process.stderr.write( + `Unknown OAuth provider: ${opts.provider}\nRun: omniroute oauth providers\n` + ); + process.exit(2); + } + switch (def.flow) { + case "browser": + return runBrowserFlow(def, opts); + case "import": + return runImportFlow(def, opts); + case "social": + return runSocialFlow(def, opts); + case "device": + return runDeviceFlow(def, opts); + } +} + +export async function runOAuthStatus(opts, cmd) { + const globalOpts = cmd.optsWithGlobals(); + const params = new URLSearchParams(); + if (opts.provider) params.set("provider", opts.provider); + const res = await apiFetch(`/api/providers?${params}`); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const data = await res.json(); + const connections = (data.providers ?? data.items ?? data).filter( + (c) => c.authType === "oauth" || c.authType === "oauth2" + ); + emit(connections, globalOpts, connectionSchema); +} + +export async function runOAuthRevoke(opts, cmd) { + if (!opts.yes) { + process.stdout.write( + `Revoke OAuth for ${opts.provider}${opts.connectionId ? ` (${opts.connectionId})` : ""}? (yes/no) ` + ); + const answer = await new Promise((resolve) => { + process.stdin.setEncoding("utf8"); + process.stdin.once("data", (c) => resolve(c.toString().trim().toLowerCase())); + }); + if (!answer.startsWith("y")) process.exit(0); + } + const id = opts.connectionId; + const res = id + ? await apiFetch(`/api/providers/${id}`, { method: "DELETE" }) + : await apiFetch(`/api/oauth/${opts.provider}/revoke`, { method: "POST" }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + process.stdout.write(`Revoked\n`); +} + +export function registerOAuth(program) { + const oauth = program.command("oauth").description(t("oauth.description")); + + oauth + .command("providers") + .description(t("oauth.providers.description")) + .action(async (opts, cmd) => { + emit(PROVIDERS_WITH_OAUTH, cmd.optsWithGlobals(), oauthProviderSchema); + }); + + oauth + .command("start") + .description(t("oauth.start.description")) + .requiredOption("--provider ", t("oauth.start.provider")) + .option("--no-browser", t("oauth.start.no_browser")) + .option("--import-from-system", t("oauth.start.import_system")) + .option("--social ", t("oauth.start.social")) + .option("--timeout ", t("oauth.start.timeout"), parseInt, 300000) + .action(runOAuthStart); + + oauth + .command("status") + .description(t("oauth.status.description")) + .option("--provider ", t("oauth.status.provider")) + .action(runOAuthStatus); + + oauth + .command("revoke") + .description(t("oauth.revoke.description")) + .requiredOption("--provider ", t("oauth.revoke.provider")) + .option("--connection-id ", t("oauth.revoke.connection_id")) + .option("--yes", t("oauth.revoke.yes")) + .action(runOAuthRevoke); +} diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 7d4e3c07db..b50417235e 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -1,6 +1,10 @@ import { registerMemory } from "./memory.mjs"; import { registerSkills } from "./skills.mjs"; import { registerAudit } from "./audit.mjs"; +import { registerOAuth } from "./oauth.mjs"; +import { registerCloud } from "./cloud.mjs"; +import { registerEval } from "./eval.mjs"; +import { registerWebhooks } from "./webhooks.mjs"; import { registerChat } from "./chat.mjs"; import { registerStream } from "./stream.mjs"; import { registerSimulate } from "./simulate.mjs"; @@ -36,6 +40,10 @@ export function registerCommands(program) { registerMemory(program); registerSkills(program); registerAudit(program); + registerOAuth(program); + registerCloud(program); + registerEval(program); + registerWebhooks(program); registerChat(program); registerStream(program); registerSimulate(program); diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index 69f3615ff5..5768c3948e 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -416,6 +416,155 @@ "description": "Show memory subsystem health (FTS5 + Qdrant)" } }, + "oauth": { + "description": "Manage OAuth provider connections", + "providers": { + "description": "List OAuth-capable providers and their flow types" + }, + "start": { + "description": "Start OAuth authorization flow for a provider", + "provider": "Provider ID (gemini, copilot, cursor, …)", + "no_browser": "Print URL only — do not open browser", + "import_system": "Auto-import credentials from local system config", + "social": "Social login provider (google|github) — required for kiro", + "timeout": "Timeout waiting for authorization in ms (default: 300000)" + }, + "status": { + "description": "List active OAuth connections", + "provider": "Filter by provider ID" + }, + "revoke": { + "description": "Revoke an OAuth connection", + "provider": "Provider ID to revoke", + "connection_id": "Revoke a specific connection by ID", + "yes": "Skip confirmation prompt" + } + }, + "cloud": { + "description": "Manage cloud AI agent tasks (codex, devin, jules)", + "agents": { + "description": "List available cloud agents" + }, + "agent": { + "description": "Manage {agent} cloud agent tasks", + "auth": { + "description": "Authorize {agent} via OAuth" + } + }, + "task": { + "description": "Manage cloud agent tasks", + "create": { + "description": "Create a new agent task", + "title": "Task title (defaults to first 80 chars of prompt)", + "prompt": "Task prompt text", + "prompt_file": "Read prompt from file", + "repo": "Repository URL to clone for the task", + "branch": "Branch name to use", + "metadata": "JSON metadata object" + }, + "list": { + "description": "List agent tasks", + "status": "Filter by status (running|completed|failed|cancelled)", + "limit": "Maximum results (default: 50)" + }, + "get": { + "description": "Get task details by ID" + }, + "status": { + "description": "Print task status (running|completed|failed|cancelled)" + }, + "cancel": { + "description": "Cancel a running task", + "yes": "Skip confirmation prompt" + }, + "approve": { + "description": "Approve the agent plan to start execution" + }, + "message": { + "description": "Send a message to a running task" + } + }, + "sources": { + "description": "List source files produced by a task" + } + }, + "eval": { + "description": "Manage eval suites and runs", + "suites": { + "description": "Manage eval suites", + "list": { + "description": "List eval suites" + }, + "get": { + "description": "Get eval suite details by ID" + }, + "create": { + "description": "Create an eval suite from a JSON file", + "file": "Path to suite definition JSON file" + } + }, + "run": { + "description": "Start an eval run for a suite", + "model": "Model ID to evaluate (default: auto)", + "combo": "Force a specific combo by name", + "concurrency": "Number of concurrent samples (default: 4)", + "tag": "Tag this run for later filtering", + "watch": "Watch run progress until completion" + }, + "list": { + "description": "List eval runs", + "suite": "Filter by suite ID", + "status": "Filter by status", + "since": "Return runs since this timestamp", + "limit": "Maximum results (default: 50)" + }, + "get": { + "description": "Get eval run details by ID" + }, + "results": { + "description": "Show sample results for an eval run", + "failed": "Show only failed samples" + }, + "cancel": { + "description": "Cancel a running eval", + "yes": "Skip confirmation prompt" + }, + "scorecard": { + "description": "Show scorecard for a completed eval run" + } + }, + "webhooks": { + "description": "Manage OmniRoute webhooks", + "events": { + "description": "List all available webhook event types" + }, + "list": { + "description": "List configured webhooks" + }, + "get": { + "description": "Get webhook details by ID" + }, + "add": { + "description": "Register a new webhook", + "url": "Destination URL", + "events": "Comma-separated list of event types", + "secret": "HMAC signing secret", + "header": "Extra header in key=value format (repeatable)", + "no_enabled": "Create webhook in disabled state" + }, + "update": { + "description": "Update an existing webhook", + "enabled": "Set enabled state (true|false)" + }, + "remove": { + "description": "Delete a webhook", + "yes": "Skip confirmation prompt" + }, + "test": { + "description": "Send a test event to a webhook", + "event": "Event type to simulate (default: request.completed)" + } + }, "program": { "description": "OmniRoute — Smart AI Router with Auto Fallback", "version": "Print version and exit", diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index e4dc3203ec..fcf15760e1 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -416,6 +416,155 @@ "description": "Exibir saúde do subsistema de memória (FTS5 + Qdrant)" } }, + "oauth": { + "description": "Gerenciar conexões OAuth de provedores", + "providers": { + "description": "Listar provedores com suporte OAuth e seus tipos de fluxo" + }, + "start": { + "description": "Iniciar fluxo de autorização OAuth para um provedor", + "provider": "ID do provedor (gemini, copilot, cursor, …)", + "no_browser": "Apenas exibir URL — não abrir navegador", + "import_system": "Auto-importar credenciais da configuração local do sistema", + "social": "Provedor de login social (google|github) — obrigatório para kiro", + "timeout": "Timeout aguardando autorização em ms (padrão: 300000)" + }, + "status": { + "description": "Listar conexões OAuth ativas", + "provider": "Filtrar por ID do provedor" + }, + "revoke": { + "description": "Revogar uma conexão OAuth", + "provider": "ID do provedor a revogar", + "connection_id": "Revogar uma conexão específica por ID", + "yes": "Pular confirmação" + } + }, + "cloud": { + "description": "Gerenciar tarefas de agentes cloud de IA (codex, devin, jules)", + "agents": { + "description": "Listar agentes cloud disponíveis" + }, + "agent": { + "description": "Gerenciar tarefas do agente cloud {agent}", + "auth": { + "description": "Autorizar {agent} via OAuth" + } + }, + "task": { + "description": "Gerenciar tarefas de agentes cloud", + "create": { + "description": "Criar nova tarefa de agente", + "title": "Título da tarefa (padrão: primeiros 80 chars do prompt)", + "prompt": "Texto do prompt da tarefa", + "prompt_file": "Ler prompt de arquivo", + "repo": "URL do repositório para clonar na tarefa", + "branch": "Nome do branch a usar", + "metadata": "Objeto JSON de metadados" + }, + "list": { + "description": "Listar tarefas de agentes", + "status": "Filtrar por status (running|completed|failed|cancelled)", + "limit": "Máximo de resultados (padrão: 50)" + }, + "get": { + "description": "Obter detalhes da tarefa por ID" + }, + "status": { + "description": "Exibir status da tarefa (running|completed|failed|cancelled)" + }, + "cancel": { + "description": "Cancelar uma tarefa em execução", + "yes": "Pular confirmação" + }, + "approve": { + "description": "Aprovar o plano do agente para iniciar execução" + }, + "message": { + "description": "Enviar mensagem para uma tarefa em execução" + } + }, + "sources": { + "description": "Listar arquivos de origem produzidos por uma tarefa" + } + }, + "eval": { + "description": "Gerenciar suites e execuções de avaliação", + "suites": { + "description": "Gerenciar suites de avaliação", + "list": { + "description": "Listar suites de avaliação" + }, + "get": { + "description": "Obter detalhes de suite por ID" + }, + "create": { + "description": "Criar suite de avaliação a partir de arquivo JSON", + "file": "Caminho para arquivo JSON de definição da suite" + } + }, + "run": { + "description": "Iniciar execução de avaliação para uma suite", + "model": "ID do modelo a avaliar (padrão: auto)", + "combo": "Forçar combo específico por nome", + "concurrency": "Número de amostras concorrentes (padrão: 4)", + "tag": "Tag para esta execução", + "watch": "Monitorar progresso até conclusão" + }, + "list": { + "description": "Listar execuções de avaliação", + "suite": "Filtrar por ID de suite", + "status": "Filtrar por status", + "since": "Retornar execuções desde este timestamp", + "limit": "Máximo de resultados (padrão: 50)" + }, + "get": { + "description": "Obter detalhes de execução por ID" + }, + "results": { + "description": "Exibir resultados de amostras de uma execução", + "failed": "Exibir apenas amostras que falharam" + }, + "cancel": { + "description": "Cancelar uma avaliação em execução", + "yes": "Pular confirmação" + }, + "scorecard": { + "description": "Exibir scorecard de uma execução concluída" + } + }, + "webhooks": { + "description": "Gerenciar webhooks do OmniRoute", + "events": { + "description": "Listar todos os tipos de evento disponíveis para webhooks" + }, + "list": { + "description": "Listar webhooks configurados" + }, + "get": { + "description": "Obter detalhes de webhook por ID" + }, + "add": { + "description": "Registrar novo webhook", + "url": "URL de destino", + "events": "Lista de tipos de evento separados por vírgula", + "secret": "Segredo HMAC de assinatura", + "header": "Header extra no formato chave=valor (repetível)", + "no_enabled": "Criar webhook em estado desabilitado" + }, + "update": { + "description": "Atualizar webhook existente", + "enabled": "Definir estado habilitado (true|false)" + }, + "remove": { + "description": "Deletar webhook", + "yes": "Pular confirmação" + }, + "test": { + "description": "Enviar evento de teste para um webhook", + "event": "Tipo de evento a simular (padrão: request.completed)" + } + }, "program": { "description": "OmniRoute — Roteador de IA com Fallback Automático", "version": "Exibir versão e sair", diff --git a/tests/unit/cli-oauth-commands.test.ts b/tests/unit/cli-oauth-commands.test.ts new file mode 100644 index 0000000000..e7a4cde103 --- /dev/null +++ b/tests/unit/cli-oauth-commands.test.ts @@ -0,0 +1,203 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const CONNECTIONS = [ + { + id: "conn1", + provider: "gemini", + name: "My Gemini", + authType: "oauth", + isActive: true, + testStatus: "ok", + }, + { + id: "conn2", + provider: "copilot", + name: "Copilot", + authType: "oauth2", + isActive: true, + testStatus: "ok", + }, + { + id: "conn3", + provider: "openai", + name: "OpenAI Key", + authType: "api_key", + isActive: true, + testStatus: "ok", + }, +]; + +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("runOAuthStatus filtra apenas conexões oauth/oauth2", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + assert.ok(url.includes("/api/providers")); + return Promise.resolve(makeResp({ providers: CONNECTIONS })); + }) as any; + + const { runOAuthStatus } = await import("../../bin/cli/commands/oauth.mjs"); + const out = await captureStdout(() => runOAuthStatus({}, makeCmd() as any)); + + globalThis.fetch = origFetch; + const parsed = JSON.parse(out); + assert.ok(Array.isArray(parsed)); + assert.equal(parsed.length, 2); + assert.ok(parsed.every((c: any) => c.authType === "oauth" || c.authType === "oauth2")); +}); + +test("runOAuthStatus filtra por provider", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve( + makeResp({ providers: CONNECTIONS.filter((c) => c.provider === "gemini") }) + ); + }) as any; + + const { runOAuthStatus } = await import("../../bin/cli/commands/oauth.mjs"); + await captureStdout(() => runOAuthStatus({ provider: "gemini" }, makeCmd() as any)); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("provider=gemini")); +}); + +test("runOAuthRevoke com --yes chama endpoint de revogação", 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({})); + }) as any; + + const out = await captureStdout(async () => { + const { runOAuthRevoke } = await import("../../bin/cli/commands/oauth.mjs"); + await runOAuthRevoke({ provider: "gemini", yes: true }, makeCmd() as any); + }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/oauth/gemini/revoke")); + assert.equal(capturedMethod, "POST"); + assert.ok(out.includes("Revoked")); +}); + +test("runOAuthRevoke com connectionId usa DELETE no provider", 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({})); + }) as any; + + const out = await captureStdout(async () => { + const { runOAuthRevoke } = await import("../../bin/cli/commands/oauth.mjs"); + await runOAuthRevoke( + { provider: "gemini", connectionId: "conn1", yes: true }, + makeCmd() as any + ); + }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/providers/conn1")); + assert.equal(capturedMethod, "DELETE"); + assert.ok(out.includes("Revoked")); +}); + +test("runOAuthStart flow=import chama endpoint de import", 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({ count: 3 })); + }) as any; + + const out = await captureStdout(async () => { + const { runOAuthStart } = await import("../../bin/cli/commands/oauth.mjs"); + await runOAuthStart({ provider: "cursor" }, makeCmd() as any); + }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/oauth/cursor/import")); + assert.equal(capturedMethod, "POST"); + assert.ok(out.includes("3")); +}); + +test("runOAuthStart flow=import com --import-from-system usa auto-import", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ count: 1 })); + }) as any; + + const out = await captureStdout(async () => { + const { runOAuthStart } = await import("../../bin/cli/commands/oauth.mjs"); + await runOAuthStart({ provider: "zed", importFromSystem: true }, makeCmd() as any); + }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/oauth/zed/auto-import")); + assert.ok(out.includes("1")); +}); + +test("providers lista provedores OAuth conhecidos", async () => { + const { PROVIDERS_WITH_OAUTH_TEST } = await import("../../bin/cli/commands/oauth.mjs").catch( + () => ({ PROVIDERS_WITH_OAUTH_TEST: null }) + ); + // validate via runOAuthStart unknown provider exits + const origExit = process.exit; + let exitCode: number | undefined; + process.exit = ((code: number) => { + exitCode = code; + throw new Error("exit"); + }) as any; + + try { + const { runOAuthStart } = await import("../../bin/cli/commands/oauth.mjs"); + await runOAuthStart({ provider: "unknown_provider_xyz" }, makeCmd() as any).catch(() => {}); + } catch { + // expected + } + + process.exit = origExit; + assert.equal(exitCode, 2); +});