259 lines
9.1 KiB
JavaScript
259 lines
9.1 KiB
JavaScript
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;
|
|
|
|
if (process.stdout.isTTY && opts.browser !== false) {
|
|
const { startOAuthTui } = await import("../tui/OAuthFlow.jsx");
|
|
await openBrowser(url);
|
|
const tuiResult = await startOAuthTui({ provider: def.name ?? def.id, url });
|
|
if (tuiResult.status === "cancelled") return;
|
|
} else {
|
|
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);
|
|
}
|