diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index bae76b84..5a86b96e 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -25,6 +25,7 @@ Override root: | Accounts backup | `~/.codex/multi-auth/openai-codex-accounts.json.bak` | | Accounts WAL | `~/.codex/multi-auth/openai-codex-accounts.json.wal` | | Flagged accounts | `~/.codex/multi-auth/openai-codex-flagged-accounts.json` | +| Flagged accounts backup | `~/.codex/multi-auth/openai-codex-flagged-accounts.json.bak` | | Quota cache | `~/.codex/multi-auth/quota-cache.json` | | Logs | `~/.codex/multi-auth/logs/codex-plugin/` | | Cache | `~/.codex/multi-auth/cache/` | @@ -36,6 +37,10 @@ Ownership note: - `~/.codex/multi-auth/*` is managed by this project. - `~/.codex/accounts.json` and `~/.codex/auth.json` are managed by official Codex CLI. +Backup metadata: + +- `getBackupMetadata()` reports deterministic snapshot lists for the canonical account pool and flagged-account state (primary, WAL, `.bak`, `.bak.1`, `.bak.2`, and discovered manual backups). Cache-like artifacts are excluded from recovery candidates. + --- ## Project-Scoped Account Paths diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 38e66050..57014c5f 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -1,34 +1,10 @@ -import { - getLastAccountsSaveTimestamp, - type AccountMetadataV3, - type AccountStorageV3, -} from "../storage.js"; -import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; import { createLogger } from "../logger.js"; -import { loadCodexCliState, type CodexCliAccountSnapshot } from "./state.js"; -import { - incrementCodexCliMetric, - makeAccountFingerprint, -} from "./observability.js"; -import { getLastCodexCliSelectionWriteTimestamp } from "./writer.js"; +import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; +import { type AccountStorageV3 } from "../storage.js"; +import { incrementCodexCliMetric } from "./observability.js"; const log = createLogger("codex-cli-sync"); -function normalizeEmail(value: string | undefined): string | undefined { - if (!value) return undefined; - const trimmed = value.trim().toLowerCase(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function createEmptyStorage(): AccountStorageV3 { - return { - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - }; -} - function cloneStorage(storage: AccountStorageV3): AccountStorageV3 { return { version: 3, @@ -40,150 +16,6 @@ function cloneStorage(storage: AccountStorageV3): AccountStorageV3 { }; } -function buildIndexByAccountId(accounts: AccountMetadataV3[]): Map { - const map = new Map(); - for (let i = 0; i < accounts.length; i += 1) { - const account = accounts[i]; - if (!account?.accountId) continue; - map.set(account.accountId, i); - } - return map; -} - -function buildIndexByRefresh(accounts: AccountMetadataV3[]): Map { - const map = new Map(); - for (let i = 0; i < accounts.length; i += 1) { - const account = accounts[i]; - if (!account?.refreshToken) continue; - map.set(account.refreshToken, i); - } - return map; -} - -function buildIndexByEmail(accounts: AccountMetadataV3[]): Map { - const map = new Map(); - for (let i = 0; i < accounts.length; i += 1) { - const email = normalizeEmail(accounts[i]?.email); - if (!email) continue; - map.set(email, i); - } - return map; -} - -function toStorageAccount(snapshot: CodexCliAccountSnapshot): AccountMetadataV3 | null { - if (!snapshot.refreshToken) return null; - const now = Date.now(); - return { - accountId: snapshot.accountId, - accountIdSource: snapshot.accountId ? "token" : undefined, - email: snapshot.email, - refreshToken: snapshot.refreshToken, - accessToken: snapshot.accessToken, - expiresAt: snapshot.expiresAt, - enabled: true, - addedAt: now, - lastUsed: 0, - }; -} - -function upsertFromSnapshot( - accounts: AccountMetadataV3[], - snapshot: CodexCliAccountSnapshot, -): boolean { - const nextAccount = toStorageAccount(snapshot); - if (!nextAccount) return false; - - const byAccountId = buildIndexByAccountId(accounts); - const byRefresh = buildIndexByRefresh(accounts); - const byEmail = buildIndexByEmail(accounts); - const normalizedEmail = normalizeEmail(snapshot.email); - - let targetIndex: number | undefined; - if (snapshot.accountId && byAccountId.has(snapshot.accountId)) { - targetIndex = byAccountId.get(snapshot.accountId); - } else if (snapshot.refreshToken && byRefresh.has(snapshot.refreshToken)) { - targetIndex = byRefresh.get(snapshot.refreshToken); - } else if (normalizedEmail && byEmail.has(normalizedEmail)) { - targetIndex = byEmail.get(normalizedEmail); - } - - if (targetIndex === undefined) { - accounts.push(nextAccount); - return true; - } - - const current = accounts[targetIndex]; - if (!current) return false; - - const merged: AccountMetadataV3 = { - ...current, - accountId: snapshot.accountId ?? current.accountId, - accountIdSource: - snapshot.accountId - ? current.accountIdSource ?? "token" - : current.accountIdSource, - email: snapshot.email ?? current.email, - refreshToken: snapshot.refreshToken ?? current.refreshToken, - accessToken: snapshot.accessToken ?? current.accessToken, - expiresAt: snapshot.expiresAt ?? current.expiresAt, - }; - - const changed = JSON.stringify(current) !== JSON.stringify(merged); - if (changed) { - accounts[targetIndex] = merged; - } - return changed; -} - -function resolveActiveIndex( - accounts: AccountMetadataV3[], - activeAccountId: string | undefined, - activeEmail: string | undefined, -): number { - if (accounts.length === 0) return 0; - - if (activeAccountId) { - const byId = accounts.findIndex((account) => account.accountId === activeAccountId); - if (byId >= 0) return byId; - } - - const normalizedEmail = normalizeEmail(activeEmail); - if (normalizedEmail) { - const byEmail = accounts.findIndex( - (account) => normalizeEmail(account.email) === normalizedEmail, - ); - if (byEmail >= 0) return byEmail; - } - - return 0; -} - -function writeFamilyIndexes( - storage: AccountStorageV3, - index: number, -): void { - storage.activeIndex = index; - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = index; - } -} - -/** - * Normalize and clamp the global and per-family active account indexes to valid ranges. - * - * Mutates `storage` in-place: ensures `activeIndexByFamily` exists, clamps `activeIndex` to - * 0..(accounts.length - 1) (or 0 when there are no accounts), and resolves each family entry - * to a valid index within the same bounds. - * - * Concurrency: callers must synchronize externally when multiple threads/processes may write - * the same storage object. Filesystem notes: no platform-specific IO is performed here; when - * persisted to disk on Windows consumers should still ensure atomic writes. Token handling: - * this function does not read or modify authentication tokens and makes no attempt to redact - * sensitive fields. - * - * @param storage - The account storage object whose indexes will be normalized and clamped - */ function normalizeStoredFamilyIndexes(storage: AccountStorageV3): void { const count = storage.accounts.length; const clamped = count === 0 ? 0 : Math.max(0, Math.min(storage.activeIndex, count - 1)); @@ -201,164 +33,51 @@ function normalizeStoredFamilyIndexes(storage: AccountStorageV3): void { } /** - * Return the `accountId` and `email` from the first snapshot marked active. - * - * @param snapshots - Array of Codex CLI account snapshots to search - * @returns The `accountId` and `email` from the first snapshot whose `isActive` is true; properties are omitted if no active snapshot is found - * - * Concurrency: pure and side-effect free; safe to call concurrently. - * Filesystem: behavior is independent of OS/filesystem semantics (including Windows). - * Security: only `accountId` and `email` are returned; other sensitive snapshot fields (for example tokens) are not exposed or returned by this function. - */ -function readActiveFromSnapshots( - snapshots: CodexCliAccountSnapshot[], -): { accountId?: string; email?: string } { - const active = snapshots.find((snapshot) => snapshot.isActive); - return { - accountId: active?.accountId, - email: active?.email, - }; -} - -/** - * Determines whether the Codex CLI's active-account selection should override the local selection. + * Preserves one-way mirror semantics for Codex CLI compatibility state. * - * Considers the state's numeric `syncVersion` or `sourceUpdatedAtMs` and compares the derived Codex timestamp - * against local timestamps from recent account saves and last Codex selection writes. Concurrent writes or - * clock skew can affect this decision; filesystem timestamp granularity on Windows may reduce timestamp precision. - * This function only examines timestamps and identifiers in `state` and does not read or expose token values. + * Multi-auth storage is the canonical source of truth. Codex CLI account files are mirrors only + * and must never seed, merge into, or restore the canonical account pool. This helper is kept for + * older call sites that still use the historical reconcile entry point, but it now only normalizes + * the existing local indexes and never reads or applies Codex CLI account data. * - * @param state - Persisted Codex CLI state (may be undefined); the function reads `syncVersion` and `sourceUpdatedAtMs` when present - * @returns `true` if the Codex CLI selection should be applied (i.e., Codex state is newer or timestamps are unknown), `false` otherwise + * @param current - The current canonical AccountStorageV3, or null when no canonical storage exists. + * @returns The original storage when no local normalization is needed, a normalized clone when index + * values need clamping, or null when canonical storage is missing. */ -function shouldApplyCodexCliSelection(state: Awaited>): boolean { - if (!state) return false; - const hasSyncVersion = - typeof state.syncVersion === "number" && Number.isFinite(state.syncVersion); - const codexVersion = hasSyncVersion - ? (state.syncVersion as number) - : typeof state.sourceUpdatedAtMs === "number" && Number.isFinite(state.sourceUpdatedAtMs) - ? state.sourceUpdatedAtMs - : 0; - const localVersion = Math.max( - getLastAccountsSaveTimestamp(), - getLastCodexCliSelectionWriteTimestamp(), - ); - if (codexVersion <= 0 || localVersion <= 0) return true; - // Keep local selection when plugin wrote more recently than Codex state. - const toleranceMs = hasSyncVersion ? 0 : 1_000; - return codexVersion >= localVersion - toleranceMs; -} - -/** - * Reconciles the provided local account storage with the Codex CLI state and returns the resulting storage and whether it changed. - * - * This operation: - * - Merges accounts from the Codex CLI state into a clone of `current` (or into a new empty storage when `current` is null). - * - May update the active account selection and per-family active indexes when the Codex CLI selection is considered applicable. - * - Preserves secrets and sensitive fields; any tokens written to storage are subject to the project's token-redaction rules and are not exposed in logs or metrics. - * - * Concurrency assumptions: - * - Caller is responsible for serializing concurrent writes to persistent storage; this function only returns an in-memory storage object and does not perform atomic file-level coordination. - * - * Windows filesystem notes: - * - When the caller persists the returned storage to disk on Windows, standard Windows file-locking and path-length semantics apply; this function does not perform Windows-specific path normalization. - * - * @param current - The current local AccountStorageV3, or `null` to indicate none exists. - * @returns An object containing: - * - `storage`: the reconciled AccountStorageV3 to persist (may be the original `current` when no changes were applied). - * - `changed`: `true` if the reconciled storage differs from `current`, `false` otherwise. - */ -export async function syncAccountStorageFromCodexCli( - current: AccountStorageV3 | null, +export function syncAccountStorageFromCodexCli( + current: AccountStorageV3 | null, ): Promise<{ storage: AccountStorageV3 | null; changed: boolean }> { - incrementCodexCliMetric("reconcileAttempts"); - try { - const state = await loadCodexCliState(); - if (!state) { - incrementCodexCliMetric("reconcileNoops"); - return { storage: current, changed: false }; - } - - const next = current ? cloneStorage(current) : createEmptyStorage(); - let changed = false; - - for (const snapshot of state.accounts) { - const updated = upsertFromSnapshot(next.accounts, snapshot); - if (updated) changed = true; - } - - if (next.accounts.length === 0) { - incrementCodexCliMetric(changed ? "reconcileChanges" : "reconcileNoops"); - log.debug("Codex CLI reconcile completed", { - operation: "reconcile-storage", - outcome: changed ? "changed" : "noop", - accountCount: next.accounts.length, - }); - return { - storage: current ?? next, - changed, - }; - } - - const activeFromSnapshots = readActiveFromSnapshots(state.accounts); - const applyActiveFromCodex = shouldApplyCodexCliSelection(state); - if (applyActiveFromCodex) { - const desiredIndex = resolveActiveIndex( - next.accounts, - state.activeAccountId ?? activeFromSnapshots.accountId, - state.activeEmail ?? activeFromSnapshots.email, - ); + incrementCodexCliMetric("reconcileAttempts"); - const previousActive = next.activeIndex; - const previousFamilies = JSON.stringify(next.activeIndexByFamily ?? {}); - writeFamilyIndexes(next, desiredIndex); - normalizeStoredFamilyIndexes(next); - if (previousActive !== next.activeIndex) { - changed = true; - } - if (previousFamilies !== JSON.stringify(next.activeIndexByFamily ?? {})) { - changed = true; - } - } else { - const previousActive = next.activeIndex; - const previousFamilies = JSON.stringify(next.activeIndexByFamily ?? {}); - normalizeStoredFamilyIndexes(next); - if (previousActive !== next.activeIndex) { - changed = true; - } - if (previousFamilies !== JSON.stringify(next.activeIndexByFamily ?? {})) { - changed = true; - } - log.debug("Skipped Codex CLI active selection overwrite due to newer local state", { - operation: "reconcile-storage", - outcome: "local-newer", - }); - } - - incrementCodexCliMetric(changed ? "reconcileChanges" : "reconcileNoops"); - log.debug("Codex CLI reconcile completed", { - operation: "reconcile-storage", - outcome: changed ? "changed" : "noop", - accountCount: next.accounts.length, - activeAccountRef: makeAccountFingerprint({ - accountId: state.activeAccountId ?? activeFromSnapshots.accountId, - email: state.activeEmail ?? activeFromSnapshots.email, - }), - }); - return { - storage: next, - changed, - }; - } catch (error) { - incrementCodexCliMetric("reconcileFailures"); - log.warn("Codex CLI reconcile failed", { + if (!current) { + incrementCodexCliMetric("reconcileNoops"); + log.debug("Skipped Codex CLI reconcile because canonical storage is missing", { operation: "reconcile-storage", - outcome: "error", - error: String(error), + outcome: "canonical-missing", }); - return { storage: current, changed: false }; + return Promise.resolve({ storage: null, changed: false }); } + + const next = cloneStorage(current); + const previousActive = next.activeIndex; + const previousFamilies = JSON.stringify(next.activeIndexByFamily ?? {}); + normalizeStoredFamilyIndexes(next); + + const changed = + previousActive !== next.activeIndex || + previousFamilies !== JSON.stringify(next.activeIndexByFamily ?? {}); + + incrementCodexCliMetric(changed ? "reconcileChanges" : "reconcileNoops"); + log.debug("Skipped Codex CLI authority import; canonical storage remains authoritative", { + operation: "reconcile-storage", + outcome: changed ? "normalized-local-indexes" : "canonical-authoritative", + accountCount: next.accounts.length, + }); + + return Promise.resolve({ + storage: changed ? next : current, + changed, + }); } export function getActiveSelectionForFamily( diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 794eb7c6..b4b1e7e6 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -10,7 +10,11 @@ import { } from "./auth/auth.js"; import { startLocalOAuthServer } from "./auth/server.js"; import { copyTextToClipboard, openBrowserUrl } from "./auth/browser.js"; -import { promptAddAnotherAccount, promptLoginMode, type ExistingAccountInfo } from "./cli.js"; +import { + isNonInteractiveMode, + promptAddAnotherAccount, + promptLoginMode, +} from "./cli.js"; import { extractAccountEmail, extractAccountId, @@ -27,7 +31,6 @@ import { loadDashboardDisplaySettings, DEFAULT_DASHBOARD_DISPLAY_SETTINGS, type DashboardDisplaySettings, - type DashboardAccountSortMode, } from "./dashboard-settings.js"; import { evaluateForecastAccounts, @@ -50,6 +53,10 @@ import { type QuotaCacheEntry, } from "./quota-cache.js"; import { + cloneAccountStorage, + createEmptyAccountStorage, + clearAccounts, + getRestoreAssessment, getStoragePath, loadFlaggedAccounts, loadAccounts, @@ -59,6 +66,7 @@ import { type AccountMetadataV3, type AccountStorageV3, type FlaggedAccountMetadataV1, + withAccountStorageTransaction, } from "./storage.js"; import type { AccountIdSource, TokenFailure, TokenResult } from "./types.js"; import { @@ -72,7 +80,21 @@ import { UI_COPY } from "./ui/copy.js"; import { paintUiText, quotaToneFromLeftPercent } from "./ui/format.js"; import { getUiRuntimeOptions } from "./ui/runtime.js"; import { select, type MenuItem } from "./ui/select.js"; -import { applyUiThemeFromDashboardSettings, configureUnifiedSettings, resolveMenuLayoutMode } from "./codex-manager/settings-hub.js"; +import { + buildAuthDashboardViewModel, + formatCompactQuotaSnapshot, + formatRateLimitEntry, + getQuotaCacheEntryForAccount, + resolveActiveIndex, + resolveAuthDashboardCommand, +} from "./codex-manager/auth-ui-controller.js"; +import { applyUiThemeFromDashboardSettings, configureUnifiedSettings } from "./codex-manager/settings-hub.js"; +import { + configureInkUnifiedSettings, + promptInkAuthDashboard, + promptInkRestoreForLogin, + type InkShellTone, +} from "./ui-ink/index.js"; type TokenSuccess = Extract; type TokenSuccessWithAccount = TokenSuccess & { @@ -300,7 +322,7 @@ function printUsage(): void { "", "Notes:", " - Uses ~/.codex/multi-auth/openai-codex-accounts.json", - " - Syncs active account into Codex CLI auth state", + " - Syncs active account into Codex CLI auth mirror", ].join("\n"), ); } @@ -362,141 +384,6 @@ function runFeaturesReport(): number { return 0; } -function resolveActiveIndex( - storage: AccountStorageV3, - family: ModelFamily = "codex", -): number { - const total = storage.accounts.length; - if (total === 0) return 0; - const rawCandidate = storage.activeIndexByFamily?.[family] ?? storage.activeIndex; - const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0; - return Math.max(0, Math.min(raw, total - 1)); -} - -function getRateLimitResetTimeForFamily( - account: { rateLimitResetTimes?: Record }, - now: number, - family: ModelFamily, -): number | null { - const times = account.rateLimitResetTimes; - if (!times) return null; - - let minReset: number | null = null; - const prefix = `${family}:`; - for (const [key, value] of Object.entries(times)) { - if (typeof value !== "number") continue; - if (value <= now) continue; - if (key !== family && !key.startsWith(prefix)) continue; - if (minReset === null || value < minReset) { - minReset = value; - } - } - - return minReset; -} - -function formatRateLimitEntry( - account: { rateLimitResetTimes?: Record }, - now: number, - family: ModelFamily = "codex", -): string | null { - const resetAt = getRateLimitResetTimeForFamily(account, now, family); - if (typeof resetAt !== "number") return null; - const remaining = resetAt - now; - if (remaining <= 0) return null; - return `resets in ${formatWaitTime(remaining)}`; -} - -function normalizeQuotaEmail(email: string | undefined): string | null { - const normalized = sanitizeEmail(email); - return normalized && normalized.length > 0 ? normalized : null; -} - -function quotaCacheEntryToSnapshot(entry: QuotaCacheEntry): CodexQuotaSnapshot { - return { - status: entry.status, - planType: entry.planType, - model: entry.model, - primary: { - usedPercent: entry.primary.usedPercent, - windowMinutes: entry.primary.windowMinutes, - resetAtMs: entry.primary.resetAtMs, - }, - secondary: { - usedPercent: entry.secondary.usedPercent, - windowMinutes: entry.secondary.windowMinutes, - resetAtMs: entry.secondary.resetAtMs, - }, - }; -} - -function formatCompactQuotaWindowLabel(windowMinutes: number | undefined): string { - if (!windowMinutes || !Number.isFinite(windowMinutes) || windowMinutes <= 0) { - return "quota"; - } - if (windowMinutes % 1440 === 0) return `${windowMinutes / 1440}d`; - if (windowMinutes % 60 === 0) return `${windowMinutes / 60}h`; - return `${windowMinutes}m`; -} - -function formatCompactQuotaPart(windowMinutes: number | undefined, usedPercent: number | undefined): string | null { - const label = formatCompactQuotaWindowLabel(windowMinutes); - if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) { - return null; - } - const left = quotaLeftPercentFromUsed(usedPercent); - return `${label} ${left}%`; -} - -function quotaLeftPercentFromUsed(usedPercent: number | undefined): number | undefined { - if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) { - return undefined; - } - return Math.max(0, Math.min(100, Math.round(100 - usedPercent))); -} - -function formatCompactQuotaSnapshot(snapshot: CodexQuotaSnapshot): string { - const parts = [ - formatCompactQuotaPart(snapshot.primary.windowMinutes, snapshot.primary.usedPercent), - formatCompactQuotaPart(snapshot.secondary.windowMinutes, snapshot.secondary.usedPercent), - ].filter((value): value is string => typeof value === "string" && value.length > 0); - if (snapshot.status === 429) { - parts.push("rate-limited"); - } - if (parts.length > 0) { - return parts.join(" | "); - } - return formatQuotaSnapshotLine(snapshot); -} - -function formatAccountQuotaSummary(entry: QuotaCacheEntry): string { - const parts = [ - formatCompactQuotaPart(entry.primary.windowMinutes, entry.primary.usedPercent), - formatCompactQuotaPart(entry.secondary.windowMinutes, entry.secondary.usedPercent), - ].filter((value): value is string => typeof value === "string" && value.length > 0); - if (entry.status === 429) { - parts.push("rate-limited"); - } - if (parts.length > 0) { - return parts.join(" | "); - } - return formatQuotaSnapshotLine(quotaCacheEntryToSnapshot(entry)); -} - -function getQuotaCacheEntryForAccount( - cache: QuotaCacheData, - account: Pick, -): QuotaCacheEntry | null { - if (account.accountId && cache.byAccountId[account.accountId]) { - return cache.byAccountId[account.accountId] ?? null; - } - const email = normalizeQuotaEmail(account.email); - if (email && cache.byEmail[email]) { - return cache.byEmail[email] ?? null; - } - return null; -} - function updateQuotaCacheForAccount( cache: QuotaCacheData, account: Pick, @@ -524,7 +411,7 @@ function updateQuotaCacheForAccount( cache.byAccountId[account.accountId] = nextEntry; changed = true; } - const email = normalizeQuotaEmail(account.email); + const email = sanitizeEmail(account.email); if (email) { cache.byEmail[email] = nextEntry; changed = true; @@ -659,167 +546,6 @@ function hasLikelyInvalidRefreshToken(refreshToken: string | undefined): boolean return trimmed.startsWith("token-"); } -function mapAccountStatus( - account: AccountMetadataV3, - index: number, - activeIndex: number, - now: number, -): ExistingAccountInfo["status"] { - if (account.enabled === false) return "disabled"; - if (typeof account.coolingDownUntil === "number" && account.coolingDownUntil > now) { - return "cooldown"; - } - const rateLimit = formatRateLimitEntry(account, now, "codex"); - if (rateLimit) return "rate-limited"; - if (index === activeIndex) return "active"; - return "ok"; -} - -function parseLeftPercentFromQuotaSummary( - summary: string | undefined, - windowLabel: "5h" | "7d", -): number { - if (!summary) return -1; - const match = summary.match(new RegExp(`(?:^|\\|)\\s*${windowLabel}\\s+(\\d{1,3})%`, "i")); - const value = Number.parseInt(match?.[1] ?? "", 10); - if (!Number.isFinite(value)) return -1; - return Math.max(0, Math.min(100, value)); -} - -function readQuotaLeftPercent( - account: ExistingAccountInfo, - windowLabel: "5h" | "7d", -): number { - const direct = windowLabel === "5h" ? account.quota5hLeftPercent : account.quota7dLeftPercent; - if (typeof direct === "number" && Number.isFinite(direct)) { - return Math.max(0, Math.min(100, Math.round(direct))); - } - return parseLeftPercentFromQuotaSummary(account.quotaSummary, windowLabel); -} - -function accountStatusSortBucket(status: ExistingAccountInfo["status"]): number { - switch (status) { - case "active": - case "ok": - return 0; - case "unknown": - return 1; - case "cooldown": - case "rate-limited": - return 2; - case "disabled": - case "error": - case "flagged": - return 3; - default: - return 1; - } -} - -function compareReadyFirstAccounts( - left: ExistingAccountInfo, - right: ExistingAccountInfo, -): number { - const left5h = readQuotaLeftPercent(left, "5h"); - const right5h = readQuotaLeftPercent(right, "5h"); - if (left5h !== right5h) return right5h - left5h; - - const left7d = readQuotaLeftPercent(left, "7d"); - const right7d = readQuotaLeftPercent(right, "7d"); - if (left7d !== right7d) return right7d - left7d; - - const bucketDelta = accountStatusSortBucket(left.status) - accountStatusSortBucket(right.status); - if (bucketDelta !== 0) return bucketDelta; - - const leftLastUsed = left.lastUsed ?? 0; - const rightLastUsed = right.lastUsed ?? 0; - if (leftLastUsed !== rightLastUsed) return rightLastUsed - leftLastUsed; - - const leftSource = left.sourceIndex ?? left.index; - const rightSource = right.sourceIndex ?? right.index; - return leftSource - rightSource; -} - -function applyAccountMenuOrdering( - accounts: ExistingAccountInfo[], - displaySettings: DashboardDisplaySettings, -): ExistingAccountInfo[] { - const sortEnabled = - displaySettings.menuSortEnabled ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? true); - const sortMode: DashboardAccountSortMode = - displaySettings.menuSortMode ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? "ready-first"); - if (!sortEnabled || sortMode !== "ready-first") { - return [...accounts]; - } - - const sorted = [...accounts].sort(compareReadyFirstAccounts); - const pinCurrent = displaySettings.menuSortPinCurrent ?? - (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? false); - if (pinCurrent) { - const currentIndex = sorted.findIndex((account) => account.isCurrentAccount); - if (currentIndex > 0) { - const current = sorted.splice(currentIndex, 1)[0]; - const first = sorted[0]; - if (current && first && compareReadyFirstAccounts(current, first) <= 0) { - sorted.unshift(current); - } else if (current) { - sorted.splice(currentIndex, 0, current); - } - } - } - return sorted; -} - -function toExistingAccountInfo( - storage: AccountStorageV3, - quotaCache: QuotaCacheData | null, - displaySettings: DashboardDisplaySettings, -): ExistingAccountInfo[] { - const now = Date.now(); - const activeIndex = resolveActiveIndex(storage, "codex"); - const layoutMode = resolveMenuLayoutMode(displaySettings); - const baseAccounts = storage.accounts.map((account, index) => { - const entry = quotaCache ? getQuotaCacheEntryForAccount(quotaCache, account) : null; - return { - index, - sourceIndex: index, - accountId: account.accountId, - accountLabel: account.accountLabel, - email: account.email, - addedAt: account.addedAt, - lastUsed: account.lastUsed, - status: mapAccountStatus(account, index, activeIndex, now), - quotaSummary: (displaySettings.menuShowQuotaSummary ?? true) && entry - ? formatAccountQuotaSummary(entry) - : undefined, - quota5hLeftPercent: quotaLeftPercentFromUsed(entry?.primary.usedPercent), - quota5hResetAtMs: entry?.primary.resetAtMs, - quota7dLeftPercent: quotaLeftPercentFromUsed(entry?.secondary.usedPercent), - quota7dResetAtMs: entry?.secondary.resetAtMs, - quotaRateLimited: entry?.status === 429, - isCurrentAccount: index === activeIndex, - enabled: account.enabled !== false, - showStatusBadge: displaySettings.menuShowStatusBadge ?? true, - showCurrentBadge: displaySettings.menuShowCurrentBadge ?? true, - showLastUsed: displaySettings.menuShowLastUsed ?? true, - showQuotaCooldown: displaySettings.menuShowQuotaCooldown ?? true, - showHintsForUnselectedRows: layoutMode === "expanded-rows", - highlightCurrentRow: displaySettings.menuHighlightCurrentRow ?? true, - focusStyle: displaySettings.menuFocusStyle ?? "row-invert", - statuslineFields: displaySettings.menuStatuslineFields ?? ["last-used", "limits", "status"], - }; - }); - const orderedAccounts = applyAccountMenuOrdering(baseAccounts, displaySettings); - const quickSwitchUsesVisibleRows = displaySettings.menuSortQuickSwitchVisibleRow ?? true; - return orderedAccounts.map((account, displayIndex) => ({ - ...account, - index: displayIndex, - quickSwitchNumber: quickSwitchUsesVisibleRows - ? displayIndex + 1 - : (account.sourceIndex ?? displayIndex) + 1, - })); -} - function resolveAccountSelection(tokens: TokenSuccess): TokenSuccessWithAccount { const override = (process.env.CODEX_AUTH_ACCOUNT_ID ?? "").trim(); if (override) { @@ -931,6 +657,196 @@ async function promptOAuthSignInMode(): Promise { return selected ?? "cancel"; } +function formatRestoreReasonText(reason: "empty-storage" | "intentional-reset" | "missing-storage" | undefined): string { + if (reason === "missing-storage") { + return "No saved account pool was found."; + } + if (reason === "empty-storage") { + return "The saved account pool is currently empty."; + } + if (reason === "intentional-reset") { + return "The saved account pool was intentionally reset."; + } + return "Saved accounts may be recoverable."; +} + +function formatRestoreSnapshotKind(kind: string): string { + switch (kind) { + case "accounts-wal": + return "journal snapshot"; + case "accounts-backup": + return "latest backup"; + case "accounts-backup-history": + return "backup history"; + case "accounts-discovered-backup": + return "discovered backup"; + default: + return "saved snapshot"; + } +} + +function formatRestoreSnapshotInfo( + snapshot: { + kind: string; + path: string; + accountCount?: number; + bytes?: number; + mtimeMs?: number; + }, +): string { + const count = typeof snapshot.accountCount === "number" + ? `${snapshot.accountCount} account${snapshot.accountCount === 1 ? "" : "s"}` + : "unknown account count"; + const bytes = typeof snapshot.bytes === "number" ? `${snapshot.bytes} bytes` : "unknown size"; + const updatedAt = typeof snapshot.mtimeMs === "number" && Number.isFinite(snapshot.mtimeMs) + ? new Date(snapshot.mtimeMs).toLocaleString() + : "unknown time"; + return `${formatRestoreSnapshotKind(snapshot.kind)} | ${count} | ${bytes} | ${updatedAt}\n${snapshot.path}`; +} + +function findLatestRestorableSnapshot( + assessment: Awaited>, +): Awaited>["latestSnapshot"] { + return assessment.backupMetadata.accounts.snapshots.find((snapshot) => + snapshot.valid && + snapshot.path !== assessment.storagePath && + (snapshot.accountCount ?? 0) > 0, + ) ?? undefined; +} + +async function ensureEmptyAccountPoolExists(): Promise { + if (existsSync(getStoragePath())) return; + await saveAccounts(createEmptyAccountStorage()); +} + +async function promptRestoreForLogin( + assessment: Awaited>, + snapshot: NonNullable>["latestSnapshot"]>, +): Promise { + if (isNonInteractiveMode() || !input.isTTY || !output.isTTY) { + return false; + } + + const inkResult = await promptInkRestoreForLogin({ + reasonText: formatRestoreReasonText(assessment.restoreReason), + snapshotInfo: formatRestoreSnapshotInfo(snapshot), + snapshotCount: snapshot.accountCount ?? 0, + }); + if (inkResult !== null) { + return inkResult; + } + + const ui = getUiRuntimeOptions(); + const items: MenuItem[] = [ + { + label: `Restore ${snapshot.accountCount ?? 0} saved account${snapshot.accountCount === 1 ? "" : "s"}`, + value: true, + color: "green", + }, + { + label: "Continue to sign in", + value: false, + color: "yellow", + }, + ]; + + const selected = await select(items, { + message: "Restore saved accounts before signing in?", + subtitle: `${formatRestoreReasonText(assessment.restoreReason)}\n${formatRestoreSnapshotInfo(snapshot)}`, + help: "↑↓ Move | Enter Select | 1 Restore | 2 Continue | Q Continue", + clearScreen: true, + theme: ui.theme, + selectedEmphasis: "minimal", + allowEscape: false, + onInput: (raw) => { + const lower = raw.toLowerCase(); + if (lower === "1" || lower === "r") return true; + if (lower === "2" || lower === "s" || lower === "q") return false; + return undefined; + }, + }); + + return selected === true; +} + +interface LoginStorageResolution { + storage: AccountStorageV3 | null; + statusText?: string; + statusTone?: InkShellTone; +} + +async function promptDeleteAllForDashboard(): Promise { + if (isNonInteractiveMode() || !input.isTTY || !output.isTTY) { + return false; + } + const rl = createInterface({ input, output }); + try { + const answer = await rl.question("Type DELETE to remove all saved accounts: "); + return answer.trim() === "DELETE"; + } finally { + rl.close(); + } +} + +async function resolveLoginStorage(): Promise { + const assessment = await getRestoreAssessment(); + const latestSnapshot = assessment.restoreEligible ? findLatestRestorableSnapshot(assessment) : undefined; + + if (!assessment.restoreEligible || !latestSnapshot) { + return { storage: await loadAccounts() }; + } + + if (isNonInteractiveMode()) { + await ensureEmptyAccountPoolExists(); + console.log( + stylePromptText( + `Restore available from ${formatRestoreSnapshotKind(latestSnapshot.kind)}; non-interactive mode skips the prompt and continues to sign in.`, + "muted", + ), + ); + return { storage: createEmptyAccountStorage() }; + } + + const shouldRestore = await promptRestoreForLogin(assessment, latestSnapshot); + if (!shouldRestore) { + await ensureEmptyAccountPoolExists(); + return { + storage: createEmptyAccountStorage(), + statusText: "Skipping restore and continuing to sign in.", + statusTone: "warning", + }; + } + + if (assessment.restoreReason === "empty-storage") { + try { + await fs.unlink(assessment.storagePath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + console.warn( + `Restore preparation failed for ${assessment.storagePath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + + const restored = await loadAccounts(); + if (restored && restored.accounts.length > 0) { + return { + storage: restored, + statusText: `Restored ${restored.accounts.length} account${restored.accounts.length === 1 ? "" : "s"} from ${formatRestoreSnapshotKind(latestSnapshot.kind)}.`, + statusTone: "success", + }; + } + + await ensureEmptyAccountPoolExists(); + return { + storage: createEmptyAccountStorage(), + statusText: `Restore did not recover any accounts from ${formatRestoreSnapshotKind(latestSnapshot.kind)}. Continuing to sign in.`, + statusTone: "warning", + }; +} + interface WaitForReturnOptions { promptText?: string; autoReturnMs?: number; @@ -1260,128 +1176,127 @@ async function persistAccountPool( ): Promise { if (results.length === 0) return; - const loadedStorage = replaceAll - ? null - : await loadAccounts(); - const now = Date.now(); - const accounts = loadedStorage?.accounts ? [...loadedStorage.accounts] : []; + await withAccountStorageTransaction(async (current, persist) => { + const loadedStorage = replaceAll ? null : cloneAccountStorage(current); + const now = Date.now(); + const accounts = loadedStorage?.accounts ? [...loadedStorage.accounts] : []; - const indexByRefreshToken = new Map(); - const indexByAccountId = new Map(); - const indexByEmail = new Map(); - let selectedAccountIndex: number | null = null; + const indexByRefreshToken = new Map(); + const indexByAccountId = new Map(); + const indexByEmail = new Map(); + let selectedAccountIndex: number | null = null; - for (let i = 0; i < accounts.length; i += 1) { - const account = accounts[i]; - if (!account) continue; - if (account.refreshToken) indexByRefreshToken.set(account.refreshToken, i); - if (account.accountId) indexByAccountId.set(account.accountId, i); - if (account.email) indexByEmail.set(account.email, i); - } + for (let i = 0; i < accounts.length; i += 1) { + const account = accounts[i]; + if (!account) continue; + if (account.refreshToken) indexByRefreshToken.set(account.refreshToken, i); + if (account.accountId) indexByAccountId.set(account.accountId, i); + if (account.email) indexByEmail.set(account.email, i); + } - for (const result of results) { - const tokenAccountId = extractAccountId(result.access); - const accountId = resolveRequestAccountId( - result.accountIdOverride, - result.accountIdSource, - tokenAccountId, - ); - const accountIdSource = accountId - ? (result.accountIdSource ?? (result.accountIdOverride ? "manual" : "token")) - : undefined; - const accountLabel = result.accountLabel; - const accountEmail = sanitizeEmail(extractAccountEmail(result.access, result.idToken)); - - const existingByEmail = - accountEmail && indexByEmail.has(accountEmail) - ? indexByEmail.get(accountEmail) - : undefined; - const existingById = - accountId && indexByAccountId.has(accountId) - ? indexByAccountId.get(accountId) + for (const result of results) { + const tokenAccountId = extractAccountId(result.access); + const accountId = resolveRequestAccountId( + result.accountIdOverride, + result.accountIdSource, + tokenAccountId, + ); + const accountIdSource = accountId + ? (result.accountIdSource ?? (result.accountIdOverride ? "manual" : "token")) : undefined; - const existingByToken = indexByRefreshToken.get(result.refresh); - const existingIndex = existingById ?? existingByEmail ?? existingByToken; - - if (existingIndex === undefined) { - const newIndex = accounts.length; - accounts.push({ - accountId, - accountIdSource, - accountLabel, - email: accountEmail, + const accountLabel = result.accountLabel; + const accountEmail = sanitizeEmail(extractAccountEmail(result.access, result.idToken)); + + const existingByEmail = + accountEmail && indexByEmail.has(accountEmail) + ? indexByEmail.get(accountEmail) + : undefined; + const existingById = + accountId && indexByAccountId.has(accountId) + ? indexByAccountId.get(accountId) + : undefined; + const existingByToken = indexByRefreshToken.get(result.refresh); + const existingIndex = existingById ?? existingByEmail ?? existingByToken; + + if (existingIndex === undefined) { + const newIndex = accounts.length; + accounts.push({ + accountId, + accountIdSource, + accountLabel, + email: accountEmail, + refreshToken: result.refresh, + accessToken: result.access, + expiresAt: result.expires, + enabled: true, + addedAt: now, + lastUsed: now, + }); + indexByRefreshToken.set(result.refresh, newIndex); + if (accountId) indexByAccountId.set(accountId, newIndex); + if (accountEmail) indexByEmail.set(accountEmail, newIndex); + selectedAccountIndex = newIndex; + continue; + } + + const existing = accounts[existingIndex]; + if (!existing) continue; + + const oldToken = existing.refreshToken; + const oldEmail = existing.email; + const nextEmail = accountEmail ?? existing.email; + const nextAccountId = accountId ?? existing.accountId; + const nextAccountIdSource = accountId + ? (accountIdSource ?? existing.accountIdSource) + : existing.accountIdSource; + + accounts[existingIndex] = { + ...existing, + accountId: nextAccountId, + accountIdSource: nextAccountIdSource, + accountLabel: accountLabel ?? existing.accountLabel, + email: nextEmail, refreshToken: result.refresh, accessToken: result.access, expiresAt: result.expires, enabled: true, - addedAt: now, lastUsed: now, - }); - indexByRefreshToken.set(result.refresh, newIndex); - if (accountId) indexByAccountId.set(accountId, newIndex); - if (accountEmail) indexByEmail.set(accountEmail, newIndex); - selectedAccountIndex = newIndex; - continue; + }; + if (oldToken !== result.refresh) { + indexByRefreshToken.delete(oldToken); + indexByRefreshToken.set(result.refresh, existingIndex); + } + if (nextAccountId) { + indexByAccountId.set(nextAccountId, existingIndex); + } + if (oldEmail && oldEmail !== nextEmail) { + indexByEmail.delete(oldEmail); + } + if (nextEmail) { + indexByEmail.set(nextEmail, existingIndex); + } + selectedAccountIndex = existingIndex; } - const existing = accounts[existingIndex]; - if (!existing) continue; - - const oldToken = existing.refreshToken; - const oldEmail = existing.email; - const nextEmail = accountEmail ?? existing.email; - const nextAccountId = accountId ?? existing.accountId; - const nextAccountIdSource = accountId - ? (accountIdSource ?? existing.accountIdSource) - : existing.accountIdSource; - - accounts[existingIndex] = { - ...existing, - accountId: nextAccountId, - accountIdSource: nextAccountIdSource, - accountLabel: accountLabel ?? existing.accountLabel, - email: nextEmail, - refreshToken: result.refresh, - accessToken: result.access, - expiresAt: result.expires, - enabled: true, - lastUsed: now, - }; - - if (oldToken !== result.refresh) { - indexByRefreshToken.delete(oldToken); - indexByRefreshToken.set(result.refresh, existingIndex); - } - if (nextAccountId) { - indexByAccountId.set(nextAccountId, existingIndex); - } - if (oldEmail && oldEmail !== nextEmail) { - indexByEmail.delete(oldEmail); + const fallbackActiveIndex = accounts.length === 0 + ? 0 + : Math.max(0, Math.min(loadedStorage?.activeIndex ?? 0, accounts.length - 1)); + const nextActiveIndex = accounts.length === 0 + ? 0 + : selectedAccountIndex === null + ? fallbackActiveIndex + : Math.max(0, Math.min(selectedAccountIndex, accounts.length - 1)); + const activeIndexByFamily: Partial> = {}; + for (const family of MODEL_FAMILIES) { + activeIndexByFamily[family] = nextActiveIndex; } - if (nextEmail) { - indexByEmail.set(nextEmail, existingIndex); - } - selectedAccountIndex = existingIndex; - } - - const fallbackActiveIndex = accounts.length === 0 - ? 0 - : Math.max(0, Math.min(loadedStorage?.activeIndex ?? 0, accounts.length - 1)); - const nextActiveIndex = accounts.length === 0 - ? 0 - : selectedAccountIndex === null - ? fallbackActiveIndex - : Math.max(0, Math.min(selectedAccountIndex, accounts.length - 1)); - const activeIndexByFamily: Partial> = {}; - for (const family of MODEL_FAMILIES) { - activeIndexByFamily[family] = nextActiveIndex; - } - await saveAccounts({ - version: 3, - accounts, - activeIndex: nextActiveIndex, - activeIndexByFamily, + await persist({ + version: 3, + accounts, + activeIndex: nextActiveIndex, + activeIndexByFamily, + }); }); } @@ -2385,19 +2300,6 @@ interface VerifyFlaggedReport { message: string; } -function createEmptyAccountStorage(): AccountStorageV3 { - const activeIndexByFamily: Partial> = {}; - for (const family of MODEL_FAMILIES) { - activeIndexByFamily[family] = 0; - } - return { - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily, - }; -} - function findExistingAccountIndexForFlagged( storage: AccountStorageV3, flagged: FlaggedAccountMetadataV1, @@ -3340,9 +3242,9 @@ async function runDoctor(args: string[]): Promise { key: "codex-cli-state", severity: codexCliState ? "ok" : "warn", message: codexCliState - ? "Codex CLI state loaded" - : "Codex CLI state unavailable", - details: codexCliState?.path, + ? "Codex CLI mirror state loaded" + : "Codex CLI mirror state unavailable", + details: codexCliState?.path ? `${codexCliState.path} (mirror only)` : undefined, }); const storage = await loadAccounts(); @@ -3501,8 +3403,8 @@ async function runDoctor(args: string[]): Promise { 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", + ? "Manager active account and Codex mirror are not aligned" + : "Manager active account and Codex mirror are aligned", details: `manager=${managerActiveEmail ?? managerActiveAccountId ?? "unknown"} | codex=${codexActiveEmail ?? codexActiveAccountId ?? "unknown"}`, }); @@ -3574,13 +3476,13 @@ async function runDoctor(args: string[]): Promise { fixChanged = true; fixActions.push({ key: "codex-active-sync", - message: "Synced manager active account into Codex auth state", + message: "Synced manager active account into Codex auth mirror", }); } else { addCheck({ key: "codex-active-sync", severity: "warn", - message: "Failed to sync manager active account into Codex auth state", + message: "Failed to sync manager active account into Codex auth mirror", }); } } else { @@ -3650,12 +3552,7 @@ async function runDoctor(args: string[]): Promise { } async function clearAccountsAndReset(): Promise { - await saveAccounts({ - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - }); + await clearAccounts(); } async function handleManageAction( @@ -3671,13 +3568,19 @@ async function handleManageAction( 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); + await withAccountStorageTransaction(async (current, persist) => { + const nextStorage = cloneAccountStorage(current) ?? createEmptyAccountStorage(); + if (idx < 0 || idx >= nextStorage.accounts.length) { + return; + } + nextStorage.accounts.splice(idx, 1); + nextStorage.activeIndex = 0; + nextStorage.activeIndexByFamily = {}; + for (const family of MODEL_FAMILIES) { + nextStorage.activeIndexByFamily[family] = 0; + } + await persist(nextStorage); + }); console.log(`Deleted account ${idx + 1}.`); } return; @@ -3687,10 +3590,18 @@ async function handleManageAction( const idx = menuResult.toggleAccountIndex; const account = storage.accounts[idx]; if (account) { - account.enabled = account.enabled === false; - await saveAccounts(storage); + const enabled = account.enabled === false; + await withAccountStorageTransaction(async (current, persist) => { + const nextStorage = cloneAccountStorage(current) ?? createEmptyAccountStorage(); + const nextAccount = nextStorage.accounts[idx]; + if (!nextAccount) { + return; + } + nextAccount.enabled = enabled; + await persist(nextStorage); + }); console.log( - `${account.enabled === false ? "Disabled" : "Enabled"} account ${idx + 1}.`, + `${enabled === false ? "Disabled" : "Enabled"} account ${idx + 1}.`, ); } return; @@ -3720,7 +3631,10 @@ async function runAuthLogin(): Promise { let menuQuotaRefreshStatus: string | undefined; loginFlow: while (true) { - let existingStorage = await loadAccounts(); + const loginStorage = await resolveLoginStorage(); + let existingStorage = loginStorage.storage; + let recoveryStatusText = loginStorage.statusText; + let recoveryStatusTone = loginStorage.statusTone; if (existingStorage && existingStorage.accounts.length > 0) { while (true) { existingStorage = await loadAccounts(); @@ -3758,72 +3672,87 @@ async function runAuthLogin(): Promise { } } const flaggedStorage = await loadFlaggedAccounts(); + const dashboardViewModel = buildAuthDashboardViewModel({ + storage: currentStorage, + quotaCache, + displaySettings, + flaggedCount: flaggedStorage.accounts.length, + statusMessage: showFetchStatus ? () => menuQuotaRefreshStatus : undefined, + }); - const menuResult = await promptLoginMode( - toExistingAccountInfo(currentStorage, quotaCache, displaySettings), - { - flaggedCount: flaggedStorage.accounts.length, - statusMessage: showFetchStatus ? () => menuQuotaRefreshStatus : undefined, - }, + const menuResult = await promptInkAuthDashboard({ + dashboard: dashboardViewModel, + statusTextOverride: recoveryStatusText, + statusToneOverride: recoveryStatusTone, + }) ?? await promptLoginMode( + dashboardViewModel.accounts, + dashboardViewModel.menuOptions, ); + recoveryStatusText = undefined; + recoveryStatusTone = undefined; + const command = resolveAuthDashboardCommand(menuResult); - if (menuResult.mode === "cancel") { + if (command.type === "cancel") { console.log("Cancelled."); return 0; } - if (menuResult.mode === "check") { - await runActionPanel("Quick Check", "Checking local session + live status", async () => { - await runHealthCheck({ forceRefresh: false, liveProbe: true }); - }, displaySettings); - continue; - } - if (menuResult.mode === "deep-check") { - await runActionPanel("Deep Check", "Refreshing and testing all accounts", async () => { - await runHealthCheck({ forceRefresh: true, liveProbe: true }); + if (command.type === "run-health-check") { + await runActionPanel(command.panel.title, command.panel.stage, async () => { + await runHealthCheck({ + forceRefresh: command.forceRefresh, + liveProbe: command.liveProbe, + }); }, displaySettings); continue; } - if (menuResult.mode === "forecast") { - await runActionPanel("Best Account", "Comparing accounts", async () => { - await runForecast(["--live"]); + if (command.type === "run-forecast") { + await runActionPanel(command.panel.title, command.panel.stage, async () => { + await runForecast(command.args); }, displaySettings); continue; } - if (menuResult.mode === "fix") { - await runActionPanel("Auto-Fix", "Checking and fixing common issues", async () => { - await runFix(["--live"]); + if (command.type === "run-fix") { + await runActionPanel(command.panel.title, command.panel.stage, async () => { + await runFix(command.args); }, displaySettings); continue; } - if (menuResult.mode === "settings") { - await configureUnifiedSettings(displaySettings); + if (command.type === "open-settings") { + const inkHandled = await configureInkUnifiedSettings(displaySettings); + if (!inkHandled) { + await configureUnifiedSettings(displaySettings); + } continue; } - if (menuResult.mode === "verify-flagged") { - await runActionPanel("Problem Account Check", "Checking problem accounts", async () => { - await runVerifyFlagged([]); + if (command.type === "run-verify-flagged") { + await runActionPanel(command.panel.title, command.panel.stage, async () => { + await runVerifyFlagged(command.args); }, displaySettings); continue; } - if (menuResult.mode === "fresh" && menuResult.deleteAll) { - await runActionPanel("Reset Accounts", "Deleting all saved accounts", async () => { + if (command.type === "reset-accounts") { + const confirmedDeleteAll = menuResult.deleteAll === true || await promptDeleteAllForDashboard(); + if (!confirmedDeleteAll) { + console.log("\nDelete all cancelled.\n"); + continue; + } + await runActionPanel(command.panel.title, command.panel.stage, async () => { await clearAccountsAndReset(); console.log("Deleted all accounts."); }, displaySettings); continue; } - if (menuResult.mode === "manage") { - const requiresInteractiveOAuth = typeof menuResult.refreshAccountIndex === "number"; - if (requiresInteractiveOAuth) { - await handleManageAction(currentStorage, menuResult); + if (command.type === "manage-account") { + if (command.requiresInlineFlow) { + await handleManageAction(currentStorage, command.menuResult); continue; } - await runActionPanel("Applying Change", "Updating selected account", async () => { - await handleManageAction(currentStorage, menuResult); + await runActionPanel(command.panel?.title ?? "Applying Change", command.panel?.stage ?? "Updating selected account", async () => { + await handleManageAction(currentStorage, command.menuResult); }, displaySettings); continue; } - if (menuResult.mode === "add") { + if (command.type === "add-account") { break; } } @@ -3896,16 +3825,7 @@ async function runSwitch(args: string[]): Promise { console.error(`Account ${parsed} not found.`); return 1; } - - storage.activeIndex = targetIndex; - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = targetIndex; - } const wasDisabled = account.enabled === false; - if (wasDisabled) { - account.enabled = true; - } const switchNow = Date.now(); let syncAccessToken = account.accessToken; let syncRefreshToken = account.refreshToken; @@ -3915,24 +3835,6 @@ async function runSwitch(args: string[]): Promise { if (!hasUsableAccessToken(account, switchNow)) { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type === "success") { - const tokenAccountId = extractAccountId(refreshResult.access); - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); - if (account.refreshToken !== refreshResult.refresh) { - account.refreshToken = refreshResult.refresh; - } - if (account.accessToken !== refreshResult.access) { - account.accessToken = refreshResult.access; - } - if (account.expiresAt !== refreshResult.expires) { - account.expiresAt = refreshResult.expires; - } - if (nextEmail && nextEmail !== account.email) { - account.email = nextEmail; - } - if (tokenAccountId && tokenAccountId !== account.accountId) { - account.accountId = tokenAccountId; - account.accountIdSource = "token"; - } syncAccessToken = refreshResult.access; syncRefreshToken = refreshResult.refresh; syncExpiresAt = refreshResult.expires; @@ -3944,13 +3846,57 @@ async function runSwitch(args: string[]): Promise { } } - account.lastUsed = switchNow; - account.lastSwitchReason = "rotation"; - await saveAccounts(storage); + const persisted = await withAccountStorageTransaction(async (current, persist) => { + const nextStorage = cloneAccountStorage(current); + if (!nextStorage || targetIndex < 0 || targetIndex >= nextStorage.accounts.length) { + return null; + } + + nextStorage.activeIndex = targetIndex; + nextStorage.activeIndexByFamily = nextStorage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + nextStorage.activeIndexByFamily[family] = targetIndex; + } + + const nextAccount = nextStorage.accounts[targetIndex]; + if (!nextAccount) { + return null; + } + if (wasDisabled) { + nextAccount.enabled = true; + } + if (syncRefreshToken && syncRefreshToken !== nextAccount.refreshToken) { + nextAccount.refreshToken = syncRefreshToken; + } + if (syncAccessToken && syncAccessToken !== nextAccount.accessToken) { + nextAccount.accessToken = syncAccessToken; + } + if (syncExpiresAt !== nextAccount.expiresAt) { + nextAccount.expiresAt = syncExpiresAt; + } + const nextEmail = sanitizeEmail(extractAccountEmail(syncAccessToken, syncIdToken)) ?? nextAccount.email; + if (nextEmail && nextEmail !== nextAccount.email) { + nextAccount.email = nextEmail; + } + const tokenAccountId = syncAccessToken ? extractAccountId(syncAccessToken) : undefined; + if (tokenAccountId && tokenAccountId !== nextAccount.accountId) { + nextAccount.accountId = tokenAccountId; + nextAccount.accountIdSource = "token"; + } + nextAccount.lastUsed = switchNow; + nextAccount.lastSwitchReason = "rotation"; + + await persist(nextStorage); + return { account: { ...nextAccount } }; + }); + if (!persisted?.account) { + console.error("Account changed before switch could be saved. Please try again."); + return 1; + } const synced = await setCodexCliActiveSelection({ - accountId: account.accountId, - email: account.email, + accountId: persisted.account.accountId, + email: persisted.account.email, accessToken: syncAccessToken, refreshToken: syncRefreshToken, expiresAt: syncExpiresAt, @@ -3963,7 +3909,7 @@ async function runSwitch(args: string[]): Promise { } console.log( - `Switched to account ${parsed}: ${formatAccountLabel(account, targetIndex)}${wasDisabled ? " (re-enabled)" : ""}`, + `Switched to account ${parsed}: ${formatAccountLabel(persisted.account, targetIndex)}${wasDisabled ? " (re-enabled)" : ""}`, ); return 0; } @@ -3995,38 +3941,63 @@ export async function autoSyncActiveAccountToCodex(): Promise { 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 (tokenAccountId && tokenAccountId !== account.accountId) { - account.accountId = tokenAccountId; - account.accountIdSource = "token"; - changed = true; - } syncAccessToken = refreshResult.access; syncRefreshToken = refreshResult.refresh; syncExpiresAt = refreshResult.expires; syncIdToken = refreshResult.idToken; + const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); + const tokenAccountId = extractAccountId(refreshResult.access); + changed = + refreshResult.refresh !== account.refreshToken || + refreshResult.access !== account.accessToken || + refreshResult.expires !== account.expiresAt || + (nextEmail !== undefined && nextEmail !== account.email) || + (tokenAccountId !== undefined && tokenAccountId !== account.accountId); } } if (changed) { - await saveAccounts(storage); + const persisted = await withAccountStorageTransaction(async (current, persist) => { + const nextStorage = cloneAccountStorage(current); + if (!nextStorage || activeIndex < 0 || activeIndex >= nextStorage.accounts.length) { + return null; + } + const nextAccount = nextStorage.accounts[activeIndex]; + if (!nextAccount) { + return null; + } + if (syncRefreshToken !== nextAccount.refreshToken) { + nextAccount.refreshToken = syncRefreshToken; + } + if (syncAccessToken !== nextAccount.accessToken) { + nextAccount.accessToken = syncAccessToken; + } + if (syncExpiresAt !== nextAccount.expiresAt) { + nextAccount.expiresAt = syncExpiresAt; + } + const nextEmail = sanitizeEmail(extractAccountEmail(syncAccessToken, syncIdToken)); + if (nextEmail && nextEmail !== nextAccount.email) { + nextAccount.email = nextEmail; + } + const tokenAccountId = extractAccountId(syncAccessToken); + if (tokenAccountId && tokenAccountId !== nextAccount.accountId) { + nextAccount.accountId = tokenAccountId; + nextAccount.accountIdSource = "token"; + } + await persist(nextStorage); + return { account: { ...nextAccount } }; + }); + if (!persisted?.account) { + return false; + } + return setCodexCliActiveSelection({ + accountId: persisted.account.accountId, + email: persisted.account.email, + accessToken: syncAccessToken, + refreshToken: syncRefreshToken, + expiresAt: syncExpiresAt, + ...(syncIdToken ? { idToken: syncIdToken } : {}), + }); } return setCodexCliActiveSelection({ diff --git a/lib/codex-manager/auth-ui-controller.ts b/lib/codex-manager/auth-ui-controller.ts new file mode 100644 index 00000000..8beec128 --- /dev/null +++ b/lib/codex-manager/auth-ui-controller.ts @@ -0,0 +1,526 @@ +import { formatWaitTime, sanitizeEmail } from "../accounts.js"; +import type { LoginMenuResult } from "../cli.js"; +import { + DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + type DashboardDisplaySettings, + type DashboardAccountSortMode, +} from "../dashboard-settings.js"; +import { formatQuotaSnapshotLine, type CodexQuotaSnapshot } from "../quota-probe.js"; +import type { QuotaCacheData, QuotaCacheEntry } from "../quota-cache.js"; +import type { AccountMetadataV3, AccountStorageV3 } from "../storage.js"; +import { UI_COPY, formatCheckFlaggedLabel } from "../ui/copy.js"; +import { resolveMenuLayoutMode } from "./settings-hub.js"; +import type { ModelFamily } from "../prompts/codex.js"; + +export type AuthAccountStatus = + | "active" + | "ok" + | "rate-limited" + | "cooldown" + | "disabled" + | "error" + | "flagged" + | "unknown"; + +export interface AuthAccountViewModel { + index: number; + sourceIndex?: number; + quickSwitchNumber?: number; + accountId?: string; + accountLabel?: string; + email?: string; + addedAt?: number; + lastUsed?: number; + status?: AuthAccountStatus; + quotaSummary?: string; + quota5hLeftPercent?: number; + quota5hResetAtMs?: number; + quota7dLeftPercent?: number; + quota7dResetAtMs?: number; + quotaRateLimited?: boolean; + isCurrentAccount?: boolean; + enabled?: boolean; + showStatusBadge?: boolean; + showCurrentBadge?: boolean; + showLastUsed?: boolean; + showQuotaCooldown?: boolean; + showHintsForUnselectedRows?: boolean; + highlightCurrentRow?: boolean; + focusStyle?: "row-invert" | "chip"; + statuslineFields?: string[]; +} + +export interface AuthDashboardMenuOptionsViewModel { + flaggedCount?: number; + statusMessage?: string | (() => string | undefined); +} + +export type AuthDashboardSectionId = "quick-actions" | "advanced-checks" | "saved-accounts" | "danger-zone"; +export type AuthDashboardActionId = + | "add" + | "check" + | "forecast" + | "fix" + | "settings" + | "deep-check" + | "verify-flagged" + | "delete-all"; + +export interface AuthDashboardActionViewModel { + id: AuthDashboardActionId; + label: string; + tone: "green" | "yellow" | "red"; +} + +export interface AuthDashboardSectionViewModel { + id: AuthDashboardSectionId; + title: string; + actions: AuthDashboardActionViewModel[]; +} + +export interface AuthDashboardViewModel { + accounts: AuthAccountViewModel[]; + menuOptions: AuthDashboardMenuOptionsViewModel; + sections: AuthDashboardSectionViewModel[]; +} + +export interface BuildAuthDashboardViewModelOptions { + storage: AccountStorageV3; + quotaCache: QuotaCacheData | null; + displaySettings: DashboardDisplaySettings; + flaggedCount?: number; + statusMessage?: string | (() => string | undefined); +} + +export interface AuthDashboardActionPanelViewModel { + title: string; + stage: string; +} + +export type AuthDashboardCommand = + | { type: "cancel" } + | { type: "add-account" } + | { type: "open-settings" } + | { + type: "run-health-check"; + panel: AuthDashboardActionPanelViewModel; + forceRefresh: boolean; + liveProbe: boolean; + } + | { type: "run-forecast"; panel: AuthDashboardActionPanelViewModel; args: string[] } + | { type: "run-fix"; panel: AuthDashboardActionPanelViewModel; args: string[] } + | { type: "run-verify-flagged"; panel: AuthDashboardActionPanelViewModel; args: string[] } + | { type: "reset-accounts"; panel: AuthDashboardActionPanelViewModel } + | { + type: "manage-account"; + menuResult: LoginMenuResult; + requiresInlineFlow: boolean; + panel?: AuthDashboardActionPanelViewModel; + }; + +export function resolveActiveIndex( + storage: AccountStorageV3, + family: ModelFamily = "codex", +): number { + const total = storage.accounts.length; + if (total === 0) return 0; + const rawCandidate = storage.activeIndexByFamily?.[family] ?? storage.activeIndex; + const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0; + return Math.max(0, Math.min(raw, total - 1)); +} + +function getRateLimitResetTimeForFamily( + account: { rateLimitResetTimes?: Record }, + now: number, + family: ModelFamily, +): number | null { + const times = account.rateLimitResetTimes; + if (!times) return null; + + let minReset: number | null = null; + const prefix = `${family}:`; + for (const [key, value] of Object.entries(times)) { + if (typeof value !== "number") continue; + if (value <= now) continue; + if (key !== family && !key.startsWith(prefix)) continue; + if (minReset === null || value < minReset) { + minReset = value; + } + } + + return minReset; +} + +export function formatRateLimitEntry( + account: { rateLimitResetTimes?: Record }, + now: number, + family: ModelFamily = "codex", +): string | null { + const resetAt = getRateLimitResetTimeForFamily(account, now, family); + if (typeof resetAt !== "number") return null; + const remaining = resetAt - now; + if (remaining <= 0) return null; + return `resets in ${formatWaitTime(remaining)}`; +} + +function normalizeQuotaEmail(email: string | undefined): string | null { + const normalized = sanitizeEmail(email); + return normalized && normalized.length > 0 ? normalized : null; +} + +function quotaCacheEntryToSnapshot(entry: QuotaCacheEntry): CodexQuotaSnapshot { + return { + status: entry.status, + planType: entry.planType, + model: entry.model, + primary: { + usedPercent: entry.primary.usedPercent, + windowMinutes: entry.primary.windowMinutes, + resetAtMs: entry.primary.resetAtMs, + }, + secondary: { + usedPercent: entry.secondary.usedPercent, + windowMinutes: entry.secondary.windowMinutes, + resetAtMs: entry.secondary.resetAtMs, + }, + }; +} + +function formatCompactQuotaWindowLabel(windowMinutes: number | undefined): string { + if (!windowMinutes || !Number.isFinite(windowMinutes) || windowMinutes <= 0) { + return "quota"; + } + if (windowMinutes % 1440 === 0) return `${windowMinutes / 1440}d`; + if (windowMinutes % 60 === 0) return `${windowMinutes / 60}h`; + return `${windowMinutes}m`; +} + +function formatCompactQuotaPart(windowMinutes: number | undefined, usedPercent: number | undefined): string | null { + const label = formatCompactQuotaWindowLabel(windowMinutes); + if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) { + return null; + } + const left = quotaLeftPercentFromUsed(usedPercent); + return `${label} ${left}%`; +} + +function quotaLeftPercentFromUsed(usedPercent: number | undefined): number | undefined { + if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) { + return undefined; + } + return Math.max(0, Math.min(100, Math.round(100 - usedPercent))); +} + +export function formatCompactQuotaSnapshot(snapshot: CodexQuotaSnapshot): string { + const parts = [ + formatCompactQuotaPart(snapshot.primary.windowMinutes, snapshot.primary.usedPercent), + formatCompactQuotaPart(snapshot.secondary.windowMinutes, snapshot.secondary.usedPercent), + ].filter((value): value is string => typeof value === "string" && value.length > 0); + if (snapshot.status === 429) { + parts.push("rate-limited"); + } + if (parts.length > 0) { + return parts.join(" | "); + } + return formatQuotaSnapshotLine(snapshot); +} + +function formatAccountQuotaSummary(entry: QuotaCacheEntry): string { + const parts = [ + formatCompactQuotaPart(entry.primary.windowMinutes, entry.primary.usedPercent), + formatCompactQuotaPart(entry.secondary.windowMinutes, entry.secondary.usedPercent), + ].filter((value): value is string => typeof value === "string" && value.length > 0); + if (entry.status === 429) { + parts.push("rate-limited"); + } + if (parts.length > 0) { + return parts.join(" | "); + } + return formatQuotaSnapshotLine(quotaCacheEntryToSnapshot(entry)); +} + +export function getQuotaCacheEntryForAccount( + cache: QuotaCacheData, + account: Pick, +): QuotaCacheEntry | null { + if (account.accountId && cache.byAccountId[account.accountId]) { + return cache.byAccountId[account.accountId] ?? null; + } + const email = normalizeQuotaEmail(account.email); + if (email && cache.byEmail[email]) { + return cache.byEmail[email] ?? null; + } + return null; +} + +function mapAccountStatus( + account: AccountMetadataV3, + index: number, + activeIndex: number, + now: number, +): AuthAccountViewModel["status"] { + if (account.enabled === false) return "disabled"; + if (typeof account.coolingDownUntil === "number" && account.coolingDownUntil > now) { + return "cooldown"; + } + const rateLimit = formatRateLimitEntry(account, now, "codex"); + if (rateLimit) return "rate-limited"; + if (index === activeIndex) return "active"; + return "ok"; +} + +function parseLeftPercentFromQuotaSummary( + summary: string | undefined, + windowLabel: "5h" | "7d", +): number { + if (!summary) return -1; + const match = summary.match(new RegExp(`(?:^|\\|)\\s*${windowLabel}\\s+(\\d{1,3})%`, "i")); + const value = Number.parseInt(match?.[1] ?? "", 10); + if (!Number.isFinite(value)) return -1; + return Math.max(0, Math.min(100, value)); +} + +function readQuotaLeftPercent( + account: AuthAccountViewModel, + windowLabel: "5h" | "7d", +): number { + const direct = windowLabel === "5h" ? account.quota5hLeftPercent : account.quota7dLeftPercent; + if (typeof direct === "number" && Number.isFinite(direct)) { + return Math.max(0, Math.min(100, Math.round(direct))); + } + return parseLeftPercentFromQuotaSummary(account.quotaSummary, windowLabel); +} + +function accountStatusSortBucket(status: AuthAccountViewModel["status"]): number { + switch (status) { + case "active": + case "ok": + return 0; + case "unknown": + return 1; + case "cooldown": + case "rate-limited": + return 2; + case "disabled": + case "error": + case "flagged": + return 3; + default: + return 1; + } +} + +function compareReadyFirstAccounts( + left: AuthAccountViewModel, + right: AuthAccountViewModel, +): number { + const left5h = readQuotaLeftPercent(left, "5h"); + const right5h = readQuotaLeftPercent(right, "5h"); + if (left5h !== right5h) return right5h - left5h; + + const left7d = readQuotaLeftPercent(left, "7d"); + const right7d = readQuotaLeftPercent(right, "7d"); + if (left7d !== right7d) return right7d - left7d; + + const bucketDelta = accountStatusSortBucket(left.status) - accountStatusSortBucket(right.status); + if (bucketDelta !== 0) return bucketDelta; + + const leftLastUsed = left.lastUsed ?? 0; + const rightLastUsed = right.lastUsed ?? 0; + if (leftLastUsed !== rightLastUsed) return rightLastUsed - leftLastUsed; + + const leftSource = left.sourceIndex ?? left.index; + const rightSource = right.sourceIndex ?? right.index; + return leftSource - rightSource; +} + +function applyAccountMenuOrdering( + accounts: AuthAccountViewModel[], + displaySettings: DashboardDisplaySettings, +): AuthAccountViewModel[] { + const sortEnabled = + displaySettings.menuSortEnabled ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? true); + const sortMode: DashboardAccountSortMode = + displaySettings.menuSortMode ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? "ready-first"); + if (!sortEnabled || sortMode !== "ready-first") { + return [...accounts]; + } + + const sorted = [...accounts].sort(compareReadyFirstAccounts); + const pinCurrent = displaySettings.menuSortPinCurrent ?? + (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? false); + if (pinCurrent) { + const currentIndex = sorted.findIndex((account) => account.isCurrentAccount); + if (currentIndex > 0) { + const current = sorted.splice(currentIndex, 1)[0]; + const first = sorted[0]; + if (current && first && compareReadyFirstAccounts(current, first) <= 0) { + sorted.unshift(current); + } else if (current) { + sorted.splice(currentIndex, 0, current); + } + } + } + return sorted; +} + +function toAuthAccountViewModels( + storage: AccountStorageV3, + quotaCache: QuotaCacheData | null, + displaySettings: DashboardDisplaySettings, +): AuthAccountViewModel[] { + const now = Date.now(); + const activeIndex = resolveActiveIndex(storage, "codex"); + const layoutMode = resolveMenuLayoutMode(displaySettings); + const baseAccounts = storage.accounts.map((account, index) => { + const entry = quotaCache ? getQuotaCacheEntryForAccount(quotaCache, account) : null; + return { + index, + sourceIndex: index, + accountId: account.accountId, + accountLabel: account.accountLabel, + email: account.email, + addedAt: account.addedAt, + lastUsed: account.lastUsed, + status: mapAccountStatus(account, index, activeIndex, now), + quotaSummary: (displaySettings.menuShowQuotaSummary ?? true) && entry + ? formatAccountQuotaSummary(entry) + : undefined, + quota5hLeftPercent: quotaLeftPercentFromUsed(entry?.primary.usedPercent), + quota5hResetAtMs: entry?.primary.resetAtMs, + quota7dLeftPercent: quotaLeftPercentFromUsed(entry?.secondary.usedPercent), + quota7dResetAtMs: entry?.secondary.resetAtMs, + quotaRateLimited: entry?.status === 429, + isCurrentAccount: index === activeIndex, + enabled: account.enabled !== false, + showStatusBadge: displaySettings.menuShowStatusBadge ?? true, + showCurrentBadge: displaySettings.menuShowCurrentBadge ?? true, + showLastUsed: displaySettings.menuShowLastUsed ?? true, + showQuotaCooldown: displaySettings.menuShowQuotaCooldown ?? true, + showHintsForUnselectedRows: layoutMode === "expanded-rows", + highlightCurrentRow: displaySettings.menuHighlightCurrentRow ?? true, + focusStyle: displaySettings.menuFocusStyle ?? "row-invert", + statuslineFields: displaySettings.menuStatuslineFields ?? ["last-used", "limits", "status"], + }; + }); + const orderedAccounts = applyAccountMenuOrdering(baseAccounts, displaySettings); + const quickSwitchUsesVisibleRows = displaySettings.menuSortQuickSwitchVisibleRow ?? true; + return orderedAccounts.map((account, displayIndex) => ({ + ...account, + index: displayIndex, + quickSwitchNumber: quickSwitchUsesVisibleRows + ? displayIndex + 1 + : (account.sourceIndex ?? displayIndex) + 1, + })); +} + +function buildAuthDashboardSections(flaggedCount: number): AuthDashboardSectionViewModel[] { + return [ + { + id: "quick-actions", + title: UI_COPY.mainMenu.quickStart, + actions: [ + { id: "add", label: UI_COPY.mainMenu.addAccount, tone: "green" }, + { id: "check", label: UI_COPY.mainMenu.checkAccounts, tone: "green" }, + { id: "forecast", label: UI_COPY.mainMenu.bestAccount, tone: "green" }, + { id: "fix", label: UI_COPY.mainMenu.fixIssues, tone: "green" }, + { id: "settings", label: UI_COPY.mainMenu.settings, tone: "green" }, + ], + }, + { + id: "advanced-checks", + title: UI_COPY.mainMenu.moreChecks, + actions: [ + { id: "deep-check", label: UI_COPY.mainMenu.refreshChecks, tone: "green" }, + { id: "verify-flagged", label: formatCheckFlaggedLabel(flaggedCount), tone: flaggedCount > 0 ? "red" : "yellow" }, + ], + }, + { + id: "saved-accounts", + title: UI_COPY.mainMenu.accounts, + actions: [], + }, + { + id: "danger-zone", + title: UI_COPY.mainMenu.dangerZone, + actions: [ + { id: "delete-all", label: UI_COPY.mainMenu.removeAllAccounts, tone: "red" }, + ], + }, + ]; +} + +export function buildAuthDashboardViewModel( + options: BuildAuthDashboardViewModelOptions, +): AuthDashboardViewModel { + const flaggedCount = options.flaggedCount ?? 0; + return { + accounts: toAuthAccountViewModels(options.storage, options.quotaCache, options.displaySettings), + menuOptions: { + flaggedCount, + statusMessage: options.statusMessage, + }, + sections: buildAuthDashboardSections(flaggedCount), + }; +} + +export function resolveAuthDashboardCommand(menuResult: LoginMenuResult): AuthDashboardCommand { + switch (menuResult.mode) { + case "cancel": + return { type: "cancel" }; + case "add": + return { type: "add-account" }; + case "check": + return { + type: "run-health-check", + panel: { title: "Quick Check", stage: "Checking local session + live status" }, + forceRefresh: false, + liveProbe: true, + }; + case "deep-check": + return { + type: "run-health-check", + panel: { title: "Deep Check", stage: "Refreshing and testing all accounts" }, + forceRefresh: true, + liveProbe: true, + }; + case "forecast": + return { + type: "run-forecast", + panel: { title: "Best Account", stage: "Comparing accounts" }, + args: ["--live"], + }; + case "fix": + return { + type: "run-fix", + panel: { title: "Auto-Fix", stage: "Checking and fixing common issues" }, + args: ["--live"], + }; + case "settings": + return { type: "open-settings" }; + case "verify-flagged": + return { + type: "run-verify-flagged", + panel: { title: "Problem Account Check", stage: "Checking problem accounts" }, + args: [], + }; + case "fresh": + return { + type: "reset-accounts", + panel: { title: "Reset Accounts", stage: "Deleting all saved accounts" }, + }; + case "manage": { + const requiresInlineFlow = typeof menuResult.refreshAccountIndex === "number"; + return { + type: "manage-account", + menuResult, + requiresInlineFlow, + panel: requiresInlineFlow + ? undefined + : { title: "Applying Change", stage: "Updating selected account" }, + }; + } + } + + return { type: "cancel" }; +} diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 99cbdae4..c67daa9c 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -1,23 +1,25 @@ import { stdin as input, stdout as output } from "node:process"; import { - loadDashboardDisplaySettings, - saveDashboardDisplaySettings, - getDashboardSettingsPath, - DEFAULT_DASHBOARD_DISPLAY_SETTINGS, - type DashboardDisplaySettings, - type DashboardThemePreset, - type DashboardAccentColor, - type DashboardAccountSortMode, - type DashboardStatuslineField, + loadDashboardDisplaySettings, + getDashboardSettingsPath, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + type DashboardDisplaySettings, + type DashboardThemePreset, + type DashboardAccentColor, + type DashboardAccountSortMode, + type DashboardStatuslineField, } from "../dashboard-settings.js"; -import { getDefaultPluginConfig, loadPluginConfig, savePluginConfig } from "../config.js"; -import { getUnifiedSettingsPath } from "../unified-settings.js"; +import { getDefaultPluginConfig, loadPluginConfig } from "../config.js"; import type { PluginConfig } from "../types.js"; -import { sleep } from "../utils.js"; import { ANSI } from "../ui/ansi.js"; import { UI_COPY } from "../ui/copy.js"; import { getUiRuntimeOptions, setUiRuntimeOptions } from "../ui/runtime.js"; import { select, type MenuItem } from "../ui/select.js"; +import { + persistBackendConfigSelection, + persistDashboardSettingsSelection, + withQueuedRetry, +} from "./settings-persistence.js"; type DashboardDisplaySettingKey = @@ -517,12 +519,6 @@ const BACKEND_CATEGORY_OPTIONS: BackendCategoryOption[] = [ type DashboardSettingKey = keyof DashboardDisplaySettings; -const RETRYABLE_SETTINGS_WRITE_CODES = new Set(["EBUSY", "EPERM", "EAGAIN", "ENOTEMPTY", "EACCES"]); -const SETTINGS_WRITE_MAX_ATTEMPTS = 4; -const SETTINGS_WRITE_BASE_DELAY_MS = 20; -const SETTINGS_WRITE_MAX_DELAY_MS = 30_000; -const settingsWriteQueues = new Map>(); - const ACCOUNT_LIST_PANEL_KEYS = [ "menuShowStatusBadge", "menuShowCurrentBadge", @@ -549,82 +545,6 @@ const BEHAVIOR_PANEL_KEYS = [ ] as const satisfies readonly DashboardSettingKey[]; const THEME_PANEL_KEYS = ["uiThemePreset", "uiAccentColor"] as const satisfies readonly DashboardSettingKey[]; -function readErrorNumber(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string" && value.trim().length > 0) { - const parsed = Number.parseInt(value, 10); - if (Number.isFinite(parsed)) return parsed; - } - return undefined; -} - -function getErrorStatusCode(error: unknown): number | undefined { - if (!error || typeof error !== "object") return undefined; - const record = error as Record; - return readErrorNumber(record.status) ?? readErrorNumber(record.statusCode); -} - -function getRetryAfterMs(error: unknown): number | undefined { - if (!error || typeof error !== "object") return undefined; - const record = error as Record; - return ( - readErrorNumber(record.retryAfterMs) ?? - readErrorNumber(record.retry_after_ms) ?? - readErrorNumber(record.retryAfter) ?? - readErrorNumber(record.retry_after) - ); -} - -function isRetryableSettingsWriteError(error: unknown): boolean { - const statusCode = getErrorStatusCode(error); - if (statusCode === 429) return true; - const code = (error as NodeJS.ErrnoException | undefined)?.code; - return typeof code === "string" && RETRYABLE_SETTINGS_WRITE_CODES.has(code); -} - -function resolveRetryDelayMs(error: unknown, attempt: number): number { - const retryAfterMs = getRetryAfterMs(error); - if (typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs > 0) { - return Math.max(10, Math.min(SETTINGS_WRITE_MAX_DELAY_MS, Math.round(retryAfterMs))); - } - return Math.min(SETTINGS_WRITE_MAX_DELAY_MS, SETTINGS_WRITE_BASE_DELAY_MS * 2 ** attempt); -} - -async function enqueueSettingsWrite(pathKey: string, task: () => Promise): Promise { - const previous = settingsWriteQueues.get(pathKey) ?? Promise.resolve(); - const queued = previous.catch(() => {}).then(task); - const queueTail = queued.then( - () => undefined, - () => undefined, - ); - settingsWriteQueues.set(pathKey, queueTail); - try { - return await queued; - } finally { - if (settingsWriteQueues.get(pathKey) === queueTail) { - settingsWriteQueues.delete(pathKey); - } - } -} - -async function withQueuedRetry(pathKey: string, task: () => Promise): Promise { - return enqueueSettingsWrite(pathKey, async () => { - let lastError: unknown; - for (let attempt = 0; attempt < SETTINGS_WRITE_MAX_ATTEMPTS; attempt += 1) { - try { - return await task(); - } catch (error) { - lastError = error; - if (!isRetryableSettingsWriteError(error) || attempt + 1 >= SETTINGS_WRITE_MAX_ATTEMPTS) { - throw error; - } - await sleep(resolveRetryDelayMs(error, attempt)); - } - } - throw lastError instanceof Error ? lastError : new Error("settings save retry exhausted"); - }); -} - function copyDashboardSettingValue( target: DashboardDisplaySettings, source: DashboardDisplaySettings, @@ -658,52 +578,6 @@ function mergeDashboardSettingsForKeys( return cloneDashboardSettings(next); } -function resolvePluginConfigSavePathKey(): string { - const envPath = (process.env.CODEX_MULTI_AUTH_CONFIG_PATH ?? "").trim(); - return envPath.length > 0 ? envPath : getUnifiedSettingsPath(); -} - -function formatPersistError(error: unknown): string { - if (error instanceof Error) return error.message; - return String(error); -} - -function warnPersistFailure(scope: string, error: unknown): void { - console.warn(`Settings save failed (${scope}) after retries: ${formatPersistError(error)}`); -} - -async function persistDashboardSettingsSelection( - selected: DashboardDisplaySettings, - keys: readonly DashboardSettingKey[], - scope: string, -): Promise { - const fallback = cloneDashboardSettings(selected); - try { - return await withQueuedRetry(getDashboardSettingsPath(), async () => { - const latest = cloneDashboardSettings(await loadDashboardDisplaySettings()); - const merged = mergeDashboardSettingsForKeys(latest, selected, keys); - await saveDashboardDisplaySettings(merged); - return merged; - }); - } catch (error) { - warnPersistFailure(scope, error); - return fallback; - } -} - -async function persistBackendConfigSelection(selected: PluginConfig, scope: string): Promise { - const fallback = cloneBackendPluginConfig(selected); - try { - await withQueuedRetry(resolvePluginConfigSavePathKey(), async () => { - await savePluginConfig(buildBackendConfigPatch(selected)); - }); - return fallback; - } catch (error) { - warnPersistFailure(scope, error); - return fallback; - } -} - function normalizeStatuslineFields( fields: DashboardStatuslineField[] | undefined, ): DashboardStatuslineField[] { @@ -1081,14 +955,20 @@ async function persistDashboardSettingsSelectionForTests( keys: ReadonlyArray, scope: string, ): Promise { - return persistDashboardSettingsSelection(selected, keys as readonly DashboardSettingKey[], scope); + return persistDashboardSettingsSelection(selected, keys as readonly DashboardSettingKey[], scope, { + cloneSettings: cloneDashboardSettings, + mergeSettingsForKeys: mergeDashboardSettingsForKeys, + }); } async function persistBackendConfigSelectionForTests( selected: PluginConfig, scope: string, ): Promise { - return persistBackendConfigSelection(selected, scope); + return persistBackendConfigSelection(selected, scope, { + cloneConfig: cloneBackendPluginConfig, + buildPatch: buildBackendConfigPatch, + }); } const __testOnly = { @@ -1287,7 +1167,10 @@ async function configureDashboardDisplaySettings( if (!selected) return current; if (dashboardSettingsEqual(current, selected)) return current; - const merged = await persistDashboardSettingsSelection(selected, ACCOUNT_LIST_PANEL_KEYS, "account-list"); + const merged = await persistDashboardSettingsSelection(selected, ACCOUNT_LIST_PANEL_KEYS, "account-list", { + cloneSettings: cloneDashboardSettings, + mergeSettingsForKeys: mergeDashboardSettingsForKeys, + }); applyUiThemeFromDashboardSettings(merged); return merged; } @@ -1480,7 +1363,10 @@ async function configureStatuslineSettings( if (!selected) return current; if (dashboardSettingsEqual(current, selected)) return current; - const merged = await persistDashboardSettingsSelection(selected, STATUSLINE_PANEL_KEYS, "summary-fields"); + const merged = await persistDashboardSettingsSelection(selected, STATUSLINE_PANEL_KEYS, "summary-fields", { + cloneSettings: cloneDashboardSettings, + mergeSettingsForKeys: mergeDashboardSettingsForKeys, + }); applyUiThemeFromDashboardSettings(merged); return merged; } @@ -2053,7 +1939,10 @@ async function configureBackendSettings( if (!selected) return current; if (backendSettingsEqual(current, selected)) return current; - return persistBackendConfigSelection(selected, "backend"); + return persistBackendConfigSelection(selected, "backend", { + cloneConfig: cloneBackendPluginConfig, + buildPatch: buildBackendConfigPatch, + }); } async function promptSettingsHub( @@ -2120,14 +2009,20 @@ async function configureUnifiedSettings( if (action.type === "behavior") { const selected = await promptBehaviorSettings(current); if (selected && !dashboardSettingsEqual(current, selected)) { - current = await persistDashboardSettingsSelection(selected, BEHAVIOR_PANEL_KEYS, "behavior"); + current = await persistDashboardSettingsSelection(selected, BEHAVIOR_PANEL_KEYS, "behavior", { + cloneSettings: cloneDashboardSettings, + mergeSettingsForKeys: mergeDashboardSettingsForKeys, + }); } continue; } if (action.type === "theme") { const selected = await promptThemeSettings(current); if (selected && !dashboardSettingsEqual(current, selected)) { - current = await persistDashboardSettingsSelection(selected, THEME_PANEL_KEYS, "theme"); + current = await persistDashboardSettingsSelection(selected, THEME_PANEL_KEYS, "theme", { + cloneSettings: cloneDashboardSettings, + mergeSettingsForKeys: mergeDashboardSettingsForKeys, + }); applyUiThemeFromDashboardSettings(current); } continue; diff --git a/lib/codex-manager/settings-persistence.ts b/lib/codex-manager/settings-persistence.ts new file mode 100644 index 00000000..d8be586a --- /dev/null +++ b/lib/codex-manager/settings-persistence.ts @@ -0,0 +1,153 @@ +import { + loadDashboardDisplaySettings, + saveDashboardDisplaySettings, + getDashboardSettingsPath, + type DashboardDisplaySettings, +} from "../dashboard-settings.js"; +import { savePluginConfig } from "../config.js"; +import { getUnifiedSettingsPath } from "../unified-settings.js"; +import type { PluginConfig } from "../types.js"; +import { sleep } from "../utils.js"; + +const RETRYABLE_SETTINGS_WRITE_CODES = new Set(["EBUSY", "EPERM", "EAGAIN", "ENOTEMPTY", "EACCES"]); +const SETTINGS_WRITE_MAX_ATTEMPTS = 4; +const SETTINGS_WRITE_BASE_DELAY_MS = 20; +const SETTINGS_WRITE_MAX_DELAY_MS = 30_000; +const settingsWriteQueues = new Map>(); + +function readErrorNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number.parseInt(value, 10); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +function getErrorStatusCode(error: unknown): number | undefined { + if (!error || typeof error !== "object") return undefined; + const record = error as Record; + return readErrorNumber(record.status) ?? readErrorNumber(record.statusCode); +} + +function getRetryAfterMs(error: unknown): number | undefined { + if (!error || typeof error !== "object") return undefined; + const record = error as Record; + return ( + readErrorNumber(record.retryAfterMs) ?? + readErrorNumber(record.retry_after_ms) ?? + readErrorNumber(record.retryAfter) ?? + readErrorNumber(record.retry_after) + ); +} + +function isRetryableSettingsWriteError(error: unknown): boolean { + const statusCode = getErrorStatusCode(error); + if (statusCode === 429) return true; + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return typeof code === "string" && RETRYABLE_SETTINGS_WRITE_CODES.has(code); +} + +function resolveRetryDelayMs(error: unknown, attempt: number): number { + const retryAfterMs = getRetryAfterMs(error); + if (typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs > 0) { + return Math.max(10, Math.min(SETTINGS_WRITE_MAX_DELAY_MS, Math.round(retryAfterMs))); + } + return Math.min(SETTINGS_WRITE_MAX_DELAY_MS, SETTINGS_WRITE_BASE_DELAY_MS * 2 ** attempt); +} + +async function enqueueSettingsWrite(pathKey: string, task: () => Promise): Promise { + const previous = settingsWriteQueues.get(pathKey) ?? Promise.resolve(); + const queued = previous.catch(() => {}).then(task); + const queueTail = queued.then( + () => undefined, + () => undefined, + ); + settingsWriteQueues.set(pathKey, queueTail); + try { + return await queued; + } finally { + if (settingsWriteQueues.get(pathKey) === queueTail) { + settingsWriteQueues.delete(pathKey); + } + } +} + +function resolvePluginConfigSavePathKey(): string { + const envPath = (process.env.CODEX_MULTI_AUTH_CONFIG_PATH ?? "").trim(); + return envPath.length > 0 ? envPath : getUnifiedSettingsPath(); +} + +function formatPersistError(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +function warnPersistFailure(scope: string, error: unknown): void { + console.warn(`Settings save failed (${scope}) after retries: ${formatPersistError(error)}`); +} + +export async function withQueuedRetry(pathKey: string, task: () => Promise): Promise { + return enqueueSettingsWrite(pathKey, async () => { + let lastError: unknown; + for (let attempt = 0; attempt < SETTINGS_WRITE_MAX_ATTEMPTS; attempt += 1) { + try { + return await task(); + } catch (error) { + lastError = error; + if (!isRetryableSettingsWriteError(error) || attempt + 1 >= SETTINGS_WRITE_MAX_ATTEMPTS) { + throw error; + } + await sleep(resolveRetryDelayMs(error, attempt)); + } + } + throw lastError instanceof Error ? lastError : new Error("settings save retry exhausted"); + }); +} + +export async function persistDashboardSettingsSelection( + selected: DashboardDisplaySettings, + keys: readonly (keyof DashboardDisplaySettings)[], + scope: string, + helpers: { + cloneSettings: (settings: DashboardDisplaySettings) => DashboardDisplaySettings; + mergeSettingsForKeys: ( + base: DashboardDisplaySettings, + selected: DashboardDisplaySettings, + keys: readonly (keyof DashboardDisplaySettings)[], + ) => DashboardDisplaySettings; + }, +): Promise { + const fallback = helpers.cloneSettings(selected); + try { + return await withQueuedRetry(getDashboardSettingsPath(), async () => { + const latest = helpers.cloneSettings(await loadDashboardDisplaySettings()); + const merged = helpers.mergeSettingsForKeys(latest, selected, keys); + await saveDashboardDisplaySettings(merged); + return merged; + }); + } catch (error) { + warnPersistFailure(scope, error); + return fallback; + } +} + +export async function persistBackendConfigSelection( + selected: PluginConfig, + scope: string, + helpers: { + cloneConfig: (config: PluginConfig) => PluginConfig; + buildPatch: (config: PluginConfig) => Partial; + }, +): Promise { + const fallback = helpers.cloneConfig(selected); + try { + await withQueuedRetry(resolvePluginConfigSavePathKey(), async () => { + await savePluginConfig(helpers.buildPatch(selected)); + }); + return fallback; + } catch (error) { + warnPersistFailure(scope, error); + return fallback; + } +} diff --git a/lib/runtime-paths.ts b/lib/runtime-paths.ts index b0782ed5..b81af98c 100644 --- a/lib/runtime-paths.ts +++ b/lib/runtime-paths.ts @@ -72,6 +72,17 @@ function deduplicatePaths(paths: string[]): string[] { return result; } +function pathsEqualNormalized(a: string, b: string): boolean { + const normalize = (value: string): string => { + const trimmed = value.trim(); + if (process.platform === "win32") { + return win32.normalize(trimmed).toLowerCase(); + } + return trimmed; + }; + return normalize(a) === normalize(b); +} + /** * Detects whether a directory contains known Codex storage indicators. * @@ -169,6 +180,11 @@ export function getCodexMultiAuthDir(): string { return fromEnv; } + const codexHomeFromEnv = (process.env.CODEX_HOME ?? "").trim(); + const defaultCodexHome = join(getResolvedUserHomeDir(), ".codex"); + const isExplicitNonDefaultHome = + codexHomeFromEnv.length > 0 && !pathsEqualNormalized(codexHomeFromEnv, defaultCodexHome); + const primary = join(getCodexHomeDir(), "multi-auth"); const fallbackCandidates = deduplicatePaths([ ...getFallbackCodexHomeDirs().map((dir) => join(dir, "multi-auth")), @@ -176,6 +192,10 @@ export function getCodexMultiAuthDir(): string { ]); const orderedCandidates = deduplicatePaths([primary, ...fallbackCandidates]); + if (isExplicitNonDefaultHome) { + return primary; + } + // Prefer candidates that actually contain account storage. This prevents // accidentally switching to a fresh empty directory that only has settings files. for (const candidate of orderedCandidates) { diff --git a/lib/storage.ts b/lib/storage.ts index 3453a426..5f9696d6 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,10 +1,11 @@ import { promises as fs, existsSync } from "node:fs"; -import { basename, dirname, join } from "node:path"; +import { basename, dirname, join, normalize } from "node:path"; import { createHash } from "node:crypto"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; +import { getLegacyCodexDir } from "./runtime-paths.js"; import { getConfigDir, getProjectConfigDir, @@ -34,6 +35,7 @@ const ACCOUNTS_WAL_SUFFIX = ".wal"; const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3; const BACKUP_COPY_MAX_ATTEMPTS = 5; const BACKUP_COPY_BASE_DELAY_MS = 10; +const RESET_MARKER_SUFFIX = ".reset-intent"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -114,6 +116,13 @@ type AccountLike = { lastUsed?: number; }; +type RestoreMetadata = { + restoreEligible?: boolean; + restoreReason?: "empty-storage" | "intentional-reset" | "missing-storage"; +}; + +export type AccountStorageWithMetadata = AccountStorageV3 & RestoreMetadata; + function looksLikeSyntheticFixtureAccount(account: AccountMetadataV3): boolean { const email = typeof account.email === "string" ? account.email.trim().toLowerCase() : ""; const refreshToken = @@ -197,6 +206,11 @@ function getAccountsBackupRecoveryCandidates(path: string): string[] { return candidates; } +function isCacheLikeBackupArtifactName(entryName: string): boolean { + const lower = entryName.toLowerCase(); + return lower.includes(".cache"); +} + async function getAccountsBackupRecoveryCandidatesWithDiscovery(path: string): Promise { const knownCandidates = getAccountsBackupRecoveryCandidates(path); const discoveredCandidates = new Set(); @@ -209,6 +223,7 @@ async function getAccountsBackupRecoveryCandidatesWithDiscovery(path: string): P for (const entry of entries) { if (!entry.isFile()) continue; if (!entry.name.startsWith(candidatePrefix)) continue; + if (isCacheLikeBackupArtifactName(entry.name)) continue; if (entry.name.endsWith(".tmp")) continue; if (entry.name.includes(".rotate.")) continue; if (entry.name.endsWith(ACCOUNTS_WAL_SUFFIX)) continue; @@ -236,6 +251,48 @@ function getAccountsWalPath(path: string): string { return `${path}${ACCOUNTS_WAL_SUFFIX}`; } +function normalizePathForDedup(pathValue: string): string { + const normalized = normalize(pathValue.trim()); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + +function deduplicatePathList(paths: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const candidate of paths) { + const trimmed = candidate.trim(); + if (!trimmed) continue; + const key = normalizePathForDedup(trimmed); + if (seen.has(key)) continue; + seen.add(key); + result.push(trimmed); + } + + return result; +} + +function pathsEqualNormalized(a: string, b: string): boolean { + return normalizePathForDedup(a) === normalizePathForDedup(b); +} + +function getFallbackAccountStoragePaths(currentPath: string): string[] { + const canonicalRoot = dirname(currentPath); + const canonicalHome = dirname(canonicalRoot); + const legacyRoot = getLegacyCodexDir(); + const candidateHomes = deduplicatePathList([dirname(canonicalHome), dirname(legacyRoot)]); + + const candidates = deduplicatePathList( + candidateHomes.flatMap((home) => [ + join(home, "DevTools", "config", "codex", "multi-auth", ACCOUNTS_FILE_NAME), + join(home, ".codex", "multi-auth", ACCOUNTS_FILE_NAME), + join(home, ".codex", ACCOUNTS_FILE_NAME), + ]), + ); + + return candidates.filter((candidate) => !pathsEqualNormalized(candidate, currentPath)); +} + async function copyFileWithRetry( sourcePath: string, destinationPath: string, @@ -391,6 +448,364 @@ function computeSha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } +export type BackupSnapshotKind = + | "accounts-primary" + | "accounts-wal" + | "accounts-backup" + | "accounts-backup-history" + | "accounts-discovered-backup" + | "flagged-primary" + | "flagged-backup" + | "flagged-backup-history" + | "flagged-discovered-backup"; + +export interface BackupSnapshotMetadata { + kind: BackupSnapshotKind; + path: string; + index?: number; + exists: boolean; + valid: boolean; + bytes?: number; + mtimeMs?: number; + version?: number; + accountCount?: number; + flaggedCount?: number; + schemaErrors?: string[]; +} + +export interface BackupMetadataSection { + storagePath: string; + latestValidPath?: string; + snapshotCount: number; + validSnapshotCount: number; + snapshots: BackupSnapshotMetadata[]; +} + +export interface BackupMetadata { + accounts: BackupMetadataSection; + flaggedAccounts: BackupMetadataSection; +} + +export interface RestoreAssessment { + storagePath: string; + restoreEligible: boolean; + restoreReason?: RestoreMetadata["restoreReason"]; + latestSnapshot?: BackupSnapshotMetadata; + backupMetadata: BackupMetadata; +} + +async function describePathStats( + path: string, + kind: BackupSnapshotKind, + index?: number, +): Promise> { + try { + const stats = await fs.stat(path); + return { + kind, + path, + index, + exists: true, + bytes: stats.size, + mtimeMs: stats.mtimeMs, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to stat backup candidate", { path, error: String(error) }); + } + return { kind, path, index, exists: false }; + } +} + +async function describeAccountSnapshot( + path: string, + kind: BackupSnapshotKind, + index?: number, +): Promise { + const base = await describePathStats(path, kind, index); + if (!base.exists) { + return { ...base, valid: false }; + } + + try { + const { normalized, schemaErrors, storedVersion } = await loadAccountsFromPath(path); + return { + ...base, + valid: !!normalized, + version: typeof storedVersion === "number" ? storedVersion : undefined, + accountCount: normalized?.accounts.length, + schemaErrors: schemaErrors.length > 0 ? schemaErrors : undefined, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to inspect account snapshot", { + path, + error: String(error), + }); + } + return { ...base, valid: false }; + } +} + +async function describeAccountsWalSnapshot(path: string): Promise { + const base = await describePathStats(path, "accounts-wal"); + if (!base.exists) { + return { ...base, valid: false }; + } + + try { + const raw = await fs.readFile(path, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed)) return { ...base, valid: false }; + const entry = parsed as Partial; + if (entry.version !== 1) return { ...base, valid: false }; + if (typeof entry.content !== "string" || typeof entry.checksum !== "string") { + return { ...base, valid: false }; + } + const computed = computeSha256(entry.content); + if (computed !== entry.checksum) { + return { ...base, valid: false }; + } + const data = JSON.parse(entry.content) as unknown; + const { normalized, schemaErrors, storedVersion } = parseAndNormalizeStorage(data); + return { + ...base, + valid: !!normalized, + version: typeof storedVersion === "number" ? storedVersion : undefined, + accountCount: normalized?.accounts.length, + schemaErrors: schemaErrors.length > 0 ? schemaErrors : undefined, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to inspect account WAL snapshot", { + path, + error: String(error), + }); + } + return { ...base, valid: false }; + } +} + +async function loadFlaggedAccountsFromPath(path: string): Promise { + const content = await fs.readFile(path, "utf-8"); + const data = JSON.parse(content) as unknown; + return normalizeFlaggedStorage(data); +} + +async function describeFlaggedSnapshot( + path: string, + kind: BackupSnapshotKind, + index?: number, +): Promise { + const base = await describePathStats(path, kind, index); + if (!base.exists) { + return { ...base, valid: false }; + } + + try { + const storage = await loadFlaggedAccountsFromPath(path); + return { + ...base, + valid: true, + version: storage.version, + flaggedCount: storage.accounts.length, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to inspect flagged account snapshot", { + path, + error: String(error), + }); + } + return { ...base, valid: false }; + } +} + +async function recoverFlaggedAccountsFromBackups(path: string): Promise { + const candidates = await getAccountsBackupRecoveryCandidatesWithDiscovery(path); + for (const candidate of candidates) { + try { + const recovered = await loadFlaggedAccountsFromPath(candidate); + log.warn("Recovered flagged account storage from backup file", { + path, + backupPath: candidate, + accounts: recovered.accounts.length, + }); + try { + await saveFlaggedAccounts(recovered); + } catch (persistError) { + log.warn("Failed to persist recovered flagged account storage", { + path, + error: String(persistError), + }); + } + return recovered; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to load flagged account backup", { + path: candidate, + error: String(error), + }); + } + } + } + return null; +} + +function selectLatestValidSnapshotPath(snapshots: BackupSnapshotMetadata[]): string | undefined { + const firstValid = snapshots.find((snapshot) => snapshot.valid); + return firstValid?.path; +} + +function selectSnapshotByPath( + section: BackupMetadataSection, + path: string | undefined, +): BackupSnapshotMetadata | undefined { + if (!path) return undefined; + return section.snapshots.find((snapshot) => snapshot.path === path); +} + +export async function getBackupMetadata(): Promise { + const storagePath = getStoragePath(); + const flaggedPath = getFlaggedAccountsPath(); + + const accountSnapshots: BackupSnapshotMetadata[] = []; + accountSnapshots.push(await describeAccountSnapshot(storagePath, "accounts-primary")); + accountSnapshots.push(await describeAccountsWalSnapshot(getAccountsWalPath(storagePath))); + + const knownAccountBackups = getAccountsBackupRecoveryCandidates(storagePath); + for (let i = 0; i < knownAccountBackups.length; i += 1) { + const candidate = knownAccountBackups[i]; + if (!candidate) continue; + const kind = i === 0 ? "accounts-backup" : "accounts-backup-history"; + accountSnapshots.push(await describeAccountSnapshot(candidate, kind, i)); + } + + const knownAccountSet = new Set(knownAccountBackups); + const discoveredAccountBackups = await getAccountsBackupRecoveryCandidatesWithDiscovery(storagePath); + for (const candidate of discoveredAccountBackups) { + if (knownAccountSet.has(candidate)) continue; + accountSnapshots.push(await describeAccountSnapshot(candidate, "accounts-discovered-backup")); + } + + const accountsSection: BackupMetadataSection = { + storagePath, + latestValidPath: selectLatestValidSnapshotPath(accountSnapshots), + snapshotCount: accountSnapshots.length, + validSnapshotCount: accountSnapshots.filter((snapshot) => snapshot.valid).length, + snapshots: accountSnapshots, + }; + + const flaggedSnapshots: BackupSnapshotMetadata[] = []; + flaggedSnapshots.push(await describeFlaggedSnapshot(flaggedPath, "flagged-primary")); + + const knownFlaggedBackups = getAccountsBackupRecoveryCandidates(flaggedPath); + for (let i = 0; i < knownFlaggedBackups.length; i += 1) { + const candidate = knownFlaggedBackups[i]; + if (!candidate) continue; + const kind = i === 0 ? "flagged-backup" : "flagged-backup-history"; + flaggedSnapshots.push(await describeFlaggedSnapshot(candidate, kind, i)); + } + + const knownFlaggedSet = new Set(knownFlaggedBackups); + const discoveredFlaggedBackups = await getAccountsBackupRecoveryCandidatesWithDiscovery(flaggedPath); + for (const candidate of discoveredFlaggedBackups) { + if (knownFlaggedSet.has(candidate)) continue; + flaggedSnapshots.push(await describeFlaggedSnapshot(candidate, "flagged-discovered-backup")); + } + + const flaggedSection: BackupMetadataSection = { + storagePath: flaggedPath, + latestValidPath: selectLatestValidSnapshotPath(flaggedSnapshots), + snapshotCount: flaggedSnapshots.length, + validSnapshotCount: flaggedSnapshots.filter((snapshot) => snapshot.valid).length, + snapshots: flaggedSnapshots, + }; + + return { accounts: accountsSection, flaggedAccounts: flaggedSection }; +} + +async function loadAccountsForRestoreAssessment(path: string): Promise { + const resetMarkerPath = getIntentionalResetMarkerPath(path); + const hasIntentionalResetMarker = existsSync(resetMarkerPath); + + try { + const { normalized, schemaErrors } = await loadAccountsFromPath(path); + if (schemaErrors.length > 0) { + log.warn("Account storage schema validation warnings", { + errors: schemaErrors.slice(0, 5), + }); + } + if (!normalized) { + return null; + } + + if (hasIntentionalResetMarker) { + await removeIntentionalResetMarker(path); + } + + const annotated: AccountStorageWithMetadata = { ...normalized }; + if (annotated.accounts.length === 0) { + annotated.restoreEligible = hasIntentionalResetMarker ? false : true; + annotated.restoreReason = hasIntentionalResetMarker ? "intentional-reset" : "empty-storage"; + } else if (hasIntentionalResetMarker) { + annotated.restoreEligible = false; + annotated.restoreReason = "intentional-reset"; + } + + return annotated; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + if (hasIntentionalResetMarker) { + await removeIntentionalResetMarker(path); + return { + ...createEmptyAccountStorage(), + restoreEligible: false, + restoreReason: "intentional-reset", + }; + } + return { + ...createEmptyAccountStorage(), + restoreEligible: true, + restoreReason: "missing-storage", + }; + } + + log.warn("Failed to load account storage for restore assessment", { + path, + error: String(error), + }); + return null; + } +} + +export async function getRestoreAssessment(): Promise { + const storagePath = getStoragePath(); + const [storage, backupMetadata] = await Promise.all([ + loadAccountsForRestoreAssessment(storagePath), + getBackupMetadata(), + ]); + + const latestSnapshot = selectSnapshotByPath( + backupMetadata.accounts, + backupMetadata.accounts.latestValidPath, + ); + + return { + storagePath, + restoreEligible: storage?.restoreEligible ?? false, + restoreReason: storage?.restoreReason, + latestSnapshot, + backupMetadata, + }; +} + type AccountsJournalEntry = { version: 1; createdAt: number; @@ -450,6 +865,38 @@ export function getStoragePath(): string { return join(getConfigDir(), ACCOUNTS_FILE_NAME); } +function getIntentionalResetMarkerPath(storagePath: string): string { + return `${storagePath}${RESET_MARKER_SUFFIX}`; +} + +async function writeIntentionalResetMarker(storagePath: string): Promise { + const markerPath = getIntentionalResetMarkerPath(storagePath); + try { + await fs.writeFile( + markerPath, + JSON.stringify({ version: 1, createdAt: Date.now() }), + "utf-8", + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to write reset marker", { path: markerPath, error: String(error) }); + } + } +} + +async function removeIntentionalResetMarker(storagePath: string): Promise { + const markerPath = getIntentionalResetMarkerPath(storagePath); + try { + await fs.unlink(markerPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to remove reset marker", { path: markerPath, error: String(error) }); + } + } +} + export function getFlaggedAccountsPath(): string { return join(dirname(getStoragePath()), FLAGGED_ACCOUNTS_FILE_NAME); } @@ -537,6 +984,59 @@ async function migrateLegacyProjectStorageIfNeeded( return null; } +async function migrateFallbackAccountStorageIfNeeded( + path: string, + persist: (storage: AccountStorageV3) => Promise, +): Promise { + if (existsSync(path)) { + return null; + } + + const candidates = getFallbackAccountStoragePaths(path); + for (const candidate of candidates) { + if (!existsSync(candidate)) continue; + + const fallbackStorage = await loadNormalizedStorageFromPath( + candidate, + "fallback account storage", + ); + if (!fallbackStorage) { + continue; + } + + try { + await persist(fallbackStorage); + try { + await fs.unlink(candidate); + } catch (unlinkError) { + const code = (unlinkError as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to remove fallback account storage after migration", { + path: candidate, + error: String(unlinkError), + }); + } + } + log.info("Migrated fallback account storage into canonical root", { + from: candidate, + to: path, + accounts: fallbackStorage.accounts.length, + }); + } catch (persistError) { + log.warn("Failed to persist fallback account storage into canonical root", { + from: candidate, + to: path, + error: String(persistError), + }); + return fallbackStorage; + } + + return fallbackStorage; + } + + return null; +} + async function loadNormalizedStorageFromPath( path: string, label: string, @@ -845,8 +1345,8 @@ export function normalizeAccountStorage(data: unknown): AccountStorageV3 | null * Automatically migrates v1 storage to v3 format if needed. * @returns AccountStorageV3 if file exists and is valid, null otherwise */ -export async function loadAccounts(): Promise { - return loadAccountsInternal(saveAccounts); +export async function loadAccounts(): Promise { + return loadAccountsInternal(saveAccounts); } function parseAndNormalizeStorage(data: unknown): { @@ -899,22 +1399,40 @@ async function loadAccountsFromJournal(path: string): Promise Promise) | null, -): Promise { + persistMigration: ((storage: AccountStorageV3) => Promise) | null, +): Promise { const path = getStoragePath(); + const resetMarkerPath = getIntentionalResetMarkerPath(path); + const hasIntentionalResetMarker = existsSync(resetMarkerPath); await cleanupStaleRotatingBackupArtifacts(path); const migratedLegacyStorage = persistMigration ? await migrateLegacyProjectStorageIfNeeded(persistMigration) : null; + const migratedFallbackStorage = persistMigration + ? await migrateFallbackAccountStorageIfNeeded(path, persistMigration) + : null; - try { - const { normalized, storedVersion, schemaErrors } = await loadAccountsFromPath(path); - if (schemaErrors.length > 0) { - log.warn("Account storage schema validation warnings", { errors: schemaErrors.slice(0, 5) }); - } - if (normalized && storedVersion !== normalized.version) { - log.info("Migrating account storage to v3", { from: storedVersion, to: normalized.version }); - if (persistMigration) { + if (hasIntentionalResetMarker && !existsSync(path)) { + await removeIntentionalResetMarker(path); + const emptyStorageWithMetadata: AccountStorageWithMetadata = { + ...createEmptyAccountStorage(), + restoreEligible: false, + restoreReason: "intentional-reset", + }; + return emptyStorageWithMetadata; + } + + try { + const { normalized, storedVersion, schemaErrors } = await loadAccountsFromPath(path); + if (schemaErrors.length > 0) { + log.warn("Account storage schema validation warnings", { errors: schemaErrors.slice(0, 5) }); + } + if (!normalized) { + return null; + } + if (normalized && storedVersion !== normalized.version) { + log.info("Migrating account storage to v3", { from: storedVersion, to: normalized.version }); + if (persistMigration) { try { await persistMigration(normalized); } catch (saveError) { @@ -949,7 +1467,8 @@ async function loadAccountsInternal( }); } } - return backup.normalized; + const annotated: AccountStorageWithMetadata = { ...backup.normalized }; + return annotated; } catch (backupError) { const backupCode = (backupError as NodeJS.ErrnoException).code; if (backupCode !== "ENOENT") { @@ -962,12 +1481,38 @@ async function loadAccountsInternal( } } - return normalized; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" && migratedLegacyStorage) { - return migratedLegacyStorage; - } + if (hasIntentionalResetMarker) { + await removeIntentionalResetMarker(path); + } + + const annotated: AccountStorageWithMetadata = { ...normalized }; + if (annotated.accounts.length === 0) { + annotated.restoreEligible = hasIntentionalResetMarker ? false : true; + annotated.restoreReason = hasIntentionalResetMarker ? "intentional-reset" : "empty-storage"; + } else if (hasIntentionalResetMarker) { + annotated.restoreEligible = false; + annotated.restoreReason = "intentional-reset"; + } + + return annotated; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + if (migratedFallbackStorage) { + return migratedFallbackStorage; + } + if (migratedLegacyStorage) { + return migratedLegacyStorage; + } + } + if (code === "ENOENT" && hasIntentionalResetMarker) { + await removeIntentionalResetMarker(path); + return { + ...createEmptyAccountStorage(), + restoreEligible: false, + restoreReason: "intentional-reset", + }; + } const recoveredFromWal = await loadAccountsFromJournal(path); if (recoveredFromWal) { @@ -981,7 +1526,15 @@ async function loadAccountsInternal( }); } } - return recoveredFromWal; + if (hasIntentionalResetMarker) { + await removeIntentionalResetMarker(path); + } + const annotated: AccountStorageWithMetadata = { ...recoveredFromWal }; + if (annotated.accounts.length === 0) { + annotated.restoreEligible = true; + annotated.restoreReason = "empty-storage"; + } + return annotated; } if (storageBackupEnabled) { @@ -1007,7 +1560,15 @@ async function loadAccountsInternal( }); } } - return backup.normalized; + if (hasIntentionalResetMarker) { + await removeIntentionalResetMarker(path); + } + const annotated: AccountStorageWithMetadata = { ...backup.normalized }; + if (annotated.accounts.length === 0) { + annotated.restoreEligible = true; + annotated.restoreReason = "empty-storage"; + } + return annotated; } } catch (backupError) { const backupCode = (backupError as NodeJS.ErrnoException).code; @@ -1021,10 +1582,16 @@ async function loadAccountsInternal( } } - if (code !== "ENOENT") { - log.error("Failed to load account storage", { error: String(error) }); - } - return null; + if (code === "ENOENT") { + return { + ...createEmptyAccountStorage(), + restoreEligible: true, + restoreReason: "missing-storage", + }; + } + + log.error("Failed to load account storage", { error: String(error) }); + return null; } } @@ -1034,9 +1601,10 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { const tempPath = `${path}.${uniqueSuffix}.tmp`; const walPath = getAccountsWalPath(path); - try { - await fs.mkdir(dirname(path), { recursive: true }); - await ensureGitignore(path); + try { + await fs.mkdir(dirname(path), { recursive: true }); + await ensureGitignore(path); + await removeIntentionalResetMarker(path); if (looksLikeSyntheticFixtureStorage(storage)) { try { @@ -1152,6 +1720,34 @@ export async function withAccountStorageTransaction( }); } +export function cloneAccountStorage(storage: AccountStorageV3 | null): AccountStorageV3 | null { + if (!storage) { + return null; + } + + return { + ...storage, + accounts: storage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: storage.activeIndexByFamily + ? { ...storage.activeIndexByFamily } + : undefined, + }; +} + +export function createEmptyAccountStorage(): AccountStorageV3 { + const activeIndexByFamily: Partial> = {}; + for (const family of MODEL_FAMILIES) { + activeIndexByFamily[family] = 0; + } + + return { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily, + }; +} + /** * Persists account storage to disk using atomic write (temp file + rename). * Creates the Codex multi-auth storage directory if it doesn't exist. @@ -1167,32 +1763,32 @@ export async function saveAccounts(storage: AccountStorageV3): Promise { /** * Deletes the account storage file from disk. - * Silently ignores if file doesn't exist. + * Preserves recovery artifacts while marking an intentional reset to suppress automatic restore. */ export async function clearAccounts(): Promise { return withStorageLock(async () => { const path = getStoragePath(); - const walPath = getAccountsWalPath(path); - const backupPaths = getAccountsBackupRecoveryCandidates(path); - const clearPath = async (targetPath: string): Promise => { - try { - await fs.unlink(targetPath); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.error("Failed to clear account storage artifact", { - path: targetPath, - error: String(error), - }); - } - } - }; + const markerPath = getIntentionalResetMarkerPath(path); - try { - await Promise.all([clearPath(path), clearPath(walPath), ...backupPaths.map(clearPath)]); - } catch { - // Individual path cleanup is already best-effort with per-artifact logging. - } + try { + await fs.mkdir(dirname(path), { recursive: true }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST" && code !== "ENOENT") { + log.warn("Failed to ensure storage directory before reset", { path, error: String(error) }); + } + } + + await writeIntentionalResetMarker(path); + + try { + await fs.unlink(path); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.error("Failed to clear account storage", { path, markerPath, error: String(error) }); + } + } }); } @@ -1277,17 +1873,19 @@ export async function loadFlaggedAccounts(): Promise { const empty: FlaggedAccountStorageV1 = { version: 1, accounts: [] }; try { - const content = await fs.readFile(path, "utf-8"); - const data = JSON.parse(content) as unknown; - return normalizeFlaggedStorage(data); + return await loadFlaggedAccountsFromPath(path); } 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 recovered = storageBackupEnabled ? await recoverFlaggedAccountsFromBackups(path) : null; + if (recovered) { + return recovered; + } + const legacyPath = getLegacyFlaggedAccountsPath(); if (!existsSync(legacyPath)) { return empty; @@ -1329,6 +1927,16 @@ export async function saveFlaggedAccounts(storage: FlaggedAccountStorageV1): Pro try { await fs.mkdir(dirname(path), { recursive: true }); + if (storageBackupEnabled && existsSync(path)) { + try { + await createRotatingAccountsBackup(path); + } catch (backupError) { + log.warn("Failed to create flagged account storage backup", { + path, + error: String(backupError), + }); + } + } const content = JSON.stringify(normalizeFlaggedStorage(storage), null, 2); await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); await fs.rename(tempPath, path); @@ -1346,13 +1954,26 @@ export async function saveFlaggedAccounts(storage: FlaggedAccountStorageV1): Pro export async function clearFlaggedAccounts(): Promise { return withStorageLock(async () => { - try { - await fs.unlink(getFlaggedAccountsPath()); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.error("Failed to clear flagged account storage", { error: String(error) }); + const path = getFlaggedAccountsPath(); + const backupPaths = getAccountsBackupRecoveryCandidates(path); + const clearPath = async (targetPath: string): Promise => { + try { + await fs.unlink(targetPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.error("Failed to clear flagged account storage artifact", { + path: targetPath, + error: String(error), + }); + } } + }; + + try { + await Promise.all([clearPath(path), ...backupPaths.map(clearPath)]); + } catch { + // Individual cleanup is already best effort with per-artifact logging. } }); } diff --git a/test/auth-ui-controller.test.ts b/test/auth-ui-controller.test.ts new file mode 100644 index 00000000..2aa27fc6 --- /dev/null +++ b/test/auth-ui-controller.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { + buildAuthDashboardViewModel, + resolveAuthDashboardCommand, +} from "../lib/codex-manager/auth-ui-controller.js"; + +describe("auth ui controller seam", () => { + it("builds renderer-agnostic dashboard sections and sorted account models", () => { + const now = Date.now(); + const viewModel = buildAuthDashboardViewModel({ + storage: { + version: 3, + activeIndex: 2, + activeIndexByFamily: { codex: 2 }, + accounts: [ + { + email: "a@example.com", + accountId: "acc_a", + refreshToken: "refresh-a", + accessToken: "access-a", + expiresAt: now + 3_600_000, + addedAt: now - 3_000, + lastUsed: now - 3_000, + enabled: true, + }, + { + email: "b@example.com", + accountId: "acc_b", + refreshToken: "refresh-b", + accessToken: "access-b", + expiresAt: now + 3_600_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "c@example.com", + accountId: "acc_c", + refreshToken: "refresh-c", + accessToken: "access-c", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }, + quotaCache: { + byAccountId: {}, + byEmail: { + "a@example.com": { + updatedAt: now, + status: 200, + model: "gpt-5-codex", + primary: { usedPercent: 80, windowMinutes: 300, resetAtMs: now + 1_000 }, + secondary: { usedPercent: 80, windowMinutes: 10080, resetAtMs: now + 2_000 }, + }, + "b@example.com": { + updatedAt: now, + status: 200, + model: "gpt-5-codex", + primary: { usedPercent: 0, windowMinutes: 300, resetAtMs: now + 1_000 }, + secondary: { usedPercent: 0, windowMinutes: 10080, resetAtMs: now + 2_000 }, + }, + "c@example.com": { + updatedAt: now, + status: 200, + model: "gpt-5-codex", + primary: { usedPercent: 60, windowMinutes: 300, resetAtMs: now + 1_000 }, + secondary: { usedPercent: 60, windowMinutes: 10080, resetAtMs: now + 2_000 }, + }, + }, + }, + displaySettings: { + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }, + flaggedCount: 2, + statusMessage: "Loading live limits...", + }); + + expect(viewModel.sections.map((section) => section.id)).toEqual([ + "quick-actions", + "advanced-checks", + "saved-accounts", + "danger-zone", + ]); + expect(viewModel.sections[0]?.actions.map((action) => action.id)).toEqual([ + "add", + "check", + "forecast", + "fix", + "settings", + ]); + expect(viewModel.sections[1]?.actions.map((action) => action.id)).toEqual([ + "deep-check", + "verify-flagged", + ]); + expect(viewModel.menuOptions.flaggedCount).toBe(2); + expect(viewModel.menuOptions.statusMessage).toBe("Loading live limits..."); + expect(viewModel.accounts.map((account) => account.email)).toEqual([ + "b@example.com", + "c@example.com", + "a@example.com", + ]); + expect(viewModel.accounts.map((account) => account.index)).toEqual([0, 1, 2]); + expect(viewModel.accounts.map((account) => account.sourceIndex)).toEqual([1, 2, 0]); + expect(viewModel.accounts.map((account) => account.quickSwitchNumber)).toEqual([1, 2, 3]); + expect(viewModel.accounts[1]?.isCurrentAccount).toBe(true); + }); + + it("maps login menu outcomes into renderer-agnostic commands", () => { + expect(resolveAuthDashboardCommand({ mode: "settings" })).toEqual({ + type: "open-settings", + }); + expect(resolveAuthDashboardCommand({ mode: "check" })).toEqual({ + type: "run-health-check", + panel: { title: "Quick Check", stage: "Checking local session + live status" }, + forceRefresh: false, + liveProbe: true, + }); + expect(resolveAuthDashboardCommand({ mode: "manage", refreshAccountIndex: 1 })).toEqual({ + type: "manage-account", + menuResult: { mode: "manage", refreshAccountIndex: 1 }, + requiresInlineFlow: true, + panel: undefined, + }); + expect(resolveAuthDashboardCommand({ mode: "manage", switchAccountIndex: 2 })).toEqual({ + type: "manage-account", + menuResult: { mode: "manage", switchAccountIndex: 2 }, + requiresInlineFlow: false, + panel: { title: "Applying Change", stage: "Updating selected account" }, + }); + }); +}); diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 11db5fa4..ee320a74 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -6,877 +6,316 @@ import type { AccountStorageV3 } from "../lib/storage.js"; import * as codexCliState from "../lib/codex-cli/state.js"; import { clearCodexCliStateCache } from "../lib/codex-cli/state.js"; import { - getActiveSelectionForFamily, - syncAccountStorageFromCodexCli, + getActiveSelectionForFamily, + syncAccountStorageFromCodexCli, } from "../lib/codex-cli/sync.js"; import { setCodexCliActiveSelection } from "../lib/codex-cli/writer.js"; import { MODEL_FAMILIES } from "../lib/prompts/codex.js"; describe("codex-cli sync", () => { - let tempDir: string; - let accountsPath: string; - let authPath: string; - let configPath: string; - let previousPath: string | undefined; - let previousAuthPath: string | undefined; - let previousConfigPath: string | undefined; - let previousSync: string | undefined; - let previousEnforceFileStore: string | undefined; - - beforeEach(async () => { - previousPath = process.env.CODEX_CLI_ACCOUNTS_PATH; - previousAuthPath = process.env.CODEX_CLI_AUTH_PATH; - previousConfigPath = process.env.CODEX_CLI_CONFIG_PATH; - previousSync = process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; - previousEnforceFileStore = - process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE; - tempDir = await mkdtemp(join(tmpdir(), "codex-multi-auth-sync-")); - accountsPath = join(tempDir, "accounts.json"); - authPath = join(tempDir, "auth.json"); - configPath = join(tempDir, "config.toml"); - process.env.CODEX_CLI_ACCOUNTS_PATH = accountsPath; - process.env.CODEX_CLI_AUTH_PATH = authPath; - process.env.CODEX_CLI_CONFIG_PATH = configPath; - process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "1"; - process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = "1"; - clearCodexCliStateCache(); - }); - - afterEach(async () => { - clearCodexCliStateCache(); - if (previousPath === undefined) delete process.env.CODEX_CLI_ACCOUNTS_PATH; - else process.env.CODEX_CLI_ACCOUNTS_PATH = previousPath; - if (previousAuthPath === undefined) delete process.env.CODEX_CLI_AUTH_PATH; - else process.env.CODEX_CLI_AUTH_PATH = previousAuthPath; - if (previousConfigPath === undefined) delete process.env.CODEX_CLI_CONFIG_PATH; - else process.env.CODEX_CLI_CONFIG_PATH = previousConfigPath; - if (previousSync === undefined) - delete process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; - else process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = previousSync; - if (previousEnforceFileStore === undefined) { - delete process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE; - } else { - process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = - previousEnforceFileStore; - } - await rm(tempDir, { recursive: true, force: true }); - }); - - it("merges Codex CLI accounts and sets active index", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - activeAccountId: "acc_c", - accounts: [ - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "b.access.token", - refresh_token: "refresh-b", - }, - }, - }, - { - accountId: "acc_c", - email: "c@example.com", - auth: { - tokens: { - access_token: "c.access.token", - refresh_token: "refresh-c", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b-old", - addedAt: 2, - lastUsed: 2, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(true); - expect(result.storage?.accounts.length).toBe(3); - - const mergedB = result.storage?.accounts.find( - (account) => account.accountId === "acc_b", - ); - expect(mergedB?.refreshToken).toBe("refresh-b"); - - const active = result.storage?.accounts[result.storage.activeIndex ?? 0]; - expect(active?.accountId).toBe("acc_c"); - }); - - it("creates storage from Codex CLI accounts when local storage is missing", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - email: "a@example.com", - active: true, - auth: { - tokens: { - access_token: "a.access.token", - refresh_token: "refresh-a", - }, - }, - }, - { - email: "b@example.com", - auth: { - tokens: { - access_token: "b.access.token", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const result = await syncAccountStorageFromCodexCli(null); - expect(result.changed).toBe(true); - expect(result.storage?.accounts.length).toBe(2); - expect(result.storage?.accounts[0]?.refreshToken).toBe("refresh-a"); - expect(result.storage?.activeIndex).toBe(0); - }); - - it("matches existing account by normalized email", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - email: "user@example.com", - auth: { - tokens: { - access_token: "new.access.token", - refresh_token: "refresh-new", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - email: "USER@EXAMPLE.COM", - refreshToken: "refresh-old", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(true); - expect(result.storage?.accounts.length).toBe(1); - expect(result.storage?.accounts[0]?.refreshToken).toBe("refresh-new"); - }); - - it("returns unchanged storage when sync is disabled", async () => { - process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "0"; - clearCodexCliStateCache(); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(false); - expect(result.storage).toBe(current); - }); - - it("keeps local active selection when local write is newer than codex snapshot", async () => { - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - tokens: { - access_token: "local.access.token", - refresh_token: "local-refresh-token", - account_id: "acc_a", - }, - }, - null, - 2, - ), - "utf-8", - ); - await setCodexCliActiveSelection({ - accountId: "acc_a", - accessToken: "local.access.token", - refreshToken: "local-refresh-token", - }); - - await writeFile( - accountsPath, - JSON.stringify( - { - codexMultiAuthSyncVersion: Date.now() - 120_000, - activeAccountId: "acc_b", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "a.access", - refresh_token: "refresh-a", - }, - }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "b.access", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - clearCodexCliStateCache(); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.storage?.activeIndex).toBe(0); - }); - - it("keeps local active selection when local state is newer by sub-second gap and syncVersion exists", async () => { - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - tokens: { - access_token: "local.access.token", - refresh_token: "local-refresh-token", - account_id: "acc_a", - }, - }, - null, - 2, - ), - "utf-8", - ); - await setCodexCliActiveSelection({ - accountId: "acc_a", - accessToken: "local.access.token", - refreshToken: "local-refresh-token", - }); - - const staleSyncVersion = Date.now() - 500; - await writeFile( - accountsPath, - JSON.stringify( - { - codexMultiAuthSyncVersion: staleSyncVersion, - activeAccountId: "acc_b", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "a.access", - refresh_token: "refresh-a", - }, - }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "b.access", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - clearCodexCliStateCache(); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.storage?.activeIndex).toBe(0); - }); - - it("marks changed when local index normalization mutates storage while codex selection is skipped", async () => { - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - tokens: { - access_token: "local.access.token", - refresh_token: "local-refresh-token", - account_id: "acc_a", - }, - }, - null, - 2, - ), - "utf-8", - ); - await setCodexCliActiveSelection({ - accountId: "acc_a", - accessToken: "local.access.token", - refreshToken: "local-refresh-token", - }); - - await writeFile( - accountsPath, - JSON.stringify( - { - codexMultiAuthSyncVersion: Date.now() - 120_000, - activeAccountId: "acc_b", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "a.access", - refresh_token: "refresh-a", - }, - }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "b.access", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - clearCodexCliStateCache(); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 99, - activeIndexByFamily: { codex: 99 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(true); - expect(result.storage?.activeIndex).toBe(1); - expect(result.storage?.activeIndexByFamily?.codex).toBe(1); - }); - - it("serializes concurrent active-selection writes to keep accounts/auth aligned", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "access-a", - id_token: "id-a", - refresh_token: "refresh-a", - }, - }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "access-b", - id_token: "id-b", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - email: "a@example.com", - tokens: { - access_token: "access-a", - id_token: "id-a", - refresh_token: "refresh-a", - account_id: "acc_a", - }, - }, - null, - 2, - ), - "utf-8", - ); - - const [first, second] = await Promise.all([ - setCodexCliActiveSelection({ accountId: "acc_a" }), - setCodexCliActiveSelection({ accountId: "acc_b" }), - ]); - expect(first).toBe(true); - expect(second).toBe(true); - - const writtenAccounts = JSON.parse( - await readFile(accountsPath, "utf-8"), - ) as { - activeAccountId?: string; - activeEmail?: string; - accounts?: Array<{ accountId?: string; active?: boolean }>; - }; - const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { - email?: string; - tokens?: { account_id?: string }; - }; - - expect(writtenAccounts.activeAccountId).toBe("acc_b"); - expect(writtenAccounts.activeEmail).toBe("b@example.com"); - expect(writtenAccounts.accounts?.[0]?.active).toBe(false); - expect(writtenAccounts.accounts?.[1]?.active).toBe(true); - expect(writtenAuth.tokens?.account_id).toBe("acc_b"); - expect(writtenAuth.email).toBe("b@example.com"); - }); - it("ignores Codex snapshots that do not include refresh tokens", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - access_token: "access-only", - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const result = await syncAccountStorageFromCodexCli(null); - expect(result.changed).toBe(false); - expect(result.storage?.accounts).toHaveLength(0); - expect(result.storage?.activeIndex).toBe(0); - }); - - it("matches existing account by refresh token when accountId is absent", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - email: "updated@example.com", - auth: { - tokens: { - access_token: "new-access", - refresh_token: "refresh-a", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - accountIdSource: "token", - email: "a@example.com", - refreshToken: "refresh-a", - accessToken: "old-access", - enabled: true, - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(true); - expect(result.storage?.accounts[0]?.accessToken).toBe("new-access"); - expect(result.storage?.accounts[0]?.email).toBe("updated@example.com"); - }); - - it("returns unchanged when Codex state and local selection are already aligned", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - activeAccountId: "acc_a", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "access-a", - refresh_token: "refresh-a", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const familyIndexes = Object.fromEntries( - MODEL_FAMILIES.map((family) => [family, 0]), - ); - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - accountIdSource: "token", - email: "a@example.com", - refreshToken: "refresh-a", - accessToken: "access-a", - enabled: true, - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: familyIndexes, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(false); - expect(result.storage).toEqual(current); - }); - - it("returns current storage when state loading throws", async () => { - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const loadSpy = vi - .spyOn(codexCliState, "loadCodexCliState") - .mockRejectedValue(new Error("forced load failure")); - - try { - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(false); - expect(result.storage).toBe(current); - } finally { - loadSpy.mockRestore(); - } - }); - - it("applies active selection using normalized email when accountId is absent", async () => { - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const loadSpy = vi - .spyOn(codexCliState, "loadCodexCliState") - .mockResolvedValue({ - path: "mock", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - accessToken: "a.access.token", - refreshToken: "refresh-a", - }, - { - accountId: "acc_b", - email: "b@example.com", - accessToken: "b.access.token", - refreshToken: "refresh-b", - }, - ], - activeEmail: " B@EXAMPLE.COM ", - }); - - try { - const result = await syncAccountStorageFromCodexCli(current); - expect(result.storage?.activeIndex).toBe(1); - } finally { - loadSpy.mockRestore(); - } - }); - - it("initializes family indexes when local storage omits activeIndexByFamily", async () => { - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 1, - }; - - const loadSpy = vi - .spyOn(codexCliState, "loadCodexCliState") - .mockResolvedValue({ - path: "mock", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - accessToken: "a.access.token", - refreshToken: "refresh-a", - }, - ], - activeAccountId: "acc_a", - syncVersion: undefined, - sourceUpdatedAtMs: undefined, - }); - - try { - const result = await syncAccountStorageFromCodexCli(current); - expect(result.storage?.activeIndex).toBe(0); - for (const family of MODEL_FAMILIES) { - expect(result.storage?.activeIndexByFamily?.[family]).toBe(0); - } - } finally { - loadSpy.mockRestore(); - } - }); - - it("clamps and defaults active selection indexes by model family", () => { - const family = MODEL_FAMILIES[0]; - expect( - getActiveSelectionForFamily( - { - version: 3, - accounts: [], - activeIndex: 99, - activeIndexByFamily: {}, - }, - family, - ), - ).toBe(0); - - expect( - getActiveSelectionForFamily( - { - version: 3, - accounts: [ - { refreshToken: "a", addedAt: 1, lastUsed: 1 }, - { refreshToken: "b", addedAt: 1, lastUsed: 1 }, - ], - activeIndex: 1, - activeIndexByFamily: { [family]: Number.NaN }, - }, - family, - ), - ).toBe(1); - - expect( - getActiveSelectionForFamily( - { - version: 3, - accounts: [ - { refreshToken: "a", addedAt: 1, lastUsed: 1 }, - { refreshToken: "b", addedAt: 1, lastUsed: 1 }, - ], - activeIndex: 1, - activeIndexByFamily: { [family]: -3 }, - }, - family, - ), - ).toBe(0); - }); + let tempDir: string; + let accountsPath: string; + let authPath: string; + let configPath: string; + let previousPath: string | undefined; + let previousAuthPath: string | undefined; + let previousConfigPath: string | undefined; + let previousSync: string | undefined; + let previousEnforceFileStore: string | undefined; + + beforeEach(async () => { + previousPath = process.env.CODEX_CLI_ACCOUNTS_PATH; + previousAuthPath = process.env.CODEX_CLI_AUTH_PATH; + previousConfigPath = process.env.CODEX_CLI_CONFIG_PATH; + previousSync = process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; + previousEnforceFileStore = + process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE; + tempDir = await mkdtemp(join(tmpdir(), "codex-multi-auth-sync-")); + accountsPath = join(tempDir, "accounts.json"); + authPath = join(tempDir, "auth.json"); + configPath = join(tempDir, "config.toml"); + process.env.CODEX_CLI_ACCOUNTS_PATH = accountsPath; + process.env.CODEX_CLI_AUTH_PATH = authPath; + process.env.CODEX_CLI_CONFIG_PATH = configPath; + process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "1"; + process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = "1"; + clearCodexCliStateCache(); + }); + + afterEach(async () => { + clearCodexCliStateCache(); + if (previousPath === undefined) delete process.env.CODEX_CLI_ACCOUNTS_PATH; + else process.env.CODEX_CLI_ACCOUNTS_PATH = previousPath; + if (previousAuthPath === undefined) delete process.env.CODEX_CLI_AUTH_PATH; + else process.env.CODEX_CLI_AUTH_PATH = previousAuthPath; + if (previousConfigPath === undefined) delete process.env.CODEX_CLI_CONFIG_PATH; + else process.env.CODEX_CLI_CONFIG_PATH = previousConfigPath; + if (previousSync === undefined) { + delete process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; + } else { + process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = previousSync; + } + if (previousEnforceFileStore === undefined) { + delete process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE; + } else { + process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = + previousEnforceFileStore; + } + await rm(tempDir, { recursive: true, force: true }); + }); + + it("does not seed canonical storage from Codex CLI mirror files", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_mirror", + accounts: [ + { + accountId: "acc_mirror", + email: "mirror@example.com", + auth: { + tokens: { + access_token: "mirror-access", + refresh_token: "mirror-refresh", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const loadSpy = vi.spyOn(codexCliState, "loadCodexCliState"); + try { + const result = await syncAccountStorageFromCodexCli(null); + expect(result.changed).toBe(false); + expect(result.storage).toBeNull(); + expect(loadSpy).not.toHaveBeenCalled(); + } finally { + loadSpy.mockRestore(); + } + }); + + it("does not merge or overwrite canonical storage from Codex CLI mirrors", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "b.access.token", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: Object.fromEntries( + MODEL_FAMILIES.map((family) => [family, 0]), + ), + }; + + const loadSpy = vi.spyOn(codexCliState, "loadCodexCliState"); + try { + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(false); + expect(result.storage).toBe(current); + expect(result.storage?.accounts).toEqual(current.accounts); + expect(loadSpy).not.toHaveBeenCalled(); + } finally { + loadSpy.mockRestore(); + } + }); + + it("normalizes local indexes without reading Codex CLI mirror state", async () => { + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 99, + activeIndexByFamily: { codex: 99 }, + }; + + const loadSpy = vi.spyOn(codexCliState, "loadCodexCliState"); + try { + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(true); + expect(result.storage).not.toBe(current); + expect(result.storage?.activeIndex).toBe(1); + for (const family of MODEL_FAMILIES) { + expect(result.storage?.activeIndexByFamily?.[family]).toBe(1); + } + expect(loadSpy).not.toHaveBeenCalled(); + } finally { + loadSpy.mockRestore(); + } + }); + + it("serializes concurrent active-selection writes to keep accounts/auth aligned", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + id_token: "id-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + id_token: "id-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + email: "a@example.com", + tokens: { + access_token: "access-a", + id_token: "id-a", + refresh_token: "refresh-a", + account_id: "acc_a", + }, + }, + null, + 2, + ), + "utf-8", + ); + + const [first, second] = await Promise.all([ + setCodexCliActiveSelection({ accountId: "acc_a" }), + setCodexCliActiveSelection({ accountId: "acc_b" }), + ]); + expect(first).toBe(true); + expect(second).toBe(true); + + const writtenAccounts = JSON.parse( + await readFile(accountsPath, "utf-8"), + ) as { + activeAccountId?: string; + activeEmail?: string; + accounts?: Array<{ accountId?: string; active?: boolean }>; + }; + const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { + email?: string; + tokens?: { account_id?: string }; + }; + + expect(writtenAccounts.activeAccountId).toBe("acc_b"); + expect(writtenAccounts.activeEmail).toBe("b@example.com"); + expect(writtenAccounts.accounts?.[0]?.active).toBe(false); + expect(writtenAccounts.accounts?.[1]?.active).toBe(true); + expect(writtenAuth.tokens?.account_id).toBe("acc_b"); + expect(writtenAuth.email).toBe("b@example.com"); + }); + + it("clamps and defaults active selection indexes by model family", () => { + const family = MODEL_FAMILIES[0]; + expect( + getActiveSelectionForFamily( + { + version: 3, + accounts: [], + activeIndex: 99, + activeIndexByFamily: {}, + }, + family, + ), + ).toBe(0); + + expect( + getActiveSelectionForFamily( + { + version: 3, + accounts: [ + { refreshToken: "a", addedAt: 1, lastUsed: 1 }, + { refreshToken: "b", addedAt: 1, lastUsed: 1 }, + ], + activeIndex: 1, + activeIndexByFamily: { [family]: Number.NaN }, + }, + family, + ), + ).toBe(1); + + expect( + getActiveSelectionForFamily( + { + version: 3, + accounts: [ + { refreshToken: "a", addedAt: 1, lastUsed: 1 }, + { refreshToken: "b", addedAt: 1, lastUsed: 1 }, + ], + activeIndex: 1, + activeIndexByFamily: { [family]: -3 }, + }, + family, + ), + ).toBe(0); + }); }); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 27261cd2..a26e459c 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2,14 +2,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const loadAccountsMock = vi.fn(); const loadFlaggedAccountsMock = vi.fn(); +const getRestoreAssessmentMock = vi.fn(); const saveAccountsMock = vi.fn(); const saveFlaggedAccountsMock = vi.fn(); +const clearAccountsMock = vi.fn(); +const createEmptyAccountStorageMock = vi.fn(() => ({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, +})); +const withAccountStorageTransactionMock = vi.fn(); const setStoragePathMock = vi.fn(); const getStoragePathMock = vi.fn(() => "/mock/openai-codex-accounts.json"); const queuedRefreshMock = vi.fn(); const setCodexCliActiveSelectionMock = vi.fn(); const promptAddAnotherAccountMock = vi.fn(); const promptLoginModeMock = vi.fn(); +const promptInkAuthDashboardMock = vi.fn(); +const configureInkUnifiedSettingsMock = vi.fn(); +const promptInkRestoreForLoginMock = vi.fn(); +const isNonInteractiveModeMock = vi.fn(); const fetchCodexQuotaSnapshotMock = vi.fn(); const loadDashboardDisplaySettingsMock = vi.fn(); const saveDashboardDisplaySettingsMock = vi.fn(); @@ -46,10 +59,17 @@ vi.mock("../lib/auth/server.js", () => ({ })); vi.mock("../lib/cli.js", () => ({ + isNonInteractiveMode: isNonInteractiveModeMock, promptAddAnotherAccount: promptAddAnotherAccountMock, promptLoginMode: promptLoginModeMock, })); +vi.mock("../lib/ui-ink/index.js", () => ({ + configureInkUnifiedSettings: configureInkUnifiedSettingsMock, + promptInkAuthDashboard: promptInkAuthDashboardMock, + promptInkRestoreForLogin: promptInkRestoreForLoginMock, +})); + vi.mock("../lib/prompts/codex.js", () => ({ MODEL_FAMILIES: ["codex"] as const, })); @@ -74,12 +94,19 @@ vi.mock("../lib/accounts.js", () => ({ })); vi.mock("../lib/storage.js", () => ({ + cloneAccountStorage: vi.fn((storage: unknown) => + storage == null ? storage : structuredClone(storage), + ), + createEmptyAccountStorage: createEmptyAccountStorageMock, + getRestoreAssessment: getRestoreAssessmentMock, loadAccounts: loadAccountsMock, loadFlaggedAccounts: loadFlaggedAccountsMock, saveAccounts: saveAccountsMock, saveFlaggedAccounts: saveFlaggedAccountsMock, + clearAccounts: clearAccountsMock, setStoragePath: setStoragePathMock, getStoragePath: getStoragePathMock, + withAccountStorageTransaction: withAccountStorageTransactionMock, })); vi.mock("../lib/refresh-queue.js", () => ({ @@ -178,18 +205,81 @@ function makeErrnoError(message: string, code: string): NodeJS.ErrnoException { return error; } +function createRestoreAssessment(overrides: Partial<{ + storagePath: string; + restoreEligible: boolean; + restoreReason: "empty-storage" | "intentional-reset" | "missing-storage"; + latestSnapshot: { + kind: string; + path: string; + exists: boolean; + valid: boolean; + accountCount?: number; + bytes?: number; + mtimeMs?: number; + version?: number; + }; + backupMetadata: { + accounts: { + storagePath: string; + latestValidPath?: string; + snapshotCount: number; + validSnapshotCount: number; + snapshots: Array>; + }; + flaggedAccounts: { + storagePath: string; + latestValidPath?: string; + snapshotCount: number; + validSnapshotCount: number; + snapshots: Array>; + }; + }; +}> = {}) { + const storagePath = overrides.storagePath ?? "/mock/openai-codex-accounts.json"; + const flaggedPath = "/mock/openai-codex-flagged-accounts.json"; + return { + storagePath, + restoreEligible: overrides.restoreEligible ?? false, + restoreReason: overrides.restoreReason, + latestSnapshot: overrides.latestSnapshot, + backupMetadata: overrides.backupMetadata ?? { + accounts: { + storagePath, + snapshotCount: 0, + validSnapshotCount: 0, + snapshots: [], + }, + flaggedAccounts: { + storagePath: flaggedPath, + snapshotCount: 0, + validSnapshotCount: 0, + snapshots: [], + }, + }, + }; +} + describe("codex manager cli commands", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); loadAccountsMock.mockReset(); loadFlaggedAccountsMock.mockReset(); + getRestoreAssessmentMock.mockReset(); saveAccountsMock.mockReset(); saveFlaggedAccountsMock.mockReset(); + clearAccountsMock.mockReset(); + createEmptyAccountStorageMock.mockReset(); + withAccountStorageTransactionMock.mockReset(); queuedRefreshMock.mockReset(); setCodexCliActiveSelectionMock.mockReset(); promptAddAnotherAccountMock.mockReset(); promptLoginModeMock.mockReset(); + promptInkAuthDashboardMock.mockReset(); + configureInkUnifiedSettingsMock.mockReset(); + promptInkRestoreForLoginMock.mockReset(); + isNonInteractiveModeMock.mockReset(); fetchCodexQuotaSnapshotMock.mockReset(); loadDashboardDisplaySettingsMock.mockReset(); saveDashboardDisplaySettingsMock.mockReset(); @@ -212,6 +302,7 @@ describe("codex manager cli commands", () => { version: 1, accounts: [], }); + getRestoreAssessmentMock.mockResolvedValue(createRestoreAssessment()); loadDashboardDisplaySettingsMock.mockResolvedValue({ showPerAccountRows: true, showQuotaDetails: true, @@ -226,10 +317,25 @@ describe("codex manager cli commands", () => { }); loadPluginConfigMock.mockReturnValue({}); savePluginConfigMock.mockResolvedValue(undefined); + isNonInteractiveModeMock.mockImplementation(() => { + if (process.env.FORCE_INTERACTIVE_MODE === "1") return false; + return !process.stdin.isTTY || !process.stdout.isTTY; + }); selectMock.mockResolvedValue(undefined); + promptInkAuthDashboardMock.mockResolvedValue(null); + configureInkUnifiedSettingsMock.mockResolvedValue(false); + promptInkRestoreForLoginMock.mockResolvedValue(null); restoreTTYDescriptors(); setStoragePathMock.mockReset(); getStoragePathMock.mockReturnValue("/mock/openai-codex-accounts.json"); + withAccountStorageTransactionMock.mockImplementation(async (handler) => { + const latestLoadResult = loadAccountsMock.mock.results[loadAccountsMock.mock.results.length - 1]; + const current = latestLoadResult ? await latestLoadResult.value : await loadAccountsMock(); + return handler( + current == null ? current : structuredClone(current), + async (storage) => saveAccountsMock(storage), + ); + }); }); afterEach(() => { @@ -777,7 +883,7 @@ describe("codex manager cli commands", () => { }; loadAccountsMock.mockResolvedValue(storage); setCodexCliActiveSelectionMock.mockResolvedValue(true); - promptLoginModeMock + promptInkAuthDashboardMock .mockResolvedValueOnce({ mode: "manage", switchAccountIndex: 1 }) .mockResolvedValueOnce({ mode: "cancel" }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -785,7 +891,8 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(promptInkAuthDashboardMock).toHaveBeenCalledTimes(2); + expect(promptLoginModeMock).not.toHaveBeenCalled(); expect(saveAccountsMock).toHaveBeenCalledTimes(1); expect(logSpy).toHaveBeenCalledWith( expect.stringContaining("Switched to account 2"), @@ -793,6 +900,187 @@ describe("codex manager cli commands", () => { expect(logSpy).toHaveBeenCalledWith("Cancelled."); }); + it("prompts to restore the latest backup before opening the login dashboard", async () => { + setInteractiveTTY(true); + const now = Date.now(); + const restoredStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "restored@example.com", + accountId: "acc_restored", + refreshToken: "refresh-restored", + accessToken: "access-restored", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }; + getRestoreAssessmentMock.mockResolvedValueOnce(createRestoreAssessment({ + restoreEligible: true, + restoreReason: "missing-storage", + latestSnapshot: { + kind: "accounts-backup", + path: "/mock/openai-codex-accounts.json.bak", + exists: true, + valid: true, + accountCount: 1, + bytes: 512, + mtimeMs: now - 10_000, + version: 3, + }, + backupMetadata: { + accounts: { + storagePath: "/mock/openai-codex-accounts.json", + latestValidPath: "/mock/openai-codex-accounts.json.bak", + snapshotCount: 2, + validSnapshotCount: 1, + snapshots: [ + { + kind: "accounts-primary", + path: "/mock/openai-codex-accounts.json", + exists: false, + valid: false, + }, + { + kind: "accounts-backup", + path: "/mock/openai-codex-accounts.json.bak", + exists: true, + valid: true, + accountCount: 1, + bytes: 512, + mtimeMs: now - 10_000, + version: 3, + }, + ], + }, + flaggedAccounts: { + storagePath: "/mock/openai-codex-flagged-accounts.json", + snapshotCount: 0, + validSnapshotCount: 0, + snapshots: [], + }, + }, + })); + loadAccountsMock.mockResolvedValue(restoredStorage); + selectMock.mockResolvedValueOnce(true); + promptInkAuthDashboardMock.mockResolvedValueOnce({ mode: "cancel" }); + promptInkRestoreForLoginMock.mockResolvedValueOnce(true); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptInkRestoreForLoginMock).toHaveBeenCalledTimes(1); + expect(promptInkRestoreForLoginMock).toHaveBeenCalledWith( + expect.objectContaining({ + reasonText: expect.stringContaining("No saved account pool was found."), + snapshotInfo: expect.stringContaining("/mock/openai-codex-accounts.json.bak"), + snapshotCount: 1, + }), + ); + expect(selectMock).not.toHaveBeenCalled(); + expect(promptInkAuthDashboardMock).toHaveBeenCalledTimes(1); + expect(promptInkAuthDashboardMock).toHaveBeenCalledWith( + expect.objectContaining({ + statusTextOverride: expect.stringContaining("Restored 1 account"), + statusToneOverride: "success", + }), + ); + expect(promptLoginModeMock).not.toHaveBeenCalled(); + }); + + + it("skips restore prompting deterministically in non-interactive mode", async () => { + setInteractiveTTY(false); + const now = Date.now(); + getRestoreAssessmentMock.mockResolvedValueOnce(createRestoreAssessment({ + restoreEligible: true, + restoreReason: "missing-storage", + latestSnapshot: { + kind: "accounts-backup", + path: "/mock/openai-codex-accounts.json.bak", + exists: true, + valid: true, + accountCount: 2, + bytes: 768, + mtimeMs: now - 10_000, + version: 3, + }, + backupMetadata: { + accounts: { + storagePath: "/mock/openai-codex-accounts.json", + latestValidPath: "/mock/openai-codex-accounts.json.bak", + snapshotCount: 2, + validSnapshotCount: 1, + snapshots: [ + { + kind: "accounts-primary", + path: "/mock/openai-codex-accounts.json", + exists: false, + valid: false, + }, + { + kind: "accounts-backup", + path: "/mock/openai-codex-accounts.json.bak", + exists: true, + valid: true, + accountCount: 2, + bytes: 768, + mtimeMs: now - 10_000, + version: 3, + }, + ], + }, + flaggedAccounts: { + storagePath: "/mock/openai-codex-flagged-accounts.json", + snapshotCount: 0, + validSnapshotCount: 0, + snapshots: [], + }, + }, + })); + + const authModule = await import("../lib/auth/auth.js"); + const createAuthorizationFlowMock = vi.mocked(authModule.createAuthorizationFlow); + const browserModule = await import("../lib/auth/browser.js"); + const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); + const serverModule = await import("../lib/auth/server.js"); + const startLocalOAuthServerMock = vi.mocked(serverModule.startLocalOAuthServer); + + createAuthorizationFlowMock.mockResolvedValue({ + pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" }, + state: "oauth-state", + url: "https://auth.openai.com/mock", + }); + openBrowserUrlMock.mockReturnValue(true); + startLocalOAuthServerMock.mockResolvedValue({ + ready: false, + waitForCode: vi.fn(async () => null), + close: vi.fn(), + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectMock).not.toHaveBeenCalled(); + expect(saveAccountsMock).toHaveBeenCalledWith({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }); + expect( + logSpy.mock.calls.some((call) => String(call[0]).includes("non-interactive mode skips the prompt")), + ).toBe(true); + }); + it("marks newly added login account active so smart sort reflects it immediately", async () => { const now = Date.now(); let storageState: { @@ -1010,10 +1298,13 @@ describe("codex manager cli commands", () => { promptLoginModeMock .mockResolvedValueOnce({ mode: "settings" }) .mockResolvedValueOnce({ mode: "cancel" }); + configureInkUnifiedSettingsMock.mockResolvedValueOnce(true); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); + expect(configureInkUnifiedSettingsMock).toHaveBeenCalledTimes(1); + expect(selectMock).not.toHaveBeenCalled(); expect(promptLoginModeMock).toHaveBeenCalledTimes(2); }); @@ -1095,19 +1386,21 @@ describe("codex manager cli commands", () => { }, }, }); - promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + promptInkAuthDashboardMock.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; - index: number; - sourceIndex?: number; - quickSwitchNumber?: number; - isCurrentAccount?: boolean; - }>; + const firstCallAccounts = (promptInkAuthDashboardMock.mock.calls[0]?.[0] as { + dashboard: { accounts: Array<{ + email?: string; + index: number; + sourceIndex?: number; + quickSwitchNumber?: number; + isCurrentAccount?: boolean; + }> }; + })?.dashboard.accounts ?? []; expect(firstCallAccounts.map((account) => account.email)).toEqual([ "b@example.com", "c@example.com", @@ -1180,16 +1473,15 @@ describe("codex manager cli commands", () => { }, }, }); - promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + promptInkAuthDashboardMock.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; - }>; + const firstCallAccounts = (promptInkAuthDashboardMock.mock.calls[0]?.[0] as { + dashboard: { accounts: Array<{ email?: string; quickSwitchNumber?: number }> }; + })?.dashboard.accounts ?? []; expect(firstCallAccounts.map((account) => account.email)).toEqual([ "b@example.com", "a@example.com", @@ -1938,6 +2230,7 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts).toHaveLength(1); expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.email).toBe("first@example.com"); }); @@ -1967,9 +2260,39 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.enabled).toBe(false); }); + it("resets all accounts through transactional persistence", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "reset@example.com", + refreshToken: "refresh-reset", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "fresh", deleteAll: true }) + .mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(clearAccountsMock).toHaveBeenCalledTimes(1); + expect(withAccountStorageTransactionMock).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + }); + it("keeps settings unchanged in non-interactive mode and returns to menu", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValue({ diff --git a/test/runtime-paths.test.ts b/test/runtime-paths.test.ts index 95976776..24b584db 100644 --- a/test/runtime-paths.test.ts +++ b/test/runtime-paths.test.ts @@ -86,6 +86,22 @@ describe("runtime-paths", () => { expect(mod.getCodexMultiAuthDir()).toBe(fallback); }); + it("keeps canonical multi-auth root steady-state even when fallback still holds accounts", async () => { + process.env.CODEX_HOME = "/home/neil/.codex-canonical"; + const primary = path.join("/home/neil/.codex-canonical", "multi-auth"); + const fallback = path.join("/home/neil/DevTools/config/codex", "multi-auth"); + + existsSync.mockImplementation((candidate: unknown) => { + if (typeof candidate !== "string") return false; + if (candidate === path.join(primary, "settings.json")) return true; + if (candidate === path.join(fallback, "openai-codex-accounts.json")) return true; + return false; + }); + + const mod = await import("../lib/runtime-paths.js"); + expect(mod.getCodexMultiAuthDir()).toBe(primary); + }); + it("uses legacy root when it is the only directory containing account storage", async () => { process.env.CODEX_HOME = "/home/neil/.codex"; const legacyRoot = path.join("/home/neil", ".codex"); diff --git a/test/storage-flagged.test.ts b/test/storage-flagged.test.ts index 7fce9e18..9efd733d 100644 --- a/test/storage-flagged.test.ts +++ b/test/storage-flagged.test.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { clearFlaggedAccounts, + getBackupMetadata, getFlaggedAccountsPath, getStoragePath, loadFlaggedAccounts, @@ -166,8 +167,50 @@ describe("flagged account storage", () => { await clearFlaggedAccounts(); await clearFlaggedAccounts(); - expect(existsSync(getFlaggedAccountsPath())).toBe(false); - }); + expect(existsSync(getFlaggedAccountsPath())).toBe(false); + }); + + it("emits snapshot metadata for flagged account backups", async () => { + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "first-flagged", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "first-flagged", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "second-flagged", + flaggedAt: 2, + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + const metadata = await getBackupMetadata(); + const flagged = metadata.flaggedAccounts; + expect(flagged.snapshotCount).toBeGreaterThanOrEqual(2); + expect(flagged.latestValidPath).toBe(getFlaggedAccountsPath()); + const primary = flagged.snapshots.find((snapshot) => snapshot.kind === "flagged-primary"); + const backup = flagged.snapshots.find((snapshot) => snapshot.kind === "flagged-backup"); + expect(primary?.flaggedCount).toBe(2); + expect(backup?.valid).toBe(true); + expect(backup?.flaggedCount).toBe(1); + }); it("cleans temporary file when flagged save fails", async () => { const flaggedPath = getFlaggedAccountsPath(); diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index 7e2a3f3a..286548cd 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -5,11 +5,25 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { loadAccounts, + getBackupMetadata, saveAccounts, setStorageBackupEnabled, setStoragePathDirect, + clearAccounts, + getRestoreAssessment, } from "../lib/storage.js"; +function getRestoreEligibility(value: unknown): { restoreEligible?: boolean; restoreReason?: string } { + if (value && typeof value === "object" && "restoreEligible" in value) { + const candidate = value as { restoreEligible?: unknown; restoreReason?: unknown }; + return { + restoreEligible: typeof candidate.restoreEligible === "boolean" ? candidate.restoreEligible : undefined, + restoreReason: typeof candidate.restoreReason === "string" ? candidate.restoreReason : undefined, + }; + } + return {}; +} + function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } @@ -326,6 +340,180 @@ describe("storage recovery paths", () => { expect(persisted.accounts?.[0]?.email).toBe("realuser@gmail.com"); }); + it("surfaces restore eligibility when account pool is missing", async () => { + await fs.rm(storagePath, { force: true }); + + const recovered = await loadAccounts(); + const eligibility = getRestoreEligibility(recovered); + + expect(eligibility.restoreEligible).toBe(true); + expect(eligibility.restoreReason).toBe("missing-storage"); + }); + + it("surfaces restore eligibility when account pool is empty", async () => { + await fs.writeFile( + storagePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); + + const recovered = await loadAccounts(); + const eligibility = getRestoreEligibility(recovered); + + expect(eligibility.restoreEligible).toBe(true); + expect(eligibility.restoreReason).toBe("empty-storage"); + }); + + it("suppresses restore eligibility after intentional reset but flags unexpected empty state", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "token-reset", + accountId: "reset-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await clearAccounts(); + const afterIntentionalReset = await loadAccounts(); + const intentionalEligibility = getRestoreEligibility(afterIntentionalReset); + expect(intentionalEligibility.restoreEligible).toBe(false); + + await fs.writeFile( + storagePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); + const afterAccidentalEmpty = await loadAccounts(); + const accidentalEligibility = getRestoreEligibility(afterAccidentalEmpty); + expect(accidentalEligibility.restoreEligible).toBe(true); + }); + + it("assesses restore state with latest snapshot metadata", async () => { + const backupPayload = { + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "backup-refresh", + accountId: "from-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }; + await fs.writeFile(`${storagePath}.bak`, JSON.stringify(backupPayload), "utf-8"); + + const assessment = await getRestoreAssessment(); + + expect(assessment.restoreEligible).toBe(true); + expect(assessment.restoreReason).toBe("missing-storage"); + expect(assessment.latestSnapshot?.path).toBe(`${storagePath}.bak`); + expect(assessment.backupMetadata.accounts.latestValidPath).toBe(`${storagePath}.bak`); + }); + + it("ignores Codex CLI mirror files during restore assessment", async () => { + const codexCliAccountsPath = join(workDir, "accounts.json"); + const codexCliAuthPath = join(workDir, "auth.json"); + await fs.writeFile( + codexCliAccountsPath, + JSON.stringify({ + activeAccountId: "mirror-account", + accounts: [ + { + accountId: "mirror-account", + email: "mirror@example.com", + auth: { + tokens: { + access_token: "mirror-access", + refresh_token: "mirror-refresh", + }, + }, + }, + ], + }), + "utf-8", + ); + await fs.writeFile( + codexCliAuthPath, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: "mirror-access", + refresh_token: "mirror-refresh", + account_id: "mirror-account", + }, + }), + "utf-8", + ); + + const recovered = await loadAccounts(); + const eligibility = getRestoreEligibility(recovered); + expect(recovered?.accounts).toHaveLength(0); + expect(eligibility.restoreEligible).toBe(true); + expect(eligibility.restoreReason).toBe("missing-storage"); + + const assessment = await getRestoreAssessment(); + expect(assessment.restoreEligible).toBe(true); + expect(assessment.restoreReason).toBe("missing-storage"); + expect(assessment.latestSnapshot).toBeUndefined(); + expect(assessment.backupMetadata.accounts.latestValidPath).toBeUndefined(); + expect( + assessment.backupMetadata.accounts.snapshots.some( + (snapshot) => snapshot.path === codexCliAccountsPath || snapshot.path === codexCliAuthPath, + ), + ).toBe(false); + }); + + it("returns restore eligibility and snapshot when storage is empty", async () => { + await fs.writeFile( + storagePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); + + const assessment = await getRestoreAssessment(); + + expect(assessment.restoreEligible).toBe(true); + expect(assessment.restoreReason).toBe("empty-storage"); + expect(assessment.latestSnapshot?.path).toBe(storagePath); + }); + + it("suppresses restore once after intentional reset marker", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "token-reset", + accountId: "reset-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await clearAccounts(); + + const suppressed = await getRestoreAssessment(); + expect(suppressed.restoreEligible).toBe(false); + expect(suppressed.restoreReason).toBe("intentional-reset"); + + await fs.writeFile( + storagePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); + + const eligibleAfterReset = await getRestoreAssessment(); + expect(eligibleAfterReset.restoreEligible).toBe(true); + expect(eligibleAfterReset.restoreReason).toBe("empty-storage"); + }); + it("cleans up stale staged backup artifacts during load", async () => { await fs.writeFile( storagePath, @@ -376,5 +564,77 @@ describe("storage recovery paths", () => { const recovered = await loadAccounts(); expect(recovered).toBeNull(); }); + + it("exposes snapshot metadata and ignores cache-like artifacts", async () => { + await fs.writeFile(storagePath, "{invalid-json", "utf-8"); + + const walPayload = { + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "wal-refresh-meta", + accountId: "wal-account", + addedAt: 10, + lastUsed: 10, + }, + ], + }; + const walContent = JSON.stringify(walPayload); + const walEntry = { + version: 1, + createdAt: Date.now(), + path: storagePath, + checksum: sha256(walContent), + content: walContent, + }; + await fs.writeFile(`${storagePath}.wal`, JSON.stringify(walEntry), "utf-8"); + + await fs.writeFile( + `${storagePath}.bak`, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "bak-refresh-meta", + accountId: "bak-account", + addedAt: 5, + lastUsed: 5, + }, + ], + }), + "utf-8", + ); + + await fs.writeFile(`${storagePath}.cache`, "noise", "utf-8"); + await fs.writeFile( + `${storagePath}.manual-meta-checkpoint`, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "manual-refresh-meta", + accountId: "manual-account", + addedAt: 7, + lastUsed: 7, + }, + ], + }), + "utf-8", + ); + + const metadata = await getBackupMetadata(); + const accountSnapshots = metadata.accounts.snapshots; + const cacheEntries = accountSnapshots.filter((snapshot) => snapshot.path.endsWith(".cache")); + expect(cacheEntries).toHaveLength(0); + expect(metadata.accounts.latestValidPath).toBe(`${storagePath}.wal`); + const discovered = accountSnapshots.find((snapshot) => snapshot.path.endsWith("manual-meta-checkpoint")); + expect(discovered?.kind).toBe("accounts-discovered-backup"); + expect(discovered?.valid).toBe(true); + expect(discovered?.accountCount).toBe(1); + expect(metadata.accounts.snapshotCount).toBeGreaterThanOrEqual(4); + }); }); diff --git a/test/storage.test.ts b/test/storage.test.ts index 3c3157e8..db70c317 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -638,10 +638,12 @@ describe("storage", () => { await fs.rm(testWorkDir, { recursive: true, force: true }); }); - it("returns null when file does not exist", async () => { - const result = await loadAccounts(); - expect(result).toBeNull(); - }); + it("returns null when file does not exist", async () => { + const result = await loadAccounts(); + expect(result?.accounts).toHaveLength(0); + expect(result?.restoreEligible).toBe(true); + expect(result?.restoreReason).toBe("missing-storage"); + }); it("returns null on parse error", async () => { await fs.writeFile(testStoragePath, "not valid json{{{", "utf-8"); @@ -1060,6 +1062,37 @@ describe("storage", () => { expect(existsSync(legacyStoragePath)).toBe(false); expect(existsSync(getStoragePath())).toBe(true); }); + + it("migrates populated fallback root only after canonical write succeeds", async () => { + const fakeHome = join(testWorkDir, "home-fallback"); + const canonicalPath = join(fakeHome, ".codex", "multi-auth", "openai-codex-accounts.json"); + const fallbackPath = join(fakeHome, "DevTools", "config", "codex", "multi-auth", "openai-codex-accounts.json"); + + await fs.mkdir(dirname(fallbackPath), { recursive: true }); + await fs.writeFile( + fallbackPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "fallback-refresh", + accountId: "fallback-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + setStoragePathDirect(canonicalPath); + const loaded = await loadAccounts(); + + expect(loaded?.accounts?.[0]?.accountId).toBe("fallback-account"); + expect(existsSync(canonicalPath)).toBe(true); + expect(existsSync(fallbackPath)).toBe(false); + }); }); describe("worktree-scoped storage migration", () => { @@ -1830,14 +1863,14 @@ describe("storage", () => { expect(existsSync(`${storagePath}.bak.2`)).toBe(true); expect(existsSync(`${storagePath}.wal`)).toBe(true); - await clearAccounts(); + await clearAccounts(); - expect(existsSync(storagePath)).toBe(false); - expect(existsSync(`${storagePath}.bak`)).toBe(false); - expect(existsSync(`${storagePath}.bak.1`)).toBe(false); - expect(existsSync(`${storagePath}.bak.2`)).toBe(false); - expect(existsSync(`${storagePath}.wal`)).toBe(false); - }); + expect(existsSync(storagePath)).toBe(false); + expect(existsSync(`${storagePath}.bak`)).toBe(true); + expect(existsSync(`${storagePath}.bak.1`)).toBe(true); + expect(existsSync(`${storagePath}.bak.2`)).toBe(true); + expect(existsSync(`${storagePath}.wal`)).toBe(true); + }); it("logs error for non-ENOENT errors during clear", async () => { const unlinkSpy = vi.spyOn(fs, "unlink").mockRejectedValue(