diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 5b2972d0..206bc7e1 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1,6 +1,5 @@ import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; -import { promises as fs, existsSync } from "node:fs"; import { createAuthorizationFlow, exchangeAuthorizationCode, @@ -22,18 +21,12 @@ import { selectBestAccountCandidate, shouldUpdateAccountIdFromToken, } from "./accounts.js"; -import { ACCOUNT_LIMITS } from "./constants.js"; import { loadDashboardDisplaySettings, DEFAULT_DASHBOARD_DISPLAY_SETTINGS, type DashboardDisplaySettings, type DashboardAccountSortMode, } from "./dashboard-settings.js"; -import { - evaluateForecastAccounts, - isHardRefreshFailure, - recommendForecastAccount, -} from "./forecast.js"; import { createLogger } from "./logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; import { @@ -52,24 +45,15 @@ import { clearAccounts, findMatchingAccountIndex, getStoragePath, - loadFlaggedAccounts, loadAccounts, type NamedBackupSummary, - saveFlaggedAccounts, saveAccounts, setStoragePath, - withAccountAndFlaggedStorageTransaction, withAccountStorageTransaction, type AccountMetadataV3, type AccountStorageV3, - type FlaggedAccountMetadataV1, } from "./storage.js"; -import type { AccountIdSource, TokenFailure, TokenResult } from "./types.js"; -import { - getCodexCliAuthPath, - getCodexCliConfigPath, - loadCodexCliState, -} from "./codex-cli/state.js"; +import type { AccountIdSource, TokenResult } from "./types.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { ANSI } from "./ui/ansi.js"; import { UI_COPY } from "./ui/copy.js"; @@ -86,6 +70,11 @@ import { runForecast as runForecastCommand, runReport as runReportCommand, } from "./codex-manager/forecast-report-commands.js"; +import { + runDoctor as runDoctorCommand, + runFix as runFixCommand, + runVerifyFlagged as runVerifyFlaggedCommand, +} from "./codex-manager/repair-commands.js"; import { applyUiThemeFromDashboardSettings, resolveMenuLayoutMode } from "./codex-manager/settings-hub.js"; type TokenSuccess = Extract; @@ -2050,1725 +2039,352 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { ])); } -interface FixCliOptions { - dryRun: boolean; - json: boolean; - live: boolean; - model: string; -} - -interface VerifyFlaggedCliOptions { - dryRun: boolean; - json: boolean; - restore: boolean; -} - -type ParsedArgsResult = { ok: true; options: T } | { ok: false; message: string }; - -function printFixUsage(): void { - console.log( - [ - "Usage:", - " codex auth fix [--dry-run] [--json] [--live] [--model ]", - "", - "Options:", - " --dry-run, -n Preview changes without writing storage", - " --json, -j Print machine-readable JSON output", - " --live, -l Run live session probe before deciding health", - " --model, -m Probe model for live mode (default: gpt-5-codex)", - "", - "Behavior:", - " - Refreshes tokens for enabled accounts", - " - Disables hard-failed accounts (never deletes)", - " - Recommends a better current account when needed", - ].join("\n"), - ); +async function runVerifyFlagged(args: string[]): Promise { + return runVerifyFlaggedCommand(args, repairCommandDeps); } -function printVerifyFlaggedUsage(): void { - console.log( - [ - "Usage:", - " codex auth verify-flagged [--dry-run] [--json] [--no-restore]", - "", - "Options:", - " --dry-run, -n Preview changes without writing storage", - " --json, -j Print machine-readable JSON output", - " --no-restore Check flagged accounts without restoring healthy ones", - "", - "Behavior:", - " - Refresh-checks accounts from flagged storage", - " - Restores healthy accounts back to active storage by default", - ].join("\n"), - ); +async function runFix(args: string[]): Promise { + return runFixCommand(args, repairCommandDeps); } -function parseFixArgs(args: string[]): ParsedArgsResult { - const options: FixCliOptions = { - dryRun: false, - json: false, - live: false, - model: "gpt-5-codex", - }; - - for (let i = 0; i < args.length; i += 1) { - const argValue = args[i]; - if (typeof argValue !== "string") continue; - if (argValue === "--dry-run" || argValue === "-n") { - options.dryRun = true; - continue; - } - if (argValue === "--json" || argValue === "-j") { - options.json = true; - continue; - } - if (argValue === "--live" || argValue === "-l") { - options.live = true; - continue; - } - if (argValue === "--model" || argValue === "-m") { - const value = args[i + 1]; - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - i += 1; - continue; - } - if (argValue.startsWith("--model=")) { - const value = argValue.slice("--model=".length).trim(); - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - continue; - } - return { ok: false, message: `Unknown option: ${argValue}` }; - } - - return { ok: true, options }; +async function runDoctor(args: string[]): Promise { + return runDoctorCommand(args, repairCommandDeps); } -function parseVerifyFlaggedArgs(args: string[]): ParsedArgsResult { - const options: VerifyFlaggedCliOptions = { - dryRun: false, - json: false, - restore: true, - }; - - for (const arg of args) { - if (arg === "--dry-run" || arg === "-n") { - options.dryRun = true; - continue; - } - if (arg === "--json" || arg === "-j") { - options.json = true; - continue; - } - if (arg === "--no-restore") { - options.restore = false; - continue; - } - return { ok: false, message: `Unknown option: ${arg}` }; - } - - return { ok: true, options }; -} - -interface DoctorCliOptions { - json: boolean; - fix: boolean; - dryRun: boolean; -} - -function printDoctorUsage(): void { - console.log( - [ - "Usage:", - " codex auth doctor [--json] [--fix] [--dry-run]", - "", - "Options:", - " --json, -j Print machine-readable JSON diagnostics", - " --fix Apply safe auto-fixes to storage", - " --dry-run, -n Preview --fix changes without writing storage", - "", - "Behavior:", - " - Validates account storage readability", - " - Checks active index consistency and account duplication", - " - Flags placeholder/demo accounts and disabled-all scenarios", - ].join("\n"), - ); +async function clearAccountsAndReset(): Promise { + await clearAccounts(); } -function parseDoctorArgs(args: string[]): ParsedArgsResult { - const options: DoctorCliOptions = { json: false, fix: false, dryRun: false }; - for (const arg of args) { - if (arg === "--json" || arg === "-j") { - options.json = true; - continue; - } - if (arg === "--fix") { - options.fix = true; - continue; - } - if (arg === "--dry-run" || arg === "-n") { - options.dryRun = true; - continue; - } - return { ok: false, message: `Unknown option: ${arg}` }; - } - if (options.dryRun && !options.fix) { - return { ok: false, message: "--dry-run requires --fix" }; +function adjustManageActionSelectionIndex( + currentIndex: number | undefined, + removedIndex: number, + remainingCount: number, +): number { + if (remainingCount <= 0) { + return 0; } - return { ok: true, options }; -} - - -type FixOutcome = - | "healthy" - | "disabled-hard-failure" - | "warning-soft-failure" - | "already-disabled"; - -interface FixAccountReport { - index: number; - label: string; - outcome: FixOutcome; - message: string; -} - -function summarizeFixReports( - reports: FixAccountReport[], -): { - healthy: number; - disabled: number; - warnings: number; - skipped: number; -} { - let healthy = 0; - let disabled = 0; - let warnings = 0; - let skipped = 0; - for (const report of reports) { - if (report.outcome === "healthy") healthy += 1; - else if (report.outcome === "disabled-hard-failure") disabled += 1; - else if (report.outcome === "warning-soft-failure") warnings += 1; - else skipped += 1; + if (typeof currentIndex !== "number" || currentIndex < 0) { + return 0; } - return { healthy, disabled, warnings, skipped }; -} - -interface VerifyFlaggedReport { - index: number; - label: string; - outcome: "restored" | "healthy-flagged" | "still-flagged" | "restore-skipped"; - message: string; -} - -function createEmptyAccountStorage(): AccountStorageV3 { - const activeIndexByFamily: Partial> = {}; - for (const family of MODEL_FAMILIES) { - activeIndexByFamily[family] = 0; + if (currentIndex < removedIndex) { + return Math.min(currentIndex, remainingCount - 1); } - return { - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily, - }; -} - -function findExistingAccountIndexForFlagged( - storage: AccountStorageV3, - flagged: FlaggedAccountMetadataV1, - nextRefreshToken: string, - nextAccountId: string | undefined, - nextEmail: string | undefined, -): number { - const flaggedEmail = sanitizeEmail(flagged.email); - const candidateAccountId = nextAccountId ?? flagged.accountId; - const candidateEmail = sanitizeEmail(nextEmail) ?? flaggedEmail; - const nextMatchIndex = findMatchingAccountIndex(storage.accounts, { - accountId: candidateAccountId, - email: candidateEmail, - refreshToken: nextRefreshToken, - }, { - allowUniqueAccountIdFallbackWithoutEmail: true, - }); - if (nextMatchIndex !== undefined) { - return nextMatchIndex; + if (currentIndex > removedIndex) { + return currentIndex - 1; } - - const flaggedMatchIndex = findMatchingAccountIndex(storage.accounts, { - accountId: candidateAccountId, - email: candidateEmail, - refreshToken: flagged.refreshToken, - }, { - allowUniqueAccountIdFallbackWithoutEmail: true, - }); - return flaggedMatchIndex ?? -1; + return Math.min(removedIndex, remainingCount - 1); } -function upsertRecoveredFlaggedAccount( +function resetManageActionSelection( storage: AccountStorageV3, - flagged: FlaggedAccountMetadataV1, - refreshResult: TokenSuccess, - now: number, -): { restored: boolean; changed: boolean; message: string } { - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)) ?? flagged.email; - const tokenAccountId = extractAccountId(refreshResult.access); - const { accountId: nextAccountId, accountIdSource: nextAccountIdSource } = - resolveStoredAccountIdentity(flagged.accountId, flagged.accountIdSource, tokenAccountId); - const existingIndex = findExistingAccountIndexForFlagged( - storage, - flagged, - refreshResult.refresh, - nextAccountId, - nextEmail, - ); - - if (existingIndex >= 0) { - const existing = storage.accounts[existingIndex]; - if (!existing) { - return { restored: false, changed: false, message: "existing account entry is missing" }; - } - let changed = false; - if (existing.refreshToken !== refreshResult.refresh) { - existing.refreshToken = refreshResult.refresh; - changed = true; - } - if (existing.accessToken !== refreshResult.access) { - existing.accessToken = refreshResult.access; - changed = true; - } - if (existing.expiresAt !== refreshResult.expires) { - existing.expiresAt = refreshResult.expires; - changed = true; - } - if (nextEmail && nextEmail !== existing.email) { - existing.email = nextEmail; - changed = true; - } - if ( - nextAccountId !== undefined && - ( - (nextAccountId !== existing.accountId) - || (nextAccountIdSource !== existing.accountIdSource) - ) - ) { - existing.accountId = nextAccountId; - existing.accountIdSource = nextAccountIdSource; - changed = true; - } - if (existing.enabled === false) { - existing.enabled = true; - changed = true; - } - if (existing.accountLabel !== flagged.accountLabel && flagged.accountLabel) { - existing.accountLabel = flagged.accountLabel; - changed = true; + removedIndex: number, +): void { + const remainingCount = storage.accounts.length; + if (remainingCount <= 0) { + storage.activeIndex = 0; + storage.activeIndexByFamily = {}; + for (const family of MODEL_FAMILIES) { + storage.activeIndexByFamily[family] = 0; } - existing.lastUsed = now; - return { - restored: true, - changed, - message: `restored into existing account ${existingIndex + 1}`, - }; + return; } - if (storage.accounts.length >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { - return { - restored: false, - changed: false, - message: `cannot restore (max ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts reached)`, - }; + const previousActiveIndex = storage.activeIndex; + const previousByFamily = { ...storage.activeIndexByFamily }; + storage.activeIndex = adjustManageActionSelectionIndex( + previousActiveIndex, + removedIndex, + remainingCount, + ); + storage.activeIndexByFamily = {}; + for (const family of MODEL_FAMILIES) { + storage.activeIndexByFamily[family] = adjustManageActionSelectionIndex( + previousByFamily[family] ?? previousActiveIndex, + removedIndex, + remainingCount, + ); } +} - storage.accounts.push({ - refreshToken: refreshResult.refresh, - accessToken: refreshResult.access, - expiresAt: refreshResult.expires, - accountId: nextAccountId, - accountIdSource: nextAccountIdSource, - accountLabel: flagged.accountLabel, - email: nextEmail, - addedAt: flagged.addedAt ?? now, - lastUsed: now, - enabled: true, - }); - return { - restored: true, - changed: true, - message: `restored as account ${storage.accounts.length}`, +function replaceManageActionStorage( + target: AccountStorageV3, + source: AccountStorageV3, +): void { + target.version = source.version; + target.accounts = structuredClone(source.accounts); + target.activeIndex = source.activeIndex; + target.activeIndexByFamily = { + ...source.activeIndexByFamily, }; } -async function runVerifyFlagged(args: string[]): Promise { - if (args.includes("--help") || args.includes("-h")) { - printVerifyFlaggedUsage(); - return 0; +function resolveManageActionAccountIndex( + storage: AccountStorageV3, + fallbackIndex: number, + account: AccountMetadataV3 | undefined, +): number | null { + if (account) { + const matchedIndex = findMatchingAccountIndex( + storage.accounts, + { + accountId: account.accountId, + email: account.email, + refreshToken: account.refreshToken, + }, + { + allowUniqueAccountIdFallbackWithoutEmail: true, + }, + ); + if (typeof matchedIndex === "number" && matchedIndex >= 0) { + return matchedIndex; + } + return null; } + return fallbackIndex >= 0 && fallbackIndex < storage.accounts.length + ? fallbackIndex + : null; +} - const parsedArgs = parseVerifyFlaggedArgs(args); - if (!parsedArgs.ok) { - console.error(parsedArgs.message); - printVerifyFlaggedUsage(); - return 1; +function matchesManageActionAccount( + account: AccountMetadataV3 | undefined, + candidate: AccountMetadataV3 | undefined, +): boolean { + if (!account || !candidate) { + return false; } - const options = parsedArgs.options; - - setStoragePath(null); - const flaggedStorage = await loadFlaggedAccounts(); - if (flaggedStorage.accounts.length === 0) { - if (options.json) { - console.log( - JSON.stringify( - { - command: "verify-flagged", - total: 0, - restored: 0, - healthyFlagged: 0, - stillFlagged: 0, - changed: false, - dryRun: options.dryRun, - restore: options.restore, - reports: [] as VerifyFlaggedReport[], - }, - null, - 2, - ), - ); - return 0; - } - console.log("No flagged accounts to check."); - return 0; + if (account.accountId || candidate.accountId) { + return account.accountId === candidate.accountId; } + return ( + account.refreshToken === candidate.refreshToken + && sanitizeEmail(account.email) === sanitizeEmail(candidate.email) + ); +} - let storageChanged = false; - let flaggedChanged = false; - const reports: VerifyFlaggedReport[] = []; - const nextFlaggedAccounts: FlaggedAccountMetadataV1[] = []; - const now = Date.now(); - const refreshChecks: Array<{ - index: number; - flagged: FlaggedAccountMetadataV1; - label: string; - result: Awaited>; - }> = []; - - for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { - const flagged = flaggedStorage.accounts[i]; - if (!flagged) continue; - const label = formatAccountLabel(flagged, i); - refreshChecks.push({ - index: i, - flagged, - label, - result: await queuedRefresh(flagged.refreshToken), - }); +async function handleManageAction( + storage: AccountStorageV3, + menuResult: Awaited>, +): Promise { + if (typeof menuResult.switchAccountIndex === "number") { + const index = menuResult.switchAccountIndex; + await runSwitch([String(index + 1)]); + return; } - const applyRefreshChecks = ( - storage: AccountStorageV3, - ): void => { - for (const check of refreshChecks) { - const { index: i, flagged, label, result } = check; - if (result.type === "success") { - if (!options.restore) { - const tokenAccountId = extractAccountId(result.access); - const nextIdentity = resolveStoredAccountIdentity( - flagged.accountId, - flagged.accountIdSource, - tokenAccountId, - ); - const nextFlagged: FlaggedAccountMetadataV1 = { - ...flagged, - refreshToken: result.refresh, - accessToken: result.access, - expiresAt: result.expires, - accountId: nextIdentity.accountId, - accountIdSource: nextIdentity.accountIdSource, - email: sanitizeEmail(extractAccountEmail(result.access, result.idToken)) ?? flagged.email, - lastUsed: now, - lastError: undefined, - }; - nextFlaggedAccounts.push(nextFlagged); - if (JSON.stringify(nextFlagged) !== JSON.stringify(flagged)) { - flaggedChanged = true; - } - reports.push({ - index: i, - label, - outcome: "healthy-flagged", - message: "session is healthy (left in flagged list due to --no-restore)", - }); - continue; + if (typeof menuResult.deleteAccountIndex === "number") { + const idx = menuResult.deleteAccountIndex; + const selectedAccount = storage.accounts[idx]; + let deleted = false; + if (selectedAccount) { + await withAccountStorageTransaction(async (loadedStorage, persist) => { + const nextStorage = loadedStorage + ? structuredClone(loadedStorage) + : structuredClone(storage); + const nextIndex = resolveManageActionAccountIndex( + nextStorage, + idx, + selectedAccount, + ); + if (nextIndex === null) { + return; } - - const upsertResult = upsertRecoveredFlaggedAccount(storage, flagged, result, now); - if (upsertResult.restored) { - storageChanged = storageChanged || upsertResult.changed; - flaggedChanged = true; - reports.push({ - index: i, - label, - outcome: "restored", - message: upsertResult.message, - }); - continue; + const nextAccount = nextStorage.accounts[nextIndex]; + if (!matchesManageActionAccount(selectedAccount, nextAccount)) { + return; } + nextStorage.accounts.splice(nextIndex, 1); + resetManageActionSelection(nextStorage, nextIndex); + await persist(nextStorage); + replaceManageActionStorage(storage, nextStorage); + deleted = true; + }); + } + if (deleted) { + console.log(`Deleted account ${idx + 1}.`); + } + return; + } - const tokenAccountId = extractAccountId(result.access); - const nextIdentity = resolveStoredAccountIdentity( - flagged.accountId, - flagged.accountIdSource, - tokenAccountId, + if (typeof menuResult.toggleAccountIndex === "number") { + const idx = menuResult.toggleAccountIndex; + const selectedAccount = storage.accounts[idx]; + let nextEnabledState: boolean | null = null; + if (selectedAccount) { + await withAccountStorageTransaction(async (loadedStorage, persist) => { + const nextStorage = loadedStorage + ? structuredClone(loadedStorage) + : structuredClone(storage); + const nextIndex = resolveManageActionAccountIndex( + nextStorage, + idx, + selectedAccount, ); - const updatedFlagged: FlaggedAccountMetadataV1 = { - ...flagged, - refreshToken: result.refresh, - accessToken: result.access, - expiresAt: result.expires, - accountId: nextIdentity.accountId, - accountIdSource: nextIdentity.accountIdSource, - email: sanitizeEmail(extractAccountEmail(result.access, result.idToken)) ?? flagged.email, - lastUsed: now, - lastError: upsertResult.message, - }; - nextFlaggedAccounts.push(updatedFlagged); - if (JSON.stringify(updatedFlagged) !== JSON.stringify(flagged)) { - flaggedChanged = true; + if (nextIndex === null) { + return; } - reports.push({ - index: i, - label, - outcome: "restore-skipped", - message: upsertResult.message, - }); - continue; - } - - const detail = normalizeFailureDetail(result.message, result.reason); - const failedFlagged: FlaggedAccountMetadataV1 = { - ...flagged, - lastError: detail, - }; - nextFlaggedAccounts.push(failedFlagged); - if ((flagged.lastError ?? "") !== detail) { - flaggedChanged = true; - } - reports.push({ - index: i, - label, - outcome: "still-flagged", - message: detail, + const nextAccount = nextStorage.accounts[nextIndex]; + if (!nextAccount || !matchesManageActionAccount(selectedAccount, nextAccount)) { + return; + } + nextAccount.enabled = nextAccount.enabled === false; + await persist(nextStorage); + replaceManageActionStorage(storage, nextStorage); + nextEnabledState = nextAccount.enabled !== false; }); } - }; - - if (options.restore) { - if (options.dryRun) { - applyRefreshChecks( - (await loadAccounts()) ?? createEmptyAccountStorage(), - ); - } else { - await withAccountAndFlaggedStorageTransaction( - async (loadedStorage, persist) => { - const nextStorage = loadedStorage - ? structuredClone(loadedStorage) - : createEmptyAccountStorage(); - applyRefreshChecks(nextStorage); - if (!storageChanged) { - return; - } - normalizeDoctorIndexes(nextStorage); - await persist(nextStorage, { - version: 1, - accounts: nextFlaggedAccounts, - }); - }, + if (nextEnabledState !== null) { + console.log( + `${nextEnabledState ? "Enabled" : "Disabled"} account ${idx + 1}.`, ); } - } else { - applyRefreshChecks(createEmptyAccountStorage()); + return; } - const remainingFlagged = nextFlaggedAccounts.length; - const restored = reports.filter((report) => report.outcome === "restored").length; - const healthyFlagged = reports.filter((report) => report.outcome === "healthy-flagged").length; - const stillFlagged = reports.filter((report) => report.outcome === "still-flagged").length; - const changed = storageChanged || flaggedChanged; + if (typeof menuResult.refreshAccountIndex === "number") { + const idx = menuResult.refreshAccountIndex; + const existing = storage.accounts[idx]; + if (!existing) return; - if (!options.dryRun && flaggedChanged && (!options.restore || !storageChanged)) { - await saveFlaggedAccounts({ - version: 1, - accounts: nextFlaggedAccounts, - }); - } + const signInMode = await promptOAuthSignInMode(null); + if (signInMode === "cancel") { + console.log(stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted")); + return; + } + if (signInMode !== "browser" && signInMode !== "manual") { + return; + } - if (options.json) { - console.log( - JSON.stringify( - { - command: "verify-flagged", - total: flaggedStorage.accounts.length, - restored, - healthyFlagged, - stillFlagged, - remainingFlagged, - changed, - dryRun: options.dryRun, - restore: options.restore, - reports, - }, - null, - 2, - ), - ); - return 0; - } + const tokenResult = await runOAuthFlow(true, signInMode); + if (tokenResult.type !== "success") { + console.error(`Refresh failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`); + return; + } - console.log( - stylePromptText( - `Checking ${flaggedStorage.accounts.length} flagged account(s)...`, - "accent", - ), - ); - for (const report of reports) { - const tone = report.outcome === "restored" - ? "success" - : report.outcome === "healthy-flagged" - ? "warning" - : report.outcome === "restore-skipped" - ? "warning" - : "danger"; - const marker = report.outcome === "restored" - ? "✓" - : report.outcome === "healthy-flagged" - ? "!" - : report.outcome === "restore-skipped" - ? "!" - : "✗"; - console.log( - `${stylePromptText(marker, tone)} ${stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${stylePromptText("|", "muted")} ${styleAccountDetailText(report.message, tone)}`, - ); - } - console.log(""); - console.log(formatResultSummary([ - { text: `${restored} restored`, tone: restored > 0 ? "success" : "muted" }, - { text: `${healthyFlagged} healthy (kept flagged)`, tone: healthyFlagged > 0 ? "warning" : "muted" }, - { text: `${stillFlagged} still flagged`, tone: stillFlagged > 0 ? "danger" : "muted" }, - ])); - if (options.dryRun) { - console.log(stylePromptText("Preview only: no changes were saved.", "warning")); - } else if (!changed) { - console.log(stylePromptText("No storage changes were needed.", "muted")); + const resolved = resolveAccountSelection(tokenResult); + await persistAccountPool([resolved], false); + await syncSelectionToCodex(resolved); + console.log(`Refreshed account ${idx + 1}.`); } +} - return 0; +async function runAuthLogin(args: string[]): Promise { + return runAuthLoginCommand(args, authLoginCommandDeps); } -async function runFix(args: string[]): Promise { - if (args.includes("--help") || args.includes("-h")) { - printFixUsage(); - return 0; - } +async function runSwitch(args: string[]): Promise { + return runSwitchCommand(args, authCommandHelpers); +} - const parsedArgs = parseFixArgs(args); - if (!parsedArgs.ok) { - console.error(parsedArgs.message); - printFixUsage(); - return 1; - } - const options = parsedArgs.options; - const display = DEFAULT_DASHBOARD_DISPLAY_SETTINGS; - const quotaCache = options.live ? await loadQuotaCache() : null; - const workingQuotaCache = quotaCache ? cloneQuotaCacheData(quotaCache) : null; - let quotaCacheChanged = false; +async function runBest(args: string[]): Promise { + return runBestCommand(args, authCommandHelpers); +} + +async function runForecast(args: string[]): Promise { + return runForecastCommand(args, forecastReportCommandDeps); +} + +async function runReport(args: string[]): Promise { + return runReportCommand(args, forecastReportCommandDeps); +} +export async function autoSyncActiveAccountToCodex(): Promise { setStoragePath(null); const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { - console.log("No accounts configured."); - return 0; + return false; } - let quotaEmailFallbackState = - options.live && quotaCache - ? buildQuotaEmailFallbackState(storage.accounts) - : null; - const now = Date.now(); const activeIndex = resolveActiveIndex(storage, "codex"); - let changed = false; - const reports: FixAccountReport[] = []; - const refreshFailures = new Map(); - const hardDisabledIndexes: number[] = []; + if (activeIndex < 0 || activeIndex >= storage.accounts.length) { + return false; + } - for (let i = 0; i < storage.accounts.length; i += 1) { - const account = storage.accounts[i]; - if (!account) continue; - const label = formatAccountLabel(account, i); + const account = storage.accounts[activeIndex]; + if (!account) { + return false; + } + const accountMatch = { + accountId: account.accountId, + email: account.email, + refreshToken: account.refreshToken, + }; - if (account.enabled === false) { - reports.push({ - index: i, - label, - outcome: "already-disabled", - message: "already disabled", - }); - continue; - } - - if (hasUsableAccessToken(account, now)) { - if (options.live) { - const currentAccessToken = account.accessToken; - const probeAccountId = currentAccessToken - ? (account.accountId ?? extractAccountId(currentAccessToken)) - : undefined; - if (probeAccountId && currentAccessToken) { - try { - const snapshot = await fetchCodexQuotaSnapshot({ - accountId: probeAccountId, - accessToken: currentAccessToken, - model: options.model, - }); - if (workingQuotaCache) { - quotaCacheChanged = - updateQuotaCacheForAccount( - workingQuotaCache, - account, - snapshot, - storage.accounts, - quotaEmailFallbackState ?? undefined, - ) || quotaCacheChanged; - } - reports.push({ - index: i, - label, - outcome: "healthy", - message: display.showQuotaDetails - ? `live session OK (${formatCompactQuotaSnapshot(snapshot)})` - : "live session OK", - }); - continue; - } catch (error) { - const message = normalizeFailureDetail( - error instanceof Error ? error.message : String(error), - undefined, - ); - reports.push({ - index: i, - label, - outcome: "warning-soft-failure", - message: `live probe failed (${message}), trying refresh fallback`, - }); - } - } - } - - const refreshWarning = hasLikelyInvalidRefreshToken(account.refreshToken) - ? " (refresh token looks stale; re-login recommended)" - : ""; - reports.push({ - index: i, - label, - outcome: "healthy", - message: `access token still valid${refreshWarning}`, - }); - continue; - } + const now = Date.now(); + let syncAccessToken = account.accessToken; + let syncRefreshToken = account.refreshToken; + let syncExpiresAt = account.expiresAt; + let syncIdToken: string | undefined; + let syncAccountId = account.accountId; + let syncEmail = account.email; + let changed = false; + let nextStoredAccount: AccountMetadataV3 | null = null; + if (!hasUsableAccessToken(account, now)) { const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type === "success") { - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); - const nextAccountId = extractAccountId(refreshResult.access); - const previousEmail = account.email; - let accountChanged = false; - let accountIdentityChanged = false; - - if (account.refreshToken !== refreshResult.refresh) { - account.refreshToken = refreshResult.refresh; - accountChanged = true; - } - if (account.accessToken !== refreshResult.access) { - account.accessToken = refreshResult.access; - accountChanged = true; - } - if (account.expiresAt !== refreshResult.expires) { - account.expiresAt = refreshResult.expires; - accountChanged = true; - } - if (nextEmail && nextEmail !== account.email) { - account.email = nextEmail; - accountChanged = true; - accountIdentityChanged = true; - } - if (applyTokenAccountIdentity(account, nextAccountId)) { - accountChanged = true; - accountIdentityChanged = true; - } - - if (accountChanged) changed = true; - if (accountIdentityChanged && options.live && workingQuotaCache) { - quotaEmailFallbackState = buildQuotaEmailFallbackState(storage.accounts); - quotaCacheChanged = - pruneUnsafeQuotaEmailCacheEntry( - workingQuotaCache, - previousEmail, - storage.accounts, - quotaEmailFallbackState, - ) || quotaCacheChanged; - } - if (options.live) { - const probeAccountId = account.accountId ?? nextAccountId; - if (probeAccountId) { - try { - const snapshot = await fetchCodexQuotaSnapshot({ - accountId: probeAccountId, - accessToken: refreshResult.access, - model: options.model, - }); - if (workingQuotaCache) { - quotaCacheChanged = - updateQuotaCacheForAccount( - workingQuotaCache, - account, - snapshot, - storage.accounts, - quotaEmailFallbackState ?? undefined, - ) || quotaCacheChanged; - } - reports.push({ - index: i, - label, - outcome: "healthy", - message: display.showQuotaDetails - ? `refresh + live probe succeeded (${formatCompactQuotaSnapshot(snapshot)})` - : "refresh + live probe succeeded", - }); - continue; - } catch (error) { - const message = normalizeFailureDetail( - error instanceof Error ? error.message : String(error), - undefined, - ); - reports.push({ - index: i, - label, - outcome: "warning-soft-failure", - message: `refresh succeeded but live probe failed: ${message}`, - }); - continue; - } - } - } - reports.push({ - index: i, - label, - outcome: "healthy", - message: "refresh succeeded", - }); - continue; - } - - const detail = normalizeFailureDetail(refreshResult.message, refreshResult.reason); - refreshFailures.set(i, { - ...refreshResult, - message: detail, - }); - if (isHardRefreshFailure(refreshResult)) { - account.enabled = false; - changed = true; - hardDisabledIndexes.push(i); - reports.push({ - index: i, - label, - outcome: "disabled-hard-failure", - message: detail, - }); - } else { - reports.push({ - index: i, - label, - outcome: "warning-soft-failure", - message: detail, - }); - } - } - - if (hardDisabledIndexes.length > 0) { - const enabledCount = storage.accounts.filter((account) => account.enabled !== false).length; - if (enabledCount === 0) { - const fallbackIndex = - hardDisabledIndexes.includes(activeIndex) ? activeIndex : hardDisabledIndexes[0]; - const fallback = typeof fallbackIndex === "number" - ? storage.accounts[fallbackIndex] - : undefined; - if (fallback && fallback.enabled === false) { - fallback.enabled = true; - changed = true; - const existingReport = reports.find( - (report) => - report.index === fallbackIndex && - report.outcome === "disabled-hard-failure", - ); - if (existingReport) { - existingReport.outcome = "warning-soft-failure"; - existingReport.message = `${existingReport.message} (kept enabled to avoid lockout; re-login required)`; - } - } - } - } - - const forecastResults = evaluateForecastAccounts( - storage.accounts.map((account, index) => ({ - index, - account, - isCurrent: index === activeIndex, - now, - refreshFailure: refreshFailures.get(index), - })), - ); - const recommendation = recommendForecastAccount(forecastResults); - const reportSummary = summarizeFixReports(reports); - - if (changed && !options.dryRun) { - await saveAccounts(storage); - } - - if (options.json) { - if (workingQuotaCache && quotaCacheChanged) { - await saveQuotaCache(workingQuotaCache); + if (refreshResult.type !== "success") { + return false; } - console.log( - JSON.stringify( - { - command: "fix", - dryRun: options.dryRun, - liveProbe: options.live, - model: options.model, - changed, - summary: reportSummary, - recommendation, - recommendedSwitchCommand: - recommendation.recommendedIndex !== null && - recommendation.recommendedIndex !== activeIndex - ? `codex auth switch ${recommendation.recommendedIndex + 1}` - : null, - reports, - }, - null, - 2, - ), + nextStoredAccount = structuredClone(account); + const tokenAccountId = extractAccountId(refreshResult.access); + const nextEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), ); - return 0; - } - - console.log(stylePromptText(`Auto-fix scan (${options.dryRun ? "preview" : "apply"})`, "accent")); - console.log(formatResultSummary([ - { text: `${reportSummary.healthy} working`, tone: "success" }, - { text: `${reportSummary.disabled} disabled`, tone: reportSummary.disabled > 0 ? "danger" : "muted" }, - { - text: `${reportSummary.warnings} warning${reportSummary.warnings === 1 ? "" : "s"}`, - tone: reportSummary.warnings > 0 ? "warning" : "muted", - }, - { text: `${reportSummary.skipped} already disabled`, tone: "muted" }, - ])); - if (display.showPerAccountRows) { - console.log(""); - for (const report of reports) { - const prefix = - report.outcome === "healthy" - ? "✓" - : report.outcome === "disabled-hard-failure" - ? "✗" - : report.outcome === "warning-soft-failure" - ? "!" - : "-"; - const tone = report.outcome === "healthy" - ? "success" - : report.outcome === "disabled-hard-failure" - ? "danger" - : report.outcome === "warning-soft-failure" - ? "warning" - : "muted"; - console.log( - `${stylePromptText(prefix, tone)} ${stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${stylePromptText("|", "muted")} ${styleAccountDetailText(report.message, tone === "success" ? "muted" : tone)}`, - ); - } - } else { - console.log(""); - console.log(stylePromptText("Per-account lines are hidden in dashboard settings.", "muted")); - } - - if (display.showRecommendations) { - console.log(""); - if (recommendation.recommendedIndex !== null) { - const target = recommendation.recommendedIndex + 1; - console.log(`${stylePromptText("Best next account:", "accent")} ${stylePromptText(String(target), "success")}`); - console.log(`${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); - if (recommendation.recommendedIndex !== activeIndex) { - console.log(`${stylePromptText("Switch now with:", "accent")} codex auth switch ${target}`); - } - } else { - console.log(`${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); - } - } - if (workingQuotaCache && quotaCacheChanged) { - await saveQuotaCache(workingQuotaCache); - } - - if (changed && options.dryRun) { - console.log(`\n${stylePromptText("Preview only: no changes were saved.", "warning")}`); - } else if (changed) { - console.log(`\n${stylePromptText("Saved updates.", "success")}`); - } else { - console.log(`\n${stylePromptText("No changes were needed.", "muted")}`); - } - - return 0; -} - -type DoctorSeverity = "ok" | "warn" | "error"; - -interface DoctorCheck { - key: string; - severity: DoctorSeverity; - message: string; - details?: string; -} - -interface DoctorFixAction { - key: string; - message: string; -} - -function hasPlaceholderEmail(value: string | undefined): boolean { - if (!value) return false; - const email = value.trim().toLowerCase(); - if (!email) return false; - return ( - email.endsWith("@example.com") || - email.includes("account1@example.com") || - email.includes("account2@example.com") || - email.includes("account3@example.com") - ); -} - -function normalizeDoctorIndexes(storage: AccountStorageV3): boolean { - const total = storage.accounts.length; - const nextActive = total === 0 ? 0 : Math.max(0, Math.min(storage.activeIndex, total - 1)); - let changed = false; - if (storage.activeIndex !== nextActive) { - storage.activeIndex = nextActive; - changed = true; - } - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - const raw = storage.activeIndexByFamily[family]; - const fallback = storage.activeIndex; - const candidate = typeof raw === "number" && Number.isFinite(raw) ? raw : fallback; - const clamped = total === 0 ? 0 : Math.max(0, Math.min(candidate, total - 1)); - if (storage.activeIndexByFamily[family] !== clamped) { - storage.activeIndexByFamily[family] = clamped; + if (nextStoredAccount.refreshToken !== refreshResult.refresh) { + nextStoredAccount.refreshToken = refreshResult.refresh; changed = true; } - } - return changed; -} - -function getDoctorRefreshTokenKey( - refreshToken: unknown, -): string | undefined { - if (typeof refreshToken !== "string") return undefined; - const trimmed = refreshToken.trim(); - return trimmed || undefined; -} - -function applyDoctorFixes(storage: AccountStorageV3): { changed: boolean; actions: DoctorFixAction[] } { - let changed = false; - const actions: DoctorFixAction[] = []; - - if (normalizeDoctorIndexes(storage)) { - changed = true; - actions.push({ - key: "active-index", - message: "Normalized active account indexes", - }); - } - - const seenRefreshTokens = new Map(); - for (let i = 0; i < storage.accounts.length; i += 1) { - const account = storage.accounts[i]; - if (!account) continue; - - const refreshToken = getDoctorRefreshTokenKey(account.refreshToken); - if (!refreshToken) continue; - const existingTokenIndex = seenRefreshTokens.get(refreshToken); - if (typeof existingTokenIndex === "number") { - if (account.enabled !== false) { - account.enabled = false; - changed = true; - actions.push({ - key: "duplicate-refresh-token", - message: `Disabled duplicate token entry on account ${i + 1} (kept account ${existingTokenIndex + 1})`, - }); - } - } else { - seenRefreshTokens.set(refreshToken, i); - } - - const tokenEmail = sanitizeEmail(extractAccountEmail(account.accessToken)); - if ( - tokenEmail && - (!sanitizeEmail(account.email) || hasPlaceholderEmail(account.email)) - ) { - account.email = tokenEmail; + if (nextStoredAccount.accessToken !== refreshResult.access) { + nextStoredAccount.accessToken = refreshResult.access; changed = true; - actions.push({ - key: "email-from-token", - message: `Updated account ${i + 1} email from token claims`, - }); } - - const tokenAccountId = extractAccountId(account.accessToken); - if (!account.accountId && tokenAccountId) { - account.accountId = tokenAccountId; - account.accountIdSource = "token"; + if (nextStoredAccount.expiresAt !== refreshResult.expires) { + nextStoredAccount.expiresAt = refreshResult.expires; changed = true; - actions.push({ - key: "account-id-from-token", - message: `Filled missing accountId for account ${i + 1}`, - }); } - } - - const enabledCount = storage.accounts.filter((account) => account.enabled !== false).length; - if (storage.accounts.length > 0 && enabledCount === 0) { - const index = resolveActiveIndex(storage, "codex"); - const candidate = storage.accounts[index] ?? storage.accounts[0]; - if (candidate) { - candidate.enabled = true; + if (nextEmail && nextEmail !== nextStoredAccount.email) { + nextStoredAccount.email = nextEmail; changed = true; - actions.push({ - key: "enabled-accounts", - message: `Re-enabled account ${index + 1} to avoid an all-disabled pool`, - }); - } - } - - if (normalizeDoctorIndexes(storage)) { - changed = true; - } - - return { changed, actions }; -} - -async function runDoctor(args: string[]): Promise { - if (args.includes("--help") || args.includes("-h")) { - printDoctorUsage(); - return 0; - } - - const parsedArgs = parseDoctorArgs(args); - if (!parsedArgs.ok) { - console.error(parsedArgs.message); - printDoctorUsage(); - return 1; - } - const options = parsedArgs.options; - - setStoragePath(null); - const storagePath = getStoragePath(); - const checks: DoctorCheck[] = []; - const addCheck = (check: DoctorCheck): void => { - checks.push(check); - }; - - addCheck({ - key: "storage-file", - severity: existsSync(storagePath) ? "ok" : "warn", - message: existsSync(storagePath) - ? "Account storage file found" - : "Account storage file does not exist yet (first login pending)", - details: storagePath, - }); - - if (existsSync(storagePath)) { - try { - const stat = await fs.stat(storagePath); - addCheck({ - key: "storage-readable", - severity: stat.size > 0 ? "ok" : "warn", - message: stat.size > 0 ? "Storage file is readable" : "Storage file is empty", - details: `${stat.size} bytes`, - }); - } catch (error) { - addCheck({ - key: "storage-readable", - severity: "error", - message: "Unable to read storage file metadata", - details: error instanceof Error ? error.message : String(error), - }); - } - } - - const codexAuthPath = getCodexCliAuthPath(); - const codexConfigPath = getCodexCliConfigPath(); - let codexAuthEmail: string | undefined; - let codexAuthAccountId: string | undefined; - - addCheck({ - key: "codex-auth-file", - severity: existsSync(codexAuthPath) ? "ok" : "warn", - message: existsSync(codexAuthPath) - ? "Codex auth file found" - : "Codex auth file does not exist", - details: codexAuthPath, - }); - - if (existsSync(codexAuthPath)) { - try { - const raw = await fs.readFile(codexAuthPath, "utf-8"); - const parsed = JSON.parse(raw) as unknown; - if (parsed && typeof parsed === "object") { - const payload = parsed as Record; - const tokens = payload.tokens && typeof payload.tokens === "object" - ? (payload.tokens as Record) - : null; - const accessToken = tokens && typeof tokens.access_token === "string" - ? tokens.access_token - : undefined; - const idToken = tokens && typeof tokens.id_token === "string" - ? tokens.id_token - : undefined; - const accountIdFromFile = tokens && typeof tokens.account_id === "string" - ? tokens.account_id - : undefined; - const emailFromFile = typeof payload.email === "string" ? payload.email : undefined; - codexAuthEmail = sanitizeEmail(emailFromFile ?? extractAccountEmail(accessToken, idToken)); - codexAuthAccountId = accountIdFromFile ?? extractAccountId(accessToken); - } - addCheck({ - key: "codex-auth-readable", - severity: "ok", - message: "Codex auth file is readable", - details: - codexAuthEmail || codexAuthAccountId - ? `email=${codexAuthEmail ?? "unknown"}, accountId=${codexAuthAccountId ?? "unknown"}` - : undefined, - }); - } catch (error) { - addCheck({ - key: "codex-auth-readable", - severity: "error", - message: "Unable to read Codex auth file", - details: error instanceof Error ? error.message : String(error), - }); - } - } - - addCheck({ - key: "codex-config-file", - severity: existsSync(codexConfigPath) ? "ok" : "warn", - message: existsSync(codexConfigPath) - ? "Codex config file found" - : "Codex config file does not exist", - details: codexConfigPath, - }); - - let codexAuthStoreMode: string | undefined; - if (existsSync(codexConfigPath)) { - try { - const configRaw = await fs.readFile(codexConfigPath, "utf-8"); - const match = configRaw.match(/^\s*cli_auth_credentials_store\s*=\s*"([^"]+)"\s*$/m); - if (match?.[1]) { - codexAuthStoreMode = match[1].trim(); - } - } catch (error) { - addCheck({ - key: "codex-auth-store", - severity: "warn", - message: "Unable to read Codex auth-store config", - details: error instanceof Error ? error.message : String(error), - }); } - } - if (!checks.some((check) => check.key === "codex-auth-store")) { - addCheck({ - key: "codex-auth-store", - severity: codexAuthStoreMode === "file" ? "ok" : "warn", - message: - codexAuthStoreMode === "file" - ? "Codex auth storage is set to file" - : "Codex auth storage is not explicitly set to file", - details: codexAuthStoreMode ? `mode=${codexAuthStoreMode}` : "mode=unset", - }); - } - - const codexCliState = await loadCodexCliState({ forceRefresh: true }); - addCheck({ - key: "codex-cli-state", - severity: codexCliState ? "ok" : "warn", - message: codexCliState - ? "Codex CLI state loaded" - : "Codex CLI state unavailable", - details: codexCliState?.path, - }); - - const storage = await loadAccounts(); - let fixChanged = false; - let fixActions: DoctorFixAction[] = []; - if (options.fix && storage && storage.accounts.length > 0) { - const fixed = applyDoctorFixes(storage); - fixChanged = fixed.changed; - fixActions = fixed.actions; - if (fixChanged && !options.dryRun) { - await saveAccounts(storage); + if (applyTokenAccountIdentity(nextStoredAccount, tokenAccountId)) { + changed = true; } - addCheck({ - key: "auto-fix", - severity: fixChanged ? "warn" : "ok", - message: fixChanged - ? options.dryRun - ? `Prepared ${fixActions.length} fix(es) (dry-run)` - : `Applied ${fixActions.length} fix(es)` - : "No safe auto-fixes needed", - }); + syncAccessToken = refreshResult.access; + syncRefreshToken = refreshResult.refresh; + syncExpiresAt = refreshResult.expires; + syncIdToken = refreshResult.idToken; + syncAccountId = nextStoredAccount.accountId; + syncEmail = nextStoredAccount.email; } - if (!storage || storage.accounts.length === 0) { - addCheck({ - key: "accounts", - severity: "warn", - message: "No accounts configured", - }); - } else { - addCheck({ - key: "accounts", - severity: "ok", - message: `Loaded ${storage.accounts.length} account(s)`, - }); - const activeIndex = resolveActiveIndex(storage, "codex"); - const activeExists = activeIndex >= 0 && activeIndex < storage.accounts.length; - addCheck({ - key: "active-index", - severity: activeExists ? "ok" : "error", - message: activeExists - ? `Active index is valid (${activeIndex + 1})` - : "Active index is out of range", - }); - - const disabledCount = storage.accounts.filter((a) => a.enabled === false).length; - addCheck({ - key: "enabled-accounts", - severity: disabledCount >= storage.accounts.length ? "error" : "ok", - message: - disabledCount >= storage.accounts.length - ? "All accounts are disabled" - : `${storage.accounts.length - disabledCount} enabled / ${disabledCount} disabled`, - }); - - const seenRefreshTokens = new Set(); - let duplicateTokenCount = 0; - for (const account of storage.accounts) { - const token = getDoctorRefreshTokenKey(account.refreshToken); - if (!token) continue; - if (seenRefreshTokens.has(token)) { - duplicateTokenCount += 1; - } else { - seenRefreshTokens.add(token); + if (changed && nextStoredAccount) { + let persisted = false; + await withAccountStorageTransaction(async (loadedStorage, persist) => { + if (!loadedStorage) { + return; } - } - addCheck({ - key: "duplicate-refresh-token", - severity: duplicateTokenCount > 0 ? "warn" : "ok", - message: - duplicateTokenCount > 0 - ? `Detected ${duplicateTokenCount} duplicate refresh token entr${duplicateTokenCount === 1 ? "y" : "ies"}` - : "No duplicate refresh tokens detected", - }); - - const seenEmails = new Set(); - let duplicateEmailCount = 0; - let placeholderEmailCount = 0; - let likelyInvalidRefreshTokenCount = 0; - for (const account of storage.accounts) { - const email = sanitizeEmail(account.email); - if (!email) continue; - if (seenEmails.has(email)) duplicateEmailCount += 1; - seenEmails.add(email); - if (hasPlaceholderEmail(email)) placeholderEmailCount += 1; - if (hasLikelyInvalidRefreshToken(account.refreshToken)) { - likelyInvalidRefreshTokenCount += 1; + const nextStorage = structuredClone(loadedStorage); + const targetIndex = + findMatchingAccountIndex(nextStorage.accounts, accountMatch, { + allowUniqueAccountIdFallbackWithoutEmail: true, + }) + ?? findMatchingAccountIndex(nextStorage.accounts, nextStoredAccount, { + allowUniqueAccountIdFallbackWithoutEmail: true, + }); + if (targetIndex === undefined) { + return; } - } - addCheck({ - key: "duplicate-email", - severity: duplicateEmailCount > 0 ? "warn" : "ok", - message: - duplicateEmailCount > 0 - ? `Detected ${duplicateEmailCount} duplicate email entr${duplicateEmailCount === 1 ? "y" : "ies"}` - : "No duplicate emails detected", + nextStorage.accounts[targetIndex] = structuredClone(nextStoredAccount); + await persist(nextStorage); + persisted = true; }); - addCheck({ - key: "placeholder-email", - severity: placeholderEmailCount > 0 ? "warn" : "ok", - message: - placeholderEmailCount > 0 - ? `${placeholderEmailCount} account(s) appear to be placeholder/demo entries` - : "No placeholder emails detected", - }); - addCheck({ - key: "refresh-token-shape", - severity: likelyInvalidRefreshTokenCount > 0 ? "warn" : "ok", - message: - likelyInvalidRefreshTokenCount > 0 - ? `${likelyInvalidRefreshTokenCount} account(s) have likely invalid refresh token format` - : "Refresh token format looks normal", - }); - - const now = Date.now(); - const forecastResults = evaluateForecastAccounts( - storage.accounts.map((account, index) => ({ - index, - account, - isCurrent: index === activeIndex, - now, - })), - ); - const recommendation = recommendForecastAccount(forecastResults); - if (recommendation.recommendedIndex !== null && recommendation.recommendedIndex !== activeIndex) { - addCheck({ - key: "recommended-switch", - severity: "warn", - message: `A healthier account is available: switch to ${recommendation.recommendedIndex + 1}`, - details: recommendation.reason, - }); - } else { - addCheck({ - key: "recommended-switch", - severity: "ok", - message: "Current account aligns with forecast recommendation", - }); - } - - if (activeExists) { - const activeAccount = storage.accounts[activeIndex]; - const managerActiveEmail = sanitizeEmail(activeAccount?.email); - const managerActiveAccountId = activeAccount?.accountId; - const codexActiveEmail = sanitizeEmail(codexCliState?.activeEmail) ?? codexAuthEmail; - const codexActiveAccountId = codexCliState?.activeAccountId ?? codexAuthAccountId; - const isEmailMismatch = - !!managerActiveEmail && - !!codexActiveEmail && - managerActiveEmail !== codexActiveEmail; - const isAccountIdMismatch = - !!managerActiveAccountId && - !!codexActiveAccountId && - managerActiveAccountId !== codexActiveAccountId; - - addCheck({ - key: "active-selection-sync", - severity: isEmailMismatch || isAccountIdMismatch ? "warn" : "ok", - message: - isEmailMismatch || isAccountIdMismatch - ? "Manager active account and Codex active account are not aligned" - : "Manager active account and Codex active account are aligned", - details: `manager=${managerActiveEmail ?? managerActiveAccountId ?? "unknown"} | codex=${codexActiveEmail ?? codexActiveAccountId ?? "unknown"}`, - }); - - if (options.fix && activeAccount) { - let syncAccessToken = activeAccount.accessToken; - let syncRefreshToken = activeAccount.refreshToken; - let syncExpiresAt = activeAccount.expiresAt; - let syncIdToken: string | undefined; - let storageChangedFromDoctorSync = false; - - if (!hasUsableAccessToken(activeAccount, now)) { - if (options.dryRun) { - fixActions.push({ - key: "doctor-refresh", - message: `Prepared active-account token refresh for account ${activeIndex + 1} (dry-run)`, - }); - } else { - const refreshResult = await queuedRefresh(activeAccount.refreshToken); - if (refreshResult.type === "success") { - const refreshedEmail = sanitizeEmail( - extractAccountEmail(refreshResult.access, refreshResult.idToken), - ); - const refreshedAccountId = extractAccountId(refreshResult.access); - activeAccount.accessToken = refreshResult.access; - activeAccount.refreshToken = refreshResult.refresh; - activeAccount.expiresAt = refreshResult.expires; - if (refreshedEmail) activeAccount.email = refreshedEmail; - applyTokenAccountIdentity(activeAccount, refreshedAccountId); - syncAccessToken = refreshResult.access; - syncRefreshToken = refreshResult.refresh; - syncExpiresAt = refreshResult.expires; - syncIdToken = refreshResult.idToken; - storageChangedFromDoctorSync = true; - fixActions.push({ - key: "doctor-refresh", - message: `Refreshed active account tokens for account ${activeIndex + 1}`, - }); - } else { - addCheck({ - key: "doctor-refresh", - severity: "warn", - message: "Unable to refresh active account before Codex sync", - details: normalizeFailureDetail(refreshResult.message, refreshResult.reason), - }); - } - } - } - - if (storageChangedFromDoctorSync) { - fixChanged = true; - if (!options.dryRun) { - await saveAccounts(storage); - } - } - - if (!options.dryRun) { - const synced = await setCodexCliActiveSelection({ - accountId: activeAccount.accountId, - email: activeAccount.email, - accessToken: syncAccessToken, - refreshToken: syncRefreshToken, - expiresAt: syncExpiresAt, - ...(syncIdToken ? { idToken: syncIdToken } : {}), - }); - if (synced) { - fixChanged = true; - fixActions.push({ - key: "codex-active-sync", - message: "Synced manager active account into Codex auth state", - }); - } else { - addCheck({ - key: "codex-active-sync", - severity: "warn", - message: "Failed to sync manager active account into Codex auth state", - }); - } - } else { - fixActions.push({ - key: "codex-active-sync", - message: "Prepared Codex active-account sync (dry-run)", - }); - } - } - } - } - - const summary = checks.reduce( - (acc, check) => { - acc[check.severity] += 1; - return acc; - }, - { ok: 0, warn: 0, error: 0 }, - ); - - if (options.json) { - console.log( - JSON.stringify( - { - command: "doctor", - storagePath, - summary, - checks, - fix: { - enabled: options.fix, - dryRun: options.dryRun, - changed: fixChanged, - actions: fixActions, - }, - }, - null, - 2, - ), - ); - return summary.error > 0 ? 1 : 0; - } - - console.log("Doctor diagnostics"); - console.log(`Storage: ${storagePath}`); - console.log(`Summary: ${summary.ok} ok, ${summary.warn} warnings, ${summary.error} errors`); - console.log(""); - for (const check of checks) { - const marker = check.severity === "ok" ? "✓" : check.severity === "warn" ? "!" : "✗"; - console.log(`${marker} ${check.key}: ${check.message}`); - if (check.details) { - console.log(` ${check.details}`); - } - } - if (options.fix) { - console.log(""); - if (fixActions.length > 0) { - console.log(`Auto-fix actions (${options.dryRun ? "dry-run" : "applied"}):`); - for (const action of fixActions) { - console.log(` - ${action.message}`); - } - } else { - console.log("Auto-fix actions: none"); - } - } - - return summary.error > 0 ? 1 : 0; -} - -async function clearAccountsAndReset(): Promise { - await clearAccounts(); -} - -async function handleManageAction( - storage: AccountStorageV3, - menuResult: Awaited>, -): Promise { - if (typeof menuResult.switchAccountIndex === "number") { - const index = menuResult.switchAccountIndex; - await runSwitch([String(index + 1)]); - return; - } - - if (typeof menuResult.deleteAccountIndex === "number") { - const idx = menuResult.deleteAccountIndex; - if (idx >= 0 && idx < storage.accounts.length) { - storage.accounts.splice(idx, 1); - storage.activeIndex = 0; - storage.activeIndexByFamily = {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = 0; - } - await saveAccounts(storage); - console.log(`Deleted account ${idx + 1}.`); - } - return; - } - - if (typeof menuResult.toggleAccountIndex === "number") { - const idx = menuResult.toggleAccountIndex; - const account = storage.accounts[idx]; - if (account) { - account.enabled = account.enabled === false; - await saveAccounts(storage); - console.log( - `${account.enabled === false ? "Disabled" : "Enabled"} account ${idx + 1}.`, - ); - } - return; - } - - if (typeof menuResult.refreshAccountIndex === "number") { - const idx = menuResult.refreshAccountIndex; - const existing = storage.accounts[idx]; - if (!existing) return; - - const signInMode = await promptOAuthSignInMode(null); - if (signInMode === "cancel") { - console.log(stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted")); - return; - } - if (signInMode !== "browser" && signInMode !== "manual") { - return; - } - - const tokenResult = await runOAuthFlow(true, signInMode); - if (tokenResult.type !== "success") { - console.error(`Refresh failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`); - return; + if (!persisted) { + return false; } - - const resolved = resolveAccountSelection(tokenResult); - await persistAccountPool([resolved], false); - await syncSelectionToCodex(resolved); - console.log(`Refreshed account ${idx + 1}.`); - } -} - -async function runAuthLogin(args: string[]): Promise { - return runAuthLoginCommand(args, authLoginCommandDeps); -} - -async function runSwitch(args: string[]): Promise { - return runSwitchCommand(args, authCommandHelpers); -} - -async function runBest(args: string[]): Promise { - return runBestCommand(args, authCommandHelpers); -} - -async function runForecast(args: string[]): Promise { - return runForecastCommand(args, forecastReportCommandDeps); -} - -async function runReport(args: string[]): Promise { - return runReportCommand(args, forecastReportCommandDeps); -} - -export async function autoSyncActiveAccountToCodex(): Promise { - setStoragePath(null); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - return false; - } - - const activeIndex = resolveActiveIndex(storage, "codex"); - if (activeIndex < 0 || activeIndex >= storage.accounts.length) { - return false; - } - - const account = storage.accounts[activeIndex]; - if (!account) { - return false; - } - - const now = Date.now(); - let syncAccessToken = account.accessToken; - let syncRefreshToken = account.refreshToken; - let syncExpiresAt = account.expiresAt; - let syncIdToken: string | undefined; - let changed = false; - - if (!hasUsableAccessToken(account, now)) { - 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; - 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 (nextEmail && nextEmail !== account.email) { - account.email = nextEmail; - changed = true; - } - if (applyTokenAccountIdentity(account, tokenAccountId)) { - changed = true; - } - syncAccessToken = refreshResult.access; - syncRefreshToken = refreshResult.refresh; - syncExpiresAt = refreshResult.expires; - syncIdToken = refreshResult.idToken; - } - } - - if (changed) { - await saveAccounts(storage); } return setCodexCliActiveSelection({ - accountId: account.accountId, - email: account.email, + accountId: syncAccountId, + email: syncEmail, accessToken: syncAccessToken, refreshToken: syncRefreshToken, expiresAt: syncExpiresAt, @@ -3821,6 +2437,23 @@ const forecastReportCommandDeps = { formatRateLimitEntry, }; +const repairCommandDeps = { + stylePromptText, + styleAccountDetailText, + formatResultSummary, + resolveActiveIndex, + hasUsableAccessToken, + hasLikelyInvalidRefreshToken, + normalizeFailureDetail, + buildQuotaEmailFallbackState, + updateQuotaCacheForAccount, + cloneQuotaCacheData, + pruneUnsafeQuotaEmailCacheEntry, + formatCompactQuotaSnapshot, + resolveStoredAccountIdentity, + applyTokenAccountIdentity, +}; + export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { const startupDisplaySettings = await loadDashboardDisplaySettings(); applyUiThemeFromDashboardSettings(startupDisplaySettings); diff --git a/lib/codex-manager/repair-commands.ts b/lib/codex-manager/repair-commands.ts new file mode 100644 index 00000000..65cb343d --- /dev/null +++ b/lib/codex-manager/repair-commands.ts @@ -0,0 +1,2195 @@ +import { existsSync, promises as fs } from "node:fs"; +import { ACCOUNT_LIMITS } from "../constants.js"; +import { DEFAULT_DASHBOARD_DISPLAY_SETTINGS } from "../dashboard-settings.js"; +import { + evaluateForecastAccounts, + isHardRefreshFailure, + recommendForecastAccount, +} from "../forecast.js"; +import { + extractAccountEmail, + extractAccountId, + formatAccountLabel, + sanitizeEmail, +} from "../accounts.js"; +import { loadQuotaCache, saveQuotaCache, type QuotaCacheData } from "../quota-cache.js"; +import { fetchCodexQuotaSnapshot } from "../quota-probe.js"; +import { queuedRefresh } from "../refresh-queue.js"; +import { + findMatchingAccountIndex, + getStoragePath, + loadAccounts, + loadFlaggedAccounts, + setStoragePath, + withAccountStorageTransaction, + withAccountAndFlaggedStorageTransaction, + withFlaggedStorageTransaction, + type AccountMetadataV3, + type AccountStorageV3, + type FlaggedAccountMetadataV1, + type FlaggedAccountStorageV1, +} from "../storage.js"; +import { + getCodexCliAuthPath, + getCodexCliConfigPath, + loadCodexCliState, +} from "../codex-cli/state.js"; +import { setCodexCliActiveSelection } from "../codex-cli/writer.js"; +import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; +import type { AccountIdSource, TokenFailure, TokenResult } from "../types.js"; + +type TokenSuccess = Extract; +type PromptTone = "accent" | "success" | "warning" | "danger" | "muted"; + +type QuotaEmailFallbackState = { + matchingCount: number; + distinctAccountIds: Set; +}; + +type QuotaCacheAccountRef = Pick & { + email?: string; +}; + +type ParsedArgsResult = { ok: true; options: T } | { ok: false; message: string }; + +type AccountIdentityResolution = { + accountId?: string; + accountIdSource?: AccountIdSource; +}; + +export interface FixCliOptions { + dryRun: boolean; + json: boolean; + live: boolean; + model: string; +} + +export interface VerifyFlaggedCliOptions { + dryRun: boolean; + json: boolean; + restore: boolean; +} + +export interface DoctorCliOptions { + json: boolean; + fix: boolean; + dryRun: boolean; +} + +export interface RepairCommandDeps { + stylePromptText: (text: string, tone: PromptTone) => string; + styleAccountDetailText: (detail: string, fallbackTone?: PromptTone) => string; + formatResultSummary: ( + segments: ReadonlyArray<{ text: string; tone: PromptTone }>, + ) => string; + resolveActiveIndex: ( + storage: AccountStorageV3, + family?: ModelFamily, + ) => number; + hasUsableAccessToken: ( + account: AccountMetadataV3, + now: number, + ) => boolean; + hasLikelyInvalidRefreshToken: (refreshToken: string | undefined) => boolean; + normalizeFailureDetail: ( + message: string | undefined, + reason: string | undefined, + ) => string; + buildQuotaEmailFallbackState: ( + accounts: readonly QuotaCacheAccountRef[], + ) => ReadonlyMap; + updateQuotaCacheForAccount: ( + cache: QuotaCacheData, + account: QuotaCacheAccountRef, + snapshot: Awaited>, + accounts: readonly QuotaCacheAccountRef[], + emailFallbackState?: ReadonlyMap, + ) => boolean; + cloneQuotaCacheData: (cache: QuotaCacheData) => QuotaCacheData; + pruneUnsafeQuotaEmailCacheEntry: ( + cache: QuotaCacheData, + previousEmail: string | undefined, + accounts: readonly QuotaCacheAccountRef[], + emailFallbackState: ReadonlyMap, + ) => boolean; + formatCompactQuotaSnapshot: ( + snapshot: Awaited>, + ) => string; + resolveStoredAccountIdentity: ( + storedAccountId: string | undefined, + storedAccountIdSource: AccountIdSource | undefined, + refreshedAccountId: string | undefined, + ) => AccountIdentityResolution; + applyTokenAccountIdentity: ( + account: AccountMetadataV3, + refreshedAccountId: string | undefined, + ) => boolean; +} + +export function printFixUsage(): void { + console.log( + [ + "Usage:", + " codex auth fix [--dry-run] [--json] [--live] [--model ]", + "", + "Options:", + " --dry-run, -n Preview changes without writing storage", + " --json, -j Print machine-readable JSON output", + " --live, -l Run live session probe before deciding health", + " --model, -m Probe model for live mode (default: gpt-5-codex)", + "", + "Behavior:", + " - Refreshes tokens for enabled accounts", + " - Disables hard-failed accounts (never deletes)", + " - Recommends a better current account when needed", + ].join("\n"), + ); +} + +export function printVerifyFlaggedUsage(): void { + console.log( + [ + "Usage:", + " codex auth verify-flagged [--dry-run] [--json] [--no-restore]", + "", + "Options:", + " --dry-run, -n Preview changes without writing storage", + " --json, -j Print machine-readable JSON output", + " --no-restore Check flagged accounts without restoring healthy ones", + "", + "Behavior:", + " - Refresh-checks accounts from flagged storage", + " - Restores healthy accounts back to active storage by default", + ].join("\n"), + ); +} + +export function printDoctorUsage(): void { + console.log( + [ + "Usage:", + " codex auth doctor [--json] [--fix] [--dry-run]", + "", + "Options:", + " --json, -j Print machine-readable JSON diagnostics", + " --fix Apply safe auto-fixes to storage", + " --dry-run, -n Preview --fix changes without writing storage", + "", + "Behavior:", + " - Validates account storage readability", + " - Checks active index consistency and account duplication", + " - Flags placeholder/demo accounts and disabled-all scenarios", + ].join("\n"), + ); +} + +export function parseFixArgs(args: string[]): ParsedArgsResult { + const options: FixCliOptions = { + dryRun: false, + json: false, + live: false, + model: "gpt-5-codex", + }; + + for (let i = 0; i < args.length; i += 1) { + const argValue = args[i]; + if (typeof argValue !== "string") continue; + if (argValue === "--dry-run" || argValue === "-n") { + options.dryRun = true; + continue; + } + if (argValue === "--json" || argValue === "-j") { + options.json = true; + continue; + } + if (argValue === "--live" || argValue === "-l") { + options.live = true; + continue; + } + if (argValue === "--model" || argValue === "-m") { + const value = args[i + 1]; + if (!value) { + return { ok: false, message: "Missing value for --model" }; + } + options.model = value; + i += 1; + continue; + } + if (argValue.startsWith("--model=")) { + const value = argValue.slice("--model=".length).trim(); + if (!value) { + return { ok: false, message: "Missing value for --model" }; + } + options.model = value; + continue; + } + return { ok: false, message: `Unknown option: ${argValue}` }; + } + + return { ok: true, options }; +} + +export function parseVerifyFlaggedArgs( + args: string[], +): ParsedArgsResult { + const options: VerifyFlaggedCliOptions = { + dryRun: false, + json: false, + restore: true, + }; + + for (const arg of args) { + if (arg === "--dry-run" || arg === "-n") { + options.dryRun = true; + continue; + } + if (arg === "--json" || arg === "-j") { + options.json = true; + continue; + } + if (arg === "--no-restore") { + options.restore = false; + continue; + } + return { ok: false, message: `Unknown option: ${arg}` }; + } + + return { ok: true, options }; +} + +export function parseDoctorArgs( + args: string[], +): ParsedArgsResult { + const options: DoctorCliOptions = { json: false, fix: false, dryRun: false }; + for (const arg of args) { + if (arg === "--json" || arg === "-j") { + options.json = true; + continue; + } + if (arg === "--fix") { + options.fix = true; + continue; + } + if (arg === "--dry-run" || arg === "-n") { + options.dryRun = true; + continue; + } + return { ok: false, message: `Unknown option: ${arg}` }; + } + if (options.dryRun && !options.fix) { + return { ok: false, message: "--dry-run requires --fix" }; + } + return { ok: true, options }; +} + +type FixOutcome = + | "healthy" + | "disabled-hard-failure" + | "warning-soft-failure" + | "already-disabled"; + +interface FixAccountReport { + index: number; + label: string; + outcome: FixOutcome; + message: string; +} + +function summarizeFixReports( + reports: FixAccountReport[], +): { + healthy: number; + disabled: number; + warnings: number; + skipped: number; +} { + let healthy = 0; + let disabled = 0; + let warnings = 0; + let skipped = 0; + for (const report of reports) { + if (report.outcome === "healthy") healthy += 1; + else if (report.outcome === "disabled-hard-failure") disabled += 1; + else if (report.outcome === "warning-soft-failure") warnings += 1; + else skipped += 1; + } + return { healthy, disabled, warnings, skipped }; +} + +interface VerifyFlaggedReport { + index: number; + label: string; + outcome: "restored" | "healthy-flagged" | "still-flagged" | "restore-skipped"; + message: string; +} + +function createEmptyAccountStorage(): AccountStorageV3 { + const activeIndexByFamily: Partial> = {}; + for (const family of MODEL_FAMILIES) { + activeIndexByFamily[family] = 0; + } + return { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily, + }; +} + +type AccountStorageMutation = { + before: AccountMetadataV3; + after: AccountMetadataV3; +}; + +type FlaggedStorageMutation = { + index: number; + label: string; + before: FlaggedAccountMetadataV1; + after?: FlaggedAccountMetadataV1; +}; + +function hasAccountStorageMutation( + before: AccountMetadataV3, + after: AccountMetadataV3, +): boolean { + return ( + before.refreshToken !== after.refreshToken + || before.accessToken !== after.accessToken + || before.expiresAt !== after.expiresAt + || before.email !== after.email + || before.accountId !== after.accountId + || before.accountIdSource !== after.accountIdSource + || before.enabled !== after.enabled + ); +} + +function collectAccountStorageMutations( + beforeAccounts: readonly AccountMetadataV3[], + afterAccounts: readonly AccountMetadataV3[], +): AccountStorageMutation[] { + const mutations: AccountStorageMutation[] = []; + for (let i = 0; i < afterAccounts.length; i += 1) { + const before = beforeAccounts[i]; + const after = afterAccounts[i]; + if (!before || !after) continue; + if (!hasAccountStorageMutation(before, after)) continue; + mutations.push({ + before: structuredClone(before), + after: structuredClone(after), + }); + } + return mutations; +} + +function applyAccountStorageMutations( + storage: AccountStorageV3, + mutations: readonly AccountStorageMutation[], +): void { + for (const mutation of mutations) { + const targetIndex = + findMatchingAccountIndex(storage.accounts, mutation.before, { + allowUniqueAccountIdFallbackWithoutEmail: true, + }) + ?? findMatchingAccountIndex(storage.accounts, mutation.after, { + allowUniqueAccountIdFallbackWithoutEmail: true, + }); + if (targetIndex === undefined) continue; + const target = storage.accounts[targetIndex]; + if (!target) continue; + target.refreshToken = mutation.after.refreshToken; + target.accessToken = mutation.after.accessToken; + target.expiresAt = mutation.after.expiresAt; + target.email = mutation.after.email; + target.accountId = mutation.after.accountId; + target.accountIdSource = mutation.after.accountIdSource; + target.enabled = mutation.after.enabled; + } +} + +function findExistingAccountIndexForFlagged( + storage: AccountStorageV3, + flagged: FlaggedAccountMetadataV1, + nextRefreshToken: string, + nextAccountId: string | undefined, + nextEmail: string | undefined, +): number { + const flaggedEmail = sanitizeEmail(flagged.email); + const candidateAccountId = nextAccountId ?? flagged.accountId; + const candidateEmail = sanitizeEmail(nextEmail) ?? flaggedEmail; + const nextMatchIndex = findMatchingAccountIndex(storage.accounts, { + accountId: candidateAccountId, + email: candidateEmail, + refreshToken: nextRefreshToken, + }, { + allowUniqueAccountIdFallbackWithoutEmail: true, + }); + if (nextMatchIndex !== undefined) { + return nextMatchIndex; + } + + const flaggedMatchIndex = findMatchingAccountIndex(storage.accounts, { + accountId: candidateAccountId, + email: candidateEmail, + refreshToken: flagged.refreshToken, + }, { + allowUniqueAccountIdFallbackWithoutEmail: true, + }); + return flaggedMatchIndex ?? -1; +} + +function findMatchingFlaggedAccountIndex( + accounts: readonly FlaggedAccountMetadataV1[], + target: FlaggedAccountMetadataV1, +): number { + const targetEmail = sanitizeEmail(target.email); + return accounts.findIndex((account) => { + if (account.refreshToken === target.refreshToken) { + return true; + } + if (target.accountId && account.accountId === target.accountId) { + if (!targetEmail) { + return true; + } + return sanitizeEmail(account.email) === targetEmail; + } + return Boolean(targetEmail) && sanitizeEmail(account.email) === targetEmail; + }); +} + +function findFlaggedAccountIndexByStableIdentity( + accounts: readonly FlaggedAccountMetadataV1[], + target: FlaggedAccountMetadataV1, +): number { + const targetEmail = sanitizeEmail(target.email); + return accounts.findIndex((account) => { + if (target.accountId && account.accountId === target.accountId) { + if (!targetEmail) { + return true; + } + return sanitizeEmail(account.email) === targetEmail; + } + return Boolean(targetEmail) && sanitizeEmail(account.email) === targetEmail; + }); +} + +function hasFlaggedRefreshTokenDrift( + accounts: readonly FlaggedAccountMetadataV1[], + target: FlaggedAccountMetadataV1, +): boolean { + const targetIndex = findFlaggedAccountIndexByStableIdentity(accounts, target); + if (targetIndex < 0) { + return false; + } + const current = accounts[targetIndex]; + return current ? current.refreshToken !== target.refreshToken : false; +} + +function applyFlaggedStorageMutations( + flaggedStorage: FlaggedAccountStorageV1, + mutations: readonly FlaggedStorageMutation[], +): void { + for (const mutation of mutations) { + const targetIndex = findMatchingFlaggedAccountIndex( + flaggedStorage.accounts, + mutation.before, + ); + if (targetIndex < 0) { + continue; + } + if (mutation.after) { + flaggedStorage.accounts[targetIndex] = structuredClone(mutation.after); + continue; + } + flaggedStorage.accounts.splice(targetIndex, 1); + } +} + +function upsertRecoveredFlaggedAccount( + storage: AccountStorageV3, + flagged: FlaggedAccountMetadataV1, + refreshResult: TokenSuccess, + now: number, + deps: Pick, +): { restored: boolean; changed: boolean; message: string } { + const nextEmail = + sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)) + ?? flagged.email; + const tokenAccountId = extractAccountId(refreshResult.access); + const { accountId: nextAccountId, accountIdSource: nextAccountIdSource } = + deps.resolveStoredAccountIdentity( + flagged.accountId, + flagged.accountIdSource, + tokenAccountId, + ); + const existingIndex = findExistingAccountIndexForFlagged( + storage, + flagged, + refreshResult.refresh, + nextAccountId, + nextEmail, + ); + + if (existingIndex >= 0) { + const existing = storage.accounts[existingIndex]; + if (!existing) { + return { restored: false, changed: false, message: "existing account entry is missing" }; + } + let changed = false; + if (existing.refreshToken !== refreshResult.refresh) { + existing.refreshToken = refreshResult.refresh; + changed = true; + } + if (existing.accessToken !== refreshResult.access) { + existing.accessToken = refreshResult.access; + changed = true; + } + if (existing.expiresAt !== refreshResult.expires) { + existing.expiresAt = refreshResult.expires; + changed = true; + } + if (nextEmail && nextEmail !== existing.email) { + existing.email = nextEmail; + changed = true; + } + if ( + nextAccountId !== undefined + && ( + nextAccountId !== existing.accountId + || nextAccountIdSource !== existing.accountIdSource + ) + ) { + existing.accountId = nextAccountId; + existing.accountIdSource = nextAccountIdSource; + changed = true; + } + if (existing.enabled === false) { + existing.enabled = true; + changed = true; + } + if (existing.accountLabel !== flagged.accountLabel && flagged.accountLabel) { + existing.accountLabel = flagged.accountLabel; + changed = true; + } + existing.lastUsed = now; + return { + restored: true, + changed, + message: `restored into existing account ${existingIndex + 1}`, + }; + } + + if (storage.accounts.length >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { + return { + restored: false, + changed: false, + message: `cannot restore (max ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts reached)`, + }; + } + + storage.accounts.push({ + refreshToken: refreshResult.refresh, + accessToken: refreshResult.access, + expiresAt: refreshResult.expires, + accountId: nextAccountId, + accountIdSource: nextAccountIdSource, + accountLabel: flagged.accountLabel, + email: nextEmail, + addedAt: flagged.addedAt ?? now, + lastUsed: now, + enabled: true, + }); + return { + restored: true, + changed: true, + message: `restored as account ${storage.accounts.length}`, + }; +} + +type DoctorSeverity = "ok" | "warn" | "error"; + +interface DoctorCheck { + key: string; + severity: DoctorSeverity; + message: string; + details?: string; +} + +interface DoctorFixAction { + key: string; + message: string; +} + +type DoctorRefreshMutation = { + match: AccountMetadataV3; + accessToken: string; + refreshToken: string; + expiresAt: number; + email?: string; + accountId?: string; +}; + +function maskDoctorEmail(value: string | undefined): string | undefined { + if (!value) return undefined; + const email = value.trim(); + const atIndex = email.indexOf("@"); + if (atIndex < 0) return "***@***"; + const local = email.slice(0, atIndex); + const domain = email.slice(atIndex + 1); + const parts = domain.split("."); + const tld = parts.pop() || ""; + const prefix = local.slice(0, Math.min(2, local.length)); + return `${prefix}***@***.${tld}`; +} + +function redactDoctorIdentifier(value: string | undefined): string | undefined { + if (!value) return undefined; + const identifier = value.trim(); + if (!identifier) return undefined; + if (identifier.includes("@")) { + return maskDoctorEmail(identifier); + } + if (identifier.length <= 8) { + return "***"; + } + return `${identifier.slice(0, 4)}***${identifier.slice(-3)}`; +} + +function formatDoctorIdentitySummary(identity: { + email?: string; + accountId?: string; +}): string { + const parts: string[] = []; + const maskedEmail = maskDoctorEmail(identity.email); + const maskedAccountId = redactDoctorIdentifier(identity.accountId); + if (maskedEmail) { + parts.push(`email=${maskedEmail}`); + } + if (maskedAccountId) { + parts.push(`accountId=${maskedAccountId}`); + } + return parts.join(", ") || "unknown"; +} + +function hasPlaceholderEmail(value: string | undefined): boolean { + if (!value) return false; + const email = value.trim().toLowerCase(); + if (!email) return false; + return email.endsWith("@example.com"); +} + +function normalizeDoctorIndexes(storage: AccountStorageV3): boolean { + const total = storage.accounts.length; + const nextActive = total === 0 ? 0 : Math.max(0, Math.min(storage.activeIndex, total - 1)); + let changed = false; + if (storage.activeIndex !== nextActive) { + storage.activeIndex = nextActive; + changed = true; + } + storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + const raw = storage.activeIndexByFamily[family]; + const fallback = storage.activeIndex; + const candidate = typeof raw === "number" && Number.isFinite(raw) ? raw : fallback; + const clamped = total === 0 ? 0 : Math.max(0, Math.min(candidate, total - 1)); + if (storage.activeIndexByFamily[family] !== clamped) { + storage.activeIndexByFamily[family] = clamped; + changed = true; + } + } + return changed; +} + +function getDoctorRefreshTokenKey(refreshToken: unknown): string | undefined { + if (typeof refreshToken !== "string") return undefined; + const trimmed = refreshToken.trim(); + return trimmed || undefined; +} + +function applyDoctorFixes( + storage: AccountStorageV3, + deps: Pick, +): { changed: boolean; actions: DoctorFixAction[] } { + let changed = false; + const actions: DoctorFixAction[] = []; + const recordActiveIndexAction = () => { + if (actions.some((action) => action.key === "active-index")) return; + actions.push({ + key: "active-index", + message: "Normalized active account indexes", + }); + }; + + if (normalizeDoctorIndexes(storage)) { + changed = true; + recordActiveIndexAction(); + } + + const seenRefreshTokens = new Map(); + for (let i = 0; i < storage.accounts.length; i += 1) { + const account = storage.accounts[i]; + if (!account) continue; + + const refreshToken = getDoctorRefreshTokenKey(account.refreshToken); + if (!refreshToken) continue; + const existingTokenIndex = seenRefreshTokens.get(refreshToken); + if (typeof existingTokenIndex === "number") { + if (account.enabled !== false) { + account.enabled = false; + changed = true; + actions.push({ + key: "duplicate-refresh-token", + message: `Disabled duplicate token entry on account ${i + 1} (kept account ${existingTokenIndex + 1})`, + }); + } + } else { + seenRefreshTokens.set(refreshToken, i); + } + + const tokenEmail = sanitizeEmail(extractAccountEmail(account.accessToken)); + if (tokenEmail && (!sanitizeEmail(account.email) || hasPlaceholderEmail(account.email))) { + account.email = tokenEmail; + changed = true; + actions.push({ + key: "email-from-token", + message: `Updated account ${i + 1} email from token claims`, + }); + } + + const tokenAccountId = extractAccountId(account.accessToken); + if (!account.accountId && tokenAccountId) { + account.accountId = tokenAccountId; + account.accountIdSource = "token"; + changed = true; + actions.push({ + key: "account-id-from-token", + message: `Filled missing accountId for account ${i + 1}`, + }); + } + } + + const enabledCount = storage.accounts.filter((account) => account.enabled !== false).length; + if (storage.accounts.length > 0 && enabledCount === 0) { + const index = deps.resolveActiveIndex(storage, "codex"); + const candidate = storage.accounts[index] ?? storage.accounts[0]; + if (candidate) { + candidate.enabled = true; + changed = true; + actions.push({ + key: "enabled-accounts", + message: `Re-enabled account ${index + 1} to avoid an all-disabled pool`, + }); + } + } + + if (normalizeDoctorIndexes(storage)) { + changed = true; + recordActiveIndexAction(); + } + + return { changed, actions }; +} + +export async function runVerifyFlagged( + args: string[], + deps: RepairCommandDeps, +): Promise { + if (args.includes("--help") || args.includes("-h")) { + printVerifyFlaggedUsage(); + return 0; + } + + const parsedArgs = parseVerifyFlaggedArgs(args); + if (!parsedArgs.ok) { + console.error(parsedArgs.message); + printVerifyFlaggedUsage(); + return 1; + } + const options = parsedArgs.options; + + setStoragePath(null); + const flaggedStorage = await loadFlaggedAccounts(); + if (flaggedStorage.accounts.length === 0) { + if (options.json) { + console.log( + JSON.stringify( + { + command: "verify-flagged", + total: 0, + restored: 0, + healthyFlagged: 0, + stillFlagged: 0, + remainingFlagged: 0, + changed: false, + dryRun: options.dryRun, + restore: options.restore, + reports: [] as VerifyFlaggedReport[], + }, + null, + 2, + ), + ); + return 0; + } + console.log("No flagged accounts to check."); + return 0; + } + + let storageChanged = false; + let flaggedChanged = false; + const reports: VerifyFlaggedReport[] = []; + const nextFlaggedAccounts: FlaggedAccountMetadataV1[] = []; + const flaggedMutations: FlaggedStorageMutation[] = []; + const now = Date.now(); + const collectRefreshChecks = async ( + accounts: FlaggedAccountMetadataV1[], + ): Promise< + Array<{ + index: number; + flagged: FlaggedAccountMetadataV1; + label: string; + result: Awaited>; + }> + > => { + const refreshChecks: Array<{ + index: number; + flagged: FlaggedAccountMetadataV1; + label: string; + result: Awaited>; + }> = []; + for (let i = 0; i < accounts.length; i += 1) { + const flagged = accounts[i]; + if (!flagged) continue; + refreshChecks.push({ + index: i, + flagged, + label: formatAccountLabel(flagged, i), + result: await queuedRefresh(flagged.refreshToken), + }); + } + return refreshChecks; + }; + const applyRefreshChecks = ( + storage: AccountStorageV3, + refreshChecks: Array<{ + index: number; + flagged: FlaggedAccountMetadataV1; + label: string; + result: Awaited>; + }>, + ): void => { + for (const check of refreshChecks) { + const { index: i, flagged, label, result } = check; + if (result.type === "success") { + if (!options.restore) { + const tokenAccountId = extractAccountId(result.access); + const nextIdentity = deps.resolveStoredAccountIdentity( + flagged.accountId, + flagged.accountIdSource, + tokenAccountId, + ); + const nextFlagged: FlaggedAccountMetadataV1 = { + ...flagged, + refreshToken: result.refresh, + accessToken: result.access, + expiresAt: result.expires, + accountId: nextIdentity.accountId, + accountIdSource: nextIdentity.accountIdSource, + email: + sanitizeEmail(extractAccountEmail(result.access, result.idToken)) + ?? flagged.email, + lastUsed: now, + lastError: undefined, + }; + nextFlaggedAccounts.push(nextFlagged); + if (JSON.stringify(nextFlagged) !== JSON.stringify(flagged)) { + flaggedChanged = true; + } + flaggedMutations.push({ + index: i, + label, + before: flagged, + after: nextFlagged, + }); + reports.push({ + index: i, + label, + outcome: "healthy-flagged", + message: "session is healthy (left in flagged list due to --no-restore)", + }); + continue; + } + + const upsertResult = upsertRecoveredFlaggedAccount( + storage, + flagged, + result, + now, + deps, + ); + if (upsertResult.restored) { + storageChanged = storageChanged || upsertResult.changed; + flaggedChanged = true; + flaggedMutations.push({ + index: i, + label, + before: flagged, + }); + reports.push({ + index: i, + label, + outcome: "restored", + message: upsertResult.message, + }); + continue; + } + + const tokenAccountId = extractAccountId(result.access); + const nextIdentity = deps.resolveStoredAccountIdentity( + flagged.accountId, + flagged.accountIdSource, + tokenAccountId, + ); + const updatedFlagged: FlaggedAccountMetadataV1 = { + ...flagged, + refreshToken: result.refresh, + accessToken: result.access, + expiresAt: result.expires, + accountId: nextIdentity.accountId, + accountIdSource: nextIdentity.accountIdSource, + email: + sanitizeEmail(extractAccountEmail(result.access, result.idToken)) + ?? flagged.email, + lastUsed: now, + lastError: upsertResult.message, + }; + nextFlaggedAccounts.push(updatedFlagged); + if (JSON.stringify(updatedFlagged) !== JSON.stringify(flagged)) { + flaggedChanged = true; + } + flaggedMutations.push({ + index: i, + label, + before: flagged, + after: updatedFlagged, + }); + reports.push({ + index: i, + label, + outcome: "restore-skipped", + message: upsertResult.message, + }); + continue; + } + + const detail = deps.normalizeFailureDetail(result.message, result.reason); + const failedFlagged: FlaggedAccountMetadataV1 = { + ...flagged, + lastError: detail, + }; + nextFlaggedAccounts.push(failedFlagged); + if ((flagged.lastError ?? "") !== detail) { + flaggedChanged = true; + } + flaggedMutations.push({ + index: i, + label, + before: flagged, + after: failedFlagged, + }); + reports.push({ + index: i, + label, + outcome: "still-flagged", + message: detail, + }); + } + }; + + let remainingFlagged = 0; + const refreshChecks = await collectRefreshChecks(flaggedStorage.accounts); + + if (options.restore) { + if (options.dryRun) { + applyRefreshChecks( + (await loadAccounts()) ?? createEmptyAccountStorage(), + refreshChecks, + ); + } else { + await withAccountAndFlaggedStorageTransaction( + async (loadedStorage, persist, loadedFlaggedStorage) => { + const nextStorage = loadedStorage + ? structuredClone(loadedStorage) + : createEmptyAccountStorage(); + const nextFlaggedStorage = structuredClone(loadedFlaggedStorage); + const staleRefreshChecks = refreshChecks.filter((check) => + hasFlaggedRefreshTokenDrift(nextFlaggedStorage.accounts, check.flagged), + ); + const safeRefreshChecks = refreshChecks.filter( + (check) => + !hasFlaggedRefreshTokenDrift(nextFlaggedStorage.accounts, check.flagged), + ); + applyRefreshChecks(nextStorage, safeRefreshChecks); + for (const check of staleRefreshChecks) { + reports.push({ + index: check.index, + label: check.label, + outcome: "restore-skipped", + message: + "Skipped restore because flagged refresh token changed before persistence", + }); + } + applyFlaggedStorageMutations(nextFlaggedStorage, flaggedMutations); + remainingFlagged = nextFlaggedStorage.accounts.length; + if (!storageChanged && !flaggedChanged) { + return; + } + if (storageChanged) { + normalizeDoctorIndexes(nextStorage); + } + await persist(nextStorage, nextFlaggedStorage); + }, + ); + } + } else { + applyRefreshChecks(createEmptyAccountStorage(), refreshChecks); + remainingFlagged = nextFlaggedAccounts.length; + } + + if (options.dryRun) { + remainingFlagged = nextFlaggedAccounts.length; + } + + if (!options.dryRun && !options.restore && flaggedChanged) { + await withFlaggedStorageTransaction(async (loadedFlaggedStorage, persist) => { + const nextFlaggedStorage = structuredClone(loadedFlaggedStorage); + const staleFlaggedMutations = flaggedMutations.filter((mutation) => + hasFlaggedRefreshTokenDrift(nextFlaggedStorage.accounts, mutation.before), + ); + const safeFlaggedMutations = flaggedMutations.filter( + (mutation) => + !hasFlaggedRefreshTokenDrift(nextFlaggedStorage.accounts, mutation.before), + ); + for (const mutation of staleFlaggedMutations) { + const staleReport = reports.find( + (report) => + report.index === mutation.index && report.label === mutation.label, + ); + if (staleReport) { + staleReport.outcome = "restore-skipped"; + staleReport.message = + "Skipped flagged update because refresh token changed before persistence"; + continue; + } + reports.push({ + index: mutation.index, + label: mutation.label, + outcome: "restore-skipped", + message: + "Skipped flagged update because refresh token changed before persistence", + }); + } + applyFlaggedStorageMutations(nextFlaggedStorage, safeFlaggedMutations); + remainingFlagged = nextFlaggedStorage.accounts.length; + if (safeFlaggedMutations.length === 0) { + flaggedChanged = false; + return; + } + await persist(nextFlaggedStorage); + }); + } + + const restored = reports.filter((report) => report.outcome === "restored").length; + const healthyFlagged = reports.filter( + (report) => report.outcome === "healthy-flagged", + ).length; + const stillFlagged = reports.filter( + (report) => report.outcome === "still-flagged", + ).length; + const changed = storageChanged || flaggedChanged; + + if (options.json) { + console.log( + JSON.stringify( + { + command: "verify-flagged", + total: flaggedStorage.accounts.length, + restored, + healthyFlagged, + stillFlagged, + remainingFlagged, + changed, + dryRun: options.dryRun, + restore: options.restore, + reports, + }, + null, + 2, + ), + ); + return 0; + } + + console.log( + deps.stylePromptText( + `Checking ${flaggedStorage.accounts.length} flagged account(s)...`, + "accent", + ), + ); + for (const report of reports) { + const tone: PromptTone = + report.outcome === "restored" + ? "success" + : report.outcome === "healthy-flagged" || report.outcome === "restore-skipped" + ? "warning" + : "danger"; + const marker = + report.outcome === "restored" + ? "✓" + : report.outcome === "healthy-flagged" || report.outcome === "restore-skipped" + ? "!" + : "✗"; + console.log( + `${deps.stylePromptText(marker, tone)} ${deps.stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${deps.stylePromptText("|", "muted")} ${deps.styleAccountDetailText(report.message, tone)}`, + ); + } + console.log(""); + console.log( + deps.formatResultSummary([ + { text: `${restored} restored`, tone: restored > 0 ? "success" : "muted" }, + { + text: `${healthyFlagged} healthy (kept flagged)`, + tone: healthyFlagged > 0 ? "warning" : "muted", + }, + { + text: `${stillFlagged} still flagged`, + tone: stillFlagged > 0 ? "danger" : "muted", + }, + ]), + ); + if (options.dryRun) { + console.log(deps.stylePromptText("Preview only: no changes were saved.", "warning")); + } else if (!changed) { + console.log(deps.stylePromptText("No storage changes were needed.", "muted")); + } + + return 0; +} + +export async function runFix( + args: string[], + deps: RepairCommandDeps, +): Promise { + if (args.includes("--help") || args.includes("-h")) { + printFixUsage(); + return 0; + } + + const parsedArgs = parseFixArgs(args); + if (!parsedArgs.ok) { + console.error(parsedArgs.message); + printFixUsage(); + return 1; + } + const options = parsedArgs.options; + const display = DEFAULT_DASHBOARD_DISPLAY_SETTINGS; + const quotaCache = options.live ? await loadQuotaCache() : null; + const workingQuotaCache = quotaCache ? deps.cloneQuotaCacheData(quotaCache) : null; + let quotaCacheChanged = false; + + setStoragePath(null); + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + if (options.json) { + console.log( + JSON.stringify( + { + command: "fix", + dryRun: options.dryRun, + liveProbe: options.live, + model: options.model, + changed: false, + summary: { + healthy: 0, + disabled: 0, + warnings: 0, + skipped: 0, + }, + recommendation: { + recommendedIndex: null, + reason: "No accounts configured.", + }, + recommendedSwitchCommand: null, + reports: [] as FixAccountReport[], + }, + null, + 2, + ), + ); + } else { + console.log("No accounts configured."); + } + return 0; + } + const originalAccounts = storage.accounts.map((account) => structuredClone(account)); + let quotaEmailFallbackState = + options.live && quotaCache + ? deps.buildQuotaEmailFallbackState(storage.accounts) + : null; + + const now = Date.now(); + const activeIndex = deps.resolveActiveIndex(storage, "codex"); + let accountStorageChanged = false; + const reports: FixAccountReport[] = []; + const refreshFailures = new Map(); + const hardDisabledIndexes: number[] = []; + + for (let i = 0; i < storage.accounts.length; i += 1) { + const account = storage.accounts[i]; + if (!account) continue; + const label = formatAccountLabel(account, i); + + if (account.enabled === false) { + reports.push({ + index: i, + label, + outcome: "already-disabled", + message: "already disabled", + }); + continue; + } + + if (deps.hasUsableAccessToken(account, now)) { + let refreshAfterLiveProbeFailure = false; + if (options.live) { + const currentAccessToken = account.accessToken; + const probeAccountId = currentAccessToken + ? account.accountId ?? extractAccountId(currentAccessToken) + : undefined; + if (probeAccountId && currentAccessToken) { + try { + const snapshot = await fetchCodexQuotaSnapshot({ + accountId: probeAccountId, + accessToken: currentAccessToken, + model: options.model, + }); + if (workingQuotaCache) { + quotaCacheChanged = + deps.updateQuotaCacheForAccount( + workingQuotaCache, + account, + snapshot, + storage.accounts, + quotaEmailFallbackState ?? undefined, + ) || quotaCacheChanged; + } + reports.push({ + index: i, + label, + outcome: "healthy", + message: display.showQuotaDetails + ? `live session OK (${deps.formatCompactQuotaSnapshot(snapshot)})` + : "live session OK", + }); + continue; + } catch { + refreshAfterLiveProbeFailure = true; + } + } + } + + if (!refreshAfterLiveProbeFailure) { + const refreshWarning = deps.hasLikelyInvalidRefreshToken(account.refreshToken) + ? " (refresh token looks stale; re-login recommended)" + : ""; + reports.push({ + index: i, + label, + outcome: "healthy", + message: `access token still valid${refreshWarning}`, + }); + continue; + } + } + + const refreshResult = await queuedRefresh(account.refreshToken); + if (refreshResult.type === "success") { + const nextEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); + const nextAccountId = extractAccountId(refreshResult.access); + const previousEmail = account.email; + let accountChanged = false; + let accountIdentityChanged = false; + + if (account.refreshToken !== refreshResult.refresh) { + account.refreshToken = refreshResult.refresh; + accountChanged = true; + } + if (account.accessToken !== refreshResult.access) { + account.accessToken = refreshResult.access; + accountChanged = true; + } + if (account.expiresAt !== refreshResult.expires) { + account.expiresAt = refreshResult.expires; + accountChanged = true; + } + if (nextEmail && nextEmail !== account.email) { + account.email = nextEmail; + accountChanged = true; + accountIdentityChanged = true; + } + if (deps.applyTokenAccountIdentity(account, nextAccountId)) { + accountChanged = true; + accountIdentityChanged = true; + } + + if (accountChanged) accountStorageChanged = true; + if (accountIdentityChanged && options.live && workingQuotaCache) { + quotaEmailFallbackState = deps.buildQuotaEmailFallbackState(storage.accounts); + quotaCacheChanged = + deps.pruneUnsafeQuotaEmailCacheEntry( + workingQuotaCache, + previousEmail, + storage.accounts, + quotaEmailFallbackState, + ) || quotaCacheChanged; + } + if (options.live) { + const probeAccountId = account.accountId ?? nextAccountId; + if (probeAccountId) { + try { + const snapshot = await fetchCodexQuotaSnapshot({ + accountId: probeAccountId, + accessToken: refreshResult.access, + model: options.model, + }); + if (workingQuotaCache) { + quotaCacheChanged = + deps.updateQuotaCacheForAccount( + workingQuotaCache, + account, + snapshot, + storage.accounts, + quotaEmailFallbackState ?? undefined, + ) || quotaCacheChanged; + } + reports.push({ + index: i, + label, + outcome: "healthy", + message: display.showQuotaDetails + ? `refresh + live probe succeeded (${deps.formatCompactQuotaSnapshot(snapshot)})` + : "refresh + live probe succeeded", + }); + continue; + } catch (error) { + const message = deps.normalizeFailureDetail( + error instanceof Error ? error.message : String(error), + undefined, + ); + reports.push({ + index: i, + label, + outcome: "warning-soft-failure", + message: `refresh succeeded but live probe failed: ${message}`, + }); + continue; + } + } + } + reports.push({ + index: i, + label, + outcome: "healthy", + message: "refresh succeeded", + }); + continue; + } + + const detail = deps.normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ); + refreshFailures.set(i, { + ...refreshResult, + message: detail, + }); + if (isHardRefreshFailure(refreshResult)) { + account.enabled = false; + accountStorageChanged = true; + hardDisabledIndexes.push(i); + reports.push({ + index: i, + label, + outcome: "disabled-hard-failure", + message: detail, + }); + } else { + reports.push({ + index: i, + label, + outcome: "warning-soft-failure", + message: detail, + }); + } + } + + if (hardDisabledIndexes.length > 0) { + const enabledCount = storage.accounts.filter( + (account) => account.enabled !== false, + ).length; + if (enabledCount === 0) { + const fallbackIndex = hardDisabledIndexes.includes(activeIndex) + ? activeIndex + : hardDisabledIndexes[0]; + const fallback = typeof fallbackIndex === "number" + ? storage.accounts[fallbackIndex] + : undefined; + if (fallback && fallback.enabled === false) { + fallback.enabled = true; + accountStorageChanged = true; + const existingReport = reports.find( + (report) => + report.index === fallbackIndex + && report.outcome === "disabled-hard-failure", + ); + if (existingReport) { + existingReport.outcome = "warning-soft-failure"; + existingReport.message = + `${existingReport.message} (kept enabled to avoid lockout; re-login required)`; + } + } + } + } + + const forecastResults = evaluateForecastAccounts( + storage.accounts.map((account, index) => ({ + index, + account, + isCurrent: index === activeIndex, + now, + refreshFailure: refreshFailures.get(index), + })), + ); + const recommendation = recommendForecastAccount(forecastResults); + const reportSummary = summarizeFixReports(reports); + const accountMutations = collectAccountStorageMutations( + originalAccounts, + storage.accounts, + ); + + if (accountStorageChanged && !options.dryRun) { + await withAccountStorageTransaction(async (loadedStorage, persist) => { + const nextStorage = loadedStorage + ? structuredClone(loadedStorage) + : createEmptyAccountStorage(); + applyAccountStorageMutations(nextStorage, accountMutations); + await persist(nextStorage); + }); + } + + const changed = accountStorageChanged; + const anyChanged = accountStorageChanged || quotaCacheChanged; + + if (options.json) { + if (!options.dryRun && workingQuotaCache && quotaCacheChanged) { + await saveQuotaCache(workingQuotaCache); + } + console.log( + JSON.stringify( + { + command: "fix", + dryRun: options.dryRun, + liveProbe: options.live, + model: options.model, + changed, + quotaCacheChanged, + summary: reportSummary, + recommendation, + recommendedSwitchCommand: + recommendation.recommendedIndex !== null + && recommendation.recommendedIndex !== activeIndex + ? `codex auth switch ${recommendation.recommendedIndex + 1}` + : null, + reports, + }, + null, + 2, + ), + ); + return 0; + } + + console.log( + deps.stylePromptText( + `Auto-fix scan (${options.dryRun ? "preview" : "apply"})`, + "accent", + ), + ); + console.log( + deps.formatResultSummary([ + { text: `${reportSummary.healthy} working`, tone: "success" }, + { + text: `${reportSummary.disabled} disabled`, + tone: reportSummary.disabled > 0 ? "danger" : "muted", + }, + { + text: `${reportSummary.warnings} warning${reportSummary.warnings === 1 ? "" : "s"}`, + tone: reportSummary.warnings > 0 ? "warning" : "muted", + }, + { text: `${reportSummary.skipped} already disabled`, tone: "muted" }, + ]), + ); + if (display.showPerAccountRows) { + console.log(""); + for (const report of reports) { + const prefix = + report.outcome === "healthy" + ? "✓" + : report.outcome === "disabled-hard-failure" + ? "✗" + : report.outcome === "warning-soft-failure" + ? "!" + : "-"; + const tone: PromptTone = + report.outcome === "healthy" + ? "success" + : report.outcome === "disabled-hard-failure" + ? "danger" + : report.outcome === "warning-soft-failure" + ? "warning" + : "muted"; + console.log( + `${deps.stylePromptText(prefix, tone)} ${deps.stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${deps.stylePromptText("|", "muted")} ${deps.styleAccountDetailText(report.message, tone === "success" ? "muted" : tone)}`, + ); + } + } else { + console.log(""); + console.log( + deps.stylePromptText( + "Per-account lines are hidden in dashboard settings.", + "muted", + ), + ); + } + + if (display.showRecommendations) { + console.log(""); + if (recommendation.recommendedIndex !== null) { + const target = recommendation.recommendedIndex + 1; + console.log( + `${deps.stylePromptText("Best next account:", "accent")} ${deps.stylePromptText(String(target), "success")}`, + ); + console.log( + `${deps.stylePromptText("Why:", "accent")} ${deps.stylePromptText(recommendation.reason, "muted")}`, + ); + if (recommendation.recommendedIndex !== activeIndex) { + console.log( + `${deps.stylePromptText("Switch now with:", "accent")} codex auth switch ${target}`, + ); + } + } else { + console.log( + `${deps.stylePromptText("Note:", "accent")} ${deps.stylePromptText(recommendation.reason, "muted")}`, + ); + } + } + if (!options.dryRun && workingQuotaCache && quotaCacheChanged) { + await saveQuotaCache(workingQuotaCache); + } + + if (accountStorageChanged && options.dryRun) { + console.log(`\n${deps.stylePromptText("Preview only: no changes were saved.", "warning")}`); + } else if (accountStorageChanged) { + console.log(`\n${deps.stylePromptText("Saved updates.", "success")}`); + } else if (quotaCacheChanged) { + console.log( + `\n${deps.stylePromptText("Quota cache refreshed (no account storage changes).", "muted")}`, + ); + } else { + console.log(`\n${deps.stylePromptText("No changes were needed.", "muted")}`); + } + + return 0; +} + +export async function runDoctor( + args: string[], + deps: RepairCommandDeps, +): Promise { + if (args.includes("--help") || args.includes("-h")) { + printDoctorUsage(); + return 0; + } + + const parsedArgs = parseDoctorArgs(args); + if (!parsedArgs.ok) { + console.error(parsedArgs.message); + printDoctorUsage(); + return 1; + } + const options = parsedArgs.options; + + setStoragePath(null); + const storagePath = getStoragePath(); + const storageFileExists = existsSync(storagePath); + const checks: DoctorCheck[] = []; + const addCheck = (check: DoctorCheck): void => { + checks.push(check); + }; + + addCheck({ + key: "storage-file", + severity: storageFileExists ? "ok" : "warn", + message: storageFileExists + ? "Account storage file found" + : "Account storage file does not exist yet (first login pending)", + details: storagePath, + }); + + if (storageFileExists) { + try { + const stat = await fs.stat(storagePath); + addCheck({ + key: "storage-readable", + severity: stat.size > 0 ? "ok" : "warn", + message: stat.size > 0 ? "Storage file is readable" : "Storage file is empty", + details: `${stat.size} bytes`, + }); + } catch (error) { + addCheck({ + key: "storage-readable", + severity: "error", + message: "Unable to read storage file metadata", + details: error instanceof Error ? error.message : String(error), + }); + } + } + + const codexAuthPath = getCodexCliAuthPath(); + const codexConfigPath = getCodexCliConfigPath(); + const codexAuthFileExists = existsSync(codexAuthPath); + const codexConfigFileExists = existsSync(codexConfigPath); + let codexAuthEmail: string | undefined; + let codexAuthAccountId: string | undefined; + + addCheck({ + key: "codex-auth-file", + severity: codexAuthFileExists ? "ok" : "warn", + message: codexAuthFileExists + ? "Codex auth file found" + : "Codex auth file does not exist", + details: codexAuthPath, + }); + + if (codexAuthFileExists) { + try { + const raw = await fs.readFile(codexAuthPath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object") { + const payload = parsed as Record; + const tokens = payload.tokens && typeof payload.tokens === "object" + ? payload.tokens as Record + : null; + const accessToken = tokens && typeof tokens.access_token === "string" + ? tokens.access_token + : undefined; + const idToken = tokens && typeof tokens.id_token === "string" + ? tokens.id_token + : undefined; + const accountIdFromFile = + tokens && typeof tokens.account_id === "string" + ? tokens.account_id + : undefined; + const emailFromFile = + typeof payload.email === "string" ? payload.email : undefined; + codexAuthEmail = sanitizeEmail( + emailFromFile ?? extractAccountEmail(accessToken, idToken), + ); + codexAuthAccountId = accountIdFromFile ?? extractAccountId(accessToken); + } + addCheck({ + key: "codex-auth-readable", + severity: "ok", + message: "Codex auth file is readable", + details: + codexAuthEmail || codexAuthAccountId + ? formatDoctorIdentitySummary({ + email: codexAuthEmail, + accountId: codexAuthAccountId, + }) + : undefined, + }); + } catch (error) { + addCheck({ + key: "codex-auth-readable", + severity: "error", + message: "Unable to read Codex auth file", + details: error instanceof Error ? error.message : String(error), + }); + } + } + + addCheck({ + key: "codex-config-file", + severity: codexConfigFileExists ? "ok" : "warn", + message: codexConfigFileExists + ? "Codex config file found" + : "Codex config file does not exist", + details: codexConfigPath, + }); + + let codexAuthStoreMode: string | undefined; + if (codexConfigFileExists) { + try { + const configRaw = await fs.readFile(codexConfigPath, "utf-8"); + const match = configRaw.match(/^\s*cli_auth_credentials_store\s*=\s*"([^"]+)"\s*$/m); + if (match?.[1]) { + codexAuthStoreMode = match[1].trim(); + } + } catch (error) { + addCheck({ + key: "codex-auth-store", + severity: "warn", + message: "Unable to read Codex auth-store config", + details: error instanceof Error ? error.message : String(error), + }); + } + } + if (!checks.some((check) => check.key === "codex-auth-store")) { + addCheck({ + key: "codex-auth-store", + severity: codexAuthStoreMode === "file" ? "ok" : "warn", + message: + codexAuthStoreMode === "file" + ? "Codex auth storage is set to file" + : "Codex auth storage is not explicitly set to file", + details: codexAuthStoreMode ? `mode=${codexAuthStoreMode}` : "mode=unset", + }); + } + + const codexCliState = await loadCodexCliState({ forceRefresh: true }); + addCheck({ + key: "codex-cli-state", + severity: codexCliState ? "ok" : "warn", + message: codexCliState + ? "Codex CLI state loaded" + : "Codex CLI state unavailable", + details: codexCliState?.path, + }); + + const loadedStorage = await loadAccounts(); + const storage = loadedStorage ? structuredClone(loadedStorage) : loadedStorage; + const storageForChecks = + options.fix && storage ? structuredClone(storage) : storage; + let fixChanged = false; + let storageFixChanged = false; + let structuralFixActions: DoctorFixAction[] = []; + const supplementalFixActions: DoctorFixAction[] = []; + let doctorRefreshMutation: DoctorRefreshMutation | null = null; + let pendingCodexActiveSync: { + accountId: string | undefined; + email: string | undefined; + accessToken: string | undefined; + refreshToken: string | undefined; + expiresAt: number | undefined; + idToken?: string; + } | null = null; + if (options.fix && storageForChecks && storageForChecks.accounts.length > 0) { + const fixed = applyDoctorFixes(storageForChecks, deps); + storageFixChanged = fixed.changed; + structuralFixActions = fixed.actions; + } + if (!storageForChecks || storageForChecks.accounts.length === 0) { + addCheck({ + key: "accounts", + severity: "warn", + message: "No accounts configured", + }); + } else { + addCheck({ + key: "accounts", + severity: "ok", + message: `Loaded ${storageForChecks.accounts.length} account(s)`, + }); + + const activeIndex = deps.resolveActiveIndex(storageForChecks, "codex"); + const activeExists = + activeIndex >= 0 && activeIndex < storageForChecks.accounts.length; + addCheck({ + key: "active-index", + severity: activeExists ? "ok" : "error", + message: activeExists + ? `Active index is valid (${activeIndex + 1})` + : "Active index is out of range", + }); + + const disabledCount = storageForChecks.accounts.filter( + (a) => a.enabled === false, + ).length; + addCheck({ + key: "enabled-accounts", + severity: + disabledCount >= storageForChecks.accounts.length ? "error" : "ok", + message: + disabledCount >= storageForChecks.accounts.length + ? "All accounts are disabled" + : `${storageForChecks.accounts.length - disabledCount} enabled / ${disabledCount} disabled`, + }); + + const seenRefreshTokens = new Set(); + let duplicateTokenCount = 0; + for (const account of storageForChecks.accounts) { + const token = getDoctorRefreshTokenKey(account.refreshToken); + if (!token) continue; + if (seenRefreshTokens.has(token)) { + duplicateTokenCount += 1; + } else { + seenRefreshTokens.add(token); + } + } + addCheck({ + key: "duplicate-refresh-token", + severity: duplicateTokenCount > 0 ? "warn" : "ok", + message: + duplicateTokenCount > 0 + ? `Detected ${duplicateTokenCount} duplicate refresh token entr${duplicateTokenCount === 1 ? "y" : "ies"}` + : "No duplicate refresh tokens detected", + }); + + const seenEmails = new Set(); + let duplicateEmailCount = 0; + let placeholderEmailCount = 0; + let likelyInvalidRefreshTokenCount = 0; + for (const account of storageForChecks.accounts) { + if (deps.hasLikelyInvalidRefreshToken(account.refreshToken)) { + likelyInvalidRefreshTokenCount += 1; + } + const email = sanitizeEmail(account.email); + if (!email) continue; + if (seenEmails.has(email)) duplicateEmailCount += 1; + seenEmails.add(email); + if (hasPlaceholderEmail(email)) placeholderEmailCount += 1; + } + addCheck({ + key: "duplicate-email", + severity: duplicateEmailCount > 0 ? "warn" : "ok", + message: + duplicateEmailCount > 0 + ? `Detected ${duplicateEmailCount} duplicate email entr${duplicateEmailCount === 1 ? "y" : "ies"}` + : "No duplicate emails detected", + }); + addCheck({ + key: "placeholder-email", + severity: placeholderEmailCount > 0 ? "warn" : "ok", + message: + placeholderEmailCount > 0 + ? `${placeholderEmailCount} account(s) appear to be placeholder/demo entries` + : "No placeholder emails detected", + }); + addCheck({ + key: "refresh-token-shape", + severity: likelyInvalidRefreshTokenCount > 0 ? "warn" : "ok", + message: + likelyInvalidRefreshTokenCount > 0 + ? `${likelyInvalidRefreshTokenCount} account(s) have likely invalid refresh token format` + : "Refresh token format looks normal", + }); + + const now = Date.now(); + const forecastResults = evaluateForecastAccounts( + storageForChecks.accounts.map((account, index) => ({ + index, + account, + isCurrent: index === activeIndex, + now, + })), + ); + const recommendation = recommendForecastAccount(forecastResults); + if ( + recommendation.recommendedIndex !== null + && recommendation.recommendedIndex !== activeIndex + ) { + addCheck({ + key: "recommended-switch", + severity: "warn", + message: `A healthier account is available: switch to ${recommendation.recommendedIndex + 1}`, + details: recommendation.reason, + }); + } else { + addCheck({ + key: "recommended-switch", + severity: "ok", + message: "Current account aligns with forecast recommendation", + }); + } + + if (activeExists) { + const activeAccount = storageForChecks.accounts[activeIndex]; + const managerActiveEmail = sanitizeEmail(activeAccount?.email); + const managerActiveAccountId = activeAccount?.accountId; + const codexActiveEmail = + sanitizeEmail(codexCliState?.activeEmail) ?? codexAuthEmail; + const codexActiveAccountId = + codexCliState?.activeAccountId ?? codexAuthAccountId; + const isEmailMismatch = + !!managerActiveEmail + && !!codexActiveEmail + && managerActiveEmail !== codexActiveEmail; + const isAccountIdMismatch = + !!managerActiveAccountId + && !!codexActiveAccountId + && managerActiveAccountId !== codexActiveAccountId; + + addCheck({ + key: "active-selection-sync", + severity: isEmailMismatch || isAccountIdMismatch ? "warn" : "ok", + message: + isEmailMismatch || isAccountIdMismatch + ? "Manager active account and Codex active account are not aligned" + : "Manager active account and Codex active account are aligned", + details: + `manager=${formatDoctorIdentitySummary({ + email: managerActiveEmail, + accountId: managerActiveAccountId, + })} | codex=${formatDoctorIdentitySummary({ + email: codexActiveEmail, + accountId: codexActiveAccountId, + })}`, + }); + + if (options.fix && activeAccount) { + const activeAccountMatch = structuredClone(activeAccount); + let syncAccessToken = activeAccount.accessToken; + let syncRefreshToken = activeAccount.refreshToken; + let syncExpiresAt = activeAccount.expiresAt; + let syncIdToken: string | undefined; + let canSyncActiveAccount = deps.hasUsableAccessToken(activeAccount, now); + + if (!canSyncActiveAccount) { + if (options.dryRun) { + supplementalFixActions.push({ + key: "doctor-refresh", + message: `Prepared active-account token refresh for account ${activeIndex + 1} (dry-run)`, + }); + } else { + const refreshResult = await queuedRefresh(activeAccount.refreshToken); + if (refreshResult.type === "success") { + const refreshedEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); + const refreshedAccountId = extractAccountId(refreshResult.access); + activeAccount.accessToken = refreshResult.access; + activeAccount.refreshToken = refreshResult.refresh; + activeAccount.expiresAt = refreshResult.expires; + if (refreshedEmail) activeAccount.email = refreshedEmail; + deps.applyTokenAccountIdentity(activeAccount, refreshedAccountId); + doctorRefreshMutation = { + match: activeAccountMatch, + accessToken: refreshResult.access, + refreshToken: refreshResult.refresh, + expiresAt: refreshResult.expires, + ...(refreshedEmail ? { email: refreshedEmail } : {}), + ...(refreshedAccountId ? { accountId: refreshedAccountId } : {}), + }; + syncAccessToken = refreshResult.access; + syncRefreshToken = refreshResult.refresh; + syncExpiresAt = refreshResult.expires; + syncIdToken = refreshResult.idToken; + canSyncActiveAccount = true; + storageFixChanged = true; + fixChanged = true; + supplementalFixActions.push({ + key: "doctor-refresh", + message: `Refreshed active account tokens for account ${activeIndex + 1}`, + }); + } else { + addCheck({ + key: "doctor-refresh", + severity: "warn", + message: "Unable to refresh active account before Codex sync", + details: deps.normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), + }); + } + } + } + + if (!options.dryRun && canSyncActiveAccount) { + pendingCodexActiveSync = { + accountId: activeAccount.accountId, + email: activeAccount.email, + accessToken: syncAccessToken, + refreshToken: syncRefreshToken, + expiresAt: syncExpiresAt, + ...(syncIdToken ? { idToken: syncIdToken } : {}), + }; + } else if (options.dryRun && canSyncActiveAccount) { + supplementalFixActions.push({ + key: "codex-active-sync", + message: "Prepared Codex active-account sync (dry-run)", + }); + } + } + } + } + + if ( + options.fix + && storageForChecks + && storageForChecks.accounts.length > 0 + && storageFixChanged + && !options.dryRun + ) { + await withAccountStorageTransaction(async (loadedStorage, persist) => { + const nextStorage = loadedStorage + ? structuredClone(loadedStorage) + : createEmptyAccountStorage(); + const transactionFixed = applyDoctorFixes(nextStorage, deps); + structuralFixActions = transactionFixed.actions; + let transactionChanged = transactionFixed.changed; + if (doctorRefreshMutation) { + const fallbackActiveIndex = deps.resolveActiveIndex(nextStorage, "codex"); + const fallbackTargetIndex = + fallbackActiveIndex >= 0 && fallbackActiveIndex < nextStorage.accounts.length + ? fallbackActiveIndex + : undefined; + const targetIndex = + findMatchingAccountIndex(nextStorage.accounts, doctorRefreshMutation.match, { + allowUniqueAccountIdFallbackWithoutEmail: true, + }) + ?? findMatchingAccountIndex(nextStorage.accounts, { + accountId: doctorRefreshMutation.accountId, + email: doctorRefreshMutation.email, + refreshToken: doctorRefreshMutation.refreshToken, + }, { + allowUniqueAccountIdFallbackWithoutEmail: true, + }) + ?? fallbackTargetIndex; + const target = targetIndex === undefined ? undefined : nextStorage.accounts[targetIndex]; + if (target) { + if (target.accessToken !== doctorRefreshMutation.accessToken) { + target.accessToken = doctorRefreshMutation.accessToken; + transactionChanged = true; + } + if (target.refreshToken !== doctorRefreshMutation.refreshToken) { + target.refreshToken = doctorRefreshMutation.refreshToken; + transactionChanged = true; + } + if (target.expiresAt !== doctorRefreshMutation.expiresAt) { + target.expiresAt = doctorRefreshMutation.expiresAt; + transactionChanged = true; + } + if (doctorRefreshMutation.email && target.email !== doctorRefreshMutation.email) { + target.email = doctorRefreshMutation.email; + transactionChanged = true; + } + if (deps.applyTokenAccountIdentity(target, doctorRefreshMutation.accountId)) { + transactionChanged = true; + } + } + } + if (normalizeDoctorIndexes(nextStorage)) { + transactionChanged = true; + } + if (!transactionChanged) { + structuralFixActions = []; + storageFixChanged = false; + return; + } + storageFixChanged = true; + await persist(nextStorage); + }); + } + + if (pendingCodexActiveSync && (!doctorRefreshMutation || storageFixChanged)) { + const synced = await setCodexCliActiveSelection(pendingCodexActiveSync); + if (synced) { + supplementalFixActions.push({ + key: "codex-active-sync", + message: "Synced manager active account into Codex auth state", + }); + } else { + addCheck({ + key: "codex-active-sync", + severity: "warn", + message: "Failed to sync manager active account into Codex auth state", + }); + } + } + + const fixActions = [...structuralFixActions, ...supplementalFixActions]; + + if (options.fix && storageForChecks && storageForChecks.accounts.length > 0) { + fixChanged = storageFixChanged || fixActions.length > 0; + addCheck({ + key: "auto-fix", + severity: fixChanged ? "warn" : "ok", + message: fixChanged + ? options.dryRun + ? `Prepared ${fixActions.length} fix(es) (dry-run)` + : `Applied ${fixActions.length} fix(es)` + : "No safe auto-fixes needed", + }); + } + + const summary = checks.reduce( + (acc, check) => { + acc[check.severity] += 1; + return acc; + }, + { ok: 0, warn: 0, error: 0 }, + ); + + if (options.json) { + console.log( + JSON.stringify( + { + command: "doctor", + storagePath, + summary, + checks, + fix: { + enabled: options.fix, + dryRun: options.dryRun, + changed: fixChanged, + actions: fixActions, + }, + }, + null, + 2, + ), + ); + return summary.error > 0 ? 1 : 0; + } + + console.log("Doctor diagnostics"); + console.log(`Storage: ${storagePath}`); + console.log( + `Summary: ${summary.ok} ok, ${summary.warn} warnings, ${summary.error} errors`, + ); + console.log(""); + for (const check of checks) { + const marker = + check.severity === "ok" ? "✓" : check.severity === "warn" ? "!" : "✗"; + console.log(`${marker} ${check.key}: ${check.message}`); + if (check.details) { + console.log(` ${check.details}`); + } + } + if (options.fix) { + console.log(""); + if (fixActions.length > 0) { + console.log(`Auto-fix actions (${options.dryRun ? "dry-run" : "applied"}):`); + for (const action of fixActions) { + console.log(` - ${action.message}`); + } + } else { + console.log("Auto-fix actions: none"); + } + } + + return summary.error > 0 ? 1 : 0; +} diff --git a/lib/storage.ts b/lib/storage.ts index 0509925d..9e498db1 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -767,6 +767,63 @@ async function loadFlaggedAccountsFromPath( return normalizeFlaggedStorage(data); } +async function loadFlaggedAccountsWithFallback( + path: string, + persistMigrated: (storage: FlaggedAccountStorageV1) => Promise, +): Promise { + const resetMarkerPath = getIntentionalResetMarkerPath(path); + const empty: FlaggedAccountStorageV1 = { version: 1, accounts: [] }; + + try { + const loaded = await loadFlaggedAccountsFromPath(path); + if (existsSync(resetMarkerPath)) { + return empty; + } + return loaded; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.error("Failed to load flagged account storage", { + path, + error: String(error), + }); + return empty; + } + } + + const legacyPath = getLegacyFlaggedAccountsPath(); + if (!existsSync(legacyPath)) { + return empty; + } + + try { + const legacyContent = await fs.readFile(legacyPath, "utf-8"); + const legacyData = JSON.parse(legacyContent) as unknown; + const migrated = normalizeFlaggedStorage(legacyData); + if (migrated.accounts.length > 0) { + await persistMigrated(migrated); + } + try { + await fs.unlink(legacyPath); + } catch { + // Best effort cleanup. + } + log.info("Migrated legacy flagged account storage", { + from: legacyPath, + to: path, + accounts: migrated.accounts.length, + }); + return migrated; + } catch (error) { + log.error("Failed to migrate legacy flagged account storage", { + from: legacyPath, + to: path, + error: String(error), + }); + return empty; + } +} + async function describeFlaggedSnapshot( path: string, kind: BackupSnapshotKind, @@ -2069,6 +2126,15 @@ function cloneAccountStorageForPersistence( }; } +function cloneFlaggedStorageForPersistence( + storage: FlaggedAccountStorageV1 | null | undefined, +): FlaggedAccountStorageV1 { + return { + version: 1, + accounts: structuredClone(storage?.accounts ?? []), + }; +} + export async function withAccountStorageTransaction( handler: ( current: AccountStorageV3 | null, @@ -2100,6 +2166,7 @@ export async function withAccountAndFlaggedStorageTransaction( accountStorage: AccountStorageV3, flaggedStorage: FlaggedAccountStorageV1, ) => Promise, + currentFlagged: FlaggedAccountStorageV1, ) => Promise, ): Promise { return withStorageLock(async () => { @@ -2110,15 +2177,20 @@ export async function withAccountAndFlaggedStorageTransaction( active: true, }; const current = state.snapshot; + const currentFlagged = await loadFlaggedAccountsWithFallback( + getFlaggedAccountsPath(), + saveFlaggedAccountsUnlocked, + ); const persist = async ( accountStorage: AccountStorageV3, flaggedStorage: FlaggedAccountStorageV1, ): Promise => { const previousAccounts = cloneAccountStorageForPersistence(state.snapshot); const nextAccounts = cloneAccountStorageForPersistence(accountStorage); + const nextFlagged = cloneFlaggedStorageForPersistence(flaggedStorage); await saveAccountsUnlocked(nextAccounts); try { - await saveFlaggedAccountsUnlocked(flaggedStorage); + await saveFlaggedAccountsUnlocked(nextFlagged); state.snapshot = nextAccounts; } catch (error) { try { @@ -2142,8 +2214,51 @@ export async function withAccountAndFlaggedStorageTransaction( } }; return transactionSnapshotContext.run(state, () => - handler(current, persist), + handler(current, persist, currentFlagged), + ); + }); +} + +export async function withFlaggedStorageTransaction( + handler: ( + current: FlaggedAccountStorageV1, + persist: (storage: FlaggedAccountStorageV1) => Promise, + ) => Promise, +): Promise { + return withStorageLock(async () => { + const current = await loadFlaggedAccountsWithFallback( + getFlaggedAccountsPath(), + saveFlaggedAccountsUnlocked, ); + let snapshot = cloneFlaggedStorageForPersistence(current); + const persist = async (storage: FlaggedAccountStorageV1): Promise => { + const previousStorage = cloneFlaggedStorageForPersistence(snapshot); + const nextStorage = cloneFlaggedStorageForPersistence(storage); + try { + await saveFlaggedAccountsUnlocked(nextStorage); + snapshot = nextStorage; + } catch (error) { + try { + await saveFlaggedAccountsUnlocked(previousStorage); + snapshot = previousStorage; + } catch (rollbackError) { + const combinedError = new AggregateError( + [error, rollbackError], + "Flagged save failed and flagged storage rollback also failed", + ); + log.error( + "Failed to rollback flagged storage after flagged save failure", + { + error: String(error), + rollbackError: String(rollbackError), + }, + ); + throw combinedError; + } + throw error; + } + }; + return handler(structuredClone(snapshot), persist); }); } @@ -2320,59 +2435,7 @@ function normalizeFlaggedStorage(data: unknown): FlaggedAccountStorageV1 { export async function loadFlaggedAccounts(): Promise { const path = getFlaggedAccountsPath(); - const resetMarkerPath = getIntentionalResetMarkerPath(path); - const empty: FlaggedAccountStorageV1 = { version: 1, accounts: [] }; - - try { - const content = await fs.readFile(path, "utf-8"); - const data = JSON.parse(content) as unknown; - const loaded = normalizeFlaggedStorage(data); - if (existsSync(resetMarkerPath)) { - return empty; - } - return loaded; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.error("Failed to load flagged account storage", { - path, - error: String(error), - }); - return empty; - } - } - - const legacyPath = getLegacyFlaggedAccountsPath(); - if (!existsSync(legacyPath)) { - return empty; - } - - try { - const legacyContent = await fs.readFile(legacyPath, "utf-8"); - const legacyData = JSON.parse(legacyContent) as unknown; - const migrated = normalizeFlaggedStorage(legacyData); - if (migrated.accounts.length > 0) { - await saveFlaggedAccounts(migrated); - } - try { - await fs.unlink(legacyPath); - } catch { - // Best effort cleanup. - } - log.info("Migrated legacy flagged account storage", { - from: legacyPath, - to: path, - accounts: migrated.accounts.length, - }); - return migrated; - } catch (error) { - log.error("Failed to migrate legacy flagged account storage", { - from: legacyPath, - to: path, - error: String(error), - }); - return empty; - } + return loadFlaggedAccountsWithFallback(path, saveFlaggedAccounts); } async function saveFlaggedAccountsUnlocked( diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 930cf8fb..e9476c16 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -30,10 +30,15 @@ const detectOcChatgptMultiAuthTargetMock = vi.fn(); const normalizeAccountStorageMock = vi.fn((value) => value); const withAccountStorageTransactionMock = vi.fn(); const withAccountAndFlaggedStorageTransactionMock = vi.fn(); +const withFlaggedStorageTransactionMock = vi.fn(); const loggerDebugMock = vi.fn(); const loggerInfoMock = vi.fn(); const loggerWarnMock = vi.fn(); const loggerErrorMock = vi.fn(); +let lastAccountStorageSnapshot: unknown = null; +let lastFlaggedStorageSnapshot: unknown = null; +let accountStorageState: unknown = null; +let flaggedStorageState: unknown = null; vi.mock("../lib/logger.js", () => ({ createLogger: vi.fn(() => ({ @@ -119,12 +124,25 @@ vi.mock("../lib/storage.js", async () => { const actual = await vi.importActual("../lib/storage.js"); return { ...(actual as Record), - loadAccounts: loadAccountsMock, - loadFlaggedAccounts: loadFlaggedAccountsMock, + loadAccounts: async (...args: unknown[]) => { + const value = await loadAccountsMock(...args); + if (value != null) { + updateLastAccountStorageSnapshot(value); + } + return value; + }, + loadFlaggedAccounts: async (...args: unknown[]) => { + const value = await loadFlaggedAccountsMock(...args); + if (value != null) { + updateLastFlaggedStorageSnapshot(value); + } + return value; + }, saveAccounts: saveAccountsMock, saveFlaggedAccounts: saveFlaggedAccountsMock, withAccountAndFlaggedStorageTransaction: withAccountAndFlaggedStorageTransactionMock, + withFlaggedStorageTransaction: withFlaggedStorageTransactionMock, withAccountStorageTransaction: withAccountStorageTransactionMock, setStoragePath: setStoragePathMock, getStoragePath: getStoragePathMock, @@ -252,6 +270,65 @@ function createDeferred(): { return { promise, resolve, reject }; } +function cloneValue(value: T): T { + return structuredClone(value); +} + +function updateLastAccountStorageSnapshot(snapshot: unknown): void { + if (snapshot == null) { + return; + } + const cloned = cloneValue(snapshot); + lastAccountStorageSnapshot = cloned; + accountStorageState = cloneValue(cloned); +} + +function updateLastFlaggedStorageSnapshot(snapshot: unknown): void { + if (snapshot == null) { + return; + } + const cloned = cloneValue(snapshot); + lastFlaggedStorageSnapshot = cloned; + flaggedStorageState = cloneValue(cloned); +} + +function getLastLoadedAccountSnapshot(): unknown { + return lastAccountStorageSnapshot == null + ? null + : cloneValue(lastAccountStorageSnapshot); +} + +function getLastLoadedFlaggedSnapshot(): unknown { + return lastFlaggedStorageSnapshot == null + ? { + version: 1, + accounts: [], + } + : cloneValue(lastFlaggedStorageSnapshot); +} + +async function getCurrentAccountSnapshot(): Promise { + const current = await loadAccountsMock(); + if (current != null) { + updateLastAccountStorageSnapshot(current); + return current; + } + return accountStorageState == null + ? getLastLoadedAccountSnapshot() + : cloneValue(accountStorageState); +} + +async function getCurrentFlaggedSnapshot(): Promise { + const current = await loadFlaggedAccountsMock(); + if (current != null) { + updateLastFlaggedStorageSnapshot(current); + return current; + } + return flaggedStorageState == null + ? getLastLoadedFlaggedSnapshot() + : cloneValue(flaggedStorageState); +} + function makeErrnoError(message: string, code: string): NodeJS.ErrnoException { const error = new Error(message) as NodeJS.ErrnoException; error.code = code; @@ -471,6 +548,7 @@ describe("codex manager cli commands", () => { restoreAccountsFromBackupMock.mockReset(); withAccountAndFlaggedStorageTransactionMock.mockReset(); withAccountStorageTransactionMock.mockReset(); + withFlaggedStorageTransactionMock.mockReset(); queuedRefreshMock.mockReset(); setCodexCliActiveSelectionMock.mockReset(); promptAddAnotherAccountMock.mockReset(); @@ -499,9 +577,19 @@ describe("codex manager cli commands", () => { version: 1, accounts: [], }); + lastAccountStorageSnapshot = null; + accountStorageState = null; + lastFlaggedStorageSnapshot = { + version: 1, + accounts: [], + }; + flaggedStorageState = { + version: 1, + accounts: [], + }; withAccountStorageTransactionMock.mockImplementation( async (handler) => { - const current = await loadAccountsMock(); + const current = await getCurrentAccountSnapshot(); return handler( current == null ? { @@ -511,13 +599,17 @@ describe("codex manager cli commands", () => { activeIndexByFamily: {}, } : structuredClone(current), - async (storage: unknown) => saveAccountsMock(storage), + async (storage: unknown) => { + await saveAccountsMock(storage); + updateLastAccountStorageSnapshot(storage); + }, ); }, ); withAccountAndFlaggedStorageTransactionMock.mockImplementation( async (handler) => { - const current = await loadAccountsMock(); + const current = await getCurrentAccountSnapshot(); + const flaggedCurrent = await getCurrentFlaggedSnapshot(); let snapshot = current == null ? { @@ -527,22 +619,62 @@ describe("codex manager cli commands", () => { activeIndexByFamily: {}, } : structuredClone(current); + let flaggedSnapshot = + flaggedCurrent == null + ? { + version: 1, + accounts: [], + } + : structuredClone(flaggedCurrent); return handler( structuredClone(snapshot), async (storage: unknown, flaggedStorage: unknown) => { const previousSnapshot = structuredClone(snapshot); + const previousFlaggedSnapshot = structuredClone(flaggedSnapshot); + accountStorageState = structuredClone(storage); await saveAccountsMock(storage); try { + flaggedStorageState = structuredClone(flaggedStorage); await saveFlaggedAccountsMock(flaggedStorage); snapshot = structuredClone(storage); + flaggedSnapshot = structuredClone(flaggedStorage); + updateLastAccountStorageSnapshot(snapshot); + updateLastFlaggedStorageSnapshot(flaggedSnapshot); } catch (error) { + accountStorageState = structuredClone(previousSnapshot); + flaggedStorageState = structuredClone(previousFlaggedSnapshot); await saveAccountsMock(previousSnapshot); + updateLastAccountStorageSnapshot(previousSnapshot); + updateLastFlaggedStorageSnapshot(previousFlaggedSnapshot); throw error; } }, + structuredClone(flaggedSnapshot), ); }, ); + withFlaggedStorageTransactionMock.mockImplementation(async (handler) => { + const current = await getCurrentFlaggedSnapshot(); + const snapshot = + current == null + ? { + version: 1, + accounts: [], + } + : structuredClone(current); + return handler(structuredClone(snapshot), async (storage: unknown) => { + const previousSnapshot = structuredClone(snapshot); + flaggedStorageState = structuredClone(storage); + try { + await saveFlaggedAccountsMock(storage); + updateLastFlaggedStorageSnapshot(storage); + } catch (error) { + flaggedStorageState = structuredClone(previousSnapshot); + updateLastFlaggedStorageSnapshot(previousSnapshot); + throw error; + } + }); + }); loadDashboardDisplaySettingsMock.mockResolvedValue({ showPerAccountRows: true, showQuotaDetails: true, @@ -1021,6 +1153,63 @@ describe("codex manager cli commands", () => { }); }); + it("preserves concurrently added flagged accounts during flagged recovery", async () => { + const now = Date.now(); + const originalFlagged = { + refreshToken: "flagged-refresh", + accountId: "acc_flagged", + email: "flagged@example.com", + addedAt: now - 1_000, + lastUsed: now - 1_000, + flaggedAt: now - 5_000, + }; + loadFlaggedAccountsMock + .mockResolvedValueOnce({ + version: 1, + accounts: [originalFlagged], + }) + .mockResolvedValueOnce({ + version: 1, + accounts: [ + originalFlagged, + { + refreshToken: "refresh-concurrent", + accountId: "acc_concurrent", + email: "concurrent@example.com", + addedAt: now - 2_000, + lastUsed: now - 2_000, + flaggedAt: now - 2_000, + }, + ], + }); + loadAccountsMock.mockResolvedValueOnce(null); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-restored", + refresh: "refresh-restored", + expires: now + 3_600_000, + }); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "verify-flagged", + "--json", + ]); + + expect(exitCode).toBe(0); + expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(saveFlaggedAccountsMock).toHaveBeenCalledWith({ + version: 1, + accounts: [ + expect.objectContaining({ + refreshToken: "refresh-concurrent", + accountId: "acc_concurrent", + }), + ], + }); + }); + it("preserves distinct shared-accountId accounts when flagged recovery has no email", async () => { const now = Date.now(); loadFlaggedAccountsMock.mockResolvedValueOnce({ @@ -1344,8 +1533,26 @@ describe("codex manager cli commands", () => { ); }); - it("keeps flagged account when verification still fails", async () => { + it("restores prior storage snapshot when flagged save fails during verify-flagged --no-restore", async () => { const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + refreshToken: "refresh-existing", + accountId: "acc_existing", + email: "existing@example.com", + addedAt: now - 10_000, + lastUsed: now - 10_000, + }, + ], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); loadFlaggedAccountsMock.mockResolvedValueOnce({ version: 1, accounts: [ @@ -1353,12 +1560,114 @@ describe("codex manager cli commands", () => { refreshToken: "flagged-refresh", accountId: "acc_flagged", email: "flagged@example.com", - addedAt: now - 1_000, - lastUsed: now - 1_000, + addedAt: now - 5_000, + lastUsed: now - 5_000, flaggedAt: now - 5_000, }, ], }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-restored", + refresh: "refresh-restored", + expires: now + 3_600_000, + }); + saveFlaggedAccountsMock.mockRejectedValueOnce( + new Error("flagged write failed"), + ); + + const originalStorage = structuredClone(storageState); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + await expect( + runCodexMultiAuthCli(["auth", "verify-flagged", "--no-restore", "--json"]), + ).rejects.toThrow("flagged write failed"); + expect(withFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(storageState).toEqual(originalStorage); + }); + + it("merges concurrently added flagged accounts during verify-flagged --no-restore", async () => { + const now = Date.now(); + const originalFlagged = { + refreshToken: "flagged-refresh", + accountId: "acc_flagged", + email: "flagged@example.com", + addedAt: now - 5_000, + lastUsed: now - 5_000, + flaggedAt: now - 5_000, + }; + loadFlaggedAccountsMock + .mockResolvedValueOnce({ + version: 1, + accounts: [originalFlagged], + }) + .mockResolvedValueOnce({ + version: 1, + accounts: [ + originalFlagged, + { + refreshToken: "refresh-concurrent", + accountId: "acc_concurrent", + email: "concurrent@example.com", + addedAt: now - 2_000, + lastUsed: now - 2_000, + flaggedAt: now - 2_000, + }, + ], + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-restored", + refresh: "refresh-restored", + expires: now + 3_600_000, + }); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "verify-flagged", + "--no-restore", + "--json", + ]); + + expect(exitCode).toBe(0); + expect(withFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(saveFlaggedAccountsMock).toHaveBeenCalledWith( + expect.objectContaining({ + accounts: expect.arrayContaining([ + expect.objectContaining({ + refreshToken: "refresh-restored", + }), + expect.objectContaining({ + refreshToken: "refresh-concurrent", + accountId: "acc_concurrent", + }), + ]), + }), + ); + }); + + it("keeps flagged account when verification still fails", async () => { + const now = Date.now(); + const flaggedAccount = { + refreshToken: "flagged-refresh", + accountId: "acc_flagged", + email: "flagged@example.com", + addedAt: now - 1_000, + lastUsed: now - 1_000, + flaggedAt: now - 5_000, + }; + loadFlaggedAccountsMock + .mockResolvedValueOnce({ + version: 1, + accounts: [flaggedAccount], + }) + .mockResolvedValueOnce({ + version: 1, + accounts: [flaggedAccount], + }); loadAccountsMock.mockResolvedValueOnce({ version: 3, activeIndex: 0, @@ -1379,7 +1688,13 @@ describe("codex manager cli commands", () => { "--json", ]); expect(exitCode).toBe(0); - expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledWith( + expect.objectContaining({ + accounts: [], + }), + ); expect(saveFlaggedAccountsMock).toHaveBeenCalledTimes(1); expect(saveFlaggedAccountsMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -1438,6 +1753,136 @@ describe("codex manager cli commands", () => { expect(payload.reports[0]?.outcome).toBe("warning-soft-failure"); }); + it("preserves concurrently added accounts during auth fix persistence", async () => { + const now = Date.now(); + const originalAccount = { + email: "alpha@example.com", + accountId: "acc_alpha", + refreshToken: "refresh-alpha", + accessToken: "access-alpha-stale", + expiresAt: now - 5_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }; + const concurrentAccount = { + email: "beta@example.com", + accountId: "acc_beta", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }; + loadAccountsMock + .mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [originalAccount], + }) + .mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [originalAccount, concurrentAccount], + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-alpha-refreshed", + refresh: "refresh-alpha-next", + expires: now + 7_200_000, + idToken: "id-token-alpha", + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "fix", "--json"]); + + expect(exitCode).toBe(0); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledWith( + expect.objectContaining({ + accounts: expect.arrayContaining([ + expect.objectContaining({ + refreshToken: "refresh-alpha-next", + accessToken: "access-alpha-refreshed", + }), + expect.objectContaining({ + accountId: "acc_beta", + refreshToken: "refresh-beta", + accessToken: "access-beta", + }), + ]), + }), + ); + }); + + it("does not persist quota cache during auth fix --dry-run --live", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "live-dry-run@example.com", + accountId: "acc_live_dry_run", + refreshToken: "refresh-live-dry-run", + accessToken: "access-live-dry-run", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + fetchCodexQuotaSnapshotMock.mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "fix", + "--dry-run", + "--live", + "--json", + ]); + + expect(exitCode).toBe(0); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(saveQuotaCacheMock).not.toHaveBeenCalled(); + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + dryRun: boolean; + liveProbe: boolean; + reports: Array<{ outcome: string; message: string }>; + }; + expect(payload.dryRun).toBe(true); + expect(payload.liveProbe).toBe(true); + expect(payload.reports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + outcome: "healthy", + message: expect.stringContaining("live session OK"), + }), + ]), + ); + }); + it("persists rotated tokens during auth check and preserves org-selected workspace binding", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ @@ -1923,6 +2368,7 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "fix", "--json"]); expect(exitCode).toBe(0); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); expect(saveAccountsMock).toHaveBeenCalledTimes(1); expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); @@ -2869,6 +3315,130 @@ describe("codex manager cli commands", () => { extractAccountIdMock.mockImplementation(() => "acc_test"); }); + it("autoSyncActiveAccountToCodex preserves concurrent storage updates during refresh sync", async () => { + const now = Date.now(); + loadAccountsMock + .mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + accountId: "workspace-alpha", + accountIdSource: "org", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }) + .mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + accountId: "workspace-alpha", + accountIdSource: "org", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + { + email: "b@example.com", + accountId: "workspace-beta", + accountIdSource: "org", + refreshToken: "refresh-b", + accessToken: "access-b", + expiresAt: now + 7_200_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + ], + }); + const accountsModule = await import("../lib/accounts.js"); + const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); + extractAccountIdMock.mockImplementation((accessToken?: string) => + accessToken === "access-a-next" ? "token-personal" : "workspace-alpha", + ); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-a-next", + refresh: "refresh-a-next", + expires: now + 3_600_000, + idToken: "id-a-next", + }); + setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); + + const { autoSyncActiveAccountToCodex } = await import( + "../lib/codex-manager.js" + ); + const synced = await autoSyncActiveAccountToCodex(); + + expect(synced).toBe(true); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + const savedStorage = saveAccountsMock.mock.calls.at(-1)?.[0] as { + accounts: Array<{ refreshToken?: string; accountId?: string }>; + }; + expect(savedStorage.accounts).toHaveLength(2); + expect(savedStorage.accounts[0]).toEqual( + expect.objectContaining({ + accountId: "workspace-alpha", + refreshToken: "refresh-a-next", + }), + ); + expect(savedStorage.accounts[1]).toEqual( + expect.objectContaining({ + accountId: "workspace-beta", + refreshToken: "refresh-b", + }), + ); + extractAccountIdMock.mockImplementation(() => "acc_test"); + }); + + it("autoSyncActiveAccountToCodex skips stale Codex sync when refresh fails", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "stale@example.com", + accountId: "workspace-stale", + accountIdSource: "org", + refreshToken: "refresh-stale", + accessToken: "access-stale", + expiresAt: now - 60_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "failed", + reason: "http_error", + statusCode: 401, + message: "refresh expired", + }); + + const { autoSyncActiveAccountToCodex } = await import( + "../lib/codex-manager.js" + ); + const synced = await autoSyncActiveAccountToCodex(); + + expect(synced).toBe(false); + expect(queuedRefreshMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + }); + it("keeps auth login menu open after switch until user cancels", async () => { const now = Date.now(); const storage = { @@ -5520,62 +6090,319 @@ describe("codex manager cli commands", () => { resetAtMs: now + 2_000, }, }, - }, + }, + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + const firstCallAccounts = promptLoginModeMock.mock.calls[0]?.[0] as Array<{ + email?: string; + quickSwitchNumber?: number; + }>; + expect(firstCallAccounts.map((account) => account.email)).toEqual([ + "b@example.com", + "a@example.com", + ]); + expect( + firstCallAccounts.map((account) => account.quickSwitchNumber), + ).toEqual([2, 1]); + }); + + it("runs doctor command in json mode", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "real@example.net", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, + ], + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "doctor", "--json"]); + expect(exitCode).toBe(0); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + command: string; + summary: { ok: number; warn: number; error: number }; + checks: Array<{ key: string }>; + }; + expect(payload.command).toBe("doctor"); + expect(payload.summary.error).toBe(0); + expect(payload.checks.some((check) => check.key === "active-index")).toBe( + true, + ); + }); + + it("runs doctor command in json mode with malformed token rows", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "real@example.net", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, + { + email: "broken@example.net", + refreshToken: null as unknown as string, + addedAt: now - 500, + lastUsed: now - 500, + }, + ], + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "doctor", "--json"]); + expect(exitCode).toBe(0); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + command: string; + summary: { ok: number; warn: number; error: number }; + checks: Array<{ key: string; severity: string }>; + }; + expect(payload.command).toBe("doctor"); + expect(payload.summary.error).toBe(0); + expect(payload.checks).toContainEqual( + expect.objectContaining({ + key: "duplicate-refresh-token", + severity: "ok", + }), + ); + }); + + it("runs doctor --fix in dry-run mode", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 4, + activeIndexByFamily: { codex: 4 }, + accounts: [ + { + email: "account1@example.com", + accessToken: "access-a", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + { + email: "account2@example.com", + accessToken: "access-b", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + ], + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "doctor", + "--fix", + "--dry-run", + "--json", + ]); + + expect(exitCode).toBe(0); + expect(saveAccountsMock).not.toHaveBeenCalled(); + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + fix: { + enabled: boolean; + dryRun: boolean; + changed: boolean; + actions: Array<{ key: string }>; + }; + }; + expect(payload.fix.enabled).toBe(true); + expect(payload.fix.dryRun).toBe(true); + expect(payload.fix.changed).toBe(true); + expect(payload.fix.actions.length).toBeGreaterThan(0); + }); + + it("runs doctor --fix apply mode in a single storage transaction", async () => { + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 4, + activeIndexByFamily: { codex: 4 }, + accounts: [ + { + email: "account1@example.com", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + { + email: "account2@example.com", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + ], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); }); - promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-doctor-next", + refresh: "refresh-doctor-next", + expires: now + 3_600_000, + idToken: "id-doctor-next", + }); + setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "doctor", + "--fix", + "--json", + ]); expect(exitCode).toBe(0); - const firstCallAccounts = promptLoginModeMock.mock.calls[0]?.[0] as Array<{ - email?: string; - quickSwitchNumber?: number; - }>; - expect(firstCallAccounts.map((account) => account.email)).toEqual([ - "b@example.com", - "a@example.com", - ]); - expect( - firstCallAccounts.map((account) => account.quickSwitchNumber), - ).toEqual([2, 1]); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(1); + expect(storageState.activeIndex).toBe(1); + expect(storageState.activeIndexByFamily.codex).toBe(1); + expect(storageState.accounts[1]?.enabled).toBe(true); + expect(storageState.accounts[1]?.refreshToken).toBe("refresh-doctor-next"); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + fix: { + changed: boolean; + actions: Array<{ key: string }>; + }; + }; + expect(payload.fix.changed).toBe(true); + expect(payload.fix.actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: "doctor-refresh" }), + expect.objectContaining({ key: "codex-active-sync" }), + ]), + ); }); - it("runs doctor command in json mode", async () => { + it("preserves concurrently added accounts during doctor --fix persistence", async () => { const now = Date.now(); - loadAccountsMock.mockResolvedValueOnce({ + const initialStorage = { version: 3, activeIndex: 0, activeIndexByFamily: { codex: 0 }, accounts: [ { - email: "real@example.net", - refreshToken: "refresh-a", + email: "doctor@example.com", + accountId: "doctor-account", + accountIdSource: "manual", + refreshToken: "doctor-refresh", + accessToken: "doctor-access", + expiresAt: now - 60_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + ], + }; + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "doctor@example.com", + accountId: "doctor-account", + accountIdSource: "manual", + refreshToken: "doctor-refresh", + accessToken: "doctor-access", + expiresAt: now - 60_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "concurrent@example.com", + accountId: "concurrent-account", + accountIdSource: "manual", + refreshToken: "concurrent-refresh", + accessToken: "concurrent-access", + expiresAt: now + 3_600_000, addedAt: now - 1_000, lastUsed: now - 1_000, + enabled: true, }, ], + }; + loadAccountsMock + .mockResolvedValueOnce(structuredClone(initialStorage)) + .mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "doctor-access-next", + refresh: "doctor-refresh-next", + expires: now + 3_600_000, + idToken: "doctor-id-next", }); + setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "doctor", + "--fix", + "--json", + ]); - const exitCode = await runCodexMultiAuthCli(["auth", "doctor", "--json"]); expect(exitCode).toBe(0); - - const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { - command: string; - summary: { ok: number; warn: number; error: number }; - checks: Array<{ key: string }>; - }; - expect(payload.command).toBe("doctor"); - expect(payload.summary.error).toBe(0); - expect(payload.checks.some((check) => check.key === "active-index")).toBe( - true, + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledWith( + expect.objectContaining({ + accounts: expect.arrayContaining([ + expect.objectContaining({ + accountId: "doctor-account", + refreshToken: "doctor-refresh-next", + accessToken: "doctor-access-next", + }), + expect.objectContaining({ + accountId: "concurrent-account", + refreshToken: "concurrent-refresh", + accessToken: "concurrent-access", + }), + ]), + }), ); }); - it("runs doctor command in json mode with malformed token rows", async () => { + it("skips doctor --fix Codex sync when active refresh fails", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ version: 3, @@ -5583,51 +6410,67 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [ { - email: "real@example.net", - refreshToken: "refresh-a", + email: "doctor@example.com", + accountId: "workspace-doctor", + accountIdSource: "org", + refreshToken: "refresh-doctor", + accessToken: "access-doctor", + expiresAt: now - 60_000, addedAt: now - 1_000, lastUsed: now - 1_000, - }, - { - email: "broken@example.net", - refreshToken: null as unknown as string, - addedAt: now - 500, - lastUsed: now - 500, + enabled: true, }, ], }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "failed", + reason: "http_error", + statusCode: 401, + message: "refresh expired", + }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "doctor", + "--fix", + "--json", + ]); - const exitCode = await runCodexMultiAuthCli(["auth", "doctor", "--json"]); expect(exitCode).toBe(0); - + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(withAccountStorageTransactionMock).not.toHaveBeenCalled(); + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { - command: string; - summary: { ok: number; warn: number; error: number }; - checks: Array<{ key: string; severity: string }>; + checks: Array<{ key: string; severity: string; message: string }>; + fix: { + changed: boolean; + actions: Array<{ key: string }>; + }; }; - expect(payload.command).toBe("doctor"); - expect(payload.summary.error).toBe(0); + expect(payload.fix.changed).toBe(false); + expect(payload.fix.actions).not.toContainEqual( + expect.objectContaining({ key: "codex-active-sync" }), + ); expect(payload.checks).toContainEqual( expect.objectContaining({ - key: "duplicate-refresh-token", - severity: "ok", + key: "doctor-refresh", + severity: "warn", + message: "Unable to refresh active account before Codex sync", }), ); }); - it("runs doctor --fix in dry-run mode", async () => { + it("preserves pre-fix storage when doctor --fix transaction save fails", async () => { const now = Date.now(); - loadAccountsMock.mockResolvedValueOnce({ + let storageState = { version: 3, activeIndex: 4, activeIndexByFamily: { codex: 4 }, accounts: [ { email: "account1@example.com", - accessToken: "access-a", refreshToken: "refresh-a", addedAt: now - 1_000, lastUsed: now - 1_000, @@ -5635,39 +6478,33 @@ describe("codex manager cli commands", () => { }, { email: "account2@example.com", - accessToken: "access-b", refreshToken: "refresh-a", addedAt: now - 1_000, lastUsed: now - 1_000, enabled: false, }, ], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockRejectedValueOnce(new Error("transaction save failed")); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-doctor-next", + refresh: "refresh-doctor-next", + expires: now + 3_600_000, + idToken: "id-doctor-next", }); + setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const originalStorage = structuredClone(storageState); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli([ - "auth", - "doctor", - "--fix", - "--dry-run", - "--json", - ]); - expect(exitCode).toBe(0); - expect(saveAccountsMock).not.toHaveBeenCalled(); - const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { - fix: { - enabled: boolean; - dryRun: boolean; - changed: boolean; - actions: Array<{ key: string }>; - }; - }; - expect(payload.fix.enabled).toBe(true); - expect(payload.fix.dryRun).toBe(true); - expect(payload.fix.changed).toBe(true); - expect(payload.fix.actions.length).toBeGreaterThan(0); + await expect( + runCodexMultiAuthCli(["auth", "doctor", "--fix", "--json"]), + ).rejects.toThrow("transaction save failed"); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + expect(storageState).toEqual(originalStorage); }); it("runs report command in json mode", async () => { @@ -6834,6 +7671,9 @@ describe("codex manager cli commands", () => { const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { reports: Array<{ outcome: string; message: string }>; }; + expect( + payload.reports.filter((report) => report.outcome === "healthy"), + ).toHaveLength(1); expect( payload.reports.some( (report) => @@ -7520,19 +8360,26 @@ describe("codex manager cli commands", () => { const now = Date.now(); loadAccountsMock.mockResolvedValue({ version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, accounts: [ { email: "first@example.com", refreshToken: "refresh-first", - addedAt: now - 2_000, - lastUsed: now - 2_000, + addedAt: now - 3_000, + lastUsed: now - 3_000, enabled: true, }, { email: "second@example.com", refreshToken: "refresh-second", + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "third@example.com", + refreshToken: "refresh-third", addedAt: now - 1_000, lastUsed: now - 1_000, enabled: true, @@ -7548,10 +8395,63 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(saveAccountsMock).toHaveBeenCalledTimes(1); - expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts).toHaveLength(1); + expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts).toHaveLength(2); expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.email).toBe( "first@example.com", ); + expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[1]?.email).toBe( + "third@example.com", + ); + expect(saveAccountsMock.mock.calls[0]?.[0]?.activeIndex).toBe(1); + expect(saveAccountsMock.mock.calls[0]?.[0]?.activeIndexByFamily?.codex).toBe( + 1, + ); + }); + + it("preserves the active selection when deleting a different account", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 2, + activeIndexByFamily: { codex: 2 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now - 3_000, + lastUsed: now - 3_000, + enabled: true, + }, + { + email: "second@example.com", + refreshToken: "refresh-second", + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "third@example.com", + refreshToken: "refresh-third", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "manage", deleteAccountIndex: 1 }) + .mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts).toHaveLength(2); + expect(saveAccountsMock.mock.calls[0]?.[0]?.activeIndex).toBe(1); + expect(saveAccountsMock.mock.calls[0]?.[0]?.activeIndexByFamily?.codex).toBe( + 1, + ); }); it("toggles account enabled state from manage mode", async () => { diff --git a/test/repair-commands.test.ts b/test/repair-commands.test.ts new file mode 100644 index 00000000..0853aee7 --- /dev/null +++ b/test/repair-commands.test.ts @@ -0,0 +1,1113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RepairCommandDeps } from "../lib/codex-manager/repair-commands.js"; + +const existsSyncMock = vi.fn(); +const statMock = vi.fn(); +const readFileMock = vi.fn(); + +const evaluateForecastAccountsMock = vi.fn(() => []); +const recommendForecastAccountMock = vi.fn(() => ({ + recommendedIndex: null, + reason: "stay", +})); + +const extractAccountEmailMock = vi.fn(); +const extractAccountIdMock = vi.fn(); +const formatAccountLabelMock = vi.fn( + (account: { email?: string }, index: number) => + account.email ? `${index + 1}. ${account.email}` : `Account ${index + 1}`, +); +const sanitizeEmailMock = vi.fn((email: string | undefined) => + typeof email === "string" ? email.toLowerCase() : undefined, +); + +const loadQuotaCacheMock = vi.fn(); +const saveQuotaCacheMock = vi.fn(); +const fetchCodexQuotaSnapshotMock = vi.fn(); +const queuedRefreshMock = vi.fn(); + +const loadAccountsMock = vi.fn(); +const loadFlaggedAccountsMock = vi.fn(); +const setStoragePathMock = vi.fn(); +const getStoragePathMock = vi.fn(() => "/mock/openai-codex-accounts.json"); +const withAccountStorageTransactionMock = vi.fn(); +const withAccountAndFlaggedStorageTransactionMock = vi.fn(); +const withFlaggedStorageTransactionMock = vi.fn(); + +const getCodexCliAuthPathMock = vi.fn(() => "/mock/auth.json"); +const getCodexCliConfigPathMock = vi.fn(() => "/mock/config.toml"); +const loadCodexCliStateMock = vi.fn(); +const setCodexCliActiveSelectionMock = vi.fn(); + +vi.mock("node:fs", () => ({ + existsSync: existsSyncMock, + promises: { + stat: statMock, + readFile: readFileMock, + }, +})); + +vi.mock("../lib/forecast.js", () => ({ + evaluateForecastAccounts: evaluateForecastAccountsMock, + isHardRefreshFailure: vi.fn((result: { reason?: string }) => result.reason === "revoked"), + recommendForecastAccount: recommendForecastAccountMock, +})); + +vi.mock("../lib/accounts.js", () => ({ + extractAccountEmail: extractAccountEmailMock, + extractAccountId: extractAccountIdMock, + formatAccountLabel: formatAccountLabelMock, + sanitizeEmail: sanitizeEmailMock, +})); + +vi.mock("../lib/quota-cache.js", () => ({ + loadQuotaCache: loadQuotaCacheMock, + saveQuotaCache: saveQuotaCacheMock, +})); + +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 Record), + loadAccounts: loadAccountsMock, + loadFlaggedAccounts: loadFlaggedAccountsMock, + setStoragePath: setStoragePathMock, + getStoragePath: getStoragePathMock, + withAccountStorageTransaction: withAccountStorageTransactionMock, + withAccountAndFlaggedStorageTransaction: + withAccountAndFlaggedStorageTransactionMock, + withFlaggedStorageTransaction: withFlaggedStorageTransactionMock, + }; +}); + +vi.mock("../lib/codex-cli/state.js", () => ({ + getCodexCliAuthPath: getCodexCliAuthPathMock, + getCodexCliConfigPath: getCodexCliConfigPathMock, + loadCodexCliState: loadCodexCliStateMock, +})); + +vi.mock("../lib/codex-cli/writer.js", () => ({ + setCodexCliActiveSelection: setCodexCliActiveSelectionMock, +})); + +const { + runDoctor, + runFix, + runVerifyFlagged, +} = await import("../lib/codex-manager/repair-commands.js"); + +function createDeps( + overrides: Partial = {}, +): RepairCommandDeps { + return { + stylePromptText: (text) => text, + styleAccountDetailText: (text) => text, + formatResultSummary: (segments) => segments.map((segment) => segment.text).join(" | "), + resolveActiveIndex: () => 0, + hasUsableAccessToken: () => false, + hasLikelyInvalidRefreshToken: () => false, + normalizeFailureDetail: (message, reason) => message ?? reason ?? "unknown", + buildQuotaEmailFallbackState: () => new Map(), + updateQuotaCacheForAccount: () => false, + cloneQuotaCacheData: (cache) => structuredClone(cache), + pruneUnsafeQuotaEmailCacheEntry: () => false, + formatCompactQuotaSnapshot: () => "snapshot-ok", + resolveStoredAccountIdentity: (storedAccountId, storedAccountIdSource, refreshedAccountId) => ({ + accountId: refreshedAccountId ?? storedAccountId, + accountIdSource: refreshedAccountId ? "token" : storedAccountIdSource, + }), + applyTokenAccountIdentity: () => false, + ...overrides, + }; +} + +describe("repair-commands direct deps coverage", () => { + beforeEach(() => { + vi.clearAllMocks(); + existsSyncMock.mockReturnValue(false); + loadQuotaCacheMock.mockResolvedValue(null); + loadCodexCliStateMock.mockResolvedValue(null); + extractAccountEmailMock.mockReturnValue(undefined); + extractAccountIdMock.mockReturnValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("runVerifyFlagged uses the injected identity resolver in the direct no-restore flow", async () => { + const flaggedAccount = { + email: "old@example.com", + refreshToken: "flagged-refresh", + accessToken: "old-access", + expiresAt: 10, + accountId: "stored-account", + accountIdSource: "manual" as const, + lastError: "old-error", + lastUsed: 1, + }; + let persistedFlaggedStorage: unknown; + + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [structuredClone(flaggedAccount)], + }); + queuedRefreshMock.mockResolvedValue({ + type: "success", + access: "fresh-access", + refresh: "fresh-refresh", + expires: 999, + idToken: "fresh-id-token", + }); + extractAccountEmailMock.mockReturnValue("Recovered@example.com"); + extractAccountIdMock.mockReturnValue("token-account"); + withFlaggedStorageTransactionMock.mockImplementation(async (handler) => + handler( + { version: 1, accounts: [structuredClone(flaggedAccount)] }, + async (nextStorage: unknown) => { + persistedFlaggedStorage = nextStorage; + }, + ), + ); + const resolveStoredAccountIdentity = vi.fn(() => ({ + accountId: "resolved-account", + accountIdSource: "token" as const, + })); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runVerifyFlagged( + ["--json", "--no-restore"], + createDeps({ resolveStoredAccountIdentity }), + ); + + expect(exitCode).toBe(0); + expect(resolveStoredAccountIdentity).toHaveBeenCalledWith( + "stored-account", + "manual", + "token-account", + ); + expect(withFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(persistedFlaggedStorage).toMatchObject({ + version: 1, + accounts: [ + expect.objectContaining({ + accountId: "resolved-account", + accountIdSource: "token", + accessToken: "fresh-access", + refreshToken: "fresh-refresh", + email: "recovered@example.com", + }), + ], + }); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")).reports[0], + ).toMatchObject({ + outcome: "healthy-flagged", + }); + }); + + it("runVerifyFlagged keeps remainingFlagged in the JSON schema for empty and no-op paths", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + loadFlaggedAccountsMock.mockResolvedValueOnce({ + version: 1, + accounts: [], + }); + + let exitCode = await runVerifyFlagged( + ["--json", "--no-restore"], + createDeps(), + ); + expect(exitCode).toBe(0); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")), + ).toMatchObject({ + total: 0, + remainingFlagged: 0, + changed: false, + }); + + const flaggedAccount = { + email: "flagged@example.com", + refreshToken: "flagged-refresh", + accessToken: "old-access", + expiresAt: 10, + accountId: "stored-account", + accountIdSource: "manual" as const, + lastError: "still broken", + lastUsed: 1, + }; + loadFlaggedAccountsMock.mockResolvedValueOnce({ + version: 1, + accounts: [structuredClone(flaggedAccount)], + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "failed", + reason: "revoked", + message: "still broken", + }); + + exitCode = await runVerifyFlagged( + ["--json", "--no-restore"], + createDeps(), + ); + + expect(exitCode).toBe(0); + expect(withFlaggedStorageTransactionMock).not.toHaveBeenCalled(); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")), + ).toMatchObject({ + total: 1, + remainingFlagged: 1, + stillFlagged: 1, + changed: false, + }); + }); + + it("runVerifyFlagged skips stale restore results when flagged refresh tokens changed before persistence", async () => { + const flaggedAccount = { + email: "flagged@example.com", + refreshToken: "flagged-refresh", + accessToken: "old-access", + expiresAt: 10, + accountId: "stored-account", + accountIdSource: "manual" as const, + lastError: "old-error", + lastUsed: 1, + }; + const persistSpy = vi.fn(); + + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [structuredClone(flaggedAccount)], + }); + queuedRefreshMock.mockResolvedValue({ + type: "success", + access: "fresh-access", + refresh: "fresh-refresh", + expires: 999, + idToken: "fresh-id-token", + }); + extractAccountEmailMock.mockReturnValue("flagged@example.com"); + extractAccountIdMock.mockReturnValue("token-account"); + withAccountAndFlaggedStorageTransactionMock.mockImplementation(async (handler) => + handler( + null, + persistSpy, + { + version: 1, + accounts: [ + { + ...structuredClone(flaggedAccount), + refreshToken: "rotated-refresh", + }, + ], + }, + ), + ); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runVerifyFlagged( + ["--json"], + createDeps(), + ); + + expect(exitCode).toBe(0); + expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(persistSpy).not.toHaveBeenCalled(); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")), + ).toMatchObject({ + total: 1, + restored: 0, + remainingFlagged: 1, + changed: false, + reports: [ + expect.objectContaining({ + outcome: "restore-skipped", + message: expect.stringContaining("changed before persistence"), + }), + ], + }); + }); + + it("runVerifyFlagged skips stale no-restore updates when flagged refresh tokens changed before persistence", async () => { + const flaggedAccount = { + email: "flagged@example.com", + refreshToken: "flagged-refresh", + accessToken: "old-access", + expiresAt: 10, + accountId: "stored-account", + accountIdSource: "manual" as const, + lastError: "old-error", + lastUsed: 1, + }; + const persistSpy = vi.fn(); + + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [structuredClone(flaggedAccount)], + }); + queuedRefreshMock.mockResolvedValue({ + type: "success", + access: "fresh-access", + refresh: "fresh-refresh", + expires: 999, + idToken: "fresh-id-token", + }); + extractAccountEmailMock.mockReturnValue("flagged@example.com"); + extractAccountIdMock.mockReturnValue("token-account"); + withFlaggedStorageTransactionMock.mockImplementation(async (handler) => + handler( + { + version: 1, + accounts: [ + { + ...structuredClone(flaggedAccount), + refreshToken: "rotated-refresh", + }, + ], + }, + persistSpy, + ), + ); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runVerifyFlagged( + ["--json", "--no-restore"], + createDeps(), + ); + + expect(exitCode).toBe(0); + expect(withFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(persistSpy).not.toHaveBeenCalled(); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")), + ).toMatchObject({ + total: 1, + remainingFlagged: 1, + changed: false, + reports: [ + expect.objectContaining({ + outcome: "restore-skipped", + message: expect.stringContaining("changed before persistence"), + }), + ], + }); + }); + + it("runFix uses the injected token-identity applier in the direct concurrent-write path", async () => { + const prescanStorage = { + version: 3, + accounts: [ + { + email: "old@example.com", + refreshToken: "old-refresh", + accessToken: "old-access", + expiresAt: 0, + accountId: "old-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: {}, + }; + const inTransactionStorage = { + version: 3, + accounts: [ + { + email: "old@example.com", + refreshToken: "old-refresh", + accessToken: "concurrent-access", + expiresAt: 25, + accountId: "old-account", + accountIdSource: "manual" as const, + accountLabel: "Concurrent Label", + enabled: true, + }, + { + email: "beta@example.com", + refreshToken: "beta-refresh", + accessToken: "beta-access", + expiresAt: 30, + accountId: "beta-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: {}, + }; + let persistedAccountStorage: unknown; + + loadAccountsMock.mockResolvedValue(structuredClone(prescanStorage)); + queuedRefreshMock.mockResolvedValue({ + type: "success", + access: "new-access", + refresh: "new-refresh", + expires: 5000, + idToken: "new-id-token", + }); + extractAccountEmailMock.mockReturnValue("fresh@example.com"); + extractAccountIdMock.mockReturnValue("token-account"); + withAccountStorageTransactionMock.mockImplementation(async (handler) => + handler(structuredClone(inTransactionStorage), async (nextStorage: unknown) => { + persistedAccountStorage = nextStorage; + }), + ); + const applyTokenAccountIdentity = vi.fn((account: { accountId?: string; accountIdSource?: string }, refreshedAccountId: string | undefined) => { + account.accountId = `dep-${refreshedAccountId}`; + account.accountIdSource = "token"; + return true; + }); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runFix( + ["--json"], + createDeps({ applyTokenAccountIdentity }), + ); + + expect(exitCode).toBe(0); + expect(applyTokenAccountIdentity).toHaveBeenCalled(); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(persistedAccountStorage).toMatchObject({ + accounts: [ + expect.objectContaining({ + accountLabel: "Concurrent Label", + accountId: "dep-token-account", + accountIdSource: "token", + accessToken: "new-access", + refreshToken: "new-refresh", + email: "fresh@example.com", + }), + expect.objectContaining({ + accountId: "beta-account", + refreshToken: "beta-refresh", + }), + ], + }); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")).summary, + ).toMatchObject({ + healthy: 1, + }); + }); + + it("runFix keeps JSON output consistent for no-account and quota-cache-only changes", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + loadAccountsMock.mockResolvedValueOnce(null); + let exitCode = await runFix(["--json"], createDeps()); + + expect(exitCode).toBe(0); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")), + ).toMatchObject({ + command: "fix", + changed: false, + summary: { + healthy: 0, + disabled: 0, + warnings: 0, + skipped: 0, + }, + reports: [], + }); + + loadQuotaCacheMock.mockResolvedValueOnce({ + byAccountId: {}, + byEmail: {}, + }); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + accounts: [ + { + email: "quota@example.com", + refreshToken: "quota-refresh", + accessToken: "quota-access", + expiresAt: Date.now() + 60_000, + accountId: "quota-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: {}, + }); + fetchCodexQuotaSnapshotMock.mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }); + + exitCode = await runFix( + ["--json", "--live"], + createDeps({ + hasUsableAccessToken: () => true, + updateQuotaCacheForAccount: () => true, + }), + ); + + expect(exitCode).toBe(0); + expect(withAccountStorageTransactionMock).not.toHaveBeenCalled(); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")), + ).toMatchObject({ + command: "fix", + changed: false, + quotaCacheChanged: true, + summary: { + healthy: 1, + }, + }); + }); + + it("runFix reports quota-cache-only live changes distinctly in display mode", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + loadQuotaCacheMock.mockResolvedValueOnce({ + byAccountId: {}, + byEmail: {}, + }); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + accounts: [ + { + email: "quota@example.com", + refreshToken: "quota-refresh", + accessToken: "quota-access", + expiresAt: Date.now() + 60_000, + accountId: "quota-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: {}, + }); + fetchCodexQuotaSnapshotMock.mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }); + + const exitCode = await runFix( + ["--live"], + createDeps({ + hasUsableAccessToken: () => true, + updateQuotaCacheForAccount: () => true, + }), + ); + + expect(exitCode).toBe(0); + expect(withAccountStorageTransactionMock).not.toHaveBeenCalled(); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + const output = consoleSpy.mock.calls + .map((call) => call.map((value) => String(value)).join(" ")) + .join("\n"); + expect(output).toContain("Quota cache refreshed (no account storage changes)."); + expect(output).not.toContain("Saved updates."); + expect(output).not.toContain("No changes were needed."); + }); + + it("runFix does not double-count a live probe failure followed by refresh fallback", async () => { + loadQuotaCacheMock.mockResolvedValueOnce({ + byAccountId: {}, + byEmail: {}, + }); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + accounts: [ + { + email: "fallback@example.com", + refreshToken: "refresh-fallback", + accessToken: "access-fallback", + expiresAt: Date.now() + 60_000, + accountId: "fallback-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: {}, + }); + fetchCodexQuotaSnapshotMock + .mockRejectedValueOnce(new Error("probe unavailable")) + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-fallback-next", + refresh: "refresh-fallback-next", + expires: Date.now() + 120_000, + idToken: "id-token-fallback", + }); + extractAccountEmailMock.mockReturnValue("fallback@example.com"); + extractAccountIdMock.mockReturnValue("fallback-account"); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runFix( + ["--json", "--live"], + createDeps({ hasUsableAccessToken: () => true }), + ); + + expect(exitCode).toBe(0); + const payload = JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")) as { + summary: { healthy: number; warnings: number }; + reports: Array<{ outcome: string }>; + }; + expect(payload.summary).toMatchObject({ healthy: 1, warnings: 0 }); + expect(payload.reports).toHaveLength(1); + expect(payload.reports[0]).toMatchObject({ outcome: "healthy" }); + }); + + it("runDoctor uses the injected refresh-token validator in JSON diagnostics", async () => { + loadAccountsMock.mockResolvedValue({ + version: 3, + accounts: [ + { + email: "doctor@example.com", + refreshToken: "bad-refresh-token", + accessToken: "access", + expiresAt: 100, + accountId: "doctor-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: {}, + }); + const hasLikelyInvalidRefreshToken = vi.fn(() => true); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runDoctor( + ["--json"], + createDeps({ hasLikelyInvalidRefreshToken }), + ); + + expect(exitCode).toBe(0); + expect(hasLikelyInvalidRefreshToken).toHaveBeenCalledWith("bad-refresh-token"); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")).checks, + ).toContainEqual( + expect.objectContaining({ + key: "refresh-token-shape", + severity: "warn", + }), + ); + }); + + it("runDoctor checks refresh token shape even when email is missing", async () => { + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + accounts: [ + { + refreshToken: "bad-refresh-token", + accessToken: "access", + expiresAt: 100, + accountId: "doctor-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: {}, + }); + const hasLikelyInvalidRefreshToken = vi.fn(() => true); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runDoctor( + ["--json"], + createDeps({ hasLikelyInvalidRefreshToken }), + ); + + expect(exitCode).toBe(0); + expect(hasLikelyInvalidRefreshToken).toHaveBeenCalledWith("bad-refresh-token"); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")).checks, + ).toContainEqual( + expect.objectContaining({ + key: "refresh-token-shape", + severity: "warn", + }), + ); + }); + + it("runDoctor derives auto-fix state from the final action set", async () => { + const now = Date.now(); + let persistedAccountStorage: unknown; + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + accounts: [ + { + email: "doctor@example.com", + refreshToken: "doctor-refresh", + accessToken: "doctor-access", + expiresAt: now - 60_000, + accountId: "doctor-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }); + withAccountStorageTransactionMock.mockImplementation(async (handler) => + handler( + { + version: 3, + accounts: [ + { + email: "doctor@example.com", + refreshToken: "doctor-refresh", + accessToken: "concurrent-access", + expiresAt: now - 30_000, + accountId: "doctor-account", + accountIdSource: "manual" as const, + accountLabel: "Concurrent Label", + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: { + codex: 0, + "codex-max": 0, + "gpt-5-codex": 0, + "gpt-5.1": 0, + "gpt-5.2": 0, + }, + }, + async (nextStorage: unknown) => { + persistedAccountStorage = nextStorage; + }, + ), + ); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "doctor-access-next", + refresh: "doctor-refresh-next", + expires: now + 3_600_000, + idToken: "doctor-id-next", + }); + extractAccountEmailMock.mockImplementation((accessToken: string | undefined) => + accessToken === "doctor-access-next" ? "doctor-fresh@example.com" : "doctor@example.com" + ); + extractAccountIdMock.mockImplementation((accessToken: string | undefined) => + accessToken === "doctor-access-next" ? "doctor-token-account" : "doctor-account" + ); + setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runDoctor( + ["--json", "--fix"], + createDeps({ + hasUsableAccessToken: () => false, + }), + ); + + expect(exitCode).toBe(0); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(persistedAccountStorage).toMatchObject({ + accounts: [ + expect.objectContaining({ + accountLabel: "Concurrent Label", + accessToken: "doctor-access-next", + refreshToken: "doctor-refresh-next", + }), + ], + }); + const payload = JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")) as { + checks: Array<{ key: string; severity: string; message: string }>; + fix: { + changed: boolean; + actions: Array<{ key: string }>; + }; + }; + expect(payload.fix.changed).toBe(true); + expect(payload.fix.actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: "doctor-refresh" }), + expect.objectContaining({ key: "codex-active-sync" }), + ]), + ); + expect(payload.checks).toContainEqual( + expect.objectContaining({ + key: "auto-fix", + severity: "warn", + message: expect.stringMatching(/Applied \d+ fix\(es\)/), + }), + ); + }); + + it("runDoctor records active-index fixes when normalization changes the snapshot", async () => { + const now = Date.now(); + let persistedAccountStorage: unknown; + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + accounts: [ + { + email: "doctor@example.com", + refreshToken: "doctor-refresh", + accessToken: "doctor-access", + expiresAt: now + 60_000, + accountId: "doctor-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 7, + activeIndexByFamily: { + codex: 7, + "codex-max": 7, + "gpt-5-codex": 7, + }, + }); + withAccountStorageTransactionMock.mockImplementation(async (handler) => + handler( + { + version: 3, + accounts: [ + { + email: "doctor@example.com", + refreshToken: "doctor-refresh", + accessToken: "doctor-access", + expiresAt: now + 60_000, + accountId: "doctor-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 7, + activeIndexByFamily: { + codex: 7, + "codex-max": 7, + "gpt-5-codex": 7, + }, + }, + async (nextStorage: unknown) => { + persistedAccountStorage = nextStorage; + }, + ), + ); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runDoctor( + ["--json", "--fix"], + createDeps({ + hasUsableAccessToken: () => true, + }), + ); + + expect(exitCode).toBe(0); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(persistedAccountStorage).toMatchObject({ + activeIndex: 0, + activeIndexByFamily: { + codex: 0, + "codex-max": 0, + "gpt-5-codex": 0, + }, + }); + const payload = JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")) as { + fix: { + changed: boolean; + actions: Array<{ key: string }>; + }; + }; + expect(payload.fix.changed).toBe(true); + expect(payload.fix.actions).toContainEqual( + expect.objectContaining({ key: "active-index" }), + ); + }); + + it("runDoctor keeps the prescan snapshot unchanged when the transaction is already fixed", async () => { + const now = Date.now(); + let persistedAccountStorage: unknown; + const prescanStorage = { + version: 3, + accounts: [ + { + email: "doctor@example.com", + refreshToken: "doctor-refresh", + accessToken: "doctor-access", + expiresAt: now + 60_000, + accountId: "doctor-account", + accountIdSource: "manual" as const, + enabled: true, + }, + { + email: "doctor+duplicate@example.com", + refreshToken: "doctor-refresh", + accessToken: "doctor-access-duplicate", + expiresAt: now + 60_000, + accountId: "doctor-duplicate", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + loadAccountsMock.mockResolvedValueOnce(prescanStorage); + withAccountStorageTransactionMock.mockImplementation(async (handler) => + handler( + { + version: 3, + accounts: [ + { + email: "doctor@example.com", + refreshToken: "doctor-refresh", + accessToken: "doctor-access", + expiresAt: now + 60_000, + accountId: "doctor-account", + accountIdSource: "manual" as const, + enabled: true, + }, + { + email: "doctor+duplicate@example.com", + refreshToken: "doctor-refresh-2", + accessToken: "doctor-access-duplicate", + expiresAt: now + 60_000, + accountId: "doctor-duplicate", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: { + codex: 0, + "codex-max": 0, + "gpt-5-codex": 0, + "gpt-5.1": 0, + "gpt-5.2": 0, + }, + }, + async (nextStorage: unknown) => { + persistedAccountStorage = nextStorage; + }, + ), + ); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runDoctor( + ["--json", "--fix"], + createDeps({ + hasUsableAccessToken: () => true, + }), + ); + + expect(exitCode).toBe(0); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(persistedAccountStorage).toBeUndefined(); + expect(prescanStorage.accounts[1]?.enabled).toBe(true); + const payload = JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")) as { + fix: { + changed: boolean; + actions: Array<{ key: string }>; + }; + }; + expect(payload.fix.changed).toBe(false); + expect(payload.fix.actions).toEqual([]); + }); + + it("runDoctor skips Codex sync when the refreshed account disappears before persistence", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + accounts: [ + { + email: "doctor@example.com", + refreshToken: "doctor-refresh", + accessToken: "doctor-access", + expiresAt: now - 60_000, + accountId: "doctor-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }); + withAccountStorageTransactionMock.mockImplementation(async (handler) => + handler( + { + version: 3, + accounts: [ + { + email: "remaining@example.com", + refreshToken: "remaining-refresh", + accessToken: "remaining-access", + expiresAt: now + 60_000, + accountId: "remaining-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: { + codex: 0, + "codex-max": 0, + "gpt-5-codex": 0, + "gpt-5.1": 0, + "gpt-5.2": 0, + }, + }, + async () => undefined, + ), + ); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "doctor-access-next", + refresh: "doctor-refresh-next", + expires: now + 3_600_000, + idToken: "doctor-id-next", + }); + extractAccountEmailMock.mockImplementation((accessToken: string | undefined) => + accessToken === "doctor-access-next" ? "doctor-fresh@example.com" : "doctor@example.com" + ); + extractAccountIdMock.mockImplementation((accessToken: string | undefined) => + accessToken === "doctor-access-next" ? "doctor-token-account" : "doctor-account" + ); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runDoctor( + ["--json", "--fix"], + createDeps({ + hasUsableAccessToken: () => false, + resolveActiveIndex: () => -1, + }), + ); + + expect(exitCode).toBe(1); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + const payload = JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")) as { + fix: { + changed: boolean; + actions: Array<{ key: string }>; + }; + }; + expect(payload.fix.changed).toBe(true); + expect(payload.fix.actions).not.toContainEqual( + expect.objectContaining({ key: "codex-active-sync" }), + ); + }); +}); diff --git a/test/storage.test.ts b/test/storage.test.ts index ccca65c0..a54f82ca 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -24,8 +24,10 @@ import { saveAccounts, setStoragePath, setStoragePathDirect, + clearFlaggedAccounts, withAccountAndFlaggedStorageTransaction, withAccountStorageTransaction, + withFlaggedStorageTransaction, } from "../lib/storage.js"; // Mocking the behavior we're about to implement for TDD @@ -862,6 +864,188 @@ describe("storage", () => { } }); + it("rolls back flagged storage when flagged-only transaction persistence fails", async () => { + const now = Date.now(); + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + accountId: "acct-flagged", + email: "flagged@example.com", + refreshToken: "refresh-flagged", + addedAt: now - 5_000, + lastUsed: now - 5_000, + flaggedAt: now - 5_000, + }, + ], + }); + + const originalRename = fs.rename.bind(fs); + let flaggedRenameAttempts = 0; + const renameSpy = vi.spyOn(fs, "rename").mockImplementation( + async (from, to) => { + if (String(to).endsWith("openai-codex-flagged-accounts.json")) { + flaggedRenameAttempts += 1; + if (flaggedRenameAttempts <= 5) { + const error = Object.assign( + new Error("flagged storage busy"), + { code: "EBUSY" }, + ); + throw error; + } + } + return originalRename(from, to); + }, + ); + + try { + await expect( + withFlaggedStorageTransaction(async (current, persist) => { + await persist({ + ...current, + accounts: [ + ...current.accounts, + { + accountId: "acct-restored", + email: "restored@example.com", + refreshToken: "refresh-restored", + addedAt: now, + lastUsed: now, + flaggedAt: now, + }, + ], + }); + }), + ).rejects.toThrow("flagged storage busy"); + expect(flaggedRenameAttempts).toBe(6); + } finally { + renameSpy.mockRestore(); + } + + const loadedFlagged = await loadFlaggedAccounts(); + expect(loadedFlagged.accounts).toHaveLength(1); + expect(loadedFlagged.accounts[0]).toEqual( + expect.objectContaining({ + accountId: "acct-flagged", + refreshToken: "refresh-flagged", + }), + ); + }); + + it("passes the live flagged snapshot into account+flagged transactions", async () => { + const now = Date.now(); + await saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + accountId: "acct-existing", + email: "existing@example.com", + refreshToken: "refresh-existing", + addedAt: now - 10_000, + lastUsed: now - 10_000, + }, + ], + }); + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + accountId: "acct-pre-scan", + email: "pre-scan@example.com", + refreshToken: "refresh-pre-scan", + addedAt: now - 5_000, + lastUsed: now - 5_000, + flaggedAt: now - 5_000, + }, + ], + }); + + const preScanFlagged = await loadFlaggedAccounts(); + expect(preScanFlagged.accounts[0]?.refreshToken).toBe("refresh-pre-scan"); + + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + accountId: "acct-live", + email: "live@example.com", + refreshToken: "refresh-live", + addedAt: now - 1_000, + lastUsed: now - 1_000, + flaggedAt: now - 1_000, + }, + ], + }); + + await withAccountAndFlaggedStorageTransaction( + async (current, persist, currentFlagged) => { + expect(current?.accounts).toHaveLength(1); + expect(currentFlagged.accounts).toHaveLength(1); + expect(currentFlagged.accounts[0]?.refreshToken).toBe("refresh-live"); + + currentFlagged.accounts[0]!.refreshToken = "mutated-only"; + + await persist(current!, { + version: 1, + accounts: [ + { + accountId: "acct-persisted", + email: "persisted@example.com", + refreshToken: "refresh-persisted", + addedAt: now, + lastUsed: now, + flaggedAt: now, + }, + ], + }); + }, + ); + + const loadedFlagged = await loadFlaggedAccounts(); + expect(loadedFlagged.accounts).toHaveLength(1); + expect(loadedFlagged.accounts[0]).toEqual( + expect.objectContaining({ + accountId: "acct-persisted", + refreshToken: "refresh-persisted", + }), + ); + }); + + it("treats missing flagged storage as empty inside flagged transactions", async () => { + const now = Date.now(); + await saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + accountId: "acct-existing", + email: "existing@example.com", + refreshToken: "refresh-existing", + addedAt: now - 10_000, + lastUsed: now - 10_000, + }, + ], + }); + await clearFlaggedAccounts(); + + await expect( + withFlaggedStorageTransaction(async (current) => { + expect(current).toEqual({ version: 1, accounts: [] }); + }), + ).resolves.toBeUndefined(); + + await expect( + withAccountAndFlaggedStorageTransaction( + async (_current, _persist, currentFlagged) => { + expect(currentFlagged).toEqual({ version: 1, accounts: [] }); + }, + ), + ).resolves.toBeUndefined(); + }); + it("retries transient flagged storage rename and succeeds", async () => { const now = Date.now(); await saveFlaggedAccounts({ @@ -951,6 +1135,7 @@ describe("storage", () => { it("should fail export when no accounts exist", async () => { const { exportAccounts } = await import("../lib/storage.js"); setStoragePathDirect(testStoragePath); + await clearAccounts(); await expect(exportAccounts(exportPath)).rejects.toThrow( /No accounts to export/, );