From 7b98866f2f2b466568ebb7a0bf43e6bbc5f16857 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 10:57:09 +0800 Subject: [PATCH 1/3] refactor: extract fix command --- lib/codex-manager.ts | 472 ++-------------------- lib/codex-manager/commands/fix.ts | 538 +++++++++++++++++++++++++ test/codex-manager-fix-command.test.ts | 92 +++++ 3 files changed, 667 insertions(+), 435 deletions(-) create mode 100644 lib/codex-manager/commands/fix.ts create mode 100644 test/codex-manager-fix-command.test.ts diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index ac33877b..b1c32ea7 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -43,6 +43,10 @@ import { type DoctorCliOptions, runDoctorCommand, } from "./codex-manager/commands/doctor.js"; +import { + type FixCliOptions, + runFixCommand, +} from "./codex-manager/commands/fix.js"; import { runForecastCommand } from "./codex-manager/commands/forecast.js"; import { runReportCommand } from "./codex-manager/commands/report.js"; import { @@ -107,7 +111,7 @@ import { withAccountAndFlaggedStorageTransaction, withAccountStorageTransaction, } from "./storage.js"; -import type { AccountIdSource, TokenFailure, TokenResult } from "./types.js"; +import type { AccountIdSource, TokenResult } from "./types.js"; import { ANSI } from "./ui/ansi.js"; import { confirm } from "./ui/confirm.js"; import { UI_COPY } from "./ui/copy.js"; @@ -2254,13 +2258,6 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { ); } -interface FixCliOptions { - dryRun: boolean; - json: boolean; - live: boolean; - model: string; -} - type ParsedArgsResult = | { ok: true; options: T } | { ok: false; message: string }; @@ -2512,38 +2509,6 @@ async function runForecast(args: string[]): Promise { }); } -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 }; -} - function createEmptyAccountStorage(): AccountStorageV3 { const activeIndexByFamily: Partial> = {}; for (const family of MODEL_FAMILIES) { @@ -2703,401 +2668,38 @@ function upsertRecoveredFlaggedAccount( } async function runFix(args: string[]): 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 ? cloneQuotaCacheData(quotaCache) : null; - let quotaCacheChanged = false; - - setStoragePath(null); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - console.log("No accounts configured."); - return 0; - } - 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[] = []; - - 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 (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 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); - } - 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, - ), - ); - 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; + return runFixCommand(args, { + setStoragePath, + loadAccounts, + parseFixArgs, + printFixUsage, + loadQuotaCache, + saveQuotaCache, + cloneQuotaCacheData, + buildQuotaEmailFallbackState, + updateQuotaCacheForAccount, + pruneUnsafeQuotaEmailCacheEntry, + resolveActiveIndex, + hasUsableAccessToken, + fetchCodexQuotaSnapshot, + formatCompactQuotaSnapshot, + normalizeFailureDetail, + hasLikelyInvalidRefreshToken, + queuedRefresh, + sanitizeEmail, + extractAccountEmail, + extractAccountId, + applyTokenAccountIdentity, + isHardRefreshFailure, + evaluateForecastAccounts, + recommendForecastAccount, + saveAccounts, + formatAccountLabel, + stylePromptText, + formatResultSummary, + styleAccountDetailText, + defaultDisplay: DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + }); } interface DoctorFixAction { diff --git a/lib/codex-manager/commands/fix.ts b/lib/codex-manager/commands/fix.ts new file mode 100644 index 00000000..9201e55e --- /dev/null +++ b/lib/codex-manager/commands/fix.ts @@ -0,0 +1,538 @@ +import type { DashboardDisplaySettings } from "../../dashboard-settings.js"; +import type { + ForecastAccountResult, + ForecastRecommendation, +} from "../../forecast.js"; +import type { QuotaCacheData } from "../../quota-cache.js"; +import type { CodexQuotaSnapshot } from "../../quota-probe.js"; +import type { AccountStorageV3 } from "../../storage.js"; +import type { TokenFailure, TokenResult } from "../../types.js"; + +export interface FixCliOptions { + dryRun: boolean; + json: boolean; + live: boolean; + model: string; +} + +type ParsedArgsResult = + | { ok: true; options: T } + | { ok: false; message: string }; + +type QuotaEmailFallbackState = ReadonlyMap< + string, + { matchingCount: number; distinctAccountIds: Set } +>; + +type FixOutcome = + | "healthy" + | "disabled-hard-failure" + | "warning-soft-failure" + | "already-disabled"; + +export interface FixAccountReport { + index: number; + label: string; + outcome: FixOutcome; + message: string; +} + +export interface FixCommandDeps { + setStoragePath: (path: string | null) => void; + loadAccounts: () => Promise; + parseFixArgs: (args: string[]) => ParsedArgsResult; + printFixUsage: () => void; + loadQuotaCache: () => Promise; + saveQuotaCache: (cache: QuotaCacheData) => Promise; + cloneQuotaCacheData: (cache: QuotaCacheData) => QuotaCacheData; + buildQuotaEmailFallbackState: ( + accounts: AccountStorageV3["accounts"], + ) => QuotaEmailFallbackState; + updateQuotaCacheForAccount: ( + cache: QuotaCacheData, + account: AccountStorageV3["accounts"][number], + snapshot: CodexQuotaSnapshot, + accounts: AccountStorageV3["accounts"], + emailFallbackState?: QuotaEmailFallbackState, + ) => boolean; + pruneUnsafeQuotaEmailCacheEntry: ( + cache: QuotaCacheData, + previousEmail: string | undefined, + accounts: AccountStorageV3["accounts"], + emailFallbackState: QuotaEmailFallbackState, + ) => boolean; + resolveActiveIndex: (storage: AccountStorageV3, family?: "codex") => number; + hasUsableAccessToken: ( + account: { accessToken?: string; expiresAt?: number }, + now: number, + ) => boolean; + fetchCodexQuotaSnapshot: (input: { + accountId: string; + accessToken: string; + model: string; + }) => Promise; + formatCompactQuotaSnapshot: (snapshot: CodexQuotaSnapshot) => string; + normalizeFailureDetail: ( + message: string | undefined, + reason: string | undefined, + ) => string; + hasLikelyInvalidRefreshToken: (refreshToken: string) => boolean; + queuedRefresh: (refreshToken: string) => Promise; + sanitizeEmail: (email: string | undefined) => string | undefined; + extractAccountEmail: ( + accessToken: string | undefined, + idToken?: string | undefined, + ) => string | undefined; + extractAccountId: (accessToken: string | undefined) => string | undefined; + applyTokenAccountIdentity: ( + account: AccountStorageV3["accounts"][number], + accountId: string | undefined, + ) => boolean; + isHardRefreshFailure: ( + result: Exclude, + ) => boolean; + evaluateForecastAccounts: ( + inputs: Array<{ + index: number; + account: AccountStorageV3["accounts"][number]; + isCurrent: boolean; + now: number; + refreshFailure?: TokenFailure; + }>, + ) => ForecastAccountResult[]; + recommendForecastAccount: ( + results: ForecastAccountResult[], + ) => ForecastRecommendation; + saveAccounts: (storage: AccountStorageV3) => Promise; + formatAccountLabel: ( + account: AccountStorageV3["accounts"][number], + index: number, + ) => string; + stylePromptText: ( + text: string, + tone: "accent" | "success" | "warning" | "danger" | "muted", + ) => string; + formatResultSummary: ( + segments: ReadonlyArray<{ + text: string; + tone: "accent" | "success" | "warning" | "danger" | "muted"; + }>, + ) => string; + styleAccountDetailText: ( + detail: string, + fallbackTone?: "accent" | "success" | "warning" | "danger" | "muted", + ) => string; + defaultDisplay: DashboardDisplaySettings; + logInfo?: (message: string) => void; + logError?: (message: string) => void; + getNow?: () => number; +} + +export 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 }; +} + +export async function runFixCommand( + args: string[], + deps: FixCommandDeps, +): Promise { + const logInfo = deps.logInfo ?? console.log; + const logError = deps.logError ?? console.error; + if (args.includes("--help") || args.includes("-h")) { + deps.printFixUsage(); + return 0; + } + const parsedArgs = deps.parseFixArgs(args); + if (!parsedArgs.ok) { + logError(parsedArgs.message); + deps.printFixUsage(); + return 1; + } + const options = parsedArgs.options; + const display = deps.defaultDisplay; + const quotaCache = options.live ? await deps.loadQuotaCache() : null; + const workingQuotaCache = quotaCache + ? deps.cloneQuotaCacheData(quotaCache) + : null; + let quotaCacheChanged = false; + + deps.setStoragePath(null); + const storage = await deps.loadAccounts(); + if (!storage || storage.accounts.length === 0) { + logInfo("No accounts configured."); + return 0; + } + let quotaEmailFallbackState = + options.live && quotaCache + ? deps.buildQuotaEmailFallbackState(storage.accounts) + : null; + + const now = deps.getNow?.() ?? Date.now(); + const activeIndex = deps.resolveActiveIndex(storage, "codex"); + let changed = 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 = deps.formatAccountLabel(account, i); + + if (account.enabled === false) { + reports.push({ + index: i, + label, + outcome: "already-disabled", + message: "already disabled", + }); + continue; + } + + if (deps.hasUsableAccessToken(account, now)) { + if (options.live) { + const currentAccessToken = account.accessToken; + const probeAccountId = currentAccessToken + ? (account.accountId ?? deps.extractAccountId(currentAccessToken)) + : undefined; + if (probeAccountId && currentAccessToken) { + try { + const snapshot = await deps.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 (error) { + const message = deps.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 = 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 deps.queuedRefresh(account.refreshToken); + if (refreshResult.type === "success") { + const nextEmail = deps.sanitizeEmail( + deps.extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); + const nextAccountId = deps.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) changed = 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 deps.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 (deps.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 = deps.evaluateForecastAccounts( + storage.accounts.map((account, index) => ({ + index, + account, + isCurrent: index === activeIndex, + now, + refreshFailure: refreshFailures.get(index), + })), + ); + const recommendation = deps.recommendForecastAccount(forecastResults); + const reportSummary = summarizeFixReports(reports); + + if (changed && !options.dryRun) await deps.saveAccounts(storage); + if (options.json) { + if (workingQuotaCache && quotaCacheChanged) + await deps.saveQuotaCache(workingQuotaCache); + logInfo( + 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, + ), + ); + return 0; + } + + logInfo( + deps.stylePromptText( + `Auto-fix scan (${options.dryRun ? "preview" : "apply"})`, + "accent", + ), + ); + logInfo( + 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) { + logInfo(""); + 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"; + logInfo( + `${deps.stylePromptText(prefix, tone)} ${deps.stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${deps.stylePromptText("|", "muted")} ${deps.styleAccountDetailText(report.message, tone === "success" ? "muted" : tone)}`, + ); + } + } else { + logInfo(""); + logInfo( + deps.stylePromptText( + "Per-account lines are hidden in dashboard settings.", + "muted", + ), + ); + } + if (display.showRecommendations) { + logInfo(""); + if (recommendation.recommendedIndex !== null) { + const target = recommendation.recommendedIndex + 1; + logInfo( + `${deps.stylePromptText("Best next account:", "accent")} ${deps.stylePromptText(String(target), "success")}`, + ); + logInfo( + `${deps.stylePromptText("Why:", "accent")} ${deps.stylePromptText(recommendation.reason, "muted")}`, + ); + if (recommendation.recommendedIndex !== activeIndex) { + logInfo( + `${deps.stylePromptText("Switch now with:", "accent")} codex auth switch ${target}`, + ); + } + } else { + logInfo( + `${deps.stylePromptText("Note:", "accent")} ${deps.stylePromptText(recommendation.reason, "muted")}`, + ); + } + } + if (workingQuotaCache && quotaCacheChanged) + await deps.saveQuotaCache(workingQuotaCache); + if (changed && options.dryRun) + logInfo( + `\n${deps.stylePromptText("Preview only: no changes were saved.", "warning")}`, + ); + else if (changed) + logInfo(`\n${deps.stylePromptText("Saved updates.", "success")}`); + else logInfo(`\n${deps.stylePromptText("No changes were needed.", "muted")}`); + return 0; +} diff --git a/test/codex-manager-fix-command.test.ts b/test/codex-manager-fix-command.test.ts new file mode 100644 index 00000000..6ac130a2 --- /dev/null +++ b/test/codex-manager-fix-command.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import { + runFixCommand, + type FixCliOptions, + type FixCommandDeps, +} from "../lib/codex-manager/commands/fix.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +function createStorage(): AccountStorageV3 { + return { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; +} + +function createDeps(overrides: Partial = {}): FixCommandDeps { + return { + setStoragePath: vi.fn(), + loadAccounts: vi.fn(async () => createStorage()), + parseFixArgs: vi.fn((args: string[]) => { + if (args.includes("--bad")) return { ok: false as const, message: "Unknown option: --bad" }; + return { ok: true as const, options: { dryRun: false, json: true, live: false, model: "gpt-5-codex" } satisfies FixCliOptions }; + }), + printFixUsage: vi.fn(), + loadQuotaCache: vi.fn(async () => null), + saveQuotaCache: vi.fn(async () => undefined), + cloneQuotaCacheData: vi.fn((cache) => structuredClone(cache)), + buildQuotaEmailFallbackState: vi.fn(() => new Map()), + updateQuotaCacheForAccount: vi.fn(() => false), + pruneUnsafeQuotaEmailCacheEntry: vi.fn(() => false), + resolveActiveIndex: vi.fn(() => 0), + hasUsableAccessToken: vi.fn(() => true), + fetchCodexQuotaSnapshot: vi.fn(async () => ({ status: 200, model: "gpt-5-codex", primary: {}, secondary: {} })), + formatCompactQuotaSnapshot: vi.fn(() => "5h 75%"), + normalizeFailureDetail: vi.fn((message) => message ?? "unknown"), + hasLikelyInvalidRefreshToken: vi.fn(() => false), + queuedRefresh: vi.fn(async () => ({ type: "success", access: "access-fix", refresh: "refresh-fix", expires: Date.now() + 60_000 })), + sanitizeEmail: vi.fn((email) => email), + extractAccountEmail: vi.fn(() => undefined), + extractAccountId: vi.fn(() => undefined), + applyTokenAccountIdentity: vi.fn(() => false), + isHardRefreshFailure: vi.fn(() => false), + evaluateForecastAccounts: vi.fn(() => []), + recommendForecastAccount: vi.fn(() => ({ recommendedIndex: null, reason: "none" })), + saveAccounts: vi.fn(async () => undefined), + formatAccountLabel: vi.fn((_account, index) => `${index + 1}. fix@example.com`), + stylePromptText: vi.fn((text) => text), + formatResultSummary: vi.fn((segments) => segments.map((segment) => segment.text).join(" | ")), + styleAccountDetailText: vi.fn((text) => text), + defaultDisplay: { + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }, + logInfo: vi.fn(), + logError: vi.fn(), + getNow: vi.fn(() => 1_000), + ...overrides, + }; +} + +describe("runFixCommand", () => { + it("prints usage for help", async () => { + const deps = createDeps(); + const result = await runFixCommand(["--help"], deps); + expect(result).toBe(0); + expect(deps.printFixUsage).toHaveBeenCalled(); + }); + + it("rejects invalid options", async () => { + const deps = createDeps(); + const result = await runFixCommand(["--bad"], deps); + expect(result).toBe(1); + expect(deps.logError).toHaveBeenCalledWith("Unknown option: --bad"); + }); + + it("prints json output for empty storage", async () => { + const deps = createDeps(); + const result = await runFixCommand([], deps); + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith(expect.stringContaining("No accounts configured.")); + }); +}); From 4222e88e3219f19423bf49dcaed51e407265c77c Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:00:20 +0800 Subject: [PATCH 2/3] fix: fall back cleanly from live fix probes --- lib/codex-manager/commands/fix.ts | 69 +++++++++------ test/codex-manager-fix-command.test.ts | 117 ++++++++++++++++++++++++- 2 files changed, 158 insertions(+), 28 deletions(-) diff --git a/lib/codex-manager/commands/fix.ts b/lib/codex-manager/commands/fix.ts index 9201e55e..8cabdd17 100644 --- a/lib/codex-manager/commands/fix.ts +++ b/lib/codex-manager/commands/fix.ts @@ -174,7 +174,30 @@ export async function runFixCommand( deps.setStoragePath(null); const storage = await deps.loadAccounts(); if (!storage || storage.accounts.length === 0) { - logInfo("No accounts configured."); + if (options.json) { + logInfo( + 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 { + logInfo("No accounts configured."); + } return 0; } let quotaEmailFallbackState = @@ -205,6 +228,7 @@ export async function runFixCommand( } if (deps.hasUsableAccessToken(account, now)) { + let needsRefresh = false; if (options.live) { const currentAccessToken = account.accessToken; const probeAccountId = currentAccessToken @@ -235,33 +259,26 @@ export async function runFixCommand( : "live session OK", }); 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: `live probe failed (${message}), trying refresh fallback`, - }); + } catch { + needsRefresh = true; } } } - 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; + if (!needsRefresh) { + 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 deps.queuedRefresh(account.refreshToken); @@ -426,7 +443,7 @@ export async function runFixCommand( if (changed && !options.dryRun) await deps.saveAccounts(storage); if (options.json) { - if (workingQuotaCache && quotaCacheChanged) + if (workingQuotaCache && quotaCacheChanged && !options.dryRun) await deps.saveQuotaCache(workingQuotaCache); logInfo( JSON.stringify( @@ -525,7 +542,7 @@ export async function runFixCommand( ); } } - if (workingQuotaCache && quotaCacheChanged) + if (workingQuotaCache && quotaCacheChanged && !options.dryRun) await deps.saveQuotaCache(workingQuotaCache); if (changed && options.dryRun) logInfo( diff --git a/test/codex-manager-fix-command.test.ts b/test/codex-manager-fix-command.test.ts index 6ac130a2..33beedc2 100644 --- a/test/codex-manager-fix-command.test.ts +++ b/test/codex-manager-fix-command.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { + type FixAccountReport, runFixCommand, type FixCliOptions, type FixCommandDeps, @@ -84,9 +85,121 @@ describe("runFixCommand", () => { }); it("prints json output for empty storage", async () => { - const deps = createDeps(); + const deps = createDeps({ + loadAccounts: vi.fn(async () => null), + }); const result = await runFixCommand([], deps); expect(result).toBe(0); - expect(deps.logInfo).toHaveBeenCalledWith(expect.stringContaining("No accounts configured.")); + const payload = JSON.parse(String((deps.logInfo as ReturnType).mock.calls[0]?.[0])) as { + command: string; + reports: FixAccountReport[]; + recommendation: { recommendedIndex: number | null; reason: string }; + }; + expect(payload.command).toBe("fix"); + expect(payload.reports).toEqual([]); + expect(payload.recommendation).toEqual({ + recommendedIndex: null, + reason: "No accounts configured.", + }); + }); + + it("falls back to refresh when live probe fails for a usable access token", async () => { + const storage = createStorage(); + storage.accounts.push({ + email: "fix@example.com", + refreshToken: "refresh-token", + accessToken: "access-token", + accountId: "acc_1", + expiresAt: 9_999, + addedAt: 0, + lastUsed: 0, + enabled: true, + }); + const deps = createDeps({ + loadAccounts: vi.fn(async () => structuredClone(storage)), + parseFixArgs: vi.fn(() => ({ + ok: true as const, + options: { + dryRun: false, + json: true, + live: true, + model: "gpt-5-codex", + } satisfies FixCliOptions, + })), + fetchCodexQuotaSnapshot: vi + .fn() + .mockRejectedValueOnce(new Error("probe exploded")) + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }), + extractAccountId: vi.fn((accessToken?: string) => + accessToken ? "acc_1" : undefined, + ), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "access-refreshed", + refresh: "refresh-refreshed", + expires: 8_000, + idToken: "id-token", + })), + }); + + const result = await runFixCommand([], deps); + + expect(result).toBe(0); + expect(deps.queuedRefresh).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String((deps.logInfo as ReturnType).mock.calls[0]?.[0])) as { + reports: Array<{ outcome: string; message: string }>; + }; + expect(payload.reports).toHaveLength(1); + expect(payload.reports[0]).toMatchObject({ + outcome: "healthy", + }); + expect(payload.reports[0]?.message).toContain( + "refresh + live probe succeeded", + ); + }); + + it("does not persist quota cache during dry-run", async () => { + const storage = createStorage(); + storage.accounts.push({ + email: "fix@example.com", + refreshToken: "refresh-token", + accessToken: "access-token", + accountId: "acc_1", + expiresAt: 9_999, + addedAt: 0, + lastUsed: 0, + enabled: true, + }); + const deps = createDeps({ + loadAccounts: vi.fn(async () => structuredClone(storage)), + parseFixArgs: vi.fn(() => ({ + ok: true as const, + options: { + dryRun: true, + json: true, + live: true, + model: "gpt-5-codex", + } satisfies FixCliOptions, + })), + loadQuotaCache: vi.fn(async () => ({ + version: 1, + byAccountId: {}, + byEmail: {}, + })), + updateQuotaCacheForAccount: vi.fn(() => true), + extractAccountId: vi.fn((accessToken?: string) => + accessToken ? "acc_1" : undefined, + ), + }); + + const result = await runFixCommand([], deps); + + expect(result).toBe(0); + expect(deps.saveQuotaCache).not.toHaveBeenCalled(); }); }); From 740ab50d7d3137cfb562d26900b8666d31a749fc Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:42:24 +0800 Subject: [PATCH 3/3] fix: preserve live probe fallback reporting --- lib/codex-manager/commands/fix.ts | 40 ++++++----- test/codex-manager-fix-command.test.ts | 95 ++++++++++++++++++++------ 2 files changed, 97 insertions(+), 38 deletions(-) diff --git a/lib/codex-manager/commands/fix.ts b/lib/codex-manager/commands/fix.ts index 8cabdd17..74d89cb3 100644 --- a/lib/codex-manager/commands/fix.ts +++ b/lib/codex-manager/commands/fix.ts @@ -228,7 +228,6 @@ export async function runFixCommand( } if (deps.hasUsableAccessToken(account, now)) { - let needsRefresh = false; if (options.live) { const currentAccessToken = account.accessToken; const probeAccountId = currentAccessToken @@ -259,26 +258,33 @@ export async function runFixCommand( : "live session OK", }); continue; - } catch { - needsRefresh = true; + } catch (error) { + const message = deps.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`, + }); } } } - if (!needsRefresh) { - 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 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 deps.queuedRefresh(account.refreshToken); diff --git a/test/codex-manager-fix-command.test.ts b/test/codex-manager-fix-command.test.ts index 33beedc2..f86e0bd7 100644 --- a/test/codex-manager-fix-command.test.ts +++ b/test/codex-manager-fix-command.test.ts @@ -103,7 +103,7 @@ describe("runFixCommand", () => { }); }); - it("falls back to refresh when live probe fails for a usable access token", async () => { + it("keeps usable access tokens healthy when the live probe fails", async () => { const storage = createStorage(); storage.accounts.push({ email: "fix@example.com", @@ -128,42 +128,31 @@ describe("runFixCommand", () => { })), fetchCodexQuotaSnapshot: vi .fn() - .mockRejectedValueOnce(new Error("probe exploded")) - .mockResolvedValueOnce({ - status: 200, - model: "gpt-5-codex", - primary: {}, - secondary: {}, - }), + .mockRejectedValueOnce(new Error("probe exploded")), extractAccountId: vi.fn((accessToken?: string) => accessToken ? "acc_1" : undefined, ), - queuedRefresh: vi.fn(async () => ({ - type: "success", - access: "access-refreshed", - refresh: "refresh-refreshed", - expires: 8_000, - idToken: "id-token", - })), }); const result = await runFixCommand([], deps); expect(result).toBe(0); - expect(deps.queuedRefresh).toHaveBeenCalledTimes(1); + expect(deps.queuedRefresh).not.toHaveBeenCalled(); const payload = JSON.parse(String((deps.logInfo as ReturnType).mock.calls[0]?.[0])) as { reports: Array<{ outcome: string; message: string }>; }; - expect(payload.reports).toHaveLength(1); + expect(payload.reports).toHaveLength(2); expect(payload.reports[0]).toMatchObject({ + outcome: "warning-soft-failure", + message: "live probe failed (probe exploded), trying refresh fallback", + }); + expect(payload.reports[1]).toMatchObject({ outcome: "healthy", + message: "access token still valid", }); - expect(payload.reports[0]?.message).toContain( - "refresh + live probe succeeded", - ); }); - it("does not persist quota cache during dry-run", async () => { + it("does not persist accounts or quota cache during dry-run json mode", async () => { const storage = createStorage(); storage.accounts.push({ email: "fix@example.com", @@ -200,6 +189,70 @@ describe("runFixCommand", () => { const result = await runFixCommand([], deps); expect(result).toBe(0); + expect(deps.saveAccounts).not.toHaveBeenCalled(); expect(deps.saveQuotaCache).not.toHaveBeenCalled(); }); + + it("renders preview output without saving in human mode", async () => { + const storage = createStorage(); + storage.accounts.push({ + email: "fix@example.com", + refreshToken: "refresh-token", + accessToken: "access-token", + accountId: "acc_1", + expiresAt: 900, + addedAt: 0, + lastUsed: 0, + enabled: true, + }); + const deps = createDeps({ + loadAccounts: vi.fn(async () => structuredClone(storage)), + parseFixArgs: vi.fn(() => ({ + ok: true as const, + options: { + dryRun: true, + json: false, + live: false, + model: "gpt-5-codex", + } satisfies FixCliOptions, + })), + hasUsableAccessToken: vi.fn(() => false), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "access-refreshed", + refresh: "refresh-refreshed", + expires: 8_000, + idToken: "id-token", + })), + }); + + const result = await runFixCommand([], deps); + + expect(result).toBe(0); + expect(deps.stylePromptText).toHaveBeenCalledWith( + "Auto-fix scan (preview)", + "accent", + ); + expect(deps.formatResultSummary).toHaveBeenCalledWith([ + { text: "1 working", tone: "success" }, + { text: "0 disabled", tone: "muted" }, + { text: "0 warnings", tone: "muted" }, + { text: "0 already disabled", tone: "muted" }, + ]); + expect(deps.styleAccountDetailText).toHaveBeenCalledWith( + "refresh succeeded", + "muted", + ); + expect(deps.saveAccounts).not.toHaveBeenCalled(); + expect(deps.saveQuotaCache).not.toHaveBeenCalled(); + + const infoLines = (deps.logInfo as ReturnType).mock.calls.map( + ([message]) => String(message), + ); + expect(infoLines).toContain("Auto-fix scan (preview)"); + expect(infoLines).toContain( + "1 working | 0 disabled | 0 warnings | 0 already disabled", + ); + expect(infoLines).toContain("\nPreview only: no changes were saved."); + }); });