diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 6a62ff17..c54fda9b 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -35,6 +35,10 @@ import { loadCodexCliState, } from "./codex-cli/state.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; +import { + type BestCliOptions, + runBestCommand, +} from "./codex-manager/commands/best.js"; import { runCheckCommand } from "./codex-manager/commands/check.js"; import { runForecastCommand } from "./codex-manager/commands/forecast.js"; import { runReportCommand } from "./codex-manager/commands/report.js"; @@ -2247,13 +2251,6 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { ); } -interface BestCliOptions { - live: boolean; - json: boolean; - model: string; - modelProvided: boolean; -} - interface FixCliOptions { dryRun: boolean; json: boolean; @@ -4282,279 +4279,26 @@ async function persistAndSyncSelectedAccount({ } 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< - number, - Awaited> - >(); - 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), + return runBestCommand(args, { + setStoragePath, + loadAccounts, + saveAccounts, + parseBestArgs, + printBestUsage, + resolveActiveIndex, + hasUsableAccessToken, + queuedRefresh, + normalizeFailureDetail, + extractAccountId, + extractAccountEmail, + sanitizeEmail, + formatAccountLabel, + fetchCodexQuotaSnapshot, + evaluateForecastAccounts, + recommendForecastAccount, + persistAndSyncSelectedAccount, + setCodexCliActiveSelection, }); - - 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; } export async function autoSyncActiveAccountToCodex(): Promise { diff --git a/lib/codex-manager/commands/best.ts b/lib/codex-manager/commands/best.ts new file mode 100644 index 00000000..5bc459fd --- /dev/null +++ b/lib/codex-manager/commands/best.ts @@ -0,0 +1,350 @@ +import type { ForecastAccountResult } from "../../forecast.js"; +import type { CodexQuotaSnapshot } from "../../quota-probe.js"; +import type { AccountStorageV3 } from "../../storage.js"; +import type { TokenFailure, TokenResult } from "../../types.js"; + +export interface BestCliOptions { + live: boolean; + json: boolean; + model: string; + modelProvided: boolean; +} + +type ParsedArgsResult = + | { ok: true; options: T } + | { ok: false; message: string }; + +export interface BestCommandDeps { + setStoragePath: (path: string | null) => void; + loadAccounts: () => Promise; + saveAccounts: (storage: AccountStorageV3) => Promise; + parseBestArgs: (args: string[]) => ParsedArgsResult; + printBestUsage: () => void; + resolveActiveIndex: (storage: AccountStorageV3, family?: "codex") => number; + hasUsableAccessToken: ( + account: { accessToken?: string; expiresAt?: number }, + now: number, + ) => boolean; + queuedRefresh: (refreshToken: string) => Promise; + normalizeFailureDetail: ( + message: string | undefined, + reason: string | undefined, + ) => string; + extractAccountId: (accessToken: string | undefined) => string | undefined; + extractAccountEmail: ( + accessToken: string | undefined, + idToken: string | undefined, + ) => string | undefined; + sanitizeEmail: (email: string | undefined) => string | undefined; + formatAccountLabel: ( + account: { email?: string; accountLabel?: string; accountId?: string }, + index: number, + ) => string; + fetchCodexQuotaSnapshot: (input: { + accountId: string; + accessToken: string; + model: string; + }) => Promise; + evaluateForecastAccounts: ( + inputs: Array<{ + index: number; + account: AccountStorageV3["accounts"][number]; + isCurrent: boolean; + now: number; + refreshFailure?: TokenFailure; + liveQuota?: CodexQuotaSnapshot; + }>, + ) => ForecastAccountResult[]; + recommendForecastAccount: (results: ForecastAccountResult[]) => { + recommendedIndex: number | null; + reason: string; + }; + persistAndSyncSelectedAccount: (params: { + storage: AccountStorageV3; + targetIndex: number; + parsed: number; + switchReason: "best"; + initialSyncIdToken?: string; + }) => Promise<{ synced: boolean; wasDisabled: boolean }>; + setCodexCliActiveSelection: (params: { + accountId?: string; + email?: string; + accessToken?: string; + refreshToken: string; + expiresAt?: number; + idToken?: string; + }) => Promise; + logInfo?: (message: string) => void; + logWarn?: (message: string) => void; + logError?: (message: string) => void; + getNow?: () => number; +} + +export async function runBestCommand( + args: string[], + deps: BestCommandDeps, +): Promise { + const logInfo = deps.logInfo ?? console.log; + const logWarn = deps.logWarn ?? console.warn; + const logError = deps.logError ?? console.error; + if (args.includes("--help") || args.includes("-h")) { + deps.printBestUsage(); + return 0; + } + + const parsedArgs = deps.parseBestArgs(args); + if (!parsedArgs.ok) { + logError(parsedArgs.message); + deps.printBestUsage(); + return 1; + } + const options = parsedArgs.options; + if (options.modelProvided && !options.live) { + logError("--model requires --live for codex auth best"); + deps.printBestUsage(); + return 1; + } + + deps.setStoragePath(null); + const storage = await deps.loadAccounts(); + if (!storage || storage.accounts.length === 0) { + if (options.json) { + logInfo(JSON.stringify({ error: "No accounts configured." }, null, 2)); + } else { + logInfo("No accounts configured."); + } + return 1; + } + + const now = deps.getNow?.() ?? 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 persistProbeChangesIfNeeded = async ( + beforeSave?: () => void, + ): Promise => { + if (!changed) return; + beforeSave?.(); + await deps.saveAccounts(storage); + changed = false; + }; + + for (let i = 0; i < storage.accounts.length; i += 1) { + const account = storage.accounts[i]; + if (!account || !options.live || account.enabled === false) continue; + + let probeAccessToken = account.accessToken; + let probeAccountId = + account.accountId ?? deps.extractAccountId(account.accessToken); + if (!deps.hasUsableAccessToken(account, now)) { + const refreshResult = await deps.queuedRefresh(account.refreshToken); + if (refreshResult.type !== "success") { + refreshFailures.set(i, { + ...refreshResult, + message: deps.normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), + }); + continue; + } + + const refreshedEmail = deps.sanitizeEmail( + deps.extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); + const refreshedAccountId = deps.extractAccountId(refreshResult.access); + const previousRefreshToken = account.refreshToken; + const previousAccessToken = account.accessToken; + const previousExpiresAt = account.expiresAt; + const previousEmail = account.email; + const previousAccountId = account.accountId; + const previousAccountIdSource = account.accountIdSource; + account.refreshToken = refreshResult.refresh; + account.accessToken = refreshResult.access; + account.expiresAt = refreshResult.expires; + if (refreshedEmail) account.email = refreshedEmail; + if (refreshedAccountId) { + account.accountId = refreshedAccountId; + account.accountIdSource = "token"; + } + changed = + changed || + previousRefreshToken !== account.refreshToken || + previousAccessToken !== account.accessToken || + previousExpiresAt !== account.expiresAt || + previousEmail !== account.email || + previousAccountId !== account.accountId || + previousAccountIdSource !== account.accountIdSource; + if (refreshResult.idToken) + probeIdTokenByIndex.set(i, refreshResult.idToken); + probeRefreshedIndices.add(i); + + probeAccessToken = account.accessToken; + probeAccountId = account.accountId ?? refreshedAccountId; + } + + if (!probeAccessToken || !probeAccountId) { + probeErrors.push( + `${deps.formatAccountLabel(account, i)}: missing accountId for live probe`, + ); + continue; + } + + try { + const liveQuota = await deps.fetchCodexQuotaSnapshot({ + accountId: probeAccountId, + accessToken: probeAccessToken, + model: options.model, + }); + liveQuotaByIndex.set(i, liveQuota); + } catch (error) { + const message = deps.normalizeFailureDetail( + error instanceof Error ? error.message : String(error), + undefined, + ); + probeErrors.push(`${deps.formatAccountLabel(account, i)}: ${message}`); + } + } + + const forecastInputs = storage.accounts.map((account, index) => ({ + index, + account, + isCurrent: index === deps.resolveActiveIndex(storage, "codex"), + now, + refreshFailure: refreshFailures.get(index), + liveQuota: liveQuotaByIndex.get(index), + })); + const forecastResults = deps.evaluateForecastAccounts(forecastInputs); + const recommendation = deps.recommendForecastAccount(forecastResults); + + const printProbeNotes = (): void => { + if (probeErrors.length === 0) return; + logInfo(`Live check notes (${probeErrors.length}):`); + for (const error of probeErrors) logInfo(` - ${error}`); + }; + + if (recommendation.recommendedIndex === null) { + await persistProbeChangesIfNeeded(); + if (options.json) { + logInfo( + JSON.stringify( + { + error: recommendation.reason, + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, + null, + 2, + ), + ); + } else { + logInfo(`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) { + logInfo(JSON.stringify({ error: "Best account not found." }, null, 2)); + } else { + logInfo("Best account not found."); + } + return 1; + } + + const currentIndex = deps.resolveActiveIndex(storage, "codex"); + if (currentIndex === bestIndex) { + const shouldSyncCurrentBest = + probeRefreshedIndices.has(bestIndex) || + probeIdTokenByIndex.has(bestIndex); + let alreadyBestSynced: boolean | undefined; + await persistProbeChangesIfNeeded(() => { + bestAccount.lastUsed = now; + }); + if (shouldSyncCurrentBest) { + alreadyBestSynced = await deps.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) { + logWarn( + "Codex auth sync did not complete. Multi-auth routing will still use this account.", + ); + } + } + if (options.json) { + logInfo( + JSON.stringify( + { + message: `Already on best account: ${deps.formatAccountLabel(bestAccount, bestIndex)}`, + accountIndex: bestIndex + 1, + reason: recommendation.reason, + ...(alreadyBestSynced !== undefined + ? { synced: alreadyBestSynced } + : {}), + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, + null, + 2, + ), + ); + } else { + logInfo( + `Already on best account ${bestIndex + 1}: ${deps.formatAccountLabel(bestAccount, bestIndex)}`, + ); + logInfo(`Reason: ${recommendation.reason}`); + printProbeNotes(); + } + return 0; + } + + const parsed = bestIndex + 1; + const { synced, wasDisabled } = await deps.persistAndSyncSelectedAccount({ + storage, + targetIndex: bestIndex, + parsed, + switchReason: "best", + initialSyncIdToken: probeIdTokenByIndex.get(bestIndex), + }); + + if (options.json) { + logInfo( + JSON.stringify( + { + message: `Switched to best account: ${deps.formatAccountLabel(bestAccount, bestIndex)}`, + accountIndex: parsed, + reason: recommendation.reason, + synced, + wasDisabled, + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, + null, + 2, + ), + ); + } else { + logInfo( + `Switched to best account ${parsed}: ${deps.formatAccountLabel(bestAccount, bestIndex)}${wasDisabled ? " (re-enabled)" : ""}`, + ); + logInfo(`Reason: ${recommendation.reason}`); + printProbeNotes(); + if (!synced) { + logWarn( + "Codex auth sync did not complete. Multi-auth routing will still use this account.", + ); + } + } + return 0; +} diff --git a/test/codex-manager-best-command.test.ts b/test/codex-manager-best-command.test.ts new file mode 100644 index 00000000..731d04c9 --- /dev/null +++ b/test/codex-manager-best-command.test.ts @@ -0,0 +1,371 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type BestCliOptions, + type BestCommandDeps, + runBestCommand, +} from "../lib/codex-manager/commands/best.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +function createAccount( + overrides: Partial = {}, +): AccountStorageV3["accounts"][number] { + return { + email: "best@example.com", + refreshToken: "refresh-best", + accessToken: "access-best", + expiresAt: Date.now() + 60_000, + addedAt: 1, + lastUsed: 1, + enabled: true, + ...overrides, + }; +} + +function createStorage( + accounts: AccountStorageV3["accounts"] = [createAccount()], +): AccountStorageV3 { + return { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts, + }; +} + +function createDeps(overrides: Partial = {}): BestCommandDeps { + return { + setStoragePath: vi.fn(), + loadAccounts: vi.fn(async () => createStorage()), + saveAccounts: vi.fn(async () => undefined), + parseBestArgs: vi.fn((args: string[]) => { + if (args.includes("--bad")) + return { ok: false as const, message: "Unknown option: --bad" }; + return { + ok: true as const, + options: { + live: false, + json: true, + model: "gpt-5-codex", + modelProvided: false, + } satisfies BestCliOptions, + }; + }), + printBestUsage: vi.fn(), + resolveActiveIndex: vi.fn(() => 0), + hasUsableAccessToken: vi.fn(() => true), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "access-best", + refresh: "refresh-best", + expires: Date.now() + 60_000, + })), + normalizeFailureDetail: vi.fn((message) => message ?? "unknown"), + extractAccountId: vi.fn(() => "account-id"), + extractAccountEmail: vi.fn(() => "best@example.com"), + sanitizeEmail: vi.fn((email) => email), + formatAccountLabel: vi.fn( + (_account, index) => `${index + 1}. best@example.com`, + ), + fetchCodexQuotaSnapshot: vi.fn(async () => ({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + })), + evaluateForecastAccounts: vi.fn(() => [ + { + index: 0, + label: "1. best@example.com", + isCurrent: true, + availability: "ready", + riskScore: 0, + riskLevel: "low", + waitMs: 0, + reasons: ["healthy"], + }, + ]), + recommendForecastAccount: vi.fn(() => ({ + recommendedIndex: 0, + reason: "lowest risk", + })), + persistAndSyncSelectedAccount: vi.fn(async () => ({ + synced: true, + wasDisabled: false, + })), + setCodexCliActiveSelection: vi.fn(async () => true), + logInfo: vi.fn(), + logWarn: vi.fn(), + logError: vi.fn(), + getNow: vi.fn(() => 1_000), + ...overrides, + }; +} + +describe("runBestCommand", () => { + it("prints usage for help", async () => { + const deps = createDeps(); + const result = await runBestCommand(["--help"], deps); + expect(result).toBe(0); + expect(deps.printBestUsage).toHaveBeenCalled(); + }); + + it("rejects invalid options", async () => { + const deps = createDeps(); + const result = await runBestCommand(["--bad"], deps); + expect(result).toBe(1); + expect(deps.logError).toHaveBeenCalledWith("Unknown option: --bad"); + }); + + it("rejects --model without --live", async () => { + const deps = createDeps({ + parseBestArgs: vi.fn(() => ({ + ok: true, + options: { + live: false, + json: true, + model: "gpt-5-codex", + modelProvided: true, + } satisfies BestCliOptions, + })), + }); + const result = await runBestCommand(["--model", "gpt-5-codex"], deps); + expect(result).toBe(1); + expect(deps.logError).toHaveBeenCalledWith( + "--model requires --live for codex auth best", + ); + }); + + it("emits json output when no accounts are configured", async () => { + const deps = createDeps({ + loadAccounts: vi.fn(async () => ({ + ...createStorage([]), + accounts: [], + })), + }); + const result = await runBestCommand([], deps); + expect(result).toBe(1); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining('"error": "No accounts configured."'), + ); + }); + + it("prints json output when already on the best account", async () => { + const deps = createDeps(); + const result = await runBestCommand([], deps); + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining('"accountIndex": 1'), + ); + }); + + it("persists refreshed probe tokens before an early-exit recommendation failure", async () => { + const storage = createStorage([ + createAccount({ + accessToken: "expired-access", + refreshToken: "expired-refresh", + expiresAt: 0, + }), + ]); + const deps = createDeps({ + loadAccounts: vi.fn(async () => storage), + parseBestArgs: vi.fn(() => ({ + ok: true, + options: { + live: true, + json: true, + model: "gpt-5-codex", + modelProvided: false, + } satisfies BestCliOptions, + })), + hasUsableAccessToken: vi.fn(() => false), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "fresh-access", + refresh: "fresh-refresh", + expires: 9_999, + })), + extractAccountId: vi.fn(() => "account-id"), + extractAccountEmail: vi.fn(() => "best@example.com"), + recommendForecastAccount: vi.fn(() => ({ + recommendedIndex: null, + reason: "all accounts exhausted", + })), + }); + + const result = await runBestCommand(["--live"], deps); + + expect(result).toBe(1); + expect(deps.saveAccounts).toHaveBeenCalledTimes(1); + expect(deps.saveAccounts).toHaveBeenCalledWith( + expect.objectContaining({ + accounts: [ + expect.objectContaining({ + accessToken: "fresh-access", + refreshToken: "fresh-refresh", + expiresAt: 9_999, + }), + ], + }), + ); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining('"error": "all accounts exhausted"'), + ); + }); + + it("persists changed accounts even when the current best account did not refresh", async () => { + const storage = createStorage([ + createAccount({ + email: "best@example.com", + accessToken: "best-access", + refreshToken: "best-refresh", + expiresAt: 10_000, + lastUsed: 10, + }), + createAccount({ + email: "backup@example.com", + accessToken: "stale-access", + refreshToken: "stale-refresh", + expiresAt: 0, + lastUsed: 20, + }), + ]); + const deps = createDeps({ + loadAccounts: vi.fn(async () => storage), + parseBestArgs: vi.fn(() => ({ + ok: true, + options: { + live: true, + json: true, + model: "gpt-5-codex", + modelProvided: false, + } satisfies BestCliOptions, + })), + hasUsableAccessToken: vi.fn((account) => account.accessToken === "best-access"), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "backup-access", + refresh: "backup-refresh", + expires: 20_000, + })), + extractAccountId: vi.fn((accessToken?: string) => + accessToken === "backup-access" ? "backup-id" : "best-id", + ), + extractAccountEmail: vi.fn((accessToken?: string) => + accessToken === "backup-access" + ? "backup@example.com" + : "best@example.com", + ), + formatAccountLabel: vi.fn((account, index) => `${index + 1}. ${account.email}`), + evaluateForecastAccounts: vi.fn(() => [ + { + index: 0, + label: "1. best@example.com", + isCurrent: true, + availability: "ready", + riskScore: 0, + riskLevel: "low", + waitMs: 0, + reasons: ["healthy"], + }, + { + index: 1, + label: "2. backup@example.com", + isCurrent: false, + availability: "ready", + riskScore: 1, + riskLevel: "low", + waitMs: 0, + reasons: ["healthy"], + }, + ]), + recommendForecastAccount: vi.fn(() => ({ + recommendedIndex: 0, + reason: "best account already active", + })), + }); + + const result = await runBestCommand(["--live"], deps); + + expect(result).toBe(0); + expect(deps.saveAccounts).toHaveBeenCalledTimes(1); + expect(deps.setCodexCliActiveSelection).not.toHaveBeenCalled(); + expect(deps.saveAccounts).toHaveBeenCalledWith( + expect.objectContaining({ + accounts: [ + expect.objectContaining({ lastUsed: 1_000 }), + expect.objectContaining({ + accessToken: "backup-access", + refreshToken: "backup-refresh", + expiresAt: 20_000, + }), + ], + }), + ); + }); + + it("avoids saving when a live refresh returns identical token state", async () => { + const storage = createStorage([ + createAccount({ + accountId: "account-id", + accountIdSource: "token", + expiresAt: 0, + }), + ]); + const deps = createDeps({ + loadAccounts: vi.fn(async () => storage), + parseBestArgs: vi.fn(() => ({ + ok: true, + options: { + live: true, + json: true, + model: "gpt-5-codex", + modelProvided: false, + } satisfies BestCliOptions, + })), + hasUsableAccessToken: vi.fn(() => false), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "access-best", + refresh: "refresh-best", + expires: storage.accounts[0]!.expiresAt!, + idToken: "id-token", + })), + extractAccountId: vi.fn(() => "account-id"), + extractAccountEmail: vi.fn(() => "best@example.com"), + }); + + const result = await runBestCommand(["--live"], deps); + + expect(result).toBe(0); + expect(deps.saveAccounts).not.toHaveBeenCalled(); + expect(deps.setCodexCliActiveSelection).toHaveBeenCalledTimes(1); + }); + + it("switches to the recommended account when a better account is found", async () => { + const storage = createStorage([ + createAccount({ email: "best@example.com" }), + createAccount({ email: "current@example.com" }), + ]); + const deps = createDeps({ + loadAccounts: vi.fn(async () => storage), + resolveActiveIndex: vi.fn(() => 1), + recommendForecastAccount: vi.fn(() => ({ + recommendedIndex: 0, + reason: "lower risk", + })), + formatAccountLabel: vi.fn((account, index) => `${index + 1}. ${account.email}`), + }); + + const result = await runBestCommand([], deps); + + expect(result).toBe(0); + expect(deps.persistAndSyncSelectedAccount).toHaveBeenCalledWith( + expect.objectContaining({ + storage, + targetIndex: 0, + parsed: 1, + switchReason: "best", + }), + ); + }); +});