From 86b064a3614612aca1d65db3efc534cf38aa39f3 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 00:22:52 +0800 Subject: [PATCH 1/7] refactor: split auth cli help and command flows --- lib/codex-manager.ts | 857 ++--------------------------- lib/codex-manager/auth-commands.ts | 807 +++++++++++++++++++++++++++ lib/codex-manager/help.ts | 136 +++++ test/documentation.test.ts | 22 +- 4 files changed, 1000 insertions(+), 822 deletions(-) create mode 100644 lib/codex-manager/auth-commands.ts create mode 100644 lib/codex-manager/help.ts diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 231075e4..dd3f4bc1 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -9,8 +9,8 @@ import { REDIRECT_URI, } from "./auth/auth.js"; import { startLocalOAuthServer } from "./auth/server.js"; -import { copyTextToClipboard, isBrowserLaunchSuppressed, openBrowserUrl } from "./auth/browser.js"; -import { promptAddAnotherAccount, promptLoginMode, type ExistingAccountInfo } from "./cli.js"; +import { copyTextToClipboard, openBrowserUrl } from "./auth/browser.js"; +import { promptLoginMode, type ExistingAccountInfo } from "./cli.js"; import { extractAccountEmail, extractAccountId, @@ -54,14 +54,10 @@ import { import { clearAccounts, findMatchingAccountIndex, - formatStorageErrorHint, - getNamedBackups, getStoragePath, loadFlaggedAccounts, loadAccounts, - StorageError, type NamedBackupSummary, - restoreAccountsFromBackup, saveFlaggedAccounts, saveAccounts, setStoragePath, @@ -80,11 +76,16 @@ import { import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { ANSI } from "./ui/ansi.js"; import { UI_COPY } from "./ui/copy.js"; -import { confirm } from "./ui/confirm.js"; import { paintUiText, quotaToneFromLeftPercent } from "./ui/format.js"; import { getUiRuntimeOptions } from "./ui/runtime.js"; import { select, type MenuItem } from "./ui/select.js"; -import { applyUiThemeFromDashboardSettings, configureUnifiedSettings, resolveMenuLayoutMode } from "./codex-manager/settings-hub.js"; +import { printUsage } from "./codex-manager/help.js"; +import { + runAuthLogin as runAuthLoginCommand, + runBest as runBestCommand, + runSwitch as runSwitchCommand, +} from "./codex-manager/auth-commands.js"; +import { applyUiThemeFromDashboardSettings, resolveMenuLayoutMode } from "./codex-manager/settings-hub.js"; type TokenSuccess = Extract; type TokenSuccessWithAccount = TokenSuccess & { @@ -287,76 +288,6 @@ function isAbortError(error: unknown): boolean { return maybe.name === "AbortError" || maybe.code === "ABORT_ERR"; } -function isUserCancelledOAuth(result: TokenResult): boolean { - if (result.type !== "failed") return false; - const message = (result.message ?? "").toLowerCase(); - return message.includes("cancelled"); -} - -function printUsage(): void { - console.log( - [ - "Codex Multi-Auth CLI", - "", - "Start here:", - " codex auth login [--manual|--no-browser]", - " codex auth status", - " codex auth check", - "", - "Daily use:", - " codex auth list", - " codex auth switch ", - " codex auth best [--live] [--json] [--model ]", - " codex auth forecast [--live] [--json] [--model ]", - "", - "Repair:", - " codex auth verify-flagged [--dry-run] [--json] [--no-restore]", - " codex auth fix [--dry-run] [--json] [--live] [--model ]", - " codex auth doctor [--json] [--fix] [--dry-run]", - "", - "Advanced:", - " codex auth report [--live] [--json] [--model ] [--out ]", - " codex auth features", - "", - "Notes:", - " - Uses ~/.codex/multi-auth/openai-codex-accounts.json", - " - Syncs active account into Codex CLI auth state", - " - See docs/reference/commands.md for the full command and flag matrix", - ].join("\n"), - ); -} - -type AuthLoginOptions = { - manual: boolean; -}; - -type ParsedAuthLoginArgs = - | { ok: true; options: AuthLoginOptions } - | { ok: false; message: string }; - -function parseAuthLoginArgs(args: string[]): ParsedAuthLoginArgs { - const options: AuthLoginOptions = { - manual: false, - }; - - for (const arg of args) { - if (arg === "--manual" || arg === "--no-browser") { - options.manual = true; - continue; - } - if (arg === "--help" || arg === "-h") { - printUsage(); - return { ok: false, message: "" }; - } - return { - ok: false, - message: `Unknown login option: ${arg}`, - }; - } - - return { ok: true, options }; -} - interface ImplementedFeature { id: number; name: string; @@ -2138,13 +2069,6 @@ interface ForecastCliOptions { model: string; } -interface BestCliOptions { - live: boolean; - json: boolean; - model: string; - modelProvided: boolean; -} - interface FixCliOptions { dryRun: boolean; json: boolean; @@ -2181,24 +2105,6 @@ function printForecastUsage(): void { ); } -function printBestUsage(): void { - console.log( - [ - "Usage:", - " codex auth best [--live] [--json] [--model ]", - "", - "Options:", - " --live, -l Probe live quota headers via Codex backend before switching", - " --json, -j Print machine-readable JSON output", - " --model, -m Probe model for live mode (default: gpt-5-codex)", - "", - "Behavior:", - " - Chooses the healthiest account using forecast scoring", - " - Switches to the recommended account when it is not already active", - ].join("\n"), - ); -} - function printFixUsage(): void { console.log( [ @@ -2279,49 +2185,6 @@ function parseForecastArgs(args: string[]): ParsedArgsResult return { ok: true, options }; } -function parseBestArgs(args: string[]): ParsedArgsResult { - const options: BestCliOptions = { - live: false, - json: false, - model: "gpt-5-codex", - modelProvided: false, - }; - - for (let i = 0; i < args.length; i += 1) { - const arg = args[i]; - if (!arg) continue; - if (arg === "--live" || arg === "-l") { - options.live = true; - continue; - } - if (arg === "--json" || arg === "-j") { - options.json = true; - continue; - } - if (arg === "--model" || arg === "-m") { - const value = args[i + 1]; - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - options.modelProvided = true; - i += 1; - continue; - } - if (arg.startsWith("--model=")) { - const value = arg.slice("--model=".length).trim(); - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - options.modelProvided = true; - continue; - } - return { ok: false, message: `Unknown option: ${arg}` }; - } - - return { ok: true, options }; -} function parseFixArgs(args: string[]): ParsedArgsResult { const options: FixCliOptions = { @@ -4374,680 +4237,15 @@ async function handleManageAction( } async function runAuthLogin(args: string[]): Promise { - const parsedArgs = parseAuthLoginArgs(args); - if (!parsedArgs.ok) { - if (parsedArgs.message) { - console.error(parsedArgs.message); - printUsage(); - return 1; - } - return 0; - } - - const loginOptions = parsedArgs.options; - setStoragePath(null); - let pendingMenuQuotaRefresh: Promise | null = null; - let menuQuotaRefreshStatus: string | undefined; - loginFlow: - while (true) { - let existingStorage = await loadAccounts(); - if (existingStorage && existingStorage.accounts.length > 0) { - while (true) { - existingStorage = await loadAccounts(); - if (!existingStorage || existingStorage.accounts.length === 0) { - break; - } - const currentStorage = existingStorage; - const displaySettings = await loadDashboardDisplaySettings(); - applyUiThemeFromDashboardSettings(displaySettings); - const quotaCache = await loadQuotaCache(); - const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true; - const showFetchStatus = displaySettings.menuShowFetchStatus ?? true; - const quotaTtlMs = displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; - if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) { - const staleCount = countMenuQuotaRefreshTargets(currentStorage, quotaCache, quotaTtlMs); - if (staleCount > 0) { - if (showFetchStatus) { - menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`; - } - pendingMenuQuotaRefresh = refreshQuotaCacheForMenu( - currentStorage, - quotaCache, - quotaTtlMs, - (current, total) => { - if (!showFetchStatus) return; - menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`; - }, - ) - .then(() => undefined) - .catch(() => undefined) - .finally(() => { - menuQuotaRefreshStatus = undefined; - pendingMenuQuotaRefresh = null; - }); - } - } - const flaggedStorage = await loadFlaggedAccounts(); - - const menuResult = await promptLoginMode( - toExistingAccountInfo(currentStorage, quotaCache, displaySettings), - { - flaggedCount: flaggedStorage.accounts.length, - statusMessage: showFetchStatus ? () => menuQuotaRefreshStatus : undefined, - }, - ); - - if (menuResult.mode === "cancel") { - console.log("Cancelled."); - return 0; - } - if (menuResult.mode === "check") { - await runActionPanel("Quick Check", "Checking local session + live status", async () => { - await runHealthCheck({ forceRefresh: false, liveProbe: true }); - }, displaySettings); - continue; - } - if (menuResult.mode === "deep-check") { - await runActionPanel("Deep Check", "Refreshing and testing all accounts", async () => { - await runHealthCheck({ forceRefresh: true, liveProbe: true }); - }, displaySettings); - continue; - } - if (menuResult.mode === "forecast") { - await runActionPanel("Best Account", "Comparing accounts", async () => { - await runForecast(["--live"]); - }, displaySettings); - continue; - } - if (menuResult.mode === "fix") { - await runActionPanel("Auto-Fix", "Checking and fixing common issues", async () => { - await runFix(["--live"]); - }, displaySettings); - continue; - } - if (menuResult.mode === "settings") { - await configureUnifiedSettings(displaySettings); - continue; - } - if (menuResult.mode === "verify-flagged") { - await runActionPanel("Problem Account Check", "Checking problem accounts", async () => { - await runVerifyFlagged([]); - }, displaySettings); - continue; - } - if (menuResult.mode === "fresh" && menuResult.deleteAll) { - await runActionPanel("Reset Accounts", "Deleting all saved accounts", async () => { - await clearAccountsAndReset(); - console.log("Cleared saved accounts from active storage. Recovery snapshots remain available."); - }, displaySettings); - continue; - } - if (menuResult.mode === "manage") { - const requiresInteractiveOAuth = typeof menuResult.refreshAccountIndex === "number"; - if (requiresInteractiveOAuth) { - await handleManageAction(currentStorage, menuResult); - continue; - } - await runActionPanel("Applying Change", "Updating selected account", async () => { - await handleManageAction(currentStorage, menuResult); - }, displaySettings); - continue; - } - if (menuResult.mode === "add") { - break; - } - } - } - - const refreshedStorage = await loadAccounts(); - let existingCount = refreshedStorage?.accounts.length ?? 0; - let forceNewLogin = existingCount > 0; - let onboardingBackupDiscoveryWarning: string | null = null; - const loadNamedBackupsForOnboarding = async (): Promise => { - if (existingCount > 0) { - onboardingBackupDiscoveryWarning = null; - return []; - } - try { - onboardingBackupDiscoveryWarning = null; - return await getNamedBackups(); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - log.debug("getNamedBackups failed, skipping restore option", { - code, - error: error instanceof Error ? error.message : String(error), - }); - if (code && code !== "ENOENT") { - onboardingBackupDiscoveryWarning = - "Named backup discovery failed. Continuing with browser or manual sign-in only."; - console.warn( - onboardingBackupDiscoveryWarning, - ); - } else { - onboardingBackupDiscoveryWarning = null; - } - return []; - } - }; - let namedBackups = await loadNamedBackupsForOnboarding(); - while (true) { - const latestNamedBackup = namedBackups[0] ?? null; - const preferManualMode = loginOptions.manual || isBrowserLaunchSuppressed(); - const signInMode = preferManualMode - ? "manual" - : await promptOAuthSignInMode( - latestNamedBackup, - onboardingBackupDiscoveryWarning, - ); - if (signInMode === "cancel") { - if (existingCount > 0) { - console.log(stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted")); - continue loginFlow; - } - console.log("Cancelled."); - return 0; - } - if (signInMode === "restore-backup") { - const latestAvailableBackup = namedBackups[0] ?? null; - if (!latestAvailableBackup) { - namedBackups = await loadNamedBackupsForOnboarding(); - continue; - } - const restoreMode = await promptBackupRestoreMode(latestAvailableBackup); - if (restoreMode === "back") { - namedBackups = await loadNamedBackupsForOnboarding(); - continue; - } - - const selectedBackup = restoreMode === "manual" - ? await promptManualBackupSelection(namedBackups) - : latestAvailableBackup; - if (!selectedBackup) { - namedBackups = await loadNamedBackupsForOnboarding(); - continue; - } - - const confirmed = await confirm( - UI_COPY.oauth.restoreBackupConfirm( - selectedBackup.fileName, - selectedBackup.accountCount, - ), - ); - if (!confirmed) { - namedBackups = await loadNamedBackupsForOnboarding(); - continue; - } - - const displaySettings = await loadDashboardDisplaySettings(); - applyUiThemeFromDashboardSettings(displaySettings); - try { - await runActionPanel( - "Load Backup", - `Loading ${selectedBackup.fileName}`, - async () => { - const restoredStorage = await restoreAccountsFromBackup( - selectedBackup.path, - { persist: false }, - ); - const targetIndex = resolveActiveIndex(restoredStorage); - const { synced } = await persistAndSyncSelectedAccount({ - storage: restoredStorage, - targetIndex, - parsed: targetIndex + 1, - switchReason: "restore", - preserveActiveIndexByFamily: true, - }); - console.log( - UI_COPY.oauth.restoreBackupLoaded( - selectedBackup.fileName, - restoredStorage.accounts.length, - ), - ); - if (!synced) { - console.warn(UI_COPY.oauth.restoreBackupSyncWarning); - } - }, - displaySettings, - ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (error instanceof StorageError) { - console.error(formatStorageErrorHint(error, selectedBackup.path)); - } else { - console.error(`Backup restore failed: ${message}`); - } - const storageAfterRestoreAttempt = await loadAccounts().catch(() => null); - if ((storageAfterRestoreAttempt?.accounts.length ?? 0) > 0) { - continue loginFlow; - } - namedBackups = await loadNamedBackupsForOnboarding(); - continue; - } - continue loginFlow; - } - - if (signInMode !== "browser" && signInMode !== "manual") { - continue; - } - - const tokenResult = await runOAuthFlow(forceNewLogin, signInMode); - if (tokenResult.type !== "success") { - if (isUserCancelledOAuth(tokenResult)) { - if (existingCount > 0) { - console.log(stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted")); - continue loginFlow; - } - console.log("Cancelled."); - return 0; - } - console.error(`Login failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`); - return 1; - } - - const resolved = resolveAccountSelection(tokenResult); - await persistAccountPool([resolved], false); - await syncSelectionToCodex(resolved); - - const latestStorage = await loadAccounts(); - const count = latestStorage?.accounts.length ?? 1; - existingCount = count; - namedBackups = []; - onboardingBackupDiscoveryWarning = null; - console.log(`Added account. Total: ${count}`); - console.log("Next steps:"); - console.log(" codex auth status Check that the wrapper is active."); - console.log(" codex auth check Confirm your saved accounts look healthy."); - console.log(" codex auth list Review saved accounts before switching."); - if (count >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { - console.log(`Reached maximum account limit (${ACCOUNT_LIMITS.MAX_ACCOUNTS}).`); - break; - } - - const addAnother = await promptAddAnotherAccount(count); - if (!addAnother) break; - forceNewLogin = true; - } - continue loginFlow; - } + return runAuthLoginCommand(args, authLoginCommandDeps); } async function runSwitch(args: string[]): Promise { - setStoragePath(null); - const indexArg = args[0]; - if (!indexArg) { - console.error("Missing index. Usage: codex auth switch "); - return 1; - } - const parsed = Number.parseInt(indexArg, 10); - if (!Number.isFinite(parsed) || parsed < 1) { - console.error(`Invalid index: ${indexArg}`); - return 1; - } - const targetIndex = parsed - 1; - - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - console.error("No accounts configured."); - return 1; - } - if (targetIndex < 0 || targetIndex >= storage.accounts.length) { - console.error(`Index out of range. Valid range: 1-${storage.accounts.length}`); - return 1; - } - - const account = storage.accounts[targetIndex]; - if (!account) { - console.error(`Account ${parsed} not found.`); - return 1; - } - - const { synced, wasDisabled } = await persistAndSyncSelectedAccount({ - storage, - targetIndex, - parsed, - switchReason: "rotation", - }); - if (!synced) { - console.warn( - `Switched account ${parsed} locally, but Codex auth sync did not complete. Multi-auth routing will still use this account.`, - ); - } - - console.log( - `Switched to account ${parsed}: ${formatAccountLabel(account, targetIndex)}${wasDisabled ? " (re-enabled)" : ""}`, - ); - return 0; -} - -async function persistAndSyncSelectedAccount({ - storage, - targetIndex, - parsed, - switchReason, - initialSyncIdToken, - preserveActiveIndexByFamily = false, -}: { - storage: NonNullable>>; - targetIndex: number; - parsed: number; - switchReason: "rotation" | "best" | "restore"; - initialSyncIdToken?: string; - preserveActiveIndexByFamily?: boolean; -}): Promise<{ synced: boolean; wasDisabled: boolean }> { - const account = storage.accounts[targetIndex]; - if (!account) { - throw new Error(`Account ${parsed} not found.`); - } - - const shouldPreserveActiveIndexByFamily = - preserveActiveIndexByFamily && - !!storage.activeIndexByFamily && - targetIndex === storage.activeIndex; - storage.activeIndex = targetIndex; - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - if (shouldPreserveActiveIndexByFamily) { - const maxIndex = Math.max(0, storage.accounts.length - 1); - for (const family of MODEL_FAMILIES) { - const raw = storage.activeIndexByFamily[family]; - const candidate = - typeof raw === "number" && Number.isFinite(raw) ? raw : targetIndex; - storage.activeIndexByFamily[family] = Math.max( - 0, - Math.min(candidate, maxIndex), - ); - } - } else { - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = targetIndex; - } - } - const wasDisabled = account.enabled === false; - if (wasDisabled) { - account.enabled = true; - } - const switchNow = Date.now(); - let syncAccessToken = account.accessToken; - let syncRefreshToken = account.refreshToken; - let syncExpiresAt = account.expiresAt; - let syncIdToken = initialSyncIdToken; - - if (!hasUsableAccessToken(account, switchNow)) { - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type === "success") { - const tokenAccountId = extractAccountId(refreshResult.access); - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); - if (account.refreshToken !== refreshResult.refresh) { - account.refreshToken = refreshResult.refresh; - } - if (account.accessToken !== refreshResult.access) { - account.accessToken = refreshResult.access; - } - if (account.expiresAt !== refreshResult.expires) { - account.expiresAt = refreshResult.expires; - } - if (nextEmail && nextEmail !== account.email) { - account.email = nextEmail; - } - applyTokenAccountIdentity(account, tokenAccountId); - syncAccessToken = refreshResult.access; - syncRefreshToken = refreshResult.refresh; - syncExpiresAt = refreshResult.expires; - syncIdToken = refreshResult.idToken; - } else { - console.warn( - `Switch validation refresh failed for account ${parsed}: ${normalizeFailureDetail(refreshResult.message, refreshResult.reason)}.`, - ); - } - } - - account.lastUsed = switchNow; - account.lastSwitchReason = switchReason; - await saveAccounts(storage); - - const synced = await setCodexCliActiveSelection({ - accountId: account.accountId, - email: account.email, - accessToken: syncAccessToken, - refreshToken: syncRefreshToken, - expiresAt: syncExpiresAt, - ...(syncIdToken ? { idToken: syncIdToken } : {}), - }); - return { synced, wasDisabled }; + return runSwitchCommand(args, authCommandHelpers); } async function runBest(args: string[]): Promise { - if (args.includes("--help") || args.includes("-h")) { - printBestUsage(); - return 0; - } - - const parsedArgs = parseBestArgs(args); - if (!parsedArgs.ok) { - console.error(parsedArgs.message); - printBestUsage(); - return 1; - } - const options = parsedArgs.options; - if (options.modelProvided && !options.live) { - console.error("--model requires --live for codex auth best"); - printBestUsage(); - return 1; - } - - setStoragePath(null); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - if (options.json) { - console.log(JSON.stringify({ error: "No accounts configured." }, null, 2)); - } else { - console.log("No accounts configured."); - } - return 1; - } - - const now = Date.now(); - const refreshFailures = new Map(); - const liveQuotaByIndex = new Map>>(); - const probeIdTokenByIndex = new Map(); - const probeRefreshedIndices = new Set(); - const probeErrors: string[] = []; - let changed = false; - - const printProbeNotes = (): void => { - if (probeErrors.length === 0) return; - console.log(`Live check notes (${probeErrors.length}):`); - for (const error of probeErrors) { - console.log(` - ${error}`); - } - }; - - const persistProbeChangesIfNeeded = async (): Promise => { - if (!changed) return; - await saveAccounts(storage); - changed = false; - }; - - for (let i = 0; i < storage.accounts.length; i += 1) { - const account = storage.accounts[i]; - if (!account || !options.live) continue; - if (account.enabled === false) continue; - - let probeAccessToken = account.accessToken; - let probeAccountId = account.accountId ?? extractAccountId(account.accessToken); - if (!hasUsableAccessToken(account, now)) { - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type !== "success") { - refreshFailures.set(i, { - ...refreshResult, - message: normalizeFailureDetail(refreshResult.message, refreshResult.reason), - }); - continue; - } - - const refreshedEmail = sanitizeEmail( - extractAccountEmail(refreshResult.access, refreshResult.idToken), - ); - const refreshedAccountId = extractAccountId(refreshResult.access); - - if (account.refreshToken !== refreshResult.refresh) { - account.refreshToken = refreshResult.refresh; - changed = true; - } - if (account.accessToken !== refreshResult.access) { - account.accessToken = refreshResult.access; - changed = true; - } - if (account.expiresAt !== refreshResult.expires) { - account.expiresAt = refreshResult.expires; - changed = true; - } - if (refreshedEmail && refreshedEmail !== account.email) { - account.email = refreshedEmail; - changed = true; - } - if (refreshedAccountId && refreshedAccountId !== account.accountId) { - account.accountId = refreshedAccountId; - account.accountIdSource = "token"; - changed = true; - } - if (refreshResult.idToken) { - probeIdTokenByIndex.set(i, refreshResult.idToken); - } - probeRefreshedIndices.add(i); - - probeAccessToken = account.accessToken; - probeAccountId = account.accountId ?? refreshedAccountId; - } - - if (!probeAccessToken || !probeAccountId) { - probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); - continue; - } - - try { - const liveQuota = await fetchCodexQuotaSnapshot({ - accountId: probeAccountId, - accessToken: probeAccessToken, - model: options.model, - }); - liveQuotaByIndex.set(i, liveQuota); - } catch (error) { - const message = normalizeFailureDetail( - error instanceof Error ? error.message : String(error), - undefined, - ); - probeErrors.push(`${formatAccountLabel(account, i)}: ${message}`); - } - } - - const forecastInputs = storage.accounts.map((account, index) => ({ - index, - account, - isCurrent: index === resolveActiveIndex(storage, "codex"), - now, - refreshFailure: refreshFailures.get(index), - liveQuota: liveQuotaByIndex.get(index), - })); - - const forecastResults = evaluateForecastAccounts(forecastInputs); - const recommendation = recommendForecastAccount(forecastResults); - - if (recommendation.recommendedIndex === null) { - await persistProbeChangesIfNeeded(); - if (options.json) { - console.log(JSON.stringify({ - error: recommendation.reason, - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, null, 2)); - } else { - console.log(`No best account available: ${recommendation.reason}`); - printProbeNotes(); - } - return 1; - } - - const bestIndex = recommendation.recommendedIndex; - const bestAccount = storage.accounts[bestIndex]; - if (!bestAccount) { - await persistProbeChangesIfNeeded(); - if (options.json) { - console.log(JSON.stringify({ error: "Best account not found." }, null, 2)); - } else { - console.log("Best account not found."); - } - return 1; - } - - // Check if already on best account - const currentIndex = resolveActiveIndex(storage, "codex"); - if (currentIndex === bestIndex) { - const shouldSyncCurrentBest = - probeRefreshedIndices.has(bestIndex) || probeIdTokenByIndex.has(bestIndex); - let alreadyBestSynced: boolean | undefined; - if (changed) { - bestAccount.lastUsed = now; - await persistProbeChangesIfNeeded(); - } - if (shouldSyncCurrentBest) { - alreadyBestSynced = await setCodexCliActiveSelection({ - accountId: bestAccount.accountId, - email: bestAccount.email, - accessToken: bestAccount.accessToken, - refreshToken: bestAccount.refreshToken, - expiresAt: bestAccount.expiresAt, - ...(probeIdTokenByIndex.has(bestIndex) - ? { idToken: probeIdTokenByIndex.get(bestIndex) } - : {}), - }); - if (!alreadyBestSynced && !options.json) { - console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); - } - } - if (options.json) { - console.log(JSON.stringify({ - message: `Already on best account: ${formatAccountLabel(bestAccount, bestIndex)}`, - accountIndex: bestIndex + 1, - reason: recommendation.reason, - ...(alreadyBestSynced !== undefined ? { synced: alreadyBestSynced } : {}), - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, null, 2)); - } else { - console.log(`Already on best account ${bestIndex + 1}: ${formatAccountLabel(bestAccount, bestIndex)}`); - console.log(`Reason: ${recommendation.reason}`); - printProbeNotes(); - } - return 0; - } - - const targetIndex = bestIndex; - const parsed = targetIndex + 1; - const { synced, wasDisabled } = await persistAndSyncSelectedAccount({ - storage, - targetIndex, - parsed, - switchReason: "best", - initialSyncIdToken: probeIdTokenByIndex.get(bestIndex), - }); - - if (options.json) { - console.log(JSON.stringify({ - message: `Switched to best account: ${formatAccountLabel(bestAccount, targetIndex)}`, - accountIndex: parsed, - reason: recommendation.reason, - synced, - wasDisabled, - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, null, 2)); - } else { - console.log(`Switched to best account ${parsed}: ${formatAccountLabel(bestAccount, targetIndex)}${wasDisabled ? " (re-enabled)" : ""}`); - console.log(`Reason: ${recommendation.reason}`); - printProbeNotes(); - if (!synced) { - console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); - } - } - return 0; + return runBestCommand(args, authCommandHelpers); } export async function autoSyncActiveAccountToCodex(): Promise { @@ -5119,6 +4317,37 @@ export async function autoSyncActiveAccountToCodex(): Promise { }); } +const authCommandHelpers = { + resolveActiveIndex, + hasUsableAccessToken, + applyTokenAccountIdentity, + normalizeFailureDetail, +}; + +const authLoginCommandDeps = { + ...authCommandHelpers, + stylePromptText, + runActionPanel, + toExistingAccountInfo, + countMenuQuotaRefreshTargets, + defaultMenuQuotaRefreshTtlMs: DEFAULT_MENU_QUOTA_REFRESH_TTL_MS, + refreshQuotaCacheForMenu, + clearAccountsAndReset, + handleManageAction, + promptOAuthSignInMode, + promptBackupRestoreMode, + promptManualBackupSelection, + runOAuthFlow, + resolveAccountSelection, + persistAccountPool, + syncSelectionToCodex, + runHealthCheck, + runForecast, + runFix, + runVerifyFlagged, + log, +}; + export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { const startupDisplaySettings = await loadDashboardDisplaySettings(); applyUiThemeFromDashboardSettings(startupDisplaySettings); diff --git a/lib/codex-manager/auth-commands.ts b/lib/codex-manager/auth-commands.ts new file mode 100644 index 00000000..9d3f7c80 --- /dev/null +++ b/lib/codex-manager/auth-commands.ts @@ -0,0 +1,807 @@ +import { isBrowserLaunchSuppressed } from "../auth/browser.js"; +import { + extractAccountEmail, + extractAccountId, + formatAccountLabel, + sanitizeEmail, +} from "../accounts.js"; +import { promptAddAnotherAccount, promptLoginMode, type ExistingAccountInfo } from "../cli.js"; +import { ACCOUNT_LIMITS } from "../constants.js"; +import { + loadDashboardDisplaySettings, + type DashboardDisplaySettings, +} from "../dashboard-settings.js"; +import { + evaluateForecastAccounts, + recommendForecastAccount, +} from "../forecast.js"; +import { loadQuotaCache, type QuotaCacheData } from "../quota-cache.js"; +import { fetchCodexQuotaSnapshot } from "../quota-probe.js"; +import { queuedRefresh } from "../refresh-queue.js"; +import { + getNamedBackups, + formatStorageErrorHint, + loadAccounts, + loadFlaggedAccounts, + restoreAccountsFromBackup, + saveAccounts, + setStoragePath, + StorageError, + type AccountMetadataV3, + type AccountStorageV3, + type NamedBackupSummary, +} from "../storage.js"; +import type { AccountIdSource, TokenFailure, TokenResult } from "../types.js"; +import { setCodexCliActiveSelection } from "../codex-cli/writer.js"; +import type { ModelFamily } from "../prompts/codex.js"; +import { UI_COPY } from "../ui/copy.js"; +import { confirm } from "../ui/confirm.js"; +import { + applyUiThemeFromDashboardSettings, + configureUnifiedSettings, +} from "./settings-hub.js"; +import { + parseAuthLoginArgs, + parseBestArgs, + printBestUsage, + printUsage, +} from "./help.js"; + +type PromptTone = "accent" | "success" | "warning" | "danger" | "muted"; +type TokenSuccess = Extract; +type TokenSuccessWithAccount = TokenSuccess & { + accountIdOverride?: string; + accountIdSource?: AccountIdSource; + accountLabel?: string; +}; +type OAuthSignInMode = "browser" | "manual" | "restore-backup" | "cancel"; +type BackupRestoreMode = "latest" | "manual" | "back"; +type LoginMenuResult = Awaited>; +type HealthCheckOptions = { forceRefresh?: boolean; liveProbe?: boolean }; + +export interface AuthCommandHelpers { + resolveActiveIndex: ( + storage: AccountStorageV3, + family?: ModelFamily, + ) => number; + hasUsableAccessToken: (account: AccountMetadataV3, now: number) => boolean; + applyTokenAccountIdentity: ( + account: { accountId?: string; accountIdSource?: AccountIdSource }, + tokenAccountId: string | undefined, + ) => boolean; + normalizeFailureDetail: ( + message: string | undefined, + reason: string | undefined, + ) => string; +} + +export interface AuthLoginCommandDeps extends AuthCommandHelpers { + stylePromptText: (text: string, tone: PromptTone) => string; + runActionPanel: ( + title: string, + stage: string, + action: () => Promise | void, + settings?: DashboardDisplaySettings, + ) => Promise; + toExistingAccountInfo: ( + storage: AccountStorageV3, + cache: QuotaCacheData, + settings: DashboardDisplaySettings, + ) => ExistingAccountInfo[]; + countMenuQuotaRefreshTargets: ( + storage: AccountStorageV3, + cache: QuotaCacheData, + maxAgeMs: number, + ) => number; + defaultMenuQuotaRefreshTtlMs: number; + refreshQuotaCacheForMenu: ( + storage: AccountStorageV3, + cache: QuotaCacheData, + maxAgeMs: number, + onProgress?: (current: number, total: number) => void, + ) => Promise; + clearAccountsAndReset: () => Promise; + handleManageAction: ( + storage: AccountStorageV3, + menuResult: LoginMenuResult, + ) => Promise; + promptOAuthSignInMode: ( + backupOption: NamedBackupSummary | null, + backupDiscoveryWarning?: string | null, + ) => Promise; + promptBackupRestoreMode: ( + latestBackup: NamedBackupSummary, + ) => Promise; + promptManualBackupSelection: ( + namedBackups: NamedBackupSummary[], + ) => Promise; + runOAuthFlow: ( + forceNewLogin: boolean, + signInMode: Extract, + ) => Promise; + resolveAccountSelection: (tokens: TokenSuccess) => TokenSuccessWithAccount; + persistAccountPool: ( + tokens: TokenSuccessWithAccount[], + preserveActiveIndexByFamily: boolean, + ) => Promise; + syncSelectionToCodex: (tokens: TokenSuccessWithAccount) => Promise; + runHealthCheck: (options: HealthCheckOptions) => Promise; + runForecast: (args: string[]) => Promise; + runFix: (args: string[]) => Promise; + runVerifyFlagged: (args: string[]) => Promise; + log: { + debug: (message: string, meta?: unknown) => void; + }; +} + +export async function persistAndSyncSelectedAccount({ + storage, + targetIndex, + parsed, + switchReason, + initialSyncIdToken, + preserveActiveIndexByFamily = false, + helpers, +}: { + storage: AccountStorageV3; + targetIndex: number; + parsed: number; + switchReason: "rotation" | "best" | "restore"; + initialSyncIdToken?: string; + preserveActiveIndexByFamily?: boolean; + helpers: AuthCommandHelpers; +}): Promise<{ synced: boolean; wasDisabled: boolean }> { + const account = storage.accounts[targetIndex]; + if (!account) { + throw new Error(`Account ${parsed} not found.`); + } + + const wasDisabled = account.enabled === false; + if (wasDisabled) { + account.enabled = true; + } + + storage.activeIndex = targetIndex; + if (!storage.activeIndexByFamily || !preserveActiveIndexByFamily) { + storage.activeIndexByFamily = {}; + } + storage.activeIndexByFamily.codex = targetIndex; + + const switchNow = Date.now(); + let syncAccessToken = account.accessToken; + let syncRefreshToken = account.refreshToken; + let syncExpiresAt = account.expiresAt; + let syncIdToken = initialSyncIdToken; + + if (!helpers.hasUsableAccessToken(account, switchNow)) { + const refreshResult = await queuedRefresh(account.refreshToken); + if (refreshResult.type === "success") { + const refreshedEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); + const tokenAccountId = extractAccountId(refreshResult.access); + + account.refreshToken = refreshResult.refresh; + account.accessToken = refreshResult.access; + account.expiresAt = refreshResult.expires; + if (refreshedEmail) account.email = refreshedEmail; + helpers.applyTokenAccountIdentity(account, tokenAccountId); + syncAccessToken = refreshResult.access; + syncRefreshToken = refreshResult.refresh; + syncExpiresAt = refreshResult.expires; + syncIdToken = refreshResult.idToken; + } else { + console.warn( + `Switch validation refresh failed for account ${parsed}: ${helpers.normalizeFailureDetail(refreshResult.message, refreshResult.reason)}.`, + ); + } + } + + account.lastUsed = switchNow; + account.lastSwitchReason = switchReason; + await saveAccounts(storage); + + const synced = await setCodexCliActiveSelection({ + accountId: account.accountId, + email: account.email, + accessToken: syncAccessToken, + refreshToken: syncRefreshToken, + expiresAt: syncExpiresAt, + ...(syncIdToken ? { idToken: syncIdToken } : {}), + }); + return { synced, wasDisabled }; +} + +export async function runSwitch( + args: string[], + helpers: AuthCommandHelpers, +): Promise { + setStoragePath(null); + const indexArg = args[0]; + if (!indexArg) { + console.error("Missing index. Usage: codex auth switch "); + return 1; + } + const parsed = Number.parseInt(indexArg, 10); + if (!Number.isFinite(parsed) || parsed < 1) { + console.error(`Invalid index: ${indexArg}`); + return 1; + } + const targetIndex = parsed - 1; + + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + console.error("No accounts configured."); + return 1; + } + if (targetIndex < 0 || targetIndex >= storage.accounts.length) { + console.error(`Index out of range. Valid range: 1-${storage.accounts.length}`); + return 1; + } + + const account = storage.accounts[targetIndex]; + if (!account) { + console.error(`Account ${parsed} not found.`); + return 1; + } + + const { synced, wasDisabled } = await persistAndSyncSelectedAccount({ + storage, + targetIndex, + parsed, + switchReason: "rotation", + helpers, + }); + if (!synced) { + console.warn( + `Switched account ${parsed} locally, but Codex auth sync did not complete. Multi-auth routing will still use this account.`, + ); + } + + console.log( + `Switched to account ${parsed}: ${formatAccountLabel(account, targetIndex)}${wasDisabled ? " (re-enabled)" : ""}`, + ); + return 0; +} + +export async function runBest( + args: string[], + helpers: AuthCommandHelpers, +): Promise { + if (args.includes("--help") || args.includes("-h")) { + printBestUsage(); + return 0; + } + + const parsedArgs = parseBestArgs(args); + if (!parsedArgs.ok) { + console.error(parsedArgs.message); + printBestUsage(); + return 1; + } + const options = parsedArgs.options; + if (options.modelProvided && !options.live) { + console.error("--model requires --live for codex auth best"); + printBestUsage(); + return 1; + } + + setStoragePath(null); + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + if (options.json) { + console.log(JSON.stringify({ error: "No accounts configured." }, null, 2)); + } else { + console.log("No accounts configured."); + } + return 1; + } + + const now = Date.now(); + const refreshFailures = new Map(); + const liveQuotaByIndex = new Map>>(); + const probeIdTokenByIndex = new Map(); + const probeRefreshedIndices = new Set(); + const probeErrors: string[] = []; + let changed = false; + + const printProbeNotes = (): void => { + if (probeErrors.length === 0) return; + console.log(`Live check notes (${probeErrors.length}):`); + for (const error of probeErrors) { + console.log(` - ${error}`); + } + }; + + const persistProbeChangesIfNeeded = async (): Promise => { + if (!changed) return; + await saveAccounts(storage); + changed = false; + }; + + for (let i = 0; i < storage.accounts.length; i += 1) { + const account = storage.accounts[i]; + if (!account || !options.live) continue; + if (account.enabled === false) continue; + + let probeAccessToken = account.accessToken; + let probeAccountId = account.accountId ?? extractAccountId(account.accessToken); + if (!helpers.hasUsableAccessToken(account, now)) { + const refreshResult = await queuedRefresh(account.refreshToken); + if (refreshResult.type !== "success") { + refreshFailures.set(i, { + ...refreshResult, + message: helpers.normalizeFailureDetail(refreshResult.message, refreshResult.reason), + }); + continue; + } + + const refreshedEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); + const refreshedAccountId = extractAccountId(refreshResult.access); + + if (account.refreshToken !== refreshResult.refresh) { + account.refreshToken = refreshResult.refresh; + changed = true; + } + if (account.accessToken !== refreshResult.access) { + account.accessToken = refreshResult.access; + changed = true; + } + if (account.expiresAt !== refreshResult.expires) { + account.expiresAt = refreshResult.expires; + changed = true; + } + if (refreshedEmail && refreshedEmail !== account.email) { + account.email = refreshedEmail; + changed = true; + } + if (refreshedAccountId && refreshedAccountId !== account.accountId) { + account.accountId = refreshedAccountId; + account.accountIdSource = "token"; + changed = true; + } + if (refreshResult.idToken) { + probeIdTokenByIndex.set(i, refreshResult.idToken); + } + probeRefreshedIndices.add(i); + + probeAccessToken = account.accessToken; + probeAccountId = account.accountId ?? refreshedAccountId; + } + + if (!probeAccessToken || !probeAccountId) { + probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); + continue; + } + + try { + const liveQuota = await fetchCodexQuotaSnapshot({ + accountId: probeAccountId, + accessToken: probeAccessToken, + model: options.model, + }); + liveQuotaByIndex.set(i, liveQuota); + } catch (error) { + const message = helpers.normalizeFailureDetail( + error instanceof Error ? error.message : String(error), + undefined, + ); + probeErrors.push(`${formatAccountLabel(account, i)}: ${message}`); + } + } + + const forecastInputs = storage.accounts.map((account, index) => ({ + index, + account, + isCurrent: index === helpers.resolveActiveIndex(storage, "codex"), + now, + refreshFailure: refreshFailures.get(index), + liveQuota: liveQuotaByIndex.get(index), + })); + + const forecastResults = evaluateForecastAccounts(forecastInputs); + const recommendation = recommendForecastAccount(forecastResults); + + if (recommendation.recommendedIndex === null) { + await persistProbeChangesIfNeeded(); + if (options.json) { + console.log(JSON.stringify({ + error: recommendation.reason, + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, null, 2)); + } else { + console.log(`No best account available: ${recommendation.reason}`); + printProbeNotes(); + } + return 1; + } + + const bestIndex = recommendation.recommendedIndex; + const bestAccount = storage.accounts[bestIndex]; + if (!bestAccount) { + await persistProbeChangesIfNeeded(); + if (options.json) { + console.log(JSON.stringify({ error: "Best account not found." }, null, 2)); + } else { + console.log("Best account not found."); + } + return 1; + } + + const currentIndex = helpers.resolveActiveIndex(storage, "codex"); + if (currentIndex === bestIndex) { + const shouldSyncCurrentBest = + probeRefreshedIndices.has(bestIndex) || probeIdTokenByIndex.has(bestIndex); + let alreadyBestSynced: boolean | undefined; + if (changed) { + bestAccount.lastUsed = now; + await persistProbeChangesIfNeeded(); + } + if (shouldSyncCurrentBest) { + alreadyBestSynced = await setCodexCliActiveSelection({ + accountId: bestAccount.accountId, + email: bestAccount.email, + accessToken: bestAccount.accessToken, + refreshToken: bestAccount.refreshToken, + expiresAt: bestAccount.expiresAt, + ...(probeIdTokenByIndex.has(bestIndex) + ? { idToken: probeIdTokenByIndex.get(bestIndex) } + : {}), + }); + if (!alreadyBestSynced && !options.json) { + console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); + } + } + if (options.json) { + console.log(JSON.stringify({ + message: `Already on best account: ${formatAccountLabel(bestAccount, bestIndex)}`, + accountIndex: bestIndex + 1, + reason: recommendation.reason, + ...(alreadyBestSynced !== undefined ? { synced: alreadyBestSynced } : {}), + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, null, 2)); + } else { + console.log(`Already on best account ${bestIndex + 1}: ${formatAccountLabel(bestAccount, bestIndex)}`); + console.log(`Reason: ${recommendation.reason}`); + printProbeNotes(); + } + return 0; + } + + const parsed = bestIndex + 1; + const { synced, wasDisabled } = await persistAndSyncSelectedAccount({ + storage, + targetIndex: bestIndex, + parsed, + switchReason: "best", + initialSyncIdToken: probeIdTokenByIndex.get(bestIndex), + helpers, + }); + + if (options.json) { + console.log(JSON.stringify({ + message: `Switched to best account: ${formatAccountLabel(bestAccount, bestIndex)}`, + accountIndex: parsed, + reason: recommendation.reason, + synced, + wasDisabled, + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, null, 2)); + } else { + console.log(`Switched to best account ${parsed}: ${formatAccountLabel(bestAccount, bestIndex)}${wasDisabled ? " (re-enabled)" : ""}`); + console.log(`Reason: ${recommendation.reason}`); + printProbeNotes(); + if (!synced) { + console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); + } + } + return 0; +} + +export async function runAuthLogin( + args: string[], + deps: AuthLoginCommandDeps, +): Promise { + const parsedArgs = parseAuthLoginArgs(args); + if (!parsedArgs.ok) { + if (parsedArgs.message) { + console.error(parsedArgs.message); + printUsage(); + return 1; + } + return 0; + } + + const loginOptions = parsedArgs.options; + setStoragePath(null); + let pendingMenuQuotaRefresh: Promise | null = null; + let menuQuotaRefreshStatus: string | undefined; + loginFlow: + while (true) { + let existingStorage = await loadAccounts(); + if (existingStorage && existingStorage.accounts.length > 0) { + while (true) { + existingStorage = await loadAccounts(); + if (!existingStorage || existingStorage.accounts.length === 0) { + break; + } + const currentStorage = existingStorage; + const displaySettings = await loadDashboardDisplaySettings(); + applyUiThemeFromDashboardSettings(displaySettings); + const quotaCache = await loadQuotaCache(); + const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true; + const showFetchStatus = displaySettings.menuShowFetchStatus ?? true; + const quotaTtlMs = + displaySettings.menuQuotaTtlMs ?? deps.defaultMenuQuotaRefreshTtlMs; + if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) { + const staleCount = deps.countMenuQuotaRefreshTargets( + currentStorage, + quotaCache, + quotaTtlMs, + ); + if (staleCount > 0) { + if (showFetchStatus) { + menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`; + } + pendingMenuQuotaRefresh = deps.refreshQuotaCacheForMenu( + currentStorage, + quotaCache, + quotaTtlMs, + (current, total) => { + if (!showFetchStatus) return; + menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`; + }, + ) + .then(() => undefined) + .catch(() => undefined) + .finally(() => { + menuQuotaRefreshStatus = undefined; + pendingMenuQuotaRefresh = null; + }); + } + } + const flaggedStorage = await loadFlaggedAccounts(); + + const menuResult = await promptLoginMode( + deps.toExistingAccountInfo(currentStorage, quotaCache, displaySettings), + { + flaggedCount: flaggedStorage.accounts.length, + statusMessage: showFetchStatus ? () => menuQuotaRefreshStatus : undefined, + }, + ); + + if (menuResult.mode === "cancel") { + console.log("Cancelled."); + return 0; + } + if (menuResult.mode === "check") { + await deps.runActionPanel("Quick Check", "Checking local session + live status", async () => { + await deps.runHealthCheck({ forceRefresh: false, liveProbe: true }); + }, displaySettings); + continue; + } + if (menuResult.mode === "deep-check") { + await deps.runActionPanel("Deep Check", "Refreshing and testing all accounts", async () => { + await deps.runHealthCheck({ forceRefresh: true, liveProbe: true }); + }, displaySettings); + continue; + } + if (menuResult.mode === "forecast") { + await deps.runActionPanel("Best Account", "Comparing accounts", async () => { + await deps.runForecast(["--live"]); + }, displaySettings); + continue; + } + if (menuResult.mode === "fix") { + await deps.runActionPanel("Auto-Fix", "Checking and fixing common issues", async () => { + await deps.runFix(["--live"]); + }, displaySettings); + continue; + } + if (menuResult.mode === "settings") { + await configureUnifiedSettings(displaySettings); + continue; + } + if (menuResult.mode === "verify-flagged") { + await deps.runActionPanel("Problem Account Check", "Checking problem accounts", async () => { + await deps.runVerifyFlagged([]); + }, displaySettings); + continue; + } + if (menuResult.mode === "fresh" && menuResult.deleteAll) { + await deps.runActionPanel("Reset Accounts", "Deleting all saved accounts", async () => { + await deps.clearAccountsAndReset(); + console.log("Cleared saved accounts from active storage. Recovery snapshots remain available."); + }, displaySettings); + continue; + } + if (menuResult.mode === "manage") { + const requiresInteractiveOAuth = typeof menuResult.refreshAccountIndex === "number"; + if (requiresInteractiveOAuth) { + await deps.handleManageAction(currentStorage, menuResult); + continue; + } + await deps.runActionPanel("Applying Change", "Updating selected account", async () => { + await deps.handleManageAction(currentStorage, menuResult); + }, displaySettings); + continue; + } + if (menuResult.mode === "add") { + break; + } + } + } + + const refreshedStorage = await loadAccounts(); + let existingCount = refreshedStorage?.accounts.length ?? 0; + let forceNewLogin = existingCount > 0; + let onboardingBackupDiscoveryWarning: string | null = null; + const loadNamedBackupsForOnboarding = async (): Promise => { + if (existingCount > 0) { + onboardingBackupDiscoveryWarning = null; + return []; + } + try { + onboardingBackupDiscoveryWarning = null; + return await getNamedBackups(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + deps.log.debug("getNamedBackups failed, skipping restore option", { + code, + error: error instanceof Error ? error.message : String(error), + }); + if (code && code !== "ENOENT") { + onboardingBackupDiscoveryWarning = + "Named backup discovery failed. Continuing with browser or manual sign-in only."; + console.warn(onboardingBackupDiscoveryWarning); + } else { + onboardingBackupDiscoveryWarning = null; + } + return []; + } + }; + let namedBackups = await loadNamedBackupsForOnboarding(); + while (true) { + const latestNamedBackup = namedBackups[0] ?? null; + const preferManualMode = loginOptions.manual || isBrowserLaunchSuppressed(); + const signInMode = preferManualMode + ? "manual" + : await deps.promptOAuthSignInMode( + latestNamedBackup, + onboardingBackupDiscoveryWarning, + ); + if (signInMode === "cancel") { + if (existingCount > 0) { + console.log(deps.stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted")); + continue loginFlow; + } + console.log("Cancelled."); + return 0; + } + if (signInMode === "restore-backup") { + const latestAvailableBackup = namedBackups[0] ?? null; + if (!latestAvailableBackup) { + namedBackups = await loadNamedBackupsForOnboarding(); + continue; + } + const restoreMode = await deps.promptBackupRestoreMode(latestAvailableBackup); + if (restoreMode === "back") { + namedBackups = await loadNamedBackupsForOnboarding(); + continue; + } + + const selectedBackup = restoreMode === "manual" + ? await deps.promptManualBackupSelection(namedBackups) + : latestAvailableBackup; + if (!selectedBackup) { + namedBackups = await loadNamedBackupsForOnboarding(); + continue; + } + + const confirmed = await confirm( + UI_COPY.oauth.restoreBackupConfirm( + selectedBackup.fileName, + selectedBackup.accountCount, + ), + ); + if (!confirmed) { + namedBackups = await loadNamedBackupsForOnboarding(); + continue; + } + + const displaySettings = await loadDashboardDisplaySettings(); + applyUiThemeFromDashboardSettings(displaySettings); + try { + await deps.runActionPanel( + "Load Backup", + `Loading ${selectedBackup.fileName}`, + async () => { + const restoredStorage = await restoreAccountsFromBackup( + selectedBackup.path, + { persist: false }, + ); + const targetIndex = deps.resolveActiveIndex(restoredStorage); + const { synced } = await persistAndSyncSelectedAccount({ + storage: restoredStorage, + targetIndex, + parsed: targetIndex + 1, + switchReason: "restore", + preserveActiveIndexByFamily: true, + helpers: deps, + }); + console.log( + UI_COPY.oauth.restoreBackupLoaded( + selectedBackup.fileName, + restoredStorage.accounts.length, + ), + ); + if (!synced) { + console.warn(UI_COPY.oauth.restoreBackupSyncWarning); + } + }, + displaySettings, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (error instanceof StorageError) { + console.error(formatStorageErrorHint(error, selectedBackup.path)); + } else { + console.error(`Backup restore failed: ${message}`); + } + const storageAfterRestoreAttempt = await loadAccounts().catch(() => null); + if ((storageAfterRestoreAttempt?.accounts.length ?? 0) > 0) { + continue loginFlow; + } + namedBackups = await loadNamedBackupsForOnboarding(); + continue; + } + continue loginFlow; + } + + if (signInMode !== "browser" && signInMode !== "manual") { + continue; + } + + const tokenResult = await deps.runOAuthFlow(forceNewLogin, signInMode); + if (tokenResult.type !== "success") { + const message = tokenResult.message ?? tokenResult.reason ?? "unknown error"; + if (message.toLowerCase().includes("cancelled")) { + if (existingCount > 0) { + console.log(deps.stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted")); + continue loginFlow; + } + console.log("Cancelled."); + return 0; + } + console.error(`Login failed: ${message}`); + return 1; + } + + const resolved = deps.resolveAccountSelection(tokenResult); + await deps.persistAccountPool([resolved], false); + await deps.syncSelectionToCodex(resolved); + + const latestStorage = await loadAccounts(); + const count = latestStorage?.accounts.length ?? 1; + existingCount = count; + namedBackups = []; + onboardingBackupDiscoveryWarning = null; + console.log(`Added account. Total: ${count}`); + console.log("Next steps:"); + console.log(" codex auth status Check that the wrapper is active."); + console.log(" codex auth check Confirm your saved accounts look healthy."); + console.log(" codex auth list Review saved accounts before switching."); + if (count >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { + console.log(`Reached maximum account limit (${ACCOUNT_LIMITS.MAX_ACCOUNTS}).`); + break; + } + + const addAnother = await promptAddAnotherAccount(count); + if (!addAnother) break; + forceNewLogin = true; + } + continue loginFlow; + } +} diff --git a/lib/codex-manager/help.ts b/lib/codex-manager/help.ts new file mode 100644 index 00000000..6756114b --- /dev/null +++ b/lib/codex-manager/help.ts @@ -0,0 +1,136 @@ +export function printUsage(): void { + console.log( + [ + "Codex Multi-Auth CLI", + "", + "Start here:", + " codex auth login [--manual|--no-browser]", + " codex auth status", + " codex auth check", + "", + "Daily use:", + " codex auth list", + " codex auth switch ", + " codex auth best [--live] [--json] [--model ]", + " codex auth forecast [--live] [--json] [--model ]", + "", + "Repair:", + " codex auth verify-flagged [--dry-run] [--json] [--no-restore]", + " codex auth fix [--dry-run] [--json] [--live] [--model ]", + " codex auth doctor [--json] [--fix] [--dry-run]", + "", + "Advanced:", + " codex auth report [--live] [--json] [--model ] [--out ]", + " codex auth features", + "", + "Notes:", + " - Uses ~/.codex/multi-auth/openai-codex-accounts.json", + " - Syncs active account into Codex CLI auth state", + " - See docs/reference/commands.md for the full command and flag matrix", + ].join("\n"), + ); +} + +export type AuthLoginOptions = { + manual: boolean; +}; + +export type ParsedAuthLoginArgs = + | { ok: true; options: AuthLoginOptions } + | { ok: false; message: string }; + +export function parseAuthLoginArgs(args: string[]): ParsedAuthLoginArgs { + const options: AuthLoginOptions = { + manual: false, + }; + + for (const arg of args) { + if (arg === "--manual" || arg === "--no-browser") { + options.manual = true; + continue; + } + if (arg === "--help" || arg === "-h") { + printUsage(); + return { ok: false, message: "" }; + } + return { + ok: false, + message: `Unknown login option: ${arg}`, + }; + } + + return { ok: true, options }; +} + +export interface BestCliOptions { + live: boolean; + json: boolean; + model: string; + modelProvided: boolean; +} + +export type ParsedBestArgs = + | { ok: true; options: BestCliOptions } + | { ok: false; message: string }; + +export function printBestUsage(): void { + console.log( + [ + "Usage:", + " codex auth best [--live] [--json] [--model ]", + "", + "Options:", + " --live, -l Probe live quota headers via Codex backend before switching", + " --json, -j Print machine-readable JSON output", + " --model, -m Probe model for live mode (default: gpt-5-codex)", + "", + "Behavior:", + " - Chooses the healthiest account using forecast scoring", + " - Switches to the recommended account when it is not already active", + ].join("\n"), + ); +} + +export function parseBestArgs(args: string[]): ParsedBestArgs { + const options: BestCliOptions = { + live: false, + json: false, + model: "gpt-5-codex", + modelProvided: false, + }; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg) continue; + if (arg === "--live" || arg === "-l") { + options.live = true; + continue; + } + if (arg === "--json" || arg === "-j") { + options.json = true; + continue; + } + if (arg === "--model" || arg === "-m") { + const value = args[i + 1]; + if (!value) { + return { ok: false, message: "Missing value for --model" }; + } + options.model = value; + options.modelProvided = true; + i += 1; + continue; + } + if (arg.startsWith("--model=")) { + const value = arg.slice("--model=".length).trim(); + if (!value) { + return { ok: false, message: "Missing value for --model" }; + } + options.model = value; + options.modelProvided = true; + continue; + } + return { ok: false, message: `Unknown option: ${arg}` }; + } + + return { ok: true, options }; +} diff --git a/test/documentation.test.ts b/test/documentation.test.ts index 1c696d36..4920e3fc 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -241,26 +241,32 @@ describe("Documentation Integrity", () => { it("keeps fix command flag docs aligned across README, reference, and CLI usage text", () => { const readme = read("README.md"); const commandRef = read("docs/reference/commands.md"); - const managerPath = "lib/codex-manager.ts"; + const helpPath = "lib/codex-manager/help.ts"; + const authCommandsPath = "lib/codex-manager/auth-commands.ts"; expect( - existsSync(join(projectRoot, managerPath)), - `${managerPath} should exist`, + existsSync(join(projectRoot, helpPath)), + `${helpPath} should exist`, ).toBe(true); - const manager = read(managerPath); + expect( + existsSync(join(projectRoot, authCommandsPath)), + `${authCommandsPath} should exist`, + ).toBe(true); + const help = read(helpPath); + const authCommands = read(authCommandsPath); expect(readme).toContain("codex auth fix --live --model gpt-5-codex"); expect(commandRef).toContain("| `--live` | forecast, report, fix |"); expect(commandRef).toContain( "| `--model ` | forecast, report, fix |", ); - expect(manager).toContain("codex auth login"); - expect(manager).toContain( + expect(help).toContain("codex auth login"); + expect(help).toContain( "codex auth fix [--dry-run] [--json] [--live] [--model ]", ); - expect(manager).toContain( + expect(authCommands).toContain( "Missing index. Usage: codex auth switch ", ); - expect(manager).not.toContain("codex-multi-auth auth switch "); + expect(authCommands).not.toContain("codex-multi-auth auth switch "); }); it("documents stable overrides separately from advanced and internal overrides", () => { From c307cf56810b354453fcf98e82e6c5002159b09e Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:25:40 +0800 Subject: [PATCH 2/7] test: cover extracted auth command modules --- lib/codex-manager/auth-commands.ts | 17 +- lib/codex-manager/help.ts | 31 +- test/codex-manager-auth-commands.test.ts | 399 +++++++++++++++++++++++ test/codex-manager-help.test.ts | 63 ++++ 4 files changed, 497 insertions(+), 13 deletions(-) create mode 100644 test/codex-manager-auth-commands.test.ts create mode 100644 test/codex-manager-help.test.ts diff --git a/lib/codex-manager/auth-commands.ts b/lib/codex-manager/auth-commands.ts index 9d3f7c80..55ccb813 100644 --- a/lib/codex-manager/auth-commands.ts +++ b/lib/codex-manager/auth-commands.ts @@ -264,17 +264,21 @@ export async function runSwitch( return 0; } +/** + * `codex auth best` still follows the monolith's single-writer storage pattern. + * Callers should keep concurrent CLI dispatches serialized while the live probe + * path mutates refreshed tokens before persisting them back to disk. + */ export async function runBest( args: string[], helpers: AuthCommandHelpers, ): Promise { - if (args.includes("--help") || args.includes("-h")) { - printBestUsage(); - return 0; - } - const parsedArgs = parseBestArgs(args); if (!parsedArgs.ok) { + if (parsedArgs.reason === "help") { + printBestUsage(); + return 0; + } console.error(parsedArgs.message); printBestUsage(); return 1; @@ -506,11 +510,12 @@ export async function runAuthLogin( ): Promise { const parsedArgs = parseAuthLoginArgs(args); if (!parsedArgs.ok) { - if (parsedArgs.message) { + if (parsedArgs.reason === "error") { console.error(parsedArgs.message); printUsage(); return 1; } + printUsage(); return 0; } diff --git a/lib/codex-manager/help.ts b/lib/codex-manager/help.ts index 6756114b..395596c2 100644 --- a/lib/codex-manager/help.ts +++ b/lib/codex-manager/help.ts @@ -37,7 +37,8 @@ export type AuthLoginOptions = { export type ParsedAuthLoginArgs = | { ok: true; options: AuthLoginOptions } - | { ok: false; message: string }; + | { ok: false; reason: "help" } + | { ok: false; reason: "error"; message: string }; export function parseAuthLoginArgs(args: string[]): ParsedAuthLoginArgs { const options: AuthLoginOptions = { @@ -50,11 +51,11 @@ export function parseAuthLoginArgs(args: string[]): ParsedAuthLoginArgs { continue; } if (arg === "--help" || arg === "-h") { - printUsage(); - return { ok: false, message: "" }; + return { ok: false, reason: "help" }; } return { ok: false, + reason: "error", message: `Unknown login option: ${arg}`, }; } @@ -71,7 +72,8 @@ export interface BestCliOptions { export type ParsedBestArgs = | { ok: true; options: BestCliOptions } - | { ok: false; message: string }; + | { ok: false; reason: "help" } + | { ok: false; reason: "error"; message: string }; export function printBestUsage(): void { console.log( @@ -102,6 +104,9 @@ export function parseBestArgs(args: string[]): ParsedBestArgs { for (let i = 0; i < args.length; i += 1) { const arg = args[i]; if (!arg) continue; + if (arg === "--help" || arg === "-h") { + return { ok: false, reason: "help" }; + } if (arg === "--live" || arg === "-l") { options.live = true; continue; @@ -113,7 +118,11 @@ export function parseBestArgs(args: string[]): ParsedBestArgs { if (arg === "--model" || arg === "-m") { const value = args[i + 1]; if (!value) { - return { ok: false, message: "Missing value for --model" }; + return { + ok: false, + reason: "error", + message: "Missing value for --model", + }; } options.model = value; options.modelProvided = true; @@ -123,13 +132,21 @@ export function parseBestArgs(args: string[]): ParsedBestArgs { if (arg.startsWith("--model=")) { const value = arg.slice("--model=".length).trim(); if (!value) { - return { ok: false, message: "Missing value for --model" }; + return { + ok: false, + reason: "error", + message: "Missing value for --model", + }; } options.model = value; options.modelProvided = true; continue; } - return { ok: false, message: `Unknown option: ${arg}` }; + return { + ok: false, + reason: "error", + message: `Unknown option: ${arg}`, + }; } return { ok: true, options }; diff --git a/test/codex-manager-auth-commands.test.ts b/test/codex-manager-auth-commands.test.ts new file mode 100644 index 00000000..e3c93904 --- /dev/null +++ b/test/codex-manager-auth-commands.test.ts @@ -0,0 +1,399 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ExistingAccountInfo } from "../lib/cli.js"; +import type { + DashboardDisplaySettings, +} from "../lib/dashboard-settings.js"; +import type { QuotaCacheData } from "../lib/quota-cache.js"; +import type { AccountStorageV3, NamedBackupSummary } from "../lib/storage.js"; +import type { TokenResult } from "../lib/types.js"; + +const { + extractAccountEmailMock, + extractAccountIdMock, + formatAccountLabelMock, + sanitizeEmailMock, + promptAddAnotherAccountMock, + promptLoginModeMock, + loadDashboardDisplaySettingsMock, + loadQuotaCacheMock, + fetchCodexQuotaSnapshotMock, + queuedRefreshMock, + loadAccountsMock, + loadFlaggedAccountsMock, + saveAccountsMock, + setStoragePathMock, + getNamedBackupsMock, + restoreAccountsFromBackupMock, + setCodexCliActiveSelectionMock, + confirmMock, + applyUiThemeFromDashboardSettingsMock, + configureUnifiedSettingsMock, +} = vi.hoisted(() => ({ + extractAccountEmailMock: vi.fn(), + extractAccountIdMock: vi.fn(), + formatAccountLabelMock: vi.fn(), + sanitizeEmailMock: vi.fn(), + promptAddAnotherAccountMock: vi.fn(), + promptLoginModeMock: vi.fn(), + loadDashboardDisplaySettingsMock: vi.fn(), + loadQuotaCacheMock: vi.fn(), + fetchCodexQuotaSnapshotMock: vi.fn(), + queuedRefreshMock: vi.fn(), + loadAccountsMock: vi.fn(), + loadFlaggedAccountsMock: vi.fn(), + saveAccountsMock: vi.fn(), + setStoragePathMock: vi.fn(), + getNamedBackupsMock: vi.fn(), + restoreAccountsFromBackupMock: vi.fn(), + setCodexCliActiveSelectionMock: vi.fn(), + confirmMock: vi.fn(), + applyUiThemeFromDashboardSettingsMock: vi.fn(), + configureUnifiedSettingsMock: vi.fn(), +})); + +vi.mock("../lib/auth/browser.js", () => ({ + isBrowserLaunchSuppressed: vi.fn(() => false), +})); + +vi.mock("../lib/accounts.js", () => ({ + extractAccountEmail: extractAccountEmailMock, + extractAccountId: extractAccountIdMock, + formatAccountLabel: formatAccountLabelMock, + sanitizeEmail: sanitizeEmailMock, +})); + +vi.mock("../lib/cli.js", () => ({ + promptAddAnotherAccount: promptAddAnotherAccountMock, + promptLoginMode: promptLoginModeMock, +})); + +vi.mock("../lib/dashboard-settings.js", () => ({ + loadDashboardDisplaySettings: loadDashboardDisplaySettingsMock, +})); + +vi.mock("../lib/quota-cache.js", () => ({ + loadQuotaCache: loadQuotaCacheMock, +})); + +vi.mock("../lib/quota-probe.js", () => ({ + fetchCodexQuotaSnapshot: fetchCodexQuotaSnapshotMock, +})); + +vi.mock("../lib/refresh-queue.js", () => ({ + queuedRefresh: queuedRefreshMock, +})); + +vi.mock("../lib/storage.js", async () => { + const actual = await vi.importActual("../lib/storage.js"); + return { + ...(actual as object), + loadAccounts: loadAccountsMock, + loadFlaggedAccounts: loadFlaggedAccountsMock, + saveAccounts: saveAccountsMock, + setStoragePath: setStoragePathMock, + getNamedBackups: getNamedBackupsMock, + restoreAccountsFromBackup: restoreAccountsFromBackupMock, + }; +}); + +vi.mock("../lib/codex-cli/writer.js", () => ({ + setCodexCliActiveSelection: setCodexCliActiveSelectionMock, +})); + +vi.mock("../lib/ui/confirm.js", () => ({ + confirm: confirmMock, +})); + +vi.mock("../lib/codex-manager/settings-hub.js", () => ({ + applyUiThemeFromDashboardSettings: applyUiThemeFromDashboardSettingsMock, + configureUnifiedSettings: configureUnifiedSettingsMock, +})); + +import { + persistAndSyncSelectedAccount, + runAuthLogin, + runBest, + runSwitch, + type AuthCommandHelpers, + type AuthLoginCommandDeps, +} from "../lib/codex-manager/auth-commands.js"; + +function createStorage( + accounts: AccountStorageV3["accounts"] = [ + { + email: "one@example.com", + refreshToken: "refresh-token-1", + accessToken: "access-token-1", + accountId: "acct-1", + expiresAt: Date.now() + 60_000, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + ], +): AccountStorageV3 { + return { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts, + }; +} + +function createHelpers( + overrides: Partial = {}, +): AuthCommandHelpers { + return { + resolveActiveIndex: vi.fn((storage: AccountStorageV3) => storage.activeIndex), + hasUsableAccessToken: vi.fn(() => true), + applyTokenAccountIdentity: vi.fn( + (account, tokenAccountId) => { + if (!tokenAccountId) return false; + account.accountId = tokenAccountId; + account.accountIdSource = "token"; + return true; + }, + ), + normalizeFailureDetail: vi.fn( + (message: string | undefined, reason: string | undefined) => + message ?? reason ?? "unknown", + ), + ...overrides, + }; +} + +function createAuthLoginDeps( + overrides: Partial = {}, +): AuthLoginCommandDeps { + return { + ...createHelpers(), + stylePromptText: vi.fn((text: string) => text), + runActionPanel: vi.fn(async (_title, _stage, action) => { + await action(); + }), + toExistingAccountInfo: vi.fn( + (): ExistingAccountInfo[] => [], + ), + countMenuQuotaRefreshTargets: vi.fn(() => 0), + defaultMenuQuotaRefreshTtlMs: 60_000, + refreshQuotaCacheForMenu: vi.fn( + async (_storage, cache: QuotaCacheData): Promise => cache, + ), + clearAccountsAndReset: vi.fn(async () => undefined), + handleManageAction: vi.fn(async () => undefined), + promptOAuthSignInMode: vi.fn( + async ( + _backupOption: NamedBackupSummary | null, + _backupDiscoveryWarning?: string | null, + ) => "cancel" as const, + ), + promptBackupRestoreMode: vi.fn( + async (_latestBackup: NamedBackupSummary) => "back" as const, + ), + promptManualBackupSelection: vi.fn( + async (_namedBackups: NamedBackupSummary[]) => null, + ), + runOAuthFlow: vi.fn( + async (): Promise => ({ + type: "success", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + idToken: "id-token", + }), + ), + resolveAccountSelection: vi.fn((tokens) => tokens), + persistAccountPool: vi.fn(async () => undefined), + syncSelectionToCodex: vi.fn(async () => undefined), + runHealthCheck: vi.fn(async () => undefined), + runForecast: vi.fn(async () => 0), + runFix: vi.fn(async () => 0), + runVerifyFlagged: vi.fn(async () => 0), + log: { + debug: vi.fn(), + }, + ...overrides, + }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-22T19:30:00.000Z")); + vi.clearAllMocks(); + + extractAccountEmailMock.mockReturnValue(undefined); + extractAccountIdMock.mockReturnValue("acct-refreshed"); + formatAccountLabelMock.mockImplementation( + (account: { email?: string }, index: number) => + account.email ? `${index + 1}. ${account.email}` : `Account ${index + 1}`, + ); + sanitizeEmailMock.mockImplementation( + (email: string | undefined) => + typeof email === "string" ? email.toLowerCase() : undefined, + ); + loadDashboardDisplaySettingsMock.mockResolvedValue( + {} satisfies DashboardDisplaySettings, + ); + loadQuotaCacheMock.mockResolvedValue({} satisfies QuotaCacheData); + loadFlaggedAccountsMock.mockResolvedValue({ + version: 3, + accounts: [], + }); + saveAccountsMock.mockResolvedValue(undefined); + setCodexCliActiveSelectionMock.mockResolvedValue(true); + queuedRefreshMock.mockResolvedValue({ + type: "success", + access: "fresh-access-token", + refresh: "fresh-refresh-token", + expires: Date.now() + 60_000, + idToken: "fresh-id-token", + }); + fetchCodexQuotaSnapshotMock.mockResolvedValue({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }); + confirmMock.mockResolvedValue(true); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("codex-manager auth command helpers", () => { + it("re-enables a selected account, refreshes missing tokens, and syncs it", async () => { + extractAccountEmailMock.mockReturnValue("Refreshed@Example.com"); + const storage = createStorage([ + { + email: "disabled@example.com", + refreshToken: "stale-refresh-token", + addedAt: 1, + lastUsed: 1, + enabled: false, + }, + ]); + const helpers = createHelpers({ + hasUsableAccessToken: vi.fn(() => false), + }); + + const result = await persistAndSyncSelectedAccount({ + storage, + targetIndex: 0, + parsed: 1, + switchReason: "rotation", + helpers, + }); + + expect(result).toEqual({ synced: true, wasDisabled: true }); + expect(storage.accounts[0]).toMatchObject({ + email: "refreshed@example.com", + refreshToken: "fresh-refresh-token", + accessToken: "fresh-access-token", + accountId: "acct-refreshed", + accountIdSource: "token", + enabled: true, + lastSwitchReason: "rotation", + }); + expect(saveAccountsMock).toHaveBeenCalledWith(storage); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "acct-refreshed", + email: "refreshed@example.com", + accessToken: "fresh-access-token", + refreshToken: "fresh-refresh-token", + idToken: "fresh-id-token", + }), + ); + }); + + it("keeps switching when refresh fails and surfaces the warning", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + queuedRefreshMock.mockResolvedValue({ + type: "error", + reason: "auth-failure", + message: "refresh expired", + }); + setCodexCliActiveSelectionMock.mockResolvedValue(false); + const storage = createStorage([ + { + email: "warning@example.com", + refreshToken: "warning-refresh-token", + accessToken: "existing-access-token", + accountId: "acct-warning", + expiresAt: Date.now() - 1, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + ]); + const helpers = createHelpers({ + hasUsableAccessToken: vi.fn(() => false), + }); + + const result = await persistAndSyncSelectedAccount({ + storage, + targetIndex: 0, + parsed: 1, + switchReason: "best", + helpers, + }); + + expect(result).toEqual({ synced: false, wasDisabled: false }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("refresh expired"), + ); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "acct-warning", + accessToken: "existing-access-token", + }), + ); + }); + + it("validates switch indices before mutating storage", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + loadAccountsMock.mockResolvedValue(createStorage()); + + await expect(runSwitch([], createHelpers())).resolves.toBe(1); + await expect(runSwitch(["bogus"], createHelpers())).resolves.toBe(1); + await expect(runSwitch(["2"], createHelpers())).resolves.toBe(1); + + expect(errorSpy.mock.calls.map(([message]) => String(message))).toEqual( + expect.arrayContaining([ + "Missing index. Usage: codex auth switch ", + "Invalid index: bogus", + "Index out of range. Valid range: 1-1", + ]), + ); + }); + + it("reports the current best account directly from the extracted command", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + loadAccountsMock.mockResolvedValue(createStorage()); + + const result = await runBest(["--json"], createHelpers()); + + expect(result).toBe(0); + const output = JSON.parse(String(logSpy.mock.calls.at(-1)?.[0] ?? "{}")) as { + message: string; + accountIndex: number; + }; + expect(output.message).toContain("Already on best account"); + expect(output.accountIndex).toBe(1); + }); + + it("prints usage from runAuthLogin without entering the interactive flow", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const deps = createAuthLoginDeps(); + + const result = await runAuthLogin(["--help"], deps); + + expect(result).toBe(0); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Codex Multi-Auth CLI"), + ); + expect(loadAccountsMock).not.toHaveBeenCalled(); + }); +}); diff --git a/test/codex-manager-help.test.ts b/test/codex-manager-help.test.ts new file mode 100644 index 00000000..2bb84355 --- /dev/null +++ b/test/codex-manager-help.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import { + parseAuthLoginArgs, + parseBestArgs, +} from "../lib/codex-manager/help.js"; + +describe("codex-manager help parsers", () => { + it("parses login flags without printing usage", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect(parseAuthLoginArgs(["--manual"])).toEqual({ + ok: true, + options: { manual: true }, + }); + expect(parseAuthLoginArgs(["--no-browser"])).toEqual({ + ok: true, + options: { manual: true }, + }); + expect(parseAuthLoginArgs(["--help"])).toEqual({ + ok: false, + reason: "help", + }); + expect(logSpy).not.toHaveBeenCalled(); + logSpy.mockRestore(); + }); + + it("reports login parser errors explicitly", () => { + expect(parseAuthLoginArgs(["--bogus"])).toEqual({ + ok: false, + reason: "error", + message: "Unknown login option: --bogus", + }); + }); + + it("parses best args and treats help as a first-class result", () => { + expect(parseBestArgs(["--live", "--json", "--model", "gpt-5"])).toEqual({ + ok: true, + options: { + live: true, + json: true, + model: "gpt-5", + modelProvided: true, + }, + }); + expect(parseBestArgs(["-h"])).toEqual({ + ok: false, + reason: "help", + }); + }); + + it("reports missing model values and unknown flags", () => { + expect(parseBestArgs(["--model"])).toEqual({ + ok: false, + reason: "error", + message: "Missing value for --model", + }); + expect(parseBestArgs(["--bogus"])).toEqual({ + ok: false, + reason: "error", + message: "Unknown option: --bogus", + }); + }); +}); From d48cfa872b4cc1ae4c94f811c6db32aed36354b9 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:35:31 +0800 Subject: [PATCH 3/7] fix: clamp restored auth family indices --- lib/codex-manager/auth-commands.ts | 29 ++- test/codex-manager-auth-commands.test.ts | 235 +++++++++++++++++++++++ 2 files changed, 261 insertions(+), 3 deletions(-) diff --git a/lib/codex-manager/auth-commands.ts b/lib/codex-manager/auth-commands.ts index 55ccb813..26b9643d 100644 --- a/lib/codex-manager/auth-commands.ts +++ b/lib/codex-manager/auth-commands.ts @@ -33,7 +33,7 @@ import { } from "../storage.js"; import type { AccountIdSource, TokenFailure, TokenResult } from "../types.js"; import { setCodexCliActiveSelection } from "../codex-cli/writer.js"; -import type { ModelFamily } from "../prompts/codex.js"; +import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; import { UI_COPY } from "../ui/copy.js"; import { confirm } from "../ui/confirm.js"; import { @@ -134,6 +134,27 @@ export interface AuthLoginCommandDeps extends AuthCommandHelpers { }; } +function clampPreservedActiveIndexByFamily( + storage: AccountStorageV3, + targetIndex: number, +): void { + const maxIndex = Math.max(0, storage.accounts.length - 1); + const existingByFamily = storage.activeIndexByFamily ?? {}; + const nextByFamily: Record = {}; + + for (const family of MODEL_FAMILIES) { + const candidate = existingByFamily[family]; + if (typeof candidate !== "number" || !Number.isInteger(candidate)) continue; + nextByFamily[family] = Math.min( + maxIndex, + Math.max(0, candidate), + ); + } + + nextByFamily.codex = targetIndex; + storage.activeIndexByFamily = nextByFamily; +} + export async function persistAndSyncSelectedAccount({ storage, targetIndex, @@ -162,10 +183,12 @@ export async function persistAndSyncSelectedAccount({ } storage.activeIndex = targetIndex; - if (!storage.activeIndexByFamily || !preserveActiveIndexByFamily) { + if (storage.activeIndexByFamily && preserveActiveIndexByFamily) { + clampPreservedActiveIndexByFamily(storage, targetIndex); + } else { storage.activeIndexByFamily = {}; + storage.activeIndexByFamily.codex = targetIndex; } - storage.activeIndexByFamily.codex = targetIndex; const switchNow = Date.now(); let syncAccessToken = account.accessToken; diff --git a/test/codex-manager-auth-commands.test.ts b/test/codex-manager-auth-commands.test.ts index e3c93904..91b86e7a 100644 --- a/test/codex-manager-auth-commands.test.ts +++ b/test/codex-manager-auth-commands.test.ts @@ -11,6 +11,7 @@ const { extractAccountEmailMock, extractAccountIdMock, formatAccountLabelMock, + formatWaitTimeMock, sanitizeEmailMock, promptAddAnotherAccountMock, promptLoginModeMock, @@ -32,6 +33,7 @@ const { extractAccountEmailMock: vi.fn(), extractAccountIdMock: vi.fn(), formatAccountLabelMock: vi.fn(), + formatWaitTimeMock: vi.fn(), sanitizeEmailMock: vi.fn(), promptAddAnotherAccountMock: vi.fn(), promptLoginModeMock: vi.fn(), @@ -59,6 +61,7 @@ vi.mock("../lib/accounts.js", () => ({ extractAccountEmail: extractAccountEmailMock, extractAccountId: extractAccountIdMock, formatAccountLabel: formatAccountLabelMock, + formatWaitTime: formatWaitTimeMock, sanitizeEmail: sanitizeEmailMock, })); @@ -227,6 +230,7 @@ beforeEach(() => { (account: { email?: string }, index: number) => account.email ? `${index + 1}. ${account.email}` : `Account ${index + 1}`, ); + formatWaitTimeMock.mockImplementation((waitMs: number) => `${waitMs}ms`); sanitizeEmailMock.mockImplementation( (email: string | undefined) => typeof email === "string" ? email.toLowerCase() : undefined, @@ -239,6 +243,8 @@ beforeEach(() => { version: 3, accounts: [], }); + getNamedBackupsMock.mockResolvedValue([]); + restoreAccountsFromBackupMock.mockResolvedValue(createStorage()); saveAccountsMock.mockResolvedValue(undefined); setCodexCliActiveSelectionMock.mockResolvedValue(true); queuedRefreshMock.mockResolvedValue({ @@ -255,6 +261,7 @@ beforeEach(() => { secondary: {}, }); confirmMock.mockResolvedValue(true); + promptLoginModeMock.mockResolvedValue({ mode: "cancel" }); }); afterEach(() => { @@ -308,6 +315,67 @@ describe("codex-manager auth command helpers", () => { ); }); + it("clamps preserved family indices when restoring smaller account pools", async () => { + const storage = createStorage([ + { + email: "first@example.com", + refreshToken: "refresh-token-1", + accessToken: "access-token-1", + accountId: "acct-1", + expiresAt: Date.now() + 60_000, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + { + email: "second@example.com", + refreshToken: "refresh-token-2", + accessToken: "access-token-2", + accountId: "acct-2", + expiresAt: Date.now() + 60_000, + addedAt: 2, + lastUsed: 2, + enabled: true, + }, + { + email: "third@example.com", + refreshToken: "refresh-token-3", + accessToken: "access-token-3", + accountId: "acct-3", + expiresAt: Date.now() + 60_000, + addedAt: 3, + lastUsed: 3, + enabled: true, + }, + ]); + storage.activeIndexByFamily = { + codex: 0, + "gpt-5-codex": 1, + "codex-max": 2, + "gpt-5.2": 9, + "gpt-5.1": -4, + }; + + await expect( + persistAndSyncSelectedAccount({ + storage, + targetIndex: 1, + parsed: 2, + switchReason: "restore", + preserveActiveIndexByFamily: true, + helpers: createHelpers(), + }), + ).resolves.toEqual({ synced: true, wasDisabled: false }); + + expect(storage.activeIndexByFamily).toEqual({ + codex: 1, + "gpt-5-codex": 1, + "codex-max": 2, + "gpt-5.2": 2, + "gpt-5.1": 0, + }); + }); + it("keeps switching when refresh fails and surfaces the warning", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); queuedRefreshMock.mockResolvedValue({ @@ -384,6 +452,173 @@ describe("codex-manager auth command helpers", () => { expect(output.accountIndex).toBe(1); }); + it("reports json output when runBest switches to a healthier account", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + setCodexCliActiveSelectionMock.mockResolvedValue(false); + const storage = createStorage([ + { + email: "current@example.com", + refreshToken: "refresh-token-current", + accessToken: "access-token-current", + accountId: "acct-current", + expiresAt: Date.now() + 60_000, + addedAt: 1, + lastUsed: 1, + enabled: true, + coolingDownUntil: Date.now() + 60_000, + }, + { + email: "best@example.com", + refreshToken: "refresh-token-best", + accessToken: "access-token-best", + accountId: "acct-best", + expiresAt: Date.now() + 60_000, + addedAt: 2, + lastUsed: 2, + enabled: true, + }, + ]); + loadAccountsMock.mockResolvedValue(storage); + + const result = await runBest(["--json"], createHelpers()); + + expect(result).toBe(0); + const output = JSON.parse(String(logSpy.mock.calls.at(-1)?.[0] ?? "{}")) as { + message: string; + accountIndex: number; + synced: boolean; + wasDisabled: boolean; + }; + expect(output).toMatchObject({ + accountIndex: 2, + synced: false, + wasDisabled: false, + }); + expect(output.message).toContain("Switched to best account"); + expect(storage.activeIndex).toBe(1); + }); + + it("warns when runBest switches locally but cannot sync Codex auth", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + setCodexCliActiveSelectionMock.mockResolvedValue(false); + loadAccountsMock.mockResolvedValue( + createStorage([ + { + email: "current@example.com", + refreshToken: "refresh-token-current", + accessToken: "access-token-current", + accountId: "acct-current", + expiresAt: Date.now() + 60_000, + addedAt: 1, + lastUsed: 1, + enabled: true, + coolingDownUntil: Date.now() + 60_000, + }, + { + email: "best@example.com", + refreshToken: "refresh-token-best", + accessToken: "access-token-best", + accountId: "acct-best", + expiresAt: Date.now() + 60_000, + addedAt: 2, + lastUsed: 2, + enabled: true, + }, + ]), + ); + + const result = await runBest([], createHelpers()); + + expect(result).toBe(0); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Switched to best account 2"), + ); + expect(warnSpy).toHaveBeenCalledWith( + "Codex auth sync did not complete. Multi-auth routing will still use this account.", + ); + }); + + it("restores a backup through the extracted login flow and clamps family indices", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const restoredStorage = createStorage([ + { + email: "first@example.com", + refreshToken: "refresh-token-1", + accessToken: "access-token-1", + accountId: "acct-1", + expiresAt: Date.now() + 60_000, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + { + email: "second@example.com", + refreshToken: "refresh-token-2", + accessToken: "access-token-2", + accountId: "acct-2", + expiresAt: Date.now() + 60_000, + addedAt: 2, + lastUsed: 2, + enabled: true, + }, + { + email: "third@example.com", + refreshToken: "refresh-token-3", + accessToken: "access-token-3", + accountId: "acct-3", + expiresAt: Date.now() + 60_000, + addedAt: 3, + lastUsed: 3, + enabled: true, + }, + ]); + restoredStorage.activeIndex = 1; + restoredStorage.activeIndexByFamily = { + codex: 0, + "gpt-5-codex": 1, + "codex-max": 7, + "gpt-5.2": 9, + "gpt-5.1": -3, + }; + loadAccountsMock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(restoredStorage) + .mockResolvedValueOnce(restoredStorage); + getNamedBackupsMock.mockResolvedValue([ + { + path: "/mock/backups/latest.json", + fileName: "latest.json", + accountCount: 3, + mtimeMs: Date.now(), + }, + ]); + restoreAccountsFromBackupMock.mockResolvedValue(restoredStorage); + const deps = createAuthLoginDeps({ + promptOAuthSignInMode: vi.fn(async () => "restore-backup" as const), + promptBackupRestoreMode: vi.fn(async () => "latest" as const), + }); + + const result = await runAuthLogin([], deps); + + expect(result).toBe(0); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(restoreAccountsFromBackupMock).toHaveBeenCalledWith( + "/mock/backups/latest.json", + { persist: false }, + ); + expect(saveAccountsMock).toHaveBeenCalledWith(restoredStorage); + expect(restoredStorage.activeIndexByFamily).toEqual({ + codex: 1, + "gpt-5-codex": 1, + "codex-max": 2, + "gpt-5.2": 2, + "gpt-5.1": 0, + }); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("latest.json")); + }); + it("prints usage from runAuthLogin without entering the interactive flow", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const deps = createAuthLoginDeps(); From 360fe8c5747a96134f6d9897f93c54fa492aeca9 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:58:07 +0800 Subject: [PATCH 4/7] test: cover concurrent auth best live refresh writes --- lib/codex-manager/auth-commands.ts | 6 ++- test/codex-manager-auth-commands.test.ts | 65 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/lib/codex-manager/auth-commands.ts b/lib/codex-manager/auth-commands.ts index 26b9643d..1ef4640c 100644 --- a/lib/codex-manager/auth-commands.ts +++ b/lib/codex-manager/auth-commands.ts @@ -289,8 +289,10 @@ export async function runSwitch( /** * `codex auth best` still follows the monolith's single-writer storage pattern. - * Callers should keep concurrent CLI dispatches serialized while the live probe - * path mutates refreshed tokens before persisting them back to disk. + * `saveAccounts` already serializes in-process writes and `queuedRefresh` + * deduplicates refresh work, but separate CLI processes can still overlap while + * the live probe path mutates refreshed tokens before persisting them back to + * disk. Callers should keep concurrent CLI dispatches serialized. */ export async function runBest( args: string[], diff --git a/test/codex-manager-auth-commands.test.ts b/test/codex-manager-auth-commands.test.ts index 91b86e7a..21af0207 100644 --- a/test/codex-manager-auth-commands.test.ts +++ b/test/codex-manager-auth-commands.test.ts @@ -539,6 +539,71 @@ describe("codex-manager auth command helpers", () => { ); }); + it("keeps concurrent runBest live refresh writes consistent per snapshot", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + extractAccountEmailMock.mockReturnValue("fresh@example.com"); + const baselineStorage = createStorage([ + { + email: "stale@example.com", + refreshToken: "stale-refresh-token", + accessToken: "stale-access-token", + accountId: "acct-stale", + expiresAt: Date.now() - 1, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + ]); + loadAccountsMock.mockImplementation( + async () => structuredClone(baselineStorage), + ); + + let releaseFirstSave: (() => void) | undefined; + const firstSaveReleased = new Promise((resolve) => { + releaseFirstSave = resolve; + }); + const persistedSnapshots: AccountStorageV3[] = []; + let saveCallCount = 0; + saveAccountsMock.mockImplementation(async (storage: AccountStorageV3) => { + persistedSnapshots.push(structuredClone(storage)); + saveCallCount += 1; + if (saveCallCount === 1) { + await Promise.resolve(); + await firstSaveReleased; + return; + } + if (saveCallCount === 2) { + releaseFirstSave?.(); + } + }); + + const helpers = createHelpers({ + hasUsableAccessToken: vi.fn(() => false), + }); + + const [firstResult, secondResult] = await Promise.all([ + runBest(["--live", "--json"], helpers), + runBest(["--live", "--json"], helpers), + ]); + + expect(firstResult).toBe(0); + expect(secondResult).toBe(0); + expect(saveAccountsMock).toHaveBeenCalledTimes(2); + expect(persistedSnapshots).toHaveLength(2); + for (const snapshot of persistedSnapshots) { + expect(snapshot.activeIndex).toBe(0); + expect(snapshot.accounts[0]).toMatchObject({ + email: "fresh@example.com", + refreshToken: "fresh-refresh-token", + accessToken: "fresh-access-token", + accountId: "acct-refreshed", + accountIdSource: "token", + }); + } + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(2); + expect(logSpy).toHaveBeenCalledTimes(2); + }); + it("restores a backup through the extracted login flow and clamps family indices", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const restoredStorage = createStorage([ From 276201285c51a142eeb19da78a73f88503199d63 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:04:54 +0800 Subject: [PATCH 5/7] Serialize live auth-best probe writes --- lib/codex-manager/auth-commands.ts | 422 +++++++++++++---------- test/codex-manager-auth-commands.test.ts | 107 +++--- 2 files changed, 289 insertions(+), 240 deletions(-) diff --git a/lib/codex-manager/auth-commands.ts b/lib/codex-manager/auth-commands.ts index 1ef4640c..5fbf138d 100644 --- a/lib/codex-manager/auth-commands.ts +++ b/lib/codex-manager/auth-commands.ts @@ -21,6 +21,7 @@ import { queuedRefresh } from "../refresh-queue.js"; import { getNamedBackups, formatStorageErrorHint, + getStoragePath, loadAccounts, loadFlaggedAccounts, restoreAccountsFromBackup, @@ -34,6 +35,7 @@ import { import type { AccountIdSource, TokenFailure, TokenResult } from "../types.js"; import { setCodexCliActiveSelection } from "../codex-cli/writer.js"; import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; +import { RefreshLeaseCoordinator } from "../refresh-lease.js"; import { UI_COPY } from "../ui/copy.js"; import { confirm } from "../ui/confirm.js"; import { @@ -58,6 +60,10 @@ type OAuthSignInMode = "browser" | "manual" | "restore-backup" | "cancel"; type BackupRestoreMode = "latest" | "manual" | "back"; type LoginMenuResult = Awaited>; type HealthCheckOptions = { forceRefresh?: boolean; liveProbe?: boolean }; +type SerializedLiveBestQueueEntry = { tail: Promise }; + +const liveBestLeaseCoordinator = RefreshLeaseCoordinator.fromEnvironment(); +const serializedLiveBestByStorage = new Map(); export interface AuthCommandHelpers { resolveActiveIndex: ( @@ -134,6 +140,44 @@ export interface AuthLoginCommandDeps extends AuthCommandHelpers { }; } +async function withSerializedBestLiveRun( + storagePath: string, + action: () => Promise, +): Promise { + const queueEntry = + serializedLiveBestByStorage.get(storagePath) ?? { tail: Promise.resolve() }; + serializedLiveBestByStorage.set(storagePath, queueEntry); + + const waitForPrior = queueEntry.tail.catch(() => undefined); + let releaseQueue = (): void => undefined; + const currentTail = new Promise((resolve) => { + releaseQueue = resolve; + }); + queueEntry.tail = currentTail; + + await waitForPrior; + + let lease: Awaited> | null = null; + try { + lease = await liveBestLeaseCoordinator.acquire(`codex-auth-best-live:${storagePath}`); + return await action(); + } finally { + try { + if (lease) { + await lease.release(); + } + } finally { + releaseQueue(); + if ( + serializedLiveBestByStorage.get(storagePath) === queueEntry && + queueEntry.tail === currentTail + ) { + serializedLiveBestByStorage.delete(storagePath); + } + } + } +} + function clampPreservedActiveIndexByFamily( storage: AccountStorageV3, targetIndex: number, @@ -287,13 +331,6 @@ export async function runSwitch( return 0; } -/** - * `codex auth best` still follows the monolith's single-writer storage pattern. - * `saveAccounts` already serializes in-process writes and `queuedRefresh` - * deduplicates refresh work, but separate CLI processes can still overlap while - * the live probe path mutates refreshed tokens before persisting them back to - * disk. Callers should keep concurrent CLI dispatches serialized. - */ export async function runBest( args: string[], helpers: AuthCommandHelpers, @@ -316,217 +353,228 @@ export async function runBest( } setStoragePath(null); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - if (options.json) { - console.log(JSON.stringify({ error: "No accounts configured." }, null, 2)); - } else { - console.log("No accounts configured."); + const execute = async (): Promise => { + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + if (options.json) { + console.log(JSON.stringify({ error: "No accounts configured." }, null, 2)); + } else { + console.log("No accounts configured."); + } + return 1; } - return 1; - } - const now = Date.now(); - const refreshFailures = new Map(); - const liveQuotaByIndex = new Map>>(); - const probeIdTokenByIndex = new Map(); - const probeRefreshedIndices = new Set(); - const probeErrors: string[] = []; - let changed = false; - - const printProbeNotes = (): void => { - if (probeErrors.length === 0) return; - console.log(`Live check notes (${probeErrors.length}):`); - for (const error of probeErrors) { - console.log(` - ${error}`); - } - }; + const now = Date.now(); + const refreshFailures = new Map(); + const liveQuotaByIndex = new Map>>(); + const probeIdTokenByIndex = new Map(); + const probeRefreshedIndices = new Set(); + const probeErrors: string[] = []; + let changed = false; + + const printProbeNotes = (): void => { + if (probeErrors.length === 0) return; + console.log(`Live check notes (${probeErrors.length}):`); + for (const error of probeErrors) { + console.log(` - ${error}`); + } + }; - const persistProbeChangesIfNeeded = async (): Promise => { - if (!changed) return; - await saveAccounts(storage); - changed = false; - }; + const persistProbeChangesIfNeeded = async (): Promise => { + if (!changed) return; + await saveAccounts(storage); + changed = false; + }; - for (let i = 0; i < storage.accounts.length; i += 1) { - const account = storage.accounts[i]; - if (!account || !options.live) continue; - if (account.enabled === false) continue; - - let probeAccessToken = account.accessToken; - let probeAccountId = account.accountId ?? extractAccountId(account.accessToken); - if (!helpers.hasUsableAccessToken(account, now)) { - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type !== "success") { - refreshFailures.set(i, { - ...refreshResult, - message: helpers.normalizeFailureDetail(refreshResult.message, refreshResult.reason), - }); - continue; - } + for (let i = 0; i < storage.accounts.length; i += 1) { + const account = storage.accounts[i]; + if (!account || !options.live) continue; + if (account.enabled === false) continue; + + let probeAccessToken = account.accessToken; + let probeAccountId = account.accountId ?? extractAccountId(account.accessToken); + if (!helpers.hasUsableAccessToken(account, now)) { + const refreshResult = await queuedRefresh(account.refreshToken); + if (refreshResult.type !== "success") { + refreshFailures.set(i, { + ...refreshResult, + message: helpers.normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), + }); + continue; + } - const refreshedEmail = sanitizeEmail( - extractAccountEmail(refreshResult.access, refreshResult.idToken), - ); - const refreshedAccountId = extractAccountId(refreshResult.access); + const refreshedEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); + const refreshedAccountId = extractAccountId(refreshResult.access); - if (account.refreshToken !== refreshResult.refresh) { - account.refreshToken = refreshResult.refresh; - changed = true; - } - if (account.accessToken !== refreshResult.access) { - account.accessToken = refreshResult.access; - changed = true; - } - if (account.expiresAt !== refreshResult.expires) { - account.expiresAt = refreshResult.expires; - changed = true; - } - if (refreshedEmail && refreshedEmail !== account.email) { - account.email = refreshedEmail; - changed = true; - } - if (refreshedAccountId && refreshedAccountId !== account.accountId) { - account.accountId = refreshedAccountId; - account.accountIdSource = "token"; - changed = true; - } - if (refreshResult.idToken) { - probeIdTokenByIndex.set(i, refreshResult.idToken); - } - probeRefreshedIndices.add(i); + if (account.refreshToken !== refreshResult.refresh) { + account.refreshToken = refreshResult.refresh; + changed = true; + } + if (account.accessToken !== refreshResult.access) { + account.accessToken = refreshResult.access; + changed = true; + } + if (account.expiresAt !== refreshResult.expires) { + account.expiresAt = refreshResult.expires; + changed = true; + } + if (refreshedEmail && refreshedEmail !== account.email) { + account.email = refreshedEmail; + changed = true; + } + if (refreshedAccountId && refreshedAccountId !== account.accountId) { + account.accountId = refreshedAccountId; + account.accountIdSource = "token"; + changed = true; + } + if (refreshResult.idToken) { + probeIdTokenByIndex.set(i, refreshResult.idToken); + } + probeRefreshedIndices.add(i); - probeAccessToken = account.accessToken; - probeAccountId = account.accountId ?? refreshedAccountId; - } + probeAccessToken = account.accessToken; + probeAccountId = account.accountId ?? refreshedAccountId; + } - if (!probeAccessToken || !probeAccountId) { - probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); - continue; - } + if (!probeAccessToken || !probeAccountId) { + probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); + continue; + } - try { - const liveQuota = await fetchCodexQuotaSnapshot({ - accountId: probeAccountId, - accessToken: probeAccessToken, - model: options.model, - }); - liveQuotaByIndex.set(i, liveQuota); - } catch (error) { - const message = helpers.normalizeFailureDetail( - error instanceof Error ? error.message : String(error), - undefined, - ); - probeErrors.push(`${formatAccountLabel(account, i)}: ${message}`); + try { + const liveQuota = await fetchCodexQuotaSnapshot({ + accountId: probeAccountId, + accessToken: probeAccessToken, + model: options.model, + }); + liveQuotaByIndex.set(i, liveQuota); + } catch (error) { + const message = helpers.normalizeFailureDetail( + error instanceof Error ? error.message : String(error), + undefined, + ); + probeErrors.push(`${formatAccountLabel(account, i)}: ${message}`); + } } - } - const forecastInputs = storage.accounts.map((account, index) => ({ - index, - account, - isCurrent: index === helpers.resolveActiveIndex(storage, "codex"), - now, - refreshFailure: refreshFailures.get(index), - liveQuota: liveQuotaByIndex.get(index), - })); + const forecastInputs = storage.accounts.map((account, index) => ({ + index, + account, + isCurrent: index === helpers.resolveActiveIndex(storage, "codex"), + now, + refreshFailure: refreshFailures.get(index), + liveQuota: liveQuotaByIndex.get(index), + })); - const forecastResults = evaluateForecastAccounts(forecastInputs); - const recommendation = recommendForecastAccount(forecastResults); + const forecastResults = evaluateForecastAccounts(forecastInputs); + const recommendation = recommendForecastAccount(forecastResults); - if (recommendation.recommendedIndex === null) { - await persistProbeChangesIfNeeded(); - if (options.json) { - console.log(JSON.stringify({ - error: recommendation.reason, - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, null, 2)); - } else { - console.log(`No best account available: ${recommendation.reason}`); - printProbeNotes(); - } - return 1; - } - - const bestIndex = recommendation.recommendedIndex; - const bestAccount = storage.accounts[bestIndex]; - if (!bestAccount) { - await persistProbeChangesIfNeeded(); - if (options.json) { - console.log(JSON.stringify({ error: "Best account not found." }, null, 2)); - } else { - console.log("Best account not found."); + if (recommendation.recommendedIndex === null) { + await persistProbeChangesIfNeeded(); + if (options.json) { + console.log(JSON.stringify({ + error: recommendation.reason, + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, null, 2)); + } else { + console.log(`No best account available: ${recommendation.reason}`); + printProbeNotes(); + } + return 1; } - return 1; - } - const currentIndex = helpers.resolveActiveIndex(storage, "codex"); - if (currentIndex === bestIndex) { - const shouldSyncCurrentBest = - probeRefreshedIndices.has(bestIndex) || probeIdTokenByIndex.has(bestIndex); - let alreadyBestSynced: boolean | undefined; - if (changed) { - bestAccount.lastUsed = now; + const bestIndex = recommendation.recommendedIndex; + const bestAccount = storage.accounts[bestIndex]; + if (!bestAccount) { await persistProbeChangesIfNeeded(); + if (options.json) { + console.log(JSON.stringify({ error: "Best account not found." }, null, 2)); + } else { + console.log("Best account not found."); + } + return 1; } - if (shouldSyncCurrentBest) { - alreadyBestSynced = await setCodexCliActiveSelection({ - accountId: bestAccount.accountId, - email: bestAccount.email, - accessToken: bestAccount.accessToken, - refreshToken: bestAccount.refreshToken, - expiresAt: bestAccount.expiresAt, - ...(probeIdTokenByIndex.has(bestIndex) - ? { idToken: probeIdTokenByIndex.get(bestIndex) } - : {}), - }); - if (!alreadyBestSynced && !options.json) { - console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); + + const currentIndex = helpers.resolveActiveIndex(storage, "codex"); + if (currentIndex === bestIndex) { + const shouldSyncCurrentBest = + probeRefreshedIndices.has(bestIndex) || probeIdTokenByIndex.has(bestIndex); + let alreadyBestSynced: boolean | undefined; + if (changed) { + bestAccount.lastUsed = now; + await persistProbeChangesIfNeeded(); + } + if (shouldSyncCurrentBest) { + alreadyBestSynced = await setCodexCliActiveSelection({ + accountId: bestAccount.accountId, + email: bestAccount.email, + accessToken: bestAccount.accessToken, + refreshToken: bestAccount.refreshToken, + expiresAt: bestAccount.expiresAt, + ...(probeIdTokenByIndex.has(bestIndex) + ? { idToken: probeIdTokenByIndex.get(bestIndex) } + : {}), + }); + if (!alreadyBestSynced && !options.json) { + console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); + } } + if (options.json) { + console.log(JSON.stringify({ + message: `Already on best account: ${formatAccountLabel(bestAccount, bestIndex)}`, + accountIndex: bestIndex + 1, + reason: recommendation.reason, + ...(alreadyBestSynced !== undefined ? { synced: alreadyBestSynced } : {}), + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, null, 2)); + } else { + console.log(`Already on best account ${bestIndex + 1}: ${formatAccountLabel(bestAccount, bestIndex)}`); + console.log(`Reason: ${recommendation.reason}`); + printProbeNotes(); + } + return 0; } + + const parsed = bestIndex + 1; + const { synced, wasDisabled } = await persistAndSyncSelectedAccount({ + storage, + targetIndex: bestIndex, + parsed, + switchReason: "best", + initialSyncIdToken: probeIdTokenByIndex.get(bestIndex), + helpers, + }); + if (options.json) { console.log(JSON.stringify({ - message: `Already on best account: ${formatAccountLabel(bestAccount, bestIndex)}`, - accountIndex: bestIndex + 1, + message: `Switched to best account: ${formatAccountLabel(bestAccount, bestIndex)}`, + accountIndex: parsed, reason: recommendation.reason, - ...(alreadyBestSynced !== undefined ? { synced: alreadyBestSynced } : {}), + synced, + wasDisabled, ...(probeErrors.length > 0 ? { probeErrors } : {}), }, null, 2)); } else { - console.log(`Already on best account ${bestIndex + 1}: ${formatAccountLabel(bestAccount, bestIndex)}`); + console.log(`Switched to best account ${parsed}: ${formatAccountLabel(bestAccount, bestIndex)}${wasDisabled ? " (re-enabled)" : ""}`); console.log(`Reason: ${recommendation.reason}`); printProbeNotes(); + if (!synced) { + console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); + } } return 0; - } - - const parsed = bestIndex + 1; - const { synced, wasDisabled } = await persistAndSyncSelectedAccount({ - storage, - targetIndex: bestIndex, - parsed, - switchReason: "best", - initialSyncIdToken: probeIdTokenByIndex.get(bestIndex), - helpers, - }); + }; - if (options.json) { - console.log(JSON.stringify({ - message: `Switched to best account: ${formatAccountLabel(bestAccount, bestIndex)}`, - accountIndex: parsed, - reason: recommendation.reason, - synced, - wasDisabled, - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, null, 2)); - } else { - console.log(`Switched to best account ${parsed}: ${formatAccountLabel(bestAccount, bestIndex)}${wasDisabled ? " (re-enabled)" : ""}`); - console.log(`Reason: ${recommendation.reason}`); - printProbeNotes(); - if (!synced) { - console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); - } + if (options.live) { + return withSerializedBestLiveRun(getStoragePath(), execute); } - return 0; + + return execute(); } export async function runAuthLogin( diff --git a/test/codex-manager-auth-commands.test.ts b/test/codex-manager-auth-commands.test.ts index 21af0207..05d170de 100644 --- a/test/codex-manager-auth-commands.test.ts +++ b/test/codex-manager-auth-commands.test.ts @@ -539,69 +539,70 @@ describe("codex-manager auth command helpers", () => { ); }); - it("keeps concurrent runBest live refresh writes consistent per snapshot", async () => { + it("serializes concurrent runBest live refresh writes per storage file", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); - extractAccountEmailMock.mockReturnValue("fresh@example.com"); - const baselineStorage = createStorage([ - { - email: "stale@example.com", - refreshToken: "stale-refresh-token", - accessToken: "stale-access-token", - accountId: "acct-stale", - expiresAt: Date.now() - 1, - addedAt: 1, - lastUsed: 1, - enabled: true, - }, - ]); - loadAccountsMock.mockImplementation( - async () => structuredClone(baselineStorage), - ); + const helpers = createHelpers({ + hasUsableAccessToken: vi.fn(() => false), + }); + const makeExpiredStorage = (): AccountStorageV3 => + createStorage([ + { + email: "live@example.com", + refreshToken: "refresh-token-live", + accessToken: "stale-access-token", + accountId: "acct-live", + expiresAt: Date.now() - 1, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + ]); + loadAccountsMock.mockImplementation(async () => makeExpiredStorage()); - let releaseFirstSave: (() => void) | undefined; - const firstSaveReleased = new Promise((resolve) => { - releaseFirstSave = resolve; + let resolveFirstSaveStarted = (): void => undefined; + const firstSaveStarted = new Promise((resolve) => { + resolveFirstSaveStarted = resolve; }); - const persistedSnapshots: AccountStorageV3[] = []; - let saveCallCount = 0; - saveAccountsMock.mockImplementation(async (storage: AccountStorageV3) => { - persistedSnapshots.push(structuredClone(storage)); - saveCallCount += 1; - if (saveCallCount === 1) { - await Promise.resolve(); - await firstSaveReleased; - return; - } - if (saveCallCount === 2) { - releaseFirstSave?.(); + let resolveFirstSave = (): void => undefined; + const firstSaveBlocked = new Promise((resolve) => { + resolveFirstSave = resolve; + }); + let activeSaves = 0; + let maxActiveSaves = 0; + let startedSaves = 0; + saveAccountsMock.mockImplementation(async () => { + startedSaves += 1; + activeSaves += 1; + maxActiveSaves = Math.max(maxActiveSaves, activeSaves); + if (startedSaves === 1) { + resolveFirstSaveStarted(); + await firstSaveBlocked; } + activeSaves -= 1; }); - const helpers = createHelpers({ - hasUsableAccessToken: vi.fn(() => false), - }); + const firstRun = runBest(["--live"], helpers); + await firstSaveStarted; - const [firstResult, secondResult] = await Promise.all([ - runBest(["--live", "--json"], helpers), - runBest(["--live", "--json"], helpers), - ]); + const secondRun = runBest(["--live"], helpers); + await Promise.resolve(); - expect(firstResult).toBe(0); - expect(secondResult).toBe(0); + expect(loadAccountsMock).toHaveBeenCalledTimes(1); + expect(queuedRefreshMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + + resolveFirstSave(); + + await expect(Promise.all([firstRun, secondRun])).resolves.toEqual([0, 0]); + + expect(loadAccountsMock).toHaveBeenCalledTimes(2); + expect(queuedRefreshMock).toHaveBeenCalledTimes(2); expect(saveAccountsMock).toHaveBeenCalledTimes(2); - expect(persistedSnapshots).toHaveLength(2); - for (const snapshot of persistedSnapshots) { - expect(snapshot.activeIndex).toBe(0); - expect(snapshot.accounts[0]).toMatchObject({ - email: "fresh@example.com", - refreshToken: "fresh-refresh-token", - accessToken: "fresh-access-token", - accountId: "acct-refreshed", - accountIdSource: "token", - }); - } expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(2); - expect(logSpy).toHaveBeenCalledTimes(2); + expect(maxActiveSaves).toBe(1); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Already on best account 1"), + ); }); it("restores a backup through the extracted login flow and clamps family indices", async () => { From 31fdf39aaa4be1e597e8f4ff1272a328d5d68a29 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:33:29 +0800 Subject: [PATCH 6/7] test: cover auth command error paths --- test/codex-manager-auth-commands.test.ts | 239 ++++++++++++++++++++++- 1 file changed, 238 insertions(+), 1 deletion(-) diff --git a/test/codex-manager-auth-commands.test.ts b/test/codex-manager-auth-commands.test.ts index 05d170de..03154783 100644 --- a/test/codex-manager-auth-commands.test.ts +++ b/test/codex-manager-auth-commands.test.ts @@ -4,7 +4,12 @@ import type { DashboardDisplaySettings, } from "../lib/dashboard-settings.js"; import type { QuotaCacheData } from "../lib/quota-cache.js"; -import type { AccountStorageV3, NamedBackupSummary } from "../lib/storage.js"; +import { + formatStorageErrorHint, + StorageError, + type AccountStorageV3, + type NamedBackupSummary, +} from "../lib/storage.js"; import type { TokenResult } from "../lib/types.js"; const { @@ -23,12 +28,16 @@ const { loadFlaggedAccountsMock, saveAccountsMock, setStoragePathMock, + getStoragePathMock, getNamedBackupsMock, restoreAccountsFromBackupMock, setCodexCliActiveSelectionMock, confirmMock, applyUiThemeFromDashboardSettingsMock, configureUnifiedSettingsMock, + leaseReleaseMock, + leaseAcquireMock, + refreshLeaseCoordinatorFromEnvironmentMock, } = vi.hoisted(() => ({ extractAccountEmailMock: vi.fn(), extractAccountIdMock: vi.fn(), @@ -45,12 +54,21 @@ const { loadFlaggedAccountsMock: vi.fn(), saveAccountsMock: vi.fn(), setStoragePathMock: vi.fn(), + getStoragePathMock: vi.fn(), getNamedBackupsMock: vi.fn(), restoreAccountsFromBackupMock: vi.fn(), setCodexCliActiveSelectionMock: vi.fn(), confirmMock: vi.fn(), applyUiThemeFromDashboardSettingsMock: vi.fn(), configureUnifiedSettingsMock: vi.fn(), + leaseReleaseMock: vi.fn(async () => undefined), + leaseAcquireMock: vi.fn(async () => ({ + role: "bypass", + release: leaseReleaseMock, + })), + refreshLeaseCoordinatorFromEnvironmentMock: vi.fn(() => ({ + acquire: leaseAcquireMock, + })), })); vi.mock("../lib/auth/browser.js", () => ({ @@ -94,11 +112,18 @@ vi.mock("../lib/storage.js", async () => { loadFlaggedAccounts: loadFlaggedAccountsMock, saveAccounts: saveAccountsMock, setStoragePath: setStoragePathMock, + getStoragePath: getStoragePathMock, getNamedBackups: getNamedBackupsMock, restoreAccountsFromBackup: restoreAccountsFromBackupMock, }; }); +vi.mock("../lib/refresh-lease.js", () => ({ + RefreshLeaseCoordinator: { + fromEnvironment: refreshLeaseCoordinatorFromEnvironmentMock, + }, +})); + vi.mock("../lib/codex-cli/writer.js", () => ({ setCodexCliActiveSelection: setCodexCliActiveSelectionMock, })); @@ -246,7 +271,18 @@ beforeEach(() => { getNamedBackupsMock.mockResolvedValue([]); restoreAccountsFromBackupMock.mockResolvedValue(createStorage()); saveAccountsMock.mockResolvedValue(undefined); + getStoragePathMock.mockReturnValue( + "/mock/.codex/multi-auth/openai-codex-accounts.json", + ); setCodexCliActiveSelectionMock.mockResolvedValue(true); + leaseReleaseMock.mockResolvedValue(undefined); + leaseAcquireMock.mockImplementation(async () => ({ + role: "bypass", + release: leaseReleaseMock, + })); + refreshLeaseCoordinatorFromEnvironmentMock.mockReturnValue({ + acquire: leaseAcquireMock, + }); queuedRefreshMock.mockResolvedValue({ type: "success", access: "fresh-access-token", @@ -420,6 +456,27 @@ describe("codex-manager auth command helpers", () => { ); }); + it("propagates saveAccounts EBUSY errors before syncing the selected account", async () => { + const busyError = Object.assign( + new Error("EBUSY: resource busy or locked"), + { code: "EBUSY" }, + ); + saveAccountsMock.mockRejectedValueOnce(busyError); + const storage = createStorage(); + + await expect( + persistAndSyncSelectedAccount({ + storage, + targetIndex: 0, + parsed: 1, + switchReason: "rotation", + helpers: createHelpers(), + }), + ).rejects.toBe(busyError); + + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + }); + it("validates switch indices before mutating storage", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); loadAccountsMock.mockResolvedValue(createStorage()); @@ -452,6 +509,19 @@ describe("codex-manager auth command helpers", () => { expect(output.accountIndex).toBe(1); }); + it("prints best usage and exits 0 on --help", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + const result = await runBest(["--help"], createHelpers()); + + expect(result).toBe(0); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("codex auth best"), + ); + expect(loadAccountsMock).not.toHaveBeenCalled(); + expect(leaseAcquireMock).not.toHaveBeenCalled(); + }); + it("reports json output when runBest switches to a healthier account", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); setCodexCliActiveSelectionMock.mockResolvedValue(false); @@ -605,6 +675,36 @@ describe("codex-manager auth command helpers", () => { ); }); + it("propagates live best save failures before syncing Codex auth", async () => { + const helpers = createHelpers({ + hasUsableAccessToken: vi.fn(() => false), + }); + const busyError = Object.assign( + new Error("EBUSY: resource busy or locked"), + { code: "EBUSY" }, + ); + loadAccountsMock.mockResolvedValue( + createStorage([ + { + email: "live@example.com", + refreshToken: "refresh-token-live", + accessToken: "stale-access-token", + accountId: "acct-live", + expiresAt: Date.now() - 1, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + ]), + ); + saveAccountsMock.mockRejectedValueOnce(busyError); + + await expect(runBest(["--live"], helpers)).rejects.toBe(busyError); + + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + expect(leaseAcquireMock).toHaveBeenCalledTimes(1); + }); + it("restores a backup through the extracted login flow and clamps family indices", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const restoredStorage = createStorage([ @@ -685,6 +785,143 @@ describe("codex-manager auth command helpers", () => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("latest.json")); }); + it.each([ + { + code: "EBUSY", + message: "File is busy", + hint: "File is locked", + }, + { + code: "EACCES", + message: "Access denied", + hint: "Permission denied", + }, + ])( + "prints the storage hint for $code restore failures and stays in onboarding", + async ({ code, message, hint }) => { + const backupPath = "/mock/backups/locked.json"; + const storageError = new StorageError(message, code, backupPath, hint); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + loadAccountsMock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + getNamedBackupsMock.mockResolvedValue([ + { + path: backupPath, + fileName: "locked.json", + accountCount: 1, + mtimeMs: Date.now(), + }, + ]); + restoreAccountsFromBackupMock.mockRejectedValueOnce(storageError); + const deps = createAuthLoginDeps({ + promptOAuthSignInMode: vi.fn() + .mockResolvedValueOnce("restore-backup" as const) + .mockResolvedValueOnce("cancel" as const), + promptBackupRestoreMode: vi.fn(async () => "latest" as const), + }); + + const result = await runAuthLogin([], deps); + + expect(result).toBe(0); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(restoreAccountsFromBackupMock).toHaveBeenCalledWith( + backupPath, + { persist: false }, + ); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(promptLoginModeMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + formatStorageErrorHint(storageError, backupPath), + ); + }, + ); + + it("reports generic backup restore failures and stays in onboarding when storage is still empty", async () => { + const backupPath = "/mock/backups/rate-limited.json"; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const rateLimitError = new Error("429 Too Many Requests"); + loadAccountsMock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + getNamedBackupsMock.mockResolvedValue([ + { + path: backupPath, + fileName: "rate-limited.json", + accountCount: 1, + mtimeMs: Date.now(), + }, + ]); + restoreAccountsFromBackupMock.mockRejectedValueOnce(rateLimitError); + const deps = createAuthLoginDeps({ + promptOAuthSignInMode: vi.fn() + .mockResolvedValueOnce("restore-backup" as const) + .mockResolvedValueOnce("cancel" as const), + promptBackupRestoreMode: vi.fn(async () => "latest" as const), + }); + + const result = await runAuthLogin([], deps); + + expect(result).toBe(0); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(promptLoginModeMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + "Backup restore failed: 429 Too Many Requests", + ); + }); + + it("returns to the existing-account menu when restore fails after accounts already exist", async () => { + const backupPath = "/mock/backups/existing.json"; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const existingStorage = createStorage([ + { + email: "existing@example.com", + refreshToken: "refresh-token-existing", + accessToken: "access-token-existing", + accountId: "acct-existing", + expiresAt: Date.now() + 60_000, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + ]); + loadAccountsMock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(existingStorage) + .mockResolvedValueOnce(existingStorage) + .mockResolvedValueOnce(existingStorage); + getNamedBackupsMock.mockResolvedValue([ + { + path: backupPath, + fileName: "existing.json", + accountCount: 1, + mtimeMs: Date.now(), + }, + ]); + restoreAccountsFromBackupMock.mockRejectedValueOnce( + new Error("save selected account failed"), + ); + promptLoginModeMock.mockResolvedValue({ mode: "cancel" }); + const deps = createAuthLoginDeps({ + promptOAuthSignInMode: vi.fn(async () => "restore-backup" as const), + promptBackupRestoreMode: vi.fn(async () => "latest" as const), + }); + + const result = await runAuthLogin([], deps); + + expect(result).toBe(0); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(promptLoginModeMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + "Backup restore failed: save selected account failed", + ); + }); + it("prints usage from runAuthLogin without entering the interactive flow", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const deps = createAuthLoginDeps(); From 79b39f45ef131fa882eb54a21a5f90b0331f56ce Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:52:47 +0800 Subject: [PATCH 7/7] test: cover live lease failures --- test/codex-manager-auth-commands.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/codex-manager-auth-commands.test.ts b/test/codex-manager-auth-commands.test.ts index 03154783..fd8ae2f6 100644 --- a/test/codex-manager-auth-commands.test.ts +++ b/test/codex-manager-auth-commands.test.ts @@ -522,6 +522,21 @@ describe("codex-manager auth command helpers", () => { expect(leaseAcquireMock).not.toHaveBeenCalled(); }); + it("propagates live best lease acquisition failures before loading accounts", async () => { + const leaseError = Object.assign( + new Error("EACCES: permission denied"), + { code: "EACCES" }, + ); + loadAccountsMock.mockResolvedValue(createStorage()); + leaseAcquireMock.mockRejectedValueOnce(leaseError); + + await expect(runBest(["--live"], createHelpers())).rejects.toBe(leaseError); + + expect(loadAccountsMock).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + }); + it("reports json output when runBest switches to a healthier account", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); setCodexCliActiveSelectionMock.mockResolvedValue(false);