mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
feat(cli): fase 4.1 — comando oauth com fluxos browser/import/social/device
This commit is contained in:
249
bin/cli/commands/oauth.mjs
Normal file
249
bin/cli/commands/oauth.mjs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
203
tests/unit/cli-oauth-commands.test.ts
Normal file
203
tests/unit/cli-oauth-commands.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user