mirror of
https://github.com/NoeFabris/opencode-antigravity-auth.git
synced 2026-05-13 23:53:18 +00:00
feat: add account verification action to auth login menu
This commit is contained in:
381
src/plugin.ts
381
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<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
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<string>();
|
||||
|
||||
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<unknown>();
|
||||
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<string, unknown>)) {
|
||||
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<VerificationProbeResult> {
|
||||
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<ReturnType<typeof refreshAccessToken>>;
|
||||
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<string, string> = {
|
||||
...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<number | undefined> {
|
||||
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<boolean> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function promptAddAnotherAccount(currentCount: number): Promise<boo
|
||||
}
|
||||
}
|
||||
|
||||
export type LoginMode = "add" | "fresh" | "manage" | "check" | "cancel";
|
||||
export type LoginMode = "add" | "fresh" | "manage" | "check" | "verify" | "cancel";
|
||||
|
||||
export interface ExistingAccountInfo {
|
||||
email?: string;
|
||||
@@ -47,6 +47,7 @@ export interface LoginMenuResult {
|
||||
deleteAccountIndex?: number;
|
||||
refreshAccountIndex?: number;
|
||||
toggleAccountIndex?: number;
|
||||
verifyAccountIndex?: number;
|
||||
deleteAll?: boolean;
|
||||
}
|
||||
|
||||
@@ -61,7 +62,7 @@ async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]):
|
||||
console.log("");
|
||||
|
||||
while (true) {
|
||||
const answer = await rl.question("(a)dd new, (f)resh start, (m)anage, (c)heck quotas? [a/f/m/c]: ");
|
||||
const answer = await rl.question("(a)dd new, (f)resh start, (c)heck quotas, (v)erify account? [a/f/c/v]: ");
|
||||
const normalized = answer.trim().toLowerCase();
|
||||
|
||||
if (normalized === "a" || normalized === "add") {
|
||||
@@ -70,14 +71,14 @@ async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]):
|
||||
if (normalized === "f" || normalized === "fresh") {
|
||||
return { mode: "fresh" };
|
||||
}
|
||||
if (normalized === "m" || normalized === "manage") {
|
||||
return { mode: "manage" };
|
||||
}
|
||||
if (normalized === "c" || normalized === "check") {
|
||||
return { mode: "check" };
|
||||
}
|
||||
if (normalized === "v" || normalized === "verify") {
|
||||
return { mode: "verify" };
|
||||
}
|
||||
|
||||
console.log("Please enter 'a', 'f', 'm', or 'c'.");
|
||||
console.log("Please enter 'a', 'f', 'c', or 'v'.");
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
@@ -111,8 +112,8 @@ export async function promptLoginMode(existingAccounts: ExistingAccountInfo[]):
|
||||
case "check":
|
||||
return { mode: "check" };
|
||||
|
||||
case "manage":
|
||||
return { mode: "manage" };
|
||||
case "verify":
|
||||
return { mode: "verify" };
|
||||
|
||||
case "select-account": {
|
||||
const accountAction = await showAccountDetails(action.account);
|
||||
@@ -125,6 +126,9 @@ export async function promptLoginMode(existingAccounts: ExistingAccountInfo[]):
|
||||
if (accountAction === "toggle") {
|
||||
return { mode: "manage", toggleAccountIndex: action.account.index };
|
||||
}
|
||||
if (accountAction === "verify") {
|
||||
return { mode: "verify", verifyAccountIndex: action.account.index };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,11 @@ export type AuthMenuAction =
|
||||
| { type: 'select-account'; account: AccountInfo }
|
||||
| { type: 'delete-all' }
|
||||
| { type: 'check' }
|
||||
| { type: 'manage' }
|
||||
| { type: 'verify' }
|
||||
| { type: 'configure-models' }
|
||||
| { type: 'cancel' };
|
||||
|
||||
export type AccountAction = 'back' | 'delete' | 'refresh' | 'toggle' | 'cancel';
|
||||
export type AccountAction = 'back' | 'delete' | 'refresh' | 'toggle' | 'verify' | 'cancel';
|
||||
|
||||
function formatRelativeTime(timestamp: number | undefined): string {
|
||||
if (!timestamp) return 'never';
|
||||
@@ -51,17 +51,24 @@ function getStatusBadge(status: AccountStatus | undefined): string {
|
||||
|
||||
export async function showAuthMenu(accounts: AccountInfo[]): Promise<AuthMenuAction> {
|
||||
const items: MenuItem<AuthMenuAction>[] = [
|
||||
{ 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<AuthMenuAct
|
||||
};
|
||||
}),
|
||||
|
||||
{ label: '', value: { type: 'cancel' }, separator: true },
|
||||
|
||||
{ label: 'Danger zone', value: { type: 'cancel' }, kind: 'heading' },
|
||||
{ label: 'Delete all accounts', value: { type: 'delete-all' }, color: 'red' as const },
|
||||
];
|
||||
|
||||
while (true) {
|
||||
const result = await select(items, {
|
||||
message: 'Manage accounts',
|
||||
subtitle: 'Select account'
|
||||
message: 'Google accounts (Antigravity)',
|
||||
subtitle: 'Select an action or account',
|
||||
clearScreen: true,
|
||||
});
|
||||
|
||||
if (!result) return { type: 'cancel' };
|
||||
@@ -93,22 +104,23 @@ export async function showAccountDetails(account: AccountInfo): Promise<AccountA
|
||||
const label = account.email || `Account ${account.index + 1}`;
|
||||
const badge = getStatusBadge(account.status);
|
||||
const disabledBadge = account.enabled === false ? ` ${ANSI.red}[disabled]${ANSI.reset}` : '';
|
||||
|
||||
console.log('');
|
||||
console.log(`${ANSI.bold}Account: ${label}${badge ? ' ' + badge : ''}${disabledBadge}${ANSI.reset}`);
|
||||
console.log(`${ANSI.dim}Added: ${formatDate(account.addedAt)}${ANSI.reset}`);
|
||||
console.log(`${ANSI.dim}Last used: ${formatRelativeTime(account.lastUsed)}${ANSI.reset}`);
|
||||
console.log('');
|
||||
const header = `${label}${badge ? ' ' + badge : ''}${disabledBadge}`;
|
||||
const subtitleParts = [
|
||||
`Added: ${formatDate(account.addedAt)}`,
|
||||
`Last used: ${formatRelativeTime(account.lastUsed)}`,
|
||||
];
|
||||
|
||||
while (true) {
|
||||
const result = await select([
|
||||
{ label: 'Back', value: 'back' as const },
|
||||
{ label: 'Verify account access', value: 'verify' as const, color: 'cyan' },
|
||||
{ label: account.enabled === false ? 'Enable account' : 'Disable account', value: 'toggle' as const, color: account.enabled === false ? 'green' : 'yellow' },
|
||||
{ label: 'Refresh token', value: 'refresh' as const, color: 'cyan' },
|
||||
{ label: 'Delete this account', value: 'delete' as const, color: 'red' },
|
||||
], {
|
||||
message: 'Account options',
|
||||
subtitle: 'Select action'
|
||||
message: header,
|
||||
subtitle: subtitleParts.join(' | '),
|
||||
clearScreen: true,
|
||||
});
|
||||
|
||||
if (result === 'delete') {
|
||||
|
||||
@@ -6,16 +6,64 @@ export interface MenuItem<T = string> {
|
||||
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<T>(
|
||||
throw new Error('No menu items provided');
|
||||
}
|
||||
|
||||
const enabledItems = items.filter(i => !i.disabled && !i.separator);
|
||||
const isSelectable = (i: MenuItem<T>) => !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<T>(
|
||||
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<typeof setTimeout> | 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<T>(
|
||||
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<T>(
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user