diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 36c735f9..f7a9cf0a 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -26,6 +26,7 @@ Compatibility aliases are supported: | `codex auth switch ` | Set active account by index | | `codex auth check` | Run quick account health check | | `codex auth features` | Print implemented feature summary | +| `codex auth restore-backup` | Open the backup restore picker directly | --- @@ -111,6 +112,7 @@ codex auth report --live --json Repair and recovery: ```bash +codex auth restore-backup codex auth fix --dry-run codex auth fix --live --model gpt-5-codex codex auth doctor --fix diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index 865189ff..a76eb85a 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -37,6 +37,12 @@ Compatibility policy for Tier B: - Existing exported symbols must not be removed in this release line. - Deprecated usage may be documented, but hard removals require a major version transition plan. +Current additive compatibility note: + +- `importAccounts()` now returns `{ imported, total, skipped, changed }` at runtime. +- The exported `ImportAccountsResult` type keeps `changed` optional so older callers modeling the legacy shape remain source-compatible. +- New callers should read `changed` to distinguish duplicate-only no-ops from metadata-refresh writes. + ### Tier C: Internal APIs Internal APIs are any non-exported internals and implementation details not covered by Tier A or Tier B. diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index 186ab1f5..8ea743ec 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -22,6 +22,7 @@ Override root: | --- | --- | | Unified settings | `~/.codex/multi-auth/settings.json` | | Accounts | `~/.codex/multi-auth/openai-codex-accounts.json` | +| Named backups | `~/.codex/multi-auth/backups/.json` | | Accounts backup | `~/.codex/multi-auth/openai-codex-accounts.json.bak` | | Accounts WAL | `~/.codex/multi-auth/openai-codex-accounts.json.wal` | | Flagged accounts | `~/.codex/multi-auth/openai-codex-flagged-accounts.json` | @@ -56,6 +57,7 @@ Backup metadata: When project-scoped behavior is enabled: - `~/.codex/multi-auth/projects//openai-codex-accounts.json` +- `~/.codex/multi-auth/projects//backups/.json` `` is derived as: @@ -100,6 +102,17 @@ Rules: - `.rotate.`, `.tmp`, and `.wal` names are rejected - existing files are not overwritten unless a lower-level force path is used explicitly +Restore workflow: + +1. Run `codex auth login`. +2. Open the `Recovery` section. +3. Choose `Restore From Backup`. +4. Pick a backup and confirm the merge summary before import. + +Direct entrypoint: + +- Run `codex auth restore-backup` to open the same picker without entering the full login dashboard first. + --- ## oc-chatgpt Target Paths @@ -115,6 +128,7 @@ Experimental sync targets the companion `oc-chatgpt-multi-auth` storage layout: ## Verification Commands ```bash +codex auth login codex auth status codex auth list ``` diff --git a/lib/cli.ts b/lib/cli.ts index 363b1b2b..67c304db 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -57,6 +57,7 @@ export type LoginMode = | "check" | "deep-check" | "verify-flagged" + | "restore-backup" | "cancel"; export interface ExistingAccountInfo { @@ -233,6 +234,14 @@ async function promptLoginModeFallback( ) { return { mode: "verify-flagged" }; } + if ( + normalized === "u" || + normalized === "backup" || + normalized === "restore" || + normalized === "restore-backup" + ) { + return { mode: "restore-backup" }; + } if (normalized === "q" || normalized === "quit") return { mode: "cancel" }; console.log(UI_COPY.fallback.invalidModePrompt); @@ -287,6 +296,8 @@ export async function promptLoginMode( return { mode: "deep-check" }; case "verify-flagged": return { mode: "verify-flagged" }; + case "restore-backup": + return { mode: "restore-backup" }; case "select-account": { const accountAction = await showAccountDetails(action.account); if (accountAction === "delete") { diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 0f4667b8..0999cc29 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -56,6 +56,13 @@ import { type QuotaCacheEntry, } from "./quota-cache.js"; import { + assessNamedBackupRestore, + getNamedBackupsDirectoryPath, + isNamedBackupContainmentError, + isNamedBackupPathValidationTransientError, + listNamedBackups, + NAMED_BACKUP_ASSESS_CONCURRENCY, + restoreAssessedNamedBackup, findMatchingAccountIndex, getStoragePath, loadFlaggedAccounts, @@ -77,6 +84,7 @@ import { } from "./codex-cli/state.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { ANSI } from "./ui/ansi.js"; +import { confirm } from "./ui/confirm.js"; import { UI_COPY } from "./ui/copy.js"; import { paintUiText, quotaToneFromLeftPercent } from "./ui/format.js"; import { getUiRuntimeOptions } from "./ui/runtime.js"; @@ -125,6 +133,19 @@ function formatReasonLabel(reason: string | undefined): string | undefined { return normalized.length > 0 ? normalized : undefined; } +function formatRelativeDateShort( + timestamp: number | null | undefined, +): string | null { + if (timestamp === null || timestamp === undefined || timestamp === 0) + return null; + if (!Number.isFinite(timestamp)) return null; + const days = Math.floor((Date.now() - timestamp) / 86_400_000); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 7) return `${days}d ago`; + return new Date(timestamp).toLocaleDateString(); +} + function extractErrorMessageFromPayload(payload: unknown): string | undefined { if (!payload || typeof payload !== "object") return undefined; const record = payload as Record; @@ -308,6 +329,7 @@ function printUsage(): void { " codex auth report [--live] [--json] [--model ] [--out ]", " codex auth fix [--dry-run] [--json] [--live] [--model ]", " codex auth doctor [--json] [--fix] [--dry-run]", + " codex auth restore-backup", "", "Notes:", " - Uses ~/.codex/multi-auth/openai-codex-accounts.json", @@ -3805,161 +3827,179 @@ async function runAuthLogin(): Promise { let menuQuotaRefreshStatus: string | undefined; loginFlow: while (true) { - let existingStorage = await loadAccounts(); - if (existingStorage && existingStorage.accounts.length > 0) { - while (true) { - existingStorage = await loadAccounts(); - if (!existingStorage || existingStorage.accounts.length === 0) { - break; - } - const currentStorage = existingStorage; - const displaySettings = await loadDashboardDisplaySettings(); - applyUiThemeFromDashboardSettings(displaySettings); - const quotaCache = await loadQuotaCache(); - const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true; - const showFetchStatus = displaySettings.menuShowFetchStatus ?? true; - const quotaTtlMs = displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; - if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) { - const staleCount = countMenuQuotaRefreshTargets(currentStorage, quotaCache, quotaTtlMs); - if (staleCount > 0) { - if (showFetchStatus) { - menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`; - } - pendingMenuQuotaRefresh = refreshQuotaCacheForMenu( - currentStorage, - quotaCache, - quotaTtlMs, - (current, total) => { - if (!showFetchStatus) return; - menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`; - }, - ) - .then(() => undefined) - .catch(() => undefined) - .finally(() => { - menuQuotaRefreshStatus = undefined; - pendingMenuQuotaRefresh = null; - }); + while (true) { + const existingStorage = await loadAccounts(); + const currentStorage = existingStorage ?? createEmptyAccountStorage(); + const displaySettings = await loadDashboardDisplaySettings(); + applyUiThemeFromDashboardSettings(displaySettings); + const quotaCache = await loadQuotaCache(); + const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true; + const showFetchStatus = displaySettings.menuShowFetchStatus ?? true; + const quotaTtlMs = displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; + if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) { + const staleCount = countMenuQuotaRefreshTargets(currentStorage, quotaCache, quotaTtlMs); + if (staleCount > 0) { + if (showFetchStatus) { + menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`; } + pendingMenuQuotaRefresh = refreshQuotaCacheForMenu( + currentStorage, + quotaCache, + quotaTtlMs, + (current, total) => { + if (!showFetchStatus) return; + menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`; + }, + ) + .then(() => undefined) + .catch(() => undefined) + .finally(() => { + menuQuotaRefreshStatus = undefined; + pendingMenuQuotaRefresh = null; + }); } - const flaggedStorage = await loadFlaggedAccounts(); + } + const flaggedStorage = await loadFlaggedAccounts(); - const menuResult = await promptLoginMode( - toExistingAccountInfo(currentStorage, quotaCache, displaySettings), - { - flaggedCount: flaggedStorage.accounts.length, - statusMessage: showFetchStatus ? () => menuQuotaRefreshStatus : undefined, - }, - ); + const menuResult = await promptLoginMode( + toExistingAccountInfo(currentStorage, quotaCache, displaySettings), + { + flaggedCount: flaggedStorage.accounts.length, + statusMessage: showFetchStatus ? () => menuQuotaRefreshStatus : undefined, + }, + ); - if (menuResult.mode === "cancel") { - console.log("Cancelled."); - return 0; - } - if (menuResult.mode === "check") { - await runActionPanel("Quick Check", "Checking local session + live status", async () => { - await runHealthCheck({ forceRefresh: false, liveProbe: true }); - }, displaySettings); - continue; - } - if (menuResult.mode === "deep-check") { - await runActionPanel("Deep Check", "Refreshing and testing all accounts", async () => { - await runHealthCheck({ forceRefresh: true, liveProbe: true }); - }, displaySettings); - continue; - } - if (menuResult.mode === "forecast") { - await runActionPanel("Best Account", "Comparing accounts", async () => { - await runForecast(["--live"]); - }, displaySettings); - continue; + if (menuResult.mode === "cancel") { + console.log("Cancelled."); + return 0; + } + const modeRequiresDrainedQuotaRefresh = + menuResult.mode === "check" || + menuResult.mode === "deep-check" || + menuResult.mode === "forecast" || + menuResult.mode === "fix" || + menuResult.mode === "restore-backup"; + if (modeRequiresDrainedQuotaRefresh) { + const pendingQuotaRefresh = pendingMenuQuotaRefresh; + if (pendingQuotaRefresh) { + await pendingQuotaRefresh; } - if (menuResult.mode === "fix") { - await runActionPanel("Auto-Fix", "Checking and fixing common issues", async () => { - await runFix(["--live"]); - }, displaySettings); - continue; + } + if (menuResult.mode === "check") { + await runActionPanel("Quick Check", "Checking local session + live status", async () => { + await runHealthCheck({ forceRefresh: false, liveProbe: true }); + }, displaySettings); + continue; + } + if (menuResult.mode === "deep-check") { + await runActionPanel("Deep Check", "Refreshing and testing all accounts", async () => { + await runHealthCheck({ forceRefresh: true, liveProbe: true }); + }, displaySettings); + continue; + } + if (menuResult.mode === "forecast") { + await runActionPanel("Best Account", "Comparing accounts", async () => { + await runForecast(["--live"]); + }, displaySettings); + continue; + } + if (menuResult.mode === "fix") { + await runActionPanel("Auto-Fix", "Checking and fixing common issues", async () => { + await runFix(["--live"]); + }, displaySettings); + continue; + } + if (menuResult.mode === "settings") { + await configureUnifiedSettings(displaySettings); + continue; + } + if (menuResult.mode === "verify-flagged") { + await runActionPanel("Problem Account Check", "Checking problem accounts", async () => { + await runVerifyFlagged([]); + }, displaySettings); + continue; + } + if (menuResult.mode === "restore-backup") { + try { + await runBackupRestoreManager(displaySettings); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + console.error( + `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, + ); } - if (menuResult.mode === "settings") { - await configureUnifiedSettings(displaySettings); + continue; + } + if (menuResult.mode === "fresh" && menuResult.deleteAll) { + if (destructiveActionInFlight) { + console.log("Another destructive action is already running. Wait for it to finish."); continue; } - if (menuResult.mode === "verify-flagged") { - await runActionPanel("Problem Account Check", "Checking problem accounts", async () => { - await runVerifyFlagged([]); - }, displaySettings); - continue; + destructiveActionInFlight = true; + try { + await runActionPanel( + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.label, + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.stage, + async () => { + const result = await deleteSavedAccounts(); + console.log( + result.accountsCleared + ? DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.completed + : "Delete saved accounts completed with warnings. Some saved account artifacts could not be removed; see logs.", + ); + }, + displaySettings, + ); + } finally { + destructiveActionInFlight = false; } - if (menuResult.mode === "fresh" && menuResult.deleteAll) { - if (destructiveActionInFlight) { - console.log("Another destructive action is already running. Wait for it to finish."); - continue; - } - destructiveActionInFlight = true; - try { - await runActionPanel( - DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.label, - DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.stage, - async () => { - const result = await deleteSavedAccounts(); - console.log( - result.accountsCleared - ? DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.completed - : "Delete saved accounts completed with warnings. Some saved account artifacts could not be removed; see logs.", - ); - }, - displaySettings, - ); - } finally { - destructiveActionInFlight = false; - } + continue; + } + if (menuResult.mode === "reset") { + if (destructiveActionInFlight) { + console.log("Another destructive action is already running. Wait for it to finish."); continue; } - if (menuResult.mode === "reset") { - if (destructiveActionInFlight) { - console.log("Another destructive action is already running. Wait for it to finish."); - continue; - } - destructiveActionInFlight = true; - try { - await runActionPanel( - DESTRUCTIVE_ACTION_COPY.resetLocalState.label, - DESTRUCTIVE_ACTION_COPY.resetLocalState.stage, - async () => { - const pendingQuotaRefresh = pendingMenuQuotaRefresh; - if (pendingQuotaRefresh) { - await pendingQuotaRefresh; - } - const result = await resetLocalState(); - console.log( - result.accountsCleared && - result.flaggedCleared && - result.quotaCacheCleared - ? DESTRUCTIVE_ACTION_COPY.resetLocalState.completed - : "Reset local state completed with warnings. Some local artifacts could not be removed; see logs.", - ); - }, - displaySettings, - ); - } finally { - destructiveActionInFlight = false; - } - continue; + destructiveActionInFlight = true; + try { + await runActionPanel( + DESTRUCTIVE_ACTION_COPY.resetLocalState.label, + DESTRUCTIVE_ACTION_COPY.resetLocalState.stage, + async () => { + const pendingQuotaRefresh = pendingMenuQuotaRefresh; + if (pendingQuotaRefresh) { + await pendingQuotaRefresh; + } + const result = await resetLocalState(); + console.log( + result.accountsCleared && + result.flaggedCleared && + result.quotaCacheCleared + ? DESTRUCTIVE_ACTION_COPY.resetLocalState.completed + : "Reset local state completed with warnings. Some local artifacts could not be removed; see logs.", + ); + }, + displaySettings, + ); + } finally { + destructiveActionInFlight = false; } - if (menuResult.mode === "manage") { - const requiresInteractiveOAuth = typeof menuResult.refreshAccountIndex === "number"; - if (requiresInteractiveOAuth) { - await handleManageAction(currentStorage, menuResult); - continue; - } - await runActionPanel("Applying Change", "Updating selected account", async () => { - await handleManageAction(currentStorage, menuResult); - }, displaySettings); + continue; + } + if (menuResult.mode === "manage") { + const requiresInteractiveOAuth = typeof menuResult.refreshAccountIndex === "number"; + if (requiresInteractiveOAuth) { + await handleManageAction(currentStorage, menuResult); continue; } - if (menuResult.mode === "add") { - break; - } + await runActionPanel("Applying Change", "Updating selected account", async () => { + await handleManageAction(currentStorage, menuResult); + }, displaySettings); + continue; + } + if (menuResult.mode === "add") { + break; } } @@ -4173,6 +4213,220 @@ export async function autoSyncActiveAccountToCodex(): Promise { }); } +type BackupMenuAction = + | { + type: "restore"; + assessment: Awaited>; + } + | { type: "back" }; + +async function runBackupRestoreManager( + displaySettings: DashboardDisplaySettings, +): Promise { + const backupDir = getNamedBackupsDirectoryPath(); + // Reuse only within this list -> assess flow so storage.ts can safely treat + // the cache contents as LoadedBackupCandidate entries. + const candidateCache = new Map(); + let backups: Awaited>; + try { + backups = await listNamedBackups({ candidateCache }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isNamedBackupContainmentError(error)) { + console.error( + `Backup validation failed: ${collapseWhitespace(message) || "unknown error"}`, + ); + } else if (isNamedBackupPathValidationTransientError(error)) { + console.error(collapseWhitespace(message) || "unknown error"); + } else { + console.error( + `Could not read backup directory: ${ + collapseWhitespace(message) || "unknown error" + }`, + ); + } + return false; + } + if (backups.length === 0) { + console.log(`No named backups found. Place backup files in ${backupDir}.`); + return true; + } + + const currentStorage = await loadAccounts(); + const assessments: Awaited>[] = []; + const assessmentFailures: string[] = []; + for ( + let index = 0; + index < backups.length; + index += NAMED_BACKUP_ASSESS_CONCURRENCY + ) { + const chunk = backups.slice(index, index + NAMED_BACKUP_ASSESS_CONCURRENCY); + const settledAssessments = await Promise.allSettled( + chunk.map((backup) => + assessNamedBackupRestore(backup.name, { + currentStorage, + candidateCache, + }), + ), + ); + for (const [resultIndex, result] of settledAssessments.entries()) { + if (result.status === "fulfilled") { + assessments.push(result.value); + continue; + } + if (isNamedBackupContainmentError(result.reason)) { + throw result.reason; + } + const backupName = chunk[resultIndex]?.name ?? "unknown"; + const reason = + result.reason instanceof Error + ? result.reason.message + : String(result.reason); + const normalizedReason = + collapseWhitespace(reason) || "unknown error"; + assessmentFailures.push(`${backupName}: ${normalizedReason}`); + console.warn( + `Skipped backup assessment for "${backupName}": ${normalizedReason}`, + ); + } + } + if (assessments.length === 0) { + console.error( + `Could not assess any named backups in ${backupDir}: ${ + assessmentFailures.join("; ") || "all assessments failed" + }`, + ); + return false; + } + + const items: MenuItem[] = assessments.map((assessment) => { + const status = + assessment.eligibleForRestore + ? "ready" + : assessment.wouldExceedLimit + ? "limit" + : "invalid"; + const lastUpdated = formatRelativeDateShort(assessment.backup.updatedAt); + const parts = [ + assessment.backup.accountCount !== null + ? `${assessment.backup.accountCount} account${assessment.backup.accountCount === 1 ? "" : "s"}` + : undefined, + lastUpdated ? `updated ${lastUpdated}` : undefined, + assessment.wouldExceedLimit + ? `would exceed ${ACCOUNT_LIMITS.MAX_ACCOUNTS}` + : undefined, + assessment.error ?? assessment.backup.loadError, + ].filter( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ); + + return { + label: assessment.backup.name, + hint: parts.length > 0 ? parts.join(" | ") : undefined, + value: { type: "restore", assessment }, + color: + status === "ready" ? "green" : status === "limit" ? "red" : "yellow", + disabled: !assessment.eligibleForRestore, + }; + }); + + items.push({ label: "Back", value: { type: "back" } }); + + const ui = getUiRuntimeOptions(); + const selection = await select(items, { + message: "Restore From Backup", + subtitle: backupDir, + help: UI_COPY.mainMenu.helpCompact, + clearScreen: true, + selectedEmphasis: "minimal", + focusStyle: displaySettings.menuFocusStyle ?? "row-invert", + theme: ui.theme, + }); + + if (!selection || selection.type === "back") { + return true; + } + + let latestAssessment: Awaited>; + try { + latestAssessment = await assessNamedBackupRestore( + selection.assessment.backup.name, + { currentStorage: await loadAccounts() }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error( + `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, + ); + return false; + } + if (!latestAssessment.eligibleForRestore) { + console.log(latestAssessment.error ?? "Backup is not eligible for restore."); + return false; + } + + const netNewAccounts = latestAssessment.imported ?? 0; + const confirmMessage = UI_COPY.mainMenu.restoreBackupConfirm( + latestAssessment.backup.name, + netNewAccounts, + latestAssessment.backup.accountCount ?? 0, + latestAssessment.currentAccountCount, + latestAssessment.mergedAccountCount ?? latestAssessment.currentAccountCount, + ); + const confirmed = await confirm(confirmMessage); + if (!confirmed) return true; + + try { + const result = await restoreAssessedNamedBackup(latestAssessment); + if (!result.changed) { + console.log("All accounts in this backup already exist"); + return true; + } + if (result.imported === 0) { + console.log( + UI_COPY.mainMenu.restoreBackupRefreshSuccess( + latestAssessment.backup.name, + ), + ); + } else { + console.log( + UI_COPY.mainMenu.restoreBackupSuccess( + latestAssessment.backup.name, + result.imported, + result.skipped, + result.total, + ), + ); + } + try { + const synced = await autoSyncActiveAccountToCodex(); + if (!synced) { + console.warn( + "Backup restored, but Codex CLI auth state could not be synced.", + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + `Backup restored, but Codex CLI auth sync failed: ${ + collapseWhitespace(message) || "unknown error" + }`, + ); + } + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const collapsedMessage = collapseWhitespace(message) || "unknown error"; + console.error( + /exceed maximum/i.test(collapsedMessage) + ? `Restore failed: ${collapsedMessage}. Close other Codex instances and try again.` + : `Restore failed: ${collapsedMessage}`, + ); + return false; + } +} + export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { const startupDisplaySettings = await loadDashboardDisplaySettings(); applyUiThemeFromDashboardSettings(startupDisplaySettings); @@ -4230,6 +4484,20 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { if (command === "doctor") { return runDoctor(rest); } + if (command === "restore-backup") { + setStoragePath(null); + try { + const completedWithoutFailure = + await runBackupRestoreManager(startupDisplaySettings); + return completedWithoutFailure ? 0 : 1; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error( + `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, + ); + return 1; + } + } console.error(`Unknown command: ${command}`); printUsage(); diff --git a/lib/storage.ts b/lib/storage.ts index 3435bf44..a6f0f5ad 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,11 +1,18 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; -import { existsSync, promises as fs } from "node:fs"; -import { basename, dirname, join } from "node:path"; +import { + existsSync, + lstatSync, + promises as fs, + realpathSync, + type Dirent, +} from "node:fs"; +import { basename, dirname, isAbsolute, join, relative } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { exportNamedBackupFile, + getNamedBackupRoot, resolveNamedBackupPath, } from "./named-backup-export.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; @@ -46,6 +53,15 @@ const ACCOUNTS_WAL_SUFFIX = ".wal"; const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3; const BACKUP_COPY_MAX_ATTEMPTS = 5; const BACKUP_COPY_BASE_DELAY_MS = 10; +// Max total wait across 6 sleeps is about 1.26 s with proportional jitter. +// That's acceptable for transient AV/file-lock recovery, but it also bounds how +// long the interactive restore menu can pause while listing or assessing backups. +const TRANSIENT_FILESYSTEM_MAX_ATTEMPTS = 7; +const TRANSIENT_FILESYSTEM_BASE_DELAY_MS = 10; +export const NAMED_BACKUP_LIST_CONCURRENCY = 8; +// Each assessment does more I/O than a listing pass, so keep a lower ceiling to +// reduce transient AV/file-lock pressure on Windows restore menus. +export const NAMED_BACKUP_ASSESS_CONCURRENCY = 4; const RESET_MARKER_SUFFIX = ".reset-intent"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -114,6 +130,95 @@ export type RestoreAssessment = { backupMetadata: BackupMetadata; }; +export interface NamedBackupMetadata { + name: string; + path: string; + createdAt: number | null; + updatedAt: number | null; + sizeBytes: number | null; + version: number | null; + accountCount: number | null; + schemaErrors: string[]; + valid: boolean; + loadError?: string; +} + +export interface BackupRestoreAssessment { + backup: NamedBackupMetadata; + currentAccountCount: number; + mergedAccountCount: number | null; + imported: number | null; + // Accounts already present in current storage. Metadata-only refreshes can + // still report them here because they are merged rather than newly imported. + skipped: number | null; + wouldExceedLimit: boolean; + eligibleForRestore: boolean; + error?: string; +} + +type LoadedBackupCandidate = { + normalized: AccountStorageV3 | null; + storedVersion: unknown; + schemaErrors: string[]; + error?: string; +}; + +type NamedBackupCandidateCache = Map; + +class BackupContainmentError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "BackupContainmentError"; + } +} + +class BackupPathValidationTransientError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "BackupPathValidationTransientError"; + } +} + +function isLoadedBackupCandidate( + candidate: unknown, +): candidate is LoadedBackupCandidate { + if (!candidate || typeof candidate !== "object") { + return false; + } + const typedCandidate = candidate as { + normalized?: unknown; + storedVersion?: unknown; + schemaErrors?: unknown; + error?: unknown; + }; + const normalized = typedCandidate.normalized; + return ( + "storedVersion" in typedCandidate && + Array.isArray(typedCandidate.schemaErrors) && + (normalized === null || + (typeof normalized === "object" && + normalized !== null && + Array.isArray((normalized as { accounts?: unknown }).accounts))) && + (typedCandidate.error === undefined || + typeof typedCandidate.error === "string") + ); +} + +function getCachedNamedBackupCandidate( + candidateCache: NamedBackupCandidateCache | undefined, + backupPath: string, +): LoadedBackupCandidate | undefined { + const candidate = candidateCache?.get(backupPath); + if (candidate === undefined) { + return undefined; + } + if (isLoadedBackupCandidate(candidate)) { + return candidate; + } + candidateCache?.delete(backupPath); + return undefined; +} + /** * Custom error class for storage operations with platform-aware hints. */ @@ -168,6 +273,7 @@ let storageMutex: Promise = Promise.resolve(); const transactionSnapshotContext = new AsyncLocalStorage<{ snapshot: AccountStorageV3 | null; active: boolean; + storagePath: string; }>(); function withStorageLock(fn: () => Promise): Promise { @@ -1551,6 +1657,249 @@ export async function getRestoreAssessment(): Promise { }; } +export async function listNamedBackups( + options: { candidateCache?: Map } = {}, +): Promise { + const backupRoot = getNamedBackupRoot(getStoragePath()); + const candidateCache = options.candidateCache; + try { + const entries = await retryTransientFilesystemOperation(() => + fs.readdir(backupRoot, { withFileTypes: true }), + ); + const backupEntries = entries + .filter((entry) => entry.isFile()) + .filter((entry) => entry.name.toLowerCase().endsWith(".json")); + const backups: NamedBackupMetadata[] = []; + let transientValidationError: BackupPathValidationTransientError | undefined; + for ( + let index = 0; + index < backupEntries.length; + index += NAMED_BACKUP_LIST_CONCURRENCY + ) { + const chunk = backupEntries.slice( + index, + index + NAMED_BACKUP_LIST_CONCURRENCY, + ); + const chunkResults = await Promise.allSettled( + chunk.map(async (entry) => { + const path = assertNamedBackupRestorePath( + resolvePath(join(backupRoot, entry.name)), + backupRoot, + ); + const candidate = await loadBackupCandidate(path); + candidateCache?.set(path, candidate); + return buildNamedBackupMetadata( + entry.name.slice(0, -".json".length), + path, + { candidate }, + ); + }), + ); + for (const [chunkIndex, result] of chunkResults.entries()) { + if (result.status === "fulfilled") { + backups.push(result.value); + continue; + } + if (isNamedBackupContainmentError(result.reason)) { + throw result.reason; + } + if ( + !transientValidationError && + isNamedBackupPathValidationTransientError(result.reason) + ) { + transientValidationError = result.reason; + } + log.warn("Skipped named backup during listing", { + path: join(backupRoot, chunk[chunkIndex]?.name ?? ""), + error: String(result.reason), + }); + } + } + if (backups.length === 0 && transientValidationError) { + throw transientValidationError; + } + return backups.sort((left, right) => { + // Treat epoch (0), null, and non-finite mtimes as "unknown" so the + // sort order matches the restore hints, which also suppress them. + const leftUpdatedAt = left.updatedAt; + const leftTime = + typeof leftUpdatedAt === "number" && + Number.isFinite(leftUpdatedAt) && + leftUpdatedAt !== 0 + ? leftUpdatedAt + : 0; + const rightUpdatedAt = right.updatedAt; + const rightTime = + typeof rightUpdatedAt === "number" && + Number.isFinite(rightUpdatedAt) && + rightUpdatedAt !== 0 + ? rightUpdatedAt + : 0; + return rightTime - leftTime; + }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return []; + } + log.warn("Failed to list named backups", { + path: backupRoot, + error: String(error), + }); + throw error; + } +} + +function isRetryableFilesystemErrorCode( + code: string | undefined, +): code is "EPERM" | "EBUSY" | "EAGAIN" { + if (code === "EAGAIN") { + return true; + } + if (process.platform !== "win32") { + return false; + } + return code === "EPERM" || code === "EBUSY"; +} + +async function retryTransientFilesystemOperation( + operation: () => Promise, +): Promise { + let attempt = 0; + while (true) { + try { + return await operation(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + !isRetryableFilesystemErrorCode(code) || + attempt >= TRANSIENT_FILESYSTEM_MAX_ATTEMPTS - 1 + ) { + throw error; + } + const baseDelayMs = TRANSIENT_FILESYSTEM_BASE_DELAY_MS * 2 ** attempt; + const jitterMs = Math.floor(Math.random() * baseDelayMs); + await new Promise((resolve) => + setTimeout(resolve, baseDelayMs + jitterMs), + ); + } + attempt += 1; + } +} + +export function getNamedBackupsDirectoryPath(): string { + return getNamedBackupRoot(getStoragePath()); +} + +export async function createNamedBackup( + name: string, + options: { force?: boolean } = {}, +): Promise { + const backupPath = await exportNamedBackup(name, options); + const candidate = await loadBackupCandidate(backupPath); + return buildNamedBackupMetadata( + basename(backupPath).slice(0, -".json".length), + backupPath, + { candidate }, + ); +} + +export async function assessNamedBackupRestore( + name: string, + options: { + currentStorage?: AccountStorageV3 | null; + candidateCache?: Map; + } = {}, +): Promise { + const backupPath = await resolveNamedBackupRestorePath(name); + const candidateCache = options.candidateCache; + const candidate = + getCachedNamedBackupCandidate(candidateCache, backupPath) ?? + (await loadBackupCandidate(backupPath)); + candidateCache?.delete(backupPath); + const backup = await buildNamedBackupMetadata( + basename(backupPath).slice(0, -".json".length), + backupPath, + { candidate }, + ); + const currentStorage = + options.currentStorage !== undefined + ? options.currentStorage + : await loadAccounts(); + const currentAccounts = currentStorage?.accounts ?? []; + // Baseline merge math on a deduplicated current snapshot so pre-existing + // duplicate rows in storage cannot produce negative import counts. + const currentDeduplicatedAccounts = deduplicateAccounts([...currentAccounts]); + + if (!candidate.normalized || !backup.accountCount || backup.accountCount <= 0) { + return { + backup, + currentAccountCount: currentAccounts.length, + mergedAccountCount: null, + imported: null, + skipped: null, + wouldExceedLimit: false, + eligibleForRestore: false, + error: backup.loadError ?? "Backup is empty or invalid", + }; + } + + const incomingDeduplicatedAccounts = deduplicateAccounts([ + ...candidate.normalized.accounts, + ]); + const mergedAccounts = deduplicateAccounts([ + ...currentDeduplicatedAccounts, + ...incomingDeduplicatedAccounts, + ]); + const wouldExceedLimit = mergedAccounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS; + const imported = wouldExceedLimit + ? null + : mergedAccounts.length - currentDeduplicatedAccounts.length; + const skipped = wouldExceedLimit + ? null + : Math.max(0, incomingDeduplicatedAccounts.length - (imported ?? 0)); + const changed = !haveEquivalentAccountRows( + mergedAccounts, + currentDeduplicatedAccounts, + ); + + return { + backup, + currentAccountCount: currentAccounts.length, + mergedAccountCount: mergedAccounts.length, + imported, + skipped, + wouldExceedLimit, + eligibleForRestore: !wouldExceedLimit && changed, + error: wouldExceedLimit + ? `Restore would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` + : !changed + ? "All accounts in this backup already exist" + : undefined, + }; +} + +export async function restoreNamedBackup( + name: string, +): Promise { + const assessment = await assessNamedBackupRestore(name); + return restoreAssessedNamedBackup(assessment); +} + +export async function restoreAssessedNamedBackup( + assessment: Pick, +): Promise { + if (!assessment.eligibleForRestore) { + throw new Error( + assessment.error ?? "Backup is not eligible for restore.", + ); + } + const resolvedPath = await resolveNamedBackupRestorePath( + assessment.backup.name, + ); + return importAccounts(resolvedPath); +} + function parseAndNormalizeStorage(data: unknown): { normalized: AccountStorageV3 | null; storedVersion: unknown; @@ -1564,6 +1913,70 @@ function parseAndNormalizeStorage(data: unknown): { return { normalized, storedVersion, schemaErrors }; } +export type ImportAccountsResult = { + imported: number; + total: number; + skipped: number; + // Runtime always includes this field; it stays optional in the public type so + // older compatibility callers that only model the legacy shape do not break. + changed?: boolean; +}; + +function normalizeStoragePathForComparison(path: string): string { + const resolved = resolvePath(path); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function canonicalizeComparisonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => canonicalizeComparisonValue(entry)); + } + if (!value || typeof value !== "object") { + return value; + } + + const record = value as Record; + return Object.fromEntries( + Object.keys(record) + .sort() + .map((key) => [key, canonicalizeComparisonValue(record[key])] as const), + ); +} + +function stableStringifyForComparison(value: unknown): string { + return JSON.stringify(canonicalizeComparisonValue(value)); +} + +function haveEquivalentAccountRows( + left: readonly unknown[], + right: readonly unknown[], +): boolean { + // deduplicateAccounts() keeps the last occurrence of duplicates, so incoming + // rows win when we compare merged restore data against the current snapshot. + // That keeps index-aligned comparison correct for restore no-op detection. + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if ( + stableStringifyForComparison(left[index]) !== + stableStringifyForComparison(right[index]) + ) { + return false; + } + } + return true; +} + +const namedBackupContainmentFs = { + lstat(path: string) { + return lstatSync(path); + }, + realpath(path: string) { + return realpathSync.native(path); + }, +}; + async function loadAccountsFromPath(path: string): Promise<{ normalized: AccountStorageV3 | null; storedVersion: unknown; @@ -1574,6 +1987,234 @@ async function loadAccountsFromPath(path: string): Promise<{ return parseAndNormalizeStorage(data); } +async function loadBackupCandidate(path: string): Promise { + try { + return await retryTransientFilesystemOperation(() => + loadAccountsFromPath(path), + ); + } catch (error) { + const errorMessage = + error instanceof SyntaxError + ? `Invalid JSON in import file: ${path}` + : (error as NodeJS.ErrnoException).code === "ENOENT" + ? `Import file not found: ${path}` + : error instanceof Error + ? error.message + : String(error); + return { + normalized: null, + storedVersion: undefined, + schemaErrors: [], + error: errorMessage, + }; + } +} + +function equalsNamedBackupEntry(left: string, right: string): boolean { + return process.platform === "win32" + ? left.toLowerCase() === right.toLowerCase() + : left === right; +} + +function stripNamedBackupJsonExtension(name: string): string { + return name.toLowerCase().endsWith(".json") + ? name.slice(0, -".json".length) + : name; +} + +async function findExistingNamedBackupPath( + name: string, +): Promise { + const requested = (name ?? "").trim(); + if (!requested) { + return undefined; + } + + const backupRoot = getNamedBackupRoot(getStoragePath()); + const requestedWithExtension = requested.toLowerCase().endsWith(".json") + ? requested + : `${requested}.json`; + const requestedBaseName = stripNamedBackupJsonExtension(requestedWithExtension); + let entries: Dirent[]; + + try { + entries = await retryTransientFilesystemOperation(() => + fs.readdir(backupRoot, { withFileTypes: true }), + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return undefined; + } + log.warn("Failed to read named backup directory", { + path: backupRoot, + error: String(error), + }); + throw error; + } + + for (const entry of entries) { + if (!entry.name.toLowerCase().endsWith(".json")) continue; + const entryBaseName = stripNamedBackupJsonExtension(entry.name); + const matchesRequestedEntry = + equalsNamedBackupEntry(entry.name, requested) || + equalsNamedBackupEntry(entry.name, requestedWithExtension) || + equalsNamedBackupEntry(entryBaseName, requestedBaseName); + if (!matchesRequestedEntry) { + continue; + } + if (entry.isSymbolicLink() || !entry.isFile()) { + throw new Error( + `Named backup "${entryBaseName}" is not a regular backup file`, + ); + } + return resolvePath(join(backupRoot, entry.name)); + } + + return undefined; +} + +function resolvePathForNamedBackupContainment(path: string): string { + const resolvedPath = resolvePath(path); + let existingPrefix = resolvedPath; + const unresolvedSegments: string[] = []; + while (true) { + try { + namedBackupContainmentFs.lstat(existingPrefix); + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + const parentPath = dirname(existingPrefix); + if (parentPath === existingPrefix) { + return resolvedPath; + } + unresolvedSegments.unshift(basename(existingPrefix)); + existingPrefix = parentPath; + continue; + } + if (isRetryableFilesystemErrorCode(code)) { + throw new BackupPathValidationTransientError( + "Backup path validation failed. Try again.", + { cause: error instanceof Error ? error : undefined }, + ); + } + throw error; + } + } + try { + const canonicalPrefix = namedBackupContainmentFs.realpath(existingPrefix); + return unresolvedSegments.reduce( + (currentPath, segment) => join(currentPath, segment), + canonicalPrefix, + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return resolvedPath; + } + if (isRetryableFilesystemErrorCode(code)) { + throw new BackupPathValidationTransientError( + "Backup path validation failed. Try again.", + { cause: error instanceof Error ? error : undefined }, + ); + } + throw error; + } +} + +export function assertNamedBackupRestorePath( + path: string, + backupRoot: string, +): string { + const resolvedPath = resolvePath(path); + const resolvedBackupRoot = resolvePath(backupRoot); + let backupRootIsSymlink = false; + try { + backupRootIsSymlink = + namedBackupContainmentFs.lstat(resolvedBackupRoot).isSymbolicLink(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + backupRootIsSymlink = false; + } else if (isRetryableFilesystemErrorCode(code)) { + throw new BackupPathValidationTransientError( + "Backup path validation failed. Try again.", + { cause: error instanceof Error ? error : undefined }, + ); + } else { + throw error; + } + } + if (backupRootIsSymlink) { + throw new BackupContainmentError("Backup path escapes backup directory"); + } + const canonicalBackupRoot = + resolvePathForNamedBackupContainment(resolvedBackupRoot); + const containedPath = resolvePathForNamedBackupContainment(resolvedPath); + const relativePath = relative(canonicalBackupRoot, containedPath); + const firstSegment = relativePath.split(/[\\/]/)[0]; + if ( + relativePath.length === 0 || + firstSegment === ".." || + isAbsolute(relativePath) + ) { + throw new BackupContainmentError("Backup path escapes backup directory"); + } + return containedPath; +} + +export function isNamedBackupContainmentError(error: unknown): boolean { + return ( + error instanceof BackupContainmentError || + (error instanceof Error && /escapes backup directory/i.test(error.message)) + ); +} + +export function isNamedBackupPathValidationTransientError( + error: unknown, +): error is BackupPathValidationTransientError { + return ( + error instanceof BackupPathValidationTransientError || + (error instanceof Error && + /^Backup path validation failed(\.|:|\b)/i.test(error.message)) + ); +} + +export async function resolveNamedBackupRestorePath(name: string): Promise { + const requested = (name ?? "").trim(); + const backupRoot = getNamedBackupRoot(getStoragePath()); + const existingPath = await findExistingNamedBackupPath(name); + if (existingPath) { + return assertNamedBackupRestorePath(existingPath, backupRoot); + } + const requestedWithExtension = requested.toLowerCase().endsWith(".json") + ? requested + : `${requested}.json`; + const baseName = requestedWithExtension.slice(0, -".json".length); + let builtPath: string; + try { + builtPath = buildNamedBackupPath(requested); + } catch (error) { + // buildNamedBackupPath rejects names with special characters even when the + // requested backup name is a plain filename inside the backups directory. + // In that case, reporting ENOENT is clearer than surfacing the filename + // validator, but only when no separator/traversal token is present. + if ( + requested.length > 0 && + basename(requestedWithExtension) === requestedWithExtension && + !requestedWithExtension.includes("..") && + !/^[A-Za-z0-9_-]+$/.test(baseName) + ) { + throw new Error( + `Import file not found: ${resolvePath(join(backupRoot, requestedWithExtension))}`, + ); + } + throw error; + } + return assertNamedBackupRestorePath(builtPath, backupRoot); +} + async function loadAccountsFromJournal( path: string, ): Promise { @@ -1782,6 +2423,50 @@ async function loadAccountsInternal( } } +async function buildNamedBackupMetadata( + name: string, + path: string, + opts: { candidate?: Awaited> } = {}, +): Promise { + const candidate = opts.candidate ?? (await loadBackupCandidate(path)); + let stats: { + size?: number; + mtimeMs?: number; + birthtimeMs?: number; + ctimeMs?: number; + } | null = null; + try { + stats = await retryTransientFilesystemOperation(() => fs.stat(path)); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to stat named backup", { path, error: String(error) }); + } + } + + const version = + candidate.normalized?.version ?? + (typeof candidate.storedVersion === "number" + ? candidate.storedVersion + : null); + const accountCount = candidate.normalized?.accounts.length ?? null; + const createdAt = stats?.birthtimeMs ?? stats?.ctimeMs ?? null; + const updatedAt = stats?.mtimeMs ?? null; + + return { + name, + path, + createdAt, + updatedAt, + sizeBytes: typeof stats?.size === "number" ? stats.size : null, + version, + accountCount, + schemaErrors: candidate.schemaErrors, + valid: !!candidate.normalized, + loadError: candidate.error, + }; +} + async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { const path = getStoragePath(); const resetMarkerPath = getIntentionalResetMarkerPath(path); @@ -1917,9 +2602,11 @@ export async function withAccountStorageTransaction( ) => Promise, ): Promise { return withStorageLock(async () => { + const storagePath = getStoragePath(); const state = { snapshot: await loadAccountsInternal(saveAccountsUnlocked), active: true, + storagePath, }; const current = state.snapshot; const persist = async (storage: AccountStorageV3): Promise => { @@ -1942,9 +2629,11 @@ export async function withAccountAndFlaggedStorageTransaction( ) => Promise, ): Promise { return withStorageLock(async () => { + const storagePath = getStoragePath(); const state = { snapshot: await loadAccountsInternal(saveAccountsUnlocked), active: true, + storagePath, }; const current = state.snapshot; const persist = async ( @@ -2340,11 +3029,17 @@ export async function exportAccounts( } const transactionState = transactionSnapshotContext.getStore(); + const currentStoragePath = normalizeStoragePathForComparison(getStoragePath()); const storage = transactionState?.active - ? transactionState.snapshot - : await withAccountStorageTransaction((current) => - Promise.resolve(current), - ); + ? normalizeStoragePathForComparison(transactionState.storagePath) === + currentStoragePath + ? transactionState.snapshot + : (() => { + throw new Error( + "exportAccounts called inside an active transaction for a different storage path", + ); + })() + : await withAccountStorageTransaction((current) => Promise.resolve(current)); if (!storage || storage.accounts.length === 0) { throw new Error("No accounts to export"); } @@ -2380,7 +3075,7 @@ export async function exportAccounts( */ export async function importAccounts( filePath: string, -): Promise<{ imported: number; total: number; skipped: number }> { +): Promise { const resolvedPath = resolvePath(filePath); // Check file exists with friendly error @@ -2388,7 +3083,17 @@ export async function importAccounts( throw new Error(`Import file not found: ${resolvedPath}`); } - const content = await fs.readFile(resolvedPath, "utf-8"); + let content: string; + try { + content = await retryTransientFilesystemOperation(() => + fs.readFile(resolvedPath, "utf-8"), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(`Import file not found: ${resolvedPath}`); + } + throw error; + } let imported: unknown; try { @@ -2406,22 +3111,48 @@ export async function importAccounts( imported: importedCount, total, skipped: skippedCount, + changed, } = await withAccountStorageTransaction(async (existing, persist) => { const existingAccounts = existing?.accounts ?? []; + // Keep import counts anchored to a deduplicated current snapshot for the + // same reason as assessNamedBackupRestore. + const existingDeduplicatedAccounts = deduplicateAccounts([ + ...existingAccounts, + ]); + const incomingDeduplicatedAccounts = deduplicateAccounts([ + ...normalized.accounts, + ]); const existingActiveIndex = existing?.activeIndex ?? 0; - const merged = [...existingAccounts, ...normalized.accounts]; - - if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { - const deduped = deduplicateAccounts(merged); - if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { - throw new Error( - `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduped.length})`, - ); - } + const merged = [ + ...existingDeduplicatedAccounts, + ...incomingDeduplicatedAccounts, + ]; + const deduplicatedAccounts = deduplicateAccounts(merged); + if (deduplicatedAccounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { + throw new Error( + `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduplicatedAccounts.length})`, + ); } + const imported = + deduplicatedAccounts.length - existingDeduplicatedAccounts.length; + const skipped = Math.max( + 0, + incomingDeduplicatedAccounts.length - imported, + ); + const changed = !haveEquivalentAccountRows( + deduplicatedAccounts, + existingDeduplicatedAccounts, + ); - const deduplicatedAccounts = deduplicateAccounts(merged); + if (!changed) { + return { + imported, + total: deduplicatedAccounts.length, + skipped, + changed, + }; + } const newStorage: AccountStorageV3 = { version: 3, @@ -2431,10 +3162,12 @@ export async function importAccounts( }; await persist(newStorage); - - const imported = deduplicatedAccounts.length - existingAccounts.length; - const skipped = normalized.accounts.length - imported; - return { imported, total: deduplicatedAccounts.length, skipped }; + return { + imported, + total: deduplicatedAccounts.length, + skipped, + changed, + }; }); log.info("Imported accounts", { @@ -2442,7 +3175,17 @@ export async function importAccounts( imported: importedCount, skipped: skippedCount, total, + changed, }); - return { imported: importedCount, total, skipped: skippedCount }; + return { + imported: importedCount, + total, + skipped: skippedCount, + changed, + }; } + +export const __testOnly = { + namedBackupContainmentFs, +}; diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index fbe9293a..5f77ad62 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -64,6 +64,7 @@ export type AuthMenuAction = | { type: "check" } | { type: "deep-check" } | { type: "verify-flagged" } + | { type: "restore-backup" } | { type: "select-account"; account: AccountInfo } | { type: "set-current-account"; account: AccountInfo } | { type: "refresh-account"; account: AccountInfo } @@ -517,6 +518,7 @@ function authMenuFocusKey(action: AuthMenuAction): string { case "check": case "deep-check": case "verify-flagged": + case "restore-backup": case "search": case "delete-all": case "cancel": @@ -655,6 +657,17 @@ export async function showAuthMenu( ); } + items.push({ label: "", value: { type: "cancel" }, separator: true }); + items.push({ + label: UI_COPY.mainMenu.recovery, + value: { type: "cancel" }, + kind: "heading", + }); + items.push({ + label: UI_COPY.mainMenu.restoreBackup, + value: { type: "restore-backup" }, + color: "yellow", + }); items.push({ label: "", value: { type: "cancel" }, separator: true }); items.push({ label: UI_COPY.mainMenu.dangerZone, diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index b4505e8e..1b14d107 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -14,6 +14,27 @@ export const UI_COPY = { accounts: "Saved Accounts", loadingLimits: "Fetching account limits...", noSearchMatches: "No accounts match your search", + recovery: "Recovery", + restoreBackup: "Restore From Backup", + restoreBackupConfirm: ( + name: string, + netNewAccounts: number, + backupAccountCount: number, + currentAccountCount: number, + mergedAccountCount: number, + ) => + netNewAccounts === 0 + ? `Restore backup "${name}"? This will refresh stored metadata for matching existing account(s) in this backup.` + : `Restore backup "${name}"? This will add ${netNewAccounts} new account(s) (${backupAccountCount} in backup, ${currentAccountCount} current -> ${mergedAccountCount} after dedupe).`, + restoreBackupSuccess: ( + name: string, + imported: number, + skipped: number, + total: number, + ) => + `Restored backup "${name}". Imported ${imported}, skipped ${skipped}, total ${total}.`, + restoreBackupRefreshSuccess: (name: string) => + `Restored backup "${name}". Refreshed stored metadata for matching existing account(s).`, dangerZone: "Danger Zone", removeAllAccounts: "Delete Saved Accounts", resetLocalState: "Reset Local State", @@ -131,8 +152,8 @@ export const UI_COPY = { addAnotherQuestion: (count: number) => `Add another account? (${count} added) (y/n): `, selectModePrompt: - "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (f) fresh, (r) reset, (q) back [a/c/b/x/s/d/g/f/r/q]: ", - invalidModePrompt: "Use one of: a, c, b, x, s, d, g, f, r, q.", + "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (u) restore backup, (f) fresh, (r) reset, (q) back [a/c/b/x/s/d/g/u/f/r/q]: ", + invalidModePrompt: "Use one of: a, c, b, x, s, d, g, u, f, r, q.", }, } as const; diff --git a/test/cli.test.ts b/test/cli.test.ts index a2750841..efbffdce 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -704,6 +704,30 @@ describe("CLI Module", () => { }); }); + it("returns restore-backup for fallback restore aliases", async () => { + const { promptLoginMode } = await import("../lib/cli.js"); + + mockRl.question.mockResolvedValueOnce("u"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); + + mockRl.question.mockResolvedValueOnce("restore"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); + + mockRl.question.mockResolvedValueOnce("backup"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); + + mockRl.question.mockResolvedValueOnce("restore-backup"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); + }); + it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { delete process.env.FORCE_INTERACTIVE_MODE; const { stdin, stdout } = await import("node:process"); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 6358465e..1ab460dd 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1,11 +1,25 @@ +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const MOCK_BACKUP_DIR = fileURLToPath( + new URL("./.vitest-mock-backups", import.meta.url), +); +const mockBackupPath = (name: string): string => + resolve(MOCK_BACKUP_DIR, `${name}.json`); + const loadAccountsMock = vi.fn(); const loadFlaggedAccountsMock = vi.fn(); const saveAccountsMock = vi.fn(); const saveFlaggedAccountsMock = vi.fn(); const setStoragePathMock = vi.fn(); const getStoragePathMock = vi.fn(() => "/mock/openai-codex-accounts.json"); +const listNamedBackupsMock = vi.fn(); +const assessNamedBackupRestoreMock = vi.fn(); +const getNamedBackupsDirectoryPathMock = vi.fn(); +const resolveNamedBackupRestorePathMock = vi.fn(); +const importAccountsMock = vi.fn(); +const restoreAssessedNamedBackupMock = vi.fn(); const queuedRefreshMock = vi.fn(); const setCodexCliActiveSelectionMock = vi.fn(); const promptAddAnotherAccountMock = vi.fn(); @@ -103,6 +117,12 @@ vi.mock("../lib/storage.js", async () => { withAccountStorageTransaction: withAccountStorageTransactionMock, setStoragePath: setStoragePathMock, getStoragePath: getStoragePathMock, + listNamedBackups: listNamedBackupsMock, + assessNamedBackupRestore: assessNamedBackupRestoreMock, + getNamedBackupsDirectoryPath: getNamedBackupsDirectoryPathMock, + resolveNamedBackupRestorePath: resolveNamedBackupRestorePathMock, + importAccounts: importAccountsMock, + restoreAssessedNamedBackup: restoreAssessedNamedBackupMock, exportNamedBackup: exportNamedBackupMock, normalizeAccountStorage: normalizeAccountStorageMock, }; @@ -186,6 +206,12 @@ vi.mock("../lib/ui/select.js", () => ({ select: selectMock, })); +const confirmMock = vi.fn(); + +vi.mock("../lib/ui/confirm.js", () => ({ + confirm: confirmMock, +})); + vi.mock("../lib/oc-chatgpt-orchestrator.js", () => ({ planOcChatgptSync: planOcChatgptSyncMock, applyOcChatgptSync: applyOcChatgptSyncMock, @@ -451,6 +477,7 @@ describe("codex manager cli commands", () => { withAccountStorageTransactionMock.mockReset(); queuedRefreshMock.mockReset(); setCodexCliActiveSelectionMock.mockReset(); + setCodexCliActiveSelectionMock.mockResolvedValue(true); promptAddAnotherAccountMock.mockReset(); promptLoginModeMock.mockReset(); fetchCodexQuotaSnapshotMock.mockReset(); @@ -489,6 +516,51 @@ describe("codex manager cli commands", () => { version: 1, accounts: [], }); + listNamedBackupsMock.mockReset(); + assessNamedBackupRestoreMock.mockReset(); + getNamedBackupsDirectoryPathMock.mockReset(); + resolveNamedBackupRestorePathMock.mockReset(); + importAccountsMock.mockReset(); + restoreAssessedNamedBackupMock.mockReset(); + confirmMock.mockReset(); + listNamedBackupsMock.mockResolvedValue([]); + assessNamedBackupRestoreMock.mockResolvedValue({ + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }); + getNamedBackupsDirectoryPathMock.mockReturnValue(MOCK_BACKUP_DIR); + resolveNamedBackupRestorePathMock.mockImplementation(async (name: string) => + mockBackupPath(name), + ); + importAccountsMock.mockResolvedValue({ + imported: 1, + skipped: 0, + total: 1, + changed: true, + }); + restoreAssessedNamedBackupMock.mockImplementation(async (assessment) => + importAccountsMock( + await resolveNamedBackupRestorePathMock(assessment.backup.name), + ), + ); + confirmMock.mockResolvedValue(true); withAccountStorageTransactionMock.mockImplementation( async (handler) => { const current = await loadAccountsMock(); @@ -2314,151 +2386,1657 @@ describe("codex manager cli commands", () => { ); }); - it("shows experimental settings in the settings hub", async () => { + it("restores a named backup from the login recovery menu", async () => { + setInteractiveTTY(true); const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - queueSettingsSelectSequence([{ type: "back" }]); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(readSettingsHubPanelContract()).toEqual(SETTINGS_HUB_MENU_ORDER); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce(); }); - it("runs experimental oc sync with mandatory preview before apply", async () => { + it("restores a named backup from the direct restore-backup command", async () => { + setInteractiveTTY(true); const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); - planOcChatgptSyncMock.mockResolvedValue({ - kind: "ready", - target: { - scope: "global", - root: "C:/target", - accountPath: "C:/target/openai-codex-accounts.json", - backupRoot: "C:/target/backups", - source: "default-global", - resolution: "accounts", - }, - preview: { - payload: { version: 3, accounts: [], activeIndex: 0 }, - merged: { version: 3, accounts: [], activeIndex: 0 }, - toAdd: [{ refreshTokenLast4: "1234" }], - toUpdate: [], - toSkip: [], - unchangedDestinationOnly: [], - activeSelectionBehavior: "preserve-destination", - }, - payload: { version: 3, accounts: [], activeIndex: 0 }, - destination: null, + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], }); - applyOcChatgptSyncMock.mockResolvedValue({ - kind: "applied", - target: { - scope: "global", - root: "C:/target", - accountPath: "C:/target/openai-codex-accounts.json", - backupRoot: "C:/target/backups", - source: "default-global", - resolution: "accounts", + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, }, - preview: { merged: { version: 3, accounts: [], activeIndex: 0 } }, - merged: { version: 3, accounts: [], activeIndex: 0 }, - destination: null, - persistedPath: "C:/target/openai-codex-accounts.json", - }); - const selectSequence = queueSettingsSelectSequence([ - { type: "experimental" }, - { type: "sync" }, - { type: "apply" }, - { type: "back" }, - { type: "back" }, - { type: "back" }, - ]); + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); expect(exitCode).toBe(0); - expect(selectSequence.remaining()).toBe(0); - expect(planOcChatgptSyncMock).toHaveBeenCalledOnce(); - expect(applyOcChatgptSyncMock).toHaveBeenCalledOnce(); - expect(selectMock).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ label: expect.stringContaining("Active selection: preserve-destination") }), - ]), - expect.any(Object), + expect(setStoragePathMock).toHaveBeenCalledWith(null); + expect(promptLoginModeMock).not.toHaveBeenCalled(); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), ); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce(); }); - it("shows guidance when experimental oc sync target is ambiguous or unreadable", async () => { + it("returns a non-zero exit code when the direct restore-backup command fails", async () => { + setInteractiveTTY(true); const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); - planOcChatgptSyncMock.mockResolvedValue({ - kind: "blocked-ambiguous", - detection: { kind: "ambiguous", reason: "multiple targets", candidates: [] }, + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], }); - const selectSequence = queueSettingsSelectSequence([ - { type: "experimental" }, - { type: "sync" }, - { type: "back" }, - { type: "back" }, - { type: "back" }, - ]); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + importAccountsMock.mockRejectedValueOnce(new Error("backup locked")); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); - expect(exitCode).toBe(0); - expect(selectSequence.remaining()).toBe(0); - expect(planOcChatgptSyncMock).toHaveBeenCalledOnce(); - expect(applyOcChatgptSyncMock).not.toHaveBeenCalled(); + expect(exitCode).toBe(1); + expect(promptLoginModeMock).not.toHaveBeenCalled(); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith("Restore failed: backup locked"); + } finally { + errorSpy.mockRestore(); + } }); - - it("exports named pool backup from experimental settings", async () => { + it("returns a non-zero exit code when every direct restore assessment fails", async () => { + setInteractiveTTY(true); const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - promptQuestionMock.mockResolvedValueOnce("backup-2026-03-10"); - runNamedBackupExportMock.mockResolvedValueOnce({ kind: "exported", path: "/mock/backups/backup-2026-03-10.json" }); - const selectSequence = queueSettingsSelectSequence([ - { type: "experimental" }, - { type: "backup" }, - { type: "back" }, - { type: "back" }, - { type: "back" }, + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + listNamedBackupsMock.mockResolvedValue([ + { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, ]); + assessNamedBackupRestoreMock.mockRejectedValueOnce( + makeErrnoError("backup busy", "EBUSY"), + ); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + + expect(exitCode).toBe(1); + expect(selectMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Could not assess any named backups in"), + ); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("named-backup: backup busy"), + ); + } finally { + errorSpy.mockRestore(); + } + }); - expect(exitCode).toBe(0); - expect(selectSequence.remaining()).toBe(0); - expect(promptQuestionMock).toHaveBeenCalledOnce(); - expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "backup-2026-03-10" }); + it("rejects a restore when the backup root changes before the final import path check", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + resolveNamedBackupRestorePathMock.mockRejectedValueOnce( + new Error("Backup path escapes backup directory"), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(errorSpy).toHaveBeenCalledWith( + "Restore failed: Backup path escapes backup directory", + ); + } finally { + errorSpy.mockRestore(); + } }); - it("rejects invalid or colliding experimental backup filenames", async () => { + it("offers backup restore from the login menu when no accounts are saved", async () => { + setInteractiveTTY(true); const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - promptQuestionMock.mockResolvedValueOnce("../bad-name"); - runNamedBackupExportMock.mockResolvedValueOnce({ kind: "collision", path: "/mock/backups/bad-name.json" }); - const selectSequence = queueSettingsSelectSequence([ - { type: "experimental" }, - { type: "backup" }, - { type: "back" }, - { type: "back" }, - { type: "back" }, - ]); + const restoredStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "restored@example.com", + accountId: "acc_restored", + refreshToken: "refresh-restored", + accessToken: "access-restored", + expiresAt: now + 3_600_000, + addedAt: now - 500, + lastUsed: now - 500, + enabled: true, + }, + ], + }; + loadAccountsMock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValue(restoredStorage); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(selectSequence.remaining()).toBe(0); - expect(promptQuestionMock).toHaveBeenCalledOnce(); - expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "../bad-name" }); + expect(promptLoginModeMock.mock.calls[0]?.[0]).toEqual([]); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ currentStorage: null }), + ); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "acc_restored", + email: "restored@example.com", + refreshToken: "refresh-restored", + accessToken: "access-restored", + }), + ); + }); + + it("does not restore a named backup when confirmation is declined", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + confirmMock.mockResolvedValueOnce(false); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(importAccountsMock).not.toHaveBeenCalled(); + }); + + it("catches restore failures and returns to the login menu", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + importAccountsMock.mockRejectedValueOnce( + new Error(`Import file not found: ${mockBackupPath("named-backup")}`), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + const restoreFailureCalls = [ + ...errorSpy.mock.calls, + ...logSpy.mock.calls, + ].flat(); + expect(restoreFailureCalls).toContainEqual( + expect.stringContaining("Restore failed: Import file not found"), + ); + } finally { + errorSpy.mockRestore(); + logSpy.mockRestore(); + } + }); + + it("adds actionable guidance when a confirmed restore exceeds the account limit", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + importAccountsMock.mockRejectedValueOnce( + new Error("Import would exceed maximum of 10 accounts (would have 11)"), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(errorSpy).toHaveBeenCalledWith( + "Restore failed: Import would exceed maximum of 10 accounts (would have 11). Close other Codex instances and try again.", + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it("treats post-confirm duplicate-only restores as a no-op", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + importAccountsMock.mockResolvedValueOnce({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + expect(logSpy).toHaveBeenCalledWith( + "All accounts in this backup already exist", + ); + expect(logSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Restored backup "named-backup"'), + ); + } finally { + logSpy.mockRestore(); + } + }); + + it("catches backup listing failures and returns to the login menu", async () => { + setInteractiveTTY(true); + listNamedBackupsMock.mockRejectedValueOnce( + makeErrnoError( + "EPERM: operation not permitted, scandir '/mock/backups'", + "EPERM", + ), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Could not read backup directory: EPERM: operation not permitted", + ), + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it("reports backup validation failures separately from directory read failures", async () => { + setInteractiveTTY(true); + listNamedBackupsMock.mockRejectedValueOnce( + new Error("Backup path escapes backup directory"), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Backup validation failed: Backup path escapes backup directory", + ), + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it("surfaces transient backup path validation failures with retry guidance", async () => { + setInteractiveTTY(true); + listNamedBackupsMock.mockRejectedValueOnce( + new Error("Backup path validation failed. Try again."), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + "Backup path validation failed. Try again.", + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it("propagates containment errors from batch backup assessment and returns to the login menu", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const now = Date.now(); + listNamedBackupsMock.mockResolvedValue([ + { + name: "escaped-backup", + path: mockBackupPath("escaped-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + ]); + assessNamedBackupRestoreMock.mockRejectedValueOnce( + new Error("Backup path escapes backup directory"), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(selectMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Restore failed: Backup path escapes backup directory", + ), + ); + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Skipped backup assessment for "escaped-backup"'), + ); + } finally { + errorSpy.mockRestore(); + warnSpy.mockRestore(); + } + }); + + it("keeps healthy backups selectable when one assessment fails", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const now = Date.now(); + const healthyAssessment = { + backup: { + name: "healthy-backup", + path: mockBackupPath("healthy-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([ + { + ...healthyAssessment.backup, + name: "broken-backup", + path: mockBackupPath("broken-backup"), + }, + healthyAssessment.backup, + ]); + assessNamedBackupRestoreMock.mockImplementation(async (name: string) => { + if (name === "broken-backup") { + throw new Error("backup directory busy"); + } + return healthyAssessment; + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockImplementationOnce(async (items) => { + const labels = items.map((item) => item.label); + expect(labels).toContain("healthy-backup"); + expect(labels).not.toContain("broken-backup"); + return { type: "restore", assessment: healthyAssessment }; + }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("healthy-backup"), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Skipped backup assessment for "broken-backup": backup directory busy', + ), + ); + } finally { + warnSpy.mockRestore(); + } + }); + + it("limits concurrent backup assessments in the restore menu", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const { NAMED_BACKUP_ASSESS_CONCURRENCY } = + await vi.importActual( + "../lib/storage.js", + ); + const totalBackups = NAMED_BACKUP_ASSESS_CONCURRENCY + 3; + const backups = Array.from({ length: totalBackups }, (_value, index) => ({ + name: `named-backup-${index + 1}`, + path: mockBackupPath(`named-backup-${index + 1}`), + createdAt: null, + updatedAt: Date.now() + index, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + })); + const backupsByName = new Map(backups.map((backup) => [backup.name, backup])); + let inFlight = 0; + let maxInFlight = 0; + let pending: Array>> = []; + let releaseScheduled = false; + const releasePending = () => { + if (releaseScheduled) { + return; + } + releaseScheduled = true; + queueMicrotask(() => { + releaseScheduled = false; + if (pending.length === 0) { + return; + } + const release = pending; + pending = []; + for (const deferred of release) { + deferred.resolve(); + } + }); + }; + listNamedBackupsMock.mockResolvedValue(backups); + assessNamedBackupRestoreMock.mockImplementation(async (name: string) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + const gate = createDeferred(); + pending.push(gate); + releasePending(); + await gate.promise; + inFlight -= 1; + return { + backup: backupsByName.get(name) ?? backups[0], + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "back" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledTimes(backups.length); + expect(maxInFlight).toBeLessThanOrEqual( + NAMED_BACKUP_ASSESS_CONCURRENCY, + ); + }); + + it("reassesses a backup before confirmation so the merge summary stays current", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const initialAssessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + const refreshedAssessment = { + ...initialAssessment, + currentAccountCount: 3, + mergedAccountCount: 4, + }; + listNamedBackupsMock.mockResolvedValue([initialAssessment.backup]); + assessNamedBackupRestoreMock + .mockResolvedValueOnce(initialAssessment) + .mockResolvedValueOnce(refreshedAssessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ + type: "restore", + assessment: initialAssessment, + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).toHaveBeenCalledWith( + expect.stringContaining("add 1 new account(s)"), + ); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + }); + + it("uses metadata refresh wording when a restore only updates existing accounts", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + { + email: "same@example.com", + accountId: "acc_same", + refreshToken: "refresh-same", + accessToken: "access-same", + expiresAt: now + 3_600_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 2, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 2, + mergedAccountCount: 2, + imported: 0, + skipped: 2, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + importAccountsMock.mockResolvedValueOnce({ + imported: 0, + skipped: 2, + total: 2, + changed: true, + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ + type: "restore", + assessment, + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(confirmMock).toHaveBeenCalledWith( + expect.stringContaining( + "refresh stored metadata for matching existing account(s)", + ), + ); + expect(confirmMock).not.toHaveBeenCalledWith( + expect.stringContaining("for 2 existing account(s)"), + ); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + expect(logSpy).toHaveBeenCalledWith( + 'Restored backup "named-backup". Refreshed stored metadata for matching existing account(s).', + ); + expect(logSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Imported 0, skipped 2"), + ); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce(); + } finally { + logSpy.mockRestore(); + } + }); + + it("returns to the login menu when backup reassessment becomes ineligible", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const initialAssessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + const refreshedAssessment = { + ...initialAssessment, + currentAccountCount: 1, + mergedAccountCount: 1, + imported: 0, + skipped: 1, + eligibleForRestore: false, + error: "All accounts in this backup already exist", + }; + listNamedBackupsMock.mockResolvedValue([initialAssessment.backup]); + assessNamedBackupRestoreMock + .mockResolvedValueOnce(initialAssessment) + .mockResolvedValueOnce(refreshedAssessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ + type: "restore", + assessment: initialAssessment, + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + "All accounts in this backup already exist", + ); + } finally { + logSpy.mockRestore(); + } + }); + + it("returns to the login menu when backup reassessment fails before confirmation", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const initialAssessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([initialAssessment.backup]); + assessNamedBackupRestoreMock + .mockResolvedValueOnce(initialAssessment) + .mockRejectedValueOnce(makeErrnoError("backup busy", "EBUSY")); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ + type: "restore", + assessment: initialAssessment, + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Restore failed: backup busy"), + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it("shows epoch backup timestamps in restore hints", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const assessment = { + backup: { + name: "epoch-backup", + path: mockBackupPath("epoch-backup"), + createdAt: null, + updatedAt: 0, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "back" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + const backupItems = selectMock.mock.calls[0]?.[0]; + expect(backupItems?.[0]?.hint).toContain("1 account"); + expect(backupItems?.[0]?.hint).not.toContain("updated "); + }); + + it("formats recent backup timestamps in restore hints", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const now = Date.UTC(2026, 0, 10, 12, 0, 0); + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now); + const backups = [ + { + name: "today-backup", + path: mockBackupPath("today-backup"), + createdAt: null, + updatedAt: now - 1_000, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + { + name: "yesterday-backup", + path: mockBackupPath("yesterday-backup"), + createdAt: null, + updatedAt: now - 1.5 * 86_400_000, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + { + name: "three-days-backup", + path: mockBackupPath("three-days-backup"), + createdAt: null, + updatedAt: now - 3 * 86_400_000, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + { + name: "older-backup", + path: mockBackupPath("older-backup"), + createdAt: null, + updatedAt: now - 8 * 86_400_000, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + ]; + const assessmentsByName = new Map( + backups.map((backup) => [ + backup.name, + { + backup, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }, + ]), + ); + listNamedBackupsMock.mockResolvedValue(backups); + assessNamedBackupRestoreMock.mockImplementation(async (name: string) => { + return assessmentsByName.get(name) ?? assessmentsByName.get(backups[0].name)!; + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "back" }); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + const backupItems = selectMock.mock.calls[0]?.[0]; + expect(backupItems?.[0]?.hint).toContain("updated today"); + expect(backupItems?.[1]?.hint).toContain("updated yesterday"); + expect(backupItems?.[2]?.hint).toContain("updated 3d ago"); + expect(backupItems?.[3]?.hint).toContain("updated "); + } finally { + nowSpy.mockRestore(); + } + }); + + it("suppresses invalid backup timestamps in restore hints", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const assessment = { + backup: { + name: "nan-backup", + path: mockBackupPath("nan-backup"), + createdAt: null, + updatedAt: Number.NaN, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "back" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + const backupItems = selectMock.mock.calls[0]?.[0]; + expect(backupItems?.[0]?.hint).toContain("1 account"); + expect(backupItems?.[0]?.hint).not.toContain("updated "); + }); + + it("shows experimental settings in the settings hub", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + queueSettingsSelectSequence([{ type: "back" }]); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(readSettingsHubPanelContract()).toEqual(SETTINGS_HUB_MENU_ORDER); + }); + + it("runs experimental oc sync with mandatory preview before apply", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); + planOcChatgptSyncMock.mockResolvedValue({ + kind: "ready", + target: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + preview: { + payload: { version: 3, accounts: [], activeIndex: 0 }, + merged: { version: 3, accounts: [], activeIndex: 0 }, + toAdd: [{ refreshTokenLast4: "1234" }], + toUpdate: [], + toSkip: [], + unchangedDestinationOnly: [], + activeSelectionBehavior: "preserve-destination", + }, + payload: { version: 3, accounts: [], activeIndex: 0 }, + destination: null, + }); + applyOcChatgptSyncMock.mockResolvedValue({ + kind: "applied", + target: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + preview: { merged: { version: 3, accounts: [], activeIndex: 0 } }, + merged: { version: 3, accounts: [], activeIndex: 0 }, + destination: null, + persistedPath: "C:/target/openai-codex-accounts.json", + }); + const selectSequence = queueSettingsSelectSequence([ + { type: "experimental" }, + { type: "sync" }, + { type: "apply" }, + { type: "back" }, + { type: "back" }, + { type: "back" }, + ]); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectSequence.remaining()).toBe(0); + expect(planOcChatgptSyncMock).toHaveBeenCalledOnce(); + expect(applyOcChatgptSyncMock).toHaveBeenCalledOnce(); + expect(selectMock).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ label: expect.stringContaining("Active selection: preserve-destination") }), + ]), + expect.any(Object), + ); + }); + + it("shows guidance when experimental oc sync target is ambiguous or unreadable", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); + planOcChatgptSyncMock.mockResolvedValue({ + kind: "blocked-ambiguous", + detection: { kind: "ambiguous", reason: "multiple targets", candidates: [] }, + }); + const selectSequence = queueSettingsSelectSequence([ + { type: "experimental" }, + { type: "sync" }, + { type: "back" }, + { type: "back" }, + { type: "back" }, + ]); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectSequence.remaining()).toBe(0); + expect(planOcChatgptSyncMock).toHaveBeenCalledOnce(); + expect(applyOcChatgptSyncMock).not.toHaveBeenCalled(); + }); + + + it("exports named pool backup from experimental settings", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + promptQuestionMock.mockResolvedValueOnce("backup-2026-03-10"); + runNamedBackupExportMock.mockResolvedValueOnce({ kind: "exported", path: "/mock/backups/backup-2026-03-10.json" }); + const selectSequence = queueSettingsSelectSequence([ + { type: "experimental" }, + { type: "backup" }, + { type: "back" }, + { type: "back" }, + { type: "back" }, + ]); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectSequence.remaining()).toBe(0); + expect(promptQuestionMock).toHaveBeenCalledOnce(); + expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "backup-2026-03-10" }); + }); + + it("rejects invalid or colliding experimental backup filenames", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + promptQuestionMock.mockResolvedValueOnce("../bad-name"); + runNamedBackupExportMock.mockResolvedValueOnce({ kind: "collision", path: "/mock/backups/bad-name.json" }); + const selectSequence = queueSettingsSelectSequence([ + { type: "experimental" }, + { type: "backup" }, + { type: "back" }, + { type: "back" }, + { type: "back" }, + ]); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectSequence.remaining()).toBe(0); + expect(promptQuestionMock).toHaveBeenCalledOnce(); + expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "../bad-name" }); }); it("backs out of experimental sync preview without applying", async () => { @@ -3451,6 +5029,218 @@ describe("codex manager cli commands", () => { logSpy.mockRestore(); }); + it("waits for an in-flight menu quota refresh before starting quick check", async () => { + const now = Date.now(); + const menuStorage = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "alpha@example.com", + accountId: "acc-alpha", + accessToken: "access-alpha", + expiresAt: now + 3_600_000, + refreshToken: "refresh-alpha", + addedAt: now, + lastUsed: now, + enabled: true, + }, + { + email: "beta@example.com", + accountId: "acc-beta", + accessToken: "access-beta", + expiresAt: now + 3_600_000, + refreshToken: "refresh-beta", + addedAt: now, + lastUsed: now, + enabled: true, + }, + ], + }; + const quickCheckStorage = { + ...menuStorage, + accounts: [menuStorage.accounts[0]!], + }; + let loadAccountsCalls = 0; + loadAccountsMock.mockImplementation(async () => { + loadAccountsCalls += 1; + return structuredClone( + loadAccountsCalls === 1 ? menuStorage : quickCheckStorage, + ); + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuShowFetchStatus: true, + menuQuotaTtlMs: 60_000, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + let currentQuotaCache: { + byAccountId: Record; + byEmail: Record; + } = { + byAccountId: {}, + byEmail: {}, + }; + loadQuotaCacheMock.mockImplementation(async () => + structuredClone(currentQuotaCache), + ); + saveQuotaCacheMock.mockImplementation(async (value: typeof currentQuotaCache) => { + currentQuotaCache = structuredClone(value); + }); + const firstFetchStarted = createDeferred(); + const secondFetchStarted = createDeferred(); + const releaseFirstFetch = createDeferred(); + const releaseSecondFetch = createDeferred(); + let fetchCallCount = 0; + fetchCodexQuotaSnapshotMock.mockImplementation( + async (input: { accountId: string }) => { + fetchCallCount += 1; + if (fetchCallCount === 1) { + firstFetchStarted.resolve(); + await releaseFirstFetch.promise; + } else if (fetchCallCount === 2) { + secondFetchStarted.resolve(input.accountId); + await releaseSecondFetch.promise; + } + return { + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }; + }, + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "check" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const runPromise = runCodexMultiAuthCli(["auth", "login"]); + + await firstFetchStarted.promise; + await Promise.resolve(); + + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(1); + + releaseFirstFetch.resolve(); + + const secondAccountId = await secondFetchStarted.promise; + expect(secondAccountId).toBe("acc-beta"); + + releaseSecondFetch.resolve(); + + const exitCode = await runPromise; + + expect(exitCode).toBe(0); + expect(Object.keys(currentQuotaCache.byEmail)).toEqual( + expect.arrayContaining(["alpha@example.com", "beta@example.com"]), + ); + } finally { + logSpy.mockRestore(); + } + }); + + it("waits for an in-flight menu quota refresh before starting backup restore", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "restore@example.com", + accountId: "acc-restore", + accessToken: "access-restore", + expiresAt: now + 3_600_000, + refreshToken: "refresh-restore", + addedAt: now, + lastUsed: now, + enabled: true, + }, + ], + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuShowFetchStatus: true, + menuQuotaTtlMs: 60_000, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + let currentQuotaCache: { + byAccountId: Record; + byEmail: Record; + } = { + byAccountId: {}, + byEmail: {}, + }; + loadQuotaCacheMock.mockImplementation(async () => + structuredClone(currentQuotaCache), + ); + saveQuotaCacheMock.mockImplementation(async (value: typeof currentQuotaCache) => { + currentQuotaCache = structuredClone(value); + }); + const fetchStarted = createDeferred(); + const releaseFetch = createDeferred(); + fetchCodexQuotaSnapshotMock.mockImplementation(async () => { + fetchStarted.resolve(); + await releaseFetch.promise; + return { + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }; + }); + listNamedBackupsMock.mockResolvedValue([]); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const runPromise = runCodexMultiAuthCli(["auth", "login"]); + + await fetchStarted.promise; + await Promise.resolve(); + + expect(listNamedBackupsMock).not.toHaveBeenCalled(); + + releaseFetch.resolve(); + + const exitCode = await runPromise; + + expect(exitCode).toBe(0); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock.mock.invocationCallOrder[0]).toBeLessThan( + listNamedBackupsMock.mock.invocationCallOrder[0] ?? + Number.POSITIVE_INFINITY, + ); + } finally { + logSpy.mockRestore(); + } + }); + it("skips a second destructive action while reset is already running", async () => { const now = Date.now(); const skipMessage = diff --git a/test/storage.test.ts b/test/storage.test.ts index 790ee247..7e37dcfa 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1,13 +1,19 @@ import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ACCOUNT_LIMITS } from "../lib/constants.js"; import { clearQuotaCache, getQuotaCachePath } from "../lib/quota-cache.js"; import { getConfigDir, getProjectStorageKey } from "../lib/storage/paths.js"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; import { + __testOnly, + assessNamedBackupRestore, + assertNamedBackupRestorePath, buildNamedBackupPath, clearAccounts, clearFlaggedAccounts, + createNamedBackup, deduplicateAccounts, deduplicateAccountsByEmail, exportAccounts, @@ -15,11 +21,18 @@ import { findMatchingAccountIndex, formatStorageErrorHint, getFlaggedAccountsPath, + NAMED_BACKUP_LIST_CONCURRENCY, getStoragePath, importAccounts, + isNamedBackupContainmentError, + isNamedBackupPathValidationTransientError, + listNamedBackups, loadAccounts, loadFlaggedAccounts, normalizeAccountStorage, + resolveNamedBackupRestorePath, + restoreAssessedNamedBackup, + restoreNamedBackup, resolveAccountSelectionIndex, saveFlaggedAccounts, StorageError, @@ -327,7 +340,7 @@ describe("storage", () => { afterEach(async () => { setStoragePathDirect(null); - await fs.rm(testWorkDir, { recursive: true, force: true }); + await removeWithRetry(testWorkDir, { recursive: true, force: true }); }); it("should export accounts to a file", async () => { @@ -363,6 +376,69 @@ describe("storage", () => { ); }); + it("throws when exporting inside an active transaction for a different storage path", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "transactional-export", + refreshToken: "ref-transactional-export", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const alternateStoragePath = join(testWorkDir, "alternate-accounts.json"); + + await expect( + withAccountStorageTransaction(async () => { + setStoragePathDirect(alternateStoragePath); + try { + await exportAccounts(exportPath); + } finally { + setStoragePathDirect(testStoragePath); + } + }), + ).rejects.toThrow(/different storage path/); + }); + + it("allows exporting inside an active transaction when the storage path only differs by case on win32", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "transactional-export-same-path", + refreshToken: "ref-transactional-export-same-path", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const casedStoragePath = testStoragePath.toUpperCase(); + + try { + await expect( + withAccountStorageTransaction(async () => { + setStoragePathDirect(casedStoragePath); + try { + await exportAccounts(exportPath); + } finally { + setStoragePathDirect(testStoragePath); + } + }), + ).resolves.toBeUndefined(); + expect(existsSync(exportPath)).toBe(true); + } finally { + platformSpy.mockRestore(); + } + }); + it("should import accounts from a file and merge", async () => { // @ts-expect-error const { importAccounts } = await import("../lib/storage.js"); @@ -399,6 +475,243 @@ describe("storage", () => { expect(loaded?.accounts.map((a) => a.accountId)).toContain("new"); }); + it("should skip persisting duplicate-only imports", async () => { + const { importAccounts } = await import("../lib/storage.js"); + const existing = { + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 2, + }, + ], + }; + await saveAccounts(existing); + await fs.writeFile(exportPath, JSON.stringify(existing)); + + const writeFileSpy = vi.spyOn(fs, "writeFile"); + try { + const result = await importAccounts(exportPath); + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { + const target = String(targetPath); + return ( + target === testStoragePath || + target.startsWith(`${testStoragePath}.`) + ); + }); + expect(storageWrites).toHaveLength(0); + } finally { + writeFileSpy.mockRestore(); + } + }); + + it("should treat deduplicated current snapshots as a no-op import", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + + const writeFileSpy = vi.spyOn(fs, "writeFile"); + try { + const result = await importAccounts(exportPath); + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { + const target = String(targetPath); + return ( + target === testStoragePath || + target.startsWith(`${testStoragePath}.`) + ); + }); + expect(storageWrites).toHaveLength(0); + } finally { + writeFileSpy.mockRestore(); + } + }); + + it("should deduplicate incoming backup rows before reporting skipped imports", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await clearAccounts(); + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "duplicate-import", + email: "duplicate-import@example.com", + refreshToken: "ref-duplicate-import-old", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "duplicate-import", + email: "duplicate-import@example.com", + refreshToken: "ref-duplicate-import-new", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + ); + + const result = await importAccounts(exportPath); + const loaded = await loadAccounts(); + + expect(result).toEqual({ + imported: 1, + skipped: 0, + total: 1, + changed: true, + }); + expect(loaded?.accounts).toHaveLength(1); + expect(loaded?.accounts[0]).toMatchObject({ + accountId: "duplicate-import", + email: "duplicate-import@example.com", + refreshToken: "ref-duplicate-import-new", + lastUsed: 2, + }); + }); + + it("should persist duplicate-only imports when they refresh stored metadata", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + accessToken: "stale-access", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + accessToken: "fresh-access", + addedAt: 1, + lastUsed: 10, + }, + ], + }), + ); + + const result = await importAccounts(exportPath); + const loaded = await loadAccounts(); + + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: true, + }); + expect(loaded?.accounts).toHaveLength(1); + expect(loaded?.accounts[0]).toMatchObject({ + accountId: "existing", + accessToken: "fresh-access", + lastUsed: 10, + }); + }); + + it("should skip semantically identical duplicate-only imports even when key order differs", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 2, + }, + ], + }); + await fs.writeFile( + exportPath, + '{"version":3,"activeIndex":0,"accounts":[{"lastUsed":2,"addedAt":1,"refreshToken":"ref-existing","accountId":"existing"}]}', + ); + + const writeFileSpy = vi.spyOn(fs, "writeFile"); + try { + const result = await importAccounts(exportPath); + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { + const target = String(targetPath); + return ( + target === testStoragePath || + target.startsWith(`${testStoragePath}.`) + ); + }); + expect(storageWrites).toHaveLength(0); + } finally { + writeFileSpy.mockRestore(); + } + }); + it("should preserve distinct shared-accountId imports when the imported row has no email", async () => { const { importAccounts } = await import("../lib/storage.js"); const existing = { @@ -444,7 +757,12 @@ describe("storage", () => { const imported = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(imported).toEqual({ imported: 1, total: 3, skipped: 0 }); + expect(imported).toEqual({ + imported: 1, + total: 3, + skipped: 0, + changed: true, + }); expect(loaded?.accounts).toHaveLength(3); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual( expect.arrayContaining([ @@ -495,7 +813,12 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); + expect(result).toEqual({ + imported: 1, + skipped: 0, + total: 2, + changed: true, + }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -541,7 +864,12 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); + expect(result).toEqual({ + imported: 1, + skipped: 0, + total: 2, + changed: true, + }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -920,10 +1248,78 @@ describe("storage", () => { ); }); + it("rejects a second import that would exceed MAX_ACCOUNTS", async () => { + const nearLimitAccounts = Array.from( + { length: ACCOUNT_LIMITS.MAX_ACCOUNTS - 1 }, + (_, index) => ({ + accountId: `existing-${index}`, + refreshToken: `ref-existing-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }), + ); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: nearLimitAccounts, + }); + + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "extra-one", + refreshToken: "ref-extra-one", + addedAt: 10_000, + lastUsed: 10_000, + }, + ], + }), + ); + + const first = await importAccounts(exportPath); + expect(first).toMatchObject({ + imported: 1, + skipped: 0, + total: ACCOUNT_LIMITS.MAX_ACCOUNTS, + changed: true, + }); + + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "extra-two", + refreshToken: "ref-extra-two", + addedAt: 20_000, + lastUsed: 20_000, + }, + ], + }), + ); + + await expect(importAccounts(exportPath)).rejects.toThrow( + /exceed maximum/, + ); + + const loaded = await loadAccounts(); + expect(loaded?.accounts).toHaveLength(ACCOUNT_LIMITS.MAX_ACCOUNTS); + expect( + loaded?.accounts.some((account) => account.accountId === "extra-two"), + ).toBe(false); + }); + it("should fail export when no accounts exist", async () => { - const { exportAccounts } = await import("../lib/storage.js"); - setStoragePathDirect(testStoragePath); - await expect(exportAccounts(exportPath)).rejects.toThrow( + const storageModule = await import("../lib/storage.js"); + storageModule.setStoragePathDirect(testStoragePath); + await storageModule.clearAccounts(); + await expect(storageModule.exportAccounts(exportPath)).rejects.toThrow( /No accounts to export/, ); }); @@ -936,6 +1332,51 @@ describe("storage", () => { ); }); + it("retries transient import read errors before parsing the backup", async () => { + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-import-read", + refreshToken: "ref-retry-import-read", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + const originalReadFile = fs.readFile.bind(fs); + let busyFailures = 0; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === exportPath && busyFailures === 0) { + busyFailures += 1; + const error = new Error("import file busy") as NodeJS.ErrnoException; + error.code = "EAGAIN"; + throw error; + } + return originalReadFile(...(args as Parameters)); + }); + + try { + const result = await importAccounts(exportPath); + expect(result).toMatchObject({ + imported: 1, + skipped: 0, + total: 1, + changed: true, + }); + expect(busyFailures).toBe(1); + } finally { + readFileSpy.mockRestore(); + } + }); + it("should fail import when file contains invalid JSON", async () => { const { importAccounts } = await import("../lib/storage.js"); await fs.writeFile(exportPath, "not valid json {["); @@ -1073,6 +1514,1767 @@ describe("storage", () => { ); }); }); + + it("creates and lists named backups with metadata", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "acct-backup", + refreshToken: "ref-backup", + addedAt: 1, + lastUsed: 2, + }, + ], + }); + + const backup = await createNamedBackup("backup-2026-03-12"); + const backups = await listNamedBackups(); + + expect(backup.name).toBe("backup-2026-03-12"); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "backup-2026-03-12", + accountCount: 1, + valid: true, + }), + ]), + ); + }); + + it("lists named backups across the chunk boundary", async () => { + const expectedNames: string[] = []; + for ( + let index = 0; + index <= NAMED_BACKUP_LIST_CONCURRENCY; + index += 1 + ) { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: `basic-chunk-${index}`, + refreshToken: `ref-basic-chunk-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }, + ], + }); + const name = `basic-chunk-${String(index).padStart(2, "0")}`; + expectedNames.push(name); + await createNamedBackup(name); + } + + const backups = await listNamedBackups(); + + expect(backups).toHaveLength(NAMED_BACKUP_LIST_CONCURRENCY + 1); + expect(backups).toEqual( + expect.arrayContaining( + expectedNames.map((name) => + expect.objectContaining({ + name, + accountCount: 1, + valid: true, + }), + ), + ), + ); + }); + + it("returns a contained fallback path for missing named backups", async () => { + const requestedName = " missing-backup "; + const resolvedPath = + await resolveNamedBackupRestorePath(requestedName); + + expect(resolvedPath).toBe(buildNamedBackupPath("missing-backup")); + await expect(importAccounts(resolvedPath)).rejects.toThrow( + /Import file not found/, + ); + }); + + it("maps read-time ENOENT back to the import file-not-found contract", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "read-race", + refreshToken: "ref-read-race", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const backup = await createNamedBackup("read-race"); + const originalReadFile = fs.readFile.bind(fs); + let injectedEnoent = false; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backup.path && !injectedEnoent) { + injectedEnoent = true; + const error = new Error("backup disappeared") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + } + return originalReadFile(...(args as Parameters)); + }); + + try { + await expect(importAccounts(backup.path)).rejects.toThrow( + `Import file not found: ${backup.path}`, + ); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("assesses eligibility and restores a named backup", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await createNamedBackup("restore-me"); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("restore-me"); + expect(assessment.eligibleForRestore).toBe(true); + expect(assessment.wouldExceedLimit).toBe(false); + + const restoreResult = await restoreNamedBackup("restore-me"); + expect(restoreResult.total).toBe(1); + + const restored = await loadAccounts(); + expect(restored?.accounts[0]?.accountId).toBe("primary"); + }); + + it("honors explicit null currentStorage when assessing a named backup", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "backup-account", + refreshToken: "ref-backup-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("explicit-null-current-storage"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "current-account", + refreshToken: "ref-current-account", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + const assessment = await assessNamedBackupRestore( + "explicit-null-current-storage", + { currentStorage: null }, + ); + + expect(assessment.currentAccountCount).toBe(0); + expect(assessment.mergedAccountCount).toBe(1); + expect(assessment.imported).toBe(1); + expect(assessment.skipped).toBe(0); + expect(assessment.eligibleForRestore).toBe(true); + }); + + it("deduplicates incoming backup rows when assessing restore counts", async () => { + const backupPath = join( + dirname(testStoragePath), + "backups", + "internal-duplicates.json", + ); + await fs.mkdir(dirname(backupPath), { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "duplicate-account", + email: "duplicate-account@example.com", + refreshToken: "ref-duplicate-old", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "duplicate-account", + email: "duplicate-account@example.com", + refreshToken: "ref-duplicate-new", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + "utf-8", + ); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("internal-duplicates"); + + expect(assessment.imported).toBe(1); + expect(assessment.skipped).toBe(0); + expect(assessment.mergedAccountCount).toBe(1); + expect(assessment.eligibleForRestore).toBe(true); + }); + + it("rejects duplicate-only backups with nothing new to restore", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing-account", + refreshToken: "ref-existing-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("already-present"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing-account", + refreshToken: "ref-existing-account", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + const assessment = await assessNamedBackupRestore("already-present"); + expect(assessment.imported).toBe(0); + expect(assessment.skipped).toBe(1); + expect(assessment.eligibleForRestore).toBe(false); + expect(assessment.error).toBe("All accounts in this backup already exist"); + + await expect(restoreNamedBackup("already-present")).rejects.toThrow( + "All accounts in this backup already exist", + ); + }); + + it("treats deduplicated current snapshots as a no-op restore", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing-account", + email: "existing@example.com", + refreshToken: "ref-existing-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("repair-current-duplicates"); + + const assessment = await assessNamedBackupRestore( + "repair-current-duplicates", + { + currentStorage: { + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing-account", + email: "existing@example.com", + refreshToken: "ref-existing-account", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "existing-account", + email: "existing@example.com", + refreshToken: "ref-existing-account", + addedAt: 2, + lastUsed: 2, + }, + ], + }, + }, + ); + expect(assessment.currentAccountCount).toBe(2); + expect(assessment.mergedAccountCount).toBe(1); + expect(assessment.imported).toBe(0); + expect(assessment.skipped).toBe(1); + expect(assessment.eligibleForRestore).toBe(false); + expect(assessment.error).toBe("All accounts in this backup already exist"); + + await expect( + restoreNamedBackup("repair-current-duplicates"), + ).rejects.toThrow("All accounts in this backup already exist"); + }); + + it("treats identical accounts in a different backup order as a no-op restore", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "first-account", + email: "first@example.com", + refreshToken: "ref-first-account", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "second-account", + email: "second@example.com", + refreshToken: "ref-second-account", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await createNamedBackup("reversed-order"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "second-account", + email: "second@example.com", + refreshToken: "ref-second-account", + addedAt: 2, + lastUsed: 2, + }, + { + accountId: "first-account", + email: "first@example.com", + refreshToken: "ref-first-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const assessment = await assessNamedBackupRestore("reversed-order"); + expect(assessment.imported).toBe(0); + expect(assessment.skipped).toBe(2); + expect(assessment.eligibleForRestore).toBe(false); + expect(assessment.error).toBe("All accounts in this backup already exist"); + + await expect(restoreNamedBackup("reversed-order")).rejects.toThrow( + "All accounts in this backup already exist", + ); + }); + + it("keeps metadata-only backups eligible for restore", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing-account", + email: "existing@example.com", + refreshToken: "ref-existing-account", + accessToken: "fresh-access", + addedAt: 1, + lastUsed: 10, + }, + ], + }); + await createNamedBackup("metadata-refresh"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing-account", + email: "existing@example.com", + refreshToken: "ref-existing-account", + accessToken: "stale-access", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const assessment = await assessNamedBackupRestore("metadata-refresh"); + expect(assessment.imported).toBe(0); + expect(assessment.skipped).toBe(1); + expect(assessment.eligibleForRestore).toBe(true); + expect(assessment.error).toBeUndefined(); + + const restoreResult = await restoreNamedBackup("metadata-refresh"); + expect(restoreResult).toMatchObject({ + imported: 0, + skipped: 1, + total: 1, + changed: true, + }); + + const restored = await loadAccounts(); + expect(restored?.accounts[0]).toMatchObject({ + accountId: "existing-account", + accessToken: "fresh-access", + lastUsed: 10, + }); + }); + + it("restores manually named backups that already exist inside the backups directory", async () => { + const backupPath = join( + dirname(testStoragePath), + "backups", + "Manual Backup.json", + ); + await fs.mkdir(dirname(backupPath), { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "manual", + refreshToken: "ref-manual", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "Manual Backup", valid: true }), + ]), + ); + + await clearAccounts(); + const assessment = await assessNamedBackupRestore("Manual Backup"); + expect(assessment.eligibleForRestore).toBe(true); + expect(assessment.backup.name).toBe("Manual Backup"); + + const restoreResult = await restoreNamedBackup("Manual Backup"); + expect(restoreResult.total).toBe(1); + }); + + it("restores manually named backups with uppercase JSON extensions", async () => { + const backupPath = join( + dirname(testStoragePath), + "backups", + "Manual Backup.JSON", + ); + await fs.mkdir(dirname(backupPath), { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "manual-uppercase", + refreshToken: "ref-manual-uppercase", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "Manual Backup", valid: true }), + ]), + ); + + await clearAccounts(); + const assessment = await assessNamedBackupRestore("Manual Backup"); + expect(assessment.eligibleForRestore).toBe(true); + expect(assessment.backup.name).toBe("Manual Backup"); + + const restoreResult = await restoreNamedBackup("Manual Backup"); + expect(restoreResult.total).toBe(1); + }); + + it("throws when a named backup is deleted after assessment", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "deleted-backup", + refreshToken: "ref-deleted-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const backup = await createNamedBackup("deleted-after-assessment"); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("deleted-after-assessment"); + expect(assessment.eligibleForRestore).toBe(true); + + await removeWithRetry(backup.path, { force: true }); + + await expect( + restoreNamedBackup("deleted-after-assessment"), + ).rejects.toThrow(/Import file not found/); + expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); + }); + + it("re-resolves an assessed named backup before the final import", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "deleted-helper", + refreshToken: "ref-deleted-helper", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const backup = await createNamedBackup("deleted-helper-assessment"); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore( + "deleted-helper-assessment", + ); + expect(assessment.eligibleForRestore).toBe(true); + + await removeWithRetry(backup.path, { force: true }); + + await expect(restoreAssessedNamedBackup(assessment)).rejects.toThrow( + /Import file not found/, + ); + expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); + }); + + it("throws when a named backup becomes invalid JSON after assessment", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "invalid-backup", + refreshToken: "ref-invalid-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const backup = await createNamedBackup("invalid-after-assessment"); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("invalid-after-assessment"); + expect(assessment.eligibleForRestore).toBe(true); + + await fs.writeFile(backup.path, "not valid json {[", "utf-8"); + + await expect( + restoreNamedBackup("invalid-after-assessment"), + ).rejects.toThrow(/Invalid JSON in import file/); + expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); + }); + + it.each(["../openai-codex-accounts", String.raw`..\openai-codex-accounts`])( + "rejects backup names that escape the backups directory: %s", + async (input) => { + await expect(assessNamedBackupRestore(input)).rejects.toThrow( + /must not contain path separators/i, + ); + await expect(restoreNamedBackup(input)).rejects.toThrow( + /must not contain path separators/i, + ); + }, + ); + + it("allows backup filenames that begin with dots when they stay inside the backups directory", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const backupPath = join(backupRoot, "..notes.json"); + await fs.mkdir(backupRoot, { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "leading-dot-backup", + refreshToken: "ref-leading-dot-backup", + addedAt: 1, + lastUsed: 2, + }, + ], + }), + "utf-8", + ); + + const assessment = await assessNamedBackupRestore("..notes"); + expect(assessment.eligibleForRestore).toBe(true); + + const result = await restoreNamedBackup("..notes"); + expect(result.imported).toBe(1); + expect((await loadAccounts())?.accounts).toHaveLength(1); + }); + + it("rejects matched backup entries whose resolved path escapes the backups directory", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + const readdirSpy = vi.spyOn(fs, "readdir"); + const escapedEntry = { + name: "../escaped-entry.json", + isFile: () => true, + isSymbolicLink: () => false, + } as unknown as Awaited>[number]; + readdirSpy.mockImplementation(async (...args) => { + const [path, options] = args; + if ( + String(path) === backupRoot && + typeof options === "object" && + options?.withFileTypes === true + ) { + return [escapedEntry] as Awaited>; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + await expect(assessNamedBackupRestore("../escaped-entry")).rejects.toThrow( + /escapes backup directory/i, + ); + await expect(restoreNamedBackup("../escaped-entry")).rejects.toThrow( + /escapes backup directory/i, + ); + } finally { + readdirSpy.mockRestore(); + } + }); + + it("rejects backup paths whose real path escapes the backups directory through symlinked directories", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const outsideRoot = join(testWorkDir, "outside"); + const linkedRoot = join(backupRoot, "linked"); + const outsideBackupPath = join(outsideRoot, "escape.json"); + await fs.mkdir(backupRoot, { recursive: true }); + await fs.mkdir(outsideRoot, { recursive: true }); + await fs.writeFile( + outsideBackupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "linked-escape", + refreshToken: "ref-linked-escape", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + await fs.symlink( + resolve(outsideRoot), + linkedRoot, + process.platform === "win32" ? "junction" : "dir", + ); + + expect(() => + assertNamedBackupRestorePath( + join(linkedRoot, "escape.json"), + backupRoot, + ), + ).toThrow(/escapes backup directory/i); + }); + + it("rejects missing files beneath symlinked backup subdirectories", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const outsideRoot = join(testWorkDir, "outside-missing"); + const linkedRoot = join(backupRoot, "linked-missing"); + await fs.mkdir(backupRoot, { recursive: true }); + await fs.mkdir(outsideRoot, { recursive: true }); + await fs.symlink( + resolve(outsideRoot), + linkedRoot, + process.platform === "win32" ? "junction" : "dir", + ); + + expect(() => + assertNamedBackupRestorePath( + join(linkedRoot, "missing.json"), + backupRoot, + ), + ).toThrow(/escapes backup directory/i); + }); + + it("rejects symlinked backup roots during restore path validation", async () => { + const canonicalBackupRoot = join(testWorkDir, "canonical-backups"); + const linkedBackupRoot = join(testWorkDir, "linked-backups"); + const backupPath = join(canonicalBackupRoot, "linked-root.json"); + await fs.mkdir(canonicalBackupRoot, { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "linked-root", + refreshToken: "ref-linked-root", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + await fs.symlink( + resolve(canonicalBackupRoot), + linkedBackupRoot, + process.platform === "win32" ? "junction" : "dir", + ); + + expect(() => + assertNamedBackupRestorePath( + join(linkedBackupRoot, "linked-root.json"), + linkedBackupRoot, + ), + ).toThrow(/escapes backup directory/i); + }); + + it("rethrows realpath containment errors for existing backup paths", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const backupPath = join(backupRoot, "locked.json"); + await fs.mkdir(backupRoot, { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "locked-path", + refreshToken: "ref-locked-path", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + const originalRealpath = __testOnly.namedBackupContainmentFs.realpath; + const realpathSpy = vi + .spyOn(__testOnly.namedBackupContainmentFs, "realpath") + .mockImplementation((path) => { + if (String(path) === resolve(backupPath)) { + const error = new Error( + "backup path locked", + ) as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return originalRealpath(path); + }); + + try { + expect(() => + assertNamedBackupRestorePath(backupPath, backupRoot), + ).toThrow("Backup path validation failed. Try again."); + } finally { + realpathSpy.mockRestore(); + } + }); + + it("classifies transient realpath errors for the backup root", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const backupPath = join(backupRoot, "pending", "locked.json"); + await fs.mkdir(backupRoot, { recursive: true }); + const transientCode = process.platform === "win32" ? "EPERM" : "EAGAIN"; + + const originalRealpath = __testOnly.namedBackupContainmentFs.realpath; + const realpathSpy = vi + .spyOn(__testOnly.namedBackupContainmentFs, "realpath") + .mockImplementation((path) => { + if (String(path) === resolve(backupRoot)) { + const error = new Error( + "backup root busy", + ) as NodeJS.ErrnoException; + error.code = transientCode; + throw error; + } + return originalRealpath(path); + }); + + try { + expect(() => + assertNamedBackupRestorePath(backupPath, backupRoot), + ).toThrow("Backup path validation failed. Try again."); + } finally { + realpathSpy.mockRestore(); + } + }); + + it("classifies transient lstat errors for the backup root", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const backupPath = join(backupRoot, "pending", "locked.json"); + await fs.mkdir(backupRoot, { recursive: true }); + const originalLstat = __testOnly.namedBackupContainmentFs.lstat; + const lstatSpy = vi + .spyOn(__testOnly.namedBackupContainmentFs, "lstat") + .mockImplementation((path) => { + if (String(path) === resolve(backupRoot)) { + const error = new Error("backup root locked") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalLstat(path); + }); + + try { + expect(() => + assertNamedBackupRestorePath(backupPath, backupRoot), + ).toThrow("Backup path validation failed. Try again."); + } finally { + lstatSpy.mockRestore(); + } + }); + + it("classifies transient backup path validation errors separately from containment escapes", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const backupPath = join(backupRoot, "pending", "locked.json"); + await fs.mkdir(backupRoot, { recursive: true }); + const transientCode = process.platform === "win32" ? "EPERM" : "EAGAIN"; + const originalRealpath = __testOnly.namedBackupContainmentFs.realpath; + const realpathSpy = vi + .spyOn(__testOnly.namedBackupContainmentFs, "realpath") + .mockImplementation((path) => { + if (String(path) === resolve(backupRoot)) { + const error = new Error( + "backup root locked", + ) as NodeJS.ErrnoException; + error.code = transientCode; + throw error; + } + return originalRealpath(path); + }); + + try { + let thrown: unknown; + try { + assertNamedBackupRestorePath(backupPath, backupRoot); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + expect(isNamedBackupPathValidationTransientError(thrown)).toBe(true); + expect(isNamedBackupContainmentError(thrown)).toBe(false); + } finally { + realpathSpy.mockRestore(); + } + }); + + it("rejects named backup listings whose resolved paths escape the backups directory", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + const readdirSpy = vi.spyOn(fs, "readdir"); + const readFileSpy = vi.spyOn(fs, "readFile"); + const escapedEntry = { + name: "../escaped-entry.json", + isFile: () => true, + isSymbolicLink: () => false, + } as unknown as Awaited>[number]; + readdirSpy.mockImplementation(async (...args) => { + const [path, options] = args; + if ( + String(path) === backupRoot && + typeof options === "object" && + options?.withFileTypes === true + ) { + return [escapedEntry] as Awaited>; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + await expect(listNamedBackups()).rejects.toThrow(/escapes backup directory/i); + expect(readFileSpy).not.toHaveBeenCalled(); + } finally { + readFileSpy.mockRestore(); + readdirSpy.mockRestore(); + } + }); + + it("ignores symlink-like named backup entries that point outside the backups root", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const externalBackupPath = join(testWorkDir, "outside-backup.json"); + await fs.mkdir(backupRoot, { recursive: true }); + await fs.writeFile( + externalBackupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "outside-manual-backup", + refreshToken: "ref-outside-manual-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + const originalReaddir = fs.readdir.bind(fs); + const readdirSpy = vi.spyOn(fs, "readdir"); + const escapedEntry = { + name: "escaped-link.json", + isFile: () => false, + isSymbolicLink: () => true, + } as unknown as Awaited< + ReturnType + >[number]; + readdirSpy.mockImplementation(async (...args) => { + const [path, options] = args; + if ( + String(path) === backupRoot && + typeof options === "object" && + options?.withFileTypes === true + ) { + return [escapedEntry] as Awaited>; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toEqual([]); + await expect(assessNamedBackupRestore("escaped-link")).rejects.toThrow( + /not a regular backup file/i, + ); + await expect(restoreNamedBackup("escaped-link")).rejects.toThrow( + /not a regular backup file/i, + ); + } finally { + readdirSpy.mockRestore(); + } + }); + + it("rethrows unreadable backup directory errors while listing backups on non-Windows platforms", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("linux"); + const readdirSpy = vi.spyOn(fs, "readdir"); + const error = new Error("backup directory locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + readdirSpy.mockRejectedValue(error); + + try { + await expect(listNamedBackups()).rejects.toMatchObject({ code: "EPERM" }); + expect(readdirSpy).toHaveBeenCalledTimes(1); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("rethrows unreadable backup directory errors while restoring backups on non-Windows platforms", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("linux"); + const readdirSpy = vi.spyOn(fs, "readdir"); + const error = new Error("backup directory locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + readdirSpy.mockRejectedValue(error); + + try { + await expect(restoreNamedBackup("Manual Backup")).rejects.toMatchObject({ + code: "EPERM", + }); + expect(readdirSpy).toHaveBeenCalledTimes(1); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("retries EAGAIN backup directory errors while restoring backups on non-Windows platforms", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("linux"); + const readdirSpy = vi.spyOn(fs, "readdir"); + const error = new Error("backup directory busy") as NodeJS.ErrnoException; + error.code = "EAGAIN"; + readdirSpy.mockRejectedValue(error); + + try { + await expect(restoreNamedBackup("Manual Backup")).rejects.toMatchObject({ + code: "EAGAIN", + }); + expect(readdirSpy).toHaveBeenCalledTimes(7); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("retries transient EBUSY backup directory errors while listing backups on win32", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-list-dir", + refreshToken: "ref-retry-list-dir", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("retry-list-dir"); + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + let busyFailures = 0; + const readdirSpy = vi + .spyOn(fs, "readdir") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backupRoot && busyFailures === 0) { + busyFailures += 1; + const error = new Error( + "backup directory busy", + ) as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "retry-list-dir", valid: true }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("retries transient EAGAIN backup directory errors while listing backups", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-list-dir-not-empty", + refreshToken: "ref-retry-list-dir-not-empty", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("retry-list-dir-not-empty"); + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + let busyFailures = 0; + const readdirSpy = vi + .spyOn(fs, "readdir") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backupRoot && busyFailures === 0) { + busyFailures += 1; + const error = new Error( + "backup directory not empty yet", + ) as NodeJS.ErrnoException; + error.code = "EAGAIN"; + throw error; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "retry-list-dir-not-empty", + valid: true, + }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + readdirSpy.mockRestore(); + } + }); + + it("retries transient EPERM backup directory errors while listing backups on win32", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-list-dir-eperm", + refreshToken: "ref-retry-list-dir-eperm", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("retry-list-dir-eperm"); + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + let busyFailures = 0; + const readdirSpy = vi + .spyOn(fs, "readdir") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backupRoot && busyFailures === 0) { + busyFailures += 1; + const error = new Error( + "backup directory busy", + ) as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "retry-list-dir-eperm", + valid: true, + }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("retries a second-chunk backup read when listing more than one chunk of backups", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + const backups: Awaited>[] = []; + for ( + let index = 0; + index <= NAMED_BACKUP_LIST_CONCURRENCY; + index += 1 + ) { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: `chunk-boundary-${index}`, + refreshToken: `ref-chunk-boundary-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }, + ], + }); + backups.push( + await createNamedBackup(`chunk-boundary-${String(index).padStart(2, "0")}`), + ); + } + + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + const originalReadFile = fs.readFile.bind(fs); + const secondChunkBackup = backups.at(-1); + let busyFailures = 0; + const readdirSpy = vi + .spyOn(fs, "readdir") + .mockImplementation(async (...args) => { + const [path, options] = args; + if ( + String(path) === backupRoot && + typeof options === "object" && + options?.withFileTypes === true + ) { + const entries = await originalReaddir( + ...(args as Parameters), + ); + return [...entries].sort((left, right) => + left.name.localeCompare(right.name), + ) as Awaited>; + } + return originalReaddir(...(args as Parameters)); + }); + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === secondChunkBackup?.path && busyFailures === 0) { + busyFailures += 1; + const error = new Error("backup file busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalReadFile(...(args as Parameters)); + }); + + try { + const listedBackups = await listNamedBackups(); + expect(listedBackups).toHaveLength(NAMED_BACKUP_LIST_CONCURRENCY + 1); + expect(listedBackups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: `chunk-boundary-${String( + NAMED_BACKUP_LIST_CONCURRENCY, + ).padStart(2, "0")}`, + valid: true, + }), + ]), + ); + expect(busyFailures).toBe(1); + expect( + readFileSpy.mock.calls.filter( + ([path]) => String(path) === secondChunkBackup?.path, + ), + ).toHaveLength(2); + } finally { + readFileSpy.mockRestore(); + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("retries transient EAGAIN backup directory errors while restoring backups", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-restore-dir", + refreshToken: "ref-retry-restore-dir", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("retry-restore-dir"); + await clearAccounts(); + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + let busyFailures = 0; + const readdirSpy = vi + .spyOn(fs, "readdir") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backupRoot && busyFailures === 0) { + busyFailures += 1; + const error = new Error( + "backup directory busy", + ) as NodeJS.ErrnoException; + error.code = "EAGAIN"; + throw error; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + const result = await restoreNamedBackup("retry-restore-dir"); + expect(result.total).toBe(1); + expect(busyFailures).toBe(1); + } finally { + readdirSpy.mockRestore(); + } + }); + + it("throws file-not-found when a manually named backup disappears after assessment", async () => { + const backupPath = join( + dirname(testStoragePath), + "backups", + "Manual Backup.json", + ); + await fs.mkdir(dirname(backupPath), { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "manual-missing", + refreshToken: "ref-manual-missing", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("Manual Backup"); + expect(assessment.eligibleForRestore).toBe(true); + const storageBeforeRestore = await loadAccounts(); + expect(storageBeforeRestore?.accounts ?? []).toHaveLength(0); + + await removeWithRetry(backupPath, { force: true }); + + await expect(restoreNamedBackup("Manual Backup")).rejects.toThrow( + /Import file not found/, + ); + expect(await loadAccounts()).toEqual(storageBeforeRestore); + }); + + it("retries transient EBUSY backup read errors while listing backups on win32", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-read", + refreshToken: "ref-retry-read", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const backup = await createNamedBackup("retry-read"); + const originalReadFile = fs.readFile.bind(fs); + let busyFailures = 0; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backup.path && busyFailures === 0) { + busyFailures += 1; + const error = new Error("backup file busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalReadFile(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "retry-read", valid: true }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + readFileSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("retries transient backup stat EAGAIN errors while listing backups", async () => { + let statSpy: ReturnType | undefined; + try { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-stat", + refreshToken: "ref-retry-stat", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const backup = await createNamedBackup("retry-stat"); + const originalStat = fs.stat.bind(fs); + let busyFailures = 0; + statSpy = vi.spyOn(fs, "stat").mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backup.path && busyFailures === 0) { + busyFailures += 1; + const error = new Error("backup stat busy") as NodeJS.ErrnoException; + error.code = "EAGAIN"; + throw error; + } + return originalStat(...(args as Parameters)); + }); + + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "retry-stat", valid: true }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + statSpy?.mockRestore(); + } + }); + + it("sorts backups with invalid timestamps after finite timestamps", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "valid-backup", + refreshToken: "ref-valid-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const validBackup = await createNamedBackup("valid-backup"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "nan-backup", + refreshToken: "ref-nan-backup", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + const nanBackup = await createNamedBackup("nan-backup"); + const originalStat = fs.stat.bind(fs); + const statSpy = vi.spyOn(fs, "stat").mockImplementation(async (...args) => { + const [path] = args; + const stats = await originalStat(...(args as Parameters)); + if (String(path) === nanBackup.path) { + return { + ...stats, + mtimeMs: Number.NaN, + } as Awaited>; + } + return stats; + }); + + try { + const backups = await listNamedBackups(); + expect(backups.map((backup) => backup.name)).toEqual([ + validBackup.name, + nanBackup.name, + ]); + } finally { + statSpy.mockRestore(); + } + }); + + it("reuses freshly listed backup candidates for the first restore assessment", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "cached-backup", + refreshToken: "ref-cached-backup", + addedAt: 1, + lastUsed: 2, + }, + ], + }); + const backup = await createNamedBackup("cached-backup"); + const readFileSpy = vi.spyOn(fs, "readFile"); + const candidateCache = new Map(); + + try { + await listNamedBackups({ candidateCache }); + await assessNamedBackupRestore("cached-backup", { + currentStorage: null, + candidateCache, + }); + + const firstPassReads = readFileSpy.mock.calls.filter( + ([path]) => path === backup.path, + ); + expect(firstPassReads).toHaveLength(1); + + await assessNamedBackupRestore("cached-backup", { currentStorage: null }); + + const secondPassReads = readFileSpy.mock.calls.filter( + ([path]) => path === backup.path, + ); + expect(secondPassReads).toHaveLength(2); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("ignores invalid externally provided candidate cache entries", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "external-cache-backup", + refreshToken: "ref-external-cache-backup", + addedAt: 1, + lastUsed: 2, + }, + ], + }); + const backup = await createNamedBackup("external-cache-backup"); + const readFileSpy = vi.spyOn(fs, "readFile"); + const candidateCache = new Map([ + [ + backup.path, + { + normalized: { version: 3 }, + storedVersion: 3, + schemaErrors: [], + }, + ], + ]); + + try { + const assessment = await assessNamedBackupRestore( + "external-cache-backup", + { + currentStorage: null, + candidateCache, + }, + ); + expect(assessment).toEqual( + expect.objectContaining({ + eligibleForRestore: true, + backup: expect.objectContaining({ + name: "external-cache-backup", + path: backup.path, + }), + }), + ); + expect( + readFileSpy.mock.calls.filter(([path]) => path === backup.path), + ).toHaveLength(1); + expect(candidateCache.has(backup.path)).toBe(false); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("keeps per-call named-backup caches isolated across concurrent listings", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "isolated-cache-backup", + refreshToken: "ref-isolated-cache-backup", + addedAt: 1, + lastUsed: 2, + }, + ], + }); + const backup = await createNamedBackup("isolated-cache-backup"); + const readFileSpy = vi.spyOn(fs, "readFile"); + const firstCandidateCache = new Map(); + const secondCandidateCache = new Map(); + + try { + await Promise.all([ + listNamedBackups({ candidateCache: firstCandidateCache }), + listNamedBackups({ candidateCache: secondCandidateCache }), + ]); + + await assessNamedBackupRestore("isolated-cache-backup", { + currentStorage: null, + candidateCache: firstCandidateCache, + }); + await assessNamedBackupRestore("isolated-cache-backup", { + currentStorage: null, + candidateCache: secondCandidateCache, + }); + + const cachedReads = readFileSpy.mock.calls.filter( + ([path]) => path === backup.path, + ); + expect(cachedReads).toHaveLength(2); + + await assessNamedBackupRestore("isolated-cache-backup", { + currentStorage: null, + }); + + const rereadCalls = readFileSpy.mock.calls.filter( + ([path]) => path === backup.path, + ); + expect(rereadCalls).toHaveLength(3); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("limits concurrent backup reads while listing backups", async () => { + const backupPaths: string[] = []; + const totalBackups = NAMED_BACKUP_LIST_CONCURRENCY + 4; + for (let index = 0; index < totalBackups; index += 1) { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: `concurrency-${index}`, + refreshToken: `ref-concurrency-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }, + ], + }); + const backup = await createNamedBackup(`concurrency-${index}`); + backupPaths.push(backup.path); + } + + const originalReadFile = fs.readFile.bind(fs); + const delayedPaths = new Set(backupPaths); + let activeReads = 0; + let peakReads = 0; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [path] = args; + if (delayedPaths.has(String(path))) { + activeReads += 1; + peakReads = Math.max(peakReads, activeReads); + try { + await new Promise((resolve) => setTimeout(resolve, 10)); + return await originalReadFile( + ...(args as Parameters), + ); + } finally { + activeReads -= 1; + } + } + return originalReadFile(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toHaveLength(totalBackups); + expect(peakReads).toBeLessThanOrEqual( + NAMED_BACKUP_LIST_CONCURRENCY, + ); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("serializes concurrent restores so only one succeeds when the limit is tight", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "backup-a-account", + refreshToken: "ref-backup-a-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("backup-a"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "backup-b-account", + refreshToken: "ref-backup-b-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("backup-b"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: Array.from( + { length: ACCOUNT_LIMITS.MAX_ACCOUNTS - 1 }, + (_, index) => ({ + accountId: `current-${index}`, + refreshToken: `ref-current-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }), + ), + }); + + const assessmentA = await assessNamedBackupRestore("backup-a"); + const assessmentB = await assessNamedBackupRestore("backup-b"); + expect(assessmentA.eligibleForRestore).toBe(true); + expect(assessmentB.eligibleForRestore).toBe(true); + + const results = await Promise.allSettled([ + restoreNamedBackup("backup-a"), + restoreNamedBackup("backup-b"), + ]); + const succeeded = results.filter( + (result): result is PromiseFulfilledResult<{ + imported: number; + skipped: number; + total: number; + }> => result.status === "fulfilled", + ); + const failed = results.filter( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + + expect(succeeded).toHaveLength(1); + expect(failed).toHaveLength(1); + expect(String(failed[0]?.reason)).toContain("Import would exceed maximum"); + + const restored = await loadAccounts(); + expect(restored?.accounts).toHaveLength(ACCOUNT_LIMITS.MAX_ACCOUNTS); + }); }); describe("filename migration (TDD)", () => {