From af7f13709c7cf84c68da1971a3012341c9c87059 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 23:19:54 +0800 Subject: [PATCH 01/13] fix(auth): define reset and delete safety flows --- README.md | 9 +- docs/troubleshooting.md | 44 +- index.ts | 56 +- lib/cli.ts | 119 +++- lib/codex-manager.ts | 88 ++- lib/destructive-actions.ts | 196 ++++++ lib/quota-cache.ts | 66 +- lib/storage.ts | 112 ++-- lib/ui/auth-menu.ts | 372 ++++++++--- lib/ui/copy.ts | 34 +- test/auth-menu-hotkeys.test.ts | 103 +++ test/cli-auth-menu.test.ts | 32 +- test/cli.test.ts | 787 +++++++++++++++-------- test/codex-manager-cli.test.ts | 324 +++++++++- test/destructive-actions.test.ts | 301 +++++++++ test/quota-cache.test.ts | 106 ++- test/release-main-prs-regression.test.ts | 2 +- test/storage-flagged.test.ts | 167 ++--- test/storage.test.ts | 319 ++++++++- 19 files changed, 2640 insertions(+), 597 deletions(-) create mode 100644 lib/destructive-actions.ts create mode 100644 test/destructive-actions.test.ts diff --git a/README.md b/README.md index 648058cc..c505e8f7 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Codex CLI-first multi-account OAuth manager for the official `@openai/codex` CLI ### Option A: Standard install ```bash +npm i -g @openai/codex npm i -g codex-multi-auth ``` @@ -74,16 +75,18 @@ codex auth status ### Step-by-step -1. Install global package: +1. Install global packages: + - `npm i -g @openai/codex` - `npm i -g codex-multi-auth` 2. Run first login flow with `codex auth login` -3. Validate state with `codex auth status` and `codex auth check` +3. Validate state with `codex auth list` and `codex auth check` 4. Confirm routing with `codex auth forecast --live` ### Verification ```bash codex auth status +codex auth list codex auth check ``` @@ -95,7 +98,7 @@ codex auth check ```bash codex auth login -codex auth status +codex auth list codex auth check codex auth forecast --live ``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ad0a67e5..0c0bbe86 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -89,24 +89,52 @@ codex auth doctor --json --- -## Soft Reset +## Reset Options -PowerShell: +- Delete a single saved account: `codex auth login` → pick account → **Delete Account** +- Delete saved accounts: `codex auth login` → Danger Zone → **Delete Saved Accounts** +- Reset local state: `codex auth login` → Danger Zone → **Reset Local State** + +Exact effects: + +| Action | Saved accounts | Flagged/problem accounts | Settings | Codex CLI sync state | Quota cache | +| --- | --- | --- | --- | --- | --- | +| Delete Account | Delete the selected saved account | Delete the matching flagged/problem entry for that refresh token | Keep | Keep | Keep | +| Delete Saved Accounts | Delete all saved accounts | Keep | Keep | Keep | Keep | +| Reset Local State | Delete all saved accounts | Delete all flagged/problem accounts | Keep | Keep | Clear | + +To perform the same actions manually: + +Delete saved accounts only: ```powershell Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json" -Force -ErrorAction SilentlyContinue -Remove-Item "$HOME\.codex\multi-auth\openai-codex-flagged-accounts.json" -Force -ErrorAction SilentlyContinue -Remove-Item "$HOME\.codex\multi-auth\settings.json" -Force -ErrorAction SilentlyContinue -codex auth login +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json.wal" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json.bak*" -Force -ErrorAction SilentlyContinue +``` + +```bash +rm -f ~/.codex/multi-auth/openai-codex-accounts.json +rm -f ~/.codex/multi-auth/openai-codex-accounts.json.wal +rm -f ~/.codex/multi-auth/openai-codex-accounts.json.bak* ``` -Bash: +Reset local state (also clears flagged/problem accounts and quota cache; preserves settings and Codex CLI sync state): + +```powershell +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json.wal" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json.bak*" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\openai-codex-flagged-accounts.json" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\quota-cache.json" -Force -ErrorAction SilentlyContinue +``` ```bash rm -f ~/.codex/multi-auth/openai-codex-accounts.json +rm -f ~/.codex/multi-auth/openai-codex-accounts.json.wal +rm -f ~/.codex/multi-auth/openai-codex-accounts.json.bak* rm -f ~/.codex/multi-auth/openai-codex-flagged-accounts.json -rm -f ~/.codex/multi-auth/settings.json -codex auth login +rm -f ~/.codex/multi-auth/quota-cache.json ``` --- diff --git a/index.ts b/index.ts index 147959c4..1db25ec2 100644 --- a/index.ts +++ b/index.ts @@ -101,6 +101,12 @@ import { } from "./lib/logger.js"; import { checkAndNotify } from "./lib/auto-update-checker.js"; import { handleContextOverflow } from "./lib/context-overflow.js"; +import { + DESTRUCTIVE_ACTION_COPY, + deleteAccountAtIndex, + deleteSavedAccounts, + resetLocalState, +} from "./lib/destructive-actions.js"; import { AccountManager, getAccountIdCandidates, @@ -122,13 +128,11 @@ import { loadAccounts, saveAccounts, withAccountStorageTransaction, - clearAccounts, setStoragePath, exportAccounts, importAccounts, loadFlaggedAccounts, saveFlaggedAccounts, - clearFlaggedAccounts, findMatchingAccountIndex, StorageError, formatStorageErrorHint, @@ -3101,19 +3105,18 @@ while (attempted.size < Math.max(1, accountCount)) { if (menuResult.mode === "manage") { if (typeof menuResult.deleteAccountIndex === "number") { - const target = workingStorage.accounts[menuResult.deleteAccountIndex]; - if (target) { - workingStorage.accounts.splice(menuResult.deleteAccountIndex, 1); - clampActiveIndices(workingStorage); - await saveAccounts(workingStorage); - await saveFlaggedAccounts({ - version: 1, - accounts: flaggedStorage.accounts.filter( - (flagged) => flagged.refreshToken !== target.refreshToken, - ), - }); + const deleted = await deleteAccountAtIndex({ + storage: workingStorage, + index: menuResult.deleteAccountIndex, + }); + if (deleted) { invalidateAccountManagerCache(); - console.log(`\nDeleted ${target.email ?? `Account ${menuResult.deleteAccountIndex + 1}`}.\n`); + const label = `Account ${menuResult.deleteAccountIndex + 1}`; + const flaggedNote = + deleted.removedFlaggedCount > 0 + ? ` Removed ${deleted.removedFlaggedCount} matching problem account${deleted.removedFlaggedCount === 1 ? "" : "s"}.` + : ""; + console.log(`\nDeleted ${label}.${flaggedNote}\n`); } continue; } @@ -3143,16 +3146,35 @@ while (attempted.size < Math.max(1, accountCount)) { if (menuResult.mode === "fresh") { startFresh = true; if (menuResult.deleteAll) { - await clearAccounts(); - await clearFlaggedAccounts(); + const result = await deleteSavedAccounts(); invalidateAccountManagerCache(); console.log( - "\nCleared saved accounts from active storage. Recovery snapshots remain available. Starting fresh.\n", + `\n${ + result.accountsCleared + ? DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.completed + : "Delete saved accounts completed with warnings. Some saved account artifacts could not be removed; see logs." + }\n`, ); } break; } + if (menuResult.mode === "reset") { + startFresh = true; + const result = await resetLocalState(); + invalidateAccountManagerCache(); + console.log( + `\n${ + result.accountsCleared && + result.flaggedCleared && + result.quotaCacheCleared + ? DESTRUCTIVE_ACTION_COPY.resetLocalState.completed + : "Reset local state completed with warnings. Some local artifacts could not be removed; see logs." + }\n`, + ); + break; + } + startFresh = false; break; } diff --git a/lib/cli.ts b/lib/cli.ts index d223c14c..363b1b2b 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -1,11 +1,12 @@ -import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "node:readline/promises"; +import { DESTRUCTIVE_ACTION_COPY } from "./destructive-actions.js"; import type { AccountIdSource } from "./types.js"; import { - showAuthMenu, - showAccountDetails, - isTTY, type AccountStatus, + isTTY, + showAccountDetails, + showAuthMenu, } from "./ui/auth-menu.js"; import { UI_COPY } from "./ui/copy.js"; @@ -19,12 +20,15 @@ export function isNonInteractiveMode(): boolean { if (!input.isTTY || !output.isTTY) return true; if (process.env.CODEX_TUI === "1") return true; if (process.env.CODEX_DESKTOP === "1") return true; - if ((process.env.TERM_PROGRAM ?? "").trim().toLowerCase() === "codex") return true; + if ((process.env.TERM_PROGRAM ?? "").trim().toLowerCase() === "codex") + return true; if (process.env.ELECTRON_RUN_AS_NODE === "1") return true; return false; } -export async function promptAddAnotherAccount(currentCount: number): Promise { +export async function promptAddAnotherAccount( + currentCount: number, +): Promise { if (isNonInteractiveMode()) { return false; } @@ -32,7 +36,9 @@ export async function promptAddAnotherAccount(currentCount: number): Promise 6 ? account.accountId.slice(-6) : account.accountId; + const suffix = + account.accountId.length > 6 + ? account.accountId.slice(-6) + : account.accountId; return `${num}. ${suffix}`; } return `${num}. Account`; @@ -112,7 +127,8 @@ function formatAccountLabel(account: ExistingAccountInfo, index: number): string function resolveAccountSourceIndex(account: ExistingAccountInfo): number { const sourceIndex = - typeof account.sourceIndex === "number" && Number.isFinite(account.sourceIndex) + typeof account.sourceIndex === "number" && + Number.isFinite(account.sourceIndex) ? Math.max(0, Math.floor(account.sourceIndex)) : undefined; if (typeof sourceIndex === "number") return sourceIndex; @@ -123,21 +139,40 @@ function resolveAccountSourceIndex(account: ExistingAccountInfo): number { } function warnUnresolvableAccountSelection(account: ExistingAccountInfo): void { - const label = account.email?.trim() || account.accountId?.trim() || `index ${account.index + 1}`; + const label = + account.email?.trim() || + account.accountId?.trim() || + `index ${account.index + 1}`; console.log(`Unable to resolve saved account for action: ${label}`); } async function promptDeleteAllTypedConfirm(): Promise { const rl = createInterface({ input, output }); try { - const answer = await rl.question("Type DELETE to remove all saved accounts: "); + const answer = await rl.question( + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.typedConfirm, + ); return answer.trim() === "DELETE"; } finally { rl.close(); } } -async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): Promise { +async function promptResetTypedConfirm(): Promise { + const rl = createInterface({ input, output }); + try { + const answer = await rl.question( + DESTRUCTIVE_ACTION_COPY.resetLocalState.typedConfirm, + ); + return answer.trim() === "RESET"; + } finally { + rl.close(); + } +} + +async function promptLoginModeFallback( + existingAccounts: ExistingAccountInfo[], +): Promise { const rl = createInterface({ input, output }); try { if (existingAccounts.length > 0) { @@ -152,17 +187,41 @@ async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): const answer = await rl.question(UI_COPY.fallback.selectModePrompt); const normalized = answer.trim().toLowerCase(); if (normalized === "a" || normalized === "add") return { mode: "add" }; - if (normalized === "b" || normalized === "p" || normalized === "forecast") { + if ( + normalized === "b" || + normalized === "p" || + normalized === "forecast" + ) { return { mode: "forecast" }; } if (normalized === "x" || normalized === "fix") return { mode: "fix" }; - if (normalized === "s" || normalized === "settings" || normalized === "configure") { + if ( + normalized === "s" || + normalized === "settings" || + normalized === "configure" + ) { return { mode: "settings" }; } - if (normalized === "f" || normalized === "fresh" || normalized === "clear") { + if ( + normalized === "f" || + normalized === "fresh" || + normalized === "clear" + ) { + if (!(await promptDeleteAllTypedConfirm())) { + console.log("\nDelete saved accounts cancelled.\n"); + continue; + } return { mode: "fresh", deleteAll: true }; } - if (normalized === "c" || normalized === "check") return { mode: "check" }; + if (normalized === "r" || normalized === "reset") { + if (!(await promptResetTypedConfirm())) { + console.log("\nReset local state cancelled.\n"); + continue; + } + return { mode: "reset" }; + } + if (normalized === "c" || normalized === "check") + return { mode: "check" }; if (normalized === "d" || normalized === "deep") { return { mode: "deep-check" }; } @@ -174,7 +233,8 @@ async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): ) { return { mode: "verify-flagged" }; } - if (normalized === "q" || normalized === "quit") return { mode: "cancel" }; + if (normalized === "q" || normalized === "quit") + return { mode: "cancel" }; console.log(UI_COPY.fallback.invalidModePrompt); } } finally { @@ -211,10 +271,16 @@ export async function promptLoginMode( return { mode: "settings" }; case "fresh": if (!(await promptDeleteAllTypedConfirm())) { - console.log("\nDelete all cancelled.\n"); + console.log("\nDelete saved accounts cancelled.\n"); continue; } return { mode: "fresh", deleteAll: true }; + case "reset-all": + if (!(await promptResetTypedConfirm())) { + console.log("\nReset local state cancelled.\n"); + continue; + } + return { mode: "reset" }; case "check": return { mode: "check" }; case "deep-check": @@ -278,7 +344,7 @@ export async function promptLoginMode( continue; case "delete-all": if (!(await promptDeleteAllTypedConfirm())) { - console.log("\nDelete all cancelled.\n"); + console.log("\nDelete saved accounts cancelled.\n"); continue; } return { mode: "fresh", deleteAll: true }; @@ -306,7 +372,8 @@ export async function promptAccountSelection( ): Promise { if (candidates.length === 0) return null; const defaultIndex = - typeof options.defaultIndex === "number" && Number.isFinite(options.defaultIndex) + typeof options.defaultIndex === "number" && + Number.isFinite(options.defaultIndex) ? Math.max(0, Math.min(options.defaultIndex, candidates.length - 1)) : 0; @@ -316,7 +383,9 @@ export async function promptAccountSelection( const rl = createInterface({ input, output }); try { - console.log(`\n${options.title ?? "Multiple workspaces detected for this account:"}`); + console.log( + `\n${options.title ?? "Multiple workspaces detected for this account:"}`, + ); candidates.forEach((candidate, index) => { const isDefault = candidate.isDefault ? " (default)" : ""; console.log(` ${index + 1}. ${candidate.label}${isDefault}`); @@ -324,7 +393,9 @@ export async function promptAccountSelection( console.log(""); while (true) { - const answer = await rl.question(`Select workspace [${defaultIndex + 1}]: `); + const answer = await rl.question( + `Select workspace [${defaultIndex + 1}]: `, + ); const normalized = answer.trim().toLowerCase(); if (!normalized) { return candidates[defaultIndex] ?? candidates[0] ?? null; diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index b3212c30..0f4667b8 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -29,6 +29,12 @@ import { type DashboardDisplaySettings, type DashboardAccountSortMode, } from "./dashboard-settings.js"; +import { + DESTRUCTIVE_ACTION_COPY, + deleteAccountAtIndex, + deleteSavedAccounts, + resetLocalState, +} from "./destructive-actions.js"; import { evaluateForecastAccounts, isHardRefreshFailure, @@ -50,7 +56,6 @@ import { type QuotaCacheEntry, } from "./quota-cache.js"; import { - clearAccounts, findMatchingAccountIndex, getStoragePath, loadFlaggedAccounts, @@ -86,6 +91,8 @@ type TokenSuccessWithAccount = TokenSuccess & { }; type PromptTone = "accent" | "success" | "warning" | "danger" | "muted"; +let destructiveActionInFlight = false; + function stylePromptText(text: string, tone: PromptTone): string { if (!output.isTTY) return text; const ui = getUiRuntimeOptions(); @@ -3732,10 +3739,6 @@ async function runDoctor(args: string[]): Promise { return summary.error > 0 ? 1 : 0; } -async function clearAccountsAndReset(): Promise { - await clearAccounts(); -} - async function handleManageAction( storage: AccountStorageV3, menuResult: Awaited>, @@ -3749,14 +3752,18 @@ 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; + const deleted = await deleteAccountAtIndex({ + storage, + index: idx, + }); + if (deleted) { + const label = `Account ${idx + 1}`; + const flaggedNote = + deleted.removedFlaggedCount > 0 + ? ` Removed ${deleted.removedFlaggedCount} matching problem account${deleted.removedFlaggedCount === 1 ? "" : "s"}.` + : ""; + console.log(`Deleted ${label}.${flaggedNote}`); } - await saveAccounts(storage); - console.log(`Deleted account ${idx + 1}.`); } return; } @@ -3884,10 +3891,59 @@ async function runAuthLogin(): Promise { continue; } if (menuResult.mode === "fresh" && menuResult.deleteAll) { - await runActionPanel("Reset Accounts", "Deleting all saved accounts", async () => { - await clearAccountsAndReset(); - console.log("Cleared saved accounts from active storage. Recovery snapshots remain available."); - }, displaySettings); + if (destructiveActionInFlight) { + console.log("Another destructive action is already running. Wait for it to finish."); + continue; + } + destructiveActionInFlight = true; + try { + await runActionPanel( + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.label, + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.stage, + async () => { + const result = await deleteSavedAccounts(); + console.log( + result.accountsCleared + ? DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.completed + : "Delete saved accounts completed with warnings. Some saved account artifacts could not be removed; see logs.", + ); + }, + displaySettings, + ); + } finally { + destructiveActionInFlight = false; + } + continue; + } + if (menuResult.mode === "reset") { + if (destructiveActionInFlight) { + console.log("Another destructive action is already running. Wait for it to finish."); + continue; + } + destructiveActionInFlight = true; + try { + await runActionPanel( + DESTRUCTIVE_ACTION_COPY.resetLocalState.label, + DESTRUCTIVE_ACTION_COPY.resetLocalState.stage, + async () => { + const pendingQuotaRefresh = pendingMenuQuotaRefresh; + if (pendingQuotaRefresh) { + await pendingQuotaRefresh; + } + const result = await resetLocalState(); + console.log( + result.accountsCleared && + result.flaggedCleared && + result.quotaCacheCleared + ? DESTRUCTIVE_ACTION_COPY.resetLocalState.completed + : "Reset local state completed with warnings. Some local artifacts could not be removed; see logs.", + ); + }, + displaySettings, + ); + } finally { + destructiveActionInFlight = false; + } continue; } if (menuResult.mode === "manage") { diff --git a/lib/destructive-actions.ts b/lib/destructive-actions.ts new file mode 100644 index 00000000..66a8571e --- /dev/null +++ b/lib/destructive-actions.ts @@ -0,0 +1,196 @@ +import { clearCodexCliStateCache } from "./codex-cli/state.js"; +import { MODEL_FAMILIES } from "./prompts/codex.js"; +import { clearQuotaCache } from "./quota-cache.js"; +import { + type AccountMetadataV3, + type AccountStorageV3, + clearAccounts, + clearFlaggedAccounts, + type FlaggedAccountStorageV1, + loadFlaggedAccounts, + saveAccounts, + saveFlaggedAccounts, +} from "./storage.js"; + +export const DESTRUCTIVE_ACTION_COPY = { + deleteSavedAccounts: { + label: "Delete Saved Accounts", + typedConfirm: + "Type DELETE to delete saved accounts only (saved accounts: delete; flagged/problem accounts, settings, and Codex CLI sync state: keep): ", + confirm: + "Delete saved accounts? (Saved accounts: delete. Flagged/problem accounts: keep. Settings: keep. Codex CLI sync state: keep.)", + stage: "Deleting saved accounts only", + completed: + "Deleted saved accounts. Saved accounts deleted; flagged/problem accounts, settings, and Codex CLI sync state kept.", + }, + resetLocalState: { + label: "Reset Local State", + typedConfirm: + "Type RESET to reset local state (saved accounts + flagged/problem accounts: delete; settings + Codex CLI sync state: keep; quota cache: clear): ", + confirm: + "Reset local state? (Saved accounts: delete. Flagged/problem accounts: delete. Settings: keep. Codex CLI sync state: keep. Quota cache: clear.)", + stage: "Clearing saved accounts, flagged/problem accounts, and quota cache", + completed: + "Reset local state. Saved accounts, flagged/problem accounts, and quota cache cleared; settings and Codex CLI sync state kept.", + }, +} as const; + +export function clampActiveIndices(storage: AccountStorageV3): void { + const count = storage.accounts.length; + const baseIndex = + typeof storage.activeIndex === "number" && + Number.isFinite(storage.activeIndex) + ? storage.activeIndex + : 0; + + if (count === 0) { + storage.activeIndex = 0; + storage.activeIndexByFamily = {}; + return; + } + + storage.activeIndex = Math.max(0, Math.min(baseIndex, count - 1)); + const activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + const rawIndex = activeIndexByFamily[family]; + const fallback = storage.activeIndex; + const clamped = Math.max( + 0, + Math.min( + typeof rawIndex === "number" && Number.isFinite(rawIndex) + ? rawIndex + : fallback, + count - 1, + ), + ); + activeIndexByFamily[family] = clamped; + } + storage.activeIndexByFamily = activeIndexByFamily; +} + +function rebaseActiveIndicesAfterDelete( + storage: AccountStorageV3, + removedIndex: number, +): void { + if (storage.activeIndex > removedIndex) { + storage.activeIndex -= 1; + } + const activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + const rawIndex = activeIndexByFamily[family]; + if (typeof rawIndex === "number" && Number.isFinite(rawIndex) && rawIndex > removedIndex) { + activeIndexByFamily[family] = rawIndex - 1; + } + } + storage.activeIndexByFamily = activeIndexByFamily; +} + +export interface DeleteAccountResult { + storage: AccountStorageV3; + flagged: FlaggedAccountStorageV1; + removedAccount: AccountMetadataV3; + removedFlaggedCount: number; +} + +export interface DestructiveActionResult { + accountsCleared: boolean; + flaggedCleared: boolean; + quotaCacheCleared: boolean; +} + +function asError(error: unknown, fallbackMessage: string): Error { + return error instanceof Error + ? error + : new Error(`${fallbackMessage}: ${String(error)}`); +} + +export async function deleteAccountAtIndex(options: { + storage: AccountStorageV3; + index: number; +}): Promise { + const target = options.storage.accounts.at(options.index); + if (!target) return null; + const flagged = await loadFlaggedAccounts(); + const nextStorage: AccountStorageV3 = { + ...options.storage, + accounts: options.storage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...(options.storage.activeIndexByFamily ?? {}) }, + }; + const previousStorage: AccountStorageV3 = { + ...options.storage, + accounts: options.storage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...(options.storage.activeIndexByFamily ?? {}) }, + }; + + nextStorage.accounts.splice(options.index, 1); + rebaseActiveIndicesAfterDelete(nextStorage, options.index); + clampActiveIndices(nextStorage); + await saveAccounts(nextStorage); + + const remainingFlagged = flagged.accounts.filter( + (account) => account.refreshToken !== target.refreshToken, + ); + const removedFlaggedCount = flagged.accounts.length - remainingFlagged.length; + let updatedFlagged = flagged; + if (removedFlaggedCount > 0) { + updatedFlagged = { ...flagged, accounts: remainingFlagged }; + try { + await saveFlaggedAccounts(updatedFlagged); + } catch (error) { + const originalError = asError( + error, + "Failed to save flagged account storage after deleting an account", + ); + try { + await saveAccounts(previousStorage); + } catch (rollbackError) { + throw new AggregateError( + [ + originalError, + asError( + rollbackError, + "Failed to roll back account storage after flagged save failure", + ), + ], + "Deleting the account partially failed and rollback also failed.", + ); + } + throw originalError; + } + } + + return { + storage: nextStorage, + flagged: updatedFlagged, + removedAccount: target, + removedFlaggedCount, + }; +} + +/** + * Delete saved accounts without touching flagged/problem accounts, settings, or Codex CLI sync state. + * Removes the accounts WAL and backups via the underlying storage helper. + */ +export async function deleteSavedAccounts(): Promise { + return { + accountsCleared: await clearAccounts(), + flaggedCleared: false, + quotaCacheCleared: false, + }; +} + +/** + * Reset local multi-auth state: clears saved accounts, flagged/problem accounts, and quota cache. + * Keeps unified settings and on-disk Codex CLI sync state; only the in-memory Codex CLI cache is cleared. + */ +export async function resetLocalState(): Promise { + const accountsCleared = await clearAccounts(); + const flaggedCleared = await clearFlaggedAccounts(); + const quotaCacheCleared = await clearQuotaCache(); + clearCodexCliStateCache(); + return { + accountsCleared, + flaggedCleared, + quotaCacheCleared, + }; +} diff --git a/lib/quota-cache.ts b/lib/quota-cache.ts index 9870a2b6..0fa647c6 100644 --- a/lib/quota-cache.ts +++ b/lib/quota-cache.ts @@ -30,8 +30,7 @@ interface QuotaCacheFile { byEmail: Record; } -const QUOTA_CACHE_PATH = join(getCodexMultiAuthDir(), "quota-cache.json"); -const QUOTA_CACHE_LABEL = basename(QUOTA_CACHE_PATH); +const QUOTA_CACHE_FILE_NAME = "quota-cache.json"; const RETRYABLE_FS_CODES = new Set(["EBUSY", "EPERM"]); function isRetryableFsError(error: unknown): boolean { @@ -46,7 +45,9 @@ function isRetryableFsError(error: unknown): boolean { * @returns The input as a finite number, or `undefined` if the value is not a finite number */ function normalizeNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; } /** @@ -104,7 +105,7 @@ function normalizeEntry(value: unknown): QuotaCacheEntry | null { * * @param value - Parsed JSON value (typically an object) containing raw entries keyed by identifier; non-objects, empty keys, or invalid entries are ignored. * @returns A record mapping valid string keys to normalized `QuotaCacheEntry` objects; malformed entries are omitted. - * + * * Note: This function is pure and performs no filesystem I/O. Callers are responsible for any filesystem concurrency or Windows-specific behavior when loading/saving the on-disk cache, and for redacting any sensitive tokens before logging or persisting. */ function normalizeEntryMap(value: unknown): Record { @@ -132,7 +133,9 @@ async function readCacheFileWithRetry(path: string): Promise { await sleep(10 * 2 ** attempt); } } - throw lastError instanceof Error ? lastError : new Error("quota cache read retry exhausted"); + throw lastError instanceof Error + ? lastError + : new Error("quota cache read retry exhausted"); } /** @@ -146,7 +149,11 @@ async function readCacheFileWithRetry(path: string): Promise { * @returns The absolute path to the quota-cache.json file */ export function getQuotaCachePath(): string { - return QUOTA_CACHE_PATH; + return join(getCodexMultiAuthDir(), QUOTA_CACHE_FILE_NAME); +} + +function getQuotaCacheLabel(path: string): string { + return basename(path); } /** @@ -168,18 +175,21 @@ export function getQuotaCachePath(): string { * will be empty if the on-disk file is absent, malformed, or could not be read. */ export async function loadQuotaCache(): Promise { - if (!existsSync(QUOTA_CACHE_PATH)) { + const quotaCachePath = getQuotaCachePath(); + if (!existsSync(quotaCachePath)) { return { byAccountId: {}, byEmail: {} }; } try { - const content = await readCacheFileWithRetry(QUOTA_CACHE_PATH); + const content = await readCacheFileWithRetry(quotaCachePath); const parsed = JSON.parse(content) as unknown; if (!isRecord(parsed)) { return { byAccountId: {}, byEmail: {} }; } if (parsed.version !== 1) { - logWarn(`Quota cache rejected due to version mismatch: ${String(parsed.version)}`); + logWarn( + `Quota cache rejected due to version mismatch: ${String(parsed.version)}`, + ); return { byAccountId: {}, byEmail: {} }; } @@ -189,7 +199,7 @@ export async function loadQuotaCache(): Promise { }; } catch (error) { logWarn( - `Failed to load quota cache from ${QUOTA_CACHE_LABEL}: ${ + `Failed to load quota cache from ${getQuotaCacheLabel(quotaCachePath)}: ${ error instanceof Error ? error.message : String(error) }`, ); @@ -222,10 +232,11 @@ export async function saveQuotaCache(data: QuotaCacheData): Promise { byAccountId: data.byAccountId, byEmail: data.byEmail, }; + const quotaCachePath = getQuotaCachePath(); try { await fs.mkdir(getCodexMultiAuthDir(), { recursive: true }); - const tempPath = `${QUOTA_CACHE_PATH}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; + const tempPath = `${quotaCachePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; await fs.writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600, @@ -234,7 +245,7 @@ export async function saveQuotaCache(data: QuotaCacheData): Promise { try { for (let attempt = 0; attempt < 5; attempt += 1) { try { - await fs.rename(tempPath, QUOTA_CACHE_PATH); + await fs.rename(tempPath, quotaCachePath); renamed = true; break; } catch (error) { @@ -253,9 +264,38 @@ export async function saveQuotaCache(data: QuotaCacheData): Promise { } } catch (error) { logWarn( - `Failed to save quota cache to ${QUOTA_CACHE_LABEL}: ${ + `Failed to save quota cache to ${getQuotaCacheLabel(quotaCachePath)}: ${ error instanceof Error ? error.message : String(error) }`, ); } } + +/** + * Deletes the on-disk quota cache file, ignoring missing files and logging non-ENOENT errors. + */ +export async function clearQuotaCache(): Promise { + const quotaCachePath = getQuotaCachePath(); + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.unlink(quotaCachePath); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === "ENOENT") { + return true; + } + if (!isRetryableFsError(error) || attempt >= 4) { + logWarn( + `Failed to clear quota cache ${getQuotaCacheLabel(quotaCachePath)}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return false; + } + await sleep(10 * 2 ** attempt); + } + } + + return false; +} diff --git a/lib/storage.ts b/lib/storage.ts index 6af0725e..3435bf44 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -179,6 +179,26 @@ function withStorageLock(fn: () => Promise): Promise { return previousMutex.then(fn).finally(() => releaseLock()); } +async function unlinkWithRetry(path: string): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.unlink(path); + return; + } catch (error) { + const unlinkError = error as NodeJS.ErrnoException; + const code = unlinkError.code; + if (code === "ENOENT") { + return; + } + if ((code === "EPERM" || code === "EBUSY" || code === "EAGAIN") && attempt < 4) { + await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); + continue; + } + throw unlinkError; + } + } +} + type AnyAccountStorage = AccountStorageV1 | AccountStorageV3; type AccountLike = { @@ -1166,7 +1186,7 @@ function findCompatibleRefreshTokenMatchIndex( matchingAccount = account; continue; } - const newest = selectNewestAccount(matchingAccount, account); + const newest: T = selectNewestAccount(matchingAccount, account); if (newest === account) { matchingIndex = i; matchingAccount = account; @@ -1834,34 +1854,19 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { throw emptyError; } - // Retry rename with exponential backoff for Windows EPERM/EBUSY - let lastError: NodeJS.ErrnoException | null = null; - for (let attempt = 0; attempt < 5; attempt++) { - try { - await fs.rename(tempPath, path); - try { - await fs.unlink(resetMarkerPath); - } catch { - // Best effort cleanup. - } - lastAccountsSaveTimestamp = Date.now(); - try { - await fs.unlink(walPath); - } catch { - // Best effort cleanup. - } - return; - } catch (renameError) { - const code = (renameError as NodeJS.ErrnoException).code; - if (code === "EPERM" || code === "EBUSY") { - lastError = renameError as NodeJS.ErrnoException; - await new Promise((r) => setTimeout(r, 10 * 2 ** attempt)); - continue; - } - throw renameError; - } + await renameFileWithRetry(tempPath, path); + try { + await fs.unlink(resetMarkerPath); + } catch { + // Best effort cleanup. + } + lastAccountsSaveTimestamp = Date.now(); + try { + await fs.unlink(walPath); + } catch { + // Best effort cleanup. } - if (lastError) throw lastError; + return; } catch (error) { try { await fs.unlink(tempPath); @@ -1939,6 +1944,7 @@ export async function withAccountAndFlaggedStorageTransaction( return withStorageLock(async () => { const state = { snapshot: await loadAccountsInternal(saveAccountsUnlocked), + active: true, }; const current = state.snapshot; const persist = async ( @@ -1995,24 +2001,34 @@ export async function saveAccounts(storage: AccountStorageV3): Promise { * Deletes the account storage file from disk. * Silently ignores if file doesn't exist. */ -export async function clearAccounts(): Promise { +export async function clearAccounts(): Promise { return withStorageLock(async () => { const path = getStoragePath(); const resetMarkerPath = getIntentionalResetMarkerPath(path); const walPath = getAccountsWalPath(path); const backupPaths = await getAccountsBackupRecoveryCandidatesWithDiscovery(path); + const legacyPaths = Array.from( + new Set( + [currentLegacyProjectStoragePath, currentLegacyWorktreeStoragePath].filter( + (candidate): candidate is string => + typeof candidate === "string" && candidate.length > 0, + ), + ), + ); await fs.writeFile( resetMarkerPath, JSON.stringify({ version: 1, createdAt: Date.now() }), { encoding: "utf-8", mode: 0o600 }, ); + let hadError = false; const clearPath = async (targetPath: string): Promise => { try { - await fs.unlink(targetPath); + await unlinkWithRetry(targetPath); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") { + hadError = true; log.error("Failed to clear account storage artifact", { path: targetPath, error: String(error), @@ -2022,14 +2038,13 @@ export async function clearAccounts(): Promise { }; try { - await Promise.all([ - clearPath(path), - clearPath(walPath), - ...backupPaths.map(clearPath), - ]); + const artifacts = Array.from(new Set([path, walPath, ...backupPaths, ...legacyPaths])); + await Promise.all(artifacts.map(clearPath)); } catch { // Individual path cleanup is already best-effort with per-artifact logging. } + + return !hadError; }); } @@ -2254,7 +2269,7 @@ export async function saveFlaggedAccounts( }); } -export async function clearFlaggedAccounts(): Promise { +export async function clearFlaggedAccounts(): Promise { return withStorageLock(async () => { const path = getFlaggedAccountsPath(); const markerPath = getIntentionalResetMarkerPath(path); @@ -2273,22 +2288,37 @@ export async function clearFlaggedAccounts(): Promise { } const backupPaths = await getAccountsBackupRecoveryCandidatesWithDiscovery(path); - for (const candidate of [path, ...backupPaths, markerPath]) { + let hadError = false; + for (const candidate of [path, ...backupPaths]) { try { - await fs.unlink(candidate); + await unlinkWithRetry(candidate); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") { + hadError = true; log.error("Failed to clear flagged account storage", { path: candidate, error: String(error), }); - if (candidate === path) { - throw error; - } } } } + if (!hadError) { + try { + await unlinkWithRetry(markerPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.error("Failed to clear flagged reset marker", { + path, + markerPath, + error: String(error), + }); + hadError = true; + } + } + } + return !hadError; }); } diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index b7c7708f..fbe9293a 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -1,11 +1,15 @@ -import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "node:readline/promises"; import { ANSI, isTTY } from "./ansi.js"; import { confirm } from "./confirm.js"; +import { formatCheckFlaggedLabel, UI_COPY } from "./copy.js"; +import { + formatUiBadge, + paintUiText, + quotaToneFromLeftPercent, +} from "./format.js"; import { getUiRuntimeOptions } from "./runtime.js"; -import { select, type MenuItem } from "./select.js"; -import { paintUiText, formatUiBadge, quotaToneFromLeftPercent } from "./format.js"; -import { UI_COPY, formatCheckFlaggedLabel } from "./copy.js"; +import { type MenuItem, select } from "./select.js"; export type AccountStatus = | "active" @@ -56,6 +60,7 @@ export type AuthMenuAction = | { type: "fix" } | { type: "settings" } | { type: "fresh" } + | { type: "reset-all" } | { type: "check" } | { type: "deep-check" } | { type: "verify-flagged" } @@ -68,7 +73,16 @@ export type AuthMenuAction = | { type: "delete-all" } | { type: "cancel" }; -export type AccountAction = "back" | "delete" | "refresh" | "toggle" | "set-current" | "cancel"; +export type AccountAction = + | "back" + | "delete" + | "refresh" + | "toggle" + | "set-current" + | "cancel"; + +const ANSI_ESCAPE_PATTERN = new RegExp("\\u001b\\[[0-?]*[ -/]*[@-~]", "g"); +const CONTROL_CHAR_PATTERN = new RegExp("[\\u0000-\\u001f\\u007f]", "g"); function resolveCliVersionLabel(): string | null { const raw = (process.env.CODEX_MULTI_AUTH_CLI_VERSION ?? "").trim(); @@ -85,8 +99,8 @@ function mainMenuTitleWithVersion(): string { function sanitizeTerminalText(value: string | undefined): string | undefined { if (!value) return undefined; return value - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/[\u0000-\u001f\u007f]/g, "") + .replace(ANSI_ESCAPE_PATTERN, "") + .replace(CONTROL_CHAR_PATTERN, "") .trim(); } @@ -112,10 +126,14 @@ function statusBadge(status: AccountStatus | undefined): string { tone: "accent" | "success" | "warning" | "danger" | "muted", ): string => { if (ui.v2Enabled) return formatUiBadge(ui, label, tone); - if (tone === "accent") return `${ANSI.bgGreen}${ANSI.black}[${label}]${ANSI.reset}`; - if (tone === "success") return `${ANSI.bgGreen}${ANSI.black}[${label}]${ANSI.reset}`; - if (tone === "warning") return `${ANSI.bgYellow}${ANSI.black}[${label}]${ANSI.reset}`; - if (tone === "danger") return `${ANSI.bgRed}${ANSI.white}[${label}]${ANSI.reset}`; + if (tone === "accent") + return `${ANSI.bgGreen}${ANSI.black}[${label}]${ANSI.reset}`; + if (tone === "success") + return `${ANSI.bgGreen}${ANSI.black}[${label}]${ANSI.reset}`; + if (tone === "warning") + return `${ANSI.bgYellow}${ANSI.black}[${label}]${ANSI.reset}`; + if (tone === "danger") + return `${ANSI.bgRed}${ANSI.white}[${label}]${ANSI.reset}`; return `${ANSI.inverse}[${label}]${ANSI.reset}`; }; @@ -161,7 +179,7 @@ function statusBadge(status: AccountStatus | undefined): string { } function accountTitle(account: AccountInfo): string { - const accountNumber = account.quickSwitchNumber ?? (account.index + 1); + const accountNumber = account.quickSwitchNumber ?? account.index + 1; const base = sanitizeTerminalText(account.email) || sanitizeTerminalText(account.accountLabel) || @@ -175,15 +193,21 @@ function accountSearchText(account: AccountInfo): string { sanitizeTerminalText(account.email), sanitizeTerminalText(account.accountLabel), sanitizeTerminalText(account.accountId), - String(account.quickSwitchNumber ?? (account.index + 1)), + String(account.quickSwitchNumber ?? account.index + 1), ] - .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .filter( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ) .join(" ") .toLowerCase(); } -function accountRowColor(account: AccountInfo): MenuItem["color"] { - if (account.isCurrentAccount && account.highlightCurrentRow !== false) return "green"; +function accountRowColor( + account: AccountInfo, +): MenuItem["color"] { + if (account.isCurrentAccount && account.highlightCurrentRow !== false) + return "green"; switch (account.status) { case "active": case "ok": @@ -200,7 +224,9 @@ function accountRowColor(account: AccountInfo): MenuItem["color" } } -function statusTone(status: AccountStatus | undefined): "success" | "warning" | "danger" | "muted" { +function statusTone( + status: AccountStatus | undefined, +): "success" | "warning" | "danger" | "muted" { switch (status) { case "active": case "ok": @@ -226,12 +252,16 @@ function normalizeQuotaPercent(value: number | undefined): number | null { return Math.max(0, Math.min(100, Math.round(value))); } -function parseLeftPercentFromSummary(summary: string, windowLabel: "5h" | "7d"): number | null { +function parseLeftPercentFromSummary( + summary: string, + windowLabel: "5h" | "7d", +): number | null { const segments = summary.split("|"); for (const segment of segments) { const trimmed = segment.trim().toLowerCase(); if (!trimmed.startsWith(`${windowLabel} `)) continue; - const percentToken = trimmed.slice(windowLabel.length).trim().split(/\s+/)[0] ?? ""; + const percentToken = + trimmed.slice(windowLabel.length).trim().split(/\s+/)[0] ?? ""; const parsed = Number.parseInt(percentToken.replace("%", ""), 10); if (!Number.isFinite(parsed)) continue; return Math.max(0, Math.min(100, parsed)); @@ -274,15 +304,21 @@ function formatQuotaBar( const filledText = "█".repeat(filled); const emptyText = "▒".repeat(width - filled); if (ui.v2Enabled) { - const tone = leftPercent === null ? "muted" : quotaToneFromLeftPercent(leftPercent); - const filledSegment = filledText.length > 0 ? paintUiText(ui, filledText, tone) : ""; - const emptySegment = emptyText.length > 0 ? paintUiText(ui, emptyText, "muted") : ""; + const tone = + leftPercent === null ? "muted" : quotaToneFromLeftPercent(leftPercent); + const filledSegment = + filledText.length > 0 ? paintUiText(ui, filledText, tone) : ""; + const emptySegment = + emptyText.length > 0 ? paintUiText(ui, emptyText, "muted") : ""; return `${filledSegment}${emptySegment}`; } if (leftPercent === null) return `${ANSI.dim}${emptyText}${ANSI.reset}`; - const color = leftPercent <= 15 ? ANSI.red : leftPercent <= 35 ? ANSI.yellow : ANSI.green; - const filledSegment = filledText.length > 0 ? `${color}${filledText}${ANSI.reset}` : ""; - const emptySegment = emptyText.length > 0 ? `${ANSI.dim}${emptyText}${ANSI.reset}` : ""; + const color = + leftPercent <= 15 ? ANSI.red : leftPercent <= 35 ? ANSI.yellow : ANSI.green; + const filledSegment = + filledText.length > 0 ? `${color}${filledText}${ANSI.reset}` : ""; + const emptySegment = + emptyText.length > 0 ? `${ANSI.dim}${emptyText}${ANSI.reset}` : ""; return `${filledSegment}${emptySegment}`; } @@ -293,7 +329,12 @@ function formatQuotaPercent( if (leftPercent === null) return null; const percentText = `${leftPercent}%`; if (!ui.v2Enabled) { - const color = leftPercent <= 15 ? ANSI.red : leftPercent <= 35 ? ANSI.yellow : ANSI.green; + const color = + leftPercent <= 15 + ? ANSI.red + : leftPercent <= 35 + ? ANSI.yellow + : ANSI.green; return `${color}${percentText}${ANSI.reset}`; } const tone = quotaToneFromLeftPercent(leftPercent); @@ -317,28 +358,60 @@ function formatQuotaWindow( if (!cooldown) { return percent ? `${labelText} ${bar} ${percent}` : `${labelText} ${bar}`; } - const cooldownText = ui.v2Enabled ? paintUiText(ui, cooldown, "muted") : cooldown; + const cooldownText = ui.v2Enabled + ? paintUiText(ui, cooldown, "muted") + : cooldown; if (!percent) { return `${labelText} ${bar} ${cooldownText}`; } return `${labelText} ${bar} ${percent} ${cooldownText}`; } -function formatQuotaSummary(account: AccountInfo, ui: ReturnType): string { +function formatQuotaSummary( + account: AccountInfo, + ui: ReturnType, +): string { const summary = account.quotaSummary ?? ""; const showCooldown = account.showQuotaCooldown !== false; - const left5h = normalizeQuotaPercent(account.quota5hLeftPercent) ?? parseLeftPercentFromSummary(summary, "5h"); - const left7d = normalizeQuotaPercent(account.quota7dLeftPercent) ?? parseLeftPercentFromSummary(summary, "7d"); + const left5h = + normalizeQuotaPercent(account.quota5hLeftPercent) ?? + parseLeftPercentFromSummary(summary, "5h"); + const left7d = + normalizeQuotaPercent(account.quota7dLeftPercent) ?? + parseLeftPercentFromSummary(summary, "7d"); const segments: string[] = []; if (left5h !== null || typeof account.quota5hResetAtMs === "number") { - segments.push(formatQuotaWindow("5h", left5h, account.quota5hResetAtMs, showCooldown, ui)); + segments.push( + formatQuotaWindow( + "5h", + left5h, + account.quota5hResetAtMs, + showCooldown, + ui, + ), + ); } if (left7d !== null || typeof account.quota7dResetAtMs === "number") { - segments.push(formatQuotaWindow("7d", left7d, account.quota7dResetAtMs, showCooldown, ui)); + segments.push( + formatQuotaWindow( + "7d", + left7d, + account.quota7dResetAtMs, + showCooldown, + ui, + ), + ); } - if (account.quotaRateLimited || summary.toLowerCase().includes("rate-limited")) { - segments.push(ui.v2Enabled ? paintUiText(ui, "rate-limited", "danger") : `${ANSI.red}rate-limited${ANSI.reset}`); + if ( + account.quotaRateLimited || + summary.toLowerCase().includes("rate-limited") + ) { + segments.push( + ui.v2Enabled + ? paintUiText(ui, "rate-limited", "danger") + : `${ANSI.red}rate-limited${ANSI.reset}`, + ); } if (segments.length === 0) { @@ -350,7 +423,10 @@ function formatQuotaSummary(account: AccountInfo, ui: ReturnType): string { +function formatAccountHint( + account: AccountInfo, + ui: ReturnType, +): string { const withKey = ( key: string, value: string, @@ -365,19 +441,30 @@ function formatAccountHint(account: AccountInfo, ui: ReturnType(); if (account.showStatusBadge === false) { - partsByKey.set("status", withKey("Status:", statusText(account.status), statusTone(account.status))); + partsByKey.set( + "status", + withKey( + "Status:", + statusText(account.status), + statusTone(account.status), + ), + ); } if (account.showLastUsed !== false) { - partsByKey.set("last-used", withKey("Last used:", formatRelativeTime(account.lastUsed), "heading")); + partsByKey.set( + "last-used", + withKey("Last used:", formatRelativeTime(account.lastUsed), "heading"), + ); } const quotaSummaryText = formatQuotaSummary(account, ui); if (quotaSummaryText) { partsByKey.set("limits", withKey("Limits:", quotaSummaryText, "accent")); } - const fields = account.statuslineFields && account.statuslineFields.length > 0 - ? account.statuslineFields - : ["last-used", "limits", "status"]; + const fields = + account.statuslineFields && account.statuslineFields.length > 0 + ? account.statuslineFields + : ["last-used", "limits", "status"]; const orderedParts: string[] = []; for (const field of fields) { const part = partsByKey.get(field); @@ -407,7 +494,7 @@ async function promptSearchQuery(current: string): Promise { try { const suffix = current ? ` (${current})` : ""; const answer = await rl.question(`Search${suffix} (blank clears): `); - return answer.trim().toLowerCase(); + return (sanitizeTerminalText(answer) ?? "").toLowerCase(); } finally { rl.close(); } @@ -426,6 +513,7 @@ function authMenuFocusKey(action: AuthMenuAction): string { case "fix": case "settings": case "fresh": + case "reset-all": case "check": case "deep-check": case "verify-flagged": @@ -448,13 +536,16 @@ export async function showAuthMenu( let focusKey = "action:add"; while (true) { const normalizedSearch = searchQuery.trim().toLowerCase(); - const visibleAccounts = normalizedSearch.length > 0 - ? accounts.filter((account) => accountSearchText(account).includes(normalizedSearch)) - : accounts; + const visibleAccounts = + normalizedSearch.length > 0 + ? accounts.filter((account) => + accountSearchText(account).includes(normalizedSearch), + ) + : accounts; const visibleByNumber = new Map(); const duplicateQuickSwitchNumbers = new Set(); for (const account of visibleAccounts) { - const quickSwitchNumber = account.quickSwitchNumber ?? (account.index + 1); + const quickSwitchNumber = account.quickSwitchNumber ?? account.index + 1; if (visibleByNumber.has(quickSwitchNumber)) { duplicateQuickSwitchNumbers.add(quickSwitchNumber); continue; @@ -463,18 +554,58 @@ export async function showAuthMenu( } const items: MenuItem[] = [ - { label: UI_COPY.mainMenu.quickStart, value: { type: "cancel" }, kind: "heading" }, - { label: UI_COPY.mainMenu.addAccount, value: { type: "add" }, color: "green" }, - { label: UI_COPY.mainMenu.checkAccounts, value: { type: "check" }, color: "green" }, - { label: UI_COPY.mainMenu.bestAccount, value: { type: "forecast" }, color: "green" }, - { label: UI_COPY.mainMenu.fixIssues, value: { type: "fix" }, color: "green" }, - { label: UI_COPY.mainMenu.settings, value: { type: "settings" }, color: "green" }, + { + label: UI_COPY.mainMenu.quickStart, + value: { type: "cancel" }, + kind: "heading", + }, + { + label: UI_COPY.mainMenu.addAccount, + value: { type: "add" }, + color: "green", + }, + { + label: UI_COPY.mainMenu.checkAccounts, + value: { type: "check" }, + color: "green", + }, + { + label: UI_COPY.mainMenu.bestAccount, + value: { type: "forecast" }, + color: "green", + }, + { + label: UI_COPY.mainMenu.fixIssues, + value: { type: "fix" }, + color: "green", + }, + { + label: UI_COPY.mainMenu.settings, + value: { type: "settings" }, + color: "green", + }, { label: "", value: { type: "cancel" }, separator: true }, - { label: UI_COPY.mainMenu.moreChecks, value: { type: "cancel" }, kind: "heading" }, - { label: UI_COPY.mainMenu.refreshChecks, value: { type: "deep-check" }, color: "green" }, - { label: verifyLabel, value: { type: "verify-flagged" }, color: flaggedCount > 0 ? "red" : "yellow" }, + { + label: UI_COPY.mainMenu.moreChecks, + value: { type: "cancel" }, + kind: "heading", + }, + { + label: UI_COPY.mainMenu.refreshChecks, + value: { type: "deep-check" }, + color: "green", + }, + { + label: verifyLabel, + value: { type: "verify-flagged" }, + color: flaggedCount > 0 ? "red" : "yellow", + }, { label: "", value: { type: "cancel" }, separator: true }, - { label: UI_COPY.mainMenu.accounts, value: { type: "cancel" }, kind: "heading" }, + { + label: UI_COPY.mainMenu.accounts, + value: { type: "cancel" }, + kind: "heading", + }, ]; if (visibleAccounts.length === 0) { @@ -486,20 +617,34 @@ export async function showAuthMenu( } else { items.push( ...visibleAccounts.map((account) => { - const currentBadge = account.isCurrentAccount && account.showCurrentBadge !== false - ? (ui.v2Enabled ? ` ${formatUiBadge(ui, "current", "accent")}` : ` ${ANSI.cyan}[current]${ANSI.reset}`) - : ""; - const badge = account.showStatusBadge === false ? "" : statusBadge(account.status); + const currentBadge = + account.isCurrentAccount && account.showCurrentBadge !== false + ? ui.v2Enabled + ? ` ${formatUiBadge(ui, "current", "accent")}` + : ` ${ANSI.cyan}[current]${ANSI.reset}` + : ""; + const badge = + account.showStatusBadge === false + ? "" + : statusBadge(account.status); const statusSuffix = badge ? ` ${badge}` : ""; const title = ui.v2Enabled - ? paintUiText(ui, accountTitle(account), account.isCurrentAccount ? "accent" : "heading") + ? paintUiText( + ui, + accountTitle(account), + account.isCurrentAccount ? "accent" : "heading", + ) : accountTitle(account); const label = `${title}${currentBadge}${statusSuffix}`; const hint = formatAccountHint(account, ui); const hasHint = hint.length > 0; const hintText = ui.v2Enabled - ? (hasHint ? hint : undefined) - : (hasHint ? hint : undefined); + ? hasHint + ? hint + : undefined + : hasHint + ? hint + : undefined; return { label, hint: hintText, @@ -511,27 +656,45 @@ export async function showAuthMenu( } items.push({ label: "", value: { type: "cancel" }, separator: true }); - items.push({ label: UI_COPY.mainMenu.dangerZone, value: { type: "cancel" }, kind: "heading" }); - items.push({ label: UI_COPY.mainMenu.removeAllAccounts, value: { type: "delete-all" }, color: "red" }); + items.push({ + label: UI_COPY.mainMenu.dangerZone, + value: { type: "cancel" }, + kind: "heading", + }); + items.push({ + label: UI_COPY.mainMenu.removeAllAccounts, + value: { type: "delete-all" }, + color: "red", + }); + items.push({ + label: UI_COPY.mainMenu.resetLocalState, + value: { type: "reset-all" }, + color: "red", + }); const compactHelp = UI_COPY.mainMenu.helpCompact; const detailedHelp = UI_COPY.mainMenu.helpDetailed; - const showHintsForUnselectedRows = visibleAccounts[0]?.showHintsForUnselectedRows ?? + const showHintsForUnselectedRows = + visibleAccounts[0]?.showHintsForUnselectedRows ?? accounts[0]?.showHintsForUnselectedRows ?? false; - const focusStyle = visibleAccounts[0]?.focusStyle ?? - accounts[0]?.focusStyle ?? - "row-invert"; + const focusStyle = + visibleAccounts[0]?.focusStyle ?? accounts[0]?.focusStyle ?? "row-invert"; const resolveStatusMessage = (): string | undefined => { - const raw = typeof options.statusMessage === "function" - ? options.statusMessage() - : options.statusMessage; - return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : undefined; + const raw = + typeof options.statusMessage === "function" + ? options.statusMessage() + : options.statusMessage; + const sanitized = typeof raw === "string" ? sanitizeTerminalText(raw) : undefined; + return sanitized && sanitized.length > 0 ? sanitized : undefined; }; const buildSubtitle = (): string | undefined => { const parts: string[] = []; - if (normalizedSearch.length > 0) { - parts.push(`${UI_COPY.mainMenu.searchSubtitlePrefix} ${normalizedSearch}`); + const safeSearch = sanitizeTerminalText(normalizedSearch); + if (safeSearch && safeSearch.length > 0) { + parts.push( + `${UI_COPY.mainMenu.searchSubtitlePrefix} ${safeSearch}`, + ); } const statusText = resolveStatusMessage(); if (statusText) { @@ -541,7 +704,8 @@ export async function showAuthMenu( return parts.join(" | "); }; const initialCursor = items.findIndex((item) => { - if (item.separator || item.disabled || item.kind === "heading") return false; + if (item.separator || item.disabled || item.kind === "heading") + return false; return authMenuFocusKey(item.value) === focusKey; }); @@ -582,7 +746,12 @@ export async function showAuthMenu( } const selected = context.items[context.cursor]; - if (!selected || selected.separator || selected.disabled || selected.kind === "heading") { + if ( + !selected || + selected.separator || + selected.disabled || + selected.kind === "heading" + ) { return undefined; } if (selected.value.type !== "select-account") return undefined; @@ -590,7 +759,13 @@ export async function showAuthMenu( }, onCursorChange: ({ cursor }) => { const selected = items[cursor]; - if (!selected || selected.separator || selected.disabled || selected.kind === "heading") return; + if ( + !selected || + selected.separator || + selected.disabled || + selected.kind === "heading" + ) + return; focusKey = authMenuFocusKey(selected.value); }, }); @@ -601,16 +776,16 @@ export async function showAuthMenu( focusKey = "action:search"; continue; } - if (result.type === "delete-all") { - const confirmed = await confirm("Delete all accounts?"); - if (!confirmed) continue; - } if (result.type === "delete-account") { - const confirmed = await confirm(`Delete ${accountTitle(result.account)}?`); + const confirmed = await confirm( + `Delete ${accountTitle(result.account)}?`, + ); if (!confirmed) continue; } if (result.type === "refresh-account") { - const confirmed = await confirm(`Re-authenticate ${accountTitle(result.account)}?`); + const confirmed = await confirm( + `Re-authenticate ${accountTitle(result.account)}?`, + ); if (!confirmed) continue; } focusKey = authMenuFocusKey(result); @@ -618,14 +793,16 @@ export async function showAuthMenu( } } -export async function showAccountDetails(account: AccountInfo): Promise { +export async function showAccountDetails( + account: AccountInfo, +): Promise { const ui = getUiRuntimeOptions(); const header = `${accountTitle(account)} ${statusBadge(account.status)}` + (account.enabled === false - ? (ui.v2Enabled + ? ui.v2Enabled ? ` ${formatUiBadge(ui, "disabled", "danger")}` - : ` ${ANSI.red}[disabled]${ANSI.reset}`) + : ` ${ANSI.red}[disabled]${ANSI.reset}` : ""); const statusLabel = account.status ?? "unknown"; const subtitle = `Added: ${formatDate(account.addedAt)} | Used: ${formatRelativeTime(account.lastUsed)} | Status: ${statusLabel}`; @@ -635,7 +812,10 @@ export async function showAccountDetails(account: AccountInfo): Promise[] = [ { label: UI_COPY.accountDetails.back, value: "back" }, { - label: account.enabled === false ? UI_COPY.accountDetails.enable : UI_COPY.accountDetails.disable, + label: + account.enabled === false + ? UI_COPY.accountDetails.enable + : UI_COPY.accountDetails.disable, value: "toggle", color: account.enabled === false ? "green" : "yellow", }, @@ -644,7 +824,11 @@ export async function showAccountDetails(account: AccountInfo): Promise item.value === focusAction); @@ -668,7 +852,13 @@ export async function showAccountDetails(account: AccountInfo): Promise { const selected = items[cursor]; - if (!selected || selected.separator || selected.disabled || selected.kind === "heading") return; + if ( + !selected || + selected.separator || + selected.disabled || + selected.kind === "heading" + ) + return; focusAction = selected.value; }, }); @@ -680,7 +870,9 @@ export async function showAccountDetails(account: AccountInfo): Promise `Returning in ${seconds}s... Press any key to pause.`, + autoReturn: (seconds: number) => + `Returning in ${seconds}s... Press any key to pause.`, paused: "Paused. Press any key to continue.", working: "Running...", done: "Done.", @@ -89,22 +92,27 @@ export const UI_COPY = { backNoSave: "Back Without Saving", accountListTitle: "Account List View", accountListSubtitle: "Choose row details and optional smart sorting", - accountListHelp: "Enter Toggle | Number Toggle | M Sort | L Layout | S Save | Q Back (No Save)", + accountListHelp: + "Enter Toggle | Number Toggle | M Sort | L Layout | S Save | Q Back (No Save)", summaryTitle: "Account Details Row", summarySubtitle: "Choose and order detail fields", - summaryHelp: "Enter Toggle | 1-3 Toggle | [ ] Reorder | S Save | Q Back (No Save)", + summaryHelp: + "Enter Toggle | 1-3 Toggle | [ ] Reorder | S Save | Q Back (No Save)", behaviorTitle: "Return Behavior", behaviorSubtitle: "Control how result screens return", - behaviorHelp: "Enter Select | 1-3 Delay | P Pause | L AutoFetch | F Status | T TTL | S Save | Q Back (No Save)", + behaviorHelp: + "Enter Select | 1-3 Delay | P Pause | L AutoFetch | F Status | T TTL | S Save | Q Back (No Save)", themeTitle: "Color Theme", themeSubtitle: "Pick base color and accent", themeHelp: "Enter Select | 1-2 Base | S Save | Q Back (No Save)", backendTitle: "Backend Controls", backendSubtitle: "Tune sync, retry, and limit behavior", - backendHelp: "Enter Open | 1-4 Category | S Save | R Reset | Q Back (No Save)", + backendHelp: + "Enter Open | 1-4 Category | S Save | R Reset | Q Back (No Save)", backendCategoriesHeading: "Categories", backendCategoryTitle: "Backend Category", - backendCategoryHelp: "Enter Toggle/Adjust | +/- or [ ] Number | 1-9 Toggle | R Reset | Q Back", + backendCategoryHelp: + "Enter Toggle/Adjust | +/- or [ ] Number | 1-9 Toggle | R Reset | Q Back", backendToggleHeading: "Switches", backendNumberHeading: "Numbers", backendDecrease: "Decrease Focused Value", @@ -118,11 +126,13 @@ export const UI_COPY = { moveDown: "Move Focused Field Down", }, fallback: { - addAnotherTip: "Tip: Use private mode or sign out before adding another account.", - addAnotherQuestion: (count: number) => `Add another account? (${count} added) (y/n): `, + addAnotherTip: + "Tip: Use private mode or sign out before adding another account.", + addAnotherQuestion: (count: number) => + `Add another account? (${count} added) (y/n): `, selectModePrompt: - "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (f) fresh, (q) back [a/c/b/x/s/d/g/f/q]: ", - invalidModePrompt: "Use one of: a, c, b, x, s, d, g, f, q.", + "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (f) fresh, (r) reset, (q) back [a/c/b/x/s/d/g/f/r/q]: ", + invalidModePrompt: "Use one of: a, c, b, x, s, d, g, f, r, q.", }, } as const; diff --git a/test/auth-menu-hotkeys.test.ts b/test/auth-menu-hotkeys.test.ts index 75ec2269..91413196 100644 --- a/test/auth-menu-hotkeys.test.ts +++ b/test/auth-menu-hotkeys.test.ts @@ -3,6 +3,8 @@ import type { AccountInfo } from "../lib/ui/auth-menu.js"; const selectMock = vi.fn(); const confirmMock = vi.fn(async () => true); +const searchQuestionMock = vi.fn(); +const searchCloseMock = vi.fn(); vi.mock("../lib/ui/select.js", () => ({ select: selectMock, @@ -12,6 +14,13 @@ vi.mock("../lib/ui/confirm.js", () => ({ confirm: confirmMock, })); +vi.mock("node:readline/promises", () => ({ + createInterface: vi.fn(() => ({ + question: searchQuestionMock, + close: searchCloseMock, + })), +})); + function createAccounts(): AccountInfo[] { const baseTime = 1_700_000_000_000; return [ @@ -27,6 +36,8 @@ describe("auth-menu hotkeys", () => { vi.resetModules(); selectMock.mockReset(); confirmMock.mockReset(); + searchQuestionMock.mockReset(); + searchCloseMock.mockReset(); confirmMock.mockResolvedValue(true); previousCliVersion = process.env.CODEX_MULTI_AUTH_CLI_VERSION; delete process.env.CODEX_MULTI_AUTH_CLI_VERSION; @@ -138,6 +149,34 @@ describe("auth-menu hotkeys", () => { expect(selectMock).toHaveBeenCalledTimes(2); }); + it("sanitizes search subtitles and status messages", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + searchQuestionMock.mockResolvedValueOnce(" \u001b[31mNeedle\u0007 "); + selectMock + .mockImplementationOnce( + async (items: unknown[], options: { onInput?: (...args: unknown[]) => unknown }) => { + if (!options.onInput) return null; + return options.onInput("/", { + cursor: 0, + items, + requestRerender: () => undefined, + }); + }, + ) + .mockResolvedValueOnce({ type: "cancel" }); + + const { showAuthMenu } = await import("../lib/ui/auth-menu.js"); + const result = await showAuthMenu(createAccounts(), { + statusMessage: () => "\u001b[32mNeeds\u0000 attention\u0007 ", + }); + + expect(result).toEqual({ type: "cancel" }); + expect(searchCloseMock).toHaveBeenCalledTimes(1); + const options = selectMock.mock.calls[1]?.[1] as { subtitle?: string }; + expect(options.subtitle).toBe("Search: needle | Needs attention"); + }); + it("supports help toggle hotkey (?) and requests rerender", async () => { let rerenderCalls = 0; selectMock.mockImplementationOnce(async (items: unknown[], options: { onInput?: (...args: unknown[]) => unknown }) => { @@ -197,4 +236,68 @@ describe("auth-menu hotkeys", () => { const options = selectMock.mock.calls[0]?.[1] as { message?: string }; expect(options?.message).toBe("Accounts Dashboard (v0.1.6)"); }); + + it("returns delete-all without an extra confirm prompt", async () => { + selectMock.mockResolvedValueOnce({ type: "delete-all" }); + + const { showAuthMenu } = await import("../lib/ui/auth-menu.js"); + const result = await showAuthMenu(createAccounts()); + + expect(result).toEqual({ type: "delete-all" }); + expect(confirmMock).not.toHaveBeenCalled(); + }); + + it("returns reset-all without an extra confirm prompt", async () => { + selectMock.mockResolvedValueOnce({ type: "reset-all" }); + + const { showAuthMenu } = await import("../lib/ui/auth-menu.js"); + const result = await showAuthMenu(createAccounts()); + + expect(result).toEqual({ type: "reset-all" }); + expect(confirmMock).not.toHaveBeenCalled(); + }); + + it("sanitizes ANSI and control characters in rendered account labels", async () => { + const baseTime = 1_700_000_000_000; + selectMock.mockResolvedValueOnce({ type: "cancel" }); + + const { showAuthMenu } = await import("../lib/ui/auth-menu.js"); + await showAuthMenu([ + { + index: 0, + email: "\u001b[31mfirst@example.com\u0000", + status: "ok", + lastUsed: baseTime, + }, + { + index: 1, + accountLabel: "\u001b[32mFriendly \r\nLabel\u007f", + status: "ok", + lastUsed: baseTime, + }, + { + index: 2, + email: "", + accountLabel: " \r\n ", + accountId: "\u001b[33macc-id-42\u0007", + status: "ok", + lastUsed: baseTime, + }, + ]); + + const items = (selectMock.mock.calls[0]?.[0] as Array<{ + label: string; + value: { type: string }; + }>).filter((item) => item.value.type === "select-account"); + const labels = items.map((item) => item.label); + const strippedLabels = labels.map((label) => + label.replace(new RegExp("\\u001b\\[[0-?]*[ -/]*[@-~]", "g"), ""), + ); + + expect(strippedLabels[0]).toContain("1. first@example.com"); + expect(strippedLabels[1]).toContain("2. Friendly Label"); + expect(strippedLabels[2]).toContain("3. acc-id-42"); + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional test assertion + expect(strippedLabels.join("")).not.toMatch(/[\u0000\u0007\u007f]/); + }); }); diff --git a/test/cli-auth-menu.test.ts b/test/cli-auth-menu.test.ts index 0f06f2c3..cf76445c 100644 --- a/test/cli-auth-menu.test.ts +++ b/test/cli-auth-menu.test.ts @@ -290,7 +290,35 @@ describe("CLI auth menu shortcuts", () => { const result = await promptLoginMode([{ index: 0 }]); expect(result).toEqual({ mode: "cancel" }); - expect(consoleSpy).toHaveBeenCalledWith("\nDelete all cancelled.\n"); + expect(consoleSpy).toHaveBeenCalledWith("\nDelete saved accounts cancelled.\n"); + consoleSpy.mockRestore(); + }); + + it("returns reset mode when reset-all is confirmed", async () => { + mockRl.question.mockResolvedValueOnce("RESET"); + showAuthMenu.mockResolvedValueOnce({ type: "reset-all" }); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "reset" }); + expect(mockRl.close).toHaveBeenCalled(); + }); + + it("cancels reset-all when typed confirmation is not RESET", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + mockRl.question.mockResolvedValueOnce("nope"); + showAuthMenu + .mockResolvedValueOnce({ type: "reset-all" }) + .mockResolvedValueOnce({ type: "cancel" }); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "cancel" }); + expect(consoleSpy).toHaveBeenCalledWith( + "\nReset local state cancelled.\n", + ); consoleSpy.mockRestore(); }); @@ -305,7 +333,7 @@ describe("CLI auth menu shortcuts", () => { const result = await promptLoginMode([{ index: 0 }]); expect(result).toEqual({ mode: "cancel" }); - expect(consoleSpy).toHaveBeenCalledWith("\nDelete all cancelled.\n"); + expect(consoleSpy).toHaveBeenCalledWith("\nDelete saved accounts cancelled.\n"); consoleSpy.mockRestore(); }); diff --git a/test/cli.test.ts b/test/cli.test.ts index 39f1b753..a2750841 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,274 +1,443 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createInterface } from "node:readline/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("node:readline/promises", () => ({ - createInterface: vi.fn(), + createInterface: vi.fn(), })); const mockRl = { - question: vi.fn(), - close: vi.fn(), + question: vi.fn(), + close: vi.fn(), }; describe("CLI Module", () => { - beforeEach(() => { - vi.resetModules(); - process.env.FORCE_INTERACTIVE_MODE = "1"; - mockRl.question.mockReset(); - mockRl.close.mockReset(); - vi.mocked(createInterface).mockReturnValue(mockRl as any); - vi.spyOn(console, "log").mockImplementation(() => {}); - }); - - afterEach(() => { - delete process.env.FORCE_INTERACTIVE_MODE; - vi.restoreAllMocks(); - }); - - describe("promptAddAnotherAccount", () => { - it("returns true for 'y' input", async () => { - mockRl.question.mockResolvedValueOnce("y"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(true); - expect(mockRl.close).toHaveBeenCalled(); - }); - - it("returns true for 'yes' input", async () => { - mockRl.question.mockResolvedValueOnce("yes"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(2); - - expect(result).toBe(true); - }); - - it("returns true for 'Y' input (case insensitive)", async () => { - mockRl.question.mockResolvedValueOnce("Y"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(true); - }); - - it("returns false for 'n' input", async () => { - mockRl.question.mockResolvedValueOnce("n"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(false); - }); - - it("returns false for empty input", async () => { - mockRl.question.mockResolvedValueOnce(""); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(false); - }); - - it("returns false for random input", async () => { - mockRl.question.mockResolvedValueOnce("maybe"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(false); - }); - - it("includes current count in prompt", async () => { - mockRl.question.mockResolvedValueOnce("n"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - await promptAddAnotherAccount(5); - - expect(mockRl.question).toHaveBeenCalledWith( - expect.stringContaining("5 added") - ); - }); - - it("always closes readline interface", async () => { - mockRl.question.mockRejectedValueOnce(new Error("test error")); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - - await expect(promptAddAnotherAccount(1)).rejects.toThrow("test error"); - expect(mockRl.close).toHaveBeenCalled(); - }); - }); - - describe("promptLoginMode", () => { - it("returns 'add' for 'a' input", async () => { - mockRl.question.mockResolvedValueOnce("a"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([ - { index: 0, email: "test@example.com" }, - ]); - - expect(result).toEqual({ mode: "add" }); - expect(mockRl.close).toHaveBeenCalled(); - }); - - it("returns 'add' for 'add' input", async () => { - mockRl.question.mockResolvedValueOnce("add"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "add" }); - }); - - it("returns 'forecast' for 'p' input", async () => { - mockRl.question.mockResolvedValueOnce("p"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "forecast" }); - }); - - it("returns 'fix' for 'x' input", async () => { - mockRl.question.mockResolvedValueOnce("x"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "fix" }); - }); - - it("returns 'settings' for 's' input", async () => { - mockRl.question.mockResolvedValueOnce("s"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "settings" }); - }); - - it("returns 'fresh' for 'f' input", async () => { - mockRl.question.mockResolvedValueOnce("f"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "fresh", deleteAll: true }); - }); - - it("returns 'fresh' for 'fresh' input", async () => { - mockRl.question.mockResolvedValueOnce("fresh"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "fresh", deleteAll: true }); - }); - - it("returns 'verify-flagged' for 'g' input", async () => { - mockRl.question.mockResolvedValueOnce("g"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "verify-flagged" }); - }); - - it("accepts uppercase quick shortcuts for advanced actions", async () => { - const { promptLoginMode } = await import("../lib/cli.js"); - - mockRl.question.mockResolvedValueOnce("P"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "forecast" }); - - mockRl.question.mockResolvedValueOnce("X"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "fix" }); - - mockRl.question.mockResolvedValueOnce("S"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "settings" }); - - mockRl.question.mockResolvedValueOnce("G"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "verify-flagged" }); - }); - - it("is case insensitive", async () => { - mockRl.question.mockResolvedValueOnce("A"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "add" }); - }); - - it("re-prompts on invalid input then accepts valid", async () => { - mockRl.question - .mockResolvedValueOnce("invalid") - .mockResolvedValueOnce("zzz") - .mockResolvedValueOnce("a"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "add" }); - expect(mockRl.question).toHaveBeenCalledTimes(3); - }); - - it("displays account list with email", async () => { - mockRl.question.mockResolvedValueOnce("a"); - const consoleSpy = vi.spyOn(console, "log"); - - const { promptLoginMode } = await import("../lib/cli.js"); - await promptLoginMode([ - { index: 0, email: "user1@example.com" }, - { index: 1, email: "user2@example.com" }, - ]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("2 account(s)")); - }); - - it("displays account with accountId suffix when no email", async () => { - mockRl.question.mockResolvedValueOnce("f"); - const consoleSpy = vi.spyOn(console, "log"); - - const { promptLoginMode } = await import("../lib/cli.js"); - await promptLoginMode([ - { index: 0, accountId: "acc_1234567890" }, - ]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringMatching(/1\.\s*567890/)); - }); + beforeEach(() => { + vi.resetModules(); + process.env.FORCE_INTERACTIVE_MODE = "1"; + mockRl.question.mockReset(); + mockRl.close.mockReset(); + vi.mocked(createInterface).mockReturnValue(mockRl as any); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + delete process.env.FORCE_INTERACTIVE_MODE; + vi.restoreAllMocks(); + }); + + describe("promptAddAnotherAccount", () => { + it("returns true for 'y' input", async () => { + mockRl.question.mockResolvedValueOnce("y"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(true); + expect(mockRl.close).toHaveBeenCalled(); + }); + + it("returns true for 'yes' input", async () => { + mockRl.question.mockResolvedValueOnce("yes"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(2); + + expect(result).toBe(true); + }); + + it("returns true for 'Y' input (case insensitive)", async () => { + mockRl.question.mockResolvedValueOnce("Y"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(true); + }); + + it("returns false for 'n' input", async () => { + mockRl.question.mockResolvedValueOnce("n"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(false); + }); + + it("returns false for empty input", async () => { + mockRl.question.mockResolvedValueOnce(""); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(false); + }); + + it("returns false for random input", async () => { + mockRl.question.mockResolvedValueOnce("maybe"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(false); + }); + + it("includes current count in prompt", async () => { + mockRl.question.mockResolvedValueOnce("n"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + await promptAddAnotherAccount(5); + + expect(mockRl.question).toHaveBeenCalledWith( + expect.stringContaining("5 added"), + ); + }); + + it("always closes readline interface", async () => { + mockRl.question.mockRejectedValueOnce(new Error("test error")); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + + await expect(promptAddAnotherAccount(1)).rejects.toThrow("test error"); + expect(mockRl.close).toHaveBeenCalled(); + }); + }); + + describe("promptLoginMode", () => { + it("returns 'add' for 'a' input", async () => { + mockRl.question.mockResolvedValueOnce("a"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([ + { index: 0, email: "test@example.com" }, + ]); + + expect(result).toEqual({ mode: "add" }); + expect(mockRl.close).toHaveBeenCalled(); + }); + + it("returns 'add' for 'add' input", async () => { + mockRl.question.mockResolvedValueOnce("add"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + }); + + it("returns 'forecast' for 'p' input", async () => { + mockRl.question.mockResolvedValueOnce("p"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "forecast" }); + }); + + it("returns 'fix' for 'x' input", async () => { + mockRl.question.mockResolvedValueOnce("x"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "fix" }); + }); + + it("returns 'settings' for 's' input", async () => { + mockRl.question.mockResolvedValueOnce("s"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "settings" }); + }); + + it("returns 'fresh' for 'f' input", async () => { + mockRl.question + .mockResolvedValueOnce("f") + .mockResolvedValueOnce("DELETE"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "fresh", deleteAll: true }); + }); + + it("returns 'fresh' for 'fresh' input", async () => { + mockRl.question + .mockResolvedValueOnce("fresh") + .mockResolvedValueOnce("DELETE"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "fresh", deleteAll: true }); + }); + + it("returns 'reset' for fallback reset confirmation", async () => { + mockRl.question + .mockResolvedValueOnce("reset") + .mockResolvedValueOnce("RESET"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "reset" }); + }); + + it("cancels fallback delete-all when typed confirmation does not match", async () => { + mockRl.question + .mockResolvedValueOnce("fresh") + .mockResolvedValueOnce("nope") + .mockResolvedValueOnce("a"); + const consoleSpy = vi.spyOn(console, "log"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + expect(consoleSpy).toHaveBeenCalledWith( + "\nDelete saved accounts cancelled.\n", + ); + }); + + it("cancels fallback reset when typed confirmation does not match", async () => { + mockRl.question + .mockResolvedValueOnce("reset") + .mockResolvedValueOnce("nope") + .mockResolvedValueOnce("a"); + const consoleSpy = vi.spyOn(console, "log"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + expect(consoleSpy).toHaveBeenCalledWith( + "\nReset local state cancelled.\n", + ); + }); + + it("returns reset for TTY reset-all confirmation", async () => { + const { stdin, stdout } = await import("node:process"); + const origInputTTY = stdin.isTTY; + const origOutputTTY = stdout.isTTY; + const showAuthMenuMock = vi.fn().mockResolvedValue({ type: "reset-all" }); + + Object.defineProperty(stdin, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + + try { + vi.resetModules(); + vi.doMock("../lib/ui/auth-menu.js", async () => { + const actual = await vi.importActual("../lib/ui/auth-menu.js"); + return { + ...(actual as Record), + isTTY: vi.fn(() => true), + showAuthMenu: showAuthMenuMock, + }; + }); + mockRl.question.mockResolvedValueOnce("RESET"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "reset" }); + expect(showAuthMenuMock).toHaveBeenCalledTimes(1); + } finally { + vi.doUnmock("../lib/ui/auth-menu.js"); + Object.defineProperty(stdin, "isTTY", { + value: origInputTTY, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: origOutputTTY, + writable: true, + configurable: true, + }); + } + }); + + it("uses reset local state cancellation copy in TTY reset-all flow", async () => { + const { stdin, stdout } = await import("node:process"); + const origInputTTY = stdin.isTTY; + const origOutputTTY = stdout.isTTY; + const showAuthMenuMock = vi + .fn() + .mockResolvedValueOnce({ type: "reset-all" }) + .mockResolvedValueOnce({ type: "add" }); + const consoleSpy = vi.spyOn(console, "log"); + + Object.defineProperty(stdin, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + + try { + vi.resetModules(); + vi.doMock("../lib/ui/auth-menu.js", async () => { + const actual = await vi.importActual("../lib/ui/auth-menu.js"); + return { + ...(actual as Record), + isTTY: vi.fn(() => true), + showAuthMenu: showAuthMenuMock, + }; + }); + mockRl.question.mockResolvedValueOnce("nope"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + expect(consoleSpy).toHaveBeenCalledWith( + "\nReset local state cancelled.\n", + ); + } finally { + vi.doUnmock("../lib/ui/auth-menu.js"); + Object.defineProperty(stdin, "isTTY", { + value: origInputTTY, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: origOutputTTY, + writable: true, + configurable: true, + }); + } + }); + + it("returns 'verify-flagged' for 'g' input", async () => { + mockRl.question.mockResolvedValueOnce("g"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "verify-flagged" }); + }); + + it("accepts uppercase quick shortcuts for advanced actions", async () => { + const { promptLoginMode } = await import("../lib/cli.js"); + + mockRl.question.mockResolvedValueOnce("P"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "forecast", + }); + + mockRl.question.mockResolvedValueOnce("X"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "fix", + }); + + mockRl.question.mockResolvedValueOnce("S"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "settings", + }); + + mockRl.question.mockResolvedValueOnce("G"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "verify-flagged", + }); + }); + + it("is case insensitive", async () => { + mockRl.question.mockResolvedValueOnce("A"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + }); + + it("re-prompts on invalid input then accepts valid", async () => { + mockRl.question + .mockResolvedValueOnce("invalid") + .mockResolvedValueOnce("zzz") + .mockResolvedValueOnce("a"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + expect(mockRl.question).toHaveBeenCalledTimes(3); + }); + + it("displays account list with email", async () => { + mockRl.question.mockResolvedValueOnce("a"); + const consoleSpy = vi.spyOn(console, "log"); + + const { promptLoginMode } = await import("../lib/cli.js"); + await promptLoginMode([ + { index: 0, email: "user1@example.com" }, + { index: 1, email: "user2@example.com" }, + ]); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("2 account(s)"), + ); + }); + + it("displays account with accountId suffix when no email", async () => { + mockRl.question.mockResolvedValueOnce("a"); + const consoleSpy = vi.spyOn(console, "log"); + + const { promptLoginMode } = await import("../lib/cli.js"); + await promptLoginMode([{ index: 0, accountId: "acc_1234567890" }]); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/1\.\s*567890/), + ); + }); it("displays plain Account N when no email or accountId", async () => { - mockRl.question.mockResolvedValueOnce("f"); + mockRl.question.mockResolvedValueOnce("a"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptLoginMode } = await import("../lib/cli.js"); await promptLoginMode([{ index: 0 }]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("1. Account")); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("1. Account"), + ); }); it("displays label with email when both present", async () => { mockRl.question.mockResolvedValueOnce("a"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptLoginMode } = await import("../lib/cli.js"); - await promptLoginMode([{ index: 0, accountLabel: "Work", email: "work@example.com" }]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringMatching(/Work.*work@example\.com/)); + await promptLoginMode([ + { index: 0, accountLabel: "Work", email: "work@example.com" }, + ]); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/Work.*work@example\.com/), + ); }); it("displays only label when no email", async () => { mockRl.question.mockResolvedValueOnce("a"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptLoginMode } = await import("../lib/cli.js"); await promptLoginMode([{ index: 0, accountLabel: "Personal" }]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("1. Personal")); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("1. Personal"), + ); }); }); @@ -321,16 +490,32 @@ describe("CLI Module", () => { const { stdin, stdout } = await import("node:process"); const origInputTTY = stdin.isTTY; const origOutputTTY = stdout.isTTY; - - Object.defineProperty(stdin, "isTTY", { value: true, writable: true, configurable: true }); - Object.defineProperty(stdout, "isTTY", { value: true, writable: true, configurable: true }); - + + Object.defineProperty(stdin, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + try { const { isNonInteractiveMode } = await import("../lib/cli.js"); expect(isNonInteractiveMode()).toBe(false); } finally { - Object.defineProperty(stdin, "isTTY", { value: origInputTTY, writable: true, configurable: true }); - Object.defineProperty(stdout, "isTTY", { value: origOutputTTY, writable: true, configurable: true }); + Object.defineProperty(stdin, "isTTY", { + value: origInputTTY, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: origOutputTTY, + writable: true, + configurable: true, + }); } }); }); @@ -344,63 +529,63 @@ describe("CLI Module", () => { it("returns first candidate by selection", async () => { mockRl.question.mockResolvedValueOnce("1"); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; const result = await promptAccountSelection(candidates); - + expect(result).toEqual(candidates[0]); expect(mockRl.close).toHaveBeenCalled(); }); it("returns second candidate by selection", async () => { mockRl.question.mockResolvedValueOnce("2"); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; const result = await promptAccountSelection(candidates); - + expect(result).toEqual(candidates[1]); }); it("returns default on empty input", async () => { mockRl.question.mockResolvedValueOnce(""); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; - const result = await promptAccountSelection(candidates, { defaultIndex: 1 }); - + const result = await promptAccountSelection(candidates, { + defaultIndex: 1, + }); + expect(result).toEqual(candidates[1]); }); it("returns default on quit input", async () => { mockRl.question.mockResolvedValueOnce("q"); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [{ accountId: "acc1", label: "Account 1" }]; const result = await promptAccountSelection(candidates); - + expect(result).toEqual(candidates[0]); }); it("re-prompts on invalid selection", async () => { - mockRl.question - .mockResolvedValueOnce("99") - .mockResolvedValueOnce("1"); - + mockRl.question.mockResolvedValueOnce("99").mockResolvedValueOnce("1"); + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [{ accountId: "acc1", label: "Account 1" }]; const result = await promptAccountSelection(candidates); - + expect(result).toEqual(candidates[0]); expect(mockRl.question).toHaveBeenCalledTimes(2); }); @@ -408,51 +593,59 @@ describe("CLI Module", () => { it("displays custom title", async () => { mockRl.question.mockResolvedValueOnce("1"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptAccountSelection } = await import("../lib/cli.js"); await promptAccountSelection( [{ accountId: "acc1", label: "Account 1" }], - { title: "Custom Title" } + { title: "Custom Title" }, + ); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Custom Title"), ); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Custom Title")); }); it("shows default marker for default candidates", async () => { mockRl.question.mockResolvedValueOnce("1"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptAccountSelection } = await import("../lib/cli.js"); await promptAccountSelection([ { accountId: "acc1", label: "Account 1", isDefault: true }, ]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("(default)")); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("(default)"), + ); }); it("clamps defaultIndex to valid range", async () => { mockRl.question.mockResolvedValueOnce(""); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; - const result = await promptAccountSelection(candidates, { defaultIndex: 999 }); - + const result = await promptAccountSelection(candidates, { + defaultIndex: 999, + }); + expect(result).toEqual(candidates[1]); }); it("handles negative defaultIndex", async () => { mockRl.question.mockResolvedValueOnce(""); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; - const result = await promptAccountSelection(candidates, { defaultIndex: -5 }); - + const result = await promptAccountSelection(candidates, { + defaultIndex: -5, + }); + expect(result).toEqual(candidates[0]); }); }); @@ -485,7 +678,9 @@ describe("CLI Module", () => { { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; - const result = await promptAccountSelection(candidates, { defaultIndex: 1 }); + const result = await promptAccountSelection(candidates, { + defaultIndex: 1, + }); expect(result).toEqual(candidates[1]); }); }); @@ -494,13 +689,19 @@ describe("CLI Module", () => { const { promptLoginMode } = await import("../lib/cli.js"); mockRl.question.mockResolvedValueOnce("check"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "check" }); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "check", + }); mockRl.question.mockResolvedValueOnce("deep"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "deep-check" }); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "deep-check", + }); mockRl.question.mockResolvedValueOnce("quit"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "cancel" }); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "cancel", + }); }); it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { @@ -508,8 +709,16 @@ describe("CLI Module", () => { const { stdin, stdout } = await import("node:process"); const origInputTTY = stdin.isTTY; const origOutputTTY = stdout.isTTY; - Object.defineProperty(stdin, "isTTY", { value: true, writable: true, configurable: true }); - Object.defineProperty(stdout, "isTTY", { value: true, writable: true, configurable: true }); + Object.defineProperty(stdin, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); try { process.env.CODEX_TUI = "1"; @@ -536,8 +745,16 @@ describe("CLI Module", () => { delete process.env.CODEX_DESKTOP; delete process.env.TERM_PROGRAM; delete process.env.ELECTRON_RUN_AS_NODE; - Object.defineProperty(stdin, "isTTY", { value: origInputTTY, writable: true, configurable: true }); - Object.defineProperty(stdout, "isTTY", { value: origOutputTTY, writable: true, configurable: true }); + Object.defineProperty(stdin, "isTTY", { + value: origInputTTY, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: origOutputTTY, + writable: true, + configurable: true, + }); } }); }); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 4340b503..6358465e 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -18,6 +18,9 @@ const saveQuotaCacheMock = vi.fn(); const loadPluginConfigMock = vi.fn(); const savePluginConfigMock = vi.fn(); const selectMock = vi.fn(); +const deleteSavedAccountsMock = vi.fn(); +const resetLocalStateMock = vi.fn(); +const deleteAccountAtIndexMock = vi.fn(); const planOcChatgptSyncMock = vi.fn(); const applyOcChatgptSyncMock = vi.fn(); const runNamedBackupExportMock = vi.fn(); @@ -150,6 +153,35 @@ vi.mock("../lib/quota-cache.js", () => ({ saveQuotaCache: saveQuotaCacheMock, })); +vi.mock("../lib/destructive-actions.js", () => ({ + DESTRUCTIVE_ACTION_COPY: { + deleteSavedAccounts: { + label: "Delete Saved Accounts", + typedConfirm: + "Type DELETE to delete saved accounts only (saved accounts: delete; flagged/problem accounts, settings, and Codex CLI sync state: keep): ", + confirm: + "Delete saved accounts? (Saved accounts: delete. Flagged/problem accounts: keep. Settings: keep. Codex CLI sync state: keep.)", + stage: "Deleting saved accounts only", + completed: + "Deleted saved accounts. Saved accounts deleted; flagged/problem accounts, settings, and Codex CLI sync state kept.", + }, + resetLocalState: { + label: "Reset Local State", + typedConfirm: + "Type RESET to reset local state (saved accounts + flagged/problem accounts: delete; settings + Codex CLI sync state: keep; quota cache: clear): ", + confirm: + "Reset local state? (Saved accounts: delete. Flagged/problem accounts: delete. Settings: keep. Codex CLI sync state: keep. Quota cache: clear.)", + stage: + "Clearing saved accounts, flagged/problem accounts, and quota cache", + completed: + "Reset local state. Saved accounts, flagged/problem accounts, and quota cache cleared; settings and Codex CLI sync state kept.", + }, + }, + deleteSavedAccounts: deleteSavedAccountsMock, + resetLocalState: resetLocalStateMock, + deleteAccountAtIndex: deleteAccountAtIndexMock, +})); + vi.mock("../lib/ui/select.js", () => ({ select: selectMock, })); @@ -429,6 +461,20 @@ describe("codex manager cli commands", () => { loadPluginConfigMock.mockReset(); savePluginConfigMock.mockReset(); selectMock.mockReset(); + deleteSavedAccountsMock.mockReset(); + resetLocalStateMock.mockReset(); + deleteAccountAtIndexMock.mockReset(); + deleteAccountAtIndexMock.mockResolvedValue(null); + deleteSavedAccountsMock.mockResolvedValue({ + accountsCleared: true, + flaggedCleared: false, + quotaCacheCleared: false, + }); + resetLocalStateMock.mockResolvedValue({ + accountsCleared: true, + flaggedCleared: true, + quotaCacheCleared: true, + }); fetchCodexQuotaSnapshotMock.mockResolvedValue({ status: 200, model: "gpt-5-codex", @@ -3168,16 +3214,38 @@ describe("codex manager cli commands", () => { promptLoginModeMock .mockResolvedValueOnce({ mode: "manage", deleteAccountIndex: 1 }) .mockResolvedValueOnce({ mode: "cancel" }); + deleteAccountAtIndexMock.mockResolvedValueOnce({ + storage: { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + ], + }, + flagged: { version: 1, accounts: [] }, + removedAccount: { + refreshToken: "refresh-second", + addedAt: now - 1_000, + lastUsed: now - 1_000, + accountIdSource: undefined, + enabled: true, + }, + removedFlaggedCount: 0, + }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(saveAccountsMock).toHaveBeenCalledTimes(1); - expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts).toHaveLength(1); - expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.email).toBe( - "first@example.com", - ); + expect(deleteAccountAtIndexMock).toHaveBeenCalledTimes(1); + expect(deleteAccountAtIndexMock.mock.calls[0]?.[0]?.index).toBe(1); }); it("toggles account enabled state from manage mode", async () => { @@ -3210,6 +3278,252 @@ describe("codex manager cli commands", () => { ); }); + it("skips destructive work when user cancels from menu", async () => { + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "keep@example.com", + refreshToken: "keep-refresh", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(deleteSavedAccountsMock).not.toHaveBeenCalled(); + expect(resetLocalStateMock).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + }); + + it("deletes saved accounts only when requested", async () => { + const now = Date.now(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now, + lastUsed: now, + }, + ], + }); + 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(deleteSavedAccountsMock).toHaveBeenCalledTimes(1); + expect(resetLocalStateMock).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + "Deleted saved accounts. Saved accounts deleted; flagged/problem accounts, settings, and Codex CLI sync state kept.", + ); + logSpy.mockRestore(); + }); + + it("resets local state when reset mode is chosen", async () => { + const now = Date.now(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now, + lastUsed: now, + }, + ], + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "reset" }) + .mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(resetLocalStateMock).toHaveBeenCalledTimes(1); + expect(deleteSavedAccountsMock).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + "Reset local state. Saved accounts, flagged/problem accounts, and quota cache cleared; settings and Codex CLI sync state kept.", + ); + logSpy.mockRestore(); + }); + + it("waits for an in-flight menu quota refresh before resetting local state", async () => { + const now = Date.now(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const fetchStarted = createDeferred(); + const fetchDeferred = createDeferred<{ + status: number; + model: string; + primary: Record; + secondary: Record; + }>(); + + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + accountId: "acc-first", + accessToken: "access-first", + expiresAt: now + 3_600_000, + refreshToken: "refresh-first", + addedAt: now, + lastUsed: now, + enabled: true, + }, + ], + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuShowFetchStatus: true, + menuQuotaTtlMs: 60_000, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + loadQuotaCacheMock.mockResolvedValue({ + byAccountId: {}, + byEmail: {}, + }); + fetchCodexQuotaSnapshotMock.mockImplementation(async () => { + fetchStarted.resolve(); + return fetchDeferred.promise; + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "reset" }) + .mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const runPromise = runCodexMultiAuthCli(["auth", "login"]); + + await fetchStarted.promise; + await Promise.resolve(); + + expect(resetLocalStateMock).not.toHaveBeenCalled(); + + fetchDeferred.resolve({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }); + + const exitCode = await runPromise; + + expect(exitCode).toBe(0); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(resetLocalStateMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock.mock.invocationCallOrder[0]).toBeLessThan( + resetLocalStateMock.mock.invocationCallOrder[0] ?? + Number.POSITIVE_INFINITY, + ); + expect(logSpy).toHaveBeenCalledWith( + "Reset local state. Saved accounts, flagged/problem accounts, and quota cache cleared; settings and Codex CLI sync state kept.", + ); + logSpy.mockRestore(); + }); + + it("skips a second destructive action while reset is already running", async () => { + const now = Date.now(); + const skipMessage = + "Another destructive action is already running. Wait for it to finish."; + const secondMenuAttempted = createDeferred(); + const skipLogged = createDeferred(); + const logSpy = vi.spyOn(console, "log").mockImplementation((message?: unknown) => { + if (message === skipMessage) { + skipLogged.resolve(); + } + }); + const firstResetStarted = createDeferred(); + const allowFirstResetToFinish = createDeferred(); + let menuPromptCall = 0; + + loadAccountsMock.mockImplementation(async () => ({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now, + lastUsed: now, + }, + ], + })); + promptLoginModeMock.mockImplementation(async () => { + menuPromptCall += 1; + if (menuPromptCall === 2) { + secondMenuAttempted.resolve(); + } + if (menuPromptCall <= 2) { + return { mode: "reset" }; + } + return { mode: "cancel" }; + }); + resetLocalStateMock.mockImplementationOnce(async () => { + firstResetStarted.resolve(); + await allowFirstResetToFinish.promise; + return { + accountsCleared: true, + flaggedCleared: true, + quotaCacheCleared: true, + }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const firstRunPromise = runCodexMultiAuthCli(["auth", "login"]); + + await firstResetStarted.promise; + + const secondRunPromise = runCodexMultiAuthCli(["auth", "login"]); + await secondMenuAttempted.promise; + await skipLogged.promise; + + expect(resetLocalStateMock).toHaveBeenCalledTimes(1); + + allowFirstResetToFinish.resolve(); + + const [firstExitCode, secondExitCode] = await Promise.all([ + firstRunPromise, + secondRunPromise, + ]); + + expect(firstExitCode).toBe(0); + expect(secondExitCode).toBe(0); + expect(resetLocalStateMock).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith(skipMessage); + logSpy.mockRestore(); + }); + it("keeps settings unchanged in non-interactive mode and returns to menu", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValue( diff --git a/test/destructive-actions.test.ts b/test/destructive-actions.test.ts new file mode 100644 index 00000000..50083d9d --- /dev/null +++ b/test/destructive-actions.test.ts @@ -0,0 +1,301 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const clearAccountsMock = vi.fn(); +const clearFlaggedAccountsMock = vi.fn(); +const clearQuotaCacheMock = vi.fn(); +const clearCodexCliStateCacheMock = vi.fn(); +const loadFlaggedAccountsMock = vi.fn(); +const saveAccountsMock = vi.fn(); +const saveFlaggedAccountsMock = vi.fn(); + +vi.mock("../lib/codex-cli/state.js", () => ({ + clearCodexCliStateCache: clearCodexCliStateCacheMock, +})); + +vi.mock("../lib/prompts/codex.js", () => ({ + MODEL_FAMILIES: ["codex", "gpt-5.x"] as const, +})); + +vi.mock("../lib/quota-cache.js", () => ({ + clearQuotaCache: clearQuotaCacheMock, +})); + +vi.mock("../lib/storage.js", () => ({ + clearAccounts: clearAccountsMock, + clearFlaggedAccounts: clearFlaggedAccountsMock, + loadFlaggedAccounts: loadFlaggedAccountsMock, + saveAccounts: saveAccountsMock, + saveFlaggedAccounts: saveFlaggedAccountsMock, +})); + +describe("destructive actions", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + clearAccountsMock.mockResolvedValue(true); + clearFlaggedAccountsMock.mockResolvedValue(true); + clearQuotaCacheMock.mockResolvedValue(true); + loadFlaggedAccountsMock.mockResolvedValue({ version: 1, accounts: [] }); + saveAccountsMock.mockResolvedValue(undefined); + saveFlaggedAccountsMock.mockResolvedValue(undefined); + }); + + it("returns delete-only results without pretending kept data was cleared", async () => { + const { deleteSavedAccounts } = await import( + "../lib/destructive-actions.js" + ); + + await expect(deleteSavedAccounts()).resolves.toEqual({ + accountsCleared: true, + flaggedCleared: false, + quotaCacheCleared: false, + }); + expect(clearAccountsMock).toHaveBeenCalledTimes(1); + expect(clearFlaggedAccountsMock).not.toHaveBeenCalled(); + expect(clearQuotaCacheMock).not.toHaveBeenCalled(); + expect(clearCodexCliStateCacheMock).not.toHaveBeenCalled(); + }); + + it("returns reset results and clears Codex CLI state", async () => { + clearAccountsMock.mockResolvedValueOnce(true); + clearFlaggedAccountsMock.mockResolvedValueOnce(false); + clearQuotaCacheMock.mockResolvedValueOnce(true); + + const { resetLocalState } = await import("../lib/destructive-actions.js"); + + await expect(resetLocalState()).resolves.toEqual({ + accountsCleared: true, + flaggedCleared: false, + quotaCacheCleared: true, + }); + expect(clearAccountsMock).toHaveBeenCalledTimes(1); + expect(clearFlaggedAccountsMock).toHaveBeenCalledTimes(1); + expect(clearQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(clearCodexCliStateCacheMock).toHaveBeenCalledTimes(1); + }); + + it("does not clear Codex CLI state when resetLocalState aborts on an exception", async () => { + const resetError = Object.assign(new Error("flagged clear failed"), { + code: "EPERM", + }); + clearFlaggedAccountsMock.mockRejectedValueOnce(resetError); + + const { resetLocalState } = await import("../lib/destructive-actions.js"); + + await expect(resetLocalState()).rejects.toBe(resetError); + expect(clearAccountsMock).toHaveBeenCalledTimes(1); + expect(clearFlaggedAccountsMock).toHaveBeenCalledTimes(1); + expect(clearQuotaCacheMock).not.toHaveBeenCalled(); + expect(clearCodexCliStateCacheMock).not.toHaveBeenCalled(); + }); + + it("re-bases active indices before clamping when deleting an earlier account", async () => { + const { deleteAccountAtIndex } = await import( + "../lib/destructive-actions.js" + ); + + const storage = { + version: 3, + activeIndex: 1, + activeIndexByFamily: { codex: 2, "gpt-5.x": 1 }, + accounts: [ + { + refreshToken: "refresh-remove", + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "refresh-active", + addedAt: 2, + lastUsed: 2, + }, + { + refreshToken: "refresh-other", + addedAt: 3, + lastUsed: 3, + }, + ], + }; + + const deleted = await deleteAccountAtIndex({ storage, index: 0 }); + + expect(deleted).not.toBeNull(); + expect(deleted?.storage.accounts.map((account) => account.refreshToken)).toEqual([ + "refresh-active", + "refresh-other", + ]); + expect(deleted?.storage.activeIndex).toBe(0); + expect(deleted?.storage.activeIndexByFamily).toEqual({ + codex: 1, + "gpt-5.x": 0, + }); + expect(saveAccountsMock).toHaveBeenCalledWith( + expect.objectContaining({ + activeIndex: 0, + activeIndexByFamily: { codex: 1, "gpt-5.x": 0 }, + }), + ); + }); + + it("reloads flagged storage at delete time so newer flagged entries are preserved", async () => { + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [ + { + refreshToken: "refresh-remove", + addedAt: 2, + lastUsed: 2, + flaggedAt: 2, + }, + { + refreshToken: "refresh-newer", + addedAt: 3, + lastUsed: 3, + flaggedAt: 3, + }, + ], + }); + + const { deleteAccountAtIndex } = await import( + "../lib/destructive-actions.js" + ); + + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + refreshToken: "refresh-keep", + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "refresh-remove", + addedAt: 2, + lastUsed: 2, + }, + ], + }; + + const deleted = await deleteAccountAtIndex({ storage, index: 1 }); + + expect(deleted).not.toBeNull(); + expect(deleted?.flagged.accounts).toEqual([ + expect.objectContaining({ refreshToken: "refresh-newer" }), + ]); + expect(saveFlaggedAccountsMock).toHaveBeenCalledWith({ + version: 1, + accounts: [expect.objectContaining({ refreshToken: "refresh-newer" })], + }); + }); + + it("rethrows the original flagged-save failure after a successful rollback", async () => { + const flaggedSaveError = Object.assign(new Error("flagged save failed"), { + code: "EPERM", + }); + saveFlaggedAccountsMock.mockRejectedValueOnce(flaggedSaveError); + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [ + { + refreshToken: "refresh-remove", + addedAt: 1, + lastUsed: 1, + flaggedAt: 1, + }, + ], + }); + + const { deleteAccountAtIndex } = await import( + "../lib/destructive-actions.js" + ); + + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + refreshToken: "refresh-keep", + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "refresh-remove", + addedAt: 2, + lastUsed: 2, + }, + ], + }; + + await expect(deleteAccountAtIndex({ storage, index: 1 })).rejects.toBe( + flaggedSaveError, + ); + expect(saveAccountsMock).toHaveBeenCalledTimes(2); + expect(storage.accounts).toHaveLength(2); + }); + + it("preserves both the flagged-save failure and rollback failure", async () => { + const flaggedSaveError = Object.assign(new Error("flagged save failed"), { + code: "EPERM", + }); + const rollbackError = Object.assign(new Error("rollback failed"), { + code: "EPERM", + }); + saveAccountsMock + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(rollbackError); + saveFlaggedAccountsMock.mockRejectedValueOnce(flaggedSaveError); + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [ + { + refreshToken: "refresh-remove", + addedAt: 1, + lastUsed: 1, + flaggedAt: 1, + }, + ], + }); + + const { deleteAccountAtIndex } = await import( + "../lib/destructive-actions.js" + ); + + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + refreshToken: "refresh-keep", + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "refresh-remove", + addedAt: 2, + lastUsed: 2, + }, + ], + }; + + try { + await deleteAccountAtIndex({ storage, index: 1 }); + throw new Error("expected deleteAccountAtIndex to throw"); + } catch (error) { + expect(error).toBeInstanceOf(AggregateError); + const aggregateError = error as AggregateError; + expect(aggregateError.message).toBe( + "Deleting the account partially failed and rollback also failed.", + ); + expect(aggregateError.errors).toEqual([ + flaggedSaveError, + rollbackError, + ]); + } + expect(saveAccountsMock).toHaveBeenCalledTimes(2); + expect(storage.accounts).toHaveLength(2); + }); +}); diff --git a/test/quota-cache.test.ts b/test/quota-cache.test.ts index 54b5ffb6..fd712784 100644 --- a/test/quota-cache.test.ts +++ b/test/quota-cache.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { promises as fs } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; describe("quota cache", () => { let tempDir: string; @@ -20,7 +21,7 @@ describe("quota cache", () => { } else { process.env.CODEX_MULTI_AUTH_DIR = originalDir; } - await fs.rm(tempDir, { recursive: true, force: true }); + await removeWithRetry(tempDir, { recursive: true, force: true }); }); it("returns empty cache by default", async () => { @@ -79,6 +80,109 @@ describe("quota cache", () => { expect(loaded).toEqual({ byAccountId: {}, byEmail: {} }); }); + it("resolves the quota cache path from the current CODEX_MULTI_AUTH_DIR on each call", async () => { + const { clearQuotaCache, getQuotaCachePath, saveQuotaCache } = + await import("../lib/quota-cache.js"); + const firstPath = getQuotaCachePath(); + const nextTempDir = await fs.mkdtemp( + join(tmpdir(), "codex-multi-auth-quota-next-"), + ); + + try { + process.env.CODEX_MULTI_AUTH_DIR = nextTempDir; + const nextPath = getQuotaCachePath(); + + expect(nextPath).not.toBe(firstPath); + expect(nextPath).toBe(join(nextTempDir, "quota-cache.json")); + + await saveQuotaCache({ byAccountId: {}, byEmail: {} }); + await expect(fs.access(nextPath)).resolves.toBeUndefined(); + + await clearQuotaCache(); + await expect(fs.access(nextPath)).rejects.toThrow(); + } finally { + await removeWithRetry(nextTempDir, { recursive: true, force: true }); + } + }); + + it.each(["EBUSY", "EPERM"] as const)( + "retries transient %s while clearing cache", + async (code) => { + const { clearQuotaCache, getQuotaCachePath, saveQuotaCache } = + await import("../lib/quota-cache.js"); + await saveQuotaCache({ byAccountId: {}, byEmail: {} }); + const quotaCachePath = getQuotaCachePath(); + const realUnlink = fs.unlink.bind(fs); + let attempts = 0; + const unlinkSpy = vi.spyOn(fs, "unlink"); + unlinkSpy.mockImplementation(async (...args) => { + if (String(args[0]) === quotaCachePath) { + attempts += 1; + if (attempts < 3) { + const error = new Error( + `unlink failed: ${code}`, + ) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + } + return realUnlink(...args); + }); + + try { + await expect(clearQuotaCache()).resolves.toBe(true); + await expect(fs.access(quotaCachePath)).rejects.toThrow(); + expect(attempts).toBe(3); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + + it.each(["EBUSY", "EPERM"] as const)( + "returns false when clearQuotaCache exhausts %s retries", + async (code) => { + vi.resetModules(); + const warnMock = vi.fn(); + vi.doMock("../lib/logger.js", () => ({ + logWarn: warnMock, + })); + + try { + const { clearQuotaCache, getQuotaCachePath, saveQuotaCache } = + await import("../lib/quota-cache.js"); + await saveQuotaCache({ byAccountId: {}, byEmail: {} }); + const quotaCachePath = getQuotaCachePath(); + let attempts = 0; + const unlinkSpy = vi.spyOn(fs, "unlink"); + unlinkSpy.mockImplementation(async (...args) => { + if (String(args[0]) === quotaCachePath) { + attempts += 1; + const error = new Error(`locked: ${code}`) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return Promise.resolve(); + }); + + try { + await expect(clearQuotaCache()).resolves.toBe(false); + expect(attempts).toBe(5); + await expect(fs.access(quotaCachePath)).resolves.toBeUndefined(); + expect(warnMock).toHaveBeenCalledWith( + expect.stringContaining( + `Failed to clear quota cache quota-cache.json: locked: ${code}`, + ), + ); + } finally { + unlinkSpy.mockRestore(); + } + } finally { + vi.doUnmock("../lib/logger.js"); + } + }, + ); + it("retries transient EBUSY while loading cache", async () => { const { loadQuotaCache, getQuotaCachePath } = await import("../lib/quota-cache.js"); diff --git a/test/release-main-prs-regression.test.ts b/test/release-main-prs-regression.test.ts index 6eb7929b..bd4d680b 100644 --- a/test/release-main-prs-regression.test.ts +++ b/test/release-main-prs-regression.test.ts @@ -199,7 +199,7 @@ describe("release-main-prs regressions", () => { return originalUnlink(targetPath); }); - await expect(clearFlaggedAccounts()).rejects.toThrow("EPERM primary delete"); + await expect(clearFlaggedAccounts()).resolves.toBe(false); const flagged = await loadFlaggedAccounts(); const syncResult = await syncAccountStorageFromCodexCli(null); diff --git a/test/storage-flagged.test.ts b/test/storage-flagged.test.ts index 3fda43a5..4c071622 100644 --- a/test/storage-flagged.test.ts +++ b/test/storage-flagged.test.ts @@ -188,8 +188,8 @@ describe("flagged account storage", () => { expect(existsSync(getFlaggedAccountsPath())).toBe(true); expect(existsSync(`${getFlaggedAccountsPath()}.bak`)).toBe(true); - await clearFlaggedAccounts(); - await clearFlaggedAccounts(); + await expect(clearFlaggedAccounts()).resolves.toBe(true); + await expect(clearFlaggedAccounts()).resolves.toBe(true); expect(existsSync(getFlaggedAccountsPath())).toBe(false); expect(existsSync(`${getFlaggedAccountsPath()}.bak`)).toBe(false); @@ -226,50 +226,100 @@ describe("flagged account storage", () => { ], }); - await clearFlaggedAccounts(); + await expect(clearFlaggedAccounts()).resolves.toBe(true); const flagged = await loadFlaggedAccounts(); expect(flagged.accounts).toHaveLength(0); }); - it("suppresses flagged accounts when clear cannot delete the primary file after writing the reset marker", async () => { - await saveFlaggedAccounts({ - version: 1, - accounts: [ - { - refreshToken: "stale-primary", - flaggedAt: 1, - addedAt: 1, - lastUsed: 1, - }, - ], - }); - - const flaggedPath = getFlaggedAccountsPath(); - const originalUnlink = fs.unlink.bind(fs); - const unlinkSpy = vi - .spyOn(fs, "unlink") - .mockImplementation(async (targetPath) => { - if (targetPath === flaggedPath) { - const error = new Error( - "EPERM primary delete", - ) as NodeJS.ErrnoException; - error.code = "EPERM"; - throw error; - } - return originalUnlink(targetPath); + it.each(["EPERM", "EBUSY"] as const)( + "retries transient %s when clearing flagged storage", + async (code) => { + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "stale-primary", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], }); - await expect(clearFlaggedAccounts()).rejects.toThrow( - "EPERM primary delete", - ); + const flaggedPath = getFlaggedAccountsPath(); + const realUnlink = fs.unlink.bind(fs); + let failedOnce = false; + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === flaggedPath && !failedOnce) { + failedOnce = true; + const error = new Error("locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return realUnlink(targetPath); + }); + + try { + await expect(clearFlaggedAccounts()).resolves.toBe(true); + expect(existsSync(flaggedPath)).toBe(false); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === flaggedPath, + ), + ).toHaveLength(2); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); - const flagged = await loadFlaggedAccounts(); - expect(existsSync(flaggedPath)).toBe(true); - expect(flagged.accounts).toHaveLength(0); + it.each(["EPERM", "EBUSY"] as const)( + "returns false when clearing flagged storage exhausts retryable %s failures", + async (code) => { + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "stuck-flagged", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }); - unlinkSpy.mockRestore(); - }); + const flaggedPath = getFlaggedAccountsPath(); + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === flaggedPath) { + const error = new Error( + "still locked", + ) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + const error = new Error("missing") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }); + + try { + await expect(clearFlaggedAccounts()).resolves.toBe(false); + expect(existsSync(flaggedPath)).toBe(true); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === flaggedPath, + ), + ).toHaveLength(5); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); it("does not recover flagged backups when the primary file exists but read fails", async () => { await saveFlaggedAccounts({ @@ -335,7 +385,7 @@ describe("flagged account storage", () => { const manualBackupPath = `${getFlaggedAccountsPath()}.manual-checkpoint`; await fs.copyFile(getFlaggedAccountsPath(), manualBackupPath); - await clearFlaggedAccounts(); + await expect(clearFlaggedAccounts()).resolves.toBe(true); const flagged = await loadFlaggedAccounts(); expect(existsSync(manualBackupPath)).toBe(false); @@ -383,7 +433,7 @@ describe("flagged account storage", () => { return originalUnlink(targetPath); }); - await clearFlaggedAccounts(); + await expect(clearFlaggedAccounts()).resolves.toBe(false); const flagged = await loadFlaggedAccounts(); expect(existsSync(backupPath)).toBe(true); @@ -392,45 +442,6 @@ describe("flagged account storage", () => { unlinkSpy.mockRestore(); }); - it("suppresses flagged accounts when clear cannot delete the primary file after writing the reset marker", async () => { - await saveFlaggedAccounts({ - version: 1, - accounts: [ - { - refreshToken: "stale-primary", - flaggedAt: 1, - addedAt: 1, - lastUsed: 1, - }, - ], - }); - - const flaggedPath = getFlaggedAccountsPath(); - const originalUnlink = fs.unlink.bind(fs); - const unlinkSpy = vi - .spyOn(fs, "unlink") - .mockImplementation(async (targetPath) => { - if (targetPath === flaggedPath) { - const error = new Error( - "EPERM primary delete", - ) as NodeJS.ErrnoException; - error.code = "EPERM"; - throw error; - } - return originalUnlink(targetPath); - }); - - await expect(clearFlaggedAccounts()).rejects.toThrow( - "EPERM primary delete", - ); - - const flagged = await loadFlaggedAccounts(); - expect(existsSync(flaggedPath)).toBe(true); - expect(flagged.accounts).toHaveLength(0); - - unlinkSpy.mockRestore(); - }); - it("emits snapshot metadata for flagged account backups", async () => { await saveFlaggedAccounts({ version: 1, diff --git a/test/storage.test.ts b/test/storage.test.ts index 14c13ecd..790ee247 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2,10 +2,12 @@ import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { clearQuotaCache, getQuotaCachePath } from "../lib/quota-cache.js"; import { getConfigDir, getProjectStorageKey } from "../lib/storage/paths.js"; import { buildNamedBackupPath, clearAccounts, + clearFlaggedAccounts, deduplicateAccounts, deduplicateAccountsByEmail, exportAccounts, @@ -1782,6 +1784,130 @@ describe("storage", () => { it("does not throw when file does not exist", async () => { await expect(clearAccounts()).resolves.not.toThrow(); }); + + it.each(["EPERM", "EBUSY", "EAGAIN"] as const)( + "retries transient %s when clearing saved account artifacts", + async (code) => { + await fs.writeFile(testStoragePath, "{}"); + const walPath = `${testStoragePath}.wal`; + await fs.writeFile(walPath, "{}"); + + const realUnlink = fs.unlink.bind(fs); + let failedOnce = false; + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === testStoragePath && !failedOnce) { + failedOnce = true; + const error = new Error("locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return realUnlink(targetPath); + }); + + try { + await expect(clearAccounts()).resolves.toBe(true); + expect(existsSync(testStoragePath)).toBe(false); + expect(existsSync(walPath)).toBe(false); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === testStoragePath, + ), + ).toHaveLength(2); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + }); + + describe("clearFlaggedAccounts", () => { + const testWorkDir = join( + tmpdir(), + "codex-clear-flagged-test-" + Math.random().toString(36).slice(2), + ); + let testStoragePath: string; + + beforeEach(async () => { + await fs.mkdir(testWorkDir, { recursive: true }); + testStoragePath = join(testWorkDir, "accounts.json"); + setStoragePathDirect(testStoragePath); + }); + + afterEach(async () => { + setStoragePathDirect(null); + await fs.rm(testWorkDir, { recursive: true, force: true }); + }); + + it.each(["EPERM", "EBUSY"] as const)( + "retries transient %s when clearing flagged account storage", + async (code) => { + const flaggedPath = getFlaggedAccountsPath(); + await fs.mkdir(dirname(flaggedPath), { recursive: true }); + await fs.writeFile(flaggedPath, "{}"); + + const realUnlink = fs.unlink.bind(fs); + let failedOnce = false; + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === flaggedPath && !failedOnce) { + failedOnce = true; + const error = new Error("locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return realUnlink(targetPath); + }); + + try { + await expect(clearFlaggedAccounts()).resolves.toBe(true); + expect(existsSync(flaggedPath)).toBe(false); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === flaggedPath, + ), + ).toHaveLength(2); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + + it.each(["EPERM", "EBUSY"] as const)( + "returns false when clearing flagged account storage exhausts retryable %s failures", + async (code) => { + const flaggedPath = getFlaggedAccountsPath(); + await fs.mkdir(dirname(flaggedPath), { recursive: true }); + await fs.writeFile(flaggedPath, "{}"); + + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === flaggedPath) { + const error = new Error("still locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + const error = new Error("missing") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }); + + try { + await expect(clearFlaggedAccounts()).resolves.toBe(false); + expect(existsSync(flaggedPath)).toBe(true); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === flaggedPath, + ), + ).toHaveLength(5); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); }); describe("setStoragePath", () => { @@ -1943,6 +2069,37 @@ describe("storage", () => { await expect(clearAccounts()).resolves.not.toThrow(); }); + + it.each(["EPERM", "EBUSY"] as const)( + "returns false when clearing saved accounts exhausts retryable %s failures", + async (code) => { + await fs.writeFile(testStoragePath, "{}"); + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === testStoragePath) { + const error = new Error("still locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + const error = new Error("missing") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }); + + try { + await expect(clearAccounts()).resolves.toBe(false); + expect(existsSync(testStoragePath)).toBe(true); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === testStoragePath, + ), + ).toHaveLength(5); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); }); describe("StorageError with cause", () => { @@ -2356,6 +2513,36 @@ describe("storage", () => { expect(existsSync(legacyWorktreePath)).toBe(false); }); + it("clearAccounts removes legacy project and worktree account files for linked worktrees", async () => { + const { worktreeRepo } = await prepareWorktreeFixture(); + + setStoragePath(worktreeRepo); + const canonicalPath = getStoragePath(); + const legacyProjectPath = join(worktreeRepo, ".codex", "openai-codex-accounts.json"); + const legacyWorktreePath = join( + getConfigDir(), + "projects", + getProjectStorageKey(worktreeRepo), + "openai-codex-accounts.json", + ); + const storage = buildStorage([accountFromLegacy]); + + await fs.mkdir(dirname(canonicalPath), { recursive: true }); + await fs.mkdir(dirname(legacyProjectPath), { recursive: true }); + await fs.mkdir(dirname(legacyWorktreePath), { recursive: true }); + await Promise.all([ + fs.writeFile(canonicalPath, JSON.stringify(storage), "utf-8"), + fs.writeFile(legacyProjectPath, JSON.stringify(storage), "utf-8"), + fs.writeFile(legacyWorktreePath, JSON.stringify(storage), "utf-8"), + ]); + + await expect(clearAccounts()).resolves.toBe(true); + + expect(existsSync(canonicalPath)).toBe(false); + expect(existsSync(legacyProjectPath)).toBe(false); + expect(existsSync(legacyWorktreePath)).toBe(false); + }); + it("keeps legacy worktree file when migration persist fails", async () => { const { worktreeRepo } = await prepareWorktreeFixture(); @@ -2642,6 +2829,37 @@ describe("storage", () => { renameSpy.mockRestore(); }); + it("retries on EAGAIN and cleans up the WAL after rename succeeds", async () => { + const now = Date.now(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + const walPath = `${testStoragePath}.wal`; + + const originalRename = fs.rename.bind(fs); + let attemptCount = 0; + const renameSpy = vi + .spyOn(fs, "rename") + .mockImplementation(async (oldPath, newPath) => { + attemptCount++; + if (attemptCount === 1) { + const err = new Error("EAGAIN error") as NodeJS.ErrnoException; + err.code = "EAGAIN"; + throw err; + } + return originalRename(oldPath as string, newPath as string); + }); + + await saveAccounts(storage); + expect(attemptCount).toBe(2); + expect(existsSync(testStoragePath)).toBe(true); + expect(existsSync(walPath)).toBe(false); + + renameSpy.mockRestore(); + }); + it("throws after 5 failed EPERM retries", async () => { const now = Date.now(); const storage = { @@ -3095,12 +3313,111 @@ describe("storage", () => { Object.assign(new Error("EACCES error"), { code: "EACCES" }), ); - await clearAccounts(); + await expect(clearAccounts()).resolves.toBe(false); expect(unlinkSpy).toHaveBeenCalled(); unlinkSpy.mockRestore(); }); }); + + describe("clearQuotaCache", () => { + const tmpRoot = join( + tmpdir(), + `quota-cache-test-${Math.random().toString(36).slice(2)}`, + ); + let originalDir: string | undefined; + + beforeEach(async () => { + originalDir = process.env.CODEX_MULTI_AUTH_DIR; + process.env.CODEX_MULTI_AUTH_DIR = tmpRoot; + await fs.mkdir(tmpRoot, { recursive: true }); + }); + + afterEach(async () => { + if (originalDir === undefined) delete process.env.CODEX_MULTI_AUTH_DIR; + else process.env.CODEX_MULTI_AUTH_DIR = originalDir; + await fs.rm(tmpRoot, { recursive: true, force: true }); + }); + + it("removes only the quota cache file", async () => { + const quotaPath = getQuotaCachePath(); + const accountsPath = join(tmpRoot, "openai-codex-accounts.json"); + await fs.mkdir(dirname(quotaPath), { recursive: true }); + await fs.writeFile(quotaPath, "{}", "utf-8"); + await fs.writeFile(accountsPath, "{}", "utf-8"); + + expect(existsSync(quotaPath)).toBe(true); + expect(existsSync(accountsPath)).toBe(true); + + await expect(clearQuotaCache()).resolves.toBe(true); + + expect(existsSync(quotaPath)).toBe(false); + expect(existsSync(accountsPath)).toBe(true); + }); + + it("ignores missing quota cache file", async () => { + await expect(clearQuotaCache()).resolves.toBe(true); + }); + + it.each(["EPERM", "EBUSY"] as const)( + "retries transient %s when clearing the quota cache", + async (code) => { + const quotaPath = getQuotaCachePath(); + await fs.mkdir(dirname(quotaPath), { recursive: true }); + await fs.writeFile(quotaPath, "{}", "utf-8"); + + const realUnlink = fs.unlink.bind(fs); + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (target) => { + if (target === quotaPath && unlinkSpy.mock.calls.length === 1) { + const err = new Error("locked") as NodeJS.ErrnoException; + err.code = code; + throw err; + } + return realUnlink(target); + }); + + try { + await expect(clearQuotaCache()).resolves.toBe(true); + expect(existsSync(quotaPath)).toBe(false); + expect(unlinkSpy).toHaveBeenCalledTimes(2); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + + it.each(["EPERM", "EBUSY"] as const)( + "returns false when quota-cache clear exhausts retryable %s failures", + async (code) => { + const quotaPath = getQuotaCachePath(); + await fs.mkdir(dirname(quotaPath), { recursive: true }); + await fs.writeFile(quotaPath, "{}", "utf-8"); + + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (target) => { + if (target === quotaPath) { + const err = new Error("still locked") as NodeJS.ErrnoException; + err.code = code; + throw err; + } + const err = new Error("missing") as NodeJS.ErrnoException; + err.code = "ENOENT"; + throw err; + }); + + try { + await expect(clearQuotaCache()).resolves.toBe(false); + expect(existsSync(quotaPath)).toBe(true); + expect(unlinkSpy).toHaveBeenCalledTimes(5); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + }); }); it("clearAccounts removes discovered backup artifacts as well as fixed slots", async () => { From f48ee06e4a6703baa11cafbceb474c2a31d2fdf0 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 23:23:17 +0800 Subject: [PATCH 02/13] feat(auth): add backup restore manager --- docs/reference/commands.md | 2 + docs/reference/public-api.md | 6 + docs/reference/storage-paths.md | 14 + lib/cli.ts | 11 + lib/codex-manager.ts | 550 ++++++-- lib/storage.ts | 789 ++++++++++- lib/ui/auth-menu.ts | 13 + lib/ui/copy.ts | 25 +- test/cli.test.ts | 24 + test/codex-manager-cli.test.ts | 2008 ++++++++++++++++++++++++++-- test/storage.test.ts | 2218 ++++++++++++++++++++++++++++++- 11 files changed, 5377 insertions(+), 283 deletions(-) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 36c735f9..f7a9cf0a 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -26,6 +26,7 @@ Compatibility aliases are supported: | `codex auth switch ` | Set active account by index | | `codex auth check` | Run quick account health check | | `codex auth features` | Print implemented feature summary | +| `codex auth restore-backup` | Open the backup restore picker directly | --- @@ -111,6 +112,7 @@ codex auth report --live --json Repair and recovery: ```bash +codex auth restore-backup codex auth fix --dry-run codex auth fix --live --model gpt-5-codex codex auth doctor --fix diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index 865189ff..a76eb85a 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -37,6 +37,12 @@ Compatibility policy for Tier B: - Existing exported symbols must not be removed in this release line. - Deprecated usage may be documented, but hard removals require a major version transition plan. +Current additive compatibility note: + +- `importAccounts()` now returns `{ imported, total, skipped, changed }` at runtime. +- The exported `ImportAccountsResult` type keeps `changed` optional so older callers modeling the legacy shape remain source-compatible. +- New callers should read `changed` to distinguish duplicate-only no-ops from metadata-refresh writes. + ### Tier C: Internal APIs Internal APIs are any non-exported internals and implementation details not covered by Tier A or Tier B. diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index 186ab1f5..8ea743ec 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -22,6 +22,7 @@ Override root: | --- | --- | | Unified settings | `~/.codex/multi-auth/settings.json` | | Accounts | `~/.codex/multi-auth/openai-codex-accounts.json` | +| Named backups | `~/.codex/multi-auth/backups/.json` | | 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` | @@ -56,6 +57,7 @@ Backup metadata: When project-scoped behavior is enabled: - `~/.codex/multi-auth/projects//openai-codex-accounts.json` +- `~/.codex/multi-auth/projects//backups/.json` `` is derived as: @@ -100,6 +102,17 @@ Rules: - `.rotate.`, `.tmp`, and `.wal` names are rejected - existing files are not overwritten unless a lower-level force path is used explicitly +Restore workflow: + +1. Run `codex auth login`. +2. Open the `Recovery` section. +3. Choose `Restore From Backup`. +4. Pick a backup and confirm the merge summary before import. + +Direct entrypoint: + +- Run `codex auth restore-backup` to open the same picker without entering the full login dashboard first. + --- ## oc-chatgpt Target Paths @@ -115,6 +128,7 @@ Experimental sync targets the companion `oc-chatgpt-multi-auth` storage layout: ## Verification Commands ```bash +codex auth login codex auth status codex auth list ``` diff --git a/lib/cli.ts b/lib/cli.ts index 363b1b2b..67c304db 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -57,6 +57,7 @@ export type LoginMode = | "check" | "deep-check" | "verify-flagged" + | "restore-backup" | "cancel"; export interface ExistingAccountInfo { @@ -233,6 +234,14 @@ async function promptLoginModeFallback( ) { return { mode: "verify-flagged" }; } + if ( + normalized === "u" || + normalized === "backup" || + normalized === "restore" || + normalized === "restore-backup" + ) { + return { mode: "restore-backup" }; + } if (normalized === "q" || normalized === "quit") return { mode: "cancel" }; console.log(UI_COPY.fallback.invalidModePrompt); @@ -287,6 +296,8 @@ export async function promptLoginMode( return { mode: "deep-check" }; case "verify-flagged": return { mode: "verify-flagged" }; + case "restore-backup": + return { mode: "restore-backup" }; case "select-account": { const accountAction = await showAccountDetails(action.account); if (accountAction === "delete") { diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 0f4667b8..0999cc29 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -56,6 +56,13 @@ import { type QuotaCacheEntry, } from "./quota-cache.js"; import { + assessNamedBackupRestore, + getNamedBackupsDirectoryPath, + isNamedBackupContainmentError, + isNamedBackupPathValidationTransientError, + listNamedBackups, + NAMED_BACKUP_ASSESS_CONCURRENCY, + restoreAssessedNamedBackup, findMatchingAccountIndex, getStoragePath, loadFlaggedAccounts, @@ -77,6 +84,7 @@ import { } from "./codex-cli/state.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { ANSI } from "./ui/ansi.js"; +import { confirm } from "./ui/confirm.js"; import { UI_COPY } from "./ui/copy.js"; import { paintUiText, quotaToneFromLeftPercent } from "./ui/format.js"; import { getUiRuntimeOptions } from "./ui/runtime.js"; @@ -125,6 +133,19 @@ function formatReasonLabel(reason: string | undefined): string | undefined { return normalized.length > 0 ? normalized : undefined; } +function formatRelativeDateShort( + timestamp: number | null | undefined, +): string | null { + if (timestamp === null || timestamp === undefined || timestamp === 0) + return null; + if (!Number.isFinite(timestamp)) return null; + const days = Math.floor((Date.now() - timestamp) / 86_400_000); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 7) return `${days}d ago`; + return new Date(timestamp).toLocaleDateString(); +} + function extractErrorMessageFromPayload(payload: unknown): string | undefined { if (!payload || typeof payload !== "object") return undefined; const record = payload as Record; @@ -308,6 +329,7 @@ function printUsage(): void { " codex auth report [--live] [--json] [--model ] [--out ]", " codex auth fix [--dry-run] [--json] [--live] [--model ]", " codex auth doctor [--json] [--fix] [--dry-run]", + " codex auth restore-backup", "", "Notes:", " - Uses ~/.codex/multi-auth/openai-codex-accounts.json", @@ -3805,161 +3827,179 @@ async function runAuthLogin(): Promise { let menuQuotaRefreshStatus: string | undefined; loginFlow: while (true) { - let existingStorage = await loadAccounts(); - if (existingStorage && existingStorage.accounts.length > 0) { - while (true) { - existingStorage = await loadAccounts(); - if (!existingStorage || existingStorage.accounts.length === 0) { - break; - } - const currentStorage = existingStorage; - const displaySettings = await loadDashboardDisplaySettings(); - applyUiThemeFromDashboardSettings(displaySettings); - const quotaCache = await loadQuotaCache(); - const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true; - const showFetchStatus = displaySettings.menuShowFetchStatus ?? true; - const quotaTtlMs = displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; - if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) { - const staleCount = countMenuQuotaRefreshTargets(currentStorage, quotaCache, quotaTtlMs); - if (staleCount > 0) { - if (showFetchStatus) { - menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`; - } - pendingMenuQuotaRefresh = refreshQuotaCacheForMenu( - currentStorage, - quotaCache, - quotaTtlMs, - (current, total) => { - if (!showFetchStatus) return; - menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`; - }, - ) - .then(() => undefined) - .catch(() => undefined) - .finally(() => { - menuQuotaRefreshStatus = undefined; - pendingMenuQuotaRefresh = null; - }); + while (true) { + const existingStorage = await loadAccounts(); + const currentStorage = existingStorage ?? createEmptyAccountStorage(); + const displaySettings = await loadDashboardDisplaySettings(); + applyUiThemeFromDashboardSettings(displaySettings); + const quotaCache = await loadQuotaCache(); + const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true; + const showFetchStatus = displaySettings.menuShowFetchStatus ?? true; + const quotaTtlMs = displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; + if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) { + const staleCount = countMenuQuotaRefreshTargets(currentStorage, quotaCache, quotaTtlMs); + if (staleCount > 0) { + if (showFetchStatus) { + menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`; } + pendingMenuQuotaRefresh = refreshQuotaCacheForMenu( + currentStorage, + quotaCache, + quotaTtlMs, + (current, total) => { + if (!showFetchStatus) return; + menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`; + }, + ) + .then(() => undefined) + .catch(() => undefined) + .finally(() => { + menuQuotaRefreshStatus = undefined; + pendingMenuQuotaRefresh = null; + }); } - const flaggedStorage = await loadFlaggedAccounts(); + } + const flaggedStorage = await loadFlaggedAccounts(); - const menuResult = await promptLoginMode( - toExistingAccountInfo(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, + }, + ); - if (menuResult.mode === "cancel") { - console.log("Cancelled."); - return 0; - } - if (menuResult.mode === "check") { - await runActionPanel("Quick Check", "Checking local session + live status", async () => { - await runHealthCheck({ forceRefresh: false, liveProbe: true }); - }, displaySettings); - continue; - } - if (menuResult.mode === "deep-check") { - await runActionPanel("Deep Check", "Refreshing and testing all accounts", async () => { - await runHealthCheck({ forceRefresh: true, liveProbe: true }); - }, displaySettings); - continue; - } - if (menuResult.mode === "forecast") { - await runActionPanel("Best Account", "Comparing accounts", async () => { - await runForecast(["--live"]); - }, displaySettings); - continue; + if (menuResult.mode === "cancel") { + console.log("Cancelled."); + return 0; + } + const modeRequiresDrainedQuotaRefresh = + menuResult.mode === "check" || + menuResult.mode === "deep-check" || + menuResult.mode === "forecast" || + menuResult.mode === "fix" || + menuResult.mode === "restore-backup"; + if (modeRequiresDrainedQuotaRefresh) { + const pendingQuotaRefresh = pendingMenuQuotaRefresh; + if (pendingQuotaRefresh) { + await pendingQuotaRefresh; } - if (menuResult.mode === "fix") { - await runActionPanel("Auto-Fix", "Checking and fixing common issues", async () => { - await runFix(["--live"]); - }, displaySettings); - continue; + } + if (menuResult.mode === "check") { + await runActionPanel("Quick Check", "Checking local session + live status", async () => { + await runHealthCheck({ forceRefresh: false, liveProbe: true }); + }, displaySettings); + continue; + } + if (menuResult.mode === "deep-check") { + await runActionPanel("Deep Check", "Refreshing and testing all accounts", async () => { + await runHealthCheck({ forceRefresh: true, liveProbe: true }); + }, displaySettings); + continue; + } + if (menuResult.mode === "forecast") { + await runActionPanel("Best Account", "Comparing accounts", async () => { + await runForecast(["--live"]); + }, displaySettings); + continue; + } + if (menuResult.mode === "fix") { + await runActionPanel("Auto-Fix", "Checking and fixing common issues", async () => { + await runFix(["--live"]); + }, displaySettings); + continue; + } + if (menuResult.mode === "settings") { + await configureUnifiedSettings(displaySettings); + continue; + } + if (menuResult.mode === "verify-flagged") { + await runActionPanel("Problem Account Check", "Checking problem accounts", async () => { + await runVerifyFlagged([]); + }, displaySettings); + continue; + } + if (menuResult.mode === "restore-backup") { + try { + await runBackupRestoreManager(displaySettings); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + console.error( + `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, + ); } - if (menuResult.mode === "settings") { - await configureUnifiedSettings(displaySettings); + continue; + } + if (menuResult.mode === "fresh" && menuResult.deleteAll) { + if (destructiveActionInFlight) { + console.log("Another destructive action is already running. Wait for it to finish."); continue; } - if (menuResult.mode === "verify-flagged") { - await runActionPanel("Problem Account Check", "Checking problem accounts", async () => { - await runVerifyFlagged([]); - }, displaySettings); - continue; + destructiveActionInFlight = true; + try { + await runActionPanel( + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.label, + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.stage, + async () => { + const result = await deleteSavedAccounts(); + console.log( + result.accountsCleared + ? DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.completed + : "Delete saved accounts completed with warnings. Some saved account artifacts could not be removed; see logs.", + ); + }, + displaySettings, + ); + } finally { + destructiveActionInFlight = false; } - if (menuResult.mode === "fresh" && menuResult.deleteAll) { - if (destructiveActionInFlight) { - console.log("Another destructive action is already running. Wait for it to finish."); - continue; - } - destructiveActionInFlight = true; - try { - await runActionPanel( - DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.label, - DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.stage, - async () => { - const result = await deleteSavedAccounts(); - console.log( - result.accountsCleared - ? DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.completed - : "Delete saved accounts completed with warnings. Some saved account artifacts could not be removed; see logs.", - ); - }, - displaySettings, - ); - } finally { - destructiveActionInFlight = false; - } + continue; + } + if (menuResult.mode === "reset") { + if (destructiveActionInFlight) { + console.log("Another destructive action is already running. Wait for it to finish."); continue; } - if (menuResult.mode === "reset") { - if (destructiveActionInFlight) { - console.log("Another destructive action is already running. Wait for it to finish."); - continue; - } - destructiveActionInFlight = true; - try { - await runActionPanel( - DESTRUCTIVE_ACTION_COPY.resetLocalState.label, - DESTRUCTIVE_ACTION_COPY.resetLocalState.stage, - async () => { - const pendingQuotaRefresh = pendingMenuQuotaRefresh; - if (pendingQuotaRefresh) { - await pendingQuotaRefresh; - } - const result = await resetLocalState(); - console.log( - result.accountsCleared && - result.flaggedCleared && - result.quotaCacheCleared - ? DESTRUCTIVE_ACTION_COPY.resetLocalState.completed - : "Reset local state completed with warnings. Some local artifacts could not be removed; see logs.", - ); - }, - displaySettings, - ); - } finally { - destructiveActionInFlight = false; - } - continue; + destructiveActionInFlight = true; + try { + await runActionPanel( + DESTRUCTIVE_ACTION_COPY.resetLocalState.label, + DESTRUCTIVE_ACTION_COPY.resetLocalState.stage, + async () => { + const pendingQuotaRefresh = pendingMenuQuotaRefresh; + if (pendingQuotaRefresh) { + await pendingQuotaRefresh; + } + const result = await resetLocalState(); + console.log( + result.accountsCleared && + result.flaggedCleared && + result.quotaCacheCleared + ? DESTRUCTIVE_ACTION_COPY.resetLocalState.completed + : "Reset local state completed with warnings. Some local artifacts could not be removed; see logs.", + ); + }, + displaySettings, + ); + } finally { + destructiveActionInFlight = false; } - if (menuResult.mode === "manage") { - const requiresInteractiveOAuth = typeof menuResult.refreshAccountIndex === "number"; - if (requiresInteractiveOAuth) { - await handleManageAction(currentStorage, menuResult); - continue; - } - await runActionPanel("Applying Change", "Updating selected account", async () => { - await handleManageAction(currentStorage, menuResult); - }, displaySettings); + continue; + } + if (menuResult.mode === "manage") { + const requiresInteractiveOAuth = typeof menuResult.refreshAccountIndex === "number"; + if (requiresInteractiveOAuth) { + await handleManageAction(currentStorage, menuResult); continue; } - if (menuResult.mode === "add") { - break; - } + await runActionPanel("Applying Change", "Updating selected account", async () => { + await handleManageAction(currentStorage, menuResult); + }, displaySettings); + continue; + } + if (menuResult.mode === "add") { + break; } } @@ -4173,6 +4213,220 @@ export async function autoSyncActiveAccountToCodex(): Promise { }); } +type BackupMenuAction = + | { + type: "restore"; + assessment: Awaited>; + } + | { type: "back" }; + +async function runBackupRestoreManager( + displaySettings: DashboardDisplaySettings, +): Promise { + const backupDir = getNamedBackupsDirectoryPath(); + // Reuse only within this list -> assess flow so storage.ts can safely treat + // the cache contents as LoadedBackupCandidate entries. + const candidateCache = new Map(); + let backups: Awaited>; + try { + backups = await listNamedBackups({ candidateCache }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isNamedBackupContainmentError(error)) { + console.error( + `Backup validation failed: ${collapseWhitespace(message) || "unknown error"}`, + ); + } else if (isNamedBackupPathValidationTransientError(error)) { + console.error(collapseWhitespace(message) || "unknown error"); + } else { + console.error( + `Could not read backup directory: ${ + collapseWhitespace(message) || "unknown error" + }`, + ); + } + return false; + } + if (backups.length === 0) { + console.log(`No named backups found. Place backup files in ${backupDir}.`); + return true; + } + + const currentStorage = await loadAccounts(); + const assessments: Awaited>[] = []; + const assessmentFailures: string[] = []; + for ( + let index = 0; + index < backups.length; + index += NAMED_BACKUP_ASSESS_CONCURRENCY + ) { + const chunk = backups.slice(index, index + NAMED_BACKUP_ASSESS_CONCURRENCY); + const settledAssessments = await Promise.allSettled( + chunk.map((backup) => + assessNamedBackupRestore(backup.name, { + currentStorage, + candidateCache, + }), + ), + ); + for (const [resultIndex, result] of settledAssessments.entries()) { + if (result.status === "fulfilled") { + assessments.push(result.value); + continue; + } + if (isNamedBackupContainmentError(result.reason)) { + throw result.reason; + } + const backupName = chunk[resultIndex]?.name ?? "unknown"; + const reason = + result.reason instanceof Error + ? result.reason.message + : String(result.reason); + const normalizedReason = + collapseWhitespace(reason) || "unknown error"; + assessmentFailures.push(`${backupName}: ${normalizedReason}`); + console.warn( + `Skipped backup assessment for "${backupName}": ${normalizedReason}`, + ); + } + } + if (assessments.length === 0) { + console.error( + `Could not assess any named backups in ${backupDir}: ${ + assessmentFailures.join("; ") || "all assessments failed" + }`, + ); + return false; + } + + const items: MenuItem[] = assessments.map((assessment) => { + const status = + assessment.eligibleForRestore + ? "ready" + : assessment.wouldExceedLimit + ? "limit" + : "invalid"; + const lastUpdated = formatRelativeDateShort(assessment.backup.updatedAt); + const parts = [ + assessment.backup.accountCount !== null + ? `${assessment.backup.accountCount} account${assessment.backup.accountCount === 1 ? "" : "s"}` + : undefined, + lastUpdated ? `updated ${lastUpdated}` : undefined, + assessment.wouldExceedLimit + ? `would exceed ${ACCOUNT_LIMITS.MAX_ACCOUNTS}` + : undefined, + assessment.error ?? assessment.backup.loadError, + ].filter( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ); + + return { + label: assessment.backup.name, + hint: parts.length > 0 ? parts.join(" | ") : undefined, + value: { type: "restore", assessment }, + color: + status === "ready" ? "green" : status === "limit" ? "red" : "yellow", + disabled: !assessment.eligibleForRestore, + }; + }); + + items.push({ label: "Back", value: { type: "back" } }); + + const ui = getUiRuntimeOptions(); + const selection = await select(items, { + message: "Restore From Backup", + subtitle: backupDir, + help: UI_COPY.mainMenu.helpCompact, + clearScreen: true, + selectedEmphasis: "minimal", + focusStyle: displaySettings.menuFocusStyle ?? "row-invert", + theme: ui.theme, + }); + + if (!selection || selection.type === "back") { + return true; + } + + let latestAssessment: Awaited>; + try { + latestAssessment = await assessNamedBackupRestore( + selection.assessment.backup.name, + { currentStorage: await loadAccounts() }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error( + `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, + ); + return false; + } + if (!latestAssessment.eligibleForRestore) { + console.log(latestAssessment.error ?? "Backup is not eligible for restore."); + return false; + } + + const netNewAccounts = latestAssessment.imported ?? 0; + const confirmMessage = UI_COPY.mainMenu.restoreBackupConfirm( + latestAssessment.backup.name, + netNewAccounts, + latestAssessment.backup.accountCount ?? 0, + latestAssessment.currentAccountCount, + latestAssessment.mergedAccountCount ?? latestAssessment.currentAccountCount, + ); + const confirmed = await confirm(confirmMessage); + if (!confirmed) return true; + + try { + const result = await restoreAssessedNamedBackup(latestAssessment); + if (!result.changed) { + console.log("All accounts in this backup already exist"); + return true; + } + if (result.imported === 0) { + console.log( + UI_COPY.mainMenu.restoreBackupRefreshSuccess( + latestAssessment.backup.name, + ), + ); + } else { + console.log( + UI_COPY.mainMenu.restoreBackupSuccess( + latestAssessment.backup.name, + result.imported, + result.skipped, + result.total, + ), + ); + } + try { + const synced = await autoSyncActiveAccountToCodex(); + if (!synced) { + console.warn( + "Backup restored, but Codex CLI auth state could not be synced.", + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + `Backup restored, but Codex CLI auth sync failed: ${ + collapseWhitespace(message) || "unknown error" + }`, + ); + } + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const collapsedMessage = collapseWhitespace(message) || "unknown error"; + console.error( + /exceed maximum/i.test(collapsedMessage) + ? `Restore failed: ${collapsedMessage}. Close other Codex instances and try again.` + : `Restore failed: ${collapsedMessage}`, + ); + return false; + } +} + export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { const startupDisplaySettings = await loadDashboardDisplaySettings(); applyUiThemeFromDashboardSettings(startupDisplaySettings); @@ -4230,6 +4484,20 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { if (command === "doctor") { return runDoctor(rest); } + if (command === "restore-backup") { + setStoragePath(null); + try { + const completedWithoutFailure = + await runBackupRestoreManager(startupDisplaySettings); + return completedWithoutFailure ? 0 : 1; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error( + `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, + ); + return 1; + } + } console.error(`Unknown command: ${command}`); printUsage(); diff --git a/lib/storage.ts b/lib/storage.ts index 3435bf44..a6f0f5ad 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,11 +1,18 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; -import { existsSync, promises as fs } from "node:fs"; -import { basename, dirname, join } from "node:path"; +import { + existsSync, + lstatSync, + promises as fs, + realpathSync, + type Dirent, +} from "node:fs"; +import { basename, dirname, isAbsolute, join, relative } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { exportNamedBackupFile, + getNamedBackupRoot, resolveNamedBackupPath, } from "./named-backup-export.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; @@ -46,6 +53,15 @@ const ACCOUNTS_WAL_SUFFIX = ".wal"; const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3; const BACKUP_COPY_MAX_ATTEMPTS = 5; const BACKUP_COPY_BASE_DELAY_MS = 10; +// Max total wait across 6 sleeps is about 1.26 s with proportional jitter. +// That's acceptable for transient AV/file-lock recovery, but it also bounds how +// long the interactive restore menu can pause while listing or assessing backups. +const TRANSIENT_FILESYSTEM_MAX_ATTEMPTS = 7; +const TRANSIENT_FILESYSTEM_BASE_DELAY_MS = 10; +export const NAMED_BACKUP_LIST_CONCURRENCY = 8; +// Each assessment does more I/O than a listing pass, so keep a lower ceiling to +// reduce transient AV/file-lock pressure on Windows restore menus. +export const NAMED_BACKUP_ASSESS_CONCURRENCY = 4; const RESET_MARKER_SUFFIX = ".reset-intent"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -114,6 +130,95 @@ export type RestoreAssessment = { backupMetadata: BackupMetadata; }; +export interface NamedBackupMetadata { + name: string; + path: string; + createdAt: number | null; + updatedAt: number | null; + sizeBytes: number | null; + version: number | null; + accountCount: number | null; + schemaErrors: string[]; + valid: boolean; + loadError?: string; +} + +export interface BackupRestoreAssessment { + backup: NamedBackupMetadata; + currentAccountCount: number; + mergedAccountCount: number | null; + imported: number | null; + // Accounts already present in current storage. Metadata-only refreshes can + // still report them here because they are merged rather than newly imported. + skipped: number | null; + wouldExceedLimit: boolean; + eligibleForRestore: boolean; + error?: string; +} + +type LoadedBackupCandidate = { + normalized: AccountStorageV3 | null; + storedVersion: unknown; + schemaErrors: string[]; + error?: string; +}; + +type NamedBackupCandidateCache = Map; + +class BackupContainmentError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "BackupContainmentError"; + } +} + +class BackupPathValidationTransientError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "BackupPathValidationTransientError"; + } +} + +function isLoadedBackupCandidate( + candidate: unknown, +): candidate is LoadedBackupCandidate { + if (!candidate || typeof candidate !== "object") { + return false; + } + const typedCandidate = candidate as { + normalized?: unknown; + storedVersion?: unknown; + schemaErrors?: unknown; + error?: unknown; + }; + const normalized = typedCandidate.normalized; + return ( + "storedVersion" in typedCandidate && + Array.isArray(typedCandidate.schemaErrors) && + (normalized === null || + (typeof normalized === "object" && + normalized !== null && + Array.isArray((normalized as { accounts?: unknown }).accounts))) && + (typedCandidate.error === undefined || + typeof typedCandidate.error === "string") + ); +} + +function getCachedNamedBackupCandidate( + candidateCache: NamedBackupCandidateCache | undefined, + backupPath: string, +): LoadedBackupCandidate | undefined { + const candidate = candidateCache?.get(backupPath); + if (candidate === undefined) { + return undefined; + } + if (isLoadedBackupCandidate(candidate)) { + return candidate; + } + candidateCache?.delete(backupPath); + return undefined; +} + /** * Custom error class for storage operations with platform-aware hints. */ @@ -168,6 +273,7 @@ let storageMutex: Promise = Promise.resolve(); const transactionSnapshotContext = new AsyncLocalStorage<{ snapshot: AccountStorageV3 | null; active: boolean; + storagePath: string; }>(); function withStorageLock(fn: () => Promise): Promise { @@ -1551,6 +1657,249 @@ export async function getRestoreAssessment(): Promise { }; } +export async function listNamedBackups( + options: { candidateCache?: Map } = {}, +): Promise { + const backupRoot = getNamedBackupRoot(getStoragePath()); + const candidateCache = options.candidateCache; + try { + const entries = await retryTransientFilesystemOperation(() => + fs.readdir(backupRoot, { withFileTypes: true }), + ); + const backupEntries = entries + .filter((entry) => entry.isFile()) + .filter((entry) => entry.name.toLowerCase().endsWith(".json")); + const backups: NamedBackupMetadata[] = []; + let transientValidationError: BackupPathValidationTransientError | undefined; + for ( + let index = 0; + index < backupEntries.length; + index += NAMED_BACKUP_LIST_CONCURRENCY + ) { + const chunk = backupEntries.slice( + index, + index + NAMED_BACKUP_LIST_CONCURRENCY, + ); + const chunkResults = await Promise.allSettled( + chunk.map(async (entry) => { + const path = assertNamedBackupRestorePath( + resolvePath(join(backupRoot, entry.name)), + backupRoot, + ); + const candidate = await loadBackupCandidate(path); + candidateCache?.set(path, candidate); + return buildNamedBackupMetadata( + entry.name.slice(0, -".json".length), + path, + { candidate }, + ); + }), + ); + for (const [chunkIndex, result] of chunkResults.entries()) { + if (result.status === "fulfilled") { + backups.push(result.value); + continue; + } + if (isNamedBackupContainmentError(result.reason)) { + throw result.reason; + } + if ( + !transientValidationError && + isNamedBackupPathValidationTransientError(result.reason) + ) { + transientValidationError = result.reason; + } + log.warn("Skipped named backup during listing", { + path: join(backupRoot, chunk[chunkIndex]?.name ?? ""), + error: String(result.reason), + }); + } + } + if (backups.length === 0 && transientValidationError) { + throw transientValidationError; + } + return backups.sort((left, right) => { + // Treat epoch (0), null, and non-finite mtimes as "unknown" so the + // sort order matches the restore hints, which also suppress them. + const leftUpdatedAt = left.updatedAt; + const leftTime = + typeof leftUpdatedAt === "number" && + Number.isFinite(leftUpdatedAt) && + leftUpdatedAt !== 0 + ? leftUpdatedAt + : 0; + const rightUpdatedAt = right.updatedAt; + const rightTime = + typeof rightUpdatedAt === "number" && + Number.isFinite(rightUpdatedAt) && + rightUpdatedAt !== 0 + ? rightUpdatedAt + : 0; + return rightTime - leftTime; + }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return []; + } + log.warn("Failed to list named backups", { + path: backupRoot, + error: String(error), + }); + throw error; + } +} + +function isRetryableFilesystemErrorCode( + code: string | undefined, +): code is "EPERM" | "EBUSY" | "EAGAIN" { + if (code === "EAGAIN") { + return true; + } + if (process.platform !== "win32") { + return false; + } + return code === "EPERM" || code === "EBUSY"; +} + +async function retryTransientFilesystemOperation( + operation: () => Promise, +): Promise { + let attempt = 0; + while (true) { + try { + return await operation(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + !isRetryableFilesystemErrorCode(code) || + attempt >= TRANSIENT_FILESYSTEM_MAX_ATTEMPTS - 1 + ) { + throw error; + } + const baseDelayMs = TRANSIENT_FILESYSTEM_BASE_DELAY_MS * 2 ** attempt; + const jitterMs = Math.floor(Math.random() * baseDelayMs); + await new Promise((resolve) => + setTimeout(resolve, baseDelayMs + jitterMs), + ); + } + attempt += 1; + } +} + +export function getNamedBackupsDirectoryPath(): string { + return getNamedBackupRoot(getStoragePath()); +} + +export async function createNamedBackup( + name: string, + options: { force?: boolean } = {}, +): Promise { + const backupPath = await exportNamedBackup(name, options); + const candidate = await loadBackupCandidate(backupPath); + return buildNamedBackupMetadata( + basename(backupPath).slice(0, -".json".length), + backupPath, + { candidate }, + ); +} + +export async function assessNamedBackupRestore( + name: string, + options: { + currentStorage?: AccountStorageV3 | null; + candidateCache?: Map; + } = {}, +): Promise { + const backupPath = await resolveNamedBackupRestorePath(name); + const candidateCache = options.candidateCache; + const candidate = + getCachedNamedBackupCandidate(candidateCache, backupPath) ?? + (await loadBackupCandidate(backupPath)); + candidateCache?.delete(backupPath); + const backup = await buildNamedBackupMetadata( + basename(backupPath).slice(0, -".json".length), + backupPath, + { candidate }, + ); + const currentStorage = + options.currentStorage !== undefined + ? options.currentStorage + : await loadAccounts(); + const currentAccounts = currentStorage?.accounts ?? []; + // Baseline merge math on a deduplicated current snapshot so pre-existing + // duplicate rows in storage cannot produce negative import counts. + const currentDeduplicatedAccounts = deduplicateAccounts([...currentAccounts]); + + if (!candidate.normalized || !backup.accountCount || backup.accountCount <= 0) { + return { + backup, + currentAccountCount: currentAccounts.length, + mergedAccountCount: null, + imported: null, + skipped: null, + wouldExceedLimit: false, + eligibleForRestore: false, + error: backup.loadError ?? "Backup is empty or invalid", + }; + } + + const incomingDeduplicatedAccounts = deduplicateAccounts([ + ...candidate.normalized.accounts, + ]); + const mergedAccounts = deduplicateAccounts([ + ...currentDeduplicatedAccounts, + ...incomingDeduplicatedAccounts, + ]); + const wouldExceedLimit = mergedAccounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS; + const imported = wouldExceedLimit + ? null + : mergedAccounts.length - currentDeduplicatedAccounts.length; + const skipped = wouldExceedLimit + ? null + : Math.max(0, incomingDeduplicatedAccounts.length - (imported ?? 0)); + const changed = !haveEquivalentAccountRows( + mergedAccounts, + currentDeduplicatedAccounts, + ); + + return { + backup, + currentAccountCount: currentAccounts.length, + mergedAccountCount: mergedAccounts.length, + imported, + skipped, + wouldExceedLimit, + eligibleForRestore: !wouldExceedLimit && changed, + error: wouldExceedLimit + ? `Restore would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` + : !changed + ? "All accounts in this backup already exist" + : undefined, + }; +} + +export async function restoreNamedBackup( + name: string, +): Promise { + const assessment = await assessNamedBackupRestore(name); + return restoreAssessedNamedBackup(assessment); +} + +export async function restoreAssessedNamedBackup( + assessment: Pick, +): Promise { + if (!assessment.eligibleForRestore) { + throw new Error( + assessment.error ?? "Backup is not eligible for restore.", + ); + } + const resolvedPath = await resolveNamedBackupRestorePath( + assessment.backup.name, + ); + return importAccounts(resolvedPath); +} + function parseAndNormalizeStorage(data: unknown): { normalized: AccountStorageV3 | null; storedVersion: unknown; @@ -1564,6 +1913,70 @@ function parseAndNormalizeStorage(data: unknown): { return { normalized, storedVersion, schemaErrors }; } +export type ImportAccountsResult = { + imported: number; + total: number; + skipped: number; + // Runtime always includes this field; it stays optional in the public type so + // older compatibility callers that only model the legacy shape do not break. + changed?: boolean; +}; + +function normalizeStoragePathForComparison(path: string): string { + const resolved = resolvePath(path); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function canonicalizeComparisonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => canonicalizeComparisonValue(entry)); + } + if (!value || typeof value !== "object") { + return value; + } + + const record = value as Record; + return Object.fromEntries( + Object.keys(record) + .sort() + .map((key) => [key, canonicalizeComparisonValue(record[key])] as const), + ); +} + +function stableStringifyForComparison(value: unknown): string { + return JSON.stringify(canonicalizeComparisonValue(value)); +} + +function haveEquivalentAccountRows( + left: readonly unknown[], + right: readonly unknown[], +): boolean { + // deduplicateAccounts() keeps the last occurrence of duplicates, so incoming + // rows win when we compare merged restore data against the current snapshot. + // That keeps index-aligned comparison correct for restore no-op detection. + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if ( + stableStringifyForComparison(left[index]) !== + stableStringifyForComparison(right[index]) + ) { + return false; + } + } + return true; +} + +const namedBackupContainmentFs = { + lstat(path: string) { + return lstatSync(path); + }, + realpath(path: string) { + return realpathSync.native(path); + }, +}; + async function loadAccountsFromPath(path: string): Promise<{ normalized: AccountStorageV3 | null; storedVersion: unknown; @@ -1574,6 +1987,234 @@ async function loadAccountsFromPath(path: string): Promise<{ return parseAndNormalizeStorage(data); } +async function loadBackupCandidate(path: string): Promise { + try { + return await retryTransientFilesystemOperation(() => + loadAccountsFromPath(path), + ); + } catch (error) { + const errorMessage = + error instanceof SyntaxError + ? `Invalid JSON in import file: ${path}` + : (error as NodeJS.ErrnoException).code === "ENOENT" + ? `Import file not found: ${path}` + : error instanceof Error + ? error.message + : String(error); + return { + normalized: null, + storedVersion: undefined, + schemaErrors: [], + error: errorMessage, + }; + } +} + +function equalsNamedBackupEntry(left: string, right: string): boolean { + return process.platform === "win32" + ? left.toLowerCase() === right.toLowerCase() + : left === right; +} + +function stripNamedBackupJsonExtension(name: string): string { + return name.toLowerCase().endsWith(".json") + ? name.slice(0, -".json".length) + : name; +} + +async function findExistingNamedBackupPath( + name: string, +): Promise { + const requested = (name ?? "").trim(); + if (!requested) { + return undefined; + } + + const backupRoot = getNamedBackupRoot(getStoragePath()); + const requestedWithExtension = requested.toLowerCase().endsWith(".json") + ? requested + : `${requested}.json`; + const requestedBaseName = stripNamedBackupJsonExtension(requestedWithExtension); + let entries: Dirent[]; + + try { + entries = await retryTransientFilesystemOperation(() => + fs.readdir(backupRoot, { withFileTypes: true }), + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return undefined; + } + log.warn("Failed to read named backup directory", { + path: backupRoot, + error: String(error), + }); + throw error; + } + + for (const entry of entries) { + if (!entry.name.toLowerCase().endsWith(".json")) continue; + const entryBaseName = stripNamedBackupJsonExtension(entry.name); + const matchesRequestedEntry = + equalsNamedBackupEntry(entry.name, requested) || + equalsNamedBackupEntry(entry.name, requestedWithExtension) || + equalsNamedBackupEntry(entryBaseName, requestedBaseName); + if (!matchesRequestedEntry) { + continue; + } + if (entry.isSymbolicLink() || !entry.isFile()) { + throw new Error( + `Named backup "${entryBaseName}" is not a regular backup file`, + ); + } + return resolvePath(join(backupRoot, entry.name)); + } + + return undefined; +} + +function resolvePathForNamedBackupContainment(path: string): string { + const resolvedPath = resolvePath(path); + let existingPrefix = resolvedPath; + const unresolvedSegments: string[] = []; + while (true) { + try { + namedBackupContainmentFs.lstat(existingPrefix); + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + const parentPath = dirname(existingPrefix); + if (parentPath === existingPrefix) { + return resolvedPath; + } + unresolvedSegments.unshift(basename(existingPrefix)); + existingPrefix = parentPath; + continue; + } + if (isRetryableFilesystemErrorCode(code)) { + throw new BackupPathValidationTransientError( + "Backup path validation failed. Try again.", + { cause: error instanceof Error ? error : undefined }, + ); + } + throw error; + } + } + try { + const canonicalPrefix = namedBackupContainmentFs.realpath(existingPrefix); + return unresolvedSegments.reduce( + (currentPath, segment) => join(currentPath, segment), + canonicalPrefix, + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return resolvedPath; + } + if (isRetryableFilesystemErrorCode(code)) { + throw new BackupPathValidationTransientError( + "Backup path validation failed. Try again.", + { cause: error instanceof Error ? error : undefined }, + ); + } + throw error; + } +} + +export function assertNamedBackupRestorePath( + path: string, + backupRoot: string, +): string { + const resolvedPath = resolvePath(path); + const resolvedBackupRoot = resolvePath(backupRoot); + let backupRootIsSymlink = false; + try { + backupRootIsSymlink = + namedBackupContainmentFs.lstat(resolvedBackupRoot).isSymbolicLink(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + backupRootIsSymlink = false; + } else if (isRetryableFilesystemErrorCode(code)) { + throw new BackupPathValidationTransientError( + "Backup path validation failed. Try again.", + { cause: error instanceof Error ? error : undefined }, + ); + } else { + throw error; + } + } + if (backupRootIsSymlink) { + throw new BackupContainmentError("Backup path escapes backup directory"); + } + const canonicalBackupRoot = + resolvePathForNamedBackupContainment(resolvedBackupRoot); + const containedPath = resolvePathForNamedBackupContainment(resolvedPath); + const relativePath = relative(canonicalBackupRoot, containedPath); + const firstSegment = relativePath.split(/[\\/]/)[0]; + if ( + relativePath.length === 0 || + firstSegment === ".." || + isAbsolute(relativePath) + ) { + throw new BackupContainmentError("Backup path escapes backup directory"); + } + return containedPath; +} + +export function isNamedBackupContainmentError(error: unknown): boolean { + return ( + error instanceof BackupContainmentError || + (error instanceof Error && /escapes backup directory/i.test(error.message)) + ); +} + +export function isNamedBackupPathValidationTransientError( + error: unknown, +): error is BackupPathValidationTransientError { + return ( + error instanceof BackupPathValidationTransientError || + (error instanceof Error && + /^Backup path validation failed(\.|:|\b)/i.test(error.message)) + ); +} + +export async function resolveNamedBackupRestorePath(name: string): Promise { + const requested = (name ?? "").trim(); + const backupRoot = getNamedBackupRoot(getStoragePath()); + const existingPath = await findExistingNamedBackupPath(name); + if (existingPath) { + return assertNamedBackupRestorePath(existingPath, backupRoot); + } + const requestedWithExtension = requested.toLowerCase().endsWith(".json") + ? requested + : `${requested}.json`; + const baseName = requestedWithExtension.slice(0, -".json".length); + let builtPath: string; + try { + builtPath = buildNamedBackupPath(requested); + } catch (error) { + // buildNamedBackupPath rejects names with special characters even when the + // requested backup name is a plain filename inside the backups directory. + // In that case, reporting ENOENT is clearer than surfacing the filename + // validator, but only when no separator/traversal token is present. + if ( + requested.length > 0 && + basename(requestedWithExtension) === requestedWithExtension && + !requestedWithExtension.includes("..") && + !/^[A-Za-z0-9_-]+$/.test(baseName) + ) { + throw new Error( + `Import file not found: ${resolvePath(join(backupRoot, requestedWithExtension))}`, + ); + } + throw error; + } + return assertNamedBackupRestorePath(builtPath, backupRoot); +} + async function loadAccountsFromJournal( path: string, ): Promise { @@ -1782,6 +2423,50 @@ async function loadAccountsInternal( } } +async function buildNamedBackupMetadata( + name: string, + path: string, + opts: { candidate?: Awaited> } = {}, +): Promise { + const candidate = opts.candidate ?? (await loadBackupCandidate(path)); + let stats: { + size?: number; + mtimeMs?: number; + birthtimeMs?: number; + ctimeMs?: number; + } | null = null; + try { + stats = await retryTransientFilesystemOperation(() => fs.stat(path)); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to stat named backup", { path, error: String(error) }); + } + } + + const version = + candidate.normalized?.version ?? + (typeof candidate.storedVersion === "number" + ? candidate.storedVersion + : null); + const accountCount = candidate.normalized?.accounts.length ?? null; + const createdAt = stats?.birthtimeMs ?? stats?.ctimeMs ?? null; + const updatedAt = stats?.mtimeMs ?? null; + + return { + name, + path, + createdAt, + updatedAt, + sizeBytes: typeof stats?.size === "number" ? stats.size : null, + version, + accountCount, + schemaErrors: candidate.schemaErrors, + valid: !!candidate.normalized, + loadError: candidate.error, + }; +} + async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { const path = getStoragePath(); const resetMarkerPath = getIntentionalResetMarkerPath(path); @@ -1917,9 +2602,11 @@ export async function withAccountStorageTransaction( ) => Promise, ): Promise { return withStorageLock(async () => { + const storagePath = getStoragePath(); const state = { snapshot: await loadAccountsInternal(saveAccountsUnlocked), active: true, + storagePath, }; const current = state.snapshot; const persist = async (storage: AccountStorageV3): Promise => { @@ -1942,9 +2629,11 @@ export async function withAccountAndFlaggedStorageTransaction( ) => Promise, ): Promise { return withStorageLock(async () => { + const storagePath = getStoragePath(); const state = { snapshot: await loadAccountsInternal(saveAccountsUnlocked), active: true, + storagePath, }; const current = state.snapshot; const persist = async ( @@ -2340,11 +3029,17 @@ export async function exportAccounts( } const transactionState = transactionSnapshotContext.getStore(); + const currentStoragePath = normalizeStoragePathForComparison(getStoragePath()); const storage = transactionState?.active - ? transactionState.snapshot - : await withAccountStorageTransaction((current) => - Promise.resolve(current), - ); + ? normalizeStoragePathForComparison(transactionState.storagePath) === + currentStoragePath + ? transactionState.snapshot + : (() => { + throw new Error( + "exportAccounts called inside an active transaction for a different storage path", + ); + })() + : await withAccountStorageTransaction((current) => Promise.resolve(current)); if (!storage || storage.accounts.length === 0) { throw new Error("No accounts to export"); } @@ -2380,7 +3075,7 @@ export async function exportAccounts( */ export async function importAccounts( filePath: string, -): Promise<{ imported: number; total: number; skipped: number }> { +): Promise { const resolvedPath = resolvePath(filePath); // Check file exists with friendly error @@ -2388,7 +3083,17 @@ export async function importAccounts( throw new Error(`Import file not found: ${resolvedPath}`); } - const content = await fs.readFile(resolvedPath, "utf-8"); + let content: string; + try { + content = await retryTransientFilesystemOperation(() => + fs.readFile(resolvedPath, "utf-8"), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(`Import file not found: ${resolvedPath}`); + } + throw error; + } let imported: unknown; try { @@ -2406,22 +3111,48 @@ export async function importAccounts( imported: importedCount, total, skipped: skippedCount, + changed, } = await withAccountStorageTransaction(async (existing, persist) => { const existingAccounts = existing?.accounts ?? []; + // Keep import counts anchored to a deduplicated current snapshot for the + // same reason as assessNamedBackupRestore. + const existingDeduplicatedAccounts = deduplicateAccounts([ + ...existingAccounts, + ]); + const incomingDeduplicatedAccounts = deduplicateAccounts([ + ...normalized.accounts, + ]); const existingActiveIndex = existing?.activeIndex ?? 0; - const merged = [...existingAccounts, ...normalized.accounts]; - - if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { - const deduped = deduplicateAccounts(merged); - if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { - throw new Error( - `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduped.length})`, - ); - } + const merged = [ + ...existingDeduplicatedAccounts, + ...incomingDeduplicatedAccounts, + ]; + const deduplicatedAccounts = deduplicateAccounts(merged); + if (deduplicatedAccounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { + throw new Error( + `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduplicatedAccounts.length})`, + ); } + const imported = + deduplicatedAccounts.length - existingDeduplicatedAccounts.length; + const skipped = Math.max( + 0, + incomingDeduplicatedAccounts.length - imported, + ); + const changed = !haveEquivalentAccountRows( + deduplicatedAccounts, + existingDeduplicatedAccounts, + ); - const deduplicatedAccounts = deduplicateAccounts(merged); + if (!changed) { + return { + imported, + total: deduplicatedAccounts.length, + skipped, + changed, + }; + } const newStorage: AccountStorageV3 = { version: 3, @@ -2431,10 +3162,12 @@ export async function importAccounts( }; await persist(newStorage); - - const imported = deduplicatedAccounts.length - existingAccounts.length; - const skipped = normalized.accounts.length - imported; - return { imported, total: deduplicatedAccounts.length, skipped }; + return { + imported, + total: deduplicatedAccounts.length, + skipped, + changed, + }; }); log.info("Imported accounts", { @@ -2442,7 +3175,17 @@ export async function importAccounts( imported: importedCount, skipped: skippedCount, total, + changed, }); - return { imported: importedCount, total, skipped: skippedCount }; + return { + imported: importedCount, + total, + skipped: skippedCount, + changed, + }; } + +export const __testOnly = { + namedBackupContainmentFs, +}; diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index fbe9293a..5f77ad62 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -64,6 +64,7 @@ export type AuthMenuAction = | { type: "check" } | { type: "deep-check" } | { type: "verify-flagged" } + | { type: "restore-backup" } | { type: "select-account"; account: AccountInfo } | { type: "set-current-account"; account: AccountInfo } | { type: "refresh-account"; account: AccountInfo } @@ -517,6 +518,7 @@ function authMenuFocusKey(action: AuthMenuAction): string { case "check": case "deep-check": case "verify-flagged": + case "restore-backup": case "search": case "delete-all": case "cancel": @@ -655,6 +657,17 @@ export async function showAuthMenu( ); } + items.push({ label: "", value: { type: "cancel" }, separator: true }); + items.push({ + label: UI_COPY.mainMenu.recovery, + value: { type: "cancel" }, + kind: "heading", + }); + items.push({ + label: UI_COPY.mainMenu.restoreBackup, + value: { type: "restore-backup" }, + color: "yellow", + }); items.push({ label: "", value: { type: "cancel" }, separator: true }); items.push({ label: UI_COPY.mainMenu.dangerZone, diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index b4505e8e..1b14d107 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -14,6 +14,27 @@ export const UI_COPY = { accounts: "Saved Accounts", loadingLimits: "Fetching account limits...", noSearchMatches: "No accounts match your search", + recovery: "Recovery", + restoreBackup: "Restore From Backup", + restoreBackupConfirm: ( + name: string, + netNewAccounts: number, + backupAccountCount: number, + currentAccountCount: number, + mergedAccountCount: number, + ) => + netNewAccounts === 0 + ? `Restore backup "${name}"? This will refresh stored metadata for matching existing account(s) in this backup.` + : `Restore backup "${name}"? This will add ${netNewAccounts} new account(s) (${backupAccountCount} in backup, ${currentAccountCount} current -> ${mergedAccountCount} after dedupe).`, + restoreBackupSuccess: ( + name: string, + imported: number, + skipped: number, + total: number, + ) => + `Restored backup "${name}". Imported ${imported}, skipped ${skipped}, total ${total}.`, + restoreBackupRefreshSuccess: (name: string) => + `Restored backup "${name}". Refreshed stored metadata for matching existing account(s).`, dangerZone: "Danger Zone", removeAllAccounts: "Delete Saved Accounts", resetLocalState: "Reset Local State", @@ -131,8 +152,8 @@ export const UI_COPY = { addAnotherQuestion: (count: number) => `Add another account? (${count} added) (y/n): `, selectModePrompt: - "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (f) fresh, (r) reset, (q) back [a/c/b/x/s/d/g/f/r/q]: ", - invalidModePrompt: "Use one of: a, c, b, x, s, d, g, f, r, q.", + "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (u) restore backup, (f) fresh, (r) reset, (q) back [a/c/b/x/s/d/g/u/f/r/q]: ", + invalidModePrompt: "Use one of: a, c, b, x, s, d, g, u, f, r, q.", }, } as const; diff --git a/test/cli.test.ts b/test/cli.test.ts index a2750841..efbffdce 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -704,6 +704,30 @@ describe("CLI Module", () => { }); }); + it("returns restore-backup for fallback restore aliases", async () => { + const { promptLoginMode } = await import("../lib/cli.js"); + + mockRl.question.mockResolvedValueOnce("u"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); + + mockRl.question.mockResolvedValueOnce("restore"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); + + mockRl.question.mockResolvedValueOnce("backup"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); + + mockRl.question.mockResolvedValueOnce("restore-backup"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); + }); + it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { delete process.env.FORCE_INTERACTIVE_MODE; const { stdin, stdout } = await import("node:process"); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 6358465e..1ab460dd 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1,11 +1,25 @@ +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const MOCK_BACKUP_DIR = fileURLToPath( + new URL("./.vitest-mock-backups", import.meta.url), +); +const mockBackupPath = (name: string): string => + resolve(MOCK_BACKUP_DIR, `${name}.json`); + const loadAccountsMock = vi.fn(); const loadFlaggedAccountsMock = vi.fn(); const saveAccountsMock = vi.fn(); const saveFlaggedAccountsMock = vi.fn(); const setStoragePathMock = vi.fn(); const getStoragePathMock = vi.fn(() => "/mock/openai-codex-accounts.json"); +const listNamedBackupsMock = vi.fn(); +const assessNamedBackupRestoreMock = vi.fn(); +const getNamedBackupsDirectoryPathMock = vi.fn(); +const resolveNamedBackupRestorePathMock = vi.fn(); +const importAccountsMock = vi.fn(); +const restoreAssessedNamedBackupMock = vi.fn(); const queuedRefreshMock = vi.fn(); const setCodexCliActiveSelectionMock = vi.fn(); const promptAddAnotherAccountMock = vi.fn(); @@ -103,6 +117,12 @@ vi.mock("../lib/storage.js", async () => { withAccountStorageTransaction: withAccountStorageTransactionMock, setStoragePath: setStoragePathMock, getStoragePath: getStoragePathMock, + listNamedBackups: listNamedBackupsMock, + assessNamedBackupRestore: assessNamedBackupRestoreMock, + getNamedBackupsDirectoryPath: getNamedBackupsDirectoryPathMock, + resolveNamedBackupRestorePath: resolveNamedBackupRestorePathMock, + importAccounts: importAccountsMock, + restoreAssessedNamedBackup: restoreAssessedNamedBackupMock, exportNamedBackup: exportNamedBackupMock, normalizeAccountStorage: normalizeAccountStorageMock, }; @@ -186,6 +206,12 @@ vi.mock("../lib/ui/select.js", () => ({ select: selectMock, })); +const confirmMock = vi.fn(); + +vi.mock("../lib/ui/confirm.js", () => ({ + confirm: confirmMock, +})); + vi.mock("../lib/oc-chatgpt-orchestrator.js", () => ({ planOcChatgptSync: planOcChatgptSyncMock, applyOcChatgptSync: applyOcChatgptSyncMock, @@ -451,6 +477,7 @@ describe("codex manager cli commands", () => { withAccountStorageTransactionMock.mockReset(); queuedRefreshMock.mockReset(); setCodexCliActiveSelectionMock.mockReset(); + setCodexCliActiveSelectionMock.mockResolvedValue(true); promptAddAnotherAccountMock.mockReset(); promptLoginModeMock.mockReset(); fetchCodexQuotaSnapshotMock.mockReset(); @@ -489,6 +516,51 @@ describe("codex manager cli commands", () => { version: 1, accounts: [], }); + listNamedBackupsMock.mockReset(); + assessNamedBackupRestoreMock.mockReset(); + getNamedBackupsDirectoryPathMock.mockReset(); + resolveNamedBackupRestorePathMock.mockReset(); + importAccountsMock.mockReset(); + restoreAssessedNamedBackupMock.mockReset(); + confirmMock.mockReset(); + listNamedBackupsMock.mockResolvedValue([]); + assessNamedBackupRestoreMock.mockResolvedValue({ + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }); + getNamedBackupsDirectoryPathMock.mockReturnValue(MOCK_BACKUP_DIR); + resolveNamedBackupRestorePathMock.mockImplementation(async (name: string) => + mockBackupPath(name), + ); + importAccountsMock.mockResolvedValue({ + imported: 1, + skipped: 0, + total: 1, + changed: true, + }); + restoreAssessedNamedBackupMock.mockImplementation(async (assessment) => + importAccountsMock( + await resolveNamedBackupRestorePathMock(assessment.backup.name), + ), + ); + confirmMock.mockResolvedValue(true); withAccountStorageTransactionMock.mockImplementation( async (handler) => { const current = await loadAccountsMock(); @@ -2314,151 +2386,1657 @@ describe("codex manager cli commands", () => { ); }); - it("shows experimental settings in the settings hub", async () => { + it("restores a named backup from the login recovery menu", async () => { + setInteractiveTTY(true); const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - queueSettingsSelectSequence([{ type: "back" }]); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(readSettingsHubPanelContract()).toEqual(SETTINGS_HUB_MENU_ORDER); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce(); }); - it("runs experimental oc sync with mandatory preview before apply", async () => { + it("restores a named backup from the direct restore-backup command", async () => { + setInteractiveTTY(true); const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); - planOcChatgptSyncMock.mockResolvedValue({ - kind: "ready", - target: { - scope: "global", - root: "C:/target", - accountPath: "C:/target/openai-codex-accounts.json", - backupRoot: "C:/target/backups", - source: "default-global", - resolution: "accounts", - }, - preview: { - payload: { version: 3, accounts: [], activeIndex: 0 }, - merged: { version: 3, accounts: [], activeIndex: 0 }, - toAdd: [{ refreshTokenLast4: "1234" }], - toUpdate: [], - toSkip: [], - unchangedDestinationOnly: [], - activeSelectionBehavior: "preserve-destination", - }, - payload: { version: 3, accounts: [], activeIndex: 0 }, - destination: null, + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], }); - applyOcChatgptSyncMock.mockResolvedValue({ - kind: "applied", - target: { - scope: "global", - root: "C:/target", - accountPath: "C:/target/openai-codex-accounts.json", - backupRoot: "C:/target/backups", - source: "default-global", - resolution: "accounts", + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, }, - preview: { merged: { version: 3, accounts: [], activeIndex: 0 } }, - merged: { version: 3, accounts: [], activeIndex: 0 }, - destination: null, - persistedPath: "C:/target/openai-codex-accounts.json", - }); - const selectSequence = queueSettingsSelectSequence([ - { type: "experimental" }, - { type: "sync" }, - { type: "apply" }, - { type: "back" }, - { type: "back" }, - { type: "back" }, - ]); + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); expect(exitCode).toBe(0); - expect(selectSequence.remaining()).toBe(0); - expect(planOcChatgptSyncMock).toHaveBeenCalledOnce(); - expect(applyOcChatgptSyncMock).toHaveBeenCalledOnce(); - expect(selectMock).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ label: expect.stringContaining("Active selection: preserve-destination") }), - ]), - expect.any(Object), + expect(setStoragePathMock).toHaveBeenCalledWith(null); + expect(promptLoginModeMock).not.toHaveBeenCalled(); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), ); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce(); }); - it("shows guidance when experimental oc sync target is ambiguous or unreadable", async () => { + it("returns a non-zero exit code when the direct restore-backup command fails", async () => { + setInteractiveTTY(true); const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); - planOcChatgptSyncMock.mockResolvedValue({ - kind: "blocked-ambiguous", - detection: { kind: "ambiguous", reason: "multiple targets", candidates: [] }, + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], }); - const selectSequence = queueSettingsSelectSequence([ - { type: "experimental" }, - { type: "sync" }, - { type: "back" }, - { type: "back" }, - { type: "back" }, - ]); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + importAccountsMock.mockRejectedValueOnce(new Error("backup locked")); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); - expect(exitCode).toBe(0); - expect(selectSequence.remaining()).toBe(0); - expect(planOcChatgptSyncMock).toHaveBeenCalledOnce(); - expect(applyOcChatgptSyncMock).not.toHaveBeenCalled(); + expect(exitCode).toBe(1); + expect(promptLoginModeMock).not.toHaveBeenCalled(); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith("Restore failed: backup locked"); + } finally { + errorSpy.mockRestore(); + } }); - - it("exports named pool backup from experimental settings", async () => { + it("returns a non-zero exit code when every direct restore assessment fails", async () => { + setInteractiveTTY(true); const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - promptQuestionMock.mockResolvedValueOnce("backup-2026-03-10"); - runNamedBackupExportMock.mockResolvedValueOnce({ kind: "exported", path: "/mock/backups/backup-2026-03-10.json" }); - const selectSequence = queueSettingsSelectSequence([ - { type: "experimental" }, - { type: "backup" }, - { type: "back" }, - { type: "back" }, - { type: "back" }, + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + listNamedBackupsMock.mockResolvedValue([ + { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, ]); + assessNamedBackupRestoreMock.mockRejectedValueOnce( + makeErrnoError("backup busy", "EBUSY"), + ); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + + expect(exitCode).toBe(1); + expect(selectMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Could not assess any named backups in"), + ); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("named-backup: backup busy"), + ); + } finally { + errorSpy.mockRestore(); + } + }); - expect(exitCode).toBe(0); - expect(selectSequence.remaining()).toBe(0); - expect(promptQuestionMock).toHaveBeenCalledOnce(); - expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "backup-2026-03-10" }); + it("rejects a restore when the backup root changes before the final import path check", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + resolveNamedBackupRestorePathMock.mockRejectedValueOnce( + new Error("Backup path escapes backup directory"), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(errorSpy).toHaveBeenCalledWith( + "Restore failed: Backup path escapes backup directory", + ); + } finally { + errorSpy.mockRestore(); + } }); - it("rejects invalid or colliding experimental backup filenames", async () => { + it("offers backup restore from the login menu when no accounts are saved", async () => { + setInteractiveTTY(true); const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - promptQuestionMock.mockResolvedValueOnce("../bad-name"); - runNamedBackupExportMock.mockResolvedValueOnce({ kind: "collision", path: "/mock/backups/bad-name.json" }); - const selectSequence = queueSettingsSelectSequence([ - { type: "experimental" }, - { type: "backup" }, - { type: "back" }, - { type: "back" }, - { type: "back" }, - ]); + 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 - 500, + lastUsed: now - 500, + enabled: true, + }, + ], + }; + loadAccountsMock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValue(restoredStorage); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(selectSequence.remaining()).toBe(0); - expect(promptQuestionMock).toHaveBeenCalledOnce(); - expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "../bad-name" }); + expect(promptLoginModeMock.mock.calls[0]?.[0]).toEqual([]); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ currentStorage: null }), + ); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "acc_restored", + email: "restored@example.com", + refreshToken: "refresh-restored", + accessToken: "access-restored", + }), + ); + }); + + it("does not restore a named backup when confirmation is declined", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + confirmMock.mockResolvedValueOnce(false); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(importAccountsMock).not.toHaveBeenCalled(); + }); + + it("catches restore failures and returns to the login menu", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + importAccountsMock.mockRejectedValueOnce( + new Error(`Import file not found: ${mockBackupPath("named-backup")}`), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + const restoreFailureCalls = [ + ...errorSpy.mock.calls, + ...logSpy.mock.calls, + ].flat(); + expect(restoreFailureCalls).toContainEqual( + expect.stringContaining("Restore failed: Import file not found"), + ); + } finally { + errorSpy.mockRestore(); + logSpy.mockRestore(); + } + }); + + it("adds actionable guidance when a confirmed restore exceeds the account limit", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + importAccountsMock.mockRejectedValueOnce( + new Error("Import would exceed maximum of 10 accounts (would have 11)"), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(errorSpy).toHaveBeenCalledWith( + "Restore failed: Import would exceed maximum of 10 accounts (would have 11). Close other Codex instances and try again.", + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it("treats post-confirm duplicate-only restores as a no-op", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + importAccountsMock.mockResolvedValueOnce({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + expect(logSpy).toHaveBeenCalledWith( + "All accounts in this backup already exist", + ); + expect(logSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Restored backup "named-backup"'), + ); + } finally { + logSpy.mockRestore(); + } + }); + + it("catches backup listing failures and returns to the login menu", async () => { + setInteractiveTTY(true); + listNamedBackupsMock.mockRejectedValueOnce( + makeErrnoError( + "EPERM: operation not permitted, scandir '/mock/backups'", + "EPERM", + ), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Could not read backup directory: EPERM: operation not permitted", + ), + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it("reports backup validation failures separately from directory read failures", async () => { + setInteractiveTTY(true); + listNamedBackupsMock.mockRejectedValueOnce( + new Error("Backup path escapes backup directory"), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Backup validation failed: Backup path escapes backup directory", + ), + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it("surfaces transient backup path validation failures with retry guidance", async () => { + setInteractiveTTY(true); + listNamedBackupsMock.mockRejectedValueOnce( + new Error("Backup path validation failed. Try again."), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + "Backup path validation failed. Try again.", + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it("propagates containment errors from batch backup assessment and returns to the login menu", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const now = Date.now(); + listNamedBackupsMock.mockResolvedValue([ + { + name: "escaped-backup", + path: mockBackupPath("escaped-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + ]); + assessNamedBackupRestoreMock.mockRejectedValueOnce( + new Error("Backup path escapes backup directory"), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(selectMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Restore failed: Backup path escapes backup directory", + ), + ); + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Skipped backup assessment for "escaped-backup"'), + ); + } finally { + errorSpy.mockRestore(); + warnSpy.mockRestore(); + } + }); + + it("keeps healthy backups selectable when one assessment fails", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const now = Date.now(); + const healthyAssessment = { + backup: { + name: "healthy-backup", + path: mockBackupPath("healthy-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([ + { + ...healthyAssessment.backup, + name: "broken-backup", + path: mockBackupPath("broken-backup"), + }, + healthyAssessment.backup, + ]); + assessNamedBackupRestoreMock.mockImplementation(async (name: string) => { + if (name === "broken-backup") { + throw new Error("backup directory busy"); + } + return healthyAssessment; + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockImplementationOnce(async (items) => { + const labels = items.map((item) => item.label); + expect(labels).toContain("healthy-backup"); + expect(labels).not.toContain("broken-backup"); + return { type: "restore", assessment: healthyAssessment }; + }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("healthy-backup"), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Skipped backup assessment for "broken-backup": backup directory busy', + ), + ); + } finally { + warnSpy.mockRestore(); + } + }); + + it("limits concurrent backup assessments in the restore menu", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const { NAMED_BACKUP_ASSESS_CONCURRENCY } = + await vi.importActual( + "../lib/storage.js", + ); + const totalBackups = NAMED_BACKUP_ASSESS_CONCURRENCY + 3; + const backups = Array.from({ length: totalBackups }, (_value, index) => ({ + name: `named-backup-${index + 1}`, + path: mockBackupPath(`named-backup-${index + 1}`), + createdAt: null, + updatedAt: Date.now() + index, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + })); + const backupsByName = new Map(backups.map((backup) => [backup.name, backup])); + let inFlight = 0; + let maxInFlight = 0; + let pending: Array>> = []; + let releaseScheduled = false; + const releasePending = () => { + if (releaseScheduled) { + return; + } + releaseScheduled = true; + queueMicrotask(() => { + releaseScheduled = false; + if (pending.length === 0) { + return; + } + const release = pending; + pending = []; + for (const deferred of release) { + deferred.resolve(); + } + }); + }; + listNamedBackupsMock.mockResolvedValue(backups); + assessNamedBackupRestoreMock.mockImplementation(async (name: string) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + const gate = createDeferred(); + pending.push(gate); + releasePending(); + await gate.promise; + inFlight -= 1; + return { + backup: backupsByName.get(name) ?? backups[0], + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "back" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledTimes(backups.length); + expect(maxInFlight).toBeLessThanOrEqual( + NAMED_BACKUP_ASSESS_CONCURRENCY, + ); + }); + + it("reassesses a backup before confirmation so the merge summary stays current", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const initialAssessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + const refreshedAssessment = { + ...initialAssessment, + currentAccountCount: 3, + mergedAccountCount: 4, + }; + listNamedBackupsMock.mockResolvedValue([initialAssessment.backup]); + assessNamedBackupRestoreMock + .mockResolvedValueOnce(initialAssessment) + .mockResolvedValueOnce(refreshedAssessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ + type: "restore", + assessment: initialAssessment, + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).toHaveBeenCalledWith( + expect.stringContaining("add 1 new account(s)"), + ); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + }); + + it("uses metadata refresh wording when a restore only updates existing accounts", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + { + email: "same@example.com", + accountId: "acc_same", + refreshToken: "refresh-same", + accessToken: "access-same", + expiresAt: now + 3_600_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 2, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 2, + mergedAccountCount: 2, + imported: 0, + skipped: 2, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + importAccountsMock.mockResolvedValueOnce({ + imported: 0, + skipped: 2, + total: 2, + changed: true, + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ + type: "restore", + assessment, + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(confirmMock).toHaveBeenCalledWith( + expect.stringContaining( + "refresh stored metadata for matching existing account(s)", + ), + ); + expect(confirmMock).not.toHaveBeenCalledWith( + expect.stringContaining("for 2 existing account(s)"), + ); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + expect(logSpy).toHaveBeenCalledWith( + 'Restored backup "named-backup". Refreshed stored metadata for matching existing account(s).', + ); + expect(logSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Imported 0, skipped 2"), + ); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce(); + } finally { + logSpy.mockRestore(); + } + }); + + it("returns to the login menu when backup reassessment becomes ineligible", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const initialAssessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + const refreshedAssessment = { + ...initialAssessment, + currentAccountCount: 1, + mergedAccountCount: 1, + imported: 0, + skipped: 1, + eligibleForRestore: false, + error: "All accounts in this backup already exist", + }; + listNamedBackupsMock.mockResolvedValue([initialAssessment.backup]); + assessNamedBackupRestoreMock + .mockResolvedValueOnce(initialAssessment) + .mockResolvedValueOnce(refreshedAssessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ + type: "restore", + assessment: initialAssessment, + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + "All accounts in this backup already exist", + ); + } finally { + logSpy.mockRestore(); + } + }); + + it("returns to the login menu when backup reassessment fails before confirmation", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const initialAssessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([initialAssessment.backup]); + assessNamedBackupRestoreMock + .mockResolvedValueOnce(initialAssessment) + .mockRejectedValueOnce(makeErrnoError("backup busy", "EBUSY")); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ + type: "restore", + assessment: initialAssessment, + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Restore failed: backup busy"), + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it("shows epoch backup timestamps in restore hints", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const assessment = { + backup: { + name: "epoch-backup", + path: mockBackupPath("epoch-backup"), + createdAt: null, + updatedAt: 0, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "back" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + const backupItems = selectMock.mock.calls[0]?.[0]; + expect(backupItems?.[0]?.hint).toContain("1 account"); + expect(backupItems?.[0]?.hint).not.toContain("updated "); + }); + + it("formats recent backup timestamps in restore hints", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const now = Date.UTC(2026, 0, 10, 12, 0, 0); + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now); + const backups = [ + { + name: "today-backup", + path: mockBackupPath("today-backup"), + createdAt: null, + updatedAt: now - 1_000, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + { + name: "yesterday-backup", + path: mockBackupPath("yesterday-backup"), + createdAt: null, + updatedAt: now - 1.5 * 86_400_000, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + { + name: "three-days-backup", + path: mockBackupPath("three-days-backup"), + createdAt: null, + updatedAt: now - 3 * 86_400_000, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + { + name: "older-backup", + path: mockBackupPath("older-backup"), + createdAt: null, + updatedAt: now - 8 * 86_400_000, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + ]; + const assessmentsByName = new Map( + backups.map((backup) => [ + backup.name, + { + backup, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }, + ]), + ); + listNamedBackupsMock.mockResolvedValue(backups); + assessNamedBackupRestoreMock.mockImplementation(async (name: string) => { + return assessmentsByName.get(name) ?? assessmentsByName.get(backups[0].name)!; + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "back" }); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + const backupItems = selectMock.mock.calls[0]?.[0]; + expect(backupItems?.[0]?.hint).toContain("updated today"); + expect(backupItems?.[1]?.hint).toContain("updated yesterday"); + expect(backupItems?.[2]?.hint).toContain("updated 3d ago"); + expect(backupItems?.[3]?.hint).toContain("updated "); + } finally { + nowSpy.mockRestore(); + } + }); + + it("suppresses invalid backup timestamps in restore hints", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const assessment = { + backup: { + name: "nan-backup", + path: mockBackupPath("nan-backup"), + createdAt: null, + updatedAt: Number.NaN, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "back" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + const backupItems = selectMock.mock.calls[0]?.[0]; + expect(backupItems?.[0]?.hint).toContain("1 account"); + expect(backupItems?.[0]?.hint).not.toContain("updated "); + }); + + it("shows experimental settings in the settings hub", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + queueSettingsSelectSequence([{ type: "back" }]); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(readSettingsHubPanelContract()).toEqual(SETTINGS_HUB_MENU_ORDER); + }); + + it("runs experimental oc sync with mandatory preview before apply", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); + planOcChatgptSyncMock.mockResolvedValue({ + kind: "ready", + target: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + preview: { + payload: { version: 3, accounts: [], activeIndex: 0 }, + merged: { version: 3, accounts: [], activeIndex: 0 }, + toAdd: [{ refreshTokenLast4: "1234" }], + toUpdate: [], + toSkip: [], + unchangedDestinationOnly: [], + activeSelectionBehavior: "preserve-destination", + }, + payload: { version: 3, accounts: [], activeIndex: 0 }, + destination: null, + }); + applyOcChatgptSyncMock.mockResolvedValue({ + kind: "applied", + target: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + preview: { merged: { version: 3, accounts: [], activeIndex: 0 } }, + merged: { version: 3, accounts: [], activeIndex: 0 }, + destination: null, + persistedPath: "C:/target/openai-codex-accounts.json", + }); + const selectSequence = queueSettingsSelectSequence([ + { type: "experimental" }, + { type: "sync" }, + { type: "apply" }, + { type: "back" }, + { type: "back" }, + { type: "back" }, + ]); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectSequence.remaining()).toBe(0); + expect(planOcChatgptSyncMock).toHaveBeenCalledOnce(); + expect(applyOcChatgptSyncMock).toHaveBeenCalledOnce(); + expect(selectMock).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ label: expect.stringContaining("Active selection: preserve-destination") }), + ]), + expect.any(Object), + ); + }); + + it("shows guidance when experimental oc sync target is ambiguous or unreadable", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); + planOcChatgptSyncMock.mockResolvedValue({ + kind: "blocked-ambiguous", + detection: { kind: "ambiguous", reason: "multiple targets", candidates: [] }, + }); + const selectSequence = queueSettingsSelectSequence([ + { type: "experimental" }, + { type: "sync" }, + { type: "back" }, + { type: "back" }, + { type: "back" }, + ]); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectSequence.remaining()).toBe(0); + expect(planOcChatgptSyncMock).toHaveBeenCalledOnce(); + expect(applyOcChatgptSyncMock).not.toHaveBeenCalled(); + }); + + + it("exports named pool backup from experimental settings", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + promptQuestionMock.mockResolvedValueOnce("backup-2026-03-10"); + runNamedBackupExportMock.mockResolvedValueOnce({ kind: "exported", path: "/mock/backups/backup-2026-03-10.json" }); + const selectSequence = queueSettingsSelectSequence([ + { type: "experimental" }, + { type: "backup" }, + { type: "back" }, + { type: "back" }, + { type: "back" }, + ]); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectSequence.remaining()).toBe(0); + expect(promptQuestionMock).toHaveBeenCalledOnce(); + expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "backup-2026-03-10" }); + }); + + it("rejects invalid or colliding experimental backup filenames", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + promptQuestionMock.mockResolvedValueOnce("../bad-name"); + runNamedBackupExportMock.mockResolvedValueOnce({ kind: "collision", path: "/mock/backups/bad-name.json" }); + const selectSequence = queueSettingsSelectSequence([ + { type: "experimental" }, + { type: "backup" }, + { type: "back" }, + { type: "back" }, + { type: "back" }, + ]); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectSequence.remaining()).toBe(0); + expect(promptQuestionMock).toHaveBeenCalledOnce(); + expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "../bad-name" }); }); it("backs out of experimental sync preview without applying", async () => { @@ -3451,6 +5029,218 @@ describe("codex manager cli commands", () => { logSpy.mockRestore(); }); + it("waits for an in-flight menu quota refresh before starting quick check", async () => { + const now = Date.now(); + const menuStorage = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "alpha@example.com", + accountId: "acc-alpha", + accessToken: "access-alpha", + expiresAt: now + 3_600_000, + refreshToken: "refresh-alpha", + addedAt: now, + lastUsed: now, + enabled: true, + }, + { + email: "beta@example.com", + accountId: "acc-beta", + accessToken: "access-beta", + expiresAt: now + 3_600_000, + refreshToken: "refresh-beta", + addedAt: now, + lastUsed: now, + enabled: true, + }, + ], + }; + const quickCheckStorage = { + ...menuStorage, + accounts: [menuStorage.accounts[0]!], + }; + let loadAccountsCalls = 0; + loadAccountsMock.mockImplementation(async () => { + loadAccountsCalls += 1; + return structuredClone( + loadAccountsCalls === 1 ? menuStorage : quickCheckStorage, + ); + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuShowFetchStatus: true, + menuQuotaTtlMs: 60_000, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + let currentQuotaCache: { + byAccountId: Record; + byEmail: Record; + } = { + byAccountId: {}, + byEmail: {}, + }; + loadQuotaCacheMock.mockImplementation(async () => + structuredClone(currentQuotaCache), + ); + saveQuotaCacheMock.mockImplementation(async (value: typeof currentQuotaCache) => { + currentQuotaCache = structuredClone(value); + }); + const firstFetchStarted = createDeferred(); + const secondFetchStarted = createDeferred(); + const releaseFirstFetch = createDeferred(); + const releaseSecondFetch = createDeferred(); + let fetchCallCount = 0; + fetchCodexQuotaSnapshotMock.mockImplementation( + async (input: { accountId: string }) => { + fetchCallCount += 1; + if (fetchCallCount === 1) { + firstFetchStarted.resolve(); + await releaseFirstFetch.promise; + } else if (fetchCallCount === 2) { + secondFetchStarted.resolve(input.accountId); + await releaseSecondFetch.promise; + } + return { + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }; + }, + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "check" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const runPromise = runCodexMultiAuthCli(["auth", "login"]); + + await firstFetchStarted.promise; + await Promise.resolve(); + + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(1); + + releaseFirstFetch.resolve(); + + const secondAccountId = await secondFetchStarted.promise; + expect(secondAccountId).toBe("acc-beta"); + + releaseSecondFetch.resolve(); + + const exitCode = await runPromise; + + expect(exitCode).toBe(0); + expect(Object.keys(currentQuotaCache.byEmail)).toEqual( + expect.arrayContaining(["alpha@example.com", "beta@example.com"]), + ); + } finally { + logSpy.mockRestore(); + } + }); + + it("waits for an in-flight menu quota refresh before starting backup restore", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "restore@example.com", + accountId: "acc-restore", + accessToken: "access-restore", + expiresAt: now + 3_600_000, + refreshToken: "refresh-restore", + addedAt: now, + lastUsed: now, + enabled: true, + }, + ], + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuShowFetchStatus: true, + menuQuotaTtlMs: 60_000, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + let currentQuotaCache: { + byAccountId: Record; + byEmail: Record; + } = { + byAccountId: {}, + byEmail: {}, + }; + loadQuotaCacheMock.mockImplementation(async () => + structuredClone(currentQuotaCache), + ); + saveQuotaCacheMock.mockImplementation(async (value: typeof currentQuotaCache) => { + currentQuotaCache = structuredClone(value); + }); + const fetchStarted = createDeferred(); + const releaseFetch = createDeferred(); + fetchCodexQuotaSnapshotMock.mockImplementation(async () => { + fetchStarted.resolve(); + await releaseFetch.promise; + return { + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }; + }); + listNamedBackupsMock.mockResolvedValue([]); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const runPromise = runCodexMultiAuthCli(["auth", "login"]); + + await fetchStarted.promise; + await Promise.resolve(); + + expect(listNamedBackupsMock).not.toHaveBeenCalled(); + + releaseFetch.resolve(); + + const exitCode = await runPromise; + + expect(exitCode).toBe(0); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock.mock.invocationCallOrder[0]).toBeLessThan( + listNamedBackupsMock.mock.invocationCallOrder[0] ?? + Number.POSITIVE_INFINITY, + ); + } finally { + logSpy.mockRestore(); + } + }); + it("skips a second destructive action while reset is already running", async () => { const now = Date.now(); const skipMessage = diff --git a/test/storage.test.ts b/test/storage.test.ts index 790ee247..7e37dcfa 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1,13 +1,19 @@ import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ACCOUNT_LIMITS } from "../lib/constants.js"; import { clearQuotaCache, getQuotaCachePath } from "../lib/quota-cache.js"; import { getConfigDir, getProjectStorageKey } from "../lib/storage/paths.js"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; import { + __testOnly, + assessNamedBackupRestore, + assertNamedBackupRestorePath, buildNamedBackupPath, clearAccounts, clearFlaggedAccounts, + createNamedBackup, deduplicateAccounts, deduplicateAccountsByEmail, exportAccounts, @@ -15,11 +21,18 @@ import { findMatchingAccountIndex, formatStorageErrorHint, getFlaggedAccountsPath, + NAMED_BACKUP_LIST_CONCURRENCY, getStoragePath, importAccounts, + isNamedBackupContainmentError, + isNamedBackupPathValidationTransientError, + listNamedBackups, loadAccounts, loadFlaggedAccounts, normalizeAccountStorage, + resolveNamedBackupRestorePath, + restoreAssessedNamedBackup, + restoreNamedBackup, resolveAccountSelectionIndex, saveFlaggedAccounts, StorageError, @@ -327,7 +340,7 @@ describe("storage", () => { afterEach(async () => { setStoragePathDirect(null); - await fs.rm(testWorkDir, { recursive: true, force: true }); + await removeWithRetry(testWorkDir, { recursive: true, force: true }); }); it("should export accounts to a file", async () => { @@ -363,6 +376,69 @@ describe("storage", () => { ); }); + it("throws when exporting inside an active transaction for a different storage path", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "transactional-export", + refreshToken: "ref-transactional-export", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const alternateStoragePath = join(testWorkDir, "alternate-accounts.json"); + + await expect( + withAccountStorageTransaction(async () => { + setStoragePathDirect(alternateStoragePath); + try { + await exportAccounts(exportPath); + } finally { + setStoragePathDirect(testStoragePath); + } + }), + ).rejects.toThrow(/different storage path/); + }); + + it("allows exporting inside an active transaction when the storage path only differs by case on win32", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "transactional-export-same-path", + refreshToken: "ref-transactional-export-same-path", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const casedStoragePath = testStoragePath.toUpperCase(); + + try { + await expect( + withAccountStorageTransaction(async () => { + setStoragePathDirect(casedStoragePath); + try { + await exportAccounts(exportPath); + } finally { + setStoragePathDirect(testStoragePath); + } + }), + ).resolves.toBeUndefined(); + expect(existsSync(exportPath)).toBe(true); + } finally { + platformSpy.mockRestore(); + } + }); + it("should import accounts from a file and merge", async () => { // @ts-expect-error const { importAccounts } = await import("../lib/storage.js"); @@ -399,6 +475,243 @@ describe("storage", () => { expect(loaded?.accounts.map((a) => a.accountId)).toContain("new"); }); + it("should skip persisting duplicate-only imports", async () => { + const { importAccounts } = await import("../lib/storage.js"); + const existing = { + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 2, + }, + ], + }; + await saveAccounts(existing); + await fs.writeFile(exportPath, JSON.stringify(existing)); + + const writeFileSpy = vi.spyOn(fs, "writeFile"); + try { + const result = await importAccounts(exportPath); + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { + const target = String(targetPath); + return ( + target === testStoragePath || + target.startsWith(`${testStoragePath}.`) + ); + }); + expect(storageWrites).toHaveLength(0); + } finally { + writeFileSpy.mockRestore(); + } + }); + + it("should treat deduplicated current snapshots as a no-op import", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + + const writeFileSpy = vi.spyOn(fs, "writeFile"); + try { + const result = await importAccounts(exportPath); + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { + const target = String(targetPath); + return ( + target === testStoragePath || + target.startsWith(`${testStoragePath}.`) + ); + }); + expect(storageWrites).toHaveLength(0); + } finally { + writeFileSpy.mockRestore(); + } + }); + + it("should deduplicate incoming backup rows before reporting skipped imports", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await clearAccounts(); + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "duplicate-import", + email: "duplicate-import@example.com", + refreshToken: "ref-duplicate-import-old", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "duplicate-import", + email: "duplicate-import@example.com", + refreshToken: "ref-duplicate-import-new", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + ); + + const result = await importAccounts(exportPath); + const loaded = await loadAccounts(); + + expect(result).toEqual({ + imported: 1, + skipped: 0, + total: 1, + changed: true, + }); + expect(loaded?.accounts).toHaveLength(1); + expect(loaded?.accounts[0]).toMatchObject({ + accountId: "duplicate-import", + email: "duplicate-import@example.com", + refreshToken: "ref-duplicate-import-new", + lastUsed: 2, + }); + }); + + it("should persist duplicate-only imports when they refresh stored metadata", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + accessToken: "stale-access", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + accessToken: "fresh-access", + addedAt: 1, + lastUsed: 10, + }, + ], + }), + ); + + const result = await importAccounts(exportPath); + const loaded = await loadAccounts(); + + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: true, + }); + expect(loaded?.accounts).toHaveLength(1); + expect(loaded?.accounts[0]).toMatchObject({ + accountId: "existing", + accessToken: "fresh-access", + lastUsed: 10, + }); + }); + + it("should skip semantically identical duplicate-only imports even when key order differs", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 2, + }, + ], + }); + await fs.writeFile( + exportPath, + '{"version":3,"activeIndex":0,"accounts":[{"lastUsed":2,"addedAt":1,"refreshToken":"ref-existing","accountId":"existing"}]}', + ); + + const writeFileSpy = vi.spyOn(fs, "writeFile"); + try { + const result = await importAccounts(exportPath); + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { + const target = String(targetPath); + return ( + target === testStoragePath || + target.startsWith(`${testStoragePath}.`) + ); + }); + expect(storageWrites).toHaveLength(0); + } finally { + writeFileSpy.mockRestore(); + } + }); + it("should preserve distinct shared-accountId imports when the imported row has no email", async () => { const { importAccounts } = await import("../lib/storage.js"); const existing = { @@ -444,7 +757,12 @@ describe("storage", () => { const imported = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(imported).toEqual({ imported: 1, total: 3, skipped: 0 }); + expect(imported).toEqual({ + imported: 1, + total: 3, + skipped: 0, + changed: true, + }); expect(loaded?.accounts).toHaveLength(3); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual( expect.arrayContaining([ @@ -495,7 +813,12 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); + expect(result).toEqual({ + imported: 1, + skipped: 0, + total: 2, + changed: true, + }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -541,7 +864,12 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); + expect(result).toEqual({ + imported: 1, + skipped: 0, + total: 2, + changed: true, + }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -920,10 +1248,78 @@ describe("storage", () => { ); }); + it("rejects a second import that would exceed MAX_ACCOUNTS", async () => { + const nearLimitAccounts = Array.from( + { length: ACCOUNT_LIMITS.MAX_ACCOUNTS - 1 }, + (_, index) => ({ + accountId: `existing-${index}`, + refreshToken: `ref-existing-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }), + ); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: nearLimitAccounts, + }); + + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "extra-one", + refreshToken: "ref-extra-one", + addedAt: 10_000, + lastUsed: 10_000, + }, + ], + }), + ); + + const first = await importAccounts(exportPath); + expect(first).toMatchObject({ + imported: 1, + skipped: 0, + total: ACCOUNT_LIMITS.MAX_ACCOUNTS, + changed: true, + }); + + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "extra-two", + refreshToken: "ref-extra-two", + addedAt: 20_000, + lastUsed: 20_000, + }, + ], + }), + ); + + await expect(importAccounts(exportPath)).rejects.toThrow( + /exceed maximum/, + ); + + const loaded = await loadAccounts(); + expect(loaded?.accounts).toHaveLength(ACCOUNT_LIMITS.MAX_ACCOUNTS); + expect( + loaded?.accounts.some((account) => account.accountId === "extra-two"), + ).toBe(false); + }); + it("should fail export when no accounts exist", async () => { - const { exportAccounts } = await import("../lib/storage.js"); - setStoragePathDirect(testStoragePath); - await expect(exportAccounts(exportPath)).rejects.toThrow( + const storageModule = await import("../lib/storage.js"); + storageModule.setStoragePathDirect(testStoragePath); + await storageModule.clearAccounts(); + await expect(storageModule.exportAccounts(exportPath)).rejects.toThrow( /No accounts to export/, ); }); @@ -936,6 +1332,51 @@ describe("storage", () => { ); }); + it("retries transient import read errors before parsing the backup", async () => { + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-import-read", + refreshToken: "ref-retry-import-read", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + const originalReadFile = fs.readFile.bind(fs); + let busyFailures = 0; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === exportPath && busyFailures === 0) { + busyFailures += 1; + const error = new Error("import file busy") as NodeJS.ErrnoException; + error.code = "EAGAIN"; + throw error; + } + return originalReadFile(...(args as Parameters)); + }); + + try { + const result = await importAccounts(exportPath); + expect(result).toMatchObject({ + imported: 1, + skipped: 0, + total: 1, + changed: true, + }); + expect(busyFailures).toBe(1); + } finally { + readFileSpy.mockRestore(); + } + }); + it("should fail import when file contains invalid JSON", async () => { const { importAccounts } = await import("../lib/storage.js"); await fs.writeFile(exportPath, "not valid json {["); @@ -1073,6 +1514,1767 @@ describe("storage", () => { ); }); }); + + it("creates and lists named backups with metadata", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "acct-backup", + refreshToken: "ref-backup", + addedAt: 1, + lastUsed: 2, + }, + ], + }); + + const backup = await createNamedBackup("backup-2026-03-12"); + const backups = await listNamedBackups(); + + expect(backup.name).toBe("backup-2026-03-12"); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "backup-2026-03-12", + accountCount: 1, + valid: true, + }), + ]), + ); + }); + + it("lists named backups across the chunk boundary", async () => { + const expectedNames: string[] = []; + for ( + let index = 0; + index <= NAMED_BACKUP_LIST_CONCURRENCY; + index += 1 + ) { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: `basic-chunk-${index}`, + refreshToken: `ref-basic-chunk-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }, + ], + }); + const name = `basic-chunk-${String(index).padStart(2, "0")}`; + expectedNames.push(name); + await createNamedBackup(name); + } + + const backups = await listNamedBackups(); + + expect(backups).toHaveLength(NAMED_BACKUP_LIST_CONCURRENCY + 1); + expect(backups).toEqual( + expect.arrayContaining( + expectedNames.map((name) => + expect.objectContaining({ + name, + accountCount: 1, + valid: true, + }), + ), + ), + ); + }); + + it("returns a contained fallback path for missing named backups", async () => { + const requestedName = " missing-backup "; + const resolvedPath = + await resolveNamedBackupRestorePath(requestedName); + + expect(resolvedPath).toBe(buildNamedBackupPath("missing-backup")); + await expect(importAccounts(resolvedPath)).rejects.toThrow( + /Import file not found/, + ); + }); + + it("maps read-time ENOENT back to the import file-not-found contract", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "read-race", + refreshToken: "ref-read-race", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const backup = await createNamedBackup("read-race"); + const originalReadFile = fs.readFile.bind(fs); + let injectedEnoent = false; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backup.path && !injectedEnoent) { + injectedEnoent = true; + const error = new Error("backup disappeared") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + } + return originalReadFile(...(args as Parameters)); + }); + + try { + await expect(importAccounts(backup.path)).rejects.toThrow( + `Import file not found: ${backup.path}`, + ); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("assesses eligibility and restores a named backup", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await createNamedBackup("restore-me"); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("restore-me"); + expect(assessment.eligibleForRestore).toBe(true); + expect(assessment.wouldExceedLimit).toBe(false); + + const restoreResult = await restoreNamedBackup("restore-me"); + expect(restoreResult.total).toBe(1); + + const restored = await loadAccounts(); + expect(restored?.accounts[0]?.accountId).toBe("primary"); + }); + + it("honors explicit null currentStorage when assessing a named backup", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "backup-account", + refreshToken: "ref-backup-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("explicit-null-current-storage"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "current-account", + refreshToken: "ref-current-account", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + const assessment = await assessNamedBackupRestore( + "explicit-null-current-storage", + { currentStorage: null }, + ); + + expect(assessment.currentAccountCount).toBe(0); + expect(assessment.mergedAccountCount).toBe(1); + expect(assessment.imported).toBe(1); + expect(assessment.skipped).toBe(0); + expect(assessment.eligibleForRestore).toBe(true); + }); + + it("deduplicates incoming backup rows when assessing restore counts", async () => { + const backupPath = join( + dirname(testStoragePath), + "backups", + "internal-duplicates.json", + ); + await fs.mkdir(dirname(backupPath), { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "duplicate-account", + email: "duplicate-account@example.com", + refreshToken: "ref-duplicate-old", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "duplicate-account", + email: "duplicate-account@example.com", + refreshToken: "ref-duplicate-new", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + "utf-8", + ); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("internal-duplicates"); + + expect(assessment.imported).toBe(1); + expect(assessment.skipped).toBe(0); + expect(assessment.mergedAccountCount).toBe(1); + expect(assessment.eligibleForRestore).toBe(true); + }); + + it("rejects duplicate-only backups with nothing new to restore", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing-account", + refreshToken: "ref-existing-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("already-present"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing-account", + refreshToken: "ref-existing-account", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + const assessment = await assessNamedBackupRestore("already-present"); + expect(assessment.imported).toBe(0); + expect(assessment.skipped).toBe(1); + expect(assessment.eligibleForRestore).toBe(false); + expect(assessment.error).toBe("All accounts in this backup already exist"); + + await expect(restoreNamedBackup("already-present")).rejects.toThrow( + "All accounts in this backup already exist", + ); + }); + + it("treats deduplicated current snapshots as a no-op restore", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing-account", + email: "existing@example.com", + refreshToken: "ref-existing-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("repair-current-duplicates"); + + const assessment = await assessNamedBackupRestore( + "repair-current-duplicates", + { + currentStorage: { + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing-account", + email: "existing@example.com", + refreshToken: "ref-existing-account", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "existing-account", + email: "existing@example.com", + refreshToken: "ref-existing-account", + addedAt: 2, + lastUsed: 2, + }, + ], + }, + }, + ); + expect(assessment.currentAccountCount).toBe(2); + expect(assessment.mergedAccountCount).toBe(1); + expect(assessment.imported).toBe(0); + expect(assessment.skipped).toBe(1); + expect(assessment.eligibleForRestore).toBe(false); + expect(assessment.error).toBe("All accounts in this backup already exist"); + + await expect( + restoreNamedBackup("repair-current-duplicates"), + ).rejects.toThrow("All accounts in this backup already exist"); + }); + + it("treats identical accounts in a different backup order as a no-op restore", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "first-account", + email: "first@example.com", + refreshToken: "ref-first-account", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "second-account", + email: "second@example.com", + refreshToken: "ref-second-account", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await createNamedBackup("reversed-order"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "second-account", + email: "second@example.com", + refreshToken: "ref-second-account", + addedAt: 2, + lastUsed: 2, + }, + { + accountId: "first-account", + email: "first@example.com", + refreshToken: "ref-first-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const assessment = await assessNamedBackupRestore("reversed-order"); + expect(assessment.imported).toBe(0); + expect(assessment.skipped).toBe(2); + expect(assessment.eligibleForRestore).toBe(false); + expect(assessment.error).toBe("All accounts in this backup already exist"); + + await expect(restoreNamedBackup("reversed-order")).rejects.toThrow( + "All accounts in this backup already exist", + ); + }); + + it("keeps metadata-only backups eligible for restore", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing-account", + email: "existing@example.com", + refreshToken: "ref-existing-account", + accessToken: "fresh-access", + addedAt: 1, + lastUsed: 10, + }, + ], + }); + await createNamedBackup("metadata-refresh"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing-account", + email: "existing@example.com", + refreshToken: "ref-existing-account", + accessToken: "stale-access", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const assessment = await assessNamedBackupRestore("metadata-refresh"); + expect(assessment.imported).toBe(0); + expect(assessment.skipped).toBe(1); + expect(assessment.eligibleForRestore).toBe(true); + expect(assessment.error).toBeUndefined(); + + const restoreResult = await restoreNamedBackup("metadata-refresh"); + expect(restoreResult).toMatchObject({ + imported: 0, + skipped: 1, + total: 1, + changed: true, + }); + + const restored = await loadAccounts(); + expect(restored?.accounts[0]).toMatchObject({ + accountId: "existing-account", + accessToken: "fresh-access", + lastUsed: 10, + }); + }); + + it("restores manually named backups that already exist inside the backups directory", async () => { + const backupPath = join( + dirname(testStoragePath), + "backups", + "Manual Backup.json", + ); + await fs.mkdir(dirname(backupPath), { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "manual", + refreshToken: "ref-manual", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "Manual Backup", valid: true }), + ]), + ); + + await clearAccounts(); + const assessment = await assessNamedBackupRestore("Manual Backup"); + expect(assessment.eligibleForRestore).toBe(true); + expect(assessment.backup.name).toBe("Manual Backup"); + + const restoreResult = await restoreNamedBackup("Manual Backup"); + expect(restoreResult.total).toBe(1); + }); + + it("restores manually named backups with uppercase JSON extensions", async () => { + const backupPath = join( + dirname(testStoragePath), + "backups", + "Manual Backup.JSON", + ); + await fs.mkdir(dirname(backupPath), { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "manual-uppercase", + refreshToken: "ref-manual-uppercase", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "Manual Backup", valid: true }), + ]), + ); + + await clearAccounts(); + const assessment = await assessNamedBackupRestore("Manual Backup"); + expect(assessment.eligibleForRestore).toBe(true); + expect(assessment.backup.name).toBe("Manual Backup"); + + const restoreResult = await restoreNamedBackup("Manual Backup"); + expect(restoreResult.total).toBe(1); + }); + + it("throws when a named backup is deleted after assessment", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "deleted-backup", + refreshToken: "ref-deleted-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const backup = await createNamedBackup("deleted-after-assessment"); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("deleted-after-assessment"); + expect(assessment.eligibleForRestore).toBe(true); + + await removeWithRetry(backup.path, { force: true }); + + await expect( + restoreNamedBackup("deleted-after-assessment"), + ).rejects.toThrow(/Import file not found/); + expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); + }); + + it("re-resolves an assessed named backup before the final import", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "deleted-helper", + refreshToken: "ref-deleted-helper", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const backup = await createNamedBackup("deleted-helper-assessment"); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore( + "deleted-helper-assessment", + ); + expect(assessment.eligibleForRestore).toBe(true); + + await removeWithRetry(backup.path, { force: true }); + + await expect(restoreAssessedNamedBackup(assessment)).rejects.toThrow( + /Import file not found/, + ); + expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); + }); + + it("throws when a named backup becomes invalid JSON after assessment", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "invalid-backup", + refreshToken: "ref-invalid-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const backup = await createNamedBackup("invalid-after-assessment"); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("invalid-after-assessment"); + expect(assessment.eligibleForRestore).toBe(true); + + await fs.writeFile(backup.path, "not valid json {[", "utf-8"); + + await expect( + restoreNamedBackup("invalid-after-assessment"), + ).rejects.toThrow(/Invalid JSON in import file/); + expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); + }); + + it.each(["../openai-codex-accounts", String.raw`..\openai-codex-accounts`])( + "rejects backup names that escape the backups directory: %s", + async (input) => { + await expect(assessNamedBackupRestore(input)).rejects.toThrow( + /must not contain path separators/i, + ); + await expect(restoreNamedBackup(input)).rejects.toThrow( + /must not contain path separators/i, + ); + }, + ); + + it("allows backup filenames that begin with dots when they stay inside the backups directory", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const backupPath = join(backupRoot, "..notes.json"); + await fs.mkdir(backupRoot, { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "leading-dot-backup", + refreshToken: "ref-leading-dot-backup", + addedAt: 1, + lastUsed: 2, + }, + ], + }), + "utf-8", + ); + + const assessment = await assessNamedBackupRestore("..notes"); + expect(assessment.eligibleForRestore).toBe(true); + + const result = await restoreNamedBackup("..notes"); + expect(result.imported).toBe(1); + expect((await loadAccounts())?.accounts).toHaveLength(1); + }); + + it("rejects matched backup entries whose resolved path escapes the backups directory", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + const readdirSpy = vi.spyOn(fs, "readdir"); + const escapedEntry = { + name: "../escaped-entry.json", + isFile: () => true, + isSymbolicLink: () => false, + } as unknown as Awaited>[number]; + readdirSpy.mockImplementation(async (...args) => { + const [path, options] = args; + if ( + String(path) === backupRoot && + typeof options === "object" && + options?.withFileTypes === true + ) { + return [escapedEntry] as Awaited>; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + await expect(assessNamedBackupRestore("../escaped-entry")).rejects.toThrow( + /escapes backup directory/i, + ); + await expect(restoreNamedBackup("../escaped-entry")).rejects.toThrow( + /escapes backup directory/i, + ); + } finally { + readdirSpy.mockRestore(); + } + }); + + it("rejects backup paths whose real path escapes the backups directory through symlinked directories", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const outsideRoot = join(testWorkDir, "outside"); + const linkedRoot = join(backupRoot, "linked"); + const outsideBackupPath = join(outsideRoot, "escape.json"); + await fs.mkdir(backupRoot, { recursive: true }); + await fs.mkdir(outsideRoot, { recursive: true }); + await fs.writeFile( + outsideBackupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "linked-escape", + refreshToken: "ref-linked-escape", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + await fs.symlink( + resolve(outsideRoot), + linkedRoot, + process.platform === "win32" ? "junction" : "dir", + ); + + expect(() => + assertNamedBackupRestorePath( + join(linkedRoot, "escape.json"), + backupRoot, + ), + ).toThrow(/escapes backup directory/i); + }); + + it("rejects missing files beneath symlinked backup subdirectories", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const outsideRoot = join(testWorkDir, "outside-missing"); + const linkedRoot = join(backupRoot, "linked-missing"); + await fs.mkdir(backupRoot, { recursive: true }); + await fs.mkdir(outsideRoot, { recursive: true }); + await fs.symlink( + resolve(outsideRoot), + linkedRoot, + process.platform === "win32" ? "junction" : "dir", + ); + + expect(() => + assertNamedBackupRestorePath( + join(linkedRoot, "missing.json"), + backupRoot, + ), + ).toThrow(/escapes backup directory/i); + }); + + it("rejects symlinked backup roots during restore path validation", async () => { + const canonicalBackupRoot = join(testWorkDir, "canonical-backups"); + const linkedBackupRoot = join(testWorkDir, "linked-backups"); + const backupPath = join(canonicalBackupRoot, "linked-root.json"); + await fs.mkdir(canonicalBackupRoot, { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "linked-root", + refreshToken: "ref-linked-root", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + await fs.symlink( + resolve(canonicalBackupRoot), + linkedBackupRoot, + process.platform === "win32" ? "junction" : "dir", + ); + + expect(() => + assertNamedBackupRestorePath( + join(linkedBackupRoot, "linked-root.json"), + linkedBackupRoot, + ), + ).toThrow(/escapes backup directory/i); + }); + + it("rethrows realpath containment errors for existing backup paths", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const backupPath = join(backupRoot, "locked.json"); + await fs.mkdir(backupRoot, { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "locked-path", + refreshToken: "ref-locked-path", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + const originalRealpath = __testOnly.namedBackupContainmentFs.realpath; + const realpathSpy = vi + .spyOn(__testOnly.namedBackupContainmentFs, "realpath") + .mockImplementation((path) => { + if (String(path) === resolve(backupPath)) { + const error = new Error( + "backup path locked", + ) as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return originalRealpath(path); + }); + + try { + expect(() => + assertNamedBackupRestorePath(backupPath, backupRoot), + ).toThrow("Backup path validation failed. Try again."); + } finally { + realpathSpy.mockRestore(); + } + }); + + it("classifies transient realpath errors for the backup root", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const backupPath = join(backupRoot, "pending", "locked.json"); + await fs.mkdir(backupRoot, { recursive: true }); + const transientCode = process.platform === "win32" ? "EPERM" : "EAGAIN"; + + const originalRealpath = __testOnly.namedBackupContainmentFs.realpath; + const realpathSpy = vi + .spyOn(__testOnly.namedBackupContainmentFs, "realpath") + .mockImplementation((path) => { + if (String(path) === resolve(backupRoot)) { + const error = new Error( + "backup root busy", + ) as NodeJS.ErrnoException; + error.code = transientCode; + throw error; + } + return originalRealpath(path); + }); + + try { + expect(() => + assertNamedBackupRestorePath(backupPath, backupRoot), + ).toThrow("Backup path validation failed. Try again."); + } finally { + realpathSpy.mockRestore(); + } + }); + + it("classifies transient lstat errors for the backup root", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const backupPath = join(backupRoot, "pending", "locked.json"); + await fs.mkdir(backupRoot, { recursive: true }); + const originalLstat = __testOnly.namedBackupContainmentFs.lstat; + const lstatSpy = vi + .spyOn(__testOnly.namedBackupContainmentFs, "lstat") + .mockImplementation((path) => { + if (String(path) === resolve(backupRoot)) { + const error = new Error("backup root locked") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalLstat(path); + }); + + try { + expect(() => + assertNamedBackupRestorePath(backupPath, backupRoot), + ).toThrow("Backup path validation failed. Try again."); + } finally { + lstatSpy.mockRestore(); + } + }); + + it("classifies transient backup path validation errors separately from containment escapes", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const backupPath = join(backupRoot, "pending", "locked.json"); + await fs.mkdir(backupRoot, { recursive: true }); + const transientCode = process.platform === "win32" ? "EPERM" : "EAGAIN"; + const originalRealpath = __testOnly.namedBackupContainmentFs.realpath; + const realpathSpy = vi + .spyOn(__testOnly.namedBackupContainmentFs, "realpath") + .mockImplementation((path) => { + if (String(path) === resolve(backupRoot)) { + const error = new Error( + "backup root locked", + ) as NodeJS.ErrnoException; + error.code = transientCode; + throw error; + } + return originalRealpath(path); + }); + + try { + let thrown: unknown; + try { + assertNamedBackupRestorePath(backupPath, backupRoot); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + expect(isNamedBackupPathValidationTransientError(thrown)).toBe(true); + expect(isNamedBackupContainmentError(thrown)).toBe(false); + } finally { + realpathSpy.mockRestore(); + } + }); + + it("rejects named backup listings whose resolved paths escape the backups directory", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + const readdirSpy = vi.spyOn(fs, "readdir"); + const readFileSpy = vi.spyOn(fs, "readFile"); + const escapedEntry = { + name: "../escaped-entry.json", + isFile: () => true, + isSymbolicLink: () => false, + } as unknown as Awaited>[number]; + readdirSpy.mockImplementation(async (...args) => { + const [path, options] = args; + if ( + String(path) === backupRoot && + typeof options === "object" && + options?.withFileTypes === true + ) { + return [escapedEntry] as Awaited>; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + await expect(listNamedBackups()).rejects.toThrow(/escapes backup directory/i); + expect(readFileSpy).not.toHaveBeenCalled(); + } finally { + readFileSpy.mockRestore(); + readdirSpy.mockRestore(); + } + }); + + it("ignores symlink-like named backup entries that point outside the backups root", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const externalBackupPath = join(testWorkDir, "outside-backup.json"); + await fs.mkdir(backupRoot, { recursive: true }); + await fs.writeFile( + externalBackupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "outside-manual-backup", + refreshToken: "ref-outside-manual-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + const originalReaddir = fs.readdir.bind(fs); + const readdirSpy = vi.spyOn(fs, "readdir"); + const escapedEntry = { + name: "escaped-link.json", + isFile: () => false, + isSymbolicLink: () => true, + } as unknown as Awaited< + ReturnType + >[number]; + readdirSpy.mockImplementation(async (...args) => { + const [path, options] = args; + if ( + String(path) === backupRoot && + typeof options === "object" && + options?.withFileTypes === true + ) { + return [escapedEntry] as Awaited>; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toEqual([]); + await expect(assessNamedBackupRestore("escaped-link")).rejects.toThrow( + /not a regular backup file/i, + ); + await expect(restoreNamedBackup("escaped-link")).rejects.toThrow( + /not a regular backup file/i, + ); + } finally { + readdirSpy.mockRestore(); + } + }); + + it("rethrows unreadable backup directory errors while listing backups on non-Windows platforms", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("linux"); + const readdirSpy = vi.spyOn(fs, "readdir"); + const error = new Error("backup directory locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + readdirSpy.mockRejectedValue(error); + + try { + await expect(listNamedBackups()).rejects.toMatchObject({ code: "EPERM" }); + expect(readdirSpy).toHaveBeenCalledTimes(1); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("rethrows unreadable backup directory errors while restoring backups on non-Windows platforms", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("linux"); + const readdirSpy = vi.spyOn(fs, "readdir"); + const error = new Error("backup directory locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + readdirSpy.mockRejectedValue(error); + + try { + await expect(restoreNamedBackup("Manual Backup")).rejects.toMatchObject({ + code: "EPERM", + }); + expect(readdirSpy).toHaveBeenCalledTimes(1); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("retries EAGAIN backup directory errors while restoring backups on non-Windows platforms", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("linux"); + const readdirSpy = vi.spyOn(fs, "readdir"); + const error = new Error("backup directory busy") as NodeJS.ErrnoException; + error.code = "EAGAIN"; + readdirSpy.mockRejectedValue(error); + + try { + await expect(restoreNamedBackup("Manual Backup")).rejects.toMatchObject({ + code: "EAGAIN", + }); + expect(readdirSpy).toHaveBeenCalledTimes(7); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("retries transient EBUSY backup directory errors while listing backups on win32", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-list-dir", + refreshToken: "ref-retry-list-dir", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("retry-list-dir"); + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + let busyFailures = 0; + const readdirSpy = vi + .spyOn(fs, "readdir") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backupRoot && busyFailures === 0) { + busyFailures += 1; + const error = new Error( + "backup directory busy", + ) as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "retry-list-dir", valid: true }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("retries transient EAGAIN backup directory errors while listing backups", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-list-dir-not-empty", + refreshToken: "ref-retry-list-dir-not-empty", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("retry-list-dir-not-empty"); + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + let busyFailures = 0; + const readdirSpy = vi + .spyOn(fs, "readdir") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backupRoot && busyFailures === 0) { + busyFailures += 1; + const error = new Error( + "backup directory not empty yet", + ) as NodeJS.ErrnoException; + error.code = "EAGAIN"; + throw error; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "retry-list-dir-not-empty", + valid: true, + }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + readdirSpy.mockRestore(); + } + }); + + it("retries transient EPERM backup directory errors while listing backups on win32", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-list-dir-eperm", + refreshToken: "ref-retry-list-dir-eperm", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("retry-list-dir-eperm"); + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + let busyFailures = 0; + const readdirSpy = vi + .spyOn(fs, "readdir") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backupRoot && busyFailures === 0) { + busyFailures += 1; + const error = new Error( + "backup directory busy", + ) as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "retry-list-dir-eperm", + valid: true, + }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("retries a second-chunk backup read when listing more than one chunk of backups", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + const backups: Awaited>[] = []; + for ( + let index = 0; + index <= NAMED_BACKUP_LIST_CONCURRENCY; + index += 1 + ) { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: `chunk-boundary-${index}`, + refreshToken: `ref-chunk-boundary-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }, + ], + }); + backups.push( + await createNamedBackup(`chunk-boundary-${String(index).padStart(2, "0")}`), + ); + } + + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + const originalReadFile = fs.readFile.bind(fs); + const secondChunkBackup = backups.at(-1); + let busyFailures = 0; + const readdirSpy = vi + .spyOn(fs, "readdir") + .mockImplementation(async (...args) => { + const [path, options] = args; + if ( + String(path) === backupRoot && + typeof options === "object" && + options?.withFileTypes === true + ) { + const entries = await originalReaddir( + ...(args as Parameters), + ); + return [...entries].sort((left, right) => + left.name.localeCompare(right.name), + ) as Awaited>; + } + return originalReaddir(...(args as Parameters)); + }); + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === secondChunkBackup?.path && busyFailures === 0) { + busyFailures += 1; + const error = new Error("backup file busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalReadFile(...(args as Parameters)); + }); + + try { + const listedBackups = await listNamedBackups(); + expect(listedBackups).toHaveLength(NAMED_BACKUP_LIST_CONCURRENCY + 1); + expect(listedBackups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: `chunk-boundary-${String( + NAMED_BACKUP_LIST_CONCURRENCY, + ).padStart(2, "0")}`, + valid: true, + }), + ]), + ); + expect(busyFailures).toBe(1); + expect( + readFileSpy.mock.calls.filter( + ([path]) => String(path) === secondChunkBackup?.path, + ), + ).toHaveLength(2); + } finally { + readFileSpy.mockRestore(); + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("retries transient EAGAIN backup directory errors while restoring backups", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-restore-dir", + refreshToken: "ref-retry-restore-dir", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("retry-restore-dir"); + await clearAccounts(); + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + let busyFailures = 0; + const readdirSpy = vi + .spyOn(fs, "readdir") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backupRoot && busyFailures === 0) { + busyFailures += 1; + const error = new Error( + "backup directory busy", + ) as NodeJS.ErrnoException; + error.code = "EAGAIN"; + throw error; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + const result = await restoreNamedBackup("retry-restore-dir"); + expect(result.total).toBe(1); + expect(busyFailures).toBe(1); + } finally { + readdirSpy.mockRestore(); + } + }); + + it("throws file-not-found when a manually named backup disappears after assessment", async () => { + const backupPath = join( + dirname(testStoragePath), + "backups", + "Manual Backup.json", + ); + await fs.mkdir(dirname(backupPath), { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "manual-missing", + refreshToken: "ref-manual-missing", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("Manual Backup"); + expect(assessment.eligibleForRestore).toBe(true); + const storageBeforeRestore = await loadAccounts(); + expect(storageBeforeRestore?.accounts ?? []).toHaveLength(0); + + await removeWithRetry(backupPath, { force: true }); + + await expect(restoreNamedBackup("Manual Backup")).rejects.toThrow( + /Import file not found/, + ); + expect(await loadAccounts()).toEqual(storageBeforeRestore); + }); + + it("retries transient EBUSY backup read errors while listing backups on win32", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-read", + refreshToken: "ref-retry-read", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const backup = await createNamedBackup("retry-read"); + const originalReadFile = fs.readFile.bind(fs); + let busyFailures = 0; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backup.path && busyFailures === 0) { + busyFailures += 1; + const error = new Error("backup file busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalReadFile(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "retry-read", valid: true }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + readFileSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("retries transient backup stat EAGAIN errors while listing backups", async () => { + let statSpy: ReturnType | undefined; + try { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-stat", + refreshToken: "ref-retry-stat", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const backup = await createNamedBackup("retry-stat"); + const originalStat = fs.stat.bind(fs); + let busyFailures = 0; + statSpy = vi.spyOn(fs, "stat").mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backup.path && busyFailures === 0) { + busyFailures += 1; + const error = new Error("backup stat busy") as NodeJS.ErrnoException; + error.code = "EAGAIN"; + throw error; + } + return originalStat(...(args as Parameters)); + }); + + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "retry-stat", valid: true }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + statSpy?.mockRestore(); + } + }); + + it("sorts backups with invalid timestamps after finite timestamps", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "valid-backup", + refreshToken: "ref-valid-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const validBackup = await createNamedBackup("valid-backup"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "nan-backup", + refreshToken: "ref-nan-backup", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + const nanBackup = await createNamedBackup("nan-backup"); + const originalStat = fs.stat.bind(fs); + const statSpy = vi.spyOn(fs, "stat").mockImplementation(async (...args) => { + const [path] = args; + const stats = await originalStat(...(args as Parameters)); + if (String(path) === nanBackup.path) { + return { + ...stats, + mtimeMs: Number.NaN, + } as Awaited>; + } + return stats; + }); + + try { + const backups = await listNamedBackups(); + expect(backups.map((backup) => backup.name)).toEqual([ + validBackup.name, + nanBackup.name, + ]); + } finally { + statSpy.mockRestore(); + } + }); + + it("reuses freshly listed backup candidates for the first restore assessment", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "cached-backup", + refreshToken: "ref-cached-backup", + addedAt: 1, + lastUsed: 2, + }, + ], + }); + const backup = await createNamedBackup("cached-backup"); + const readFileSpy = vi.spyOn(fs, "readFile"); + const candidateCache = new Map(); + + try { + await listNamedBackups({ candidateCache }); + await assessNamedBackupRestore("cached-backup", { + currentStorage: null, + candidateCache, + }); + + const firstPassReads = readFileSpy.mock.calls.filter( + ([path]) => path === backup.path, + ); + expect(firstPassReads).toHaveLength(1); + + await assessNamedBackupRestore("cached-backup", { currentStorage: null }); + + const secondPassReads = readFileSpy.mock.calls.filter( + ([path]) => path === backup.path, + ); + expect(secondPassReads).toHaveLength(2); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("ignores invalid externally provided candidate cache entries", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "external-cache-backup", + refreshToken: "ref-external-cache-backup", + addedAt: 1, + lastUsed: 2, + }, + ], + }); + const backup = await createNamedBackup("external-cache-backup"); + const readFileSpy = vi.spyOn(fs, "readFile"); + const candidateCache = new Map([ + [ + backup.path, + { + normalized: { version: 3 }, + storedVersion: 3, + schemaErrors: [], + }, + ], + ]); + + try { + const assessment = await assessNamedBackupRestore( + "external-cache-backup", + { + currentStorage: null, + candidateCache, + }, + ); + expect(assessment).toEqual( + expect.objectContaining({ + eligibleForRestore: true, + backup: expect.objectContaining({ + name: "external-cache-backup", + path: backup.path, + }), + }), + ); + expect( + readFileSpy.mock.calls.filter(([path]) => path === backup.path), + ).toHaveLength(1); + expect(candidateCache.has(backup.path)).toBe(false); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("keeps per-call named-backup caches isolated across concurrent listings", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "isolated-cache-backup", + refreshToken: "ref-isolated-cache-backup", + addedAt: 1, + lastUsed: 2, + }, + ], + }); + const backup = await createNamedBackup("isolated-cache-backup"); + const readFileSpy = vi.spyOn(fs, "readFile"); + const firstCandidateCache = new Map(); + const secondCandidateCache = new Map(); + + try { + await Promise.all([ + listNamedBackups({ candidateCache: firstCandidateCache }), + listNamedBackups({ candidateCache: secondCandidateCache }), + ]); + + await assessNamedBackupRestore("isolated-cache-backup", { + currentStorage: null, + candidateCache: firstCandidateCache, + }); + await assessNamedBackupRestore("isolated-cache-backup", { + currentStorage: null, + candidateCache: secondCandidateCache, + }); + + const cachedReads = readFileSpy.mock.calls.filter( + ([path]) => path === backup.path, + ); + expect(cachedReads).toHaveLength(2); + + await assessNamedBackupRestore("isolated-cache-backup", { + currentStorage: null, + }); + + const rereadCalls = readFileSpy.mock.calls.filter( + ([path]) => path === backup.path, + ); + expect(rereadCalls).toHaveLength(3); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("limits concurrent backup reads while listing backups", async () => { + const backupPaths: string[] = []; + const totalBackups = NAMED_BACKUP_LIST_CONCURRENCY + 4; + for (let index = 0; index < totalBackups; index += 1) { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: `concurrency-${index}`, + refreshToken: `ref-concurrency-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }, + ], + }); + const backup = await createNamedBackup(`concurrency-${index}`); + backupPaths.push(backup.path); + } + + const originalReadFile = fs.readFile.bind(fs); + const delayedPaths = new Set(backupPaths); + let activeReads = 0; + let peakReads = 0; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [path] = args; + if (delayedPaths.has(String(path))) { + activeReads += 1; + peakReads = Math.max(peakReads, activeReads); + try { + await new Promise((resolve) => setTimeout(resolve, 10)); + return await originalReadFile( + ...(args as Parameters), + ); + } finally { + activeReads -= 1; + } + } + return originalReadFile(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toHaveLength(totalBackups); + expect(peakReads).toBeLessThanOrEqual( + NAMED_BACKUP_LIST_CONCURRENCY, + ); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("serializes concurrent restores so only one succeeds when the limit is tight", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "backup-a-account", + refreshToken: "ref-backup-a-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("backup-a"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "backup-b-account", + refreshToken: "ref-backup-b-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("backup-b"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: Array.from( + { length: ACCOUNT_LIMITS.MAX_ACCOUNTS - 1 }, + (_, index) => ({ + accountId: `current-${index}`, + refreshToken: `ref-current-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }), + ), + }); + + const assessmentA = await assessNamedBackupRestore("backup-a"); + const assessmentB = await assessNamedBackupRestore("backup-b"); + expect(assessmentA.eligibleForRestore).toBe(true); + expect(assessmentB.eligibleForRestore).toBe(true); + + const results = await Promise.allSettled([ + restoreNamedBackup("backup-a"), + restoreNamedBackup("backup-b"), + ]); + const succeeded = results.filter( + (result): result is PromiseFulfilledResult<{ + imported: number; + skipped: number; + total: number; + }> => result.status === "fulfilled", + ); + const failed = results.filter( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + + expect(succeeded).toHaveLength(1); + expect(failed).toHaveLength(1); + expect(String(failed[0]?.reason)).toContain("Import would exceed maximum"); + + const restored = await loadAccounts(); + expect(restored?.accounts).toHaveLength(ACCOUNT_LIMITS.MAX_ACCOUNTS); + }); }); describe("filename migration (TDD)", () => { From 12074e23fdfa6ec588836604cb423d8e140d3067 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 23:41:55 +0800 Subject: [PATCH 03/13] feat(auth): prompt for recovery on startup --- docs/getting-started.md | 2 + docs/reference/commands.md | 2 - docs/reference/public-api.md | 6 - docs/reference/storage-paths.md | 4 - docs/troubleshooting.md | 2 + docs/upgrade.md | 6 + lib/cli.ts | 6 +- lib/codex-manager.ts | 408 +++-- lib/storage.ts | 784 ++++----- lib/ui/copy.ts | 19 - test/cli.test.ts | 10 - test/codex-manager-cli.test.ts | 2545 +++++++++++++-------------- test/recovery.test.ts | 2935 +++++++++++++++++++------------ test/storage.test.ts | 1613 +---------------- 14 files changed, 3708 insertions(+), 4634 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 2de9337b..45337911 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -49,6 +49,8 @@ Expected flow: 4. Return to the terminal when the browser step completes. 5. Confirm the account appears in the saved account list. +If interactive `codex auth login` starts with zero saved accounts and recoverable named backups in your `backups/` directory, the login flow will prompt you to restore before opening OAuth. Confirm to launch the existing restore manager; skip to proceed with a fresh login. The prompt is suppressed in non-interactive/fallback flows and after same-session `fresh` or `reset` actions. + Verify the new account: ```bash diff --git a/docs/reference/commands.md b/docs/reference/commands.md index f7a9cf0a..36c735f9 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -26,7 +26,6 @@ Compatibility aliases are supported: | `codex auth switch ` | Set active account by index | | `codex auth check` | Run quick account health check | | `codex auth features` | Print implemented feature summary | -| `codex auth restore-backup` | Open the backup restore picker directly | --- @@ -112,7 +111,6 @@ codex auth report --live --json Repair and recovery: ```bash -codex auth restore-backup codex auth fix --dry-run codex auth fix --live --model gpt-5-codex codex auth doctor --fix diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index a76eb85a..865189ff 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -37,12 +37,6 @@ Compatibility policy for Tier B: - Existing exported symbols must not be removed in this release line. - Deprecated usage may be documented, but hard removals require a major version transition plan. -Current additive compatibility note: - -- `importAccounts()` now returns `{ imported, total, skipped, changed }` at runtime. -- The exported `ImportAccountsResult` type keeps `changed` optional so older callers modeling the legacy shape remain source-compatible. -- New callers should read `changed` to distinguish duplicate-only no-ops from metadata-refresh writes. - ### Tier C: Internal APIs Internal APIs are any non-exported internals and implementation details not covered by Tier A or Tier B. diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index 8ea743ec..cf0747de 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -109,10 +109,6 @@ Restore workflow: 3. Choose `Restore From Backup`. 4. Pick a backup and confirm the merge summary before import. -Direct entrypoint: - -- Run `codex auth restore-backup` to open the same picker without entering the full login dashboard first. - --- ## oc-chatgpt Target Paths diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0c0bbe86..b31aa95c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -18,6 +18,8 @@ If the account pool is still not usable: codex auth login ``` +If `codex auth login` starts with no saved accounts and recoverable named backups are present, you will be prompted to restore before OAuth. This prompt only appears in interactive terminals and is skipped after same-session fresh/reset flows. + --- ## Verify Install And Routing diff --git a/docs/upgrade.md b/docs/upgrade.md index e34ecb2d..6f2d453d 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -62,6 +62,12 @@ After source selection, environment variables still override individual setting For day-to-day operator use, prefer stable overrides documented in [configuration.md](configuration.md). For maintainer/debug flows, see advanced/internal controls in [development/CONFIG_FIELDS.md](development/CONFIG_FIELDS.md). +### Startup Recovery Prompt + +Interactive `codex auth login` now offers named-backup recovery before OAuth only when the session starts with zero saved accounts and at least one recoverable named backup. + +The prompt is intentionally skipped in fallback/non-interactive login paths and after same-session `fresh` or `reset` actions so an intentional wipe does not immediately re-offer restore state. + --- ## Legacy Compatibility diff --git a/lib/cli.ts b/lib/cli.ts index 67c304db..b0a81b35 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -26,6 +26,10 @@ export function isNonInteractiveMode(): boolean { return false; } +export function isInteractiveLoginMenuAvailable(): boolean { + return !isNonInteractiveMode() && isTTY(); +} + export async function promptAddAnotherAccount( currentCount: number, ): Promise { @@ -259,7 +263,7 @@ export async function promptLoginMode( return { mode: "add" }; } - if (!isTTY()) { + if (!isInteractiveLoginMenuAvailable()) { return promptLoginModeFallback(existingAccounts); } diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 0999cc29..031b628c 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -10,7 +10,12 @@ 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 { + isInteractiveLoginMenuAvailable, + promptAddAnotherAccount, + promptLoginMode, + type ExistingAccountInfo, +} from "./cli.js"; import { extractAccountEmail, extractAccountId, @@ -57,12 +62,11 @@ import { } from "./quota-cache.js"; import { assessNamedBackupRestore, + getActionableNamedBackupRestores, getNamedBackupsDirectoryPath, - isNamedBackupContainmentError, - isNamedBackupPathValidationTransientError, listNamedBackups, - NAMED_BACKUP_ASSESS_CONCURRENCY, - restoreAssessedNamedBackup, + NAMED_BACKUP_LIST_CONCURRENCY, + restoreNamedBackup, findMatchingAccountIndex, getStoragePath, loadFlaggedAccounts, @@ -136,9 +140,7 @@ function formatReasonLabel(reason: string | undefined): string | undefined { function formatRelativeDateShort( timestamp: number | null | undefined, ): string | null { - if (timestamp === null || timestamp === undefined || timestamp === 0) - return null; - if (!Number.isFinite(timestamp)) return null; + if (timestamp === null || timestamp === undefined) return null; const days = Math.floor((Date.now() - timestamp) / 86_400_000); if (days <= 0) return "today"; if (days === 1) return "yesterday"; @@ -329,7 +331,6 @@ function printUsage(): void { " codex auth report [--live] [--json] [--model ] [--out ]", " codex auth fix [--dry-run] [--json] [--live] [--model ]", " codex auth doctor [--json] [--fix] [--dry-run]", - " codex auth restore-backup", "", "Notes:", " - Uses ~/.codex/multi-auth/openai-codex-accounts.json", @@ -3821,44 +3822,82 @@ async function handleManageAction( } } +type StartupRecoveryAction = + | "continue-with-oauth" + | "open-empty-storage-menu" + | "show-recovery-prompt"; + +export function resolveStartupRecoveryAction( + recoveryState: Awaited>, + recoveryScanFailed: boolean, +): StartupRecoveryAction { + if (recoveryState.assessments.length > 0) { + return "show-recovery-prompt"; + } + return recoveryScanFailed + ? "continue-with-oauth" + : "open-empty-storage-menu"; +} + async function runAuthLogin(): Promise { setStoragePath(null); + let suppressRecoveryPrompt = false; + let recoveryPromptAttempted = false; + let allowEmptyStorageMenu = false; + let pendingRecoveryState: Awaited< + ReturnType + > | null = null; let pendingMenuQuotaRefresh: Promise | null = null; let menuQuotaRefreshStatus: string | undefined; loginFlow: while (true) { - while (true) { - const existingStorage = await loadAccounts(); - const currentStorage = existingStorage ?? createEmptyAccountStorage(); - const displaySettings = await loadDashboardDisplaySettings(); - applyUiThemeFromDashboardSettings(displaySettings); - const quotaCache = await loadQuotaCache(); - const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true; - const showFetchStatus = displaySettings.menuShowFetchStatus ?? true; - const quotaTtlMs = displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; - if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) { - const staleCount = countMenuQuotaRefreshTargets(currentStorage, quotaCache, quotaTtlMs); - if (staleCount > 0) { - if (showFetchStatus) { - menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`; + let existingStorage = await loadAccounts(); + const canOpenEmptyStorageMenu = + allowEmptyStorageMenu && isInteractiveLoginMenuAvailable(); + if ( + (existingStorage && existingStorage.accounts.length > 0) || + canOpenEmptyStorageMenu + ) { + const menuAllowsEmptyStorage = canOpenEmptyStorageMenu; + allowEmptyStorageMenu = false; + pendingRecoveryState = null; + while (true) { + existingStorage = await loadAccounts(); + if (!existingStorage || existingStorage.accounts.length === 0) { + if (!menuAllowsEmptyStorage) { + break; + } + } + const currentStorage = existingStorage ?? createEmptyAccountStorage(); + const displaySettings = await loadDashboardDisplaySettings(); + applyUiThemeFromDashboardSettings(displaySettings); + const quotaCache = await loadQuotaCache(); + const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true; + const showFetchStatus = displaySettings.menuShowFetchStatus ?? true; + const quotaTtlMs = displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; + if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) { + const staleCount = countMenuQuotaRefreshTargets(currentStorage, quotaCache, quotaTtlMs); + if (staleCount > 0) { + if (showFetchStatus) { + menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`; + } + pendingMenuQuotaRefresh = refreshQuotaCacheForMenu( + currentStorage, + quotaCache, + quotaTtlMs, + (current, total) => { + if (!showFetchStatus) return; + menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`; + }, + ) + .then(() => undefined) + .catch(() => undefined) + .finally(() => { + menuQuotaRefreshStatus = undefined; + pendingMenuQuotaRefresh = null; + }); } - pendingMenuQuotaRefresh = refreshQuotaCacheForMenu( - currentStorage, - quotaCache, - quotaTtlMs, - (current, total) => { - if (!showFetchStatus) return; - menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`; - }, - ) - .then(() => undefined) - .catch(() => undefined) - .finally(() => { - menuQuotaRefreshStatus = undefined; - pendingMenuQuotaRefresh = null; - }); } - } const flaggedStorage = await loadFlaggedAccounts(); const menuResult = await promptLoginMode( @@ -3873,18 +3912,6 @@ async function runAuthLogin(): Promise { console.log("Cancelled."); return 0; } - const modeRequiresDrainedQuotaRefresh = - menuResult.mode === "check" || - menuResult.mode === "deep-check" || - menuResult.mode === "forecast" || - menuResult.mode === "fix" || - menuResult.mode === "restore-backup"; - if (modeRequiresDrainedQuotaRefresh) { - const pendingQuotaRefresh = pendingMenuQuotaRefresh; - if (pendingQuotaRefresh) { - await pendingQuotaRefresh; - } - } if (menuResult.mode === "check") { await runActionPanel("Quick Check", "Checking local session + live status", async () => { await runHealthCheck({ forceRefresh: false, liveProbe: true }); @@ -3921,6 +3948,10 @@ async function runAuthLogin(): Promise { } if (menuResult.mode === "restore-backup") { try { + const pendingQuotaRefresh = pendingMenuQuotaRefresh; + if (pendingQuotaRefresh) { + await pendingQuotaRefresh; + } await runBackupRestoreManager(displaySettings); } catch (error) { const message = @@ -3954,6 +3985,7 @@ async function runAuthLogin(): Promise { } finally { destructiveActionInFlight = false; } + suppressRecoveryPrompt = true; continue; } if (menuResult.mode === "reset") { @@ -3985,6 +4017,7 @@ async function runAuthLogin(): Promise { } finally { destructiveActionInFlight = false; } + suppressRecoveryPrompt = true; continue; } if (menuResult.mode === "manage") { @@ -4002,9 +4035,91 @@ async function runAuthLogin(): Promise { break; } } + } const refreshedStorage = await loadAccounts(); const existingCount = refreshedStorage?.accounts.length ?? 0; + const canPromptForRecovery = + !suppressRecoveryPrompt && + !recoveryPromptAttempted && + existingCount === 0 && + isInteractiveLoginMenuAvailable(); + if (canPromptForRecovery) { + recoveryPromptAttempted = true; + let recoveryState: Awaited< + ReturnType + > | null = pendingRecoveryState; + pendingRecoveryState = null; + if (recoveryState === null) { + let recoveryScanFailed = false; + let scannedRecoveryState: Awaited< + ReturnType + >; + try { + scannedRecoveryState = await getActionableNamedBackupRestores({ + currentStorage: refreshedStorage, + }); + } catch (error) { + recoveryScanFailed = true; + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn( + `Startup recovery scan failed (${errorLabel}). Continuing with OAuth.`, + ); + scannedRecoveryState = { + assessments: [], + allAssessments: [], + totalBackups: 0, + }; + } + recoveryState = scannedRecoveryState; + if ( + resolveStartupRecoveryAction(scannedRecoveryState, recoveryScanFailed) === + "open-empty-storage-menu" + ) { + allowEmptyStorageMenu = true; + continue loginFlow; + } + } + if (recoveryState.assessments.length > 0) { + let promptWasShown = false; + try { + const displaySettings = await loadDashboardDisplaySettings(); + applyUiThemeFromDashboardSettings(displaySettings); + const backupDir = getNamedBackupsDirectoryPath(); + const backupLabel = + recoveryState.assessments.length === 1 + ? recoveryState.assessments + .map((assessment) => assessment.backup.name) + .join("") + : `${recoveryState.assessments.length} backups`; + promptWasShown = true; + const restoreNow = await confirm( + `Found ${recoveryState.assessments.length} recoverable backup${ + recoveryState.assessments.length === 1 ? "" : "s" + } out of ${recoveryState.totalBackups} total (${backupLabel}) in ${backupDir}. Restore now?`, + ); + if (restoreNow) { + const restoreResult = await runBackupRestoreManager( + displaySettings, + recoveryState.allAssessments, + ); + if (restoreResult !== "restored") { + pendingRecoveryState = recoveryState; + recoveryPromptAttempted = false; + } + continue; + } + } catch (error) { + if (!promptWasShown) { + recoveryPromptAttempted = false; + } + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn( + `Startup recovery prompt failed (${errorLabel}). Continuing with OAuth.`, + ); + } + } + } let forceNewLogin = existingCount > 0; while (true) { const tokenResult = await runOAuthFlow(forceNewLogin); @@ -4216,57 +4331,57 @@ export async function autoSyncActiveAccountToCodex(): Promise { type BackupMenuAction = | { type: "restore"; - assessment: Awaited>; + assessment: BackupRestoreAssessment; } | { type: "back" }; -async function runBackupRestoreManager( - displaySettings: DashboardDisplaySettings, -): Promise { - const backupDir = getNamedBackupsDirectoryPath(); - // Reuse only within this list -> assess flow so storage.ts can safely treat - // the cache contents as LoadedBackupCandidate entries. - const candidateCache = new Map(); +type BackupRestoreAssessment = Awaited< + ReturnType +>; + +type BackupRestoreManagerResult = "restored" | "dismissed"; + +function getRedactedFilesystemErrorLabel(error: unknown): string { + const code = (error as NodeJS.ErrnoException).code; + if (typeof code === "string" && code.trim().length > 0) { + return code; + } + if (error instanceof Error && error.name && error.name !== "Error") { + return error.name; + } + return "UNKNOWN"; +} + +async function loadBackupRestoreManagerAssessments(): Promise< + BackupRestoreAssessment[] +> { let backups: Awaited>; try { - backups = await listNamedBackups({ candidateCache }); + backups = await listNamedBackups(); } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (isNamedBackupContainmentError(error)) { - console.error( - `Backup validation failed: ${collapseWhitespace(message) || "unknown error"}`, - ); - } else if (isNamedBackupPathValidationTransientError(error)) { - console.error(collapseWhitespace(message) || "unknown error"); - } else { - console.error( - `Could not read backup directory: ${ - collapseWhitespace(message) || "unknown error" - }`, - ); - } - return false; + console.error( + `Could not read backup directory: ${ + collapseWhitespace(message) || "unknown error" + }`, + ); + return []; } if (backups.length === 0) { - console.log(`No named backups found. Place backup files in ${backupDir}.`); - return true; + return []; } const currentStorage = await loadAccounts(); - const assessments: Awaited>[] = []; - const assessmentFailures: string[] = []; + const assessments: BackupRestoreAssessment[] = []; for ( let index = 0; index < backups.length; - index += NAMED_BACKUP_ASSESS_CONCURRENCY + index += NAMED_BACKUP_LIST_CONCURRENCY ) { - const chunk = backups.slice(index, index + NAMED_BACKUP_ASSESS_CONCURRENCY); + const chunk = backups.slice(index, index + NAMED_BACKUP_LIST_CONCURRENCY); const settledAssessments = await Promise.allSettled( chunk.map((backup) => - assessNamedBackupRestore(backup.name, { - currentStorage, - candidateCache, - }), + assessNamedBackupRestore(backup.name, { currentStorage }), ), ); for (const [resultIndex, result] of settledAssessments.entries()) { @@ -4274,29 +4389,32 @@ async function runBackupRestoreManager( assessments.push(result.value); continue; } - if (isNamedBackupContainmentError(result.reason)) { - throw result.reason; - } const backupName = chunk[resultIndex]?.name ?? "unknown"; const reason = result.reason instanceof Error ? result.reason.message : String(result.reason); - const normalizedReason = - collapseWhitespace(reason) || "unknown error"; - assessmentFailures.push(`${backupName}: ${normalizedReason}`); console.warn( - `Skipped backup assessment for "${backupName}": ${normalizedReason}`, + `Skipped backup assessment for "${backupName}": ${ + collapseWhitespace(reason) || "unknown error" + }`, ); } } + + return assessments; +} + +async function runBackupRestoreManager( + displaySettings: DashboardDisplaySettings, + assessmentsOverride?: BackupRestoreAssessment[], +): Promise { + const backupDir = getNamedBackupsDirectoryPath(); + const assessments = + assessmentsOverride ?? (await loadBackupRestoreManagerAssessments()); if (assessments.length === 0) { - console.error( - `Could not assess any named backups in ${backupDir}: ${ - assessmentFailures.join("; ") || "all assessments failed" - }`, - ); - return false; + console.log(`No named backups found. Place backup files in ${backupDir}.`); + return "dismissed"; } const items: MenuItem[] = assessments.map((assessment) => { @@ -4345,85 +4463,43 @@ async function runBackupRestoreManager( }); if (!selection || selection.type === "back") { - return true; + return "dismissed"; } - let latestAssessment: Awaited>; + let latestAssessment: BackupRestoreAssessment; try { latestAssessment = await assessNamedBackupRestore( selection.assessment.backup.name, { currentStorage: await loadAccounts() }, ); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error( - `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn( + `Failed to re-assess backup "${selection.assessment.backup.name}" before restore (${errorLabel}).`, ); - return false; + return "dismissed"; } if (!latestAssessment.eligibleForRestore) { console.log(latestAssessment.error ?? "Backup is not eligible for restore."); - return false; + return "dismissed"; } - const netNewAccounts = latestAssessment.imported ?? 0; - const confirmMessage = UI_COPY.mainMenu.restoreBackupConfirm( - latestAssessment.backup.name, - netNewAccounts, - latestAssessment.backup.accountCount ?? 0, - latestAssessment.currentAccountCount, - latestAssessment.mergedAccountCount ?? latestAssessment.currentAccountCount, - ); + const confirmMessage = `Restore backup "${latestAssessment.backup.name}"? This will merge ${latestAssessment.backup.accountCount ?? 0} account(s) into ${latestAssessment.currentAccountCount} current (${latestAssessment.mergedAccountCount ?? latestAssessment.currentAccountCount} after dedupe).`; const confirmed = await confirm(confirmMessage); - if (!confirmed) return true; + if (!confirmed) return "dismissed"; try { - const result = await restoreAssessedNamedBackup(latestAssessment); - if (!result.changed) { - console.log("All accounts in this backup already exist"); - return true; - } - if (result.imported === 0) { - console.log( - UI_COPY.mainMenu.restoreBackupRefreshSuccess( - latestAssessment.backup.name, - ), - ); - } else { - console.log( - UI_COPY.mainMenu.restoreBackupSuccess( - latestAssessment.backup.name, - result.imported, - result.skipped, - result.total, - ), - ); - } - try { - const synced = await autoSyncActiveAccountToCodex(); - if (!synced) { - console.warn( - "Backup restored, but Codex CLI auth state could not be synced.", - ); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn( - `Backup restored, but Codex CLI auth sync failed: ${ - collapseWhitespace(message) || "unknown error" - }`, - ); - } - return true; + const result = await restoreNamedBackup(latestAssessment.backup.name); + console.log( + `Restored backup "${latestAssessment.backup.name}". Imported ${result.imported}, skipped ${result.skipped}, total ${result.total}.`, + ); + return "restored"; } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const collapsedMessage = collapseWhitespace(message) || "unknown error"; - console.error( - /exceed maximum/i.test(collapsedMessage) - ? `Restore failed: ${collapsedMessage}. Close other Codex instances and try again.` - : `Restore failed: ${collapsedMessage}`, + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn( + `Failed to restore backup "${latestAssessment.backup.name}" (${errorLabel}).`, ); - return false; + return "dismissed"; } } @@ -4455,6 +4531,10 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { if (command === "login") { return runAuthLogin(); } + if (command === "restore-backup") { + await runBackupRestoreManager(startupDisplaySettings); + return 0; + } if (command === "list" || command === "status") { await showAccountStatus(); return 0; @@ -4484,20 +4564,6 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { if (command === "doctor") { return runDoctor(rest); } - if (command === "restore-backup") { - setStoragePath(null); - try { - const completedWithoutFailure = - await runBackupRestoreManager(startupDisplaySettings); - return completedWithoutFailure ? 0 : 1; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error( - `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, - ); - return 1; - } - } console.error(`Unknown command: ${command}`); printUsage(); diff --git a/lib/storage.ts b/lib/storage.ts index a6f0f5ad..d0e130cd 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,13 +1,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; -import { - existsSync, - lstatSync, - promises as fs, - realpathSync, - type Dirent, -} from "node:fs"; -import { basename, dirname, isAbsolute, join, relative } from "node:path"; +import { existsSync, promises as fs } from "node:fs"; +import { basename, dirname, join } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { @@ -53,15 +47,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; -// Max total wait across 6 sleeps is about 1.26 s with proportional jitter. -// That's acceptable for transient AV/file-lock recovery, but it also bounds how -// long the interactive restore menu can pause while listing or assessing backups. -const TRANSIENT_FILESYSTEM_MAX_ATTEMPTS = 7; -const TRANSIENT_FILESYSTEM_BASE_DELAY_MS = 10; export const NAMED_BACKUP_LIST_CONCURRENCY = 8; -// Each assessment does more I/O than a listing pass, so keep a lower ceiling to -// reduce transient AV/file-lock pressure on Windows restore menus. -export const NAMED_BACKUP_ASSESS_CONCURRENCY = 4; const RESET_MARKER_SUFFIX = ".reset-intent"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -148,75 +134,74 @@ export interface BackupRestoreAssessment { currentAccountCount: number; mergedAccountCount: number | null; imported: number | null; - // Accounts already present in current storage. Metadata-only refreshes can - // still report them here because they are merged rather than newly imported. skipped: number | null; wouldExceedLimit: boolean; eligibleForRestore: boolean; error?: string; } -type LoadedBackupCandidate = { +export interface ActionableNamedBackupRecoveries { + assessments: BackupRestoreAssessment[]; + allAssessments: BackupRestoreAssessment[]; + totalBackups: number; +} + +interface LoadedBackupCandidate { normalized: AccountStorageV3 | null; storedVersion: unknown; schemaErrors: string[]; error?: string; -}; +} -type NamedBackupCandidateCache = Map; +interface NamedBackupScanEntry { + backup: NamedBackupMetadata; + candidate: LoadedBackupCandidate; +} -class BackupContainmentError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - this.name = "BackupContainmentError"; - } +interface NamedBackupScanResult { + backups: NamedBackupScanEntry[]; + totalBackups: number; } -class BackupPathValidationTransientError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - this.name = "BackupPathValidationTransientError"; - } +interface NamedBackupMetadataListingResult { + backups: NamedBackupMetadata[]; + totalBackups: number; } -function isLoadedBackupCandidate( - candidate: unknown, -): candidate is LoadedBackupCandidate { - if (!candidate || typeof candidate !== "object") { - return false; - } - const typedCandidate = candidate as { - normalized?: unknown; - storedVersion?: unknown; - schemaErrors?: unknown; - error?: unknown; +function createUnloadedBackupCandidate(): LoadedBackupCandidate { + return { + normalized: null, + storedVersion: null, + schemaErrors: [], }; - const normalized = typedCandidate.normalized; - return ( - "storedVersion" in typedCandidate && - Array.isArray(typedCandidate.schemaErrors) && - (normalized === null || - (typeof normalized === "object" && - normalized !== null && - Array.isArray((normalized as { accounts?: unknown }).accounts))) && - (typedCandidate.error === undefined || - typeof typedCandidate.error === "string") - ); } -function getCachedNamedBackupCandidate( - candidateCache: NamedBackupCandidateCache | undefined, - backupPath: string, -): LoadedBackupCandidate | undefined { - const candidate = candidateCache?.get(backupPath); - if (candidate === undefined) { - return undefined; +function getBackupRestoreAssessmentErrorLabel(error: unknown): string { + const code = (error as NodeJS.ErrnoException).code; + if (typeof code === "string" && code.trim().length > 0) { + return code; } - if (isLoadedBackupCandidate(candidate)) { - return candidate; + if (error instanceof Error && error.name && error.name !== "Error") { + return error.name; } - candidateCache?.delete(backupPath); - return undefined; + return "UNKNOWN"; +} + +function buildFailedBackupRestoreAssessment( + backup: NamedBackupMetadata, + currentStorage: AccountStorageV3 | null, + error: unknown, +): BackupRestoreAssessment { + return { + backup, + currentAccountCount: currentStorage?.accounts.length ?? 0, + mergedAccountCount: null, + imported: null, + skipped: null, + wouldExceedLimit: false, + eligibleForRestore: false, + error: getBackupRestoreAssessmentErrorLabel(error), + }; } /** @@ -1657,20 +1642,17 @@ export async function getRestoreAssessment(): Promise { }; } -export async function listNamedBackups( - options: { candidateCache?: Map } = {}, -): Promise { +async function scanNamedBackups(): Promise { const backupRoot = getNamedBackupRoot(getStoragePath()); - const candidateCache = options.candidateCache; try { const entries = await retryTransientFilesystemOperation(() => fs.readdir(backupRoot, { withFileTypes: true }), ); const backupEntries = entries - .filter((entry) => entry.isFile()) + .filter((entry) => entry.isFile() && !entry.isSymbolicLink()) .filter((entry) => entry.name.toLowerCase().endsWith(".json")); - const backups: NamedBackupMetadata[] = []; - let transientValidationError: BackupPathValidationTransientError | undefined; + const backups: NamedBackupScanEntry[] = []; + const totalBackups = backupEntries.length; for ( let index = 0; index < backupEntries.length; @@ -1680,67 +1662,45 @@ export async function listNamedBackups( index, index + NAMED_BACKUP_LIST_CONCURRENCY, ); - const chunkResults = await Promise.allSettled( - chunk.map(async (entry) => { - const path = assertNamedBackupRestorePath( - resolvePath(join(backupRoot, entry.name)), - backupRoot, - ); - const candidate = await loadBackupCandidate(path); - candidateCache?.set(path, candidate); - return buildNamedBackupMetadata( - entry.name.slice(0, -".json".length), - path, - { candidate }, - ); - }), + backups.push( + ...(await Promise.all( + chunk.map(async (entry) => { + const path = resolvePath(join(backupRoot, entry.name)); + const name = entry.name.slice(0, -".json".length); + try { + const candidate = await loadBackupCandidate(path); + const backup = await buildNamedBackupMetadata(name, path, { + candidate, + }); + return { backup, candidate }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to scan named backup", { + name, + path, + error: String(error), + }); + } + return null; + } + }), + )).filter( + (entry): entry is NamedBackupScanEntry => entry !== null, + ), ); - for (const [chunkIndex, result] of chunkResults.entries()) { - if (result.status === "fulfilled") { - backups.push(result.value); - continue; - } - if (isNamedBackupContainmentError(result.reason)) { - throw result.reason; - } - if ( - !transientValidationError && - isNamedBackupPathValidationTransientError(result.reason) - ) { - transientValidationError = result.reason; - } - log.warn("Skipped named backup during listing", { - path: join(backupRoot, chunk[chunkIndex]?.name ?? ""), - error: String(result.reason), - }); - } } - if (backups.length === 0 && transientValidationError) { - throw transientValidationError; - } - return backups.sort((left, right) => { - // Treat epoch (0), null, and non-finite mtimes as "unknown" so the - // sort order matches the restore hints, which also suppress them. - const leftUpdatedAt = left.updatedAt; - const leftTime = - typeof leftUpdatedAt === "number" && - Number.isFinite(leftUpdatedAt) && - leftUpdatedAt !== 0 - ? leftUpdatedAt - : 0; - const rightUpdatedAt = right.updatedAt; - const rightTime = - typeof rightUpdatedAt === "number" && - Number.isFinite(rightUpdatedAt) && - rightUpdatedAt !== 0 - ? rightUpdatedAt - : 0; - return rightTime - leftTime; - }); + return { + backups: backups.sort( + (left, right) => + (right.backup.updatedAt ?? 0) - (left.backup.updatedAt ?? 0), + ), + totalBackups, + }; } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT") { - return []; + return { backups: [], totalBackups: 0 }; } log.warn("Failed to list named backups", { path: backupRoot, @@ -1750,47 +1710,188 @@ export async function listNamedBackups( } } +async function listNamedBackupsWithoutLoading(): Promise { + const backupRoot = getNamedBackupRoot(getStoragePath()); + try { + const entries = await retryTransientFilesystemOperation(() => + fs.readdir(backupRoot, { withFileTypes: true }), + ); + const backups: NamedBackupMetadata[] = []; + let totalBackups = 0; + for (const entry of entries) { + if (!entry.isFile() || entry.isSymbolicLink()) continue; + if (!entry.name.toLowerCase().endsWith(".json")) continue; + totalBackups += 1; + + const path = resolvePath(join(backupRoot, entry.name)); + const name = entry.name.slice(0, -".json".length); + try { + backups.push( + await buildNamedBackupMetadata(name, path, { + candidate: createUnloadedBackupCandidate(), + }), + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to build named backup metadata", { + name, + path, + error: String(error), + }); + } + } + } + + return { + backups: backups.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0)), + totalBackups, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to list named backups", { + path: backupRoot, + error: String(error), + }); + } + return { backups: [], totalBackups: 0 }; + } +} + +export async function listNamedBackups(): Promise { + const scanResult = await scanNamedBackups(); + return scanResult.backups.map((entry) => entry.backup); +} + function isRetryableFilesystemErrorCode( code: string | undefined, ): code is "EPERM" | "EBUSY" | "EAGAIN" { - if (code === "EAGAIN") { - return true; - } - if (process.platform !== "win32") { - return false; - } - return code === "EPERM" || code === "EBUSY"; + return code === "EPERM" || code === "EBUSY" || code === "EAGAIN"; } async function retryTransientFilesystemOperation( operation: () => Promise, ): Promise { - let attempt = 0; - while (true) { + for (let attempt = 0; attempt < 5; attempt += 1) { try { return await operation(); } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if ( - !isRetryableFilesystemErrorCode(code) || - attempt >= TRANSIENT_FILESYSTEM_MAX_ATTEMPTS - 1 - ) { + if (!isRetryableFilesystemErrorCode(code) || attempt === 4) { throw error; } - const baseDelayMs = TRANSIENT_FILESYSTEM_BASE_DELAY_MS * 2 ** attempt; - const jitterMs = Math.floor(Math.random() * baseDelayMs); - await new Promise((resolve) => - setTimeout(resolve, baseDelayMs + jitterMs), - ); + await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); } - attempt += 1; } + + throw new Error("Retry loop exhausted unexpectedly"); } export function getNamedBackupsDirectoryPath(): string { return getNamedBackupRoot(getStoragePath()); } +export async function getActionableNamedBackupRestores( + options: { + currentStorage?: AccountStorageV3 | null; + backups?: NamedBackupMetadata[]; + assess?: typeof assessNamedBackupRestore; + } = {}, +): Promise { + const usesFastPath = + options.backups === undefined && options.assess === undefined; + const scannedBackupResult = usesFastPath + ? await scanNamedBackups() + : { backups: [], totalBackups: 0 }; + const listedBackupResult = + !usesFastPath && options.backups === undefined + ? await listNamedBackupsWithoutLoading() + : { backups: [], totalBackups: 0 }; + const scannedBackups = scannedBackupResult.backups; + const backups = + options.backups ?? + (usesFastPath + ? scannedBackups.map((entry) => entry.backup) + : listedBackupResult.backups); + const totalBackups = usesFastPath + ? scannedBackupResult.totalBackups + : options.backups?.length ?? listedBackupResult.totalBackups; + if (totalBackups === 0) { + return { assessments: [], allAssessments: [], totalBackups: 0 }; + } + if (usesFastPath && scannedBackups.length === 0) { + return { assessments: [], allAssessments: [], totalBackups }; + } + + const currentStorage = + options.currentStorage === undefined + ? await loadAccounts() + : options.currentStorage; + const actionable: BackupRestoreAssessment[] = []; + const allAssessments: BackupRestoreAssessment[] = []; + const maybePushActionable = (assessment: BackupRestoreAssessment): void => { + if ( + assessment.eligibleForRestore && + !assessment.wouldExceedLimit && + assessment.imported !== null && + assessment.imported > 0 + ) { + actionable.push(assessment); + } + }; + const recordAssessment = (assessment: BackupRestoreAssessment): void => { + allAssessments.push(assessment); + maybePushActionable(assessment); + }; + + if (usesFastPath) { + for (const entry of scannedBackups) { + try { + const assessment = assessNamedBackupRestoreCandidate( + entry.backup, + entry.candidate, + currentStorage, + ); + recordAssessment(assessment); + } catch (error) { + log.warn("Failed to assess named backup restore candidate", { + name: entry.backup.name, + path: entry.backup.path, + error: String(error), + }); + allAssessments.push( + buildFailedBackupRestoreAssessment( + entry.backup, + currentStorage, + error, + ), + ); + } + } + return { assessments: actionable, allAssessments, totalBackups }; + } + + const assess = options.assess ?? assessNamedBackupRestore; + for (const backup of backups) { + try { + const assessment = await assess(backup.name, { currentStorage }); + recordAssessment(assessment); + } catch (error) { + log.warn("Failed to assess named backup restore candidate", { + name: backup.name, + path: backup.path, + error: String(error), + }); + allAssessments.push( + buildFailedBackupRestoreAssessment(backup, currentStorage, error), + ); + } + } + + return { assessments: actionable, allAssessments, totalBackups }; +} + export async function createNamedBackup( name: string, options: { force?: boolean } = {}, @@ -1806,17 +1907,10 @@ export async function createNamedBackup( export async function assessNamedBackupRestore( name: string, - options: { - currentStorage?: AccountStorageV3 | null; - candidateCache?: Map; - } = {}, + options: { currentStorage?: AccountStorageV3 | null } = {}, ): Promise { const backupPath = await resolveNamedBackupRestorePath(name); - const candidateCache = options.candidateCache; - const candidate = - getCachedNamedBackupCandidate(candidateCache, backupPath) ?? - (await loadBackupCandidate(backupPath)); - candidateCache?.delete(backupPath); + const candidate = await loadBackupCandidate(backupPath); const backup = await buildNamedBackupMetadata( basename(backupPath).slice(0, -".json".length), backupPath, @@ -1826,10 +1920,15 @@ export async function assessNamedBackupRestore( options.currentStorage !== undefined ? options.currentStorage : await loadAccounts(); + return assessNamedBackupRestoreCandidate(backup, candidate, currentStorage); +} + +function assessNamedBackupRestoreCandidate( + backup: NamedBackupMetadata, + candidate: LoadedBackupCandidate, + currentStorage: AccountStorageV3 | null, +): BackupRestoreAssessment { const currentAccounts = currentStorage?.accounts ?? []; - // Baseline merge math on a deduplicated current snapshot so pre-existing - // duplicate rows in storage cannot produce negative import counts. - const currentDeduplicatedAccounts = deduplicateAccounts([...currentAccounts]); if (!candidate.normalized || !backup.accountCount || backup.accountCount <= 0) { return { @@ -1844,24 +1943,17 @@ export async function assessNamedBackupRestore( }; } - const incomingDeduplicatedAccounts = deduplicateAccounts([ - ...candidate.normalized.accounts, - ]); const mergedAccounts = deduplicateAccounts([ - ...currentDeduplicatedAccounts, - ...incomingDeduplicatedAccounts, + ...currentAccounts, + ...candidate.normalized.accounts, ]); const wouldExceedLimit = mergedAccounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS; const imported = wouldExceedLimit ? null - : mergedAccounts.length - currentDeduplicatedAccounts.length; + : mergedAccounts.length - currentAccounts.length; const skipped = wouldExceedLimit ? null - : Math.max(0, incomingDeduplicatedAccounts.length - (imported ?? 0)); - const changed = !haveEquivalentAccountRows( - mergedAccounts, - currentDeduplicatedAccounts, - ); + : Math.max(0, candidate.normalized.accounts.length - (imported ?? 0)); return { backup, @@ -1870,34 +1962,18 @@ export async function assessNamedBackupRestore( imported, skipped, wouldExceedLimit, - eligibleForRestore: !wouldExceedLimit && changed, + eligibleForRestore: !wouldExceedLimit, error: wouldExceedLimit ? `Restore would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` - : !changed - ? "All accounts in this backup already exist" - : undefined, + : undefined, }; } export async function restoreNamedBackup( name: string, -): Promise { - const assessment = await assessNamedBackupRestore(name); - return restoreAssessedNamedBackup(assessment); -} - -export async function restoreAssessedNamedBackup( - assessment: Pick, -): Promise { - if (!assessment.eligibleForRestore) { - throw new Error( - assessment.error ?? "Backup is not eligible for restore.", - ); - } - const resolvedPath = await resolveNamedBackupRestorePath( - assessment.backup.name, - ); - return importAccounts(resolvedPath); +): Promise<{ imported: number; total: number; skipped: number }> { + const backupPath = await resolveNamedBackupRestorePath(name); + return importAccounts(backupPath); } function parseAndNormalizeStorage(data: unknown): { @@ -1913,70 +1989,6 @@ function parseAndNormalizeStorage(data: unknown): { return { normalized, storedVersion, schemaErrors }; } -export type ImportAccountsResult = { - imported: number; - total: number; - skipped: number; - // Runtime always includes this field; it stays optional in the public type so - // older compatibility callers that only model the legacy shape do not break. - changed?: boolean; -}; - -function normalizeStoragePathForComparison(path: string): string { - const resolved = resolvePath(path); - return process.platform === "win32" ? resolved.toLowerCase() : resolved; -} - -function canonicalizeComparisonValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((entry) => canonicalizeComparisonValue(entry)); - } - if (!value || typeof value !== "object") { - return value; - } - - const record = value as Record; - return Object.fromEntries( - Object.keys(record) - .sort() - .map((key) => [key, canonicalizeComparisonValue(record[key])] as const), - ); -} - -function stableStringifyForComparison(value: unknown): string { - return JSON.stringify(canonicalizeComparisonValue(value)); -} - -function haveEquivalentAccountRows( - left: readonly unknown[], - right: readonly unknown[], -): boolean { - // deduplicateAccounts() keeps the last occurrence of duplicates, so incoming - // rows win when we compare merged restore data against the current snapshot. - // That keeps index-aligned comparison correct for restore no-op detection. - if (left.length !== right.length) { - return false; - } - for (let index = 0; index < left.length; index += 1) { - if ( - stableStringifyForComparison(left[index]) !== - stableStringifyForComparison(right[index]) - ) { - return false; - } - } - return true; -} - -const namedBackupContainmentFs = { - lstat(path: string) { - return lstatSync(path); - }, - realpath(path: string) { - return realpathSync.native(path); - }, -}; - async function loadAccountsFromPath(path: string): Promise<{ normalized: AccountStorageV3 | null; storedVersion: unknown; @@ -1993,19 +2005,11 @@ async function loadBackupCandidate(path: string): Promise loadAccountsFromPath(path), ); } catch (error) { - const errorMessage = - error instanceof SyntaxError - ? `Invalid JSON in import file: ${path}` - : (error as NodeJS.ErrnoException).code === "ENOENT" - ? `Import file not found: ${path}` - : error instanceof Error - ? error.message - : String(error); return { normalized: null, storedVersion: undefined, schemaErrors: [], - error: errorMessage, + error: String(error), }; } } @@ -2035,12 +2039,28 @@ async function findExistingNamedBackupPath( ? requested : `${requested}.json`; const requestedBaseName = stripNamedBackupJsonExtension(requestedWithExtension); - let entries: Dirent[]; try { - entries = await retryTransientFilesystemOperation(() => + const entries = await retryTransientFilesystemOperation(() => fs.readdir(backupRoot, { withFileTypes: true }), ); + for (const entry of entries) { + if (!entry.name.toLowerCase().endsWith(".json")) continue; + const entryBaseName = stripNamedBackupJsonExtension(entry.name); + const matchesRequestedEntry = + equalsNamedBackupEntry(entry.name, requested) || + equalsNamedBackupEntry(entry.name, requestedWithExtension) || + equalsNamedBackupEntry(entryBaseName, requestedBaseName); + if (!matchesRequestedEntry) { + continue; + } + if (entry.isSymbolicLink() || !entry.isFile()) { + throw new Error( + `Named backup "${entryBaseName}" is not a regular backup file`, + ); + } + return resolvePath(join(backupRoot, entry.name)); + } } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT") { @@ -2053,153 +2073,25 @@ async function findExistingNamedBackupPath( throw error; } - for (const entry of entries) { - if (!entry.name.toLowerCase().endsWith(".json")) continue; - const entryBaseName = stripNamedBackupJsonExtension(entry.name); - const matchesRequestedEntry = - equalsNamedBackupEntry(entry.name, requested) || - equalsNamedBackupEntry(entry.name, requestedWithExtension) || - equalsNamedBackupEntry(entryBaseName, requestedBaseName); - if (!matchesRequestedEntry) { - continue; - } - if (entry.isSymbolicLink() || !entry.isFile()) { - throw new Error( - `Named backup "${entryBaseName}" is not a regular backup file`, - ); - } - return resolvePath(join(backupRoot, entry.name)); - } - return undefined; } -function resolvePathForNamedBackupContainment(path: string): string { - const resolvedPath = resolvePath(path); - let existingPrefix = resolvedPath; - const unresolvedSegments: string[] = []; - while (true) { - try { - namedBackupContainmentFs.lstat(existingPrefix); - break; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - const parentPath = dirname(existingPrefix); - if (parentPath === existingPrefix) { - return resolvedPath; - } - unresolvedSegments.unshift(basename(existingPrefix)); - existingPrefix = parentPath; - continue; - } - if (isRetryableFilesystemErrorCode(code)) { - throw new BackupPathValidationTransientError( - "Backup path validation failed. Try again.", - { cause: error instanceof Error ? error : undefined }, - ); - } - throw error; - } - } - try { - const canonicalPrefix = namedBackupContainmentFs.realpath(existingPrefix); - return unresolvedSegments.reduce( - (currentPath, segment) => join(currentPath, segment), - canonicalPrefix, - ); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return resolvedPath; - } - if (isRetryableFilesystemErrorCode(code)) { - throw new BackupPathValidationTransientError( - "Backup path validation failed. Try again.", - { cause: error instanceof Error ? error : undefined }, - ); - } - throw error; - } -} - -export function assertNamedBackupRestorePath( - path: string, - backupRoot: string, -): string { - const resolvedPath = resolvePath(path); - const resolvedBackupRoot = resolvePath(backupRoot); - let backupRootIsSymlink = false; - try { - backupRootIsSymlink = - namedBackupContainmentFs.lstat(resolvedBackupRoot).isSymbolicLink(); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - backupRootIsSymlink = false; - } else if (isRetryableFilesystemErrorCode(code)) { - throw new BackupPathValidationTransientError( - "Backup path validation failed. Try again.", - { cause: error instanceof Error ? error : undefined }, - ); - } else { - throw error; - } - } - if (backupRootIsSymlink) { - throw new BackupContainmentError("Backup path escapes backup directory"); - } - const canonicalBackupRoot = - resolvePathForNamedBackupContainment(resolvedBackupRoot); - const containedPath = resolvePathForNamedBackupContainment(resolvedPath); - const relativePath = relative(canonicalBackupRoot, containedPath); - const firstSegment = relativePath.split(/[\\/]/)[0]; - if ( - relativePath.length === 0 || - firstSegment === ".." || - isAbsolute(relativePath) - ) { - throw new BackupContainmentError("Backup path escapes backup directory"); - } - return containedPath; -} - -export function isNamedBackupContainmentError(error: unknown): boolean { - return ( - error instanceof BackupContainmentError || - (error instanceof Error && /escapes backup directory/i.test(error.message)) - ); -} - -export function isNamedBackupPathValidationTransientError( - error: unknown, -): error is BackupPathValidationTransientError { - return ( - error instanceof BackupPathValidationTransientError || - (error instanceof Error && - /^Backup path validation failed(\.|:|\b)/i.test(error.message)) - ); -} - -export async function resolveNamedBackupRestorePath(name: string): Promise { - const requested = (name ?? "").trim(); - const backupRoot = getNamedBackupRoot(getStoragePath()); +async function resolveNamedBackupRestorePath(name: string): Promise { const existingPath = await findExistingNamedBackupPath(name); if (existingPath) { - return assertNamedBackupRestorePath(existingPath, backupRoot); + return existingPath; } + const requested = (name ?? "").trim(); + const backupRoot = getNamedBackupRoot(getStoragePath()); const requestedWithExtension = requested.toLowerCase().endsWith(".json") ? requested : `${requested}.json`; - const baseName = requestedWithExtension.slice(0, -".json".length); - let builtPath: string; try { - builtPath = buildNamedBackupPath(requested); + return buildNamedBackupPath(name); } catch (error) { - // buildNamedBackupPath rejects names with special characters even when the - // requested backup name is a plain filename inside the backups directory. - // In that case, reporting ENOENT is clearer than surfacing the filename - // validator, but only when no separator/traversal token is present. + const baseName = requestedWithExtension.toLowerCase().endsWith(".json") + ? requestedWithExtension.slice(0, -".json".length) + : requestedWithExtension; if ( requested.length > 0 && basename(requestedWithExtension) === requestedWithExtension && @@ -2212,7 +2104,6 @@ export async function resolveNamedBackupRestorePath(name: string): Promise> } = {}, + opts: { candidate?: LoadedBackupCandidate } = {}, ): Promise { const candidate = opts.candidate ?? (await loadBackupCandidate(path)); let stats: { @@ -3029,10 +2920,9 @@ export async function exportAccounts( } const transactionState = transactionSnapshotContext.getStore(); - const currentStoragePath = normalizeStoragePathForComparison(getStoragePath()); + const currentStoragePath = getStoragePath(); const storage = transactionState?.active - ? normalizeStoragePathForComparison(transactionState.storagePath) === - currentStoragePath + ? transactionState.storagePath === currentStoragePath ? transactionState.snapshot : (() => { throw new Error( @@ -3075,7 +2965,7 @@ export async function exportAccounts( */ export async function importAccounts( filePath: string, -): Promise { +): Promise<{ imported: number; total: number; skipped: number }> { const resolvedPath = resolvePath(filePath); // Check file exists with friendly error @@ -3083,17 +2973,7 @@ export async function importAccounts( throw new Error(`Import file not found: ${resolvedPath}`); } - let content: string; - try { - content = await retryTransientFilesystemOperation(() => - fs.readFile(resolvedPath, "utf-8"), - ); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - throw new Error(`Import file not found: ${resolvedPath}`); - } - throw error; - } + const content = await fs.readFile(resolvedPath, "utf-8"); let imported: unknown; try { @@ -3111,49 +2991,23 @@ export async function importAccounts( imported: importedCount, total, skipped: skippedCount, - changed, } = await withAccountStorageTransaction(async (existing, persist) => { const existingAccounts = existing?.accounts ?? []; - // Keep import counts anchored to a deduplicated current snapshot for the - // same reason as assessNamedBackupRestore. - const existingDeduplicatedAccounts = deduplicateAccounts([ - ...existingAccounts, - ]); - const incomingDeduplicatedAccounts = deduplicateAccounts([ - ...normalized.accounts, - ]); const existingActiveIndex = existing?.activeIndex ?? 0; - const merged = [ - ...existingDeduplicatedAccounts, - ...incomingDeduplicatedAccounts, - ]; - const deduplicatedAccounts = deduplicateAccounts(merged); - if (deduplicatedAccounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { - throw new Error( - `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduplicatedAccounts.length})`, - ); - } - const imported = - deduplicatedAccounts.length - existingDeduplicatedAccounts.length; - const skipped = Math.max( - 0, - incomingDeduplicatedAccounts.length - imported, - ); - const changed = !haveEquivalentAccountRows( - deduplicatedAccounts, - existingDeduplicatedAccounts, - ); + const merged = [...existingAccounts, ...normalized.accounts]; - if (!changed) { - return { - imported, - total: deduplicatedAccounts.length, - skipped, - changed, - }; + if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { + const deduped = deduplicateAccounts(merged); + if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { + throw new Error( + `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduped.length})`, + ); + } } + const deduplicatedAccounts = deduplicateAccounts(merged); + const newStorage: AccountStorageV3 = { version: 3, accounts: deduplicatedAccounts, @@ -3162,12 +3016,10 @@ export async function importAccounts( }; await persist(newStorage); - return { - imported, - total: deduplicatedAccounts.length, - skipped, - changed, - }; + + const imported = deduplicatedAccounts.length - existingAccounts.length; + const skipped = normalized.accounts.length - imported; + return { imported, total: deduplicatedAccounts.length, skipped }; }); log.info("Imported accounts", { @@ -3175,17 +3027,7 @@ export async function importAccounts( imported: importedCount, skipped: skippedCount, total, - changed, }); - return { - imported: importedCount, - total, - skipped: skippedCount, - changed, - }; + return { imported: importedCount, total, skipped: skippedCount }; } - -export const __testOnly = { - namedBackupContainmentFs, -}; diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 1b14d107..10f123e4 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -16,25 +16,6 @@ export const UI_COPY = { noSearchMatches: "No accounts match your search", recovery: "Recovery", restoreBackup: "Restore From Backup", - restoreBackupConfirm: ( - name: string, - netNewAccounts: number, - backupAccountCount: number, - currentAccountCount: number, - mergedAccountCount: number, - ) => - netNewAccounts === 0 - ? `Restore backup "${name}"? This will refresh stored metadata for matching existing account(s) in this backup.` - : `Restore backup "${name}"? This will add ${netNewAccounts} new account(s) (${backupAccountCount} in backup, ${currentAccountCount} current -> ${mergedAccountCount} after dedupe).`, - restoreBackupSuccess: ( - name: string, - imported: number, - skipped: number, - total: number, - ) => - `Restored backup "${name}". Imported ${imported}, skipped ${skipped}, total ${total}.`, - restoreBackupRefreshSuccess: (name: string) => - `Restored backup "${name}". Refreshed stored metadata for matching existing account(s).`, dangerZone: "Danger Zone", removeAllAccounts: "Delete Saved Accounts", resetLocalState: "Reset Local State", diff --git a/test/cli.test.ts b/test/cli.test.ts index efbffdce..269a0eba 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -716,16 +716,6 @@ describe("CLI Module", () => { await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "restore-backup", }); - - mockRl.question.mockResolvedValueOnce("backup"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ - mode: "restore-backup", - }); - - mockRl.question.mockResolvedValueOnce("restore-backup"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ - mode: "restore-backup", - }); }); it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 1ab460dd..109c5721 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1,28 +1,23 @@ -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const MOCK_BACKUP_DIR = fileURLToPath( - new URL("./.vitest-mock-backups", import.meta.url), -); -const mockBackupPath = (name: string): string => - resolve(MOCK_BACKUP_DIR, `${name}.json`); - +const createAuthorizationFlowMock = vi.fn(); +const exchangeAuthorizationCodeMock = vi.fn(); +const startLocalOAuthServerMock = vi.fn(); const loadAccountsMock = vi.fn(); const loadFlaggedAccountsMock = vi.fn(); const saveAccountsMock = vi.fn(); const saveFlaggedAccountsMock = vi.fn(); const setStoragePathMock = vi.fn(); const getStoragePathMock = vi.fn(() => "/mock/openai-codex-accounts.json"); +const getActionableNamedBackupRestoresMock = vi.fn(); const listNamedBackupsMock = vi.fn(); const assessNamedBackupRestoreMock = vi.fn(); const getNamedBackupsDirectoryPathMock = vi.fn(); -const resolveNamedBackupRestorePathMock = vi.fn(); -const importAccountsMock = vi.fn(); -const restoreAssessedNamedBackupMock = vi.fn(); +const restoreNamedBackupMock = vi.fn(); const queuedRefreshMock = vi.fn(); const setCodexCliActiveSelectionMock = vi.fn(); const promptAddAnotherAccountMock = vi.fn(); +const isInteractiveLoginMenuAvailableMock = vi.fn(() => true); const promptLoginModeMock = vi.fn(); const fetchCodexQuotaSnapshotMock = vi.fn(); const loadDashboardDisplaySettingsMock = vi.fn(); @@ -56,8 +51,8 @@ vi.mock("../lib/logger.js", () => ({ })); vi.mock("../lib/auth/auth.js", () => ({ - createAuthorizationFlow: vi.fn(), - exchangeAuthorizationCode: vi.fn(), + createAuthorizationFlow: createAuthorizationFlowMock, + exchangeAuthorizationCode: exchangeAuthorizationCodeMock, parseAuthorizationInput: vi.fn(), REDIRECT_URI: "http://localhost:1455/auth/callback", })); @@ -68,10 +63,11 @@ vi.mock("../lib/auth/browser.js", () => ({ })); vi.mock("../lib/auth/server.js", () => ({ - startLocalOAuthServer: vi.fn(), + startLocalOAuthServer: startLocalOAuthServerMock, })); vi.mock("../lib/cli.js", () => ({ + isInteractiveLoginMenuAvailable: isInteractiveLoginMenuAvailableMock, promptAddAnotherAccount: promptAddAnotherAccountMock, promptLoginMode: promptLoginModeMock, })); @@ -117,12 +113,11 @@ vi.mock("../lib/storage.js", async () => { withAccountStorageTransaction: withAccountStorageTransactionMock, setStoragePath: setStoragePathMock, getStoragePath: getStoragePathMock, + getActionableNamedBackupRestores: getActionableNamedBackupRestoresMock, listNamedBackups: listNamedBackupsMock, assessNamedBackupRestore: assessNamedBackupRestoreMock, getNamedBackupsDirectoryPath: getNamedBackupsDirectoryPathMock, - resolveNamedBackupRestorePath: resolveNamedBackupRestorePathMock, - importAccounts: importAccountsMock, - restoreAssessedNamedBackup: restoreAssessedNamedBackupMock, + restoreNamedBackup: restoreNamedBackupMock, exportNamedBackup: exportNamedBackupMock, normalizeAccountStorage: normalizeAccountStorageMock, }; @@ -282,6 +277,42 @@ function makeErrnoError(message: string, code: string): NodeJS.ErrnoException { return error; } +async function configureSuccessfulOAuthFlow(now = Date.now()): Promise { + const authModule = await import("../lib/auth/auth.js"); + const browserModule = await import("../lib/auth/browser.js"); + const serverModule = await import("../lib/auth/server.js"); + const mockedCreateAuthorizationFlow = vi.mocked( + authModule.createAuthorizationFlow, + ); + const mockedExchangeAuthorizationCode = vi.mocked( + authModule.exchangeAuthorizationCode, + ); + const mockedOpenBrowserUrl = vi.mocked(browserModule.openBrowserUrl); + const mockedStartLocalOAuthServer = vi.mocked( + serverModule.startLocalOAuthServer, + ); + + mockedCreateAuthorizationFlow.mockResolvedValue({ + pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" }, + state: "oauth-state", + url: "https://auth.openai.com/mock", + }); + mockedExchangeAuthorizationCode.mockResolvedValue({ + type: "success", + access: "access-new", + refresh: "refresh-new", + expires: now + 7_200_000, + idToken: "id-token-new", + multiAccount: true, + }); + mockedOpenBrowserUrl.mockReturnValue(true); + mockedStartLocalOAuthServer.mockResolvedValue({ + ready: true, + waitForCode: vi.fn(async () => ({ code: "oauth-code" })), + close: vi.fn(), + }); +} + type SettingsTestAccount = { email: string; accountId: string; @@ -476,9 +507,13 @@ describe("codex manager cli commands", () => { withAccountAndFlaggedStorageTransactionMock.mockReset(); withAccountStorageTransactionMock.mockReset(); queuedRefreshMock.mockReset(); + createAuthorizationFlowMock.mockReset(); + exchangeAuthorizationCodeMock.mockReset(); + startLocalOAuthServerMock.mockReset(); setCodexCliActiveSelectionMock.mockReset(); - setCodexCliActiveSelectionMock.mockResolvedValue(true); promptAddAnotherAccountMock.mockReset(); + isInteractiveLoginMenuAvailableMock.mockReset(); + isInteractiveLoginMenuAvailableMock.mockReturnValue(true); promptLoginModeMock.mockReset(); fetchCodexQuotaSnapshotMock.mockReset(); loadDashboardDisplaySettingsMock.mockReset(); @@ -519,15 +554,19 @@ describe("codex manager cli commands", () => { listNamedBackupsMock.mockReset(); assessNamedBackupRestoreMock.mockReset(); getNamedBackupsDirectoryPathMock.mockReset(); - resolveNamedBackupRestorePathMock.mockReset(); - importAccountsMock.mockReset(); - restoreAssessedNamedBackupMock.mockReset(); + restoreNamedBackupMock.mockReset(); confirmMock.mockReset(); + getActionableNamedBackupRestoresMock.mockReset(); + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [], + allAssessments: [], + totalBackups: 0, + }); listNamedBackupsMock.mockResolvedValue([]); assessNamedBackupRestoreMock.mockResolvedValue({ backup: { name: "named-backup", - path: mockBackupPath("named-backup"), + path: "/mock/backups/named-backup.json", createdAt: null, updatedAt: null, sizeBytes: null, @@ -545,21 +584,12 @@ describe("codex manager cli commands", () => { eligibleForRestore: true, error: undefined, }); - getNamedBackupsDirectoryPathMock.mockReturnValue(MOCK_BACKUP_DIR); - resolveNamedBackupRestorePathMock.mockImplementation(async (name: string) => - mockBackupPath(name), - ); - importAccountsMock.mockResolvedValue({ + getNamedBackupsDirectoryPathMock.mockReturnValue("/mock/backups"); + restoreNamedBackupMock.mockResolvedValue({ imported: 1, skipped: 0, total: 1, - changed: true, }); - restoreAssessedNamedBackupMock.mockImplementation(async (assessment) => - importAccountsMock( - await resolveNamedBackupRestorePathMock(assessment.backup.name), - ), - ); confirmMock.mockResolvedValue(true); withAccountStorageTransactionMock.mockImplementation( async (handler) => { @@ -620,6 +650,21 @@ describe("codex manager cli commands", () => { loadPluginConfigMock.mockReturnValue({}); savePluginConfigMock.mockResolvedValue(undefined); selectMock.mockResolvedValue(undefined); + createAuthorizationFlowMock.mockResolvedValue({ + pkce: { verifier: "test-verifier" }, + state: "test-state", + url: "https://example.com/oauth", + }); + exchangeAuthorizationCodeMock.mockResolvedValue({ + type: "failed", + reason: "unknown", + message: "not configured", + }); + startLocalOAuthServerMock.mockResolvedValue({ + ready: false, + waitForCode: vi.fn(), + close: vi.fn(), + }); restoreTTYDescriptors(); setStoragePathMock.mockReset(); getStoragePathMock.mockReturnValue("/mock/openai-codex-accounts.json"); @@ -654,6 +699,7 @@ describe("codex manager cli commands", () => { }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -674,6 +720,7 @@ describe("codex manager cli commands", () => { it("prints implemented 40-feature matrix", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -692,6 +739,7 @@ describe("codex manager cli commands", () => { it("prints auth help when subcommand is --help", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -701,6 +749,47 @@ describe("codex manager cli commands", () => { expect(logSpy.mock.calls[0]?.[0]).toContain("Codex Multi-Auth CLI"); }); + it("restores a named backup from direct auth restore-backup command", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue(null); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + + expect(exitCode).toBe(0); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ currentStorage: null }), + ); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + }); + it("restores healthy flagged accounts into active storage", async () => { const now = Date.now(); loadFlaggedAccountsMock.mockResolvedValueOnce({ @@ -1688,149 +1777,957 @@ describe("codex manager cli commands", () => { expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); }); - it("writes shared workspace quota cache entries by email without reusing bare accountId keys", async () => { + it("offers backup recovery before OAuth when actionable backups exist", async () => { + setInteractiveTTY(true); const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "alpha@example.com", - accountId: "shared-workspace", - refreshToken: "refresh-alpha", - accessToken: "access-alpha", - expiresAt: now + 60 * 60 * 1000, - addedAt: now - 2_000, - lastUsed: now - 2_000, - enabled: true, - }, - { - email: "beta@example.com", - accountId: "shared-workspace", - refreshToken: "refresh-beta", - accessToken: "access-beta", - expiresAt: now + 60 * 60 * 1000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], + let storageState: { + version: number; + activeIndex: number; + activeIndexByFamily: { codex: number }; + accounts: Array<{ + email?: string; + refreshToken: string; + addedAt: number; + lastUsed: number; + enabled?: boolean; + }>; + } | null = null; + loadAccountsMock.mockImplementation(async () => + storageState ? structuredClone(storageState) : null, + ); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: now - 1_000, + updatedAt: now - 1_000, + sizeBytes: 512, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 2, }); - loadDashboardDisplaySettingsMock.mockResolvedValue({ - showPerAccountRows: true, - showQuotaDetails: true, - showForecastReasons: true, - showRecommendations: true, - showLiveProbeNotes: true, - menuAutoFetchLimits: true, - menuSortEnabled: false, - menuSortMode: "manual", - menuSortPinCurrent: true, - menuSortQuickSwitchVisibleRow: true, + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + restoreNamedBackupMock.mockImplementation(async () => { + storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "restored@example.com", + refreshToken: "refresh-restored", + addedAt: now, + lastUsed: now, + enabled: true, + }, + ], + }; + return { imported: 1, skipped: 0, total: 1 }; }); - fetchCodexQuotaSnapshotMock - .mockResolvedValueOnce({ - status: 200, - model: "gpt-5-codex", - primary: { - usedPercent: 20, - windowMinutes: 300, - resetAtMs: now + 1_000, - }, - secondary: { - usedPercent: 10, - windowMinutes: 10080, - resetAtMs: now + 2_000, - }, - }) - .mockResolvedValueOnce({ - status: 200, - model: "gpt-5-codex", - primary: { - usedPercent: 70, - windowMinutes: 300, - resetAtMs: now + 3_000, - }, - secondary: { - usedPercent: 40, - windowMinutes: 10080, - resetAtMs: now + 4_000, - }, - }); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + createAuthorizationFlowMock.mockRejectedValue( + new Error("oauth flow should be skipped when restoring backup"), + ); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); - expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); - expect(saveQuotaCacheMock).toHaveBeenCalledWith({ - byAccountId: {}, - byEmail: { - "alpha@example.com": { - updatedAt: expect.any(Number), - status: 200, - model: "gpt-5-codex", - primary: { - usedPercent: 20, - windowMinutes: 300, - resetAtMs: now + 1_000, - }, - secondary: { - usedPercent: 10, - windowMinutes: 10080, - resetAtMs: now + 2_000, - }, - }, - "beta@example.com": { - updatedAt: expect.any(Number), - status: 200, - model: "gpt-5-codex", - primary: { - usedPercent: 70, - windowMinutes: 300, - resetAtMs: now + 3_000, - }, - secondary: { - usedPercent: 40, - windowMinutes: 10080, - resetAtMs: now + 4_000, - }, - }, - }, - }); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalled(); + expect(confirmMock).toHaveBeenCalledTimes(2); + expect(selectMock).toHaveBeenCalled(); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); }); - it("keeps login loop running when settings action is selected", async () => { + it("continues into OAuth when startup recovery is declined", async () => { + setInteractiveTTY(true); const now = Date.now(); - const storage = { + let storageState = { version: 3, activeIndex: 0, activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "a@example.com", - accountId: "acc_a", - refreshToken: "refresh-a", - accessToken: "access-a", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], + accounts: [], }; - loadAccountsMock.mockResolvedValue(storage); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "settings" }) - .mockResolvedValueOnce({ mode: "cancel" }); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + const secondAssessment = { + ...assessment, + backup: { + ...assessment.backup, + name: "startup-backup-2", + path: "/mock/backups/startup-backup-2.json", + }, + }; + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [assessment, secondAssessment], + allAssessments: [assessment, secondAssessment], + totalBackups: 2, + }); + confirmMock.mockResolvedValue(false); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + expect(exitCode).toBe(0); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledWith( + "Found 2 recoverable backups out of 2 total (2 backups) in /mock/backups. Restore now?", + ); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + }); + + it("shows the empty storage menu before OAuth when startup recovery finds backups but none are actionable", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [], + allAssessments: [], + totalBackups: 2, + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "add" }); + await configureSuccessfulOAuthFlow(now); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(promptLoginModeMock).toHaveBeenCalledTimes(1); + expect(promptLoginModeMock.mock.calls[0]?.[0]).toEqual([]); + expect(confirmMock).not.toHaveBeenCalled(); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(selectMock.mock.calls[0]?.[1]).toMatchObject({ + message: "Sign-In Method", + }); + expect(getNamedBackupsDirectoryPathMock).not.toHaveBeenCalled(); + expect(listNamedBackupsMock).not.toHaveBeenCalled(); + expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + expect(promptLoginModeMock.mock.invocationCallOrder[0]).toBeLessThan( + createAuthorizationFlowMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + }); + + it("shows all startup-scanned backups in the restore manager before re-prompting", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + const invalidAssessment = { + backup: { + name: "stale-backup", + path: "/mock/backups/stale-backup.json", + createdAt: null, + updatedAt: now - 60_000, + sizeBytes: 64, + version: 3, + accountCount: 0, + schemaErrors: [], + valid: false, + loadError: "Backup is empty or invalid", + }, + currentAccountCount: 0, + mergedAccountCount: null, + imported: null, + skipped: null, + wouldExceedLimit: false, + eligibleForRestore: false, + error: "Backup is empty or invalid", + }; + getActionableNamedBackupRestoresMock.mockResolvedValueOnce({ + assessments: [assessment], + allAssessments: [assessment, invalidAssessment], + totalBackups: 2, + }); + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + selectMock.mockResolvedValueOnce({ type: "back" }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(2); + const restoreManagerCall = selectMock.mock.calls.find( + ([, options]) => options?.message === "Restore From Backup", + ); + expect(restoreManagerCall).toBeDefined(); + expect(restoreManagerCall?.[1]).toMatchObject({ + message: "Restore From Backup", + }); + expect(restoreManagerCall?.[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: "startup-backup", + disabled: false, + }), + expect.objectContaining({ + label: "stale-backup", + disabled: true, + }), + ]), + ); + expect(listNamedBackupsMock).not.toHaveBeenCalled(); + expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + }); + + it("re-prompts startup recovery after backing out of the backup browser", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValueOnce({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 2, + }); + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + selectMock.mockResolvedValueOnce({ type: "back" }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(2); + expect(selectMock).toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + }); + + it("re-prompts startup recovery after cancelling restore inside the backup browser", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValueOnce({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 2, + }); + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + confirmMock + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(3); + expect(selectMock).toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + }); + + it("re-prompts startup recovery after restore fails inside the backup browser", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValueOnce({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 2, + }); + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + confirmMock + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + restoreNamedBackupMock.mockRejectedValueOnce( + makeErrnoError("resource busy", "EBUSY"), + ); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(3); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("startup-backup"); + expect(warnSpy).toHaveBeenCalledWith( + 'Failed to restore backup "startup-backup" (EBUSY).', + ); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + it("skips startup restore prompt in fallback login mode", async () => { + setInteractiveTTY(true); + isInteractiveLoginMenuAvailableMock.mockReturnValue(false); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }); + selectMock.mockResolvedValueOnce("cancel"); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + }); + + it("skips startup restore prompt when login starts non-interactive", async () => { + setInteractiveTTY(false); + isInteractiveLoginMenuAvailableMock.mockReturnValue(false); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + }); + + it("falls back to OAuth when startup recovery scan throws EBUSY", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + getActionableNamedBackupRestoresMock.mockRejectedValueOnce( + makeErrnoError("resource busy", "EBUSY"), + ); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + await configureSuccessfulOAuthFlow(now); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + "Startup recovery scan failed (EBUSY). Continuing with OAuth.", + ); + warnSpy.mockRestore(); + }); + + it("falls back to OAuth when startup recovery re-assessment throws EBUSY", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValueOnce({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 1, + }); + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + assessNamedBackupRestoreMock.mockRejectedValueOnce( + makeErrnoError("resource busy", "EBUSY"), + ); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + await configureSuccessfulOAuthFlow(now); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "startup-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ accounts: [] }), + }), + ); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'Failed to re-assess backup "startup-backup" before restore (EBUSY).', + ); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + it("falls back to OAuth when the startup recovery prompt throws", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 2, + }); + confirmMock.mockRejectedValueOnce( + makeErrnoError( + "no such file or directory, open '/mock/settings.json'", + "ENOENT", + ), + ); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + "Startup recovery prompt failed (ENOENT). Continuing with OAuth.", + ); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + it("falls back to OAuth when startup recovery display settings load fails before confirm", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 2, + }); + loadDashboardDisplaySettingsMock + .mockResolvedValueOnce({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }) + .mockImplementationOnce(async () => { + throw makeErrnoError( + "no such file or directory, open '/mock/dashboard-settings.json'", + "ENOENT", + ); + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(getNamedBackupsDirectoryPathMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + "Startup recovery prompt failed (ENOENT). Continuing with OAuth.", + ); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + it.each([ + { mode: "fresh", action: deleteSavedAccountsMock }, + { mode: "reset", action: resetLocalStateMock }, + ] as const)( + "suppresses startup restore prompt after deliberate $mode action in the same login session", + async ({ mode, action }) => { + setInteractiveTTY(true); + const now = Date.now(); + const populatedStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "existing@example.com", + refreshToken: "existing-refresh", + addedAt: now, + lastUsed: now, + }, + ], + }; + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + let loadCount = 0; + loadAccountsMock.mockImplementation(async () => { + loadCount += 1; + return loadCount <= 2 + ? structuredClone(populatedStorage) + : structuredClone(emptyStorage); + }); + promptLoginModeMock.mockResolvedValueOnce( + mode === "fresh" + ? { mode: "fresh", deleteAll: true } + : { mode: "reset" }, + ); + selectMock.mockResolvedValueOnce("cancel"); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(action).toHaveBeenCalledTimes(1); + expect(getActionableNamedBackupRestoresMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + }, + ); + + it("writes shared workspace quota cache entries by email without reusing bare accountId keys", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "alpha@example.com", + accountId: "shared-workspace", + refreshToken: "refresh-alpha", + accessToken: "access-alpha", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "beta@example.com", + accountId: "shared-workspace", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: false, + menuSortMode: "manual", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + fetchCodexQuotaSnapshotMock + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }) + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: {}, + byEmail: { + "alpha@example.com": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + "beta@example.com": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, + }, + }, + }); + }); + + it("keeps login loop running when settings action is selected", async () => { + const now = Date.now(); + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + accountId: "acc_a", + refreshToken: "refresh-a", + accessToken: "access-a", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }; + loadAccountsMock.mockResolvedValue(storage); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); }); it("passes smart-sorted accounts to auth menu while preserving source index mapping", async () => { @@ -2376,274 +3273,17 @@ describe("codex manager cli commands", () => { SETTINGS_HUB_MENU_ORDER, ); expect(selectSequence.remaining()).toBe(0); - expect(saveDashboardDisplaySettingsMock).toHaveBeenCalled(); - expect(savePluginConfigMock).toHaveBeenCalledTimes(1); - expect(savePluginConfigMock).toHaveBeenCalledWith( - expect.objectContaining({ - preemptiveQuotaEnabled: expect.any(Boolean), - preemptiveQuotaRemainingPercent5h: expect.any(Number), - }), - ); - }); - - it("restores a named backup from the login recovery menu", async () => { - setInteractiveTTY(true); - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - const assessment = { - backup: { - name: "named-backup", - path: mockBackupPath("named-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - currentAccountCount: 1, - mergedAccountCount: 2, - imported: 1, - skipped: 0, - wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); - expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), - }), - }), - ); - expect(confirmMock).toHaveBeenCalledOnce(); - expect(importAccountsMock).toHaveBeenCalledWith( - mockBackupPath("named-backup"), - ); - expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce(); - }); - - it("restores a named backup from the direct restore-backup command", async () => { - setInteractiveTTY(true); - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - const assessment = { - backup: { - name: "named-backup", - path: mockBackupPath("named-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - currentAccountCount: 1, - mergedAccountCount: 2, - imported: 1, - skipped: 0, - wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); - - expect(exitCode).toBe(0); - expect(setStoragePathMock).toHaveBeenCalledWith(null); - expect(promptLoginModeMock).not.toHaveBeenCalled(); - expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); - expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), - }), - }), - ); - expect(confirmMock).toHaveBeenCalledOnce(); - expect(importAccountsMock).toHaveBeenCalledWith( - mockBackupPath("named-backup"), - ); - expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce(); - }); - - it("returns a non-zero exit code when the direct restore-backup command fails", async () => { - setInteractiveTTY(true); - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - const assessment = { - backup: { - name: "named-backup", - path: mockBackupPath("named-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - currentAccountCount: 1, - mergedAccountCount: 2, - imported: 1, - skipped: 0, - wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - importAccountsMock.mockRejectedValueOnce(new Error("backup locked")); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); - - expect(exitCode).toBe(1); - expect(promptLoginModeMock).not.toHaveBeenCalled(); - expect(confirmMock).toHaveBeenCalledOnce(); - expect(importAccountsMock).toHaveBeenCalledWith( - mockBackupPath("named-backup"), - ); - expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); - expect(errorSpy).toHaveBeenCalledWith("Restore failed: backup locked"); - } finally { - errorSpy.mockRestore(); - } - }); - - it("returns a non-zero exit code when every direct restore assessment fails", async () => { - setInteractiveTTY(true); - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - listNamedBackupsMock.mockResolvedValue([ - { - name: "named-backup", - path: mockBackupPath("named-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - ]); - assessNamedBackupRestoreMock.mockRejectedValueOnce( - makeErrnoError("backup busy", "EBUSY"), - ); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); - - expect(exitCode).toBe(1); - expect(selectMock).not.toHaveBeenCalled(); - expect(confirmMock).not.toHaveBeenCalled(); - expect(importAccountsMock).not.toHaveBeenCalled(); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining("Could not assess any named backups in"), - ); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining("named-backup: backup busy"), - ); - } finally { - errorSpy.mockRestore(); - } + expect(saveDashboardDisplaySettingsMock).toHaveBeenCalled(); + expect(savePluginConfigMock).toHaveBeenCalledTimes(1); + expect(savePluginConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + preemptiveQuotaEnabled: expect.any(Boolean), + preemptiveQuotaRemainingPercent5h: expect.any(Number), + }), + ); }); - it("rejects a restore when the backup root changes before the final import path check", async () => { + it("restores a named backup from the login recovery menu", async () => { setInteractiveTTY(true); const now = Date.now(); loadAccountsMock.mockResolvedValue({ @@ -2666,7 +3306,7 @@ describe("codex manager cli commands", () => { const assessment = { backup: { name: "named-backup", - path: mockBackupPath("named-backup"), + path: "/mock/backups/named-backup.json", createdAt: null, updatedAt: now, sizeBytes: 128, @@ -2686,59 +3326,36 @@ describe("codex manager cli commands", () => { }; listNamedBackupsMock.mockResolvedValue([assessment.backup]); assessNamedBackupRestoreMock.mockResolvedValue(assessment); - resolveNamedBackupRestorePathMock.mockRejectedValueOnce( - new Error("Backup path escapes backup directory"), - ); promptLoginModeMock .mockResolvedValueOnce({ mode: "restore-backup" }) .mockResolvedValueOnce({ mode: "cancel" }); selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - expect(exitCode).toBe(0); - expect(confirmMock).toHaveBeenCalledOnce(); - expect(importAccountsMock).not.toHaveBeenCalled(); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(errorSpy).toHaveBeenCalledWith( - "Restore failed: Backup path escapes backup directory", - ); - } finally { - errorSpy.mockRestore(); - } + expect(exitCode).toBe(0); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); }); it("offers backup restore from the login menu when no accounts are saved", 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 - 500, - lastUsed: now - 500, - enabled: true, - }, - ], - }; - loadAccountsMock - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(null) - .mockResolvedValue(restoredStorage); + loadAccountsMock.mockResolvedValue(null); const assessment = { backup: { name: "named-backup", - path: mockBackupPath("named-backup"), + path: "/mock/backups/named-backup.json", createdAt: null, updatedAt: now, sizeBytes: 128, @@ -2773,17 +3390,7 @@ describe("codex manager cli commands", () => { "named-backup", expect.objectContaining({ currentStorage: null }), ); - expect(importAccountsMock).toHaveBeenCalledWith( - mockBackupPath("named-backup"), - ); - expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith( - expect.objectContaining({ - accountId: "acc_restored", - email: "restored@example.com", - refreshToken: "refresh-restored", - accessToken: "access-restored", - }), - ); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); }); it("does not restore a named backup when confirmation is declined", async () => { @@ -2809,7 +3416,7 @@ describe("codex manager cli commands", () => { const assessment = { backup: { name: "named-backup", - path: mockBackupPath("named-backup"), + path: "/mock/backups/named-backup.json", createdAt: null, updatedAt: now, sizeBytes: 128, @@ -2849,8 +3456,7 @@ describe("codex manager cli commands", () => { }), ); expect(confirmMock).toHaveBeenCalledOnce(); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(importAccountsMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); }); it("catches restore failures and returns to the login menu", async () => { @@ -2876,147 +3482,7 @@ describe("codex manager cli commands", () => { const assessment = { backup: { name: "named-backup", - path: mockBackupPath("named-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - currentAccountCount: 1, - mergedAccountCount: 2, - imported: 1, - skipped: 0, - wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); - importAccountsMock.mockRejectedValueOnce( - new Error(`Import file not found: ${mockBackupPath("named-backup")}`), - ); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(importAccountsMock).toHaveBeenCalledWith( - mockBackupPath("named-backup"), - ); - const restoreFailureCalls = [ - ...errorSpy.mock.calls, - ...logSpy.mock.calls, - ].flat(); - expect(restoreFailureCalls).toContainEqual( - expect.stringContaining("Restore failed: Import file not found"), - ); - } finally { - errorSpy.mockRestore(); - logSpy.mockRestore(); - } - }); - - it("adds actionable guidance when a confirmed restore exceeds the account limit", async () => { - setInteractiveTTY(true); - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - const assessment = { - backup: { - name: "named-backup", - path: mockBackupPath("named-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - currentAccountCount: 1, - mergedAccountCount: 2, - imported: 1, - skipped: 0, - wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); - importAccountsMock.mockRejectedValueOnce( - new Error("Import would exceed maximum of 10 accounts (would have 11)"), - ); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(errorSpy).toHaveBeenCalledWith( - "Restore failed: Import would exceed maximum of 10 accounts (would have 11). Close other Codex instances and try again.", - ); - } finally { - errorSpy.mockRestore(); - } - }); - - it("treats post-confirm duplicate-only restores as a no-op", async () => { - setInteractiveTTY(true); - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - const assessment = { - backup: { - name: "named-backup", - path: mockBackupPath("named-backup"), + path: "/mock/backups/named-backup.json", createdAt: null, updatedAt: now, sizeBytes: 128, @@ -3033,110 +3499,17 @@ describe("codex manager cli commands", () => { wouldExceedLimit: false, eligibleForRestore: true, error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); - importAccountsMock.mockResolvedValueOnce({ - imported: 0, - skipped: 1, - total: 1, - changed: false, - }); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(importAccountsMock).toHaveBeenCalledWith( - mockBackupPath("named-backup"), - ); - expect(logSpy).toHaveBeenCalledWith( - "All accounts in this backup already exist", - ); - expect(logSpy).not.toHaveBeenCalledWith( - expect.stringContaining('Restored backup "named-backup"'), - ); - } finally { - logSpy.mockRestore(); - } - }); - - it("catches backup listing failures and returns to the login menu", async () => { - setInteractiveTTY(true); - listNamedBackupsMock.mockRejectedValueOnce( - makeErrnoError( - "EPERM: operation not permitted, scandir '/mock/backups'", - "EPERM", - ), - ); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); - expect(selectMock).not.toHaveBeenCalled(); - expect(importAccountsMock).not.toHaveBeenCalled(); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining( - "Could not read backup directory: EPERM: operation not permitted", - ), - ); - } finally { - errorSpy.mockRestore(); - } - }); - - it("reports backup validation failures separately from directory read failures", async () => { - setInteractiveTTY(true); - listNamedBackupsMock.mockRejectedValueOnce( - new Error("Backup path escapes backup directory"), - ); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); - expect(selectMock).not.toHaveBeenCalled(); - expect(importAccountsMock).not.toHaveBeenCalled(); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining( - "Backup validation failed: Backup path escapes backup directory", - ), - ); - } finally { - errorSpy.mockRestore(); - } - }); - - it("surfaces transient backup path validation failures with retry guidance", async () => { - setInteractiveTTY(true); - listNamedBackupsMock.mockRejectedValueOnce( - new Error("Backup path validation failed. Try again."), + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + restoreNamedBackupMock.mockRejectedValueOnce( + new Error("Import file not found: /mock/backups/named-backup.json"), ); promptLoginModeMock .mockResolvedValueOnce({ mode: "restore-backup" }) .mockResolvedValueOnce({ mode: "cancel" }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); try { const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -3144,43 +3517,30 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); - expect(selectMock).not.toHaveBeenCalled(); - expect(importAccountsMock).not.toHaveBeenCalled(); - expect(errorSpy).toHaveBeenCalledWith( - "Backup path validation failed. Try again.", + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(warnSpy).toHaveBeenCalledWith( + 'Failed to restore backup "named-backup" (UNKNOWN).', + ); + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining("/mock/backups/named-backup.json"), ); } finally { - errorSpy.mockRestore(); + warnSpy.mockRestore(); } }); - it("propagates containment errors from batch backup assessment and returns to the login menu", async () => { + it("catches backup listing failures and returns to the login menu", async () => { setInteractiveTTY(true); - loadAccountsMock.mockResolvedValue(null); - const now = Date.now(); - listNamedBackupsMock.mockResolvedValue([ - { - name: "escaped-backup", - path: mockBackupPath("escaped-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - ]); - assessNamedBackupRestoreMock.mockRejectedValueOnce( - new Error("Backup path escapes backup directory"), + listNamedBackupsMock.mockRejectedValueOnce( + makeErrnoError( + "EPERM: operation not permitted, scandir '/mock/backups'", + "EPERM", + ), ); promptLoginModeMock .mockResolvedValueOnce({ mode: "restore-backup" }) .mockResolvedValueOnce({ mode: "cancel" }); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); try { const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -3188,19 +3548,16 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); expect(selectMock).not.toHaveBeenCalled(); - expect(importAccountsMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining( - "Restore failed: Backup path escapes backup directory", + "Could not read backup directory: EPERM: operation not permitted", ), ); - expect(warnSpy).not.toHaveBeenCalledWith( - expect.stringContaining('Skipped backup assessment for "escaped-backup"'), - ); } finally { errorSpy.mockRestore(); - warnSpy.mockRestore(); } }); @@ -3211,7 +3568,7 @@ describe("codex manager cli commands", () => { const healthyAssessment = { backup: { name: "healthy-backup", - path: mockBackupPath("healthy-backup"), + path: "/mock/backups/healthy-backup.json", createdAt: null, updatedAt: now, sizeBytes: 128, @@ -3233,7 +3590,7 @@ describe("codex manager cli commands", () => { { ...healthyAssessment.backup, name: "broken-backup", - path: mockBackupPath("broken-backup"), + path: "/mock/backups/broken-backup.json", }, healthyAssessment.backup, ]); @@ -3259,9 +3616,7 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(importAccountsMock).toHaveBeenCalledWith( - mockBackupPath("healthy-backup"), - ); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("healthy-backup"); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining( 'Skipped backup assessment for "broken-backup": backup directory busy', @@ -3275,14 +3630,13 @@ describe("codex manager cli commands", () => { it("limits concurrent backup assessments in the restore menu", async () => { setInteractiveTTY(true); loadAccountsMock.mockResolvedValue(null); - const { NAMED_BACKUP_ASSESS_CONCURRENCY } = + const { NAMED_BACKUP_LIST_CONCURRENCY } = await vi.importActual( "../lib/storage.js", ); - const totalBackups = NAMED_BACKUP_ASSESS_CONCURRENCY + 3; - const backups = Array.from({ length: totalBackups }, (_value, index) => ({ + const backups = Array.from({ length: 9 }, (_value, index) => ({ name: `named-backup-${index + 1}`, - path: mockBackupPath(`named-backup-${index + 1}`), + path: `/mock/backups/named-backup-${index + 1}.json`, createdAt: null, updatedAt: Date.now() + index, sizeBytes: 128, @@ -3345,7 +3699,7 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(assessNamedBackupRestoreMock).toHaveBeenCalledTimes(backups.length); expect(maxInFlight).toBeLessThanOrEqual( - NAMED_BACKUP_ASSESS_CONCURRENCY, + NAMED_BACKUP_LIST_CONCURRENCY, ); }); @@ -3372,7 +3726,7 @@ describe("codex manager cli commands", () => { const initialAssessment = { backup: { name: "named-backup", - path: mockBackupPath("named-backup"), + path: "/mock/backups/named-backup.json", createdAt: null, updatedAt: now, sizeBytes: 128, @@ -3430,204 +3784,9 @@ describe("codex manager cli commands", () => { }), ); expect(confirmMock).toHaveBeenCalledWith( - expect.stringContaining("add 1 new account(s)"), + expect.stringContaining("into 3 current (4 after dedupe)"), ); - expect(importAccountsMock).toHaveBeenCalledWith( - mockBackupPath("named-backup"), - ); - }); - - it("uses metadata refresh wording when a restore only updates existing accounts", async () => { - setInteractiveTTY(true); - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - { - email: "same@example.com", - accountId: "acc_same", - refreshToken: "refresh-same", - accessToken: "access-same", - expiresAt: now + 3_600_000, - addedAt: now - 2_000, - lastUsed: now - 2_000, - enabled: true, - }, - ], - }); - const assessment = { - backup: { - name: "named-backup", - path: mockBackupPath("named-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 2, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - currentAccountCount: 2, - mergedAccountCount: 2, - imported: 0, - skipped: 2, - wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); - importAccountsMock.mockResolvedValueOnce({ - imported: 0, - skipped: 2, - total: 2, - changed: true, - }); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ - type: "restore", - assessment, - }); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(confirmMock).toHaveBeenCalledWith( - expect.stringContaining( - "refresh stored metadata for matching existing account(s)", - ), - ); - expect(confirmMock).not.toHaveBeenCalledWith( - expect.stringContaining("for 2 existing account(s)"), - ); - expect(importAccountsMock).toHaveBeenCalledWith( - mockBackupPath("named-backup"), - ); - expect(logSpy).toHaveBeenCalledWith( - 'Restored backup "named-backup". Refreshed stored metadata for matching existing account(s).', - ); - expect(logSpy).not.toHaveBeenCalledWith( - expect.stringContaining("Imported 0, skipped 2"), - ); - expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce(); - } finally { - logSpy.mockRestore(); - } - }); - - it("returns to the login menu when backup reassessment becomes ineligible", async () => { - setInteractiveTTY(true); - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - const initialAssessment = { - backup: { - name: "named-backup", - path: mockBackupPath("named-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - currentAccountCount: 0, - mergedAccountCount: 1, - imported: 1, - skipped: 0, - wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }; - const refreshedAssessment = { - ...initialAssessment, - currentAccountCount: 1, - mergedAccountCount: 1, - imported: 0, - skipped: 1, - eligibleForRestore: false, - error: "All accounts in this backup already exist", - }; - listNamedBackupsMock.mockResolvedValue([initialAssessment.backup]); - assessNamedBackupRestoreMock - .mockResolvedValueOnce(initialAssessment) - .mockResolvedValueOnce(refreshedAssessment); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ - type: "restore", - assessment: initialAssessment, - }); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 1, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), - }), - }), - ); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 2, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), - }), - }), - ); - expect(confirmMock).not.toHaveBeenCalled(); - expect(importAccountsMock).not.toHaveBeenCalled(); - expect(logSpy).toHaveBeenCalledWith( - "All accounts in this backup already exist", - ); - } finally { - logSpy.mockRestore(); - } + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); }); it("returns to the login menu when backup reassessment fails before confirmation", async () => { @@ -3640,93 +3799,22 @@ describe("codex manager cli commands", () => { accounts: [ { email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - const initialAssessment = { - backup: { - name: "named-backup", - path: mockBackupPath("named-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - currentAccountCount: 1, - mergedAccountCount: 2, - imported: 1, - skipped: 0, - wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([initialAssessment.backup]); - assessNamedBackupRestoreMock - .mockResolvedValueOnce(initialAssessment) - .mockRejectedValueOnce(makeErrnoError("backup busy", "EBUSY")); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ - type: "restore", - assessment: initialAssessment, - }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 1, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), - }), - }), - ); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 2, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), - }), - }), - ); - expect(confirmMock).not.toHaveBeenCalled(); - expect(importAccountsMock).not.toHaveBeenCalled(); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining("Restore failed: backup busy"), - ); - } finally { - errorSpy.mockRestore(); - } - }); - - it("shows epoch backup timestamps in restore hints", async () => { - setInteractiveTTY(true); - loadAccountsMock.mockResolvedValue(null); - const assessment = { + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const initialAssessment = { backup: { - name: "epoch-backup", - path: mockBackupPath("epoch-backup"), + name: "named-backup", + path: "/mock/backups/named-backup.json", createdAt: null, - updatedAt: 0, + updatedAt: now, sizeBytes: 128, version: 3, accountCount: 1, @@ -3734,133 +3822,68 @@ describe("codex manager cli commands", () => { valid: true, loadError: undefined, }, - currentAccountCount: 0, - mergedAccountCount: 1, + currentAccountCount: 1, + mergedAccountCount: 2, imported: 1, skipped: 0, wouldExceedLimit: false, eligibleForRestore: true, error: undefined, }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); + listNamedBackupsMock.mockResolvedValue([initialAssessment.backup]); + assessNamedBackupRestoreMock + .mockResolvedValueOnce(initialAssessment) + .mockRejectedValueOnce(makeErrnoError("backup busy", "EBUSY")); promptLoginModeMock .mockResolvedValueOnce({ mode: "restore-backup" }) .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ type: "back" }); + selectMock.mockResolvedValueOnce({ + type: "restore", + assessment: initialAssessment, + }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - const backupItems = selectMock.mock.calls[0]?.[0]; - expect(backupItems?.[0]?.hint).toContain("1 account"); - expect(backupItems?.[0]?.hint).not.toContain("updated "); - }); - - it("formats recent backup timestamps in restore hints", async () => { - setInteractiveTTY(true); - loadAccountsMock.mockResolvedValue(null); - const now = Date.UTC(2026, 0, 10, 12, 0, 0); - const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now); - const backups = [ - { - name: "today-backup", - path: mockBackupPath("today-backup"), - createdAt: null, - updatedAt: now - 1_000, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - { - name: "yesterday-backup", - path: mockBackupPath("yesterday-backup"), - createdAt: null, - updatedAt: now - 1.5 * 86_400_000, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - { - name: "three-days-backup", - path: mockBackupPath("three-days-backup"), - createdAt: null, - updatedAt: now - 3 * 86_400_000, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - { - name: "older-backup", - path: mockBackupPath("older-backup"), - createdAt: null, - updatedAt: now - 8 * 86_400_000, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - ]; - const assessmentsByName = new Map( - backups.map((backup) => [ - backup.name, - { - backup, - currentAccountCount: 0, - mergedAccountCount: 1, - imported: 1, - skipped: 0, - wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }, - ]), + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to re-assess backup "named-backup" before restore (EBUSY).', + ), ); - listNamedBackupsMock.mockResolvedValue(backups); - assessNamedBackupRestoreMock.mockImplementation(async (name: string) => { - return assessmentsByName.get(name) ?? assessmentsByName.get(backups[0].name)!; - }); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ type: "back" }); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - const backupItems = selectMock.mock.calls[0]?.[0]; - expect(backupItems?.[0]?.hint).toContain("updated today"); - expect(backupItems?.[1]?.hint).toContain("updated yesterday"); - expect(backupItems?.[2]?.hint).toContain("updated 3d ago"); - expect(backupItems?.[3]?.hint).toContain("updated "); - } finally { - nowSpy.mockRestore(); - } }); - it("suppresses invalid backup timestamps in restore hints", async () => { + it("shows epoch backup timestamps in restore hints", async () => { setInteractiveTTY(true); loadAccountsMock.mockResolvedValue(null); const assessment = { backup: { - name: "nan-backup", - path: mockBackupPath("nan-backup"), + name: "epoch-backup", + path: "/mock/backups/epoch-backup.json", createdAt: null, - updatedAt: Number.NaN, + updatedAt: 0, sizeBytes: 128, version: 3, accountCount: 1, @@ -3888,8 +3911,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); const backupItems = selectMock.mock.calls[0]?.[0]; - expect(backupItems?.[0]?.hint).toContain("1 account"); - expect(backupItems?.[0]?.hint).not.toContain("updated "); + expect(backupItems?.[0]?.hint).toContain( + `updated ${new Date(0).toLocaleDateString()}`, + ); }); it("shows experimental settings in the settings hub", async () => { @@ -5029,142 +5053,48 @@ describe("codex manager cli commands", () => { logSpy.mockRestore(); }); - it("waits for an in-flight menu quota refresh before starting quick check", async () => { + it("waits for an in-flight menu quota refresh before opening backup restore manager", async () => { const now = Date.now(); - const menuStorage = { - version: 3 as const, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "alpha@example.com", - accountId: "acc-alpha", - accessToken: "access-alpha", - expiresAt: now + 3_600_000, - refreshToken: "refresh-alpha", - addedAt: now, - lastUsed: now, - enabled: true, - }, - { - email: "beta@example.com", - accountId: "acc-beta", - accessToken: "access-beta", - expiresAt: now + 3_600_000, - refreshToken: "refresh-beta", - addedAt: now, - lastUsed: now, - enabled: true, - }, - ], - }; - const quickCheckStorage = { - ...menuStorage, - accounts: [menuStorage.accounts[0]!], - }; - let loadAccountsCalls = 0; - loadAccountsMock.mockImplementation(async () => { - loadAccountsCalls += 1; - return structuredClone( - loadAccountsCalls === 1 ? menuStorage : quickCheckStorage, - ); - }); - loadDashboardDisplaySettingsMock.mockResolvedValue({ - showPerAccountRows: true, - showQuotaDetails: true, - showForecastReasons: true, - showRecommendations: true, - showLiveProbeNotes: true, - menuAutoFetchLimits: true, - menuShowFetchStatus: true, - menuQuotaTtlMs: 60_000, - menuSortEnabled: true, - menuSortMode: "ready-first", - menuSortPinCurrent: true, - menuSortQuickSwitchVisibleRow: true, - }); - let currentQuotaCache: { - byAccountId: Record; - byEmail: Record; - } = { - byAccountId: {}, - byEmail: {}, - }; - loadQuotaCacheMock.mockImplementation(async () => - structuredClone(currentQuotaCache), - ); - saveQuotaCacheMock.mockImplementation(async (value: typeof currentQuotaCache) => { - currentQuotaCache = structuredClone(value); - }); - const firstFetchStarted = createDeferred(); - const secondFetchStarted = createDeferred(); - const releaseFirstFetch = createDeferred(); - const releaseSecondFetch = createDeferred(); - let fetchCallCount = 0; - fetchCodexQuotaSnapshotMock.mockImplementation( - async (input: { accountId: string }) => { - fetchCallCount += 1; - if (fetchCallCount === 1) { - firstFetchStarted.resolve(); - await releaseFirstFetch.promise; - } else if (fetchCallCount === 2) { - secondFetchStarted.resolve(input.accountId); - await releaseSecondFetch.promise; - } - return { - status: 200, - model: "gpt-5-codex", - primary: {}, - secondary: {}, - }; + const fetchStarted = createDeferred(); + const fetchDeferred = createDeferred<{ + status: number; + model: string; + primary: Record; + secondary: Record; + }>(); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, }, - ); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "check" }) - .mockResolvedValueOnce({ mode: "cancel" }); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const runPromise = runCodexMultiAuthCli(["auth", "login"]); - - await firstFetchStarted.promise; - await Promise.resolve(); - - expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(1); - - releaseFirstFetch.resolve(); - - const secondAccountId = await secondFetchStarted.promise; - expect(secondAccountId).toBe("acc-beta"); - - releaseSecondFetch.resolve(); - - const exitCode = await runPromise; - - expect(exitCode).toBe(0); - expect(Object.keys(currentQuotaCache.byEmail)).toEqual( - expect.arrayContaining(["alpha@example.com", "beta@example.com"]), - ); - } finally { - logSpy.mockRestore(); - } - }); + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; - it("waits for an in-flight menu quota refresh before starting backup restore", async () => { - setInteractiveTTY(true); - const now = Date.now(); loadAccountsMock.mockResolvedValue({ version: 3, activeIndex: 0, activeIndexByFamily: { codex: 0 }, accounts: [ { - email: "restore@example.com", - accountId: "acc-restore", - accessToken: "access-restore", + email: "first@example.com", + accountId: "acc-first", + accessToken: "access-first", expiresAt: now + 3_600_000, - refreshToken: "refresh-restore", + refreshToken: "refresh-first", addedAt: now, lastUsed: now, enabled: true, @@ -5185,60 +5115,45 @@ describe("codex manager cli commands", () => { menuSortPinCurrent: true, menuSortQuickSwitchVisibleRow: true, }); - let currentQuotaCache: { - byAccountId: Record; - byEmail: Record; - } = { + loadQuotaCacheMock.mockResolvedValue({ byAccountId: {}, byEmail: {}, - }; - loadQuotaCacheMock.mockImplementation(async () => - structuredClone(currentQuotaCache), - ); - saveQuotaCacheMock.mockImplementation(async (value: typeof currentQuotaCache) => { - currentQuotaCache = structuredClone(value); }); - const fetchStarted = createDeferred(); - const releaseFetch = createDeferred(); + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); fetchCodexQuotaSnapshotMock.mockImplementation(async () => { fetchStarted.resolve(); - await releaseFetch.promise; - return { - status: 200, - model: "gpt-5-codex", - primary: {}, - secondary: {}, - }; + return fetchDeferred.promise; }); - listNamedBackupsMock.mockResolvedValue([]); promptLoginModeMock .mockResolvedValueOnce({ mode: "restore-backup" }) .mockResolvedValueOnce({ mode: "cancel" }); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const runPromise = runCodexMultiAuthCli(["auth", "login"]); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const runPromise = runCodexMultiAuthCli(["auth", "login"]); - await fetchStarted.promise; - await Promise.resolve(); + await fetchStarted.promise; + await Promise.resolve(); - expect(listNamedBackupsMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); - releaseFetch.resolve(); + fetchDeferred.resolve({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }); - const exitCode = await runPromise; + const exitCode = await runPromise; - expect(exitCode).toBe(0); - expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); - expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); - expect(saveQuotaCacheMock.mock.invocationCallOrder[0]).toBeLessThan( - listNamedBackupsMock.mock.invocationCallOrder[0] ?? - Number.POSITIVE_INFINITY, - ); - } finally { - logSpy.mockRestore(); - } + expect(exitCode).toBe(0); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(restoreNamedBackupMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock.mock.invocationCallOrder[0]).toBeLessThan( + restoreNamedBackupMock.mock.invocationCallOrder[0] ?? + Number.POSITIVE_INFINITY, + ); }); it("skips a second destructive action while reset is already running", async () => { diff --git a/test/recovery.test.ts b/test/recovery.test.ts index 6176d6ac..54f572c8 100644 --- a/test/recovery.test.ts +++ b/test/recovery.test.ts @@ -1,1119 +1,1900 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - detectErrorType, - isRecoverableError, - getRecoveryToastContent, - getRecoverySuccessToast, - getRecoveryFailureToast, - createSessionRecoveryHook, + createSessionRecoveryHook, + detectErrorType, + getRecoveryFailureToast, + getRecoverySuccessToast, + getRecoveryToastContent, + isRecoverableError, } from "../lib/recovery"; +afterEach(() => { + vi.restoreAllMocks(); +}); + vi.mock("../lib/recovery/storage.js", () => ({ - readParts: vi.fn(() => []), - findMessagesWithThinkingBlocks: vi.fn(() => []), - findMessagesWithOrphanThinking: vi.fn(() => []), - findMessageByIndexNeedingThinking: vi.fn(() => null), - prependThinkingPart: vi.fn(() => false), - stripThinkingParts: vi.fn(() => false), + readParts: vi.fn(() => []), + findMessagesWithThinkingBlocks: vi.fn(() => []), + findMessagesWithOrphanThinking: vi.fn(() => []), + findMessageByIndexNeedingThinking: vi.fn(() => null), + prependThinkingPart: vi.fn(() => false), + stripThinkingParts: vi.fn(() => false), })); import { - readParts, - findMessagesWithThinkingBlocks, - findMessagesWithOrphanThinking, - findMessageByIndexNeedingThinking, - prependThinkingPart, - stripThinkingParts, + findMessageByIndexNeedingThinking, + findMessagesWithOrphanThinking, + findMessagesWithThinkingBlocks, + prependThinkingPart, + readParts, + stripThinkingParts, } from "../lib/recovery/storage.js"; const mockedReadParts = vi.mocked(readParts); -const mockedFindMessagesWithThinkingBlocks = vi.mocked(findMessagesWithThinkingBlocks); -const mockedFindMessagesWithOrphanThinking = vi.mocked(findMessagesWithOrphanThinking); -const mockedFindMessageByIndexNeedingThinking = vi.mocked(findMessageByIndexNeedingThinking); +const mockedFindMessagesWithThinkingBlocks = vi.mocked( + findMessagesWithThinkingBlocks, +); +const mockedFindMessagesWithOrphanThinking = vi.mocked( + findMessagesWithOrphanThinking, +); +const mockedFindMessageByIndexNeedingThinking = vi.mocked( + findMessageByIndexNeedingThinking, +); const mockedPrependThinkingPart = vi.mocked(prependThinkingPart); const mockedStripThinkingParts = vi.mocked(stripThinkingParts); +async function removeWithRetry(targetPath: string): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.rm(targetPath, { recursive: true, force: true }); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + (code !== "EBUSY" && code !== "EPERM" && code !== "ENOTEMPTY") || + attempt === 4 + ) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + function createMockClient() { - return { - session: { - prompt: vi.fn().mockResolvedValue({}), - abort: vi.fn().mockResolvedValue({}), - messages: vi.fn().mockResolvedValue({ data: [] }), - }, - tui: { - showToast: vi.fn().mockResolvedValue({}), - }, - }; + return { + session: { + prompt: vi.fn().mockResolvedValue({}), + abort: vi.fn().mockResolvedValue({}), + messages: vi.fn().mockResolvedValue({ data: [] }), + }, + tui: { + showToast: vi.fn().mockResolvedValue({}), + }, + }; } describe("detectErrorType", () => { - describe("tool_result_missing detection", () => { - it("detects tool_use without tool_result error", () => { - const error = { - type: "invalid_request_error", - message: "messages.105: `tool_use` ids were found without `tool_result` blocks immediately after: tool-call-59" - }; - expect(detectErrorType(error)).toBe("tool_result_missing"); - }); - - it("detects tool_use/tool_result mismatch error", () => { - const error = "Each `tool_use` block must have a corresponding `tool_result` block in the next message."; - expect(detectErrorType(error)).toBe("tool_result_missing"); - }); - - it("detects error from string message", () => { - const error = "tool_use without matching tool_result"; - expect(detectErrorType(error)).toBe("tool_result_missing"); - }); - }); - - describe("thinking_block_order detection", () => { - it("detects thinking first block error", () => { - const error = "thinking must be the first block in the message"; - expect(detectErrorType(error)).toBe("thinking_block_order"); - }); - - it("detects thinking must start with error", () => { - const error = "Response must start with thinking block"; - expect(detectErrorType(error)).toBe("thinking_block_order"); - }); - - it("detects thinking preceeding error", () => { - const error = "thinking block preceeding tool use is required"; - expect(detectErrorType(error)).toBe("thinking_block_order"); - }); - - it("detects thinking expected/found error", () => { - const error = "Expected thinking block but found text"; - expect(detectErrorType(error)).toBe("thinking_block_order"); - }); - }); - - describe("thinking_disabled_violation detection", () => { - it("detects thinking disabled error", () => { - const error = "thinking is disabled for this model and cannot contain thinking blocks"; - expect(detectErrorType(error)).toBe("thinking_disabled_violation"); - }); - }); - - describe("non-recoverable errors", () => { - it("returns null for prompt too long error", () => { - const error = { message: "Prompt is too long" }; - expect(detectErrorType(error)).toBeNull(); - }); - - it("returns null for context length exceeded error", () => { - const error = "context length exceeded"; - expect(detectErrorType(error)).toBeNull(); - }); - - it("returns null for generic errors", () => { - expect(detectErrorType("Something went wrong")).toBeNull(); - expect(detectErrorType({ message: "Unknown error" })).toBeNull(); - expect(detectErrorType(null)).toBeNull(); - expect(detectErrorType(undefined)).toBeNull(); - }); - - it("returns null for rate limit errors", () => { - const error = { message: "Rate limit exceeded. Retry after 5s" }; - expect(detectErrorType(error)).toBeNull(); - }); - - it("handles error with circular reference gracefully (line 50 coverage)", () => { - const circularError: Record = { name: "CircularError" }; - circularError.self = circularError; - expect(detectErrorType(circularError)).toBeNull(); - }); - }); + describe("tool_result_missing detection", () => { + it("detects tool_use without tool_result error", () => { + const error = { + type: "invalid_request_error", + message: + "messages.105: `tool_use` ids were found without `tool_result` blocks immediately after: tool-call-59", + }; + expect(detectErrorType(error)).toBe("tool_result_missing"); + }); + + it("detects tool_use/tool_result mismatch error", () => { + const error = + "Each `tool_use` block must have a corresponding `tool_result` block in the next message."; + expect(detectErrorType(error)).toBe("tool_result_missing"); + }); + + it("detects error from string message", () => { + const error = "tool_use without matching tool_result"; + expect(detectErrorType(error)).toBe("tool_result_missing"); + }); + }); + + describe("thinking_block_order detection", () => { + it("detects thinking first block error", () => { + const error = "thinking must be the first block in the message"; + expect(detectErrorType(error)).toBe("thinking_block_order"); + }); + + it("detects thinking must start with error", () => { + const error = "Response must start with thinking block"; + expect(detectErrorType(error)).toBe("thinking_block_order"); + }); + + it("detects thinking preceeding error", () => { + const error = "thinking block preceeding tool use is required"; + expect(detectErrorType(error)).toBe("thinking_block_order"); + }); + + it("detects thinking expected/found error", () => { + const error = "Expected thinking block but found text"; + expect(detectErrorType(error)).toBe("thinking_block_order"); + }); + }); + + describe("thinking_disabled_violation detection", () => { + it("detects thinking disabled error", () => { + const error = + "thinking is disabled for this model and cannot contain thinking blocks"; + expect(detectErrorType(error)).toBe("thinking_disabled_violation"); + }); + }); + + describe("non-recoverable errors", () => { + it("returns null for prompt too long error", () => { + const error = { message: "Prompt is too long" }; + expect(detectErrorType(error)).toBeNull(); + }); + + it("returns null for context length exceeded error", () => { + const error = "context length exceeded"; + expect(detectErrorType(error)).toBeNull(); + }); + + it("returns null for generic errors", () => { + expect(detectErrorType("Something went wrong")).toBeNull(); + expect(detectErrorType({ message: "Unknown error" })).toBeNull(); + expect(detectErrorType(null)).toBeNull(); + expect(detectErrorType(undefined)).toBeNull(); + }); + + it("returns null for rate limit errors", () => { + const error = { message: "Rate limit exceeded. Retry after 5s" }; + expect(detectErrorType(error)).toBeNull(); + }); + + it("handles error with circular reference gracefully (line 50 coverage)", () => { + const circularError: Record = { name: "CircularError" }; + circularError.self = circularError; + expect(detectErrorType(circularError)).toBeNull(); + }); + }); +}); + +describe("getActionableNamedBackupRestores (override)", () => { + it("accepts injected backups and assessor", async () => { + const storage = await import("../lib/storage.js"); + const mockBackups = [ + { + name: "invalid-backup", + path: "/mock/backups/invalid.json", + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: 0, + schemaErrors: [], + valid: false, + loadError: "invalid", + }, + { + name: "valid-backup", + path: "/mock/backups/valid.json", + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + ]; + + const result = await storage.getActionableNamedBackupRestores({ + backups: mockBackups, + assess: async (name: string) => { + if (name === "valid-backup") { + return { + backup: mockBackups[1], + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + } + + return { + backup: mockBackups[0], + currentAccountCount: 0, + mergedAccountCount: null, + imported: null, + skipped: null, + wouldExceedLimit: false, + eligibleForRestore: false, + error: "invalid", + }; + }, + }); + + expect(result.totalBackups).toBe(mockBackups.length); + expect(result.assessments.map((item) => item.backup.name)).toEqual([ + "valid-backup", + ]); + }); + it("passes explicit null currentStorage through without reloading accounts", async () => { + const storage = await import("../lib/storage.js"); + const loadAccountsSpy = vi.spyOn(storage, "loadAccounts"); + const mockBackups = [ + { + name: "valid-backup", + path: "/mock/backups/valid.json", + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + ]; + const assess = vi.fn(async (_name: string, options?: { currentStorage?: unknown }) => ({ + backup: mockBackups[0], + currentAccountCount: options?.currentStorage === null ? 0 : 99, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + })); + + const result = await storage.getActionableNamedBackupRestores({ + backups: mockBackups, + assess, + currentStorage: null, + }); + + expect(result.assessments).toHaveLength(1); + expect(assess).toHaveBeenCalledWith("valid-backup", { + currentStorage: null, + }); + expect(loadAccountsSpy).not.toHaveBeenCalled(); + loadAccountsSpy.mockRestore(); + }); + + it("keeps actionable backups when another assessment throws", async () => { + const storage = await import("../lib/storage.js"); + const mockBackups = [ + { + name: "broken-backup", + path: "/mock/backups/broken.json", + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + { + name: "valid-backup", + path: "/mock/backups/valid.json", + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + ]; + const assess = vi.fn(async (name: string) => { + if (name === "broken-backup") { + throw new Error("backup locked"); + } + + return { + backup: mockBackups[1], + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + }); + + const result = await storage.getActionableNamedBackupRestores({ + backups: mockBackups, + assess, + currentStorage: null, + }); + + expect(result.totalBackups).toBe(mockBackups.length); + expect(result.assessments.map((item) => item.backup.name)).toEqual([ + "valid-backup", + ]); + expect(assess).toHaveBeenCalledTimes(2); + }); + +}); + +describe("getActionableNamedBackupRestores (storage-backed paths)", () => { + let testWorkDir: string; + let testStoragePath: string; + + beforeEach(async () => { + testWorkDir = await fs.mkdtemp(join(tmpdir(), "recovery-backups-")); + testStoragePath = join(testWorkDir, "accounts.json"); + const storage = await import("../lib/storage.js"); + storage.setStoragePathDirect(testStoragePath); + }); + + afterEach(async () => { + const storage = await import("../lib/storage.js"); + storage.setStoragePathDirect(null); + await removeWithRetry(testWorkDir); + }); + + it("scans named backups by default and returns actionable restores", async () => { + const storage = await import("../lib/storage.js"); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "restored@example.com", + refreshToken: "restore-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await storage.createNamedBackup("startup-fast-path"); + await storage.saveAccounts(emptyStorage); + + const result = await storage.getActionableNamedBackupRestores(); + + expect(result.totalBackups).toBe(1); + expect(result.assessments.map((item) => item.backup.name)).toEqual([ + "startup-fast-path", + ]); + expect(result.assessments[0]?.imported).toBe(1); + }); + + it("keeps actionable backups when fast-path scan hits EBUSY", async () => { + const storage = await import("../lib/storage.js"); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "locked@example.com", + refreshToken: "locked-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await storage.createNamedBackup("locked-backup"); + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "valid@example.com", + refreshToken: "valid-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await storage.createNamedBackup("valid-backup"); + await storage.saveAccounts(emptyStorage); + + const backups = await storage.listNamedBackups(); + const lockedBackup = backups.find((backup) => backup.name === "locked-backup"); + const validBackup = backups.find((backup) => backup.name === "valid-backup"); + expect(lockedBackup).toBeDefined(); + expect(validBackup).toBeDefined(); + + const originalReadFile = fs.readFile.bind(fs); + const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation( + (async (...args: Parameters) => { + const [path] = args; + if (path === lockedBackup?.path) { + const error = new Error("resource busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalReadFile(...args); + }) as typeof fs.readFile, + ); + + try { + const result = await storage.getActionableNamedBackupRestores({ + currentStorage: emptyStorage, + }); + + expect(result.totalBackups).toBe(2); + expect(result.assessments.map((item) => item.backup.name)).toEqual([ + "valid-backup", + ]); + expect(result.assessments[0]?.imported).toBe(1); + expect(readFileSpy.mock.calls.map(([path]) => path)).toEqual( + expect.arrayContaining([lockedBackup?.path, validBackup?.path]), + ); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("keeps actionable backups when fast-path metadata stat hits EBUSY", async () => { + const storage = await import("../lib/storage.js"); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "locked@example.com", + refreshToken: "locked-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await storage.createNamedBackup("locked-backup"); + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "valid@example.com", + refreshToken: "valid-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await storage.createNamedBackup("valid-backup"); + await storage.saveAccounts(emptyStorage); + + const backupDir = storage.getNamedBackupsDirectoryPath(); + const lockedBackupPath = join(backupDir, "locked-backup.json"); + const validBackupPath = join(backupDir, "valid-backup.json"); + const originalStat = fs.stat.bind(fs); + const statSpy = vi.spyOn(fs, "stat").mockImplementation( + (async (...args: Parameters) => { + const [path] = args; + const normalizedPath = + typeof path === "string" ? path.replaceAll("\\", "/") : String(path); + if ( + path === lockedBackupPath || + normalizedPath.endsWith("/locked-backup.json") + ) { + const error = new Error("resource busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalStat(...args); + }) as typeof fs.stat, + ); + + try { + const result = await storage.getActionableNamedBackupRestores({ + currentStorage: emptyStorage, + }); + + expect(result.totalBackups).toBe(2); + expect(result.assessments.map((item) => item.backup.name)).toEqual([ + "valid-backup", + "locked-backup", + ]); + expect(result.assessments.map((item) => item.imported)).toEqual([1, 1]); + expect(statSpy.mock.calls.map(([path]) => path)).toEqual( + expect.arrayContaining([lockedBackupPath, validBackupPath]), + ); + } finally { + statSpy.mockRestore(); + } + }); + + it("does not pre-read backups when a custom assessor is injected", async () => { + const storage = await import("../lib/storage.js"); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "first-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await storage.createNamedBackup("first-backup"); + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "second@example.com", + refreshToken: "second-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await storage.createNamedBackup("second-backup"); + await storage.saveAccounts(emptyStorage); + + const backups = await storage.listNamedBackups(); + const backupByName = new Map(backups.map((backup) => [backup.name, backup])); + const assess = vi.fn(async (name: string) => ({ + backup: backupByName.get(name)!, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + })); + + const readFileSpy = vi.spyOn(fs, "readFile"); + try { + const result = await storage.getActionableNamedBackupRestores({ + assess, + currentStorage: emptyStorage, + }); + + expect(result.totalBackups).toBe(2); + expect(result.assessments).toHaveLength(2); + expect( + assess.mock.calls.map(([name]) => name).sort((a, b) => a.localeCompare(b)), + ).toEqual(["first-backup", "second-backup"]); + expect(readFileSpy).not.toHaveBeenCalled(); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("keeps injected-assessor backups when metadata stat hits EBUSY", async () => { + const storage = await import("../lib/storage.js"); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "first-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await storage.createNamedBackup("first-backup"); + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "second@example.com", + refreshToken: "second-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await storage.createNamedBackup("second-backup"); + await storage.saveAccounts(emptyStorage); + + const backups = await storage.listNamedBackups(); + const backupByName = new Map(backups.map((backup) => [backup.name, backup])); + const lockedBackup = backupByName.get("first-backup"); + const secondBackup = backupByName.get("second-backup"); + expect(lockedBackup).toBeDefined(); + expect(secondBackup).toBeDefined(); + + const originalStat = fs.stat.bind(fs); + const statSpy = vi.spyOn(fs, "stat").mockImplementation( + (async (...args: Parameters) => { + const [path] = args; + const normalizedPath = + typeof path === "string" ? path.replaceAll("\\", "/") : String(path); + if ( + path === lockedBackup?.path || + normalizedPath.endsWith("/locked-backup.json") + ) { + const error = new Error("resource busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalStat(...args); + }) as typeof fs.stat, + ); + const readFileSpy = vi.spyOn(fs, "readFile"); + const assess = vi.fn(async (name: string) => ({ + backup: backupByName.get(name)!, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + })); + + try { + const result = await storage.getActionableNamedBackupRestores({ + assess, + currentStorage: emptyStorage, + }); + + expect(result.totalBackups).toBe(2); + expect(result.assessments).toHaveLength(2); + expect( + assess.mock.calls.map(([name]) => name).sort((a, b) => a.localeCompare(b)), + ).toEqual(["first-backup", "second-backup"]); + expect(readFileSpy).not.toHaveBeenCalled(); + expect(statSpy.mock.calls.map(([path]) => path)).toEqual( + expect.arrayContaining([lockedBackup?.path, secondBackup?.path]), + ); + } finally { + statSpy.mockRestore(); + readFileSpy.mockRestore(); + } + }); + + it("keeps actionable backups when default assessment hits EBUSY", async () => { + const storage = await import("../lib/storage.js"); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "locked@example.com", + refreshToken: "locked-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await storage.createNamedBackup("locked-backup"); + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "valid@example.com", + refreshToken: "valid-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await storage.createNamedBackup("valid-backup"); + await storage.saveAccounts(emptyStorage); + const backups = await storage.listNamedBackups(); + const lockedBackup = backups.find((backup) => backup.name === "locked-backup"); + const validBackup = backups.find((backup) => backup.name === "valid-backup"); + expect(lockedBackup).toBeDefined(); + expect(validBackup).toBeDefined(); + + const originalReadFile = fs.readFile.bind(fs); + const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation( + (async (...args: Parameters) => { + const [path] = args; + if (path === lockedBackup?.path) { + const error = new Error("resource busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalReadFile(...args); + }) as typeof fs.readFile, + ); + + const result = await storage.getActionableNamedBackupRestores({ + backups, + currentStorage: emptyStorage, + }); + + expect(result.totalBackups).toBe(2); + expect(result.assessments.map((item) => item.backup.name)).toEqual([ + "valid-backup", + ]); + expect( + result.allAssessments + .map((item) => item.backup.name) + .sort((left, right) => left.localeCompare(right)), + ).toEqual(["locked-backup", "valid-backup"]); + expect( + result.allAssessments.find((item) => item.backup.name === "locked-backup"), + ).toMatchObject({ + eligibleForRestore: false, + error: expect.stringContaining("busy"), + }); + const readPaths = readFileSpy.mock.calls.map(([path]) => path); + expect(readPaths).toEqual( + expect.arrayContaining([lockedBackup?.path, validBackup?.path]), + ); + expect(readPaths.filter((path) => path === lockedBackup?.path)).toHaveLength(5); + expect(readPaths.filter((path) => path === validBackup?.path)).toHaveLength(1); + }); + +}); + +describe("resolveStartupRecoveryAction", () => { + it("re-enters the empty storage menu instead of OAuth when backups exist but none are actionable", async () => { + const { resolveStartupRecoveryAction } = await import( + "../lib/codex-manager.js" + ); + + expect( + resolveStartupRecoveryAction( + { assessments: [], totalBackups: 2 }, + false, + ), + ).toBe("open-empty-storage-menu"); + expect( + resolveStartupRecoveryAction( + { assessments: [], totalBackups: 2 }, + false, + ), + ).not.toBe("continue-with-oauth"); + }); + + it("falls through to OAuth when the startup recovery scan itself failed", async () => { + const { resolveStartupRecoveryAction } = await import( + "../lib/codex-manager.js" + ); + + expect( + resolveStartupRecoveryAction( + { assessments: [], totalBackups: 0 }, + true, + ), + ).toBe("continue-with-oauth"); + }); }); describe("isRecoverableError", () => { - it("returns true for tool_result_missing", () => { - const error = "tool_use without tool_result"; - expect(isRecoverableError(error)).toBe(true); - }); - - it("returns true for thinking_block_order", () => { - const error = "thinking must be the first block"; - expect(isRecoverableError(error)).toBe(true); - }); - - it("returns true for thinking_disabled_violation", () => { - const error = "thinking is disabled and cannot contain thinking"; - expect(isRecoverableError(error)).toBe(true); - }); - - it("returns false for non-recoverable errors", () => { - expect(isRecoverableError("Prompt is too long")).toBe(false); - expect(isRecoverableError("context length exceeded")).toBe(false); - expect(isRecoverableError("Generic error")).toBe(false); - expect(isRecoverableError(null)).toBe(false); - }); + it("returns true for tool_result_missing", () => { + const error = "tool_use without tool_result"; + expect(isRecoverableError(error)).toBe(true); + }); + + it("returns true for thinking_block_order", () => { + const error = "thinking must be the first block"; + expect(isRecoverableError(error)).toBe(true); + }); + + it("returns true for thinking_disabled_violation", () => { + const error = "thinking is disabled and cannot contain thinking"; + expect(isRecoverableError(error)).toBe(true); + }); + + it("returns false for non-recoverable errors", () => { + expect(isRecoverableError("Prompt is too long")).toBe(false); + expect(isRecoverableError("context length exceeded")).toBe(false); + expect(isRecoverableError("Generic error")).toBe(false); + expect(isRecoverableError(null)).toBe(false); + }); }); describe("context error message patterns", () => { - describe("prompt too long patterns", () => { - const promptTooLongPatterns = [ - "Prompt is too long", - "prompt is too long for this model", - "The prompt is too long", - ]; - - it.each(promptTooLongPatterns)("'%s' is not a recoverable error", (msg) => { - expect(isRecoverableError(msg)).toBe(false); - expect(detectErrorType(msg)).toBeNull(); - }); - }); - - describe("context length exceeded patterns", () => { - const contextLengthPatterns = [ - "context length exceeded", - "context_length_exceeded", - "maximum context length", - "exceeds the maximum context window", - ]; - - it.each(contextLengthPatterns)("'%s' is not a recoverable error", (msg) => { - expect(isRecoverableError(msg)).toBe(false); - expect(detectErrorType(msg)).toBeNull(); - }); - }); - - describe("tool pairing error patterns", () => { - const toolPairingPatterns = [ - "tool_use ids were found without tool_result blocks immediately after", - "Each tool_use block must have a corresponding tool_result", - "tool_use without matching tool_result", - ]; - - it.each(toolPairingPatterns)("'%s' is detected as tool_result_missing", (msg) => { - expect(detectErrorType(msg)).toBe("tool_result_missing"); - expect(isRecoverableError(msg)).toBe(true); - }); - }); + describe("prompt too long patterns", () => { + const promptTooLongPatterns = [ + "Prompt is too long", + "prompt is too long for this model", + "The prompt is too long", + ]; + + it.each(promptTooLongPatterns)("'%s' is not a recoverable error", (msg) => { + expect(isRecoverableError(msg)).toBe(false); + expect(detectErrorType(msg)).toBeNull(); + }); + }); + + describe("context length exceeded patterns", () => { + const contextLengthPatterns = [ + "context length exceeded", + "context_length_exceeded", + "maximum context length", + "exceeds the maximum context window", + ]; + + it.each(contextLengthPatterns)("'%s' is not a recoverable error", (msg) => { + expect(isRecoverableError(msg)).toBe(false); + expect(detectErrorType(msg)).toBeNull(); + }); + }); + + describe("tool pairing error patterns", () => { + const toolPairingPatterns = [ + "tool_use ids were found without tool_result blocks immediately after", + "Each tool_use block must have a corresponding tool_result", + "tool_use without matching tool_result", + ]; + + it.each( + toolPairingPatterns, + )("'%s' is detected as tool_result_missing", (msg) => { + expect(detectErrorType(msg)).toBe("tool_result_missing"); + expect(isRecoverableError(msg)).toBe(true); + }); + }); }); describe("getRecoveryToastContent", () => { - it("returns tool crash recovery for tool_result_missing", () => { - const content = getRecoveryToastContent("tool_result_missing"); - expect(content.title).toBe("Tool Crash Recovery"); - expect(content.message).toBe("Injecting cancelled tool results..."); - }); - - it("returns thinking block recovery for thinking_block_order", () => { - const content = getRecoveryToastContent("thinking_block_order"); - expect(content.title).toBe("Thinking Block Recovery"); - expect(content.message).toBe("Fixing message structure..."); - }); - - it("returns thinking strip recovery for thinking_disabled_violation", () => { - const content = getRecoveryToastContent("thinking_disabled_violation"); - expect(content.title).toBe("Thinking Strip Recovery"); - expect(content.message).toBe("Stripping thinking blocks..."); - }); - - it("returns generic recovery for null error type", () => { - const content = getRecoveryToastContent(null); - expect(content.title).toBe("Session Recovery"); - expect(content.message).toBe("Attempting to recover session..."); - }); + it("returns tool crash recovery for tool_result_missing", () => { + const content = getRecoveryToastContent("tool_result_missing"); + expect(content.title).toBe("Tool Crash Recovery"); + expect(content.message).toBe("Injecting cancelled tool results..."); + }); + + it("returns thinking block recovery for thinking_block_order", () => { + const content = getRecoveryToastContent("thinking_block_order"); + expect(content.title).toBe("Thinking Block Recovery"); + expect(content.message).toBe("Fixing message structure..."); + }); + + it("returns thinking strip recovery for thinking_disabled_violation", () => { + const content = getRecoveryToastContent("thinking_disabled_violation"); + expect(content.title).toBe("Thinking Strip Recovery"); + expect(content.message).toBe("Stripping thinking blocks..."); + }); + + it("returns generic recovery for null error type", () => { + const content = getRecoveryToastContent(null); + expect(content.title).toBe("Session Recovery"); + expect(content.message).toBe("Attempting to recover session..."); + }); }); describe("getRecoverySuccessToast", () => { - it("returns success toast content", () => { - const content = getRecoverySuccessToast(); - expect(content.title).toBe("Session Recovered"); - expect(content.message).toBe("Continuing where you left off..."); - }); + it("returns success toast content", () => { + const content = getRecoverySuccessToast(); + expect(content.title).toBe("Session Recovered"); + expect(content.message).toBe("Continuing where you left off..."); + }); }); describe("getRecoveryFailureToast", () => { - it("returns failure toast content", () => { - const content = getRecoveryFailureToast(); - expect(content.title).toBe("Recovery Failed"); - expect(content.message).toBe("Please retry or start a new session."); - }); + it("returns failure toast content", () => { + const content = getRecoveryFailureToast(); + expect(content.title).toBe("Recovery Failed"); + expect(content.message).toBe("Please retry or start a new session."); + }); }); describe("createSessionRecoveryHook", () => { - it("returns null when sessionRecovery is disabled", () => { - const ctx = { client: {} as never, directory: "/test" }; - const config = { sessionRecovery: false, autoResume: false }; - const hook = createSessionRecoveryHook(ctx, config); - expect(hook).toBeNull(); - }); - - it("returns hook object when sessionRecovery is enabled", () => { - const ctx = { client: {} as never, directory: "/test" }; - const config = { sessionRecovery: true, autoResume: false }; - const hook = createSessionRecoveryHook(ctx, config); - expect(hook).not.toBeNull(); - expect(hook?.handleSessionRecovery).toBeTypeOf("function"); - expect(hook?.isRecoverableError).toBeTypeOf("function"); - expect(hook?.setOnAbortCallback).toBeTypeOf("function"); - expect(hook?.setOnRecoveryCompleteCallback).toBeTypeOf("function"); - }); - - it("hook.isRecoverableError delegates to module function", () => { - const ctx = { client: {} as never, directory: "/test" }; - const config = { sessionRecovery: true, autoResume: false }; - const hook = createSessionRecoveryHook(ctx, config); - expect(hook?.isRecoverableError("tool_use without tool_result")).toBe(true); - expect(hook?.isRecoverableError("generic error")).toBe(false); - }); + it("returns null when sessionRecovery is disabled", () => { + const ctx = { client: {} as never, directory: "/test" }; + const config = { sessionRecovery: false, autoResume: false }; + const hook = createSessionRecoveryHook(ctx, config); + expect(hook).toBeNull(); + }); + + it("returns hook object when sessionRecovery is enabled", () => { + const ctx = { client: {} as never, directory: "/test" }; + const config = { sessionRecovery: true, autoResume: false }; + const hook = createSessionRecoveryHook(ctx, config); + expect(hook).not.toBeNull(); + expect(hook?.handleSessionRecovery).toBeTypeOf("function"); + expect(hook?.isRecoverableError).toBeTypeOf("function"); + expect(hook?.setOnAbortCallback).toBeTypeOf("function"); + expect(hook?.setOnRecoveryCompleteCallback).toBeTypeOf("function"); + }); + + it("hook.isRecoverableError delegates to module function", () => { + const ctx = { client: {} as never, directory: "/test" }; + const config = { sessionRecovery: true, autoResume: false }; + const hook = createSessionRecoveryHook(ctx, config); + expect(hook?.isRecoverableError("tool_use without tool_result")).toBe(true); + expect(hook?.isRecoverableError("generic error")).toBe(false); + }); }); describe("error message extraction edge cases", () => { - it("handles nested error.data.error structure", () => { - const error = { - data: { - error: { - message: "tool_use without tool_result found" - } - } - }; - expect(detectErrorType(error)).toBe("tool_result_missing"); - }); - - it("handles error.data.message structure", () => { - const error = { - data: { - message: "thinking must be the first block" - } - }; - expect(detectErrorType(error)).toBe("thinking_block_order"); - }); - - it("handles deeply nested error objects", () => { - const error = { - error: { - message: "thinking is disabled and cannot contain thinking blocks" - } - }; - expect(detectErrorType(error)).toBe("thinking_disabled_violation"); - }); - - it("falls back to JSON stringify for non-standard errors", () => { - const error = { custom: "tool_use without tool_result" }; - expect(detectErrorType(error)).toBe("tool_result_missing"); - }); - - it("handles empty object", () => { - expect(detectErrorType({})).toBeNull(); - }); - - it("handles number input", () => { - expect(detectErrorType(42)).toBeNull(); - }); - - it("handles array input", () => { - expect(detectErrorType(["tool_use", "tool_result"])).toBe("tool_result_missing"); - }); + it("handles nested error.data.error structure", () => { + const error = { + data: { + error: { + message: "tool_use without tool_result found", + }, + }, + }; + expect(detectErrorType(error)).toBe("tool_result_missing"); + }); + + it("handles error.data.message structure", () => { + const error = { + data: { + message: "thinking must be the first block", + }, + }; + expect(detectErrorType(error)).toBe("thinking_block_order"); + }); + + it("handles deeply nested error objects", () => { + const error = { + error: { + message: "thinking is disabled and cannot contain thinking blocks", + }, + }; + expect(detectErrorType(error)).toBe("thinking_disabled_violation"); + }); + + it("falls back to JSON stringify for non-standard errors", () => { + const error = { custom: "tool_use without tool_result" }; + expect(detectErrorType(error)).toBe("tool_result_missing"); + }); + + it("handles empty object", () => { + expect(detectErrorType({})).toBeNull(); + }); + + it("handles number input", () => { + expect(detectErrorType(42)).toBeNull(); + }); + + it("handles array input", () => { + expect(detectErrorType(["tool_use", "tool_result"])).toBe( + "tool_result_missing", + ); + }); }); describe("handleSessionRecovery", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("returns false when info is null", async () => { - const client = createMockClient(); - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - const result = await hook?.handleSessionRecovery(null as never); - expect(result).toBe(false); - }); - - it("returns false when role is not assistant", async () => { - const client = createMockClient(); - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - const result = await hook?.handleSessionRecovery({ - role: "user", - error: "tool_use without tool_result", - sessionID: "session-1", - } as never); - expect(result).toBe(false); - }); - - it("returns false when no error property", async () => { - const client = createMockClient(); - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - const result = await hook?.handleSessionRecovery({ - role: "assistant", - sessionID: "session-1", - } as never); - expect(result).toBe(false); - }); - - it("returns false when error is not recoverable", async () => { - const client = createMockClient(); - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "generic error that is not recoverable", - sessionID: "session-1", - } as never); - expect(result).toBe(false); - }); - - it("returns false when sessionID is missing", async () => { - const client = createMockClient(); - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - } as never); - expect(result).toBe(false); - }); - - it("calls onAbortCallback when set", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const abortCallback = vi.fn(); - hook?.setOnAbortCallback(abortCallback); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(abortCallback).toHaveBeenCalledWith("session-1"); - }); - - it("calls session.abort on recovery", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(client.session.abort).toHaveBeenCalledWith({ path: { id: "session-1" } }); - }); - - it("shows toast notification on recovery attempt", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(client.tui.showToast).toHaveBeenCalledWith({ - body: { - title: "Tool Crash Recovery", - message: "Injecting cancelled tool results...", - variant: "warning", - }, - }); - }); - - describe("tool_result_missing recovery", () => { - it("injects tool_result parts for tool_use parts in message", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [ - { type: "tool_use", id: "tool-1", name: "read" }, - { type: "tool_use", id: "tool-2", name: "write" }, - ], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(true); - expect(client.session.prompt).toHaveBeenCalledWith({ - path: { id: "session-1" }, - body: { - parts: [ - { type: "tool_result", tool_use_id: "tool-1", content: "Operation cancelled by user (ESC pressed)" }, - { type: "tool_result", tool_use_id: "tool-2", content: "Operation cancelled by user (ESC pressed)" }, - ], - }, - }); - }); - - it("reads parts from storage when parts array is empty", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - mockedReadParts.mockReturnValue([ - { type: "tool", callID: "tool-1", tool: "read" }, - { type: "tool", callID: "tool-2", tool: "write" }, - ] as never); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(mockedReadParts).toHaveBeenCalledWith("msg-1"); - expect(result).toBe(true); - }); - - it("returns false when no tool_use parts found", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [{ type: "text", text: "Hello" }], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - }); - - it("returns false when prompt injection fails", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [{ type: "tool_use", id: "tool-1", name: "read" }], - }], - }); - client.session.prompt.mockRejectedValue(new Error("Prompt failed")); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - }); - }); - - describe("thinking_block_order recovery", () => { - it("uses message index from error to find target message", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); - mockedPrependThinkingPart.mockReturnValue(true); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "messages.5: thinking must be the first block", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(mockedFindMessageByIndexNeedingThinking).toHaveBeenCalledWith("session-1", 5); - expect(mockedPrependThinkingPart).toHaveBeenCalledWith("session-1", "msg-target"); - expect(result).toBe(true); - }); - - it("falls back to findMessagesWithOrphanThinking when no index", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - mockedFindMessageByIndexNeedingThinking.mockReturnValue(null); - mockedFindMessagesWithOrphanThinking.mockReturnValue(["orphan-1", "orphan-2"]); - mockedPrependThinkingPart.mockReturnValue(true); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "thinking must be the first block", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(mockedFindMessagesWithOrphanThinking).toHaveBeenCalledWith("session-1"); - expect(mockedPrependThinkingPart).toHaveBeenCalledTimes(2); - expect(result).toBe(true); - }); - - it("returns false when no orphan messages found", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - mockedFindMessageByIndexNeedingThinking.mockReturnValue(null); - mockedFindMessagesWithOrphanThinking.mockReturnValue([]); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "thinking must be the first block", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - }); - - it("resumes session when autoResume is enabled", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-0", role: "user", agent: "build", model: "gpt-5" } }, - { info: { id: "msg-1", role: "assistant" }, parts: [] }, - ], - }); - - mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); - mockedPrependThinkingPart.mockReturnValue(true); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: true } - ); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "messages.1: thinking must be the first block", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(client.session.prompt).toHaveBeenCalledWith({ - path: { id: "session-1" }, - body: { - parts: [{ type: "text", text: "[session recovered - continuing previous task]" }], - agent: "build", - model: "gpt-5", - }, - query: { directory: "/test" }, - }); - }); - }); - - describe("thinking_disabled_violation recovery", () => { - it("strips thinking blocks from messages", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - mockedFindMessagesWithThinkingBlocks.mockReturnValue(["msg-with-thinking-1", "msg-with-thinking-2"]); - mockedStripThinkingParts.mockReturnValue(true); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "thinking is disabled and cannot contain thinking blocks", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(mockedFindMessagesWithThinkingBlocks).toHaveBeenCalledWith("session-1"); - expect(mockedStripThinkingParts).toHaveBeenCalledTimes(2); - expect(result).toBe(true); - }); - - it("returns false when no messages with thinking blocks found", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - mockedFindMessagesWithThinkingBlocks.mockReturnValue([]); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "thinking is disabled and cannot contain thinking blocks", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - }); - - it("resumes session when autoResume is enabled", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-0", role: "user", agent: "explore", model: "gpt-5.1" } }, - { info: { id: "msg-1", role: "assistant" }, parts: [] }, - ], - }); - - mockedFindMessagesWithThinkingBlocks.mockReturnValue(["msg-1"]); - mockedStripThinkingParts.mockReturnValue(true); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: true } - ); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "thinking is disabled and cannot contain thinking blocks", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(client.session.prompt).toHaveBeenCalledWith({ - path: { id: "session-1" }, - body: { - parts: [{ type: "text", text: "[session recovered - continuing previous task]" }], - agent: "explore", - model: "gpt-5.1", - }, - query: { directory: "/test" }, - }); - }); - }); - - describe("callback handling", () => { - it("calls onRecoveryCompleteCallback on success", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [{ type: "tool_use", id: "tool-1", name: "read" }], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const completeCallback = vi.fn(); - hook?.setOnRecoveryCompleteCallback(completeCallback); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(completeCallback).toHaveBeenCalledWith("session-1"); - }); - - it("calls onRecoveryCompleteCallback on failure", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const completeCallback = vi.fn(); - hook?.setOnRecoveryCompleteCallback(completeCallback); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(completeCallback).toHaveBeenCalledWith("session-1"); - }); - }); - - describe("deduplication", () => { - it("prevents duplicate processing of same message ID", async () => { - const client = createMockClient(); - - let resolveFirst: () => void; - const firstPromise = new Promise((r) => { resolveFirst = r; }); - - client.session.messages.mockImplementation(async () => { - await firstPromise; - return { - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [{ type: "tool_use", id: "tool-1", name: "read" }], - }], - }; - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const info = { - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never; - - const first = hook?.handleSessionRecovery(info); - const second = hook?.handleSessionRecovery(info); - - resolveFirst!(); - - const [result1, result2] = await Promise.all([first, second]); - - expect(result1).toBe(true); - expect(result2).toBe(false); - }); - }); - - describe("error handling", () => { - it("returns false when failed message not found in session", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "different-msg", role: "assistant" }, - parts: [], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - }); - - it("finds assistant message ID from session when not provided", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-user", role: "user" }, parts: [] }, - { info: { id: "msg-assistant", role: "assistant" }, parts: [{ type: "tool_use", id: "tool-1" }] }, - ], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - } as never); - - expect(result).toBe(true); - }); - - it("returns false when no assistant message found and none in session", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-user", role: "user" }, parts: [] }, - ], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - } as never); - - expect(result).toBe(false); - }); - - it("handles exception in recovery logic gracefully", async () => { - const client = createMockClient(); - client.session.abort.mockResolvedValue({}); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [{ type: "tool_use", id: "tool-1", name: "read" }], - }], - }); - client.tui.showToast.mockRejectedValue(new Error("Toast error")); - client.session.prompt.mockRejectedValue(new Error("Prompt error")); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - }); - - it("filters out tool_use parts with falsy id (line 98 coverage)", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [ - { type: "tool_use", id: "", name: "read" }, - { type: "tool_use", name: "write" }, - { type: "tool_use", id: null, name: "delete" }, - { type: "tool_use", id: "valid-id", name: "exec" }, - ], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(true); - expect(client.session.prompt).toHaveBeenCalledWith({ - path: { id: "session-1" }, - body: { - parts: [ - { type: "tool_result", tool_use_id: "valid-id", content: "Operation cancelled by user (ESC pressed)" }, - ], - }, - }); - }); - - it("continues recovery when resumeSession fails (line 226 coverage)", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-0", role: "user", agent: "build", model: "gpt-5" } }, - { info: { id: "msg-1", role: "assistant" }, parts: [] }, - ], - }); - - mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); - mockedPrependThinkingPart.mockReturnValue(true); - client.session.prompt.mockRejectedValue(new Error("Resume prompt failed")); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: true } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "messages.1: thinking must be the first block", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(mockedPrependThinkingPart).toHaveBeenCalled(); - expect(client.session.prompt).toHaveBeenCalled(); - expect(result).toBe(true); - }); - - it("handles session with no user messages (line 198 coverage)", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-0", role: "assistant" }, parts: [] }, - { info: { id: "msg-1", role: "assistant" }, parts: [] }, - ], - }); - - mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); - mockedPrependThinkingPart.mockReturnValue(true); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: true } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "messages.1: thinking must be the first block", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(true); - const promptCall = client.session.prompt.mock.calls[0]; - expect(promptCall[0].body.agent).toBeUndefined(); - expect(promptCall[0].body.model).toBeUndefined(); - }); - - it("returns false when thinking_disabled_violation recovery throws (lines 401-402 coverage)", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-1", role: "assistant" }, parts: [] }, - ], - }); - - mockedFindMessagesWithThinkingBlocks.mockImplementation(() => { - throw new Error("Storage access error"); - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "thinking is disabled and cannot contain thinking blocks", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - mockedFindMessagesWithThinkingBlocks.mockReturnValue([]); - }); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns false when info is null", async () => { + const client = createMockClient(); + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + const result = await hook?.handleSessionRecovery(null as never); + expect(result).toBe(false); + }); + + it("returns false when role is not assistant", async () => { + const client = createMockClient(); + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + const result = await hook?.handleSessionRecovery({ + role: "user", + error: "tool_use without tool_result", + sessionID: "session-1", + } as never); + expect(result).toBe(false); + }); + + it("returns false when no error property", async () => { + const client = createMockClient(); + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + const result = await hook?.handleSessionRecovery({ + role: "assistant", + sessionID: "session-1", + } as never); + expect(result).toBe(false); + }); + + it("returns false when error is not recoverable", async () => { + const client = createMockClient(); + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "generic error that is not recoverable", + sessionID: "session-1", + } as never); + expect(result).toBe(false); + }); + + it("returns false when sessionID is missing", async () => { + const client = createMockClient(); + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + } as never); + expect(result).toBe(false); + }); + + it("calls onAbortCallback when set", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const abortCallback = vi.fn(); + hook?.setOnAbortCallback(abortCallback); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(abortCallback).toHaveBeenCalledWith("session-1"); + }); + + it("calls session.abort on recovery", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(client.session.abort).toHaveBeenCalledWith({ + path: { id: "session-1" }, + }); + }); + + it("shows toast notification on recovery attempt", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(client.tui.showToast).toHaveBeenCalledWith({ + body: { + title: "Tool Crash Recovery", + message: "Injecting cancelled tool results...", + variant: "warning", + }, + }); + }); + + describe("tool_result_missing recovery", () => { + it("injects tool_result parts for tool_use parts in message", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [ + { type: "tool_use", id: "tool-1", name: "read" }, + { type: "tool_use", id: "tool-2", name: "write" }, + ], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(true); + expect(client.session.prompt).toHaveBeenCalledWith({ + path: { id: "session-1" }, + body: { + parts: [ + { + type: "tool_result", + tool_use_id: "tool-1", + content: "Operation cancelled by user (ESC pressed)", + }, + { + type: "tool_result", + tool_use_id: "tool-2", + content: "Operation cancelled by user (ESC pressed)", + }, + ], + }, + }); + }); + + it("reads parts from storage when parts array is empty", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + mockedReadParts.mockReturnValue([ + { type: "tool", callID: "tool-1", tool: "read" }, + { type: "tool", callID: "tool-2", tool: "write" }, + ] as never); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(mockedReadParts).toHaveBeenCalledWith("msg-1"); + expect(result).toBe(true); + }); + + it("returns false when no tool_use parts found", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [{ type: "text", text: "Hello" }], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + }); + + it("returns false when prompt injection fails", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [{ type: "tool_use", id: "tool-1", name: "read" }], + }, + ], + }); + client.session.prompt.mockRejectedValue(new Error("Prompt failed")); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + }); + }); + + describe("thinking_block_order recovery", () => { + it("uses message index from error to find target message", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); + mockedPrependThinkingPart.mockReturnValue(true); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "messages.5: thinking must be the first block", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(mockedFindMessageByIndexNeedingThinking).toHaveBeenCalledWith( + "session-1", + 5, + ); + expect(mockedPrependThinkingPart).toHaveBeenCalledWith( + "session-1", + "msg-target", + ); + expect(result).toBe(true); + }); + + it("falls back to findMessagesWithOrphanThinking when no index", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + mockedFindMessageByIndexNeedingThinking.mockReturnValue(null); + mockedFindMessagesWithOrphanThinking.mockReturnValue([ + "orphan-1", + "orphan-2", + ]); + mockedPrependThinkingPart.mockReturnValue(true); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "thinking must be the first block", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(mockedFindMessagesWithOrphanThinking).toHaveBeenCalledWith( + "session-1", + ); + expect(mockedPrependThinkingPart).toHaveBeenCalledTimes(2); + expect(result).toBe(true); + }); + + it("returns false when no orphan messages found", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + mockedFindMessageByIndexNeedingThinking.mockReturnValue(null); + mockedFindMessagesWithOrphanThinking.mockReturnValue([]); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "thinking must be the first block", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + }); + + it("resumes session when autoResume is enabled", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-0", role: "user", agent: "build", model: "gpt-5" }, + }, + { info: { id: "msg-1", role: "assistant" }, parts: [] }, + ], + }); + + mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); + mockedPrependThinkingPart.mockReturnValue(true); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: true }, + ); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "messages.1: thinking must be the first block", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(client.session.prompt).toHaveBeenCalledWith({ + path: { id: "session-1" }, + body: { + parts: [ + { + type: "text", + text: "[session recovered - continuing previous task]", + }, + ], + agent: "build", + model: "gpt-5", + }, + query: { directory: "/test" }, + }); + }); + }); + + describe("thinking_disabled_violation recovery", () => { + it("strips thinking blocks from messages", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + mockedFindMessagesWithThinkingBlocks.mockReturnValue([ + "msg-with-thinking-1", + "msg-with-thinking-2", + ]); + mockedStripThinkingParts.mockReturnValue(true); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "thinking is disabled and cannot contain thinking blocks", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(mockedFindMessagesWithThinkingBlocks).toHaveBeenCalledWith( + "session-1", + ); + expect(mockedStripThinkingParts).toHaveBeenCalledTimes(2); + expect(result).toBe(true); + }); + + it("returns false when no messages with thinking blocks found", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + mockedFindMessagesWithThinkingBlocks.mockReturnValue([]); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "thinking is disabled and cannot contain thinking blocks", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + }); + + it("resumes session when autoResume is enabled", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { + id: "msg-0", + role: "user", + agent: "explore", + model: "gpt-5.1", + }, + }, + { info: { id: "msg-1", role: "assistant" }, parts: [] }, + ], + }); + + mockedFindMessagesWithThinkingBlocks.mockReturnValue(["msg-1"]); + mockedStripThinkingParts.mockReturnValue(true); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: true }, + ); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "thinking is disabled and cannot contain thinking blocks", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(client.session.prompt).toHaveBeenCalledWith({ + path: { id: "session-1" }, + body: { + parts: [ + { + type: "text", + text: "[session recovered - continuing previous task]", + }, + ], + agent: "explore", + model: "gpt-5.1", + }, + query: { directory: "/test" }, + }); + }); + }); + + describe("callback handling", () => { + it("calls onRecoveryCompleteCallback on success", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [{ type: "tool_use", id: "tool-1", name: "read" }], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const completeCallback = vi.fn(); + hook?.setOnRecoveryCompleteCallback(completeCallback); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(completeCallback).toHaveBeenCalledWith("session-1"); + }); + + it("calls onRecoveryCompleteCallback on failure", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const completeCallback = vi.fn(); + hook?.setOnRecoveryCompleteCallback(completeCallback); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(completeCallback).toHaveBeenCalledWith("session-1"); + }); + }); + + describe("deduplication", () => { + it("prevents duplicate processing of same message ID", async () => { + const client = createMockClient(); + + let resolveFirst: () => void; + const firstPromise = new Promise((r) => { + resolveFirst = r; + }); + + client.session.messages.mockImplementation(async () => { + await firstPromise; + return { + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [{ type: "tool_use", id: "tool-1", name: "read" }], + }, + ], + }; + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const info = { + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never; + + const first = hook?.handleSessionRecovery(info); + const second = hook?.handleSessionRecovery(info); + + resolveFirst!(); + + const [result1, result2] = await Promise.all([first, second]); + + expect(result1).toBe(true); + expect(result2).toBe(false); + }); + }); + + describe("error handling", () => { + it("returns false when failed message not found in session", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "different-msg", role: "assistant" }, + parts: [], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + }); + + it("finds assistant message ID from session when not provided", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { info: { id: "msg-user", role: "user" }, parts: [] }, + { + info: { id: "msg-assistant", role: "assistant" }, + parts: [{ type: "tool_use", id: "tool-1" }], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + } as never); + + expect(result).toBe(true); + }); + + it("returns false when no assistant message found and none in session", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [{ info: { id: "msg-user", role: "user" }, parts: [] }], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + } as never); + + expect(result).toBe(false); + }); + + it("handles exception in recovery logic gracefully", async () => { + const client = createMockClient(); + client.session.abort.mockResolvedValue({}); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [{ type: "tool_use", id: "tool-1", name: "read" }], + }, + ], + }); + client.tui.showToast.mockRejectedValue(new Error("Toast error")); + client.session.prompt.mockRejectedValue(new Error("Prompt error")); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + }); + + it("filters out tool_use parts with falsy id (line 98 coverage)", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [ + { type: "tool_use", id: "", name: "read" }, + { type: "tool_use", name: "write" }, + { type: "tool_use", id: null, name: "delete" }, + { type: "tool_use", id: "valid-id", name: "exec" }, + ], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(true); + expect(client.session.prompt).toHaveBeenCalledWith({ + path: { id: "session-1" }, + body: { + parts: [ + { + type: "tool_result", + tool_use_id: "valid-id", + content: "Operation cancelled by user (ESC pressed)", + }, + ], + }, + }); + }); + + it("continues recovery when resumeSession fails (line 226 coverage)", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-0", role: "user", agent: "build", model: "gpt-5" }, + }, + { info: { id: "msg-1", role: "assistant" }, parts: [] }, + ], + }); + + mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); + mockedPrependThinkingPart.mockReturnValue(true); + client.session.prompt.mockRejectedValue( + new Error("Resume prompt failed"), + ); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: true }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "messages.1: thinking must be the first block", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(mockedPrependThinkingPart).toHaveBeenCalled(); + expect(client.session.prompt).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("handles session with no user messages (line 198 coverage)", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { info: { id: "msg-0", role: "assistant" }, parts: [] }, + { info: { id: "msg-1", role: "assistant" }, parts: [] }, + ], + }); + + mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); + mockedPrependThinkingPart.mockReturnValue(true); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: true }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "messages.1: thinking must be the first block", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(true); + const promptCall = client.session.prompt.mock.calls[0]; + expect(promptCall[0].body.agent).toBeUndefined(); + expect(promptCall[0].body.model).toBeUndefined(); + }); + + it("returns false when thinking_disabled_violation recovery throws (lines 401-402 coverage)", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [{ info: { id: "msg-1", role: "assistant" }, parts: [] }], + }); + + mockedFindMessagesWithThinkingBlocks.mockImplementation(() => { + throw new Error("Storage access error"); + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "thinking is disabled and cannot contain thinking blocks", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + mockedFindMessagesWithThinkingBlocks.mockReturnValue([]); + }); + }); }); diff --git a/test/storage.test.ts b/test/storage.test.ts index 7e37dcfa..33bd8c33 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1,15 +1,13 @@ import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ACCOUNT_LIMITS } from "../lib/constants.js"; import { clearQuotaCache, getQuotaCachePath } from "../lib/quota-cache.js"; import { getConfigDir, getProjectStorageKey } from "../lib/storage/paths.js"; import { removeWithRetry } from "./helpers/remove-with-retry.js"; import { - __testOnly, assessNamedBackupRestore, - assertNamedBackupRestorePath, buildNamedBackupPath, clearAccounts, clearFlaggedAccounts, @@ -24,14 +22,10 @@ import { NAMED_BACKUP_LIST_CONCURRENCY, getStoragePath, importAccounts, - isNamedBackupContainmentError, - isNamedBackupPathValidationTransientError, listNamedBackups, loadAccounts, loadFlaggedAccounts, normalizeAccountStorage, - resolveNamedBackupRestorePath, - restoreAssessedNamedBackup, restoreNamedBackup, resolveAccountSelectionIndex, saveFlaggedAccounts, @@ -404,41 +398,6 @@ describe("storage", () => { ).rejects.toThrow(/different storage path/); }); - it("allows exporting inside an active transaction when the storage path only differs by case on win32", async () => { - const platformSpy = vi - .spyOn(process, "platform", "get") - .mockReturnValue("win32"); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "transactional-export-same-path", - refreshToken: "ref-transactional-export-same-path", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - const casedStoragePath = testStoragePath.toUpperCase(); - - try { - await expect( - withAccountStorageTransaction(async () => { - setStoragePathDirect(casedStoragePath); - try { - await exportAccounts(exportPath); - } finally { - setStoragePathDirect(testStoragePath); - } - }), - ).resolves.toBeUndefined(); - expect(existsSync(exportPath)).toBe(true); - } finally { - platformSpy.mockRestore(); - } - }); - it("should import accounts from a file and merge", async () => { // @ts-expect-error const { importAccounts } = await import("../lib/storage.js"); @@ -475,243 +434,6 @@ describe("storage", () => { expect(loaded?.accounts.map((a) => a.accountId)).toContain("new"); }); - it("should skip persisting duplicate-only imports", async () => { - const { importAccounts } = await import("../lib/storage.js"); - const existing = { - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - refreshToken: "ref-existing", - addedAt: 1, - lastUsed: 2, - }, - ], - }; - await saveAccounts(existing); - await fs.writeFile(exportPath, JSON.stringify(existing)); - - const writeFileSpy = vi.spyOn(fs, "writeFile"); - try { - const result = await importAccounts(exportPath); - expect(result).toEqual({ - imported: 0, - skipped: 1, - total: 1, - changed: false, - }); - const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { - const target = String(targetPath); - return ( - target === testStoragePath || - target.startsWith(`${testStoragePath}.`) - ); - }); - expect(storageWrites).toHaveLength(0); - } finally { - writeFileSpy.mockRestore(); - } - }); - - it("should treat deduplicated current snapshots as a no-op import", async () => { - const { importAccounts } = await import("../lib/storage.js"); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - addedAt: 2, - lastUsed: 2, - }, - ], - }); - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - addedAt: 1, - lastUsed: 1, - }, - ], - }), - ); - - const writeFileSpy = vi.spyOn(fs, "writeFile"); - try { - const result = await importAccounts(exportPath); - expect(result).toEqual({ - imported: 0, - skipped: 1, - total: 1, - changed: false, - }); - const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { - const target = String(targetPath); - return ( - target === testStoragePath || - target.startsWith(`${testStoragePath}.`) - ); - }); - expect(storageWrites).toHaveLength(0); - } finally { - writeFileSpy.mockRestore(); - } - }); - - it("should deduplicate incoming backup rows before reporting skipped imports", async () => { - const { importAccounts } = await import("../lib/storage.js"); - await clearAccounts(); - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "duplicate-import", - email: "duplicate-import@example.com", - refreshToken: "ref-duplicate-import-old", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "duplicate-import", - email: "duplicate-import@example.com", - refreshToken: "ref-duplicate-import-new", - addedAt: 2, - lastUsed: 2, - }, - ], - }), - ); - - const result = await importAccounts(exportPath); - const loaded = await loadAccounts(); - - expect(result).toEqual({ - imported: 1, - skipped: 0, - total: 1, - changed: true, - }); - expect(loaded?.accounts).toHaveLength(1); - expect(loaded?.accounts[0]).toMatchObject({ - accountId: "duplicate-import", - email: "duplicate-import@example.com", - refreshToken: "ref-duplicate-import-new", - lastUsed: 2, - }); - }); - - it("should persist duplicate-only imports when they refresh stored metadata", async () => { - const { importAccounts } = await import("../lib/storage.js"); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - accessToken: "stale-access", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - accessToken: "fresh-access", - addedAt: 1, - lastUsed: 10, - }, - ], - }), - ); - - const result = await importAccounts(exportPath); - const loaded = await loadAccounts(); - - expect(result).toEqual({ - imported: 0, - skipped: 1, - total: 1, - changed: true, - }); - expect(loaded?.accounts).toHaveLength(1); - expect(loaded?.accounts[0]).toMatchObject({ - accountId: "existing", - accessToken: "fresh-access", - lastUsed: 10, - }); - }); - - it("should skip semantically identical duplicate-only imports even when key order differs", async () => { - const { importAccounts } = await import("../lib/storage.js"); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - refreshToken: "ref-existing", - addedAt: 1, - lastUsed: 2, - }, - ], - }); - await fs.writeFile( - exportPath, - '{"version":3,"activeIndex":0,"accounts":[{"lastUsed":2,"addedAt":1,"refreshToken":"ref-existing","accountId":"existing"}]}', - ); - - const writeFileSpy = vi.spyOn(fs, "writeFile"); - try { - const result = await importAccounts(exportPath); - expect(result).toEqual({ - imported: 0, - skipped: 1, - total: 1, - changed: false, - }); - const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { - const target = String(targetPath); - return ( - target === testStoragePath || - target.startsWith(`${testStoragePath}.`) - ); - }); - expect(storageWrites).toHaveLength(0); - } finally { - writeFileSpy.mockRestore(); - } - }); - it("should preserve distinct shared-accountId imports when the imported row has no email", async () => { const { importAccounts } = await import("../lib/storage.js"); const existing = { @@ -757,12 +479,7 @@ describe("storage", () => { const imported = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(imported).toEqual({ - imported: 1, - total: 3, - skipped: 0, - changed: true, - }); + expect(imported).toEqual({ imported: 1, total: 3, skipped: 0 }); expect(loaded?.accounts).toHaveLength(3); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual( expect.arrayContaining([ @@ -813,12 +530,7 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ - imported: 1, - skipped: 0, - total: 2, - changed: true, - }); + expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -864,12 +576,7 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ - imported: 1, - skipped: 0, - total: 2, - changed: true, - }); + expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -1248,78 +955,17 @@ describe("storage", () => { ); }); - it("rejects a second import that would exceed MAX_ACCOUNTS", async () => { - const nearLimitAccounts = Array.from( - { length: ACCOUNT_LIMITS.MAX_ACCOUNTS - 1 }, - (_, index) => ({ - accountId: `existing-${index}`, - refreshToken: `ref-existing-${index}`, - addedAt: index + 1, - lastUsed: index + 1, - }), - ); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: nearLimitAccounts, - }); - - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "extra-one", - refreshToken: "ref-extra-one", - addedAt: 10_000, - lastUsed: 10_000, - }, - ], - }), - ); - - const first = await importAccounts(exportPath); - expect(first).toMatchObject({ - imported: 1, - skipped: 0, - total: ACCOUNT_LIMITS.MAX_ACCOUNTS, - changed: true, - }); - - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "extra-two", - refreshToken: "ref-extra-two", - addedAt: 20_000, - lastUsed: 20_000, - }, - ], - }), - ); - - await expect(importAccounts(exportPath)).rejects.toThrow( - /exceed maximum/, - ); - - const loaded = await loadAccounts(); - expect(loaded?.accounts).toHaveLength(ACCOUNT_LIMITS.MAX_ACCOUNTS); - expect( - loaded?.accounts.some((account) => account.accountId === "extra-two"), - ).toBe(false); - }); - it("should fail export when no accounts exist", async () => { - const storageModule = await import("../lib/storage.js"); - storageModule.setStoragePathDirect(testStoragePath); - await storageModule.clearAccounts(); - await expect(storageModule.exportAccounts(exportPath)).rejects.toThrow( + const { exportAccounts } = await import("../lib/storage.js"); + const isolatedStorageDir = join( + testWorkDir, + "empty-export-" + Math.random().toString(36).slice(2), + ); + const isolatedStoragePath = join(isolatedStorageDir, "accounts.json"); + const isolatedExportPath = join(isolatedStorageDir, "export.json"); + await fs.mkdir(isolatedStorageDir, { recursive: true }); + setStoragePathDirect(isolatedStoragePath); + await expect(exportAccounts(isolatedExportPath)).rejects.toThrow( /No accounts to export/, ); }); @@ -1332,51 +978,6 @@ describe("storage", () => { ); }); - it("retries transient import read errors before parsing the backup", async () => { - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "retry-import-read", - refreshToken: "ref-retry-import-read", - addedAt: 1, - lastUsed: 1, - }, - ], - }), - ); - const originalReadFile = fs.readFile.bind(fs); - let busyFailures = 0; - const readFileSpy = vi - .spyOn(fs, "readFile") - .mockImplementation(async (...args) => { - const [path] = args; - if (String(path) === exportPath && busyFailures === 0) { - busyFailures += 1; - const error = new Error("import file busy") as NodeJS.ErrnoException; - error.code = "EAGAIN"; - throw error; - } - return originalReadFile(...(args as Parameters)); - }); - - try { - const result = await importAccounts(exportPath); - expect(result).toMatchObject({ - imported: 1, - skipped: 0, - total: 1, - changed: true, - }); - expect(busyFailures).toBe(1); - } finally { - readFileSpy.mockRestore(); - } - }); - it("should fail import when file contains invalid JSON", async () => { const { importAccounts } = await import("../lib/storage.js"); await fs.writeFile(exportPath, "not valid json {["); @@ -1544,95 +1145,6 @@ describe("storage", () => { ); }); - it("lists named backups across the chunk boundary", async () => { - const expectedNames: string[] = []; - for ( - let index = 0; - index <= NAMED_BACKUP_LIST_CONCURRENCY; - index += 1 - ) { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: `basic-chunk-${index}`, - refreshToken: `ref-basic-chunk-${index}`, - addedAt: index + 1, - lastUsed: index + 1, - }, - ], - }); - const name = `basic-chunk-${String(index).padStart(2, "0")}`; - expectedNames.push(name); - await createNamedBackup(name); - } - - const backups = await listNamedBackups(); - - expect(backups).toHaveLength(NAMED_BACKUP_LIST_CONCURRENCY + 1); - expect(backups).toEqual( - expect.arrayContaining( - expectedNames.map((name) => - expect.objectContaining({ - name, - accountCount: 1, - valid: true, - }), - ), - ), - ); - }); - - it("returns a contained fallback path for missing named backups", async () => { - const requestedName = " missing-backup "; - const resolvedPath = - await resolveNamedBackupRestorePath(requestedName); - - expect(resolvedPath).toBe(buildNamedBackupPath("missing-backup")); - await expect(importAccounts(resolvedPath)).rejects.toThrow( - /Import file not found/, - ); - }); - - it("maps read-time ENOENT back to the import file-not-found contract", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "read-race", - refreshToken: "ref-read-race", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - const backup = await createNamedBackup("read-race"); - const originalReadFile = fs.readFile.bind(fs); - let injectedEnoent = false; - const readFileSpy = vi - .spyOn(fs, "readFile") - .mockImplementation(async (...args) => { - const [path] = args; - if (String(path) === backup.path && !injectedEnoent) { - injectedEnoent = true; - const error = new Error("backup disappeared") as NodeJS.ErrnoException; - error.code = "ENOENT"; - throw error; - } - return originalReadFile(...(args as Parameters)); - }); - - try { - await expect(importAccounts(backup.path)).rejects.toThrow( - `Import file not found: ${backup.path}`, - ); - } finally { - readFileSpy.mockRestore(); - } - }); - it("assesses eligibility and restores a named backup", async () => { await saveAccounts({ version: 3, @@ -1701,248 +1213,6 @@ describe("storage", () => { expect(assessment.eligibleForRestore).toBe(true); }); - it("deduplicates incoming backup rows when assessing restore counts", async () => { - const backupPath = join( - dirname(testStoragePath), - "backups", - "internal-duplicates.json", - ); - await fs.mkdir(dirname(backupPath), { recursive: true }); - await fs.writeFile( - backupPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "duplicate-account", - email: "duplicate-account@example.com", - refreshToken: "ref-duplicate-old", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "duplicate-account", - email: "duplicate-account@example.com", - refreshToken: "ref-duplicate-new", - addedAt: 2, - lastUsed: 2, - }, - ], - }), - "utf-8", - ); - await clearAccounts(); - - const assessment = await assessNamedBackupRestore("internal-duplicates"); - - expect(assessment.imported).toBe(1); - expect(assessment.skipped).toBe(0); - expect(assessment.mergedAccountCount).toBe(1); - expect(assessment.eligibleForRestore).toBe(true); - }); - - it("rejects duplicate-only backups with nothing new to restore", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing-account", - refreshToken: "ref-existing-account", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - await createNamedBackup("already-present"); - - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing-account", - refreshToken: "ref-existing-account", - addedAt: 2, - lastUsed: 2, - }, - ], - }); - - const assessment = await assessNamedBackupRestore("already-present"); - expect(assessment.imported).toBe(0); - expect(assessment.skipped).toBe(1); - expect(assessment.eligibleForRestore).toBe(false); - expect(assessment.error).toBe("All accounts in this backup already exist"); - - await expect(restoreNamedBackup("already-present")).rejects.toThrow( - "All accounts in this backup already exist", - ); - }); - - it("treats deduplicated current snapshots as a no-op restore", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing-account", - email: "existing@example.com", - refreshToken: "ref-existing-account", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - await createNamedBackup("repair-current-duplicates"); - - const assessment = await assessNamedBackupRestore( - "repair-current-duplicates", - { - currentStorage: { - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing-account", - email: "existing@example.com", - refreshToken: "ref-existing-account", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "existing-account", - email: "existing@example.com", - refreshToken: "ref-existing-account", - addedAt: 2, - lastUsed: 2, - }, - ], - }, - }, - ); - expect(assessment.currentAccountCount).toBe(2); - expect(assessment.mergedAccountCount).toBe(1); - expect(assessment.imported).toBe(0); - expect(assessment.skipped).toBe(1); - expect(assessment.eligibleForRestore).toBe(false); - expect(assessment.error).toBe("All accounts in this backup already exist"); - - await expect( - restoreNamedBackup("repair-current-duplicates"), - ).rejects.toThrow("All accounts in this backup already exist"); - }); - - it("treats identical accounts in a different backup order as a no-op restore", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "first-account", - email: "first@example.com", - refreshToken: "ref-first-account", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "second-account", - email: "second@example.com", - refreshToken: "ref-second-account", - addedAt: 2, - lastUsed: 2, - }, - ], - }); - await createNamedBackup("reversed-order"); - - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "second-account", - email: "second@example.com", - refreshToken: "ref-second-account", - addedAt: 2, - lastUsed: 2, - }, - { - accountId: "first-account", - email: "first@example.com", - refreshToken: "ref-first-account", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - - const assessment = await assessNamedBackupRestore("reversed-order"); - expect(assessment.imported).toBe(0); - expect(assessment.skipped).toBe(2); - expect(assessment.eligibleForRestore).toBe(false); - expect(assessment.error).toBe("All accounts in this backup already exist"); - - await expect(restoreNamedBackup("reversed-order")).rejects.toThrow( - "All accounts in this backup already exist", - ); - }); - - it("keeps metadata-only backups eligible for restore", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing-account", - email: "existing@example.com", - refreshToken: "ref-existing-account", - accessToken: "fresh-access", - addedAt: 1, - lastUsed: 10, - }, - ], - }); - await createNamedBackup("metadata-refresh"); - - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing-account", - email: "existing@example.com", - refreshToken: "ref-existing-account", - accessToken: "stale-access", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - - const assessment = await assessNamedBackupRestore("metadata-refresh"); - expect(assessment.imported).toBe(0); - expect(assessment.skipped).toBe(1); - expect(assessment.eligibleForRestore).toBe(true); - expect(assessment.error).toBeUndefined(); - - const restoreResult = await restoreNamedBackup("metadata-refresh"); - expect(restoreResult).toMatchObject({ - imported: 0, - skipped: 1, - total: 1, - changed: true, - }); - - const restored = await loadAccounts(); - expect(restored?.accounts[0]).toMatchObject({ - accountId: "existing-account", - accessToken: "fresh-access", - lastUsed: 10, - }); - }); - it("restores manually named backups that already exist inside the backups directory", async () => { const backupPath = join( dirname(testStoragePath), @@ -2051,36 +1321,6 @@ describe("storage", () => { expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); }); - it("re-resolves an assessed named backup before the final import", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "deleted-helper", - refreshToken: "ref-deleted-helper", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - - const backup = await createNamedBackup("deleted-helper-assessment"); - await clearAccounts(); - - const assessment = await assessNamedBackupRestore( - "deleted-helper-assessment", - ); - expect(assessment.eligibleForRestore).toBe(true); - - await removeWithRetry(backup.path, { force: true }); - - await expect(restoreAssessedNamedBackup(assessment)).rejects.toThrow( - /Import file not found/, - ); - expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); - }); - it("throws when a named backup becomes invalid JSON after assessment", async () => { await saveAccounts({ version: 3, @@ -2121,44 +1361,36 @@ describe("storage", () => { }, ); - it("allows backup filenames that begin with dots when they stay inside the backups directory", async () => { + it("ignores symlink-like named backup entries that point outside the backups root", async () => { const backupRoot = join(dirname(testStoragePath), "backups"); - const backupPath = join(backupRoot, "..notes.json"); + const externalBackupPath = join(testWorkDir, "outside-backup.json"); await fs.mkdir(backupRoot, { recursive: true }); await fs.writeFile( - backupPath, + externalBackupPath, JSON.stringify({ version: 3, activeIndex: 0, accounts: [ { - accountId: "leading-dot-backup", - refreshToken: "ref-leading-dot-backup", + accountId: "outside-manual-backup", + refreshToken: "ref-outside-manual-backup", addedAt: 1, - lastUsed: 2, + lastUsed: 1, }, ], }), "utf-8", ); - const assessment = await assessNamedBackupRestore("..notes"); - expect(assessment.eligibleForRestore).toBe(true); - - const result = await restoreNamedBackup("..notes"); - expect(result.imported).toBe(1); - expect((await loadAccounts())?.accounts).toHaveLength(1); - }); - - it("rejects matched backup entries whose resolved path escapes the backups directory", async () => { - const backupRoot = join(dirname(testStoragePath), "backups"); const originalReaddir = fs.readdir.bind(fs); const readdirSpy = vi.spyOn(fs, "readdir"); const escapedEntry = { - name: "../escaped-entry.json", + name: "escaped-link.json", isFile: () => true, - isSymbolicLink: () => false, - } as unknown as Awaited>[number]; + isSymbolicLink: () => true, + } as unknown as Awaited< + ReturnType + >[number]; readdirSpy.mockImplementation(async (...args) => { const [path, options] = args; if ( @@ -2172,332 +1404,20 @@ describe("storage", () => { }); try { - await expect(assessNamedBackupRestore("../escaped-entry")).rejects.toThrow( - /escapes backup directory/i, + const backups = await listNamedBackups(); + expect(backups).toEqual([]); + await expect(assessNamedBackupRestore("escaped-link")).rejects.toThrow( + /not a regular backup file/i, ); - await expect(restoreNamedBackup("../escaped-entry")).rejects.toThrow( - /escapes backup directory/i, + await expect(restoreNamedBackup("escaped-link")).rejects.toThrow( + /not a regular backup file/i, ); } finally { readdirSpy.mockRestore(); } }); - it("rejects backup paths whose real path escapes the backups directory through symlinked directories", async () => { - const backupRoot = join(dirname(testStoragePath), "backups"); - const outsideRoot = join(testWorkDir, "outside"); - const linkedRoot = join(backupRoot, "linked"); - const outsideBackupPath = join(outsideRoot, "escape.json"); - await fs.mkdir(backupRoot, { recursive: true }); - await fs.mkdir(outsideRoot, { recursive: true }); - await fs.writeFile( - outsideBackupPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "linked-escape", - refreshToken: "ref-linked-escape", - addedAt: 1, - lastUsed: 1, - }, - ], - }), - "utf-8", - ); - await fs.symlink( - resolve(outsideRoot), - linkedRoot, - process.platform === "win32" ? "junction" : "dir", - ); - - expect(() => - assertNamedBackupRestorePath( - join(linkedRoot, "escape.json"), - backupRoot, - ), - ).toThrow(/escapes backup directory/i); - }); - - it("rejects missing files beneath symlinked backup subdirectories", async () => { - const backupRoot = join(dirname(testStoragePath), "backups"); - const outsideRoot = join(testWorkDir, "outside-missing"); - const linkedRoot = join(backupRoot, "linked-missing"); - await fs.mkdir(backupRoot, { recursive: true }); - await fs.mkdir(outsideRoot, { recursive: true }); - await fs.symlink( - resolve(outsideRoot), - linkedRoot, - process.platform === "win32" ? "junction" : "dir", - ); - - expect(() => - assertNamedBackupRestorePath( - join(linkedRoot, "missing.json"), - backupRoot, - ), - ).toThrow(/escapes backup directory/i); - }); - - it("rejects symlinked backup roots during restore path validation", async () => { - const canonicalBackupRoot = join(testWorkDir, "canonical-backups"); - const linkedBackupRoot = join(testWorkDir, "linked-backups"); - const backupPath = join(canonicalBackupRoot, "linked-root.json"); - await fs.mkdir(canonicalBackupRoot, { recursive: true }); - await fs.writeFile( - backupPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "linked-root", - refreshToken: "ref-linked-root", - addedAt: 1, - lastUsed: 1, - }, - ], - }), - "utf-8", - ); - await fs.symlink( - resolve(canonicalBackupRoot), - linkedBackupRoot, - process.platform === "win32" ? "junction" : "dir", - ); - - expect(() => - assertNamedBackupRestorePath( - join(linkedBackupRoot, "linked-root.json"), - linkedBackupRoot, - ), - ).toThrow(/escapes backup directory/i); - }); - - it("rethrows realpath containment errors for existing backup paths", async () => { - const backupRoot = join(dirname(testStoragePath), "backups"); - const backupPath = join(backupRoot, "locked.json"); - await fs.mkdir(backupRoot, { recursive: true }); - await fs.writeFile( - backupPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "locked-path", - refreshToken: "ref-locked-path", - addedAt: 1, - lastUsed: 1, - }, - ], - }), - "utf-8", - ); - - const originalRealpath = __testOnly.namedBackupContainmentFs.realpath; - const realpathSpy = vi - .spyOn(__testOnly.namedBackupContainmentFs, "realpath") - .mockImplementation((path) => { - if (String(path) === resolve(backupPath)) { - const error = new Error( - "backup path locked", - ) as NodeJS.ErrnoException; - error.code = "EPERM"; - throw error; - } - return originalRealpath(path); - }); - - try { - expect(() => - assertNamedBackupRestorePath(backupPath, backupRoot), - ).toThrow("Backup path validation failed. Try again."); - } finally { - realpathSpy.mockRestore(); - } - }); - - it("classifies transient realpath errors for the backup root", async () => { - const backupRoot = join(dirname(testStoragePath), "backups"); - const backupPath = join(backupRoot, "pending", "locked.json"); - await fs.mkdir(backupRoot, { recursive: true }); - const transientCode = process.platform === "win32" ? "EPERM" : "EAGAIN"; - - const originalRealpath = __testOnly.namedBackupContainmentFs.realpath; - const realpathSpy = vi - .spyOn(__testOnly.namedBackupContainmentFs, "realpath") - .mockImplementation((path) => { - if (String(path) === resolve(backupRoot)) { - const error = new Error( - "backup root busy", - ) as NodeJS.ErrnoException; - error.code = transientCode; - throw error; - } - return originalRealpath(path); - }); - - try { - expect(() => - assertNamedBackupRestorePath(backupPath, backupRoot), - ).toThrow("Backup path validation failed. Try again."); - } finally { - realpathSpy.mockRestore(); - } - }); - - it("classifies transient lstat errors for the backup root", async () => { - const backupRoot = join(dirname(testStoragePath), "backups"); - const backupPath = join(backupRoot, "pending", "locked.json"); - await fs.mkdir(backupRoot, { recursive: true }); - const originalLstat = __testOnly.namedBackupContainmentFs.lstat; - const lstatSpy = vi - .spyOn(__testOnly.namedBackupContainmentFs, "lstat") - .mockImplementation((path) => { - if (String(path) === resolve(backupRoot)) { - const error = new Error("backup root locked") as NodeJS.ErrnoException; - error.code = "EBUSY"; - throw error; - } - return originalLstat(path); - }); - - try { - expect(() => - assertNamedBackupRestorePath(backupPath, backupRoot), - ).toThrow("Backup path validation failed. Try again."); - } finally { - lstatSpy.mockRestore(); - } - }); - - it("classifies transient backup path validation errors separately from containment escapes", async () => { - const backupRoot = join(dirname(testStoragePath), "backups"); - const backupPath = join(backupRoot, "pending", "locked.json"); - await fs.mkdir(backupRoot, { recursive: true }); - const transientCode = process.platform === "win32" ? "EPERM" : "EAGAIN"; - const originalRealpath = __testOnly.namedBackupContainmentFs.realpath; - const realpathSpy = vi - .spyOn(__testOnly.namedBackupContainmentFs, "realpath") - .mockImplementation((path) => { - if (String(path) === resolve(backupRoot)) { - const error = new Error( - "backup root locked", - ) as NodeJS.ErrnoException; - error.code = transientCode; - throw error; - } - return originalRealpath(path); - }); - - try { - let thrown: unknown; - try { - assertNamedBackupRestorePath(backupPath, backupRoot); - } catch (error) { - thrown = error; - } - expect(thrown).toBeInstanceOf(Error); - expect(isNamedBackupPathValidationTransientError(thrown)).toBe(true); - expect(isNamedBackupContainmentError(thrown)).toBe(false); - } finally { - realpathSpy.mockRestore(); - } - }); - - it("rejects named backup listings whose resolved paths escape the backups directory", async () => { - const backupRoot = join(dirname(testStoragePath), "backups"); - const originalReaddir = fs.readdir.bind(fs); - const readdirSpy = vi.spyOn(fs, "readdir"); - const readFileSpy = vi.spyOn(fs, "readFile"); - const escapedEntry = { - name: "../escaped-entry.json", - isFile: () => true, - isSymbolicLink: () => false, - } as unknown as Awaited>[number]; - readdirSpy.mockImplementation(async (...args) => { - const [path, options] = args; - if ( - String(path) === backupRoot && - typeof options === "object" && - options?.withFileTypes === true - ) { - return [escapedEntry] as Awaited>; - } - return originalReaddir(...(args as Parameters)); - }); - - try { - await expect(listNamedBackups()).rejects.toThrow(/escapes backup directory/i); - expect(readFileSpy).not.toHaveBeenCalled(); - } finally { - readFileSpy.mockRestore(); - readdirSpy.mockRestore(); - } - }); - - it("ignores symlink-like named backup entries that point outside the backups root", async () => { - const backupRoot = join(dirname(testStoragePath), "backups"); - const externalBackupPath = join(testWorkDir, "outside-backup.json"); - await fs.mkdir(backupRoot, { recursive: true }); - await fs.writeFile( - externalBackupPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "outside-manual-backup", - refreshToken: "ref-outside-manual-backup", - addedAt: 1, - lastUsed: 1, - }, - ], - }), - "utf-8", - ); - - const originalReaddir = fs.readdir.bind(fs); - const readdirSpy = vi.spyOn(fs, "readdir"); - const escapedEntry = { - name: "escaped-link.json", - isFile: () => false, - isSymbolicLink: () => true, - } as unknown as Awaited< - ReturnType - >[number]; - readdirSpy.mockImplementation(async (...args) => { - const [path, options] = args; - if ( - String(path) === backupRoot && - typeof options === "object" && - options?.withFileTypes === true - ) { - return [escapedEntry] as Awaited>; - } - return originalReaddir(...(args as Parameters)); - }); - - try { - const backups = await listNamedBackups(); - expect(backups).toEqual([]); - await expect(assessNamedBackupRestore("escaped-link")).rejects.toThrow( - /not a regular backup file/i, - ); - await expect(restoreNamedBackup("escaped-link")).rejects.toThrow( - /not a regular backup file/i, - ); - } finally { - readdirSpy.mockRestore(); - } - }); - - it("rethrows unreadable backup directory errors while listing backups on non-Windows platforms", async () => { - const platformSpy = vi - .spyOn(process, "platform", "get") - .mockReturnValue("linux"); + it("rethrows unreadable backup directory errors while listing backups", async () => { const readdirSpy = vi.spyOn(fs, "readdir"); const error = new Error("backup directory locked") as NodeJS.ErrnoException; error.code = "EPERM"; @@ -2505,17 +1425,12 @@ describe("storage", () => { try { await expect(listNamedBackups()).rejects.toMatchObject({ code: "EPERM" }); - expect(readdirSpy).toHaveBeenCalledTimes(1); } finally { readdirSpy.mockRestore(); - platformSpy.mockRestore(); } }); - it("rethrows unreadable backup directory errors while restoring backups on non-Windows platforms", async () => { - const platformSpy = vi - .spyOn(process, "platform", "get") - .mockReturnValue("linux"); + it("rethrows unreadable backup directory errors while restoring backups", async () => { const readdirSpy = vi.spyOn(fs, "readdir"); const error = new Error("backup directory locked") as NodeJS.ErrnoException; error.code = "EPERM"; @@ -2525,37 +1440,12 @@ describe("storage", () => { await expect(restoreNamedBackup("Manual Backup")).rejects.toMatchObject({ code: "EPERM", }); - expect(readdirSpy).toHaveBeenCalledTimes(1); - } finally { - readdirSpy.mockRestore(); - platformSpy.mockRestore(); - } - }); - - it("retries EAGAIN backup directory errors while restoring backups on non-Windows platforms", async () => { - const platformSpy = vi - .spyOn(process, "platform", "get") - .mockReturnValue("linux"); - const readdirSpy = vi.spyOn(fs, "readdir"); - const error = new Error("backup directory busy") as NodeJS.ErrnoException; - error.code = "EAGAIN"; - readdirSpy.mockRejectedValue(error); - - try { - await expect(restoreNamedBackup("Manual Backup")).rejects.toMatchObject({ - code: "EAGAIN", - }); - expect(readdirSpy).toHaveBeenCalledTimes(7); } finally { readdirSpy.mockRestore(); - platformSpy.mockRestore(); } }); - it("retries transient EBUSY backup directory errors while listing backups on win32", async () => { - const platformSpy = vi - .spyOn(process, "platform", "get") - .mockReturnValue("win32"); + it("retries transient backup directory errors while listing backups", async () => { await saveAccounts({ version: 3, activeIndex: 0, @@ -2595,202 +1485,12 @@ describe("storage", () => { ]), ); expect(busyFailures).toBe(1); - } finally { - readdirSpy.mockRestore(); - platformSpy.mockRestore(); - } - }); - - it("retries transient EAGAIN backup directory errors while listing backups", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "retry-list-dir-not-empty", - refreshToken: "ref-retry-list-dir-not-empty", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - await createNamedBackup("retry-list-dir-not-empty"); - const backupRoot = join(dirname(testStoragePath), "backups"); - const originalReaddir = fs.readdir.bind(fs); - let busyFailures = 0; - const readdirSpy = vi - .spyOn(fs, "readdir") - .mockImplementation(async (...args) => { - const [path] = args; - if (String(path) === backupRoot && busyFailures === 0) { - busyFailures += 1; - const error = new Error( - "backup directory not empty yet", - ) as NodeJS.ErrnoException; - error.code = "EAGAIN"; - throw error; - } - return originalReaddir(...(args as Parameters)); - }); - - try { - const backups = await listNamedBackups(); - expect(backups).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: "retry-list-dir-not-empty", - valid: true, - }), - ]), - ); - expect(busyFailures).toBe(1); } finally { readdirSpy.mockRestore(); } }); - it("retries transient EPERM backup directory errors while listing backups on win32", async () => { - const platformSpy = vi - .spyOn(process, "platform", "get") - .mockReturnValue("win32"); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "retry-list-dir-eperm", - refreshToken: "ref-retry-list-dir-eperm", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - await createNamedBackup("retry-list-dir-eperm"); - const backupRoot = join(dirname(testStoragePath), "backups"); - const originalReaddir = fs.readdir.bind(fs); - let busyFailures = 0; - const readdirSpy = vi - .spyOn(fs, "readdir") - .mockImplementation(async (...args) => { - const [path] = args; - if (String(path) === backupRoot && busyFailures === 0) { - busyFailures += 1; - const error = new Error( - "backup directory busy", - ) as NodeJS.ErrnoException; - error.code = "EPERM"; - throw error; - } - return originalReaddir(...(args as Parameters)); - }); - - try { - const backups = await listNamedBackups(); - expect(backups).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: "retry-list-dir-eperm", - valid: true, - }), - ]), - ); - expect(busyFailures).toBe(1); - } finally { - readdirSpy.mockRestore(); - platformSpy.mockRestore(); - } - }); - - it("retries a second-chunk backup read when listing more than one chunk of backups", async () => { - const platformSpy = vi - .spyOn(process, "platform", "get") - .mockReturnValue("win32"); - const backups: Awaited>[] = []; - for ( - let index = 0; - index <= NAMED_BACKUP_LIST_CONCURRENCY; - index += 1 - ) { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: `chunk-boundary-${index}`, - refreshToken: `ref-chunk-boundary-${index}`, - addedAt: index + 1, - lastUsed: index + 1, - }, - ], - }); - backups.push( - await createNamedBackup(`chunk-boundary-${String(index).padStart(2, "0")}`), - ); - } - - const backupRoot = join(dirname(testStoragePath), "backups"); - const originalReaddir = fs.readdir.bind(fs); - const originalReadFile = fs.readFile.bind(fs); - const secondChunkBackup = backups.at(-1); - let busyFailures = 0; - const readdirSpy = vi - .spyOn(fs, "readdir") - .mockImplementation(async (...args) => { - const [path, options] = args; - if ( - String(path) === backupRoot && - typeof options === "object" && - options?.withFileTypes === true - ) { - const entries = await originalReaddir( - ...(args as Parameters), - ); - return [...entries].sort((left, right) => - left.name.localeCompare(right.name), - ) as Awaited>; - } - return originalReaddir(...(args as Parameters)); - }); - const readFileSpy = vi - .spyOn(fs, "readFile") - .mockImplementation(async (...args) => { - const [path] = args; - if (String(path) === secondChunkBackup?.path && busyFailures === 0) { - busyFailures += 1; - const error = new Error("backup file busy") as NodeJS.ErrnoException; - error.code = "EBUSY"; - throw error; - } - return originalReadFile(...(args as Parameters)); - }); - - try { - const listedBackups = await listNamedBackups(); - expect(listedBackups).toHaveLength(NAMED_BACKUP_LIST_CONCURRENCY + 1); - expect(listedBackups).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: `chunk-boundary-${String( - NAMED_BACKUP_LIST_CONCURRENCY, - ).padStart(2, "0")}`, - valid: true, - }), - ]), - ); - expect(busyFailures).toBe(1); - expect( - readFileSpy.mock.calls.filter( - ([path]) => String(path) === secondChunkBackup?.path, - ), - ).toHaveLength(2); - } finally { - readFileSpy.mockRestore(); - readdirSpy.mockRestore(); - platformSpy.mockRestore(); - } - }); - - it("retries transient EAGAIN backup directory errors while restoring backups", async () => { + it("retries transient backup directory errors while restoring backups", async () => { await saveAccounts({ version: 3, activeIndex: 0, @@ -2859,21 +1559,15 @@ describe("storage", () => { const assessment = await assessNamedBackupRestore("Manual Backup"); expect(assessment.eligibleForRestore).toBe(true); - const storageBeforeRestore = await loadAccounts(); - expect(storageBeforeRestore?.accounts ?? []).toHaveLength(0); await removeWithRetry(backupPath, { force: true }); await expect(restoreNamedBackup("Manual Backup")).rejects.toThrow( /Import file not found/, ); - expect(await loadAccounts()).toEqual(storageBeforeRestore); }); - it("retries transient EBUSY backup read errors while listing backups on win32", async () => { - const platformSpy = vi - .spyOn(process, "platform", "get") - .mockReturnValue("win32"); + it("retries transient backup read errors while listing backups", async () => { await saveAccounts({ version: 3, activeIndex: 0, @@ -2912,249 +1606,52 @@ describe("storage", () => { expect(busyFailures).toBe(1); } finally { readFileSpy.mockRestore(); - platformSpy.mockRestore(); } }); - it("retries transient backup stat EAGAIN errors while listing backups", async () => { - let statSpy: ReturnType | undefined; - try { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "retry-stat", - refreshToken: "ref-retry-stat", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - const backup = await createNamedBackup("retry-stat"); - const originalStat = fs.stat.bind(fs); - let busyFailures = 0; - statSpy = vi.spyOn(fs, "stat").mockImplementation(async (...args) => { - const [path] = args; - if (String(path) === backup.path && busyFailures === 0) { - busyFailures += 1; - const error = new Error("backup stat busy") as NodeJS.ErrnoException; - error.code = "EAGAIN"; - throw error; - } - return originalStat(...(args as Parameters)); - }); - - const backups = await listNamedBackups(); - expect(backups).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: "retry-stat", valid: true }), - ]), - ); - expect(busyFailures).toBe(1); - } finally { - statSpy?.mockRestore(); - } - }); - - it("sorts backups with invalid timestamps after finite timestamps", async () => { + it("retries transient backup stat errors while listing backups", async () => { await saveAccounts({ version: 3, activeIndex: 0, accounts: [ { - accountId: "valid-backup", - refreshToken: "ref-valid-backup", + accountId: "retry-stat", + refreshToken: "ref-retry-stat", addedAt: 1, lastUsed: 1, }, ], }); - const validBackup = await createNamedBackup("valid-backup"); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "nan-backup", - refreshToken: "ref-nan-backup", - addedAt: 2, - lastUsed: 2, - }, - ], - }); - const nanBackup = await createNamedBackup("nan-backup"); + const backup = await createNamedBackup("retry-stat"); const originalStat = fs.stat.bind(fs); + let busyFailures = 0; const statSpy = vi.spyOn(fs, "stat").mockImplementation(async (...args) => { const [path] = args; - const stats = await originalStat(...(args as Parameters)); - if (String(path) === nanBackup.path) { - return { - ...stats, - mtimeMs: Number.NaN, - } as Awaited>; + if (String(path) === backup.path && busyFailures === 0) { + busyFailures += 1; + const error = new Error("backup stat busy") as NodeJS.ErrnoException; + error.code = "EAGAIN"; + throw error; } - return stats; + return originalStat(...(args as Parameters)); }); try { const backups = await listNamedBackups(); - expect(backups.map((backup) => backup.name)).toEqual([ - validBackup.name, - nanBackup.name, - ]); - } finally { - statSpy.mockRestore(); - } - }); - - it("reuses freshly listed backup candidates for the first restore assessment", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "cached-backup", - refreshToken: "ref-cached-backup", - addedAt: 1, - lastUsed: 2, - }, - ], - }); - const backup = await createNamedBackup("cached-backup"); - const readFileSpy = vi.spyOn(fs, "readFile"); - const candidateCache = new Map(); - - try { - await listNamedBackups({ candidateCache }); - await assessNamedBackupRestore("cached-backup", { - currentStorage: null, - candidateCache, - }); - - const firstPassReads = readFileSpy.mock.calls.filter( - ([path]) => path === backup.path, - ); - expect(firstPassReads).toHaveLength(1); - - await assessNamedBackupRestore("cached-backup", { currentStorage: null }); - - const secondPassReads = readFileSpy.mock.calls.filter( - ([path]) => path === backup.path, - ); - expect(secondPassReads).toHaveLength(2); - } finally { - readFileSpy.mockRestore(); - } - }); - - it("ignores invalid externally provided candidate cache entries", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "external-cache-backup", - refreshToken: "ref-external-cache-backup", - addedAt: 1, - lastUsed: 2, - }, - ], - }); - const backup = await createNamedBackup("external-cache-backup"); - const readFileSpy = vi.spyOn(fs, "readFile"); - const candidateCache = new Map([ - [ - backup.path, - { - normalized: { version: 3 }, - storedVersion: 3, - schemaErrors: [], - }, - ], - ]); - - try { - const assessment = await assessNamedBackupRestore( - "external-cache-backup", - { - currentStorage: null, - candidateCache, - }, - ); - expect(assessment).toEqual( - expect.objectContaining({ - eligibleForRestore: true, - backup: expect.objectContaining({ - name: "external-cache-backup", - path: backup.path, - }), - }), - ); - expect( - readFileSpy.mock.calls.filter(([path]) => path === backup.path), - ).toHaveLength(1); - expect(candidateCache.has(backup.path)).toBe(false); - } finally { - readFileSpy.mockRestore(); - } - }); - - it("keeps per-call named-backup caches isolated across concurrent listings", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "isolated-cache-backup", - refreshToken: "ref-isolated-cache-backup", - addedAt: 1, - lastUsed: 2, - }, - ], - }); - const backup = await createNamedBackup("isolated-cache-backup"); - const readFileSpy = vi.spyOn(fs, "readFile"); - const firstCandidateCache = new Map(); - const secondCandidateCache = new Map(); - - try { - await Promise.all([ - listNamedBackups({ candidateCache: firstCandidateCache }), - listNamedBackups({ candidateCache: secondCandidateCache }), - ]); - - await assessNamedBackupRestore("isolated-cache-backup", { - currentStorage: null, - candidateCache: firstCandidateCache, - }); - await assessNamedBackupRestore("isolated-cache-backup", { - currentStorage: null, - candidateCache: secondCandidateCache, - }); - - const cachedReads = readFileSpy.mock.calls.filter( - ([path]) => path === backup.path, - ); - expect(cachedReads).toHaveLength(2); - - await assessNamedBackupRestore("isolated-cache-backup", { - currentStorage: null, - }); - - const rereadCalls = readFileSpy.mock.calls.filter( - ([path]) => path === backup.path, + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "retry-stat", valid: true }), + ]), ); - expect(rereadCalls).toHaveLength(3); + expect(busyFailures).toBe(1); } finally { - readFileSpy.mockRestore(); + statSpy.mockRestore(); } }); it("limits concurrent backup reads while listing backups", async () => { const backupPaths: string[] = []; - const totalBackups = NAMED_BACKUP_LIST_CONCURRENCY + 4; - for (let index = 0; index < totalBackups; index += 1) { + for (let index = 0; index < 12; index += 1) { await saveAccounts({ version: 3, activeIndex: 0, @@ -3196,7 +1693,7 @@ describe("storage", () => { try { const backups = await listNamedBackups(); - expect(backups).toHaveLength(totalBackups); + expect(backups).toHaveLength(12); expect(peakReads).toBeLessThanOrEqual( NAMED_BACKUP_LIST_CONCURRENCY, ); From 82128315bd9f5d897dc18048ef402a44361b9777 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 23:49:29 +0800 Subject: [PATCH 04/13] test(storage): isolate empty export path --- test/storage.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test/storage.test.ts b/test/storage.test.ts index 7e37dcfa..1c364b5d 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1317,9 +1317,15 @@ describe("storage", () => { it("should fail export when no accounts exist", async () => { const storageModule = await import("../lib/storage.js"); - storageModule.setStoragePathDirect(testStoragePath); - await storageModule.clearAccounts(); - await expect(storageModule.exportAccounts(exportPath)).rejects.toThrow( + const isolatedStorageDir = join( + testWorkDir, + "empty-export-" + Math.random().toString(36).slice(2), + ); + const isolatedStoragePath = join(isolatedStorageDir, "accounts.json"); + const isolatedExportPath = join(isolatedStorageDir, "export.json"); + await fs.mkdir(isolatedStorageDir, { recursive: true }); + storageModule.setStoragePathDirect(isolatedStoragePath); + await expect(storageModule.exportAccounts(isolatedExportPath)).rejects.toThrow( /No accounts to export/, ); }); From 390f801a4986cb4d3a9768f8a0ca215858816bca Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 11:22:01 +0800 Subject: [PATCH 05/13] fix(auth): clean startup recovery prompt branch --- docs/reference/commands.md | 2 + docs/reference/storage-paths.md | 4 + lib/codex-manager.ts | 37 +++--- lib/storage.ts | 28 ++++- test/cli.test.ts | 10 ++ test/codex-manager-cli.test.ts | 181 ++++++++++++++++++++++++++++ test/storage-recovery-paths.test.ts | 6 + test/storage.test.ts | 94 ++++++++++++++- 8 files changed, 334 insertions(+), 28 deletions(-) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 36c735f9..f7a9cf0a 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -26,6 +26,7 @@ Compatibility aliases are supported: | `codex auth switch ` | Set active account by index | | `codex auth check` | Run quick account health check | | `codex auth features` | Print implemented feature summary | +| `codex auth restore-backup` | Open the backup restore picker directly | --- @@ -111,6 +112,7 @@ codex auth report --live --json Repair and recovery: ```bash +codex auth restore-backup codex auth fix --dry-run codex auth fix --live --model gpt-5-codex codex auth doctor --fix diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index cf0747de..8ea743ec 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -109,6 +109,10 @@ Restore workflow: 3. Choose `Restore From Backup`. 4. Pick a backup and confirm the merge summary before import. +Direct entrypoint: + +- Run `codex auth restore-backup` to open the same picker without entering the full login dashboard first. + --- ## oc-chatgpt Target Paths diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 031b628c..c4690218 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -63,6 +63,7 @@ import { import { assessNamedBackupRestore, getActionableNamedBackupRestores, + getRedactedFilesystemErrorLabel, getNamedBackupsDirectoryPath, listNamedBackups, NAMED_BACKUP_LIST_CONCURRENCY, @@ -326,6 +327,7 @@ function printUsage(): void { " codex auth switch ", " codex auth check", " codex auth features", + " codex auth restore-backup", " codex auth verify-flagged [--dry-run] [--json] [--no-restore]", " codex auth forecast [--live] [--json] [--model ]", " codex auth report [--live] [--json] [--model ] [--out ]", @@ -3912,6 +3914,17 @@ async function runAuthLogin(): Promise { console.log("Cancelled."); return 0; } + const modeRequiresDrainedQuotaRefresh = + menuResult.mode === "check" || + menuResult.mode === "deep-check" || + menuResult.mode === "forecast" || + menuResult.mode === "fix"; + if (modeRequiresDrainedQuotaRefresh) { + const pendingQuotaRefresh = pendingMenuQuotaRefresh; + if (pendingQuotaRefresh) { + await pendingQuotaRefresh; + } + } if (menuResult.mode === "check") { await runActionPanel("Quick Check", "Checking local session + live status", async () => { await runHealthCheck({ forceRefresh: false, liveProbe: true }); @@ -4339,18 +4352,7 @@ type BackupRestoreAssessment = Awaited< ReturnType >; -type BackupRestoreManagerResult = "restored" | "dismissed"; - -function getRedactedFilesystemErrorLabel(error: unknown): string { - const code = (error as NodeJS.ErrnoException).code; - if (typeof code === "string" && code.trim().length > 0) { - return code; - } - if (error instanceof Error && error.name && error.name !== "Error") { - return error.name; - } - return "UNKNOWN"; -} +type BackupRestoreManagerResult = "restored" | "dismissed" | "failed"; async function loadBackupRestoreManagerAssessments(): Promise< BackupRestoreAssessment[] @@ -4477,11 +4479,11 @@ async function runBackupRestoreManager( console.warn( `Failed to re-assess backup "${selection.assessment.backup.name}" before restore (${errorLabel}).`, ); - return "dismissed"; + return "failed"; } if (!latestAssessment.eligibleForRestore) { console.log(latestAssessment.error ?? "Backup is not eligible for restore."); - return "dismissed"; + return "failed"; } const confirmMessage = `Restore backup "${latestAssessment.backup.name}"? This will merge ${latestAssessment.backup.accountCount ?? 0} account(s) into ${latestAssessment.currentAccountCount} current (${latestAssessment.mergedAccountCount ?? latestAssessment.currentAccountCount} after dedupe).`; @@ -4499,7 +4501,7 @@ async function runBackupRestoreManager( console.warn( `Failed to restore backup "${latestAssessment.backup.name}" (${errorLabel}).`, ); - return "dismissed"; + return "failed"; } } @@ -4532,8 +4534,9 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { return runAuthLogin(); } if (command === "restore-backup") { - await runBackupRestoreManager(startupDisplaySettings); - return 0; + return (await runBackupRestoreManager(startupDisplaySettings)) === "failed" + ? 1 + : 0; } if (command === "list" || command === "status") { await showAccountStatus(); diff --git a/lib/storage.ts b/lib/storage.ts index d0e130cd..e9dea29e 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -176,7 +176,7 @@ function createUnloadedBackupCandidate(): LoadedBackupCandidate { }; } -function getBackupRestoreAssessmentErrorLabel(error: unknown): string { +export function getRedactedFilesystemErrorLabel(error: unknown): string { const code = (error as NodeJS.ErrnoException).code; if (typeof code === "string" && code.trim().length > 0) { return code; @@ -200,10 +200,16 @@ function buildFailedBackupRestoreAssessment( skipped: null, wouldExceedLimit: false, eligibleForRestore: false, - error: getBackupRestoreAssessmentErrorLabel(error), + error: getRedactedFilesystemErrorLabel(error), }; } +function normalizeBackupUpdatedAt(updatedAt: number | null | undefined): number { + return typeof updatedAt === "number" && Number.isFinite(updatedAt) && updatedAt !== 0 + ? updatedAt + : 0; +} + /** * Custom error class for storage operations with platform-aware hints. */ @@ -1693,7 +1699,8 @@ async function scanNamedBackups(): Promise { return { backups: backups.sort( (left, right) => - (right.backup.updatedAt ?? 0) - (left.backup.updatedAt ?? 0), + normalizeBackupUpdatedAt(right.backup.updatedAt) - + normalizeBackupUpdatedAt(left.backup.updatedAt), ), totalBackups, }; @@ -1744,7 +1751,11 @@ async function listNamedBackupsWithoutLoading(): Promise (right.updatedAt ?? 0) - (left.updatedAt ?? 0)), + backups: backups.sort( + (left, right) => + normalizeBackupUpdatedAt(right.updatedAt) - + normalizeBackupUpdatedAt(left.updatedAt), + ), totalBackups, }; } catch (error) { @@ -1767,7 +1778,10 @@ export async function listNamedBackups(): Promise { function isRetryableFilesystemErrorCode( code: string | undefined, ): code is "EPERM" | "EBUSY" | "EAGAIN" { - return code === "EPERM" || code === "EBUSY" || code === "EAGAIN"; + if (code === "EBUSY" || code === "EAGAIN") { + return true; + } + return code === "EPERM" && process.platform === "win32"; } async function retryTransientFilesystemOperation( @@ -1781,7 +1795,9 @@ async function retryTransientFilesystemOperation( if (!isRetryableFilesystemErrorCode(code) || attempt === 4) { throw error; } - await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); + await new Promise((resolve) => + setTimeout(resolve, 10 * 2 ** attempt + Math.floor(Math.random() * 10)), + ); } } diff --git a/test/cli.test.ts b/test/cli.test.ts index 269a0eba..efbffdce 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -716,6 +716,16 @@ describe("CLI Module", () => { await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "restore-backup", }); + + mockRl.question.mockResolvedValueOnce("backup"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); + + mockRl.question.mockResolvedValueOnce("restore-backup"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); }); it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 109c5721..2c1f4ecb 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -790,6 +790,61 @@ describe("codex manager cli commands", () => { expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); }); + it("returns a non-zero exit code when the direct restore-backup command fails", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + restoreNamedBackupMock.mockRejectedValueOnce(new Error("backup locked")); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + + expect(exitCode).toBe(1); + expect(promptLoginModeMock).not.toHaveBeenCalled(); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + }); + it("restores healthy flagged accounts into active storage", async () => { const now = Date.now(); loadFlaggedAccountsMock.mockResolvedValueOnce({ @@ -4969,6 +5024,132 @@ describe("codex manager cli commands", () => { logSpy.mockRestore(); }); + it("waits for an in-flight menu quota refresh before starting quick check", async () => { + const now = Date.now(); + const menuStorage = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "alpha@example.com", + accountId: "acc-alpha", + accessToken: "access-alpha", + expiresAt: now + 3_600_000, + refreshToken: "refresh-alpha", + addedAt: now, + lastUsed: now, + enabled: true, + }, + { + email: "beta@example.com", + accountId: "acc-beta", + accessToken: "access-beta", + expiresAt: now + 3_600_000, + refreshToken: "refresh-beta", + addedAt: now, + lastUsed: now, + enabled: true, + }, + ], + }; + const quickCheckStorage = { + ...menuStorage, + accounts: [menuStorage.accounts[0]!], + }; + let loadAccountsCalls = 0; + loadAccountsMock.mockImplementation(async () => { + loadAccountsCalls += 1; + return structuredClone( + loadAccountsCalls === 1 ? menuStorage : quickCheckStorage, + ); + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuShowFetchStatus: true, + menuQuotaTtlMs: 60_000, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + let currentQuotaCache: { + byAccountId: Record; + byEmail: Record; + } = { + byAccountId: {}, + byEmail: {}, + }; + loadQuotaCacheMock.mockImplementation(async () => + structuredClone(currentQuotaCache), + ); + saveQuotaCacheMock.mockImplementation(async (value: typeof currentQuotaCache) => { + currentQuotaCache = structuredClone(value); + }); + const firstFetchStarted = createDeferred(); + const secondFetchStarted = createDeferred(); + const releaseFirstFetch = createDeferred(); + const releaseSecondFetch = createDeferred(); + let secondFetchObserved = false; + let fetchCallCount = 0; + fetchCodexQuotaSnapshotMock.mockImplementation( + async (input: { accountId: string }) => { + fetchCallCount += 1; + if (fetchCallCount === 1) { + firstFetchStarted.resolve(); + await releaseFirstFetch.promise; + } else if (fetchCallCount === 2) { + secondFetchStarted.resolve(input.accountId); + await releaseSecondFetch.promise; + } + return { + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }; + }, + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "check" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const runPromise = runCodexMultiAuthCli(["auth", "login"]); + void secondFetchStarted.promise.then(() => { + secondFetchObserved = true; + }); + + await firstFetchStarted.promise; + await Promise.resolve(); + + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(1); + expect(secondFetchObserved).toBe(false); + + releaseFirstFetch.resolve(); + + const secondAccountId = await secondFetchStarted.promise; + expect(secondAccountId).toBe("acc-alpha"); + + releaseSecondFetch.resolve(); + + const exitCode = await runPromise; + + expect(exitCode).toBe(0); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); + expect(Object.keys(currentQuotaCache.byEmail)).toEqual(["alpha@example.com"]); + } finally { + logSpy.mockRestore(); + } + }); + it("waits for an in-flight menu quota refresh before resetting local state", async () => { const now = Date.now(); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index 264bf494..08032032 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -735,6 +735,12 @@ describe("storage recovery paths", () => { }), "utf-8", ); + const manualCheckpointMtime = new Date(Date.now() + 5_000); + await fs.utimes( + `${storagePath}.manual-meta-checkpoint`, + manualCheckpointMtime, + manualCheckpointMtime, + ); const metadata = await getBackupMetadata(); const accountSnapshots = metadata.accounts.snapshots; diff --git a/test/storage.test.ts b/test/storage.test.ts index 33bd8c33..a022215a 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -956,7 +956,6 @@ describe("storage", () => { }); it("should fail export when no accounts exist", async () => { - const { exportAccounts } = await import("../lib/storage.js"); const isolatedStorageDir = join( testWorkDir, "empty-export-" + Math.random().toString(36).slice(2), @@ -964,10 +963,26 @@ describe("storage", () => { const isolatedStoragePath = join(isolatedStorageDir, "accounts.json"); const isolatedExportPath = join(isolatedStorageDir, "export.json"); await fs.mkdir(isolatedStorageDir, { recursive: true }); - setStoragePathDirect(isolatedStoragePath); - await expect(exportAccounts(isolatedExportPath)).rejects.toThrow( - /No accounts to export/, - ); + vi.resetModules(); + const isolatedStorageModule = await import("../lib/storage.js"); + isolatedStorageModule.setStoragePathDirect(isolatedStoragePath); + try { + await fs.writeFile( + isolatedStoragePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [], + }), + ); + await expect( + isolatedStorageModule.exportAccounts(isolatedExportPath), + ).rejects.toThrow(/No accounts to export/); + } finally { + isolatedStorageModule.setStoragePathDirect(null); + vi.resetModules(); + } }); it("should fail import when file does not exist", async () => { @@ -1430,6 +1445,24 @@ describe("storage", () => { } }); + it("rethrows unreadable backup directory errors after one attempt on non-Windows platforms", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("linux"); + const readdirSpy = vi.spyOn(fs, "readdir"); + const error = new Error("backup directory locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + readdirSpy.mockRejectedValue(error); + + try { + await expect(listNamedBackups()).rejects.toMatchObject({ code: "EPERM" }); + expect(readdirSpy).toHaveBeenCalledTimes(1); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + it("rethrows unreadable backup directory errors while restoring backups", async () => { const readdirSpy = vi.spyOn(fs, "readdir"); const error = new Error("backup directory locked") as NodeJS.ErrnoException; @@ -1649,6 +1682,57 @@ describe("storage", () => { } }); + it("sorts backups with invalid timestamps after finite timestamps", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "valid-backup", + refreshToken: "ref-valid-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const validBackup = await createNamedBackup("valid-backup"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "nan-backup", + refreshToken: "ref-nan-backup", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + const nanBackup = await createNamedBackup("nan-backup"); + const originalStat = fs.stat.bind(fs); + const statSpy = vi.spyOn(fs, "stat").mockImplementation(async (...args) => { + const [path] = args; + const stats = await originalStat(...(args as Parameters)); + if (String(path) === nanBackup.path) { + return { + ...stats, + mtimeMs: Number.NaN, + } as Awaited>; + } + return stats; + }); + + try { + const backups = await listNamedBackups(); + expect(backups.map((backup) => backup.name)).toEqual([ + validBackup.name, + nanBackup.name, + ]); + } finally { + statSpy.mockRestore(); + } + }); + it("limits concurrent backup reads while listing backups", async () => { const backupPaths: string[] = []; for (let index = 0; index < 12; index += 1) { From a7d87cdf4155c392fd04a42a5415d8b4cd1df303 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 19:16:30 +0800 Subject: [PATCH 06/13] fix(storage): restore safe import and restore-backup handling --- lib/codex-manager.ts | 10 +++++++- lib/storage.ts | 10 +++++--- test/codex-manager-cli.test.ts | 5 ++-- test/storage.test.ts | 45 ++++++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 7 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index c4690218..a7d94ced 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -141,7 +141,14 @@ function formatReasonLabel(reason: string | undefined): string | undefined { function formatRelativeDateShort( timestamp: number | null | undefined, ): string | null { - if (timestamp === null || timestamp === undefined) return null; + if ( + timestamp === null || + timestamp === undefined || + !Number.isFinite(timestamp) || + timestamp <= 0 + ) { + return null; + } const days = Math.floor((Date.now() - timestamp) / 86_400_000); if (days <= 0) return "today"; if (days === 1) return "yesterday"; @@ -4534,6 +4541,7 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { return runAuthLogin(); } if (command === "restore-backup") { + setStoragePath(null); return (await runBackupRestoreManager(startupDisplaySettings)) === "failed" ? 1 : 0; diff --git a/lib/storage.ts b/lib/storage.ts index e9dea29e..175a1fa2 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2989,7 +2989,9 @@ export async function importAccounts( throw new Error(`Import file not found: ${resolvedPath}`); } - const content = await fs.readFile(resolvedPath, "utf-8"); + const content = await retryTransientFilesystemOperation(() => + fs.readFile(resolvedPath, "utf-8"), + ); let imported: unknown; try { @@ -3009,6 +3011,7 @@ export async function importAccounts( skipped: skippedCount, } = await withAccountStorageTransaction(async (existing, persist) => { const existingAccounts = existing?.accounts ?? []; + const existingDeduplicatedAccounts = deduplicateAccounts(existingAccounts); const existingActiveIndex = existing?.activeIndex ?? 0; const merged = [...existingAccounts, ...normalized.accounts]; @@ -3033,8 +3036,9 @@ export async function importAccounts( await persist(newStorage); - const imported = deduplicatedAccounts.length - existingAccounts.length; - const skipped = normalized.accounts.length - imported; + const imported = + deduplicatedAccounts.length - existingDeduplicatedAccounts.length; + const skipped = Math.max(0, normalized.accounts.length - imported); return { imported, total: deduplicatedAccounts.length, skipped }; }); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 2c1f4ecb..0a3c2f13 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -782,6 +782,7 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); expect(exitCode).toBe(0); + expect(setStoragePathMock).toHaveBeenCalledWith(null); expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( "named-backup", @@ -3966,9 +3967,7 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); const backupItems = selectMock.mock.calls[0]?.[0]; - expect(backupItems?.[0]?.hint).toContain( - `updated ${new Date(0).toLocaleDateString()}`, - ); + expect(backupItems?.[0]?.hint).not.toContain("updated"); }); it("shows experimental settings in the settings hub", async () => { diff --git a/test/storage.test.ts b/test/storage.test.ts index a022215a..1a1e1057 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -584,6 +584,51 @@ describe("storage", () => { ]); }); + it("should report imported counts against deduplicated existing storage", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "shared-workspace", + email: "same@example.com", + refreshToken: "refresh-existing-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "shared-workspace", + email: "same@example.com", + refreshToken: "refresh-existing-b", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "new-workspace", + email: "new@example.com", + refreshToken: "refresh-imported", + addedAt: 3, + lastUsed: 3, + }, + ], + }), + ); + + const result = await importAccounts(exportPath); + + expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); + }); + it("should serialize concurrent transactional updates without losing accounts", async () => { await saveAccounts({ version: 3, From 1eeec0b3c5859b3a5ecf4d5ca76a16a98c80fde0 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:34:55 +0800 Subject: [PATCH 07/13] Block non-interactive backup restore mode --- lib/cli.ts | 8 -------- lib/codex-manager.ts | 14 +++++++++++++- test/cli.test.ts | 16 +++++++++++----- test/codex-manager-cli.test.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 14 deletions(-) diff --git a/lib/cli.ts b/lib/cli.ts index 67c304db..e2b0b805 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -234,14 +234,6 @@ async function promptLoginModeFallback( ) { return { mode: "verify-flagged" }; } - if ( - normalized === "u" || - normalized === "backup" || - normalized === "restore" || - normalized === "restore-backup" - ) { - return { mode: "restore-backup" }; - } if (normalized === "q" || normalized === "quit") return { mode: "cancel" }; console.log(UI_COPY.fallback.invalidModePrompt); diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 0999cc29..2b1ee89c 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -10,7 +10,12 @@ 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, + type ExistingAccountInfo, +} from "./cli.js"; import { extractAccountEmail, extractAccountId, @@ -4223,6 +4228,13 @@ type BackupMenuAction = async function runBackupRestoreManager( displaySettings: DashboardDisplaySettings, ): Promise { + if (isNonInteractiveMode()) { + console.error( + "Backup restore manager requires an interactive TTY. Run this command in an interactive terminal.", + ); + return false; + } + const backupDir = getNamedBackupsDirectoryPath(); // Reuse only within this list -> assess flow so storage.ts can safely treat // the cache contents as LoadedBackupCandidate entries. diff --git a/test/cli.test.ts b/test/cli.test.ts index efbffdce..8e0acb86 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -704,28 +704,34 @@ describe("CLI Module", () => { }); }); - it("returns restore-backup for fallback restore aliases", async () => { + it("rejects fallback restore aliases that need the interactive backup browser", async () => { const { promptLoginMode } = await import("../lib/cli.js"); mockRl.question.mockResolvedValueOnce("u"); + mockRl.question.mockResolvedValueOnce("quit"); await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ - mode: "restore-backup", + mode: "cancel", }); mockRl.question.mockResolvedValueOnce("restore"); + mockRl.question.mockResolvedValueOnce("quit"); await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ - mode: "restore-backup", + mode: "cancel", }); mockRl.question.mockResolvedValueOnce("backup"); + mockRl.question.mockResolvedValueOnce("quit"); await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ - mode: "restore-backup", + mode: "cancel", }); mockRl.question.mockResolvedValueOnce("restore-backup"); + mockRl.question.mockResolvedValueOnce("quit"); await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ - mode: "restore-backup", + mode: "cancel", }); + + expect(mockRl.question).toHaveBeenCalledTimes(8); }); it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 1ab460dd..cc215570 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -24,6 +24,7 @@ const queuedRefreshMock = vi.fn(); const setCodexCliActiveSelectionMock = vi.fn(); const promptAddAnotherAccountMock = vi.fn(); const promptLoginModeMock = vi.fn(); +const isNonInteractiveModeMock = vi.fn(() => false); const fetchCodexQuotaSnapshotMock = vi.fn(); const loadDashboardDisplaySettingsMock = vi.fn(); const saveDashboardDisplaySettingsMock = vi.fn(); @@ -72,6 +73,7 @@ vi.mock("../lib/auth/server.js", () => ({ })); vi.mock("../lib/cli.js", () => ({ + isNonInteractiveMode: isNonInteractiveModeMock, promptAddAnotherAccount: promptAddAnotherAccountMock, promptLoginMode: promptLoginModeMock, })); @@ -480,6 +482,8 @@ describe("codex manager cli commands", () => { setCodexCliActiveSelectionMock.mockResolvedValue(true); promptAddAnotherAccountMock.mockReset(); promptLoginModeMock.mockReset(); + isNonInteractiveModeMock.mockReset(); + isNonInteractiveModeMock.mockReturnValue(false); fetchCodexQuotaSnapshotMock.mockReset(); loadDashboardDisplaySettingsMock.mockReset(); saveDashboardDisplaySettingsMock.mockReset(); @@ -2521,6 +2525,28 @@ describe("codex manager cli commands", () => { expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce(); }); + it("returns a non-zero exit code for the direct restore-backup command without an interactive TTY", async () => { + setInteractiveTTY(false); + isNonInteractiveModeMock.mockReturnValueOnce(true); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + + expect(exitCode).toBe(1); + expect(promptLoginModeMock).not.toHaveBeenCalled(); + expect(listNamedBackupsMock).not.toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + "Backup restore manager requires an interactive TTY. Run this command in an interactive terminal.", + ); + } finally { + errorSpy.mockRestore(); + } + }); + it("returns a non-zero exit code when the direct restore-backup command fails", async () => { setInteractiveTTY(true); const now = Date.now(); From 474fbc413869ace522b19608c1fd8c5ea55b7821 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:34:55 +0800 Subject: [PATCH 08/13] Fix startup recovery backup accounting --- lib/codex-manager.ts | 7 ++++--- lib/storage.ts | 30 +++++++++++++++++++----------- test/codex-manager-cli.test.ts | 8 ++------ 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index a7d94ced..d2376dc1 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -3843,9 +3843,10 @@ export function resolveStartupRecoveryAction( if (recoveryState.assessments.length > 0) { return "show-recovery-prompt"; } - return recoveryScanFailed - ? "continue-with-oauth" - : "open-empty-storage-menu"; + if (recoveryScanFailed || recoveryState.totalBackups > 0) { + return "continue-with-oauth"; + } + return "open-empty-storage-menu"; } async function runAuthLogin(): Promise { diff --git a/lib/storage.ts b/lib/storage.ts index 175a1fa2..ff3b3735 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1760,13 +1760,14 @@ async function listNamedBackupsWithoutLoading(): Promise ACCOUNT_LIMITS.MAX_ACCOUNTS; const imported = wouldExceedLimit ? null - : mergedAccounts.length - currentAccounts.length; + : mergedAccounts.length - deduplicatedCurrentAccounts.length; const skipped = wouldExceedLimit ? null - : Math.max(0, candidate.normalized.accounts.length - (imported ?? 0)); + : Math.max(0, deduplicatedIncomingAccounts.length - (imported ?? 0)); return { backup, @@ -2938,7 +2943,10 @@ export async function exportAccounts( const transactionState = transactionSnapshotContext.getStore(); const currentStoragePath = getStoragePath(); const storage = transactionState?.active - ? transactionState.storagePath === currentStoragePath + ? (process.platform === "win32" + ? transactionState.storagePath?.toLowerCase() === + currentStoragePath?.toLowerCase() + : transactionState.storagePath === currentStoragePath) ? transactionState.snapshot : (() => { throw new Error( diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 0a3c2f13..c6b519d9 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1978,7 +1978,7 @@ describe("codex manager cli commands", () => { expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); }); - it("shows the empty storage menu before OAuth when startup recovery finds backups but none are actionable", async () => { + it("continues with OAuth when startup recovery finds backups but none are actionable", async () => { setInteractiveTTY(true); const now = Date.now(); let storageState = { @@ -2004,8 +2004,7 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); - expect(promptLoginModeMock).toHaveBeenCalledTimes(1); - expect(promptLoginModeMock.mock.calls[0]?.[0]).toEqual([]); + expect(promptLoginModeMock).not.toHaveBeenCalled(); expect(confirmMock).not.toHaveBeenCalled(); expect(selectMock).toHaveBeenCalledTimes(1); expect(selectMock.mock.calls[0]?.[1]).toMatchObject({ @@ -2016,9 +2015,6 @@ describe("codex manager cli commands", () => { expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); expect(restoreNamedBackupMock).not.toHaveBeenCalled(); expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); - expect(promptLoginModeMock.mock.invocationCallOrder[0]).toBeLessThan( - createAuthorizationFlowMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, - ); }); it("shows all startup-scanned backups in the restore manager before re-prompting", async () => { From 326d5f1df765fd48589ea4f7a04cb5095deb9917 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 23:30:29 +0800 Subject: [PATCH 09/13] fix(auth): harden restore backup fallback and file reads --- lib/codex-manager.ts | 2 +- lib/storage.ts | 5 ---- lib/ui/copy.ts | 4 +-- test/cli.test.ts | 9 ++++++ test/codex-manager-cli.test.ts | 3 +- test/storage.test.ts | 52 ++++++++++++++++++++++++++++++++++ 6 files changed, 65 insertions(+), 10 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 2b1ee89c..203e5faa 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4228,7 +4228,7 @@ type BackupMenuAction = async function runBackupRestoreManager( displaySettings: DashboardDisplaySettings, ): Promise { - if (isNonInteractiveMode()) { + if (isNonInteractiveMode() || !input.isTTY || !output.isTTY) { console.error( "Backup restore manager requires an interactive TTY. Run this command in an interactive terminal.", ); diff --git a/lib/storage.ts b/lib/storage.ts index a6f0f5ad..adc22308 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -3078,11 +3078,6 @@ export async function importAccounts( ): Promise { const resolvedPath = resolvePath(filePath); - // Check file exists with friendly error - if (!existsSync(resolvedPath)) { - throw new Error(`Import file not found: ${resolvedPath}`); - } - let content: string; try { content = await retryTransientFilesystemOperation(() => diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 1b14d107..c2f39ab1 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -152,8 +152,8 @@ export const UI_COPY = { addAnotherQuestion: (count: number) => `Add another account? (${count} added) (y/n): `, selectModePrompt: - "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (u) restore backup, (f) fresh, (r) reset, (q) back [a/c/b/x/s/d/g/u/f/r/q]: ", - invalidModePrompt: "Use one of: a, c, b, x, s, d, g, u, f, r, q.", + "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (f) fresh, (r) reset, (q) back [a/c/b/x/s/d/g/f/r/q]: ", + invalidModePrompt: "Use one of: a, c, b, x, s, d, g, f, r, q.", }, } as const; diff --git a/test/cli.test.ts b/test/cli.test.ts index 8e0acb86..61808157 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -706,6 +706,8 @@ describe("CLI Module", () => { it("rejects fallback restore aliases that need the interactive backup browser", async () => { const { promptLoginMode } = await import("../lib/cli.js"); + const { UI_COPY } = await import("../lib/ui/copy.js"); + const consoleSpy = vi.spyOn(console, "log"); mockRl.question.mockResolvedValueOnce("u"); mockRl.question.mockResolvedValueOnce("quit"); @@ -731,7 +733,14 @@ describe("CLI Module", () => { mode: "cancel", }); + expect(UI_COPY.fallback.selectModePrompt).not.toContain("(u)"); + expect(UI_COPY.fallback.invalidModePrompt).not.toContain(", u,"); expect(mockRl.question).toHaveBeenCalledTimes(8); + expect( + consoleSpy.mock.calls.filter( + ([message]) => message === UI_COPY.fallback.invalidModePrompt, + ), + ).toHaveLength(4); }); it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index cc215570..532d1c96 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2525,9 +2525,8 @@ describe("codex manager cli commands", () => { expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce(); }); - it("returns a non-zero exit code for the direct restore-backup command without an interactive TTY", async () => { + it("returns a non-zero exit code for the direct restore-backup command when stdin/stdout are not TTYs", async () => { setInteractiveTTY(false); - isNonInteractiveModeMock.mockReturnValueOnce(true); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { diff --git a/test/storage.test.ts b/test/storage.test.ts index 1c364b5d..05d27379 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1383,6 +1383,58 @@ describe("storage", () => { } }); + it("imports a backup even when existsSync falsely reports the file missing", async () => { + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "exists-sync-false-negative", + refreshToken: "ref-exists-sync-false-negative", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + + const actualFs = await vi.importActual( + "node:fs", + ); + vi.resetModules(); + vi.doMock("node:fs", () => ({ + ...actualFs, + existsSync: (path: Parameters[0]) => + String(path) === exportPath ? false : actualFs.existsSync(path), + })); + + try { + const isolatedStorageModule = await import("../lib/storage.js"); + isolatedStorageModule.setStoragePathDirect(testStoragePath); + + const result = await isolatedStorageModule.importAccounts(exportPath); + expect(result).toMatchObject({ + imported: 1, + skipped: 0, + total: 1, + changed: true, + }); + + const loaded = await isolatedStorageModule.loadAccounts(); + expect(loaded?.accounts).toEqual([ + expect.objectContaining({ + accountId: "exists-sync-false-negative", + refreshToken: "ref-exists-sync-false-negative", + }), + ]); + } finally { + vi.doUnmock("node:fs"); + vi.resetModules(); + } + }); + it("should fail import when file contains invalid JSON", async () => { const { importAccounts } = await import("../lib/storage.js"); await fs.writeFile(exportPath, "not valid json {["); From de6e256ba4ed5bdde096408d43c274f2427dbd72 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 23:52:21 +0800 Subject: [PATCH 10/13] fix(auth): sync direct restore and narrow retries --- lib/codex-manager.ts | 15 +++++++++++++++ lib/storage.ts | 4 ++-- test/codex-manager-cli.test.ts | 32 +++++++++++++++++++++++++++++++- test/recovery.test.ts | 6 +++--- test/storage.test.ts | 18 ++++++++++++++++++ 5 files changed, 69 insertions(+), 6 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index d2376dc1..bcbc2548 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4503,6 +4503,21 @@ async function runBackupRestoreManager( console.log( `Restored backup "${latestAssessment.backup.name}". Imported ${result.imported}, skipped ${result.skipped}, total ${result.total}.`, ); + try { + const synced = await autoSyncActiveAccountToCodex(); + if (!synced) { + console.warn( + "Backup restored, but Codex CLI auth state could not be synced.", + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + `Backup restored, but Codex CLI auth sync failed: ${ + collapseWhitespace(message) || "unknown error" + }`, + ); + } return "restored"; } catch (error) { const errorLabel = getRedactedFilesystemErrorLabel(error); diff --git a/lib/storage.ts b/lib/storage.ts index ff3b3735..69bc7b88 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1779,10 +1779,10 @@ export async function listNamedBackups(): Promise { function isRetryableFilesystemErrorCode( code: string | undefined, ): code is "EPERM" | "EBUSY" | "EAGAIN" { - if (code === "EBUSY" || code === "EAGAIN") { + if (code === "EAGAIN") { return true; } - return code === "EPERM" && process.platform === "win32"; + return (code === "EPERM" || code === "EBUSY") && process.platform === "win32"; } async function retryTransientFilesystemOperation( diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index c6b519d9..757b4fd5 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -752,7 +752,29 @@ describe("codex manager cli commands", () => { it("restores a named backup from direct auth restore-backup command", async () => { setInteractiveTTY(true); const now = Date.now(); - loadAccountsMock.mockResolvedValue(null); + 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, + }, + ], + }; + let loadCallCount = 0; + loadAccountsMock.mockImplementation(async () => { + loadCallCount += 1; + return loadCallCount === 1 ? null : structuredClone(restoredStorage); + }); + setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); const assessment = { backup: { name: "named-backup", @@ -789,6 +811,14 @@ describe("codex manager cli commands", () => { expect.objectContaining({ currentStorage: null }), ); expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "acc-restored", + email: "restored@example.com", + accessToken: "access-restored", + refreshToken: "refresh-restored", + }), + ); }); it("returns a non-zero exit code when the direct restore-backup command fails", async () => { diff --git a/test/recovery.test.ts b/test/recovery.test.ts index 54f572c8..637cd37f 100644 --- a/test/recovery.test.ts +++ b/test/recovery.test.ts @@ -781,7 +781,7 @@ describe("getActionableNamedBackupRestores (storage-backed paths)", () => { }); describe("resolveStartupRecoveryAction", () => { - it("re-enters the empty storage menu instead of OAuth when backups exist but none are actionable", async () => { + it("falls through to OAuth when backups exist but none are actionable", async () => { const { resolveStartupRecoveryAction } = await import( "../lib/codex-manager.js" ); @@ -791,13 +791,13 @@ describe("resolveStartupRecoveryAction", () => { { assessments: [], totalBackups: 2 }, false, ), - ).toBe("open-empty-storage-menu"); + ).toBe("continue-with-oauth"); expect( resolveStartupRecoveryAction( { assessments: [], totalBackups: 2 }, false, ), - ).not.toBe("continue-with-oauth"); + ).not.toBe("open-empty-storage-menu"); }); it("falls through to OAuth when the startup recovery scan itself failed", async () => { diff --git a/test/storage.test.ts b/test/storage.test.ts index 1a1e1057..475a587c 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1508,6 +1508,24 @@ describe("storage", () => { } }); + it("rethrows EBUSY backup directory errors after one attempt on non-Windows platforms", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("linux"); + const readdirSpy = vi.spyOn(fs, "readdir"); + const error = new Error("backup directory busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + readdirSpy.mockRejectedValue(error); + + try { + await expect(listNamedBackups()).rejects.toMatchObject({ code: "EBUSY" }); + expect(readdirSpy).toHaveBeenCalledTimes(1); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + it("rethrows unreadable backup directory errors while restoring backups", async () => { const readdirSpy = vi.spyOn(fs, "readdir"); const error = new Error("backup directory locked") as NodeJS.ErrnoException; From d92f36e109344c94d8e5b5167665f45e975191f4 Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 17 Mar 2026 20:39:58 +0800 Subject: [PATCH 11/13] fix(startup): restore backup recovery safeguards --- lib/codex-manager.ts | 14 ++-- lib/storage.ts | 96 ++++++++++++++++++++--- test/codex-manager-cli.test.ts | 27 +++---- test/storage.test.ts | 136 ++++++++++++++++++++++++++++++++- 4 files changed, 237 insertions(+), 36 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 5e6ffc45..3110bd76 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -67,7 +67,6 @@ import { getRedactedFilesystemErrorLabel, getNamedBackupsDirectoryPath, listNamedBackups, - NAMED_BACKUP_LIST_CONCURRENCY, restoreNamedBackup, findMatchingAccountIndex, getStoragePath, @@ -4126,8 +4125,12 @@ async function runAuthLogin(): Promise { recoveryState.allAssessments, ); if (restoreResult !== "restored") { - pendingRecoveryState = recoveryState; - recoveryPromptAttempted = false; + if (restoreResult === "dismissed") { + suppressRecoveryPrompt = true; + } else { + pendingRecoveryState = recoveryState; + recoveryPromptAttempted = false; + } } continue; } @@ -4362,6 +4365,7 @@ type BackupRestoreAssessment = Awaited< >; type BackupRestoreManagerResult = "restored" | "dismissed" | "failed"; +const NAMED_BACKUP_ASSESS_CONCURRENCY = 4; async function loadBackupRestoreManagerAssessments(): Promise< BackupRestoreAssessment[] @@ -4387,9 +4391,9 @@ async function loadBackupRestoreManagerAssessments(): Promise< for ( let index = 0; index < backups.length; - index += NAMED_BACKUP_LIST_CONCURRENCY + index += NAMED_BACKUP_ASSESS_CONCURRENCY ) { - const chunk = backups.slice(index, index + NAMED_BACKUP_LIST_CONCURRENCY); + const chunk = backups.slice(index, index + NAMED_BACKUP_ASSESS_CONCURRENCY); const settledAssessments = await Promise.allSettled( chunk.map((backup) => assessNamedBackupRestore(backup.name, { currentStorage }), diff --git a/lib/storage.ts b/lib/storage.ts index 63198f58..7a110e0e 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -140,6 +140,14 @@ export interface BackupRestoreAssessment { error?: string; } +export type ImportAccountsResult = { + imported: number; + total: number; + skipped: number; + // Keep the legacy field in the public type so existing callers do not break. + changed?: boolean; +}; + export interface ActionableNamedBackupRecoveries { assessments: BackupRestoreAssessment[]; allAssessments: BackupRestoreAssessment[]; @@ -1420,6 +1428,44 @@ export function deduplicateAccountsByEmail< return deduplicateAccountsByIdentity(accounts); } +function canonicalizeComparisonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => canonicalizeComparisonValue(entry)); + } + if (!value || typeof value !== "object") { + return value; + } + + const record = value as Record; + return Object.fromEntries( + Object.keys(record) + .sort() + .map((key) => [key, canonicalizeComparisonValue(record[key])] as const), + ); +} + +function stableStringifyForComparison(value: unknown): string { + return JSON.stringify(canonicalizeComparisonValue(value)); +} + +function haveEquivalentAccountRows( + left: readonly unknown[], + right: readonly unknown[], +): boolean { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if ( + stableStringifyForComparison(left[index]) !== + stableStringifyForComparison(right[index]) + ) { + return false; + } + } + return true; +} + function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -1969,6 +2015,10 @@ function assessNamedBackupRestoreCandidate( ...deduplicatedIncomingAccounts, ]); const wouldExceedLimit = mergedAccounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS; + const changed = !haveEquivalentAccountRows( + mergedAccounts, + deduplicatedCurrentAccounts, + ); const imported = wouldExceedLimit ? null : mergedAccounts.length - deduplicatedCurrentAccounts.length; @@ -1983,16 +2033,18 @@ function assessNamedBackupRestoreCandidate( imported, skipped, wouldExceedLimit, - eligibleForRestore: !wouldExceedLimit, + eligibleForRestore: !wouldExceedLimit && changed, error: wouldExceedLimit ? `Restore would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` + : !changed + ? "All accounts in this backup already exist" : undefined, }; } export async function restoreNamedBackup( name: string, -): Promise<{ imported: number; total: number; skipped: number }> { +): Promise { const backupPath = await resolveNamedBackupRestorePath(name); return importAccounts(backupPath); } @@ -2989,7 +3041,7 @@ export async function exportAccounts( */ export async function importAccounts( filePath: string, -): Promise<{ imported: number; total: number; skipped: number }> { +): Promise { const resolvedPath = resolvePath(filePath); let content: string; @@ -3020,9 +3072,11 @@ export async function importAccounts( imported: importedCount, total, skipped: skippedCount, + changed, } = await withAccountStorageTransaction(async (existing, persist) => { const existingAccounts = existing?.accounts ?? []; const existingDeduplicatedAccounts = deduplicateAccounts(existingAccounts); + const incomingDeduplicatedAccounts = deduplicateAccounts(normalized.accounts); const existingActiveIndex = existing?.activeIndex ?? 0; const merged = [...existingAccounts, ...normalized.accounts]; @@ -3037,6 +3091,23 @@ export async function importAccounts( } const deduplicatedAccounts = deduplicateAccounts(merged); + const changed = !haveEquivalentAccountRows( + deduplicatedAccounts, + existingDeduplicatedAccounts, + ); + + const imported = + deduplicatedAccounts.length - existingDeduplicatedAccounts.length; + const skipped = Math.max(0, incomingDeduplicatedAccounts.length - imported); + + if (!changed) { + return { + imported, + total: deduplicatedAccounts.length, + skipped, + changed, + }; + } const newStorage: AccountStorageV3 = { version: 3, @@ -3046,11 +3117,12 @@ export async function importAccounts( }; await persist(newStorage); - - const imported = - deduplicatedAccounts.length - existingDeduplicatedAccounts.length; - const skipped = Math.max(0, normalized.accounts.length - imported); - return { imported, total: deduplicatedAccounts.length, skipped }; + return { + imported, + total: deduplicatedAccounts.length, + skipped, + changed, + }; }); log.info("Imported accounts", { @@ -3058,7 +3130,13 @@ export async function importAccounts( imported: importedCount, skipped: skippedCount, total, + changed, }); - return { imported: importedCount, total, skipped: skippedCount }; + return { + imported: importedCount, + total, + skipped: skippedCount, + changed, + }; } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index a698a212..4d742cbf 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2132,7 +2132,7 @@ describe("codex manager cli commands", () => { allAssessments: [assessment, invalidAssessment], totalBackups: 2, }); - confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + confirmMock.mockResolvedValueOnce(true); selectMock.mockResolvedValueOnce({ type: "back" }); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); await configureSuccessfulOAuthFlow(now); @@ -2142,7 +2142,7 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); - expect(confirmMock).toHaveBeenCalledTimes(2); + expect(confirmMock).toHaveBeenCalledTimes(1); const restoreManagerCall = selectMock.mock.calls.find( ([, options]) => options?.message === "Restore From Backup", ); @@ -2168,7 +2168,7 @@ describe("codex manager cli commands", () => { expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); }); - it("re-prompts startup recovery after backing out of the backup browser", async () => { + it("continues to OAuth after backing out of the startup recovery browser", async () => { setInteractiveTTY(true); const now = Date.now(); let storageState = { @@ -2209,7 +2209,7 @@ describe("codex manager cli commands", () => { }); listNamedBackupsMock.mockResolvedValue([assessment.backup]); assessNamedBackupRestoreMock.mockResolvedValue(assessment); - confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + confirmMock.mockResolvedValueOnce(true); selectMock.mockResolvedValueOnce({ type: "back" }); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); await configureSuccessfulOAuthFlow(now); @@ -2219,13 +2219,13 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); - expect(confirmMock).toHaveBeenCalledTimes(2); + expect(confirmMock).toHaveBeenCalledTimes(1); expect(selectMock).toHaveBeenCalled(); expect(restoreNamedBackupMock).not.toHaveBeenCalled(); expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); }); - it("re-prompts startup recovery after cancelling restore inside the backup browser", async () => { + it("continues to OAuth after cancelling restore inside the startup recovery browser", async () => { setInteractiveTTY(true); const now = Date.now(); let storageState = { @@ -2266,10 +2266,7 @@ describe("codex manager cli commands", () => { }); listNamedBackupsMock.mockResolvedValue([assessment.backup]); assessNamedBackupRestoreMock.mockResolvedValue(assessment); - confirmMock - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(false); + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); selectMock.mockResolvedValueOnce({ type: "restore", assessment }); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); await configureSuccessfulOAuthFlow(now); @@ -2279,7 +2276,7 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); - expect(confirmMock).toHaveBeenCalledTimes(3); + expect(confirmMock).toHaveBeenCalledTimes(2); expect(selectMock).toHaveBeenCalled(); expect(restoreNamedBackupMock).not.toHaveBeenCalled(); expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); @@ -3737,10 +3734,6 @@ describe("codex manager cli commands", () => { it("limits concurrent backup assessments in the restore menu", async () => { setInteractiveTTY(true); loadAccountsMock.mockResolvedValue(null); - const { NAMED_BACKUP_LIST_CONCURRENCY } = - await vi.importActual( - "../lib/storage.js", - ); const backups = Array.from({ length: 9 }, (_value, index) => ({ name: `named-backup-${index + 1}`, path: `/mock/backups/named-backup-${index + 1}.json`, @@ -3805,9 +3798,7 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(assessNamedBackupRestoreMock).toHaveBeenCalledTimes(backups.length); - expect(maxInFlight).toBeLessThanOrEqual( - NAMED_BACKUP_LIST_CONCURRENCY, - ); + expect(maxInFlight).toBeLessThanOrEqual(4); }); it("reassesses a backup before confirmation so the merge summary stays current", async () => { diff --git a/test/storage.test.ts b/test/storage.test.ts index 35b83bcc..62fb1ee3 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -398,6 +398,44 @@ describe("storage", () => { ).rejects.toThrow(/different storage path/); }); + it("allows export inside an active transaction when Windows path casing differs", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "transactional-export-win32", + refreshToken: "ref-transactional-export-win32", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + const caseVariantStoragePath = + testStoragePath.replace(/[a-z]/, (value) => value.toUpperCase()) ?? + testStoragePath; + + try { + await expect( + withAccountStorageTransaction(async () => { + setStoragePathDirect(caseVariantStoragePath); + try { + await exportAccounts(exportPath); + } finally { + setStoragePathDirect(testStoragePath); + } + }), + ).resolves.toBeUndefined(); + expect(existsSync(exportPath)).toBe(true); + } finally { + platformSpy.mockRestore(); + } + }); + it("should import accounts from a file and merge", async () => { // @ts-expect-error const { importAccounts } = await import("../lib/storage.js"); @@ -479,7 +517,12 @@ describe("storage", () => { const imported = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(imported).toEqual({ imported: 1, total: 3, skipped: 0 }); + expect(imported).toEqual({ + imported: 1, + total: 3, + skipped: 0, + changed: true, + }); expect(loaded?.accounts).toHaveLength(3); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual( expect.arrayContaining([ @@ -530,7 +573,12 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); + expect(result).toEqual({ + imported: 1, + skipped: 0, + total: 2, + changed: true, + }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -538,6 +586,47 @@ describe("storage", () => { ]); }); + it("reports changed false when import adds no effective accounts", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "shared-account", + refreshToken: "refresh-shared", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "shared-account", + refreshToken: "refresh-shared", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + + const result = await importAccounts(exportPath); + + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + }); + it("should preserve duplicate shared accountId entries when imported rows lack email", async () => { const { importAccounts } = await import("../lib/storage.js"); await saveAccounts({ @@ -576,7 +665,12 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); + expect(result).toEqual({ + imported: 1, + skipped: 0, + total: 2, + changed: true, + }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -626,7 +720,12 @@ describe("storage", () => { const result = await importAccounts(exportPath); - expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); + expect(result).toEqual({ + imported: 1, + skipped: 0, + total: 2, + changed: true, + }); }); it("should serialize concurrent transactional updates without losing accounts", async () => { @@ -1327,6 +1426,35 @@ describe("storage", () => { expect(restored?.accounts[0]?.accountId).toBe("primary"); }); + it("marks identical named backups as already restored", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await createNamedBackup("already-restored"); + + const assessment = await assessNamedBackupRestore("already-restored"); + expect(assessment.imported).toBe(0); + expect(assessment.skipped).toBe(1); + expect(assessment.eligibleForRestore).toBe(false); + expect(assessment.error).toBe("All accounts in this backup already exist"); + await expect(restoreNamedBackup("already-restored")).resolves.toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + }); + it("honors explicit null currentStorage when assessing a named backup", async () => { await saveAccounts({ version: 3, From dc93cfe3854c0cb0afc611c0f509c03a2f23dd20 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 01:04:44 +0800 Subject: [PATCH 12/13] fix(auth): guard named restore backup retries --- lib/storage.ts | 7 +++++++ test/storage.test.ts | 15 ++++++++------- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 7a110e0e..7ee216a6 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2046,6 +2046,13 @@ export async function restoreNamedBackup( name: string, ): Promise { const backupPath = await resolveNamedBackupRestorePath(name); + const assessment = await assessNamedBackupRestore(name); + if (!assessment.eligibleForRestore) { + if (assessment.backup.loadError) { + return importAccounts(backupPath); + } + throw new Error(assessment.error ?? "Backup is not eligible for restore."); + } return importAccounts(backupPath); } diff --git a/test/storage.test.ts b/test/storage.test.ts index 62fb1ee3..ed87e5d4 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1426,7 +1426,7 @@ describe("storage", () => { expect(restored?.accounts[0]?.accountId).toBe("primary"); }); - it("marks identical named backups as already restored", async () => { + it("rejects duplicate-only named backups as ineligible restore no-ops", async () => { await saveAccounts({ version: 3, activeIndex: 0, @@ -1447,12 +1447,9 @@ describe("storage", () => { expect(assessment.skipped).toBe(1); expect(assessment.eligibleForRestore).toBe(false); expect(assessment.error).toBe("All accounts in this backup already exist"); - await expect(restoreNamedBackup("already-restored")).resolves.toEqual({ - imported: 0, - skipped: 1, - total: 1, - changed: false, - }); + await expect(restoreNamedBackup("already-restored")).rejects.toThrow( + "All accounts in this backup already exist", + ); }); it("honors explicit null currentStorage when assessing a named backup", async () => { @@ -1764,6 +1761,9 @@ describe("storage", () => { }); it("retries transient backup directory errors while listing backups", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); await saveAccounts({ version: 3, activeIndex: 0, @@ -1805,6 +1805,7 @@ describe("storage", () => { expect(busyFailures).toBe(1); } finally { readdirSpy.mockRestore(); + platformSpy.mockRestore(); } }); From c2a894a29e5d31fb4a760764f3ec3e3877076853 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 08:55:56 +0800 Subject: [PATCH 13/13] fix(auth): retry startup recovery after prompt failure --- lib/codex-manager.ts | 2 +- test/codex-manager-cli.test.ts | 75 ++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 3110bd76..46c04d2a 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4113,12 +4113,12 @@ async function runAuthLogin(): Promise { .map((assessment) => assessment.backup.name) .join("") : `${recoveryState.assessments.length} backups`; - promptWasShown = true; const restoreNow = await confirm( `Found ${recoveryState.assessments.length} recoverable backup${ recoveryState.assessments.length === 1 ? "" : "s" } out of ${recoveryState.totalBackups} total (${backupLabel}) in ${backupDir}. Restore now?`, ); + promptWasShown = true; if (restoreNow) { const restoreResult = await runBackupRestoreManager( displaySettings, diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 4d742cbf..fd054690 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -5474,4 +5474,79 @@ describe("codex manager cli commands", () => { expect(saveDashboardDisplaySettingsMock).not.toHaveBeenCalled(); expect(savePluginConfigMock).not.toHaveBeenCalled(); }); + + it("retries startup recovery after the prompt throws before it is shown", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 1, + }); + confirmMock + .mockRejectedValueOnce( + makeErrnoError( + "no such file or directory, open '/mock/settings.json'", + "ENOENT", + ), + ) + .mockResolvedValueOnce(false); + promptAddAnotherAccountMock.mockResolvedValue(false); + await configureSuccessfulOAuthFlow(now); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const firstExitCode = await runCodexMultiAuthCli(["auth", "login"]); + storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + const secondExitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(firstExitCode).toBe(0); + expect(secondExitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(2); + expect(confirmMock).toHaveBeenCalledTimes(2); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledWith( + "Startup recovery prompt failed (ENOENT). Continuing with OAuth.", + ); + } finally { + warnSpy.mockRestore(); + } + }); });