diff --git a/src/plugin.ts b/src/plugin.ts index 80becc6..afeb507 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,6 +1,13 @@ import { exec } from "node:child_process"; import { tool } from "@opencode-ai/plugin"; -import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_ENDPOINT_PROD, ANTIGRAVITY_PROVIDER_ID, type HeaderStyle } from "./constants"; +import { + ANTIGRAVITY_DEFAULT_PROJECT_ID, + ANTIGRAVITY_ENDPOINT_FALLBACKS, + ANTIGRAVITY_ENDPOINT_PROD, + ANTIGRAVITY_HEADERS, + ANTIGRAVITY_PROVIDER_ID, + type HeaderStyle, +} from "./constants"; import { authorizeAntigravity, exchangeAntigravity } from "./antigravity/oauth"; import type { AntigravityTokenExchangeResult } from "./antigravity/oauth"; import { accessTokenExpired, isOAuthAuth, parseRefreshParts, formatRefreshParts } from "./plugin/auth"; @@ -260,6 +267,326 @@ async function openBrowser(url: string): Promise { } } +type VerificationProbeResult = { + status: "ok" | "blocked" | "error"; + message: string; + verifyUrl?: string; +}; + +function decodeEscapedText(input: string): string { + return input + .replace(/&/g, "&") + .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16))); +} + +function normalizeGoogleVerificationUrl(rawUrl: string): string | undefined { + const normalized = decodeEscapedText(rawUrl).trim(); + if (!normalized) { + return undefined; + } + try { + const parsed = new URL(normalized); + if (parsed.hostname !== "accounts.google.com") { + return undefined; + } + return parsed.toString(); + } catch { + return undefined; + } +} + +function selectBestVerificationUrl(urls: string[]): string | undefined { + const unique = Array.from(new Set(urls.map((url) => normalizeGoogleVerificationUrl(url)).filter(Boolean) as string[])); + if (unique.length === 0) { + return undefined; + } + unique.sort((a, b) => { + const score = (value: string): number => { + let total = 0; + if (value.includes("plt=")) total += 4; + if (value.includes("/signin/continue")) total += 3; + if (value.includes("continue=")) total += 2; + if (value.includes("service=cloudcode")) total += 1; + return total; + }; + return score(b) - score(a); + }); + return unique[0]; +} + +function extractVerificationErrorDetails(bodyText: string): { + validationRequired: boolean; + message?: string; + verifyUrl?: string; +} { + const decodedBody = decodeEscapedText(bodyText); + const lowerBody = decodedBody.toLowerCase(); + let validationRequired = lowerBody.includes("validation_required"); + let message: string | undefined; + const verificationUrls = new Set(); + + const collectUrlsFromText = (text: string): void => { + for (const match of text.matchAll(/https:\/\/accounts\.google\.com\/[^\s"'<>]+/gi)) { + if (match[0]) { + verificationUrls.add(match[0]); + } + } + }; + + collectUrlsFromText(decodedBody); + + const payloads: unknown[] = []; + const trimmed = decodedBody.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + payloads.push(JSON.parse(trimmed)); + } catch { + } + } + + for (const rawLine of decodedBody.split("\n")) { + const line = rawLine.trim(); + if (!line.startsWith("data:")) { + continue; + } + const payloadText = line.slice(5).trim(); + if (!payloadText || payloadText === "[DONE]") { + continue; + } + try { + payloads.push(JSON.parse(payloadText)); + } catch { + collectUrlsFromText(payloadText); + } + } + + const visited = new Set(); + const walk = (value: unknown, key?: string): void => { + if (typeof value === "string") { + const normalizedValue = decodeEscapedText(value); + const lowerValue = normalizedValue.toLowerCase(); + const lowerKey = key?.toLowerCase() ?? ""; + + if (lowerValue.includes("validation_required")) { + validationRequired = true; + } + if ( + !message && + (lowerKey.includes("message") || lowerKey.includes("detail") || lowerKey.includes("description")) + ) { + message = normalizedValue; + } + if ( + lowerKey.includes("validation_url") || + lowerKey.includes("verify_url") || + lowerKey.includes("verification_url") || + lowerKey === "url" + ) { + verificationUrls.add(normalizedValue); + } + collectUrlsFromText(normalizedValue); + return; + } + + if (!value || typeof value !== "object" || visited.has(value)) { + return; + } + + visited.add(value); + + if (Array.isArray(value)) { + for (const item of value) { + walk(item); + } + return; + } + + for (const [childKey, childValue] of Object.entries(value as Record)) { + walk(childValue, childKey); + } + }; + + for (const payload of payloads) { + walk(payload); + } + + if (!validationRequired) { + validationRequired = + lowerBody.includes("verification required") || + lowerBody.includes("verify your account") || + lowerBody.includes("account verification"); + } + + if (!message) { + const fallback = decodedBody + .split("\n") + .map((line) => line.trim()) + .find((line) => line && !line.startsWith("data:") && /(verify|validation|required)/i.test(line)); + if (fallback) { + message = fallback; + } + } + + return { + validationRequired, + message, + verifyUrl: selectBestVerificationUrl([...verificationUrls]), + }; +} + +async function verifyAccountAccess( + account: { + refreshToken: string; + email?: string; + projectId?: string; + managedProjectId?: string; + }, + client: PluginClient, + providerId: string, +): Promise { + const parsed = parseRefreshParts(account.refreshToken); + if (!parsed.refreshToken) { + return { status: "error", message: "Missing refresh token for selected account." }; + } + + const auth = { + type: "oauth" as const, + refresh: formatRefreshParts({ + refreshToken: parsed.refreshToken, + projectId: parsed.projectId ?? account.projectId, + managedProjectId: parsed.managedProjectId ?? account.managedProjectId, + }), + access: "", + expires: 0, + }; + + let refreshedAuth: Awaited>; + try { + refreshedAuth = await refreshAccessToken(auth, client, providerId); + } catch (error) { + if (error instanceof AntigravityTokenRefreshError) { + return { status: "error", message: error.message }; + } + return { status: "error", message: `Token refresh failed: ${String(error)}` }; + } + + if (!refreshedAuth?.access) { + return { status: "error", message: "Could not refresh access token for this account." }; + } + + const projectId = + parsed.managedProjectId ?? + parsed.projectId ?? + account.managedProjectId ?? + account.projectId ?? + ANTIGRAVITY_DEFAULT_PROJECT_ID; + + const headers: Record = { + ...ANTIGRAVITY_HEADERS, + Authorization: `Bearer ${refreshedAuth.access}`, + "Content-Type": "application/json", + }; + if (projectId) { + headers["x-goog-user-project"] = projectId; + } + + const requestBody = { + model: "gemini-3-flash", + request: { + model: "gemini-3-flash", + contents: [{ role: "user", parts: [{ text: "ping" }] }], + generationConfig: { maxOutputTokens: 1, temperature: 0 }, + }, + }; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 20000); + + let response: Response; + try { + response = await fetch(`${ANTIGRAVITY_ENDPOINT_PROD}/v1internal:streamGenerateContent?alt=sse`, { + method: "POST", + headers, + body: JSON.stringify(requestBody), + signal: controller.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + return { status: "error", message: "Verification check timed out." }; + } + return { status: "error", message: `Verification check failed: ${String(error)}` }; + } finally { + clearTimeout(timeoutId); + } + + let responseBody = ""; + try { + responseBody = await response.text(); + } catch { + responseBody = ""; + } + + if (response.ok) { + return { status: "ok", message: "Account verification check passed." }; + } + + const extracted = extractVerificationErrorDetails(responseBody); + if (response.status === 403 && extracted.validationRequired) { + return { + status: "blocked", + message: extracted.message ?? "Google requires additional account verification.", + verifyUrl: extracted.verifyUrl, + }; + } + + const fallbackMessage = extracted.message ?? `Request failed (${response.status} ${response.statusText}).`; + return { + status: "error", + message: fallbackMessage, + }; +} + +async function promptAccountIndexForVerification( + accounts: Array<{ email?: string; index: number }>, +): Promise { + const { createInterface } = await import("node:readline/promises"); + const { stdin, stdout } = await import("node:process"); + const rl = createInterface({ input: stdin, output: stdout }); + try { + console.log("\nSelect an account to verify:"); + for (const account of accounts) { + const label = account.email || `Account ${account.index + 1}`; + console.log(` ${account.index + 1}. ${label}`); + } + console.log(""); + + while (true) { + const answer = (await rl.question("Account number (leave blank to cancel): ")).trim(); + if (!answer) { + return undefined; + } + const parsedIndex = Number(answer); + if (!Number.isInteger(parsedIndex)) { + console.log("Please enter a valid account number."); + continue; + } + const normalizedIndex = parsedIndex - 1; + if (normalizedIndex < 0 || normalizedIndex >= accounts.length) { + console.log("Please enter a number from the list above."); + continue; + } + return normalizedIndex; + } + } finally { + rl.close(); + } +} + +async function promptOpenVerificationUrl(): Promise { + const answer = (await promptOAuthCallbackValue("Open verification URL in your browser now? [Y/n]: ")).trim().toLowerCase(); + return answer === "" || answer === "y" || answer === "yes"; +} + async function promptOAuthCallbackValue(message: string): Promise { const { createInterface } = await import("node:readline/promises"); const { stdin, stdout } = await import("node:process"); @@ -2290,6 +2617,58 @@ export const createAntigravityPlugin = (providerId: string) => async ( continue; } + if (menuResult.mode === "verify") { + let verifyAccountIndex = menuResult.verifyAccountIndex; + if (verifyAccountIndex === undefined) { + verifyAccountIndex = await promptAccountIndexForVerification(existingAccounts); + } + + if (verifyAccountIndex === undefined) { + console.log("\nVerification cancelled.\n"); + continue; + } + + const account = existingStorage.accounts[verifyAccountIndex]; + if (!account) { + console.log(`\nAccount ${verifyAccountIndex + 1} not found.\n`); + continue; + } + + const label = account.email || `Account ${verifyAccountIndex + 1}`; + console.log(`\nChecking verification status for ${label}...\n`); + + const verification = await verifyAccountAccess(account, client, providerId); + + if (verification.status === "ok") { + console.log(`✓ ${label} is ready for requests.\n`); + continue; + } + + if (verification.status === "blocked") { + console.log(`⚠ ${label} needs Google verification before it can be used.`); + if (verification.message) { + console.log(verification.message); + } + if (verification.verifyUrl) { + console.log(`\nVerification URL:\n${verification.verifyUrl}\n`); + if (await promptOpenVerificationUrl()) { + const opened = await openBrowser(verification.verifyUrl); + if (opened) { + console.log("Opened verification URL in your browser.\n"); + } else { + console.log("Could not open browser automatically. Please open the URL manually.\n"); + } + } + } else { + console.log("No verification URL was returned. Try re-authenticating this account.\n"); + } + continue; + } + + console.log(`✗ ${label}: ${verification.message}\n`); + continue; + } + break; } diff --git a/src/plugin/cli.ts b/src/plugin/cli.ts index f16ad6f..c8e7bf0 100644 --- a/src/plugin/cli.ts +++ b/src/plugin/cli.ts @@ -30,7 +30,7 @@ export async function promptAddAnotherAccount(currentCount: number): Promise { const items: MenuItem[] = [ - { label: 'Add new account', value: { type: 'add' } }, - { label: 'Check quotas', value: { type: 'check' } }, - { label: 'Manage accounts (enable/disable)', value: { type: 'manage' } }, - { label: 'Configure models in opencode config', value: { type: 'configure-models' } }, + { label: 'Actions', value: { type: 'cancel' }, kind: 'heading' }, + { label: 'Add account', value: { type: 'add' }, color: 'cyan' }, + { label: 'Check quotas', value: { type: 'check' }, color: 'cyan' }, + { label: 'Verify account access', value: { type: 'verify' }, color: 'cyan' }, + { label: 'Configure models in opencode.json', value: { type: 'configure-models' }, color: 'cyan' }, + + { label: '', value: { type: 'cancel' }, separator: true }, + + { label: 'Accounts', value: { type: 'cancel' }, kind: 'heading' }, ...accounts.map(account => { - const badge = getStatusBadge(account.status); + const statusBadge = getStatusBadge(account.status); + const currentBadge = account.isCurrentAccount ? ` ${ANSI.cyan}[current]${ANSI.reset}` : ''; const disabledBadge = account.enabled === false ? ` ${ANSI.red}[disabled]${ANSI.reset}` : ''; - const label = account.email || `Account ${account.index + 1}`; - const fullLabel = `${label}${badge ? ' ' + badge : ''}${disabledBadge}`; - + const baseLabel = account.email || `Account ${account.index + 1}`; + const numbered = `${account.index + 1}. ${baseLabel}`; + const fullLabel = `${numbered}${currentBadge}${statusBadge ? ' ' + statusBadge : ''}${disabledBadge}`; + return { label: fullLabel, hint: account.lastUsed ? `used ${formatRelativeTime(account.lastUsed)}` : '', @@ -69,13 +76,17 @@ export async function showAuthMenu(accounts: AccountInfo[]): Promise { hint?: string; disabled?: boolean; separator?: boolean; + /** Non-selectable label row (section heading). */ + kind?: 'heading'; color?: 'red' | 'green' | 'yellow' | 'cyan'; } export interface SelectOptions { message: string; subtitle?: string; + /** Override the help line shown at the bottom of the menu. */ + help?: string; + /** + * Clear the terminal before each render (opt-in). + * Useful for nested flows where previous logs make menus feel cluttered. + */ + clearScreen?: boolean; } const ESCAPE_TIMEOUT_MS = 50; +const ANSI_REGEX = new RegExp("\\x1b\\[[0-9;]*m", "g"); +const ANSI_LEADING_REGEX = new RegExp("^\\x1b\\[[0-9;]*m"); + +function stripAnsi(input: string): string { + return input.replace(ANSI_REGEX, ''); +} + +function truncateAnsi(input: string, maxVisibleChars: number): string { + if (maxVisibleChars <= 0) return ''; + + const visible = stripAnsi(input); + if (visible.length <= maxVisibleChars) return input; + + const suffix = maxVisibleChars >= 3 ? '...' : '.'.repeat(maxVisibleChars); + const keep = Math.max(0, maxVisibleChars - suffix.length); + + let out = ''; + let i = 0; + let kept = 0; + + while (i < input.length && kept < keep) { + // Preserve ANSI sequences without counting them. + if (input[i] === '\x1b') { + const m = input.slice(i).match(ANSI_LEADING_REGEX); + if (m) { + out += m[0]; + i += m[0].length; + continue; + } + } + + out += input[i]; + i += 1; + kept += 1; + } + + return out + suffix; +} + function getColorCode(color: MenuItem['color']): string { switch (color) { case 'red': return ANSI.red; @@ -38,7 +86,8 @@ export async function select( throw new Error('No menu items provided'); } - const enabledItems = items.filter(i => !i.disabled && !i.separator); + const isSelectable = (i: MenuItem) => !i.disabled && !i.separator && i.kind !== 'heading'; + const enabledItems = items.filter(isSelectable); if (enabledItems.length === 0) { throw new Error('All items disabled'); } @@ -50,43 +99,75 @@ export async function select( const { message, subtitle } = options; const { stdin, stdout } = process; - let cursor = items.findIndex(i => !i.disabled && !i.separator); + let cursor = items.findIndex(isSelectable); if (cursor === -1) cursor = 0; // Fallback, though validation above should prevent this let escapeTimeout: ReturnType | null = null; let isCleanedUp = false; - let isFirstRender = true; - - const getTotalLines = (): number => { - const subtitleLines = subtitle ? 3 : 0; - return 1 + subtitleLines + items.length + 1 + 1; - }; + let renderedLines = 0; const render = () => { - const totalLines = getTotalLines(); + const columns = stdout.columns ?? 80; + const rows = stdout.rows ?? 24; + const shouldClearScreen = options.clearScreen === true; + const previousRenderedLines = renderedLines; - if (!isFirstRender) { - stdout.write(ANSI.up(totalLines) + '\r'); + if (shouldClearScreen) { + stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1)); + } else if (previousRenderedLines > 0) { + stdout.write(ANSI.up(previousRenderedLines)); } - isFirstRender = false; - stdout.write(`${ANSI.clearLine}${ANSI.dim}┌ ${ANSI.reset}${message}\n`); + let linesWritten = 0; + const writeLine = (line: string) => { + stdout.write(`${ANSI.clearLine}${line}\n`); + linesWritten += 1; + }; + + // Subtitle renders as 3 lines: + // 1) blank "│" spacer, 2) subtitle line, 3) blank line. Header is counted separately. + const subtitleLines = subtitle ? 3 : 0; + const fixedLines = 1 + subtitleLines + 2; // header + subtitle + (help + bottom) + // Keep a small safety margin so the final newline doesn't scroll the terminal. + const maxVisibleItems = Math.max(1, Math.min(items.length, rows - fixedLines - 1)); + + // If the menu is taller than the viewport, only render a window around the cursor. + // This prevents terminal scrollback spam (e.g. repeated headers when pressing arrows). + let windowStart = 0; + let windowEnd = items.length; + if (items.length > maxVisibleItems) { + windowStart = cursor - Math.floor(maxVisibleItems / 2); + windowStart = Math.max(0, Math.min(windowStart, items.length - maxVisibleItems)); + windowEnd = windowStart + maxVisibleItems; + } + + const visibleItems = items.slice(windowStart, windowEnd); + const headerMessage = truncateAnsi(message, Math.max(1, columns - 4)); + writeLine(`${ANSI.dim}┌ ${ANSI.reset}${headerMessage}`); if (subtitle) { - stdout.write(`${ANSI.clearLine}${ANSI.dim}│${ANSI.reset}\n`); - stdout.write(`${ANSI.clearLine}${ANSI.cyan}◆${ANSI.reset} ${subtitle}\n`); - stdout.write(`${ANSI.clearLine}\n`); + writeLine(`${ANSI.dim}│${ANSI.reset}`); + const sub = truncateAnsi(subtitle, Math.max(1, columns - 4)); + writeLine(`${ANSI.cyan}◆${ANSI.reset} ${sub}`); + writeLine(""); } - for (let i = 0; i < items.length; i++) { - const item = items[i]; + for (let i = 0; i < visibleItems.length; i++) { + const itemIndex = windowStart + i; + const item = visibleItems[i]; if (!item) continue; if (item.separator) { - stdout.write(`${ANSI.clearLine}${ANSI.dim}│${ANSI.reset}\n`); + writeLine(`${ANSI.dim}│${ANSI.reset}`); continue; } - const isSelected = i === cursor; + if (item.kind === 'heading') { + const heading = truncateAnsi(`${ANSI.dim}${ANSI.bold}${item.label}${ANSI.reset}`, Math.max(1, columns - 6)); + writeLine(`${ANSI.cyan}│${ANSI.reset} ${heading}`); + continue; + } + + const isSelected = itemIndex === cursor; const colorCode = getColorCode(item.color); let labelText: string; @@ -102,15 +183,32 @@ export async function select( if (item.hint) labelText += ` ${ANSI.dim}${item.hint}${ANSI.reset}`; } + // Prevent wrapping: cursor positioning relies on a fixed line count. + labelText = truncateAnsi(labelText, Math.max(1, columns - 8)); + if (isSelected) { - stdout.write(`${ANSI.clearLine}${ANSI.cyan}│${ANSI.reset} ${ANSI.green}●${ANSI.reset} ${labelText}\n`); + writeLine(`${ANSI.cyan}│${ANSI.reset} ${ANSI.green}●${ANSI.reset} ${labelText}`); } else { - stdout.write(`${ANSI.clearLine}${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}○${ANSI.reset} ${labelText}\n`); + writeLine(`${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}○${ANSI.reset} ${labelText}`); } } - stdout.write(`${ANSI.clearLine}${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}↑/↓ to select • Enter: confirm${ANSI.reset}\n`); - stdout.write(`${ANSI.clearLine}${ANSI.cyan}└${ANSI.reset}\n`); + const windowHint = items.length > visibleItems.length + ? ` (${windowStart + 1}-${windowEnd}/${items.length})` + : ''; + const helpText = options.help ?? `Up/Down to select | Enter: confirm | Esc: back${windowHint}`; + const help = truncateAnsi(helpText, Math.max(1, columns - 6)); + writeLine(`${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}${help}${ANSI.reset}`); + writeLine(`${ANSI.cyan}└${ANSI.reset}`); + + if (!shouldClearScreen && previousRenderedLines > linesWritten) { + const extra = previousRenderedLines - linesWritten; + for (let i = 0; i < extra; i++) { + writeLine(""); + } + } + + renderedLines = linesWritten; }; return new Promise((resolve) => { @@ -154,7 +252,7 @@ export async function select( let next = from; do { next = (next + direction + items.length) % items.length; - } while (items[next]?.disabled || items[next]?.separator); + } while (items[next]?.disabled || items[next]?.separator || items[next]?.kind === 'heading'); return next; };