diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index 186ab1f5..cf0747de 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,13 @@ 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. + --- ## oc-chatgpt Target Paths @@ -115,6 +124,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..e155ca9a 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -56,6 +56,12 @@ import { type QuotaCacheEntry, } from "./quota-cache.js"; import { + assessNamedBackupRestore, + assertNamedBackupRestorePath, + importAccounts, + getNamedBackupsDirectoryPath, + listNamedBackups, + NAMED_BACKUP_ASSESS_CONCURRENCY, findMatchingAccountIndex, getStoragePath, loadFlaggedAccounts, @@ -77,6 +83,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 +132,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; @@ -3805,161 +3825,178 @@ 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 modeTouchesQuotaCache = + menuResult.mode === "check" || + menuResult.mode === "deep-check" || + menuResult.mode === "forecast" || + menuResult.mode === "fix"; + if (modeTouchesQuotaCache) { + 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 +4210,201 @@ 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); + 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; + } + + const currentStorage = await loadAccounts(); + const assessments: Awaited>[] = []; + 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; + } + const backupName = chunk[resultIndex]?.name ?? "unknown"; + const reason = + result.reason instanceof Error + ? result.reason.message + : String(result.reason); + console.warn( + `Skipped backup assessment for "${backupName}": ${ + collapseWhitespace(reason) || "unknown error" + }`, + ); + } + } + + 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; + } + + 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; + } + if (!latestAssessment.eligibleForRestore) { + console.log(latestAssessment.error ?? "Backup is not eligible for restore."); + return; + } + + 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; + + try { + const validatedBackupPath = assertNamedBackupRestorePath( + latestAssessment.backup.path, + getNamedBackupsDirectoryPath(), + ); + const result = await importAccounts(validatedBackupPath); + if (!result.changed) { + console.log("All accounts in this backup already exist"); + return; + } + 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" + }`, + ); + } + } 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}`, + ); + } +} + export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { const startupDisplaySettings = await loadDashboardDisplaySettings(); applyUiThemeFromDashboardSettings(startupDisplaySettings); diff --git a/lib/storage.ts b/lib/storage.ts index 3435bf44..0e507feb 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,11 +1,12 @@ 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, promises as fs, 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 +47,12 @@ const ACCOUNTS_WAL_SUFFIX = ".wal"; const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3; const BACKUP_COPY_MAX_ATTEMPTS = 5; const BACKUP_COPY_BASE_DELAY_MS = 10; +const 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 +121,76 @@ 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; + skipped: number | null; + wouldExceedLimit: boolean; + eligibleForRestore: boolean; + error?: string; +} + +type LoadedBackupCandidate = { + normalized: AccountStorageV3 | null; + storedVersion: unknown; + schemaErrors: string[]; + error?: string; +}; + +type NamedBackupCandidateCache = Map; + +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; + }; + return ( + "storedVersion" in typedCandidate && + Array.isArray(typedCandidate.schemaErrors) && + (typedCandidate.normalized === null || + typeof typedCandidate.normalized === "object") && + (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 +245,7 @@ let storageMutex: Promise = Promise.resolve(); const transactionSnapshotContext = new AsyncLocalStorage<{ snapshot: AccountStorageV3 | null; active: boolean; + storagePath: string; }>(); function withStorageLock(fn: () => Promise): Promise { @@ -1551,6 +1629,223 @@ 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() && !entry.isSymbolicLink()) + .filter((entry) => entry.name.toLowerCase().endsWith(".json")); + const backups: NamedBackupMetadata[] = []; + 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; + } + log.warn("Skipped named backup during listing", { + path: join(backupRoot, chunk[chunkIndex]?.name ?? ""), + error: String(result.reason), + }); + } + } + 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" | "ENOTEMPTY" { + if (code === "EBUSY" || code === "ENOTEMPTY" || code === "EAGAIN") { + return true; + } + return code === "EPERM" && process.platform === "win32"; +} + +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() * 10); + 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 ?? []; + + 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 mergedAccounts = deduplicateAccounts([ + ...currentAccounts, + ...candidate.normalized.accounts, + ]); + const wouldExceedLimit = mergedAccounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS; + const imported = wouldExceedLimit + ? null + : mergedAccounts.length - currentAccounts.length; + const skipped = wouldExceedLimit + ? null + : Math.max(0, candidate.normalized.accounts.length - (imported ?? 0)); + const changed = !haveEquivalentAccountRows(mergedAccounts, currentAccounts); + const nothingToImport = !wouldExceedLimit && !changed; + + return { + backup, + currentAccountCount: currentAccounts.length, + mergedAccountCount: mergedAccounts.length, + imported, + skipped, + wouldExceedLimit, + eligibleForRestore: !wouldExceedLimit && !nothingToImport, + error: wouldExceedLimit + ? `Restore would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` + : nothingToImport + ? "All accounts in this backup already exist" + : undefined, + }; +} + +export async function restoreNamedBackup( + name: string, +): Promise { + const assessment = await assessNamedBackupRestore(name); + if (!assessment.eligibleForRestore) { + throw new Error( + assessment.error ?? "Backup is not eligible for restore.", + ); + } + const validatedPath = assertNamedBackupRestorePath( + assessment.backup.path, + getNamedBackupRoot(getStoragePath()), + ); + return importAccounts(validatedPath); +} + function parseAndNormalizeStorage(data: unknown): { normalized: AccountStorageV3 | null; storedVersion: unknown; @@ -1564,6 +1859,59 @@ function parseAndNormalizeStorage(data: unknown): { return { normalized, storedVersion, schemaErrors }; } +export type ImportAccountsResult = { + imported: number; + total: number; + skipped: number; + 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() preserves the existing-first ordering from + // [...currentAccounts, ...incomingAccounts], so index-aligned comparison is + // the correct no-op check for restore assessment/import. + 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; +} + async function loadAccountsFromPath(path: string): Promise<{ normalized: AccountStorageV3 | null; storedVersion: unknown; @@ -1574,6 +1922,151 @@ 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; +} + +export function assertNamedBackupRestorePath( + path: string, + backupRoot: string, +): string { + const resolvedPath = resolvePath(path); + const relativePath = relative(resolvePath(backupRoot), resolvedPath); + const firstSegment = relativePath.split(/[\\/]/)[0]; + if ( + relativePath.length === 0 || + firstSegment === ".." || + isAbsolute(relativePath) + ) { + throw new Error("Backup path escapes backup directory"); + } + return resolvedPath; +} + +function isNamedBackupContainmentError(error: unknown): boolean { + return ( + error instanceof Error && + /escapes backup directory/i.test(error.message) + ); +} + +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(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. + 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 +2275,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 +2454,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 +2481,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 +2881,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 +2927,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 +2935,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 { @@ -2406,22 +2955,33 @@ export async function importAccounts( imported: importedCount, total, skipped: skippedCount, + changed, } = await withAccountStorageTransaction(async (existing, persist) => { const existingAccounts = existing?.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 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 - existingAccounts.length; + const skipped = Math.max(0, normalized.accounts.length - imported); + const changed = !haveEquivalentAccountRows( + deduplicatedAccounts, + existingAccounts, + ); - const deduplicatedAccounts = deduplicateAccounts(merged); + if (!changed) { + return { + imported, + total: deduplicatedAccounts.length, + skipped, + changed, + }; + } const newStorage: AccountStorageV3 = { version: 3, @@ -2431,10 +2991,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", { @@ -2444,5 +3006,10 @@ export async function importAccounts( total, }); - return { imported: importedCount, total, skipped: skippedCount }; + return { + imported: importedCount, + total, + skipped: skippedCount, + changed, + }; } 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..d1ed467d 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1,11 +1,20 @@ +import { resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const MOCK_BACKUP_DIR = resolve(process.cwd(), ".vitest-mock-backups"); +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 importAccountsMock = vi.fn(); const queuedRefreshMock = vi.fn(); const setCodexCliActiveSelectionMock = vi.fn(); const promptAddAnotherAccountMock = vi.fn(); @@ -103,6 +112,10 @@ vi.mock("../lib/storage.js", async () => { withAccountStorageTransaction: withAccountStorageTransactionMock, setStoragePath: setStoragePathMock, getStoragePath: getStoragePathMock, + listNamedBackups: listNamedBackupsMock, + assessNamedBackupRestore: assessNamedBackupRestoreMock, + getNamedBackupsDirectoryPath: getNamedBackupsDirectoryPathMock, + importAccounts: importAccountsMock, exportNamedBackup: exportNamedBackupMock, normalizeAccountStorage: normalizeAccountStorageMock, }; @@ -186,6 +199,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 +470,7 @@ describe("codex manager cli commands", () => { withAccountStorageTransactionMock.mockReset(); queuedRefreshMock.mockReset(); setCodexCliActiveSelectionMock.mockReset(); + setCodexCliActiveSelectionMock.mockResolvedValue(true); promptAddAnotherAccountMock.mockReset(); promptLoginModeMock.mockReset(); fetchCodexQuotaSnapshotMock.mockReset(); @@ -489,6 +509,41 @@ describe("codex manager cli commands", () => { version: 1, accounts: [], }); + listNamedBackupsMock.mockReset(); + assessNamedBackupRestoreMock.mockReset(); + getNamedBackupsDirectoryPathMock.mockReset(); + importAccountsMock.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); + importAccountsMock.mockResolvedValue({ + imported: 1, + skipped: 0, + total: 1, + changed: true, + }); + confirmMock.mockResolvedValue(true); withAccountStorageTransactionMock.mockImplementation( async (handler) => { const current = await loadAccountsMock(); @@ -2314,170 +2369,1387 @@ 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("rejects a restore when the reassessed backup path escapes the backup directory", 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, + }; + const escapedAssessment = { + ...assessment, + backup: { + ...assessment.backup, + path: resolve(MOCK_BACKUP_DIR, "../outside.json"), + }, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock + .mockResolvedValueOnce(assessment) + .mockResolvedValueOnce(escapedAssessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + 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", "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), - ); + 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("shows guidance when experimental oc sync target is ambiguous or unreadable", async () => { + it("offers backup restore from the login menu when no accounts are saved", 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: [] }, - }); - const selectSequence = queueSettingsSelectSequence([ - { type: "experimental" }, - { type: "sync" }, - { 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(planOcChatgptSyncMock).toHaveBeenCalledOnce(); - expect(applyOcChatgptSyncMock).not.toHaveBeenCalled(); + 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("exports named pool backup from experimental settings", async () => { + it("does not restore a named backup when confirmation is declined", 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, + }, + ], + }); + 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(selectSequence.remaining()).toBe(0); - expect(promptQuestionMock).toHaveBeenCalledOnce(); - expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "backup-2026-03-10" }); + 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("rejects invalid or colliding experimental backup filenames", async () => { + it("catches restore failures and returns to the login menu", 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" }, - ]); + 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(() => {}); - 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", "login"]); - expect(exitCode).toBe(0); - expect(selectSequence.remaining()).toBe(0); - expect(promptQuestionMock).toHaveBeenCalledOnce(); - expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "../bad-name" }); + 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("backs out of experimental sync preview without applying", async () => { + it("adds actionable guidance when a confirmed restore exceeds the account limit", 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" } }); - normalizeAccountStorageMock.mockReturnValue({ version: 3, accounts: [], activeIndex: 0 }); - 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: [], toUpdate: [], toSkip: [], unchangedDestinationOnly: [], activeSelectionBehavior: "preserve-destination" }, - payload: { version: 3, accounts: [], activeIndex: 0 }, - destination: { version: 3, accounts: [], activeIndex: 0 }, - }); - const selectSequence = queueSettingsSelectSequence([ - { type: "experimental" }, - { type: "sync" }, - { 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, + }, + ], + }); + 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("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"), + ); + } 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 () => { + 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" } }); + normalizeAccountStorageMock.mockReturnValue({ version: 3, accounts: [], activeIndex: 0 }); + 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: [], toUpdate: [], toSkip: [], unchangedDestinationOnly: [], activeSelectionBehavior: "preserve-destination" }, + payload: { version: 3, accounts: [], activeIndex: 0 }, + destination: { version: 3, accounts: [], activeIndex: 0 }, + }); + const selectSequence = queueSettingsSelectSequence([ + { type: "experimental" }, + { type: "sync" }, + { type: "back" }, + { type: "back" }, { type: "back" }, ]); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -3451,6 +4723,128 @@ 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("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..1221c54b 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2,12 +2,16 @@ 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 { 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 { + assessNamedBackupRestore, buildNamedBackupPath, clearAccounts, clearFlaggedAccounts, + createNamedBackup, deduplicateAccounts, deduplicateAccountsByEmail, exportAccounts, @@ -15,11 +19,14 @@ import { findMatchingAccountIndex, formatStorageErrorHint, getFlaggedAccountsPath, + NAMED_BACKUP_LIST_CONCURRENCY, getStoragePath, importAccounts, + listNamedBackups, loadAccounts, loadFlaggedAccounts, normalizeAccountStorage, + restoreNamedBackup, resolveAccountSelectionIndex, saveFlaggedAccounts, StorageError, @@ -327,7 +334,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 +370,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 +469,137 @@ 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 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 +645,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 +701,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 +752,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,9 +1136,77 @@ 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 clearAccounts(); await expect(exportAccounts(exportPath)).rejects.toThrow( /No accounts to export/, ); @@ -936,6 +1220,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 +1402,1309 @@ 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("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("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 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("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 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: () => true, + 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 backup directory errors while listing backups", async () => { + 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(); + } + }); + + it("retries transient ENOTEMPTY 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 = "ENOTEMPTY"; + 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 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-08", + valid: true, + }), + ]), + ); + expect(busyFailures).toBe(1); + expect( + readFileSpy.mock.calls.filter( + ([path]) => String(path) === secondChunkBackup?.path, + ), + ).toHaveLength(2); + } finally { + readFileSpy.mockRestore(); + readdirSpy.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 backup read errors while listing backups", async () => { + 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(); + } + }); + + 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: "invalid" }], + ]); + + 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)", () => {