feat(cli): fase 4.1 — comando oauth com fluxos browser/import/social/device

This commit is contained in:
diegosouzapw
2026-05-15 01:51:07 -03:00
parent 28629bf7f0
commit 8c48abc40c
5 changed files with 758 additions and 0 deletions

249
bin/cli/commands/oauth.mjs Normal file
View File

@@ -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 <google|github> 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 <id>", t("oauth.start.provider"))
.option("--no-browser", t("oauth.start.no_browser"))
.option("--import-from-system", t("oauth.start.import_system"))
.option("--social <s>", t("oauth.start.social"))
.option("--timeout <ms>", t("oauth.start.timeout"), parseInt, 300000)
.action(runOAuthStart);
oauth
.command("status")
.description(t("oauth.status.description"))
.option("--provider <id>", t("oauth.status.provider"))
.action(runOAuthStatus);
oauth
.command("revoke")
.description(t("oauth.revoke.description"))
.requiredOption("--provider <id>", t("oauth.revoke.provider"))
.option("--connection-id <id>", t("oauth.revoke.connection_id"))
.option("--yes", t("oauth.revoke.yes"))
.action(runOAuthRevoke);
}

View File

@@ -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);

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<void>): Promise<string> {
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);
});