diff --git a/README.md b/README.md index 648058cc..dfe7a4c3 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ Codex CLI-first multi-account OAuth manager for the official `@openai/codex` CLI - Canonical `codex auth ...` workflow for account login, switching, checks, and diagnostics - Multi-account OAuth pool with health-aware selection and automatic failover - Project-scoped account storage under `~/.codex/multi-auth/projects//...` -- Interactive dashboard for account actions and settings -- Experimental settings tab for staged sync, backup, and refresh-guard controls +- Interactive dashboard for login, restore, switching, sync preview, and settings +- Productized settings flow with `Everyday Settings` plus `Advanced & Operator` sections for `Codex CLI Sync`, `Experimental`, and `Advanced Backend Controls` - Forecast, report, fix, and doctor commands for operational safety - Flagged account verification and restore flow - Session affinity and live account sync controls @@ -50,6 +50,7 @@ Codex CLI-first multi-account OAuth manager for the official `@openai/codex` CLI ### Option A: Standard install ```bash +npm i -g @openai/codex npm i -g codex-multi-auth ``` @@ -74,16 +75,18 @@ codex auth status ### Step-by-step -1. Install global package: +1. Install global packages: + - `npm i -g @openai/codex` - `npm i -g codex-multi-auth` 2. Run first login flow with `codex auth login` -3. Validate state with `codex auth status` and `codex auth check` +3. Validate state with `codex auth list` and `codex auth check` 4. Confirm routing with `codex auth forecast --live` ### Verification ```bash codex auth status +codex auth list codex auth check ``` @@ -95,7 +98,7 @@ codex auth check ```bash codex auth login -codex auth status +codex auth list codex auth check codex auth forecast --live ``` @@ -109,6 +112,12 @@ codex auth fix --dry-run codex auth doctor --fix ``` +Interactive dashboard paths: + +- restore named backups: `codex auth login` -> `Restore From Backup` +- preview Codex CLI sync: `codex auth login` -> `Settings` -> `Advanced & Operator` -> `Codex CLI Sync` +- adjust stable dashboard preferences: `codex auth login` -> `Settings` -> `Everyday Settings` + --- ## Command Toolkit @@ -170,16 +179,17 @@ Override root with `CODEX_MULTI_AUTH_DIR=`. ## Configuration -Primary config root: -- `~/.codex/multi-auth/settings.json` -- or `CODEX_MULTI_AUTH_DIR/settings.json` when custom root is set +Primary config file: +- `~/.codex/multi-auth/settings.json` by default +- `CODEX_MULTI_AUTH_DIR/settings.json` when a custom root is set +- `CODEX_MULTI_AUTH_CONFIG_PATH=` when you want to override the default config file lookup Selected runtime/environment overrides: | Variable | Effect | | --- | --- | | `CODEX_MULTI_AUTH_DIR` | Override settings/accounts root | -| `CODEX_MULTI_AUTH_CONFIG_PATH` | Alternate config file path | +| `CODEX_MULTI_AUTH_CONFIG_PATH` | Override the default config file path lookup | | `CODEX_MODE=0/1` | Disable/enable Codex mode | | `CODEX_TUI_V2=0/1` | Disable/enable TUI v2 | | `CODEX_TUI_COLOR_PROFILE=truecolor|ansi256|ansi16` | TUI color profile | @@ -201,7 +211,7 @@ codex auth forecast --live The Settings menu now includes an `Experimental` section for staged features: -- preview-first sync into `oc-chatgpt-multi-auth` +- preview-first sync into the companion `oc-chatgpt-multi-auth` account pool - named local pool backup export with filename prompt - refresh guard toggle and interval controls moved out of Backend Controls @@ -234,6 +244,7 @@ codex auth login - `codex auth` unrecognized: run `where codex`, then follow `docs/troubleshooting.md` for routing fallback commands - Switch succeeds but wrong account appears active: run `codex auth switch `, then restart session - OAuth callback on port `1455` fails: free the port and re-run `codex auth login` +- Interactive login skipped restore and went straight to OAuth: place named backups in your active backup root (`$CODEX_MULTI_AUTH_DIR/backups` or `%CODEX_MULTI_AUTH_DIR%\backups`; default examples: `~/.codex/multi-auth/backups/` and `C:\Users\\.codex\multi-auth\backups\`), then rerun `codex auth login` in a normal TTY - `missing field id_token` / `token_expired` / `refresh_token_reused`: re-login affected account diff --git a/docs/README.md b/docs/README.md index a30b8de9..e31230ae 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,13 +17,13 @@ Public documentation for `codex-multi-auth`. | Document | Focus | | --- | --- | -| [index.md](index.md) | Daily-use landing page for common `codex auth ...` workflows | -| [getting-started.md](getting-started.md) | Install, first login, and first health check | +| [index.md](index.md) | Daily-use landing page for login, restore, sync, and diagnostics workflows | +| [getting-started.md](getting-started.md) | Install, first login, startup restore prompt, and first health check | | [faq.md](faq.md) | Short answers to common adoption questions | | [architecture.md](architecture.md) | Public system overview of the wrapper, storage, and optional plugin runtime | -| [features.md](features.md) | User-facing capability map | +| [features.md](features.md) | User-facing capability map, including backup restore, sync center, and settings split | | [configuration.md](configuration.md) | Stable defaults, precedence, and environment overrides | -| [troubleshooting.md](troubleshooting.md) | Recovery playbooks for install, login, switching, and stale state | +| [troubleshooting.md](troubleshooting.md) | Recovery playbooks for install, login, restore, sync, and stale state | | [privacy.md](privacy.md) | Data handling and local storage behavior | | [upgrade.md](upgrade.md) | Migration from legacy package and path history | | [releases/v0.1.9.md](releases/v0.1.9.md) | Stable release notes | @@ -39,8 +39,8 @@ Public documentation for `codex-multi-auth`. | Document | Focus | | --- | --- | -| [reference/commands.md](reference/commands.md) | Commands, flags, and hotkeys | -| [reference/settings.md](reference/settings.md) | Dashboard and runtime settings | +| [reference/commands.md](reference/commands.md) | Commands, flags, hotkeys, and interactive entry points | +| [reference/settings.md](reference/settings.md) | Everyday settings, sync center, and advanced operator controls | | [reference/storage-paths.md](reference/storage-paths.md) | Canonical and compatibility storage paths | | [reference/public-api.md](reference/public-api.md) | Public API stability and semver contract | | [reference/error-contracts.md](reference/error-contracts.md) | CLI, JSON, and helper error semantics | diff --git a/docs/features.md b/docs/features.md index e88c6342..841afada 100644 --- a/docs/features.md +++ b/docs/features.md @@ -9,6 +9,8 @@ User-facing capability map for `codex-multi-auth`. | Capability | What it gives you | Primary entry | | --- | --- | --- | | Multi-account dashboard login | Add and manage multiple OAuth identities from one terminal flow | `codex auth login` | +| Startup recovery prompt | Offer restore before OAuth when recoverable named backups are found and no active accounts exist | `codex auth login` | +| Backup restore manager | Review named backups, merge with dedupe, and skip invalid or over-limit restores | `codex auth login` -> `Restore From Backup` | | Account dedupe and identity normalization | Avoid duplicate saved account rows | login flow | | Explicit active-account switching | Pick the current account by index instead of relying on hidden state | `codex auth switch ` | | Fast and deep health checks | See whether the current pool is usable before a coding session | `codex auth check` | @@ -22,7 +24,7 @@ User-facing capability map for `codex-multi-auth`. | --- | --- | --- | | Readiness and risk forecast | Suggests the best next account | `codex auth forecast` | | Live quota probe mode | Uses live headers for stronger decisions | `codex auth forecast --live` | -| JSON report output | Lets you inspect account state in automation or support workflows | `codex auth report --live --json` | +| JSON report and diagnostics pack | Lets you inspect account state in automation, support, and bug-report workflows | `codex auth report --live --json` | --- @@ -53,6 +55,8 @@ User-facing capability map for `codex-multi-auth`. | --- | --- | | Quick switch and search hotkeys | Faster navigation in the dashboard | | Account action hotkeys | Per-account set, refresh, toggle, and delete shortcuts | +| Productized settings split | Keeps `Everyday Settings` separate from `Advanced & Operator` controls | +| Preview-first sync center | Shows one-way Codex CLI sync results and rollback context before apply | | In-dashboard settings hub | Runtime and display tuning without editing files directly | | Browser-first OAuth with manual fallback | Works in normal and constrained terminal environments | diff --git a/docs/getting-started.md b/docs/getting-started.md index 2de9337b..8caf3fb4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -49,6 +49,8 @@ Expected flow: 4. Return to the terminal when the browser step completes. 5. Confirm the account appears in the saved account list. +If interactive `codex auth login` starts with zero saved accounts and recoverable named backups in your `backups/` directory, the login flow will prompt you to restore before opening OAuth. Confirm to launch the existing restore manager; skip to proceed with a fresh login. The prompt is suppressed in non-interactive/fallback flows and after same-session `fresh` or `reset` actions. + Verify the new account: ```bash @@ -70,6 +72,29 @@ codex auth forecast --live --- +## Restore Or Start Fresh + +Use the restore path when you already have named backup files and want to recover account state before creating new OAuth sessions. + +- Automatic path: run `codex auth login`, then confirm the startup restore prompt when it appears +- Manual path: run `codex auth login`, then choose `Restore From Backup` +- Backup location: `~/.codex/multi-auth/backups/.json` + +The restore manager shows each backup name, account count, freshness, and whether the restore would exceed the account limit before it lets you apply anything. + +--- + +## Sync And Settings + +The settings flow is split into two productized sections: + +- `Everyday Settings` for list appearance, details line, results and refresh behavior, and colors +- `Advanced & Operator` for `Codex CLI Sync`, `Experimental`, and backend tuning + +Use `Codex CLI Sync` when you want to preview one-way sync from official Codex CLI account files before applying it. The sync screen shows source and target paths, preview summary, destination-only preservation, and backup rollback paths before apply. + +--- + ## Day-1 Command Pack ```bash diff --git a/docs/index.md b/docs/index.md index fb4074f8..938054fa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # codex-multi-auth Docs -Daily-use guide for the `codex auth ...` workflow. +Daily-use guide for the `codex auth ...` workflow, including restore, sync, and diagnostics. --- @@ -12,6 +12,8 @@ codex auth list codex auth check ``` +If login detects recoverable named backups before OAuth, confirm the prompt to open `Restore From Backup` first. + If you are choosing an account for the next session: ```bash @@ -39,6 +41,12 @@ codex auth report --live --json codex auth doctor --fix ``` +Interactive workflows that ship in the dashboard: + +- backup restore: `codex auth login` -> `Restore From Backup` +- sync preview and apply: `codex auth login` -> `Settings` -> `Advanced & Operator` -> `Codex CLI Sync` +- settings split: `codex auth login` -> `Settings` -> `Everyday Settings` or `Advanced & Operator` + --- ## Canonical Policy diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 36c735f9..5d5f548b 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -20,12 +20,13 @@ Compatibility aliases are supported: | Command | Description | | --- | --- | -| `codex auth login` | Open interactive auth dashboard | +| `codex auth login` | Open interactive auth dashboard, including login, restore, settings, and dashboard paths and links to diagnostics commands | | `codex auth list` | List saved accounts and active account | | `codex auth status` | Print short runtime/account summary | | `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 | --- @@ -90,16 +91,26 @@ Compatibility aliases are supported: Settings screen hotkeys are panel-specific: -- Account List View: `Enter Toggle | Number Toggle | M Sort | L Layout | S Save | Q Back (No Save)` -- Summary Line: `Enter Toggle | 1-3 Toggle | [ ] Reorder | S Save | Q Back (No Save)` -- Menu Behavior: `Enter Select | 1-3 Delay | P Pause | L AutoFetch | F Status | T TTL | S Save | Q Back (No Save)` -- Color Theme: `Enter Select | 1-2 Base | S Save | Q Back (No Save)` -- Backend Controls: `Enter Open | 1-4 Category | S Save | R Reset | Q Back (No Save)` +- List Appearance: `Enter Toggle | Number Toggle | M Sort | L Layout | S Save | Q Back (No Save)` +- Details Line: `Enter Toggle | 1-3 Toggle | [ ] Reorder | S Save | Q Back (No Save)` +- Results & Refresh: `Enter Select | 1-3 Delay | P Pause | L AutoFetch | F Status | T TTL | S Save | Q Back (No Save)` +- Colors: `Enter Select | 1-2 Base | S Save | Q Back (No Save)` +- Advanced Backend Controls: `Enter Open | 1-4 Category | S Save | R Reset | Q Back (No Save)` --- ## Workflow Packs +Interactive dashboard workflows: + +- Backup restore: `codex auth login` -> `Restore From Backup` +- Startup recovery prompt: interactive `codex auth login` TTY flow only, then confirm restore when recoverable named backups are found before OAuth +- Sync preview and apply: `codex auth login` -> `Settings` -> `Advanced & Operator` -> `Codex CLI Sync` +- Stable settings path: `codex auth login` -> `Settings` -> `Everyday Settings` +- Advanced settings path: `codex auth login` -> `Settings` -> `Advanced & Operator` + +--- + Health and planning: ```bash @@ -111,6 +122,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/settings.md b/docs/reference/settings.md index dfc9e8ee..9134736d 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -1,6 +1,6 @@ # Settings Reference -Reference for dashboard and backend settings available from `codex auth login` -> `Settings`. +Reference for the settings surface available from `codex auth login` -> `Settings`. --- @@ -19,7 +19,11 @@ When `CODEX_MULTI_AUTH_DIR` is set, this root moves accordingly. --- -## Account List View +## Everyday Settings + +The shipped settings menu starts with `Everyday Settings` and keeps the stable dashboard path separate from advanced operator controls. This is the default path for most users. + +### List Appearance Controls account-row display and sorting behavior: @@ -37,7 +41,7 @@ Controls account-row display and sorting behavior: - `menuSortQuickSwitchVisibleRow` - `menuLayoutMode` -## Summary Line +### Details Line Controls detail-line fields and order: @@ -46,7 +50,7 @@ Controls detail-line fields and order: - `limits` - `status` -## Menu Behavior +### Results & Refresh Controls result-screen and fetch behavior: @@ -56,7 +60,7 @@ Controls result-screen and fetch behavior: - `menuShowFetchStatus` - `menuQuotaTtlMs` -## Color Theme +### Colors Controls display style: @@ -66,7 +70,31 @@ Controls display style: --- -## Experimental +## Advanced & Operator + +The second top-level section is `Advanced & Operator`. It holds the sync workflow and backend tuning that are useful when you need to inspect or change lower-level behavior. + +### Codex CLI Sync + +`Codex CLI Sync` is a preview-first sync center for Codex CLI account sync. + +Before applying sync, it shows: + +- target path +- current source path when available +- last sync result for this session +- preview summary (adds, updates, destination-only preserved accounts) +- destination-only preservation behavior +- backup and rollback context (`.bak`, `.bak.1`, `.bak.2`, `.wal`) when storage backups are enabled + +Workflow notes: + +- refresh recomputes the read-only preview from Codex CLI source files +- apply writes the preview result into the target path +- sync is one-way, it is not a bidirectional merge +- target-only accounts are preserved rather than deleted + +### Experimental Experimental currently hosts: @@ -88,7 +116,11 @@ Named backup behavior: - rejects separators, traversal (`..`), `.rotate.`, `.tmp`, and `.wal` suffixes - fails safely on collisions instead of overwriting by default -## Backend Controls +### Advanced Backend Controls + +`Advanced Backend Controls` stay available without changing the saved settings schema. They are grouped into categories so the everyday path can stay simpler for day-to-day use. + +## Backend Categories ### Session & Sync @@ -178,6 +210,7 @@ For most environments: - smart sort enabled - auto-fetch limits enabled +- storage backups enabled when you want rollback context for sync and recovery flows - live sync enabled - session affinity enabled - preemptive quota deferral enabled 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/docs/troubleshooting.md b/docs/troubleshooting.md index ad0a67e5..65ddccc8 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,6 +1,6 @@ # Troubleshooting -Recovery guide for install, login, switching, worktree storage, and stale local auth state. +Recovery guide for install, login, backup restore, sync preview, worktree storage, and stale local auth state. --- @@ -18,6 +18,10 @@ If the account pool is still not usable: codex auth login ``` +If `codex auth login` starts with no saved accounts and recoverable named backups are present, you will be prompted to restore before OAuth. This prompt only appears in interactive terminals and is skipped after same-session fresh/reset flows. + +If you want to inspect backup options yourself instead of taking the prompt immediately, open `codex auth login` and choose `Restore From Backup`. + --- ## Verify Install And Routing @@ -55,6 +59,17 @@ npm i -g codex-multi-auth --- +## Backup Restore Problems + +| Symptom | Likely cause | Action | +| --- | --- | --- | +| You expected a restore prompt but went straight to OAuth | No recoverable named backups were found, the terminal is non-interactive, or the flow is skipping restore after an intentional reset | Verify the active backup root (`$CODEX_MULTI_AUTH_DIR/backups` or `%CODEX_MULTI_AUTH_DIR%\backups`; default examples: `~/.codex/multi-auth/backups/` and `C:\Users\\.codex\multi-auth\backups\`), then rerun `codex auth login` in an interactive terminal | +| `Restore From Backup` says no backups were found | The named backup directory is empty or the files are elsewhere under the active data root | Place backup files in the active backup root (`$CODEX_MULTI_AUTH_DIR/backups` or `%CODEX_MULTI_AUTH_DIR%\backups`) and retry | +| A backup is listed but cannot be selected | The backup is invalid or would exceed the account limit | Trim current accounts first or choose a different backup | +| Restore succeeded but some rows were skipped | Deduping kept the existing matching account state | Run `codex auth list` and `codex auth check` to review the merged result | + +--- + ## Switching And State Problems | Symptom | Likely cause | Action | @@ -65,6 +80,19 @@ npm i -g codex-multi-auth --- +## Codex CLI Sync Problems + +Use `codex auth login` -> `Settings` -> `Advanced & Operator` -> `Codex CLI Sync` when you want to inspect sync state before applying it. + +| Symptom | Likely cause | Action | +| --- | --- | --- | +| Sync preview looks one-way | This is the shipped behavior | Review the preview, then apply only if the target result is what you want | +| You want to confirm target-only accounts are preserved | The sync center preserves destination-only accounts instead of deleting them | Recheck the preview summary before apply | +| You want rollback context before syncing | Backup support is disabled in current settings | Enable storage backups in advanced settings, then refresh the sync preview | +| Active selection does not match expectation | Preview kept the newer local choice or updated from Codex CLI based on selection precedence | Refresh preview and review the selection summary before apply | + +--- + ## Worktrees And Project Storage | Symptom | Likely cause | Action | @@ -87,26 +115,59 @@ codex auth report --live --json codex auth doctor --json ``` +Interactive diagnostics path: + +- `codex auth login` -> `Settings` -> `Advanced & Operator` -> `Codex CLI Sync` for preview-based sync diagnostics +- `codex auth login` -> `Settings` -> `Advanced & Operator` -> `Advanced Backend Controls` for sync, retry, quota, recovery, and timeout tuning + --- -## Soft Reset +## Reset Options -PowerShell: +- Delete a single saved account: `codex auth login` → pick account → **Delete Account** +- Delete saved accounts: `codex auth login` → Danger Zone → **Delete Saved Accounts** +- Reset local state: `codex auth login` → Danger Zone → **Reset Local State** + +Exact effects: + +| Action | Saved accounts | Flagged/problem accounts | Settings | Codex CLI sync state | Quota cache | +| --- | --- | --- | --- | --- | --- | +| Delete Account | Delete the selected saved account | Delete the matching flagged/problem entry for that refresh token | Keep | Keep | Keep | +| Delete Saved Accounts | Delete all saved accounts | Keep | Keep | Keep | Keep | +| Reset Local State | Delete all saved accounts | Delete all flagged/problem accounts | Keep | Keep | Clear | + +To perform the same actions manually: + +Delete saved accounts only: ```powershell Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json" -Force -ErrorAction SilentlyContinue -Remove-Item "$HOME\.codex\multi-auth\openai-codex-flagged-accounts.json" -Force -ErrorAction SilentlyContinue -Remove-Item "$HOME\.codex\multi-auth\settings.json" -Force -ErrorAction SilentlyContinue -codex auth login +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json.wal" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json.bak*" -Force -ErrorAction SilentlyContinue +``` + +```bash +rm -f ~/.codex/multi-auth/openai-codex-accounts.json +rm -f ~/.codex/multi-auth/openai-codex-accounts.json.wal +rm -f ~/.codex/multi-auth/openai-codex-accounts.json.bak* ``` -Bash: +Reset local state (also clears flagged/problem accounts and quota cache; preserves settings and Codex CLI sync state): + +```powershell +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json.wal" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json.bak*" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\openai-codex-flagged-accounts.json" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\quota-cache.json" -Force -ErrorAction SilentlyContinue +``` ```bash rm -f ~/.codex/multi-auth/openai-codex-accounts.json +rm -f ~/.codex/multi-auth/openai-codex-accounts.json.wal +rm -f ~/.codex/multi-auth/openai-codex-accounts.json.bak* rm -f ~/.codex/multi-auth/openai-codex-flagged-accounts.json -rm -f ~/.codex/multi-auth/settings.json -codex auth login +rm -f ~/.codex/multi-auth/quota-cache.json ``` --- diff --git a/docs/upgrade.md b/docs/upgrade.md index e34ecb2d..6f2d453d 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -62,6 +62,12 @@ After source selection, environment variables still override individual setting For day-to-day operator use, prefer stable overrides documented in [configuration.md](configuration.md). For maintainer/debug flows, see advanced/internal controls in [development/CONFIG_FIELDS.md](development/CONFIG_FIELDS.md). +### Startup Recovery Prompt + +Interactive `codex auth login` now offers named-backup recovery before OAuth only when the session starts with zero saved accounts and at least one recoverable named backup. + +The prompt is intentionally skipped in fallback/non-interactive login paths and after same-session `fresh` or `reset` actions so an intentional wipe does not immediately re-offer restore state. + --- ## Legacy Compatibility diff --git a/index.ts b/index.ts index 147959c4..1db25ec2 100644 --- a/index.ts +++ b/index.ts @@ -101,6 +101,12 @@ import { } from "./lib/logger.js"; import { checkAndNotify } from "./lib/auto-update-checker.js"; import { handleContextOverflow } from "./lib/context-overflow.js"; +import { + DESTRUCTIVE_ACTION_COPY, + deleteAccountAtIndex, + deleteSavedAccounts, + resetLocalState, +} from "./lib/destructive-actions.js"; import { AccountManager, getAccountIdCandidates, @@ -122,13 +128,11 @@ import { loadAccounts, saveAccounts, withAccountStorageTransaction, - clearAccounts, setStoragePath, exportAccounts, importAccounts, loadFlaggedAccounts, saveFlaggedAccounts, - clearFlaggedAccounts, findMatchingAccountIndex, StorageError, formatStorageErrorHint, @@ -3101,19 +3105,18 @@ while (attempted.size < Math.max(1, accountCount)) { if (menuResult.mode === "manage") { if (typeof menuResult.deleteAccountIndex === "number") { - const target = workingStorage.accounts[menuResult.deleteAccountIndex]; - if (target) { - workingStorage.accounts.splice(menuResult.deleteAccountIndex, 1); - clampActiveIndices(workingStorage); - await saveAccounts(workingStorage); - await saveFlaggedAccounts({ - version: 1, - accounts: flaggedStorage.accounts.filter( - (flagged) => flagged.refreshToken !== target.refreshToken, - ), - }); + const deleted = await deleteAccountAtIndex({ + storage: workingStorage, + index: menuResult.deleteAccountIndex, + }); + if (deleted) { invalidateAccountManagerCache(); - console.log(`\nDeleted ${target.email ?? `Account ${menuResult.deleteAccountIndex + 1}`}.\n`); + const label = `Account ${menuResult.deleteAccountIndex + 1}`; + const flaggedNote = + deleted.removedFlaggedCount > 0 + ? ` Removed ${deleted.removedFlaggedCount} matching problem account${deleted.removedFlaggedCount === 1 ? "" : "s"}.` + : ""; + console.log(`\nDeleted ${label}.${flaggedNote}\n`); } continue; } @@ -3143,16 +3146,35 @@ while (attempted.size < Math.max(1, accountCount)) { if (menuResult.mode === "fresh") { startFresh = true; if (menuResult.deleteAll) { - await clearAccounts(); - await clearFlaggedAccounts(); + const result = await deleteSavedAccounts(); invalidateAccountManagerCache(); console.log( - "\nCleared saved accounts from active storage. Recovery snapshots remain available. Starting fresh.\n", + `\n${ + result.accountsCleared + ? DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.completed + : "Delete saved accounts completed with warnings. Some saved account artifacts could not be removed; see logs." + }\n`, ); } break; } + if (menuResult.mode === "reset") { + startFresh = true; + const result = await resetLocalState(); + invalidateAccountManagerCache(); + console.log( + `\n${ + result.accountsCleared && + result.flaggedCleared && + result.quotaCacheCleared + ? DESTRUCTIVE_ACTION_COPY.resetLocalState.completed + : "Reset local state completed with warnings. Some local artifacts could not be removed; see logs." + }\n`, + ); + break; + } + startFresh = false; break; } diff --git a/lib/accounts.ts b/lib/accounts.ts index 3eae1306..d872e516 100644 --- a/lib/accounts.ts +++ b/lib/accounts.ts @@ -22,7 +22,11 @@ import { loadCodexCliState, type CodexCliTokenCacheEntry, } from "./codex-cli/state.js"; -import { syncAccountStorageFromCodexCli } from "./codex-cli/sync.js"; +import { + commitCodexCliSyncRunFailure, + commitPendingCodexCliSyncRun, + syncAccountStorageFromCodexCli, +} from "./codex-cli/sync.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; export { @@ -116,7 +120,9 @@ export class AccountManager { if (synced.changed && sourceOfTruthStorage) { try { await saveAccounts(sourceOfTruthStorage); + commitPendingCodexCliSyncRun(synced.pendingRun); } catch (error) { + commitCodexCliSyncRunFailure(synced.pendingRun, error); log.debug("Failed to persist Codex CLI source-of-truth sync", { error: String(error), }); diff --git a/lib/cli.ts b/lib/cli.ts index d223c14c..b0a81b35 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -1,11 +1,12 @@ -import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "node:readline/promises"; +import { DESTRUCTIVE_ACTION_COPY } from "./destructive-actions.js"; import type { AccountIdSource } from "./types.js"; import { - showAuthMenu, - showAccountDetails, - isTTY, type AccountStatus, + isTTY, + showAccountDetails, + showAuthMenu, } from "./ui/auth-menu.js"; import { UI_COPY } from "./ui/copy.js"; @@ -19,12 +20,19 @@ export function isNonInteractiveMode(): boolean { if (!input.isTTY || !output.isTTY) return true; if (process.env.CODEX_TUI === "1") return true; if (process.env.CODEX_DESKTOP === "1") return true; - if ((process.env.TERM_PROGRAM ?? "").trim().toLowerCase() === "codex") return true; + if ((process.env.TERM_PROGRAM ?? "").trim().toLowerCase() === "codex") + return true; if (process.env.ELECTRON_RUN_AS_NODE === "1") return true; return false; } -export async function promptAddAnotherAccount(currentCount: number): Promise { +export function isInteractiveLoginMenuAvailable(): boolean { + return !isNonInteractiveMode() && isTTY(); +} + +export async function promptAddAnotherAccount( + currentCount: number, +): Promise { if (isNonInteractiveMode()) { return false; } @@ -32,7 +40,9 @@ export async function promptAddAnotherAccount(currentCount: number): Promise 6 ? account.accountId.slice(-6) : account.accountId; + const suffix = + account.accountId.length > 6 + ? account.accountId.slice(-6) + : account.accountId; return `${num}. ${suffix}`; } return `${num}. Account`; @@ -112,7 +132,8 @@ function formatAccountLabel(account: ExistingAccountInfo, index: number): string function resolveAccountSourceIndex(account: ExistingAccountInfo): number { const sourceIndex = - typeof account.sourceIndex === "number" && Number.isFinite(account.sourceIndex) + typeof account.sourceIndex === "number" && + Number.isFinite(account.sourceIndex) ? Math.max(0, Math.floor(account.sourceIndex)) : undefined; if (typeof sourceIndex === "number") return sourceIndex; @@ -123,21 +144,40 @@ function resolveAccountSourceIndex(account: ExistingAccountInfo): number { } function warnUnresolvableAccountSelection(account: ExistingAccountInfo): void { - const label = account.email?.trim() || account.accountId?.trim() || `index ${account.index + 1}`; + const label = + account.email?.trim() || + account.accountId?.trim() || + `index ${account.index + 1}`; console.log(`Unable to resolve saved account for action: ${label}`); } async function promptDeleteAllTypedConfirm(): Promise { const rl = createInterface({ input, output }); try { - const answer = await rl.question("Type DELETE to remove all saved accounts: "); + const answer = await rl.question( + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.typedConfirm, + ); return answer.trim() === "DELETE"; } finally { rl.close(); } } -async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): Promise { +async function promptResetTypedConfirm(): Promise { + const rl = createInterface({ input, output }); + try { + const answer = await rl.question( + DESTRUCTIVE_ACTION_COPY.resetLocalState.typedConfirm, + ); + return answer.trim() === "RESET"; + } finally { + rl.close(); + } +} + +async function promptLoginModeFallback( + existingAccounts: ExistingAccountInfo[], +): Promise { const rl = createInterface({ input, output }); try { if (existingAccounts.length > 0) { @@ -152,17 +192,41 @@ async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): const answer = await rl.question(UI_COPY.fallback.selectModePrompt); const normalized = answer.trim().toLowerCase(); if (normalized === "a" || normalized === "add") return { mode: "add" }; - if (normalized === "b" || normalized === "p" || normalized === "forecast") { + if ( + normalized === "b" || + normalized === "p" || + normalized === "forecast" + ) { return { mode: "forecast" }; } if (normalized === "x" || normalized === "fix") return { mode: "fix" }; - if (normalized === "s" || normalized === "settings" || normalized === "configure") { + if ( + normalized === "s" || + normalized === "settings" || + normalized === "configure" + ) { return { mode: "settings" }; } - if (normalized === "f" || normalized === "fresh" || normalized === "clear") { + if ( + normalized === "f" || + normalized === "fresh" || + normalized === "clear" + ) { + if (!(await promptDeleteAllTypedConfirm())) { + console.log("\nDelete saved accounts cancelled.\n"); + continue; + } return { mode: "fresh", deleteAll: true }; } - if (normalized === "c" || normalized === "check") return { mode: "check" }; + if (normalized === "r" || normalized === "reset") { + if (!(await promptResetTypedConfirm())) { + console.log("\nReset local state cancelled.\n"); + continue; + } + return { mode: "reset" }; + } + if (normalized === "c" || normalized === "check") + return { mode: "check" }; if (normalized === "d" || normalized === "deep") { return { mode: "deep-check" }; } @@ -174,7 +238,16 @@ async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): ) { return { mode: "verify-flagged" }; } - if (normalized === "q" || normalized === "quit") return { mode: "cancel" }; + 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); } } finally { @@ -190,7 +263,7 @@ export async function promptLoginMode( return { mode: "add" }; } - if (!isTTY()) { + if (!isInteractiveLoginMenuAvailable()) { return promptLoginModeFallback(existingAccounts); } @@ -211,16 +284,24 @@ export async function promptLoginMode( return { mode: "settings" }; case "fresh": if (!(await promptDeleteAllTypedConfirm())) { - console.log("\nDelete all cancelled.\n"); + console.log("\nDelete saved accounts cancelled.\n"); continue; } return { mode: "fresh", deleteAll: true }; + case "reset-all": + if (!(await promptResetTypedConfirm())) { + console.log("\nReset local state cancelled.\n"); + continue; + } + return { mode: "reset" }; case "check": return { mode: "check" }; case "deep-check": 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") { @@ -278,7 +359,7 @@ export async function promptLoginMode( continue; case "delete-all": if (!(await promptDeleteAllTypedConfirm())) { - console.log("\nDelete all cancelled.\n"); + console.log("\nDelete saved accounts cancelled.\n"); continue; } return { mode: "fresh", deleteAll: true }; @@ -306,7 +387,8 @@ export async function promptAccountSelection( ): Promise { if (candidates.length === 0) return null; const defaultIndex = - typeof options.defaultIndex === "number" && Number.isFinite(options.defaultIndex) + typeof options.defaultIndex === "number" && + Number.isFinite(options.defaultIndex) ? Math.max(0, Math.min(options.defaultIndex, candidates.length - 1)) : 0; @@ -316,7 +398,9 @@ export async function promptAccountSelection( const rl = createInterface({ input, output }); try { - console.log(`\n${options.title ?? "Multiple workspaces detected for this account:"}`); + console.log( + `\n${options.title ?? "Multiple workspaces detected for this account:"}`, + ); candidates.forEach((candidate, index) => { const isDefault = candidate.isDefault ? " (default)" : ""; console.log(` ${index + 1}. ${candidate.label}${isDefault}`); @@ -324,7 +408,9 @@ export async function promptAccountSelection( console.log(""); while (true) { - const answer = await rl.question(`Select workspace [${defaultIndex + 1}]: `); + const answer = await rl.question( + `Select workspace [${defaultIndex + 1}]: `, + ); const normalized = answer.trim().toLowerCase(); if (!normalized) { return candidates[defaultIndex] ?? candidates[0] ?? null; diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index ba0257de..a5eff517 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -1,9 +1,39 @@ +import { promises as fs } from "node:fs"; import { createLogger } from "../logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; -import { type AccountStorageV3 } from "../storage.js"; -import { incrementCodexCliMetric } from "./observability.js"; +import { + type AccountMetadataV3, + type AccountStorageV3, + findMatchingAccountIndex, + getLastAccountsSaveTimestamp, + getStoragePath, + normalizeEmailKey, +} from "../storage.js"; +import { + incrementCodexCliMetric, + makeAccountFingerprint, +} from "./observability.js"; +import { sleep } from "../utils.js"; +import { + type CodexCliAccountSnapshot, + type CodexCliState, + isCodexCliSyncEnabled, + loadCodexCliState, +} from "./state.js"; +import { getLastCodexCliSelectionWriteTimestamp } from "./writer.js"; const log = createLogger("codex-cli-sync"); +const RETRYABLE_SELECTION_TIMESTAMP_CODES = new Set(["EBUSY", "EPERM"]); +export const SELECTION_TIMESTAMP_READ_MAX_ATTEMPTS = 4; + +function createEmptyStorage(): AccountStorageV3 { + return { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; +} function cloneStorage(storage: AccountStorageV3): AccountStorageV3 { return { @@ -16,6 +46,95 @@ function cloneStorage(storage: AccountStorageV3): AccountStorageV3 { }; } +export function formatRollbackPaths(targetPath: string): string[] { + return [ + `${targetPath}.bak`, + `${targetPath}.bak.1`, + `${targetPath}.bak.2`, + `${targetPath}.wal`, + ]; +} + +export interface CodexCliSyncSummary { + sourceAccountCount: number; + targetAccountCountBefore: number; + targetAccountCountAfter: number; + addedAccountCount: number; + updatedAccountCount: number; + unchangedAccountCount: number; + destinationOnlyPreservedCount: number; + selectionChanged: boolean; +} + +export interface CodexCliSyncBackupContext { + enabled: boolean; + targetPath: string; + rollbackPaths: string[]; +} + +export interface CodexCliSyncPreview { + status: "ready" | "noop" | "disabled" | "unavailable" | "error"; + statusDetail: string; + sourcePath: string | null; + sourceAccountCount: number | null; + targetPath: string; + summary: CodexCliSyncSummary; + backup: CodexCliSyncBackupContext; + lastSync: CodexCliSyncRun | null; +} + +export interface CodexCliSyncRun { + outcome: "changed" | "noop" | "disabled" | "unavailable" | "error"; + runAt: number; + sourcePath: string | null; + targetPath: string; + summary: CodexCliSyncSummary; + message?: string; +} + +export interface PendingCodexCliSyncRun { + revision: number; + run: CodexCliSyncRun; +} + +type UpsertAction = "skipped" | "added" | "updated" | "unchanged"; + +interface UpsertResult { + action: UpsertAction; + matchedIndex?: number; +} + +interface ReconcileResult { + next: AccountStorageV3; + changed: boolean; + summary: CodexCliSyncSummary; +} + +let lastCodexCliSyncRun: CodexCliSyncRun | null = null; +let lastCodexCliSyncRunRevision = 0; +let nextCodexCliSyncRunRevision = 0; +const activePendingCodexCliSyncRunRevisions = new Set(); + +function createEmptySyncSummary(): CodexCliSyncSummary { + return { + sourceAccountCount: 0, + targetAccountCountBefore: 0, + targetAccountCountAfter: 0, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }; +} + +function cloneCodexCliSyncRun(run: CodexCliSyncRun): CodexCliSyncRun { + return { + ...run, + summary: { ...run.summary }, + }; +} + function normalizeIndexCandidate(value: number, fallback: number): number { if (!Number.isFinite(value)) { return Number.isFinite(fallback) ? Math.trunc(fallback) : 0; @@ -23,6 +142,287 @@ function normalizeIndexCandidate(value: number, fallback: number): number { return Math.trunc(value); } +function allocateCodexCliSyncRunRevision(): number { + nextCodexCliSyncRunRevision += 1; + return nextCodexCliSyncRunRevision; +} + +function allocatePendingCodexCliSyncRunRevision(): number { + const revision = allocateCodexCliSyncRunRevision(); + activePendingCodexCliSyncRunRevisions.add(revision); + return revision; +} + +function markPendingCodexCliSyncRunCompleted(revision: number): boolean { + return activePendingCodexCliSyncRunRevisions.delete(revision); +} + +function publishCodexCliSyncRun( + run: CodexCliSyncRun, + revision: number, +): boolean { + if (revision <= lastCodexCliSyncRunRevision) { + return false; + } + lastCodexCliSyncRunRevision = revision; + lastCodexCliSyncRun = cloneCodexCliSyncRun(run); + return true; +} + +function buildSyncRunError( + run: CodexCliSyncRun, + error: unknown, +): CodexCliSyncRun { + return { + ...run, + outcome: "error", + message: error instanceof Error ? error.message : String(error), + }; +} + +function createSyncRun( + run: Omit, +): CodexCliSyncRun { + return { + ...run, + runAt: Date.now(), + }; +} + +function hasSourceStateOverride(options: { + sourceState?: CodexCliState | null; +}): boolean { + return Object.prototype.hasOwnProperty.call(options, "sourceState"); +} + +async function resolveCodexCliSyncState(options: { + forceRefresh?: boolean; + sourceState?: CodexCliState | null; +}): Promise { + if (hasSourceStateOverride(options)) { + return options.sourceState ?? null; + } + return loadCodexCliState({ + forceRefresh: options.forceRefresh, + }); +} + +export function getLastCodexCliSyncRun(): CodexCliSyncRun | null { + return lastCodexCliSyncRun ? cloneCodexCliSyncRun(lastCodexCliSyncRun) : null; +} + +export function commitPendingCodexCliSyncRun( + pendingRun: PendingCodexCliSyncRun | null | undefined, +): void { + if (!pendingRun) return; + if (!markPendingCodexCliSyncRunCompleted(pendingRun.revision)) { + return; + } + publishCodexCliSyncRun( + { + ...pendingRun.run, + runAt: Date.now(), + }, + allocateCodexCliSyncRunRevision(), + ); +} + +export function commitCodexCliSyncRunFailure( + pendingRun: PendingCodexCliSyncRun | null | undefined, + error: unknown, +): void { + if (!pendingRun) return; + if (!markPendingCodexCliSyncRunCompleted(pendingRun.revision)) { + return; + } + publishCodexCliSyncRun( + buildSyncRunError( + { + ...pendingRun.run, + runAt: Date.now(), + }, + error, + ), + allocateCodexCliSyncRunRevision(), + ); +} + +export function __resetLastCodexCliSyncRunForTests(): void { + lastCodexCliSyncRun = null; + lastCodexCliSyncRunRevision = 0; + nextCodexCliSyncRunRevision = 0; + activePendingCodexCliSyncRunRevisions.clear(); +} + +function hasConflictingIdentity( + accounts: AccountMetadataV3[], + snapshot: CodexCliAccountSnapshot, +): boolean { + const normalizedEmail = normalizeEmailKey(snapshot.email); + for (const account of accounts) { + if (!account) continue; + if (snapshot.accountId && account.accountId === snapshot.accountId) { + return true; + } + if (snapshot.refreshToken && account.refreshToken === snapshot.refreshToken) { + return true; + } + if (normalizedEmail && normalizeEmailKey(account.email) === normalizedEmail) { + return true; + } + } + return false; +} + +function toStorageAccount( + snapshot: CodexCliAccountSnapshot, +): AccountMetadataV3 | null { + if (!snapshot.refreshToken) return null; + const now = Date.now(); + return { + accountId: snapshot.accountId, + accountIdSource: snapshot.accountId ? "token" : undefined, + email: snapshot.email, + refreshToken: snapshot.refreshToken, + accessToken: snapshot.accessToken, + expiresAt: snapshot.expiresAt, + enabled: true, + addedAt: now, + lastUsed: 0, + }; +} + +function upsertFromSnapshot( + accounts: AccountMetadataV3[], + snapshot: CodexCliAccountSnapshot, +): UpsertResult { + const nextAccount = toStorageAccount(snapshot); + if (!nextAccount) return { action: "skipped" }; + + const targetIndex = findMatchingAccountIndex(accounts, snapshot, { + allowUniqueAccountIdFallbackWithoutEmail: true, + }); + + if (targetIndex === undefined) { + if (hasConflictingIdentity(accounts, snapshot)) { + return { action: "skipped" }; + } + accounts.push(nextAccount); + return { action: "added" }; + } + + const current = accounts[targetIndex]; + if (!current) return { action: "skipped" }; + + const merged: AccountMetadataV3 = { + ...current, + accountId: snapshot.accountId ?? current.accountId, + accountIdSource: snapshot.accountId + ? (current.accountIdSource ?? "token") + : current.accountIdSource, + email: snapshot.email ?? current.email, + refreshToken: snapshot.refreshToken ?? current.refreshToken, + accessToken: snapshot.accessToken ?? current.accessToken, + expiresAt: snapshot.expiresAt ?? current.expiresAt, + }; + + const changed = JSON.stringify(current) !== JSON.stringify(merged); + if (changed) { + accounts[targetIndex] = merged; + } + return { + action: changed ? "updated" : "unchanged", + matchedIndex: targetIndex, + }; +} + +function resolveActiveIndex( + accounts: AccountMetadataV3[], + activeAccountId: string | undefined, + activeEmail: string | undefined, +): number | undefined { + if (accounts.length === 0) return undefined; + if (!activeAccountId && !normalizeEmailKey(activeEmail)) return undefined; + return findMatchingAccountIndex( + accounts, + { + accountId: activeAccountId, + email: activeEmail, + refreshToken: undefined, + }, + { + allowUniqueAccountIdFallbackWithoutEmail: true, + }, + ); +} + +function applyCodexCliSelection( + storage: AccountStorageV3, + index: number, +): void { + const previousActiveIndex = normalizeIndexCandidate(storage.activeIndex, 0); + storage.activeIndex = index; + storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + const raw = storage.activeIndexByFamily[family]; + if ( + typeof raw === "number" && + normalizeIndexCandidate(raw, previousActiveIndex) === previousActiveIndex + ) { + storage.activeIndexByFamily[family] = index; + } + } +} + +async function getPersistedLocalSelectionTimestamp(): Promise { + let lastError: unknown; + for ( + let attempt = 0; + attempt < SELECTION_TIMESTAMP_READ_MAX_ATTEMPTS; + attempt += 1 + ) { + try { + const stats = await fs.stat(getStoragePath()); + return Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : 0; + } catch (error) { + lastError = error; + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return 0; + } + if ( + typeof code !== "string" || + !RETRYABLE_SELECTION_TIMESTAMP_CODES.has(code) + ) { + return null; + } + if (attempt >= SELECTION_TIMESTAMP_READ_MAX_ATTEMPTS - 1) { + log.debug("Exhausted retries reading persisted local selection timestamp", { + error: lastError instanceof Error ? lastError.message : String(lastError), + }); + return null; + } + await sleep(10 * 2 ** attempt); + } + } + return null; +} + +/** + * Normalize and clamp the global and per-family active account indexes to valid ranges. + * + * Mutates `storage` in-place: ensures `activeIndexByFamily` exists, clamps `activeIndex` to + * 0..(accounts.length - 1) (or 0 when there are no accounts), and resolves each family entry + * to a valid index within the same bounds. + * + * Concurrency: callers must synchronize externally when multiple threads/processes may write + * the same storage object. Filesystem notes: no platform-specific IO is performed here; when + * persisted to disk on Windows consumers should still ensure atomic writes. Token handling: + * this function does not read or modify authentication tokens and makes no attempt to redact + * sensitive fields. + * + * @param storage - The account storage object whose indexes will be normalized and clamped + */ function normalizeStoredFamilyIndexes(storage: AccountStorageV3): void { const count = storage.accounts.length; const normalizedActiveIndex = normalizeIndexCandidate(storage.activeIndex, 0); @@ -52,20 +452,398 @@ function normalizeStoredFamilyIndexes(storage: AccountStorageV3): void { } /** - * Preserves one-way mirror semantics for Codex CLI compatibility state. + * Return the `accountId` and `email` from the first snapshot marked active. + * + * @param snapshots - Array of Codex CLI account snapshots to search + * @returns The `accountId` and `email` from the first snapshot whose `isActive` is true; properties are omitted if no active snapshot is found + * + * Concurrency: pure and side-effect free; safe to call concurrently. + * Filesystem: behavior is independent of OS/filesystem semantics (including Windows). + * Security: only `accountId` and `email` are returned; other sensitive snapshot fields (for example tokens) are not exposed or returned by this function. + */ +function readActiveFromSnapshots(snapshots: CodexCliAccountSnapshot[]): { + accountId?: string; + email?: string; +} { + const active = snapshots.find((snapshot) => snapshot.isActive); + return { + accountId: active?.accountId, + email: active?.email, + }; +} + +/** + * Determines whether the Codex CLI's active-account selection should override the local selection. * - * Multi-auth storage is the canonical source of truth. Codex CLI account files are mirrors only - * and must never seed, merge into, or restore the canonical account pool. This helper is kept for - * older call sites that still use the historical reconcile entry point, but it now only normalizes - * the existing local indexes and never reads or applies Codex CLI account data. + * Considers the state's numeric `syncVersion` or `sourceUpdatedAtMs` and compares the derived Codex timestamp + * against local timestamps from recent account saves and last Codex selection writes. Concurrent writes or + * clock skew can affect this decision; filesystem timestamp granularity on Windows may reduce timestamp precision. + * This function only examines timestamps and identifiers in `state` and does not read or expose token values. * - * @param current - The current canonical AccountStorageV3, or null when no canonical storage exists. - * @returns The original storage when no local normalization is needed, a normalized clone when index - * values need clamping, or null when canonical storage is missing. + * @param state - Persisted Codex CLI state (may be undefined); the function reads `syncVersion` and `sourceUpdatedAtMs` when present + * @returns `true` if the Codex CLI selection should be applied (i.e., Codex state is newer or timestamps are unknown), `false` otherwise */ +function shouldApplyCodexCliSelection( + state: Awaited>, + persistedLocalTimestamp: number | null = 0, +): boolean { + if (!state) return false; + const hasSyncVersion = + typeof state.syncVersion === "number" && Number.isFinite(state.syncVersion); + const codexVersion = hasSyncVersion + ? (state.syncVersion as number) + : typeof state.sourceUpdatedAtMs === "number" && + Number.isFinite(state.sourceUpdatedAtMs) + ? state.sourceUpdatedAtMs + : 0; + const inProcessLocalVersion = Math.max( + getLastAccountsSaveTimestamp(), + getLastCodexCliSelectionWriteTimestamp(), + ); + if (persistedLocalTimestamp === null && inProcessLocalVersion <= 0) { + return false; + } + const localVersion = Math.max( + inProcessLocalVersion, + persistedLocalTimestamp ?? 0, + ); + if (codexVersion <= 0) return localVersion <= 0; + if (localVersion <= 0) { + return persistedLocalTimestamp !== null; + } + // When only source mtime is available, require Codex to be at least as new as the + // local selection. A grace window here can overwrite a newer persisted local choice. + return codexVersion >= localVersion; +} + +function reconcileCodexCliState( + current: AccountStorageV3 | null, + state: NonNullable>>, + options: { persistedLocalTimestamp?: number | null } = {}, +): ReconcileResult { + const next = current ? cloneStorage(current) : createEmptyStorage(); + const targetAccountCountBefore = next.accounts.length; + const matchedExistingIndexes = new Set(); + const summary = createEmptySyncSummary(); + summary.sourceAccountCount = state.accounts.length; + summary.targetAccountCountBefore = targetAccountCountBefore; + + let changed = false; + for (const snapshot of state.accounts) { + const result = upsertFromSnapshot(next.accounts, snapshot); + if (result.action === "skipped") continue; + if ( + typeof result.matchedIndex === "number" && + result.matchedIndex >= 0 && + result.matchedIndex < targetAccountCountBefore + ) { + matchedExistingIndexes.add(result.matchedIndex); + } + if (result.action === "added") { + summary.addedAccountCount += 1; + changed = true; + continue; + } + if (result.action === "updated") { + summary.updatedAccountCount += 1; + changed = true; + continue; + } + summary.unchangedAccountCount += 1; + } + + summary.destinationOnlyPreservedCount = Math.max( + 0, + targetAccountCountBefore - matchedExistingIndexes.size, + ); + + if (next.accounts.length > 0) { + const activeFromSnapshots = readActiveFromSnapshots(state.accounts); + const previousActive = next.activeIndex; + const previousFamilies = JSON.stringify(next.activeIndexByFamily ?? {}); + const applyActiveFromCodex = shouldApplyCodexCliSelection( + state, + options.persistedLocalTimestamp, + ); + if (applyActiveFromCodex) { + const desiredIndex = resolveActiveIndex( + next.accounts, + state.activeAccountId ?? activeFromSnapshots.accountId, + state.activeEmail ?? activeFromSnapshots.email, + ); + if (typeof desiredIndex === "number") { + applyCodexCliSelection(next, desiredIndex); + } else if ( + state.activeAccountId || + state.activeEmail || + activeFromSnapshots.accountId || + activeFromSnapshots.email + ) { + log.debug( + "Skipped Codex CLI active selection overwrite due to ambiguous source selection", + { + operation: "reconcile-storage", + outcome: "selection-ambiguous", + }, + ); + } + } else { + log.debug( + "Skipped Codex CLI active selection overwrite due to newer local state", + { + operation: "reconcile-storage", + outcome: "local-newer", + }, + ); + } + normalizeStoredFamilyIndexes(next); + const currentFamilies = JSON.stringify(next.activeIndexByFamily ?? {}); + if ( + previousActive !== next.activeIndex || + previousFamilies !== currentFamilies + ) { + summary.selectionChanged = true; + changed = true; + } + } + + summary.targetAccountCountAfter = next.accounts.length; + return { next, changed, summary }; +} + +export async function previewCodexCliSync( + current: AccountStorageV3 | null, + options: { + forceRefresh?: boolean; + storageBackupEnabled?: boolean; + sourceState?: CodexCliState | null; + } = {}, +): Promise { + const targetPath = getStoragePath(); + const syncEnabled = isCodexCliSyncEnabled(); + const backup = { + enabled: options.storageBackupEnabled ?? true, + targetPath, + rollbackPaths: formatRollbackPaths(targetPath), + }; + const lastSync = getLastCodexCliSyncRun(); + const emptySummary = createEmptySyncSummary(); + emptySummary.targetAccountCountBefore = current?.accounts.length ?? 0; + emptySummary.targetAccountCountAfter = current?.accounts.length ?? 0; + try { + if (!syncEnabled) { + return { + status: "disabled", + statusDetail: "Codex CLI sync is disabled by environment override.", + sourcePath: null, + sourceAccountCount: null, + targetPath, + summary: emptySummary, + backup, + lastSync, + }; + } + const state = await resolveCodexCliSyncState(options); + if (!state) { + return { + status: "unavailable", + statusDetail: "No Codex CLI sync source was found.", + sourcePath: null, + sourceAccountCount: null, + targetPath, + summary: emptySummary, + backup, + lastSync, + }; + } + + const reconciled = reconcileCodexCliState(current, state, { + persistedLocalTimestamp: await getPersistedLocalSelectionTimestamp(), + }); + const status = reconciled.changed ? "ready" : "noop"; + const skippedAccountCount = Math.max( + 0, + reconciled.summary.sourceAccountCount - + reconciled.summary.addedAccountCount - + reconciled.summary.updatedAccountCount - + reconciled.summary.unchangedAccountCount, + ); + const statusDetail = reconciled.changed + ? `Preview ready: ${reconciled.summary.addedAccountCount} add, ${reconciled.summary.updatedAccountCount} update, ${reconciled.summary.destinationOnlyPreservedCount} destination-only preserved${ + skippedAccountCount > 0 ? `, ${skippedAccountCount} skipped` : "" + }.` + : skippedAccountCount > 0 + ? `Target already matches the current one-way sync result. ${skippedAccountCount} source account skipped due to conflicting or incomplete identity.` + : "Target already matches the current one-way sync result."; + return { + status, + statusDetail, + sourcePath: state.path, + sourceAccountCount: state.accounts.length, + targetPath, + summary: reconciled.summary, + backup, + lastSync, + }; + } catch (error) { + return { + status: "error", + statusDetail: error instanceof Error ? error.message : String(error), + sourcePath: null, + sourceAccountCount: null, + targetPath, + summary: emptySummary, + backup, + lastSync, + }; + } +} + +/** + * Reconciles the provided local account storage with the Codex CLI state and returns the resulting storage and whether it changed. + * + * This operation: + * - Merges accounts from the Codex CLI state into a clone of `current` (or into a new empty storage when `current` is null). + * - May update the active account selection and per-family active indexes when the Codex CLI selection is considered applicable. + * - Preserves secrets and sensitive fields; any tokens written to storage are subject to the project's token-redaction rules and are not exposed in logs or metrics. + * + * Concurrency assumptions: + * - Caller is responsible for serializing concurrent writes to persistent storage; this function only returns an in-memory storage object and does not perform atomic file-level coordination. + * + * Windows filesystem notes: + * - When the caller persists the returned storage to disk on Windows, standard Windows file-locking and path-length semantics apply; this function does not perform Windows-specific path normalization. + * + * @param current - The current local AccountStorageV3, or `null` to indicate none exists. + * @returns An object containing: + * - `storage`: the reconciled AccountStorageV3 to persist (may be the original `current` when no changes were applied). + * - `changed`: `true` if the reconciled storage differs from `current`, `false` otherwise. + */ +export async function applyCodexCliSyncToStorage( + current: AccountStorageV3 | null, + options: { forceRefresh?: boolean; sourceState?: CodexCliState | null } = {}, +): Promise<{ + storage: AccountStorageV3 | null; + changed: boolean; + pendingRun: PendingCodexCliSyncRun | null; +}> { + incrementCodexCliMetric("reconcileAttempts"); + const targetPath = getStoragePath(); + try { + if (!isCodexCliSyncEnabled()) { + incrementCodexCliMetric("reconcileNoops"); + publishCodexCliSyncRun( + createSyncRun({ + outcome: "disabled", + sourcePath: null, + targetPath, + summary: { + ...createEmptySyncSummary(), + targetAccountCountBefore: current?.accounts.length ?? 0, + targetAccountCountAfter: current?.accounts.length ?? 0, + }, + message: "Codex CLI sync disabled by environment override.", + }), + allocateCodexCliSyncRunRevision(), + ); + return { storage: current, changed: false, pendingRun: null }; + } + + const state = await resolveCodexCliSyncState({ + ...options, + forceRefresh: options.forceRefresh ?? true, + }); + if (!state) { + incrementCodexCliMetric("reconcileNoops"); + publishCodexCliSyncRun( + createSyncRun({ + outcome: "unavailable", + sourcePath: null, + targetPath, + summary: { + ...createEmptySyncSummary(), + targetAccountCountBefore: current?.accounts.length ?? 0, + targetAccountCountAfter: current?.accounts.length ?? 0, + }, + message: "No Codex CLI sync source was available.", + }), + allocateCodexCliSyncRunRevision(), + ); + return { storage: current, changed: false, pendingRun: null }; + } + + const reconciled = reconcileCodexCliState(current, state, { + persistedLocalTimestamp: await getPersistedLocalSelectionTimestamp(), + }); + const next = reconciled.next; + const changed = reconciled.changed; + const storage = + next.accounts.length === 0 ? (current ?? next) : next; + const syncRun = createSyncRun({ + outcome: changed ? "changed" : "noop", + sourcePath: state.path, + targetPath, + summary: reconciled.summary, + }); + + if (!changed) { + incrementCodexCliMetric("reconcileNoops"); + publishCodexCliSyncRun(syncRun, allocateCodexCliSyncRunRevision()); + } else { + incrementCodexCliMetric("reconcileChanges"); + } + + const activeFromSnapshots = readActiveFromSnapshots(state.accounts); + log.debug("Codex CLI reconcile completed", { + operation: "reconcile-storage", + outcome: changed ? "changed" : "noop", + accountCount: next.accounts.length, + activeAccountRef: makeAccountFingerprint({ + accountId: state.activeAccountId ?? activeFromSnapshots.accountId, + email: state.activeEmail ?? activeFromSnapshots.email, + }), + }); + return { + storage, + changed, + pendingRun: changed + ? { + revision: allocatePendingCodexCliSyncRunRevision(), + run: syncRun, + } + : null, + }; + } catch (error) { + incrementCodexCliMetric("reconcileFailures"); + publishCodexCliSyncRun( + createSyncRun({ + outcome: "error", + sourcePath: null, + targetPath, + summary: { + ...createEmptySyncSummary(), + targetAccountCountBefore: current?.accounts.length ?? 0, + targetAccountCountAfter: current?.accounts.length ?? 0, + }, + message: error instanceof Error ? error.message : String(error), + }), + allocateCodexCliSyncRunRevision(), + ); + log.warn("Codex CLI reconcile failed", { + operation: "reconcile-storage", + outcome: "error", + error: String(error), + }); + return { storage: current, changed: false, pendingRun: null }; + } +} + export function syncAccountStorageFromCodexCli( current: AccountStorageV3 | null, -): Promise<{ storage: AccountStorageV3 | null; changed: boolean }> { +): Promise<{ + storage: AccountStorageV3 | null; + changed: boolean; + pendingRun: PendingCodexCliSyncRun | null; +}> { incrementCodexCliMetric("reconcileAttempts"); if (!current) { @@ -74,7 +852,7 @@ export function syncAccountStorageFromCodexCli( operation: "reconcile-storage", outcome: "canonical-missing", }); - return Promise.resolve({ storage: null, changed: false }); + return Promise.resolve({ storage: null, changed: false, pendingRun: null }); } const next = cloneStorage(current); @@ -87,15 +865,19 @@ export function syncAccountStorageFromCodexCli( previousFamilies !== JSON.stringify(next.activeIndexByFamily ?? {}); incrementCodexCliMetric(changed ? "reconcileChanges" : "reconcileNoops"); - log.debug("Skipped Codex CLI authority import; canonical storage remains authoritative", { - operation: "reconcile-storage", - outcome: changed ? "normalized-local-indexes" : "canonical-authoritative", - accountCount: next.accounts.length, - }); + log.debug( + "Skipped Codex CLI authority import; canonical storage remains authoritative", + { + operation: "reconcile-storage", + outcome: changed ? "normalized-local-indexes" : "canonical-authoritative", + accountCount: next.accounts.length, + }, + ); return Promise.resolve({ storage: changed ? next : current, changed, + pendingRun: null, }); } diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index b3212c30..c4690218 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -10,7 +10,12 @@ import { } from "./auth/auth.js"; import { startLocalOAuthServer } from "./auth/server.js"; import { copyTextToClipboard, openBrowserUrl } from "./auth/browser.js"; -import { promptAddAnotherAccount, promptLoginMode, type ExistingAccountInfo } from "./cli.js"; +import { + isInteractiveLoginMenuAvailable, + promptAddAnotherAccount, + promptLoginMode, + type ExistingAccountInfo, +} from "./cli.js"; import { extractAccountEmail, extractAccountId, @@ -29,6 +34,12 @@ import { type DashboardDisplaySettings, type DashboardAccountSortMode, } from "./dashboard-settings.js"; +import { + DESTRUCTIVE_ACTION_COPY, + deleteAccountAtIndex, + deleteSavedAccounts, + resetLocalState, +} from "./destructive-actions.js"; import { evaluateForecastAccounts, isHardRefreshFailure, @@ -50,7 +61,13 @@ import { type QuotaCacheEntry, } from "./quota-cache.js"; import { - clearAccounts, + assessNamedBackupRestore, + getActionableNamedBackupRestores, + getRedactedFilesystemErrorLabel, + getNamedBackupsDirectoryPath, + listNamedBackups, + NAMED_BACKUP_LIST_CONCURRENCY, + restoreNamedBackup, findMatchingAccountIndex, getStoragePath, loadFlaggedAccounts, @@ -72,6 +89,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"; @@ -86,6 +104,8 @@ type TokenSuccessWithAccount = TokenSuccess & { }; type PromptTone = "accent" | "success" | "warning" | "danger" | "muted"; +let destructiveActionInFlight = false; + function stylePromptText(text: string, tone: PromptTone): string { if (!output.isTTY) return text; const ui = getUiRuntimeOptions(); @@ -118,6 +138,17 @@ 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) 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; @@ -296,6 +327,7 @@ function printUsage(): void { " codex auth switch ", " codex auth check", " codex auth features", + " codex auth restore-backup", " codex auth verify-flagged [--dry-run] [--json] [--no-restore]", " codex auth forecast [--live] [--json] [--model ]", " codex auth report [--live] [--json] [--model ] [--out ]", @@ -3732,10 +3764,6 @@ async function runDoctor(args: string[]): Promise { return summary.error > 0 ? 1 : 0; } -async function clearAccountsAndReset(): Promise { - await clearAccounts(); -} - async function handleManageAction( storage: AccountStorageV3, menuResult: Awaited>, @@ -3749,14 +3777,18 @@ async function handleManageAction( if (typeof menuResult.deleteAccountIndex === "number") { const idx = menuResult.deleteAccountIndex; if (idx >= 0 && idx < storage.accounts.length) { - storage.accounts.splice(idx, 1); - storage.activeIndex = 0; - storage.activeIndexByFamily = {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = 0; + const deleted = await deleteAccountAtIndex({ + storage, + index: idx, + }); + if (deleted) { + const label = `Account ${idx + 1}`; + const flaggedNote = + deleted.removedFlaggedCount > 0 + ? ` Removed ${deleted.removedFlaggedCount} matching problem account${deleted.removedFlaggedCount === 1 ? "" : "s"}.` + : ""; + console.log(`Deleted ${label}.${flaggedNote}`); } - await saveAccounts(storage); - console.log(`Deleted account ${idx + 1}.`); } return; } @@ -3792,20 +3824,53 @@ async function handleManageAction( } } +type StartupRecoveryAction = + | "continue-with-oauth" + | "open-empty-storage-menu" + | "show-recovery-prompt"; + +export function resolveStartupRecoveryAction( + recoveryState: Awaited>, + recoveryScanFailed: boolean, +): StartupRecoveryAction { + if (recoveryState.assessments.length > 0) { + return "show-recovery-prompt"; + } + return recoveryScanFailed + ? "continue-with-oauth" + : "open-empty-storage-menu"; +} + async function runAuthLogin(): Promise { setStoragePath(null); + let suppressRecoveryPrompt = false; + let recoveryPromptAttempted = false; + let allowEmptyStorageMenu = false; + let pendingRecoveryState: Awaited< + ReturnType + > | null = null; let pendingMenuQuotaRefresh: Promise | null = null; let menuQuotaRefreshStatus: string | undefined; loginFlow: while (true) { let existingStorage = await loadAccounts(); - if (existingStorage && existingStorage.accounts.length > 0) { + const canOpenEmptyStorageMenu = + allowEmptyStorageMenu && isInteractiveLoginMenuAvailable(); + if ( + (existingStorage && existingStorage.accounts.length > 0) || + canOpenEmptyStorageMenu + ) { + const menuAllowsEmptyStorage = canOpenEmptyStorageMenu; + allowEmptyStorageMenu = false; + pendingRecoveryState = null; while (true) { existingStorage = await loadAccounts(); if (!existingStorage || existingStorage.accounts.length === 0) { - break; + if (!menuAllowsEmptyStorage) { + break; + } } - const currentStorage = existingStorage; + const currentStorage = existingStorage ?? createEmptyAccountStorage(); const displaySettings = await loadDashboardDisplaySettings(); applyUiThemeFromDashboardSettings(displaySettings); const quotaCache = await loadQuotaCache(); @@ -3835,80 +3900,239 @@ async function runAuthLogin(): Promise { }); } } - 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 === "cancel") { + console.log("Cancelled."); + return 0; + } + const modeRequiresDrainedQuotaRefresh = + menuResult.mode === "check" || + menuResult.mode === "deep-check" || + menuResult.mode === "forecast" || + menuResult.mode === "fix"; + if (modeRequiresDrainedQuotaRefresh) { + const pendingQuotaRefresh = pendingMenuQuotaRefresh; + if (pendingQuotaRefresh) { + await pendingQuotaRefresh; } - if (menuResult.mode === "check") { - await runActionPanel("Quick Check", "Checking local session + live status", async () => { - await runHealthCheck({ forceRefresh: false, liveProbe: true }); - }, 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 { + const pendingQuotaRefresh = pendingMenuQuotaRefresh; + if (pendingQuotaRefresh) { + await pendingQuotaRefresh; + } + 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 === "deep-check") { - await runActionPanel("Deep Check", "Refreshing and testing all accounts", async () => { - await runHealthCheck({ forceRefresh: true, liveProbe: true }); - }, 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 === "forecast") { - await runActionPanel("Best Account", "Comparing accounts", async () => { - await runForecast(["--live"]); - }, 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 === "fix") { - await runActionPanel("Auto-Fix", "Checking and fixing common issues", async () => { - await runFix(["--live"]); - }, displaySettings); + suppressRecoveryPrompt = true; + continue; + } + if (menuResult.mode === "reset") { + if (destructiveActionInFlight) { + console.log("Another destructive action is already running. Wait for it to finish."); continue; } - if (menuResult.mode === "settings") { - await configureUnifiedSettings(displaySettings); - 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 === "verify-flagged") { - await runActionPanel("Problem Account Check", "Checking problem accounts", async () => { - await runVerifyFlagged([]); - }, displaySettings); + suppressRecoveryPrompt = true; + continue; + } + if (menuResult.mode === "manage") { + const requiresInteractiveOAuth = typeof menuResult.refreshAccountIndex === "number"; + if (requiresInteractiveOAuth) { + await handleManageAction(currentStorage, menuResult); continue; } - if (menuResult.mode === "fresh" && menuResult.deleteAll) { - await runActionPanel("Reset Accounts", "Deleting all saved accounts", async () => { - await clearAccountsAndReset(); - console.log("Cleared saved accounts from active storage. Recovery snapshots remain available."); - }, displaySettings); - continue; + await runActionPanel("Applying Change", "Updating selected account", async () => { + await handleManageAction(currentStorage, menuResult); + }, displaySettings); + continue; + } + if (menuResult.mode === "add") { + break; + } + } + } + + const refreshedStorage = await loadAccounts(); + const existingCount = refreshedStorage?.accounts.length ?? 0; + const canPromptForRecovery = + !suppressRecoveryPrompt && + !recoveryPromptAttempted && + existingCount === 0 && + isInteractiveLoginMenuAvailable(); + if (canPromptForRecovery) { + recoveryPromptAttempted = true; + let recoveryState: Awaited< + ReturnType + > | null = pendingRecoveryState; + pendingRecoveryState = null; + if (recoveryState === null) { + let recoveryScanFailed = false; + let scannedRecoveryState: Awaited< + ReturnType + >; + try { + scannedRecoveryState = await getActionableNamedBackupRestores({ + currentStorage: refreshedStorage, + }); + } catch (error) { + recoveryScanFailed = true; + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn( + `Startup recovery scan failed (${errorLabel}). Continuing with OAuth.`, + ); + scannedRecoveryState = { + assessments: [], + allAssessments: [], + totalBackups: 0, + }; + } + recoveryState = scannedRecoveryState; + if ( + resolveStartupRecoveryAction(scannedRecoveryState, recoveryScanFailed) === + "open-empty-storage-menu" + ) { + allowEmptyStorageMenu = true; + continue loginFlow; } - if (menuResult.mode === "manage") { - const requiresInteractiveOAuth = typeof menuResult.refreshAccountIndex === "number"; - if (requiresInteractiveOAuth) { - await handleManageAction(currentStorage, menuResult); + } + if (recoveryState.assessments.length > 0) { + let promptWasShown = false; + try { + const displaySettings = await loadDashboardDisplaySettings(); + applyUiThemeFromDashboardSettings(displaySettings); + const backupDir = getNamedBackupsDirectoryPath(); + const backupLabel = + recoveryState.assessments.length === 1 + ? recoveryState.assessments + .map((assessment) => assessment.backup.name) + .join("") + : `${recoveryState.assessments.length} backups`; + promptWasShown = true; + const restoreNow = await confirm( + `Found ${recoveryState.assessments.length} recoverable backup${ + recoveryState.assessments.length === 1 ? "" : "s" + } out of ${recoveryState.totalBackups} total (${backupLabel}) in ${backupDir}. Restore now?`, + ); + if (restoreNow) { + const restoreResult = await runBackupRestoreManager( + displaySettings, + recoveryState.allAssessments, + ); + if (restoreResult !== "restored") { + pendingRecoveryState = recoveryState; + recoveryPromptAttempted = false; + } continue; } - await runActionPanel("Applying Change", "Updating selected account", async () => { - await handleManageAction(currentStorage, menuResult); - }, displaySettings); - continue; - } - if (menuResult.mode === "add") { - break; + } catch (error) { + if (!promptWasShown) { + recoveryPromptAttempted = false; + } + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn( + `Startup recovery prompt failed (${errorLabel}). Continuing with OAuth.`, + ); } } } - - const refreshedStorage = await loadAccounts(); - const existingCount = refreshedStorage?.accounts.length ?? 0; let forceNewLogin = existingCount > 0; while (true) { const tokenResult = await runOAuthFlow(forceNewLogin); @@ -4117,6 +4341,170 @@ export async function autoSyncActiveAccountToCodex(): Promise { }); } +type BackupMenuAction = + | { + type: "restore"; + assessment: BackupRestoreAssessment; + } + | { type: "back" }; + +type BackupRestoreAssessment = Awaited< + ReturnType +>; + +type BackupRestoreManagerResult = "restored" | "dismissed" | "failed"; + +async function loadBackupRestoreManagerAssessments(): Promise< + BackupRestoreAssessment[] +> { + let backups: Awaited>; + try { + backups = await listNamedBackups(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error( + `Could not read backup directory: ${ + collapseWhitespace(message) || "unknown error" + }`, + ); + return []; + } + if (backups.length === 0) { + return []; + } + + const currentStorage = await loadAccounts(); + const assessments: BackupRestoreAssessment[] = []; + for ( + let index = 0; + index < backups.length; + index += NAMED_BACKUP_LIST_CONCURRENCY + ) { + const chunk = backups.slice(index, index + NAMED_BACKUP_LIST_CONCURRENCY); + const settledAssessments = await Promise.allSettled( + chunk.map((backup) => + assessNamedBackupRestore(backup.name, { currentStorage }), + ), + ); + for (const [resultIndex, result] of settledAssessments.entries()) { + if (result.status === "fulfilled") { + assessments.push(result.value); + continue; + } + const backupName = chunk[resultIndex]?.name ?? "unknown"; + const reason = + result.reason instanceof Error + ? result.reason.message + : String(result.reason); + console.warn( + `Skipped backup assessment for "${backupName}": ${ + collapseWhitespace(reason) || "unknown error" + }`, + ); + } + } + + return assessments; +} + +async function runBackupRestoreManager( + displaySettings: DashboardDisplaySettings, + assessmentsOverride?: BackupRestoreAssessment[], +): Promise { + const backupDir = getNamedBackupsDirectoryPath(); + const assessments = + assessmentsOverride ?? (await loadBackupRestoreManagerAssessments()); + if (assessments.length === 0) { + console.log(`No named backups found. Place backup files in ${backupDir}.`); + return "dismissed"; + } + + 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 "dismissed"; + } + + let latestAssessment: BackupRestoreAssessment; + try { + latestAssessment = await assessNamedBackupRestore( + selection.assessment.backup.name, + { currentStorage: await loadAccounts() }, + ); + } catch (error) { + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn( + `Failed to re-assess backup "${selection.assessment.backup.name}" before restore (${errorLabel}).`, + ); + return "failed"; + } + if (!latestAssessment.eligibleForRestore) { + console.log(latestAssessment.error ?? "Backup is not eligible for restore."); + return "failed"; + } + + const confirmMessage = `Restore backup "${latestAssessment.backup.name}"? This will merge ${latestAssessment.backup.accountCount ?? 0} account(s) into ${latestAssessment.currentAccountCount} current (${latestAssessment.mergedAccountCount ?? latestAssessment.currentAccountCount} after dedupe).`; + const confirmed = await confirm(confirmMessage); + if (!confirmed) return "dismissed"; + + try { + const result = await restoreNamedBackup(latestAssessment.backup.name); + console.log( + `Restored backup "${latestAssessment.backup.name}". Imported ${result.imported}, skipped ${result.skipped}, total ${result.total}.`, + ); + return "restored"; + } catch (error) { + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn( + `Failed to restore backup "${latestAssessment.backup.name}" (${errorLabel}).`, + ); + return "failed"; + } +} + export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { const startupDisplaySettings = await loadDashboardDisplaySettings(); applyUiThemeFromDashboardSettings(startupDisplaySettings); @@ -4145,6 +4533,11 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { if (command === "login") { return runAuthLogin(); } + if (command === "restore-backup") { + return (await runBackupRestoreManager(startupDisplaySettings)) === "failed" + ? 1 + : 0; + } if (command === "list" || command === "status") { await showAccountStatus(); return 0; diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 172648d3..5af5a386 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -1,8 +1,28 @@ import { promises as fs } from "node:fs"; import { stdin as input, stdout as output } from "node:process"; import { createInterface } from "node:readline/promises"; +import { + type CodexCliState, + getCodexCliAccountsPath, + getCodexCliAuthPath, + getCodexCliConfigPath, + isCodexCliSyncEnabled, + loadCodexCliState, +} from "../codex-cli/state.js"; +import { + applyCodexCliSyncToStorage, + commitCodexCliSyncRunFailure, + commitPendingCodexCliSyncRun, + type CodexCliSyncPreview, + type CodexCliSyncRun, + type CodexCliSyncSummary, + formatRollbackPaths, + getLastCodexCliSyncRun, + previewCodexCliSync, +} from "../codex-cli/sync.js"; import { getDefaultPluginConfig, + getStorageBackupEnabled, loadPluginConfig, savePluginConfig, } from "../config.js"; @@ -17,13 +37,22 @@ import { loadDashboardDisplaySettings, saveDashboardDisplaySettings, } from "../dashboard-settings.js"; +import { + getLastLiveAccountSyncSnapshot, + type LiveAccountSyncSnapshot, +} from "../live-account-sync.js"; import { applyOcChatgptSync, planOcChatgptSync, runNamedBackupExport, } from "../oc-chatgpt-orchestrator.js"; import { detectOcChatgptMultiAuthTarget } from "../oc-chatgpt-target-detection.js"; -import { loadAccounts, normalizeAccountStorage } from "../storage.js"; +import { + getStoragePath, + loadAccounts, + normalizeAccountStorage, + saveAccounts, +} from "../storage.js"; import type { PluginConfig } from "../types.js"; import { ANSI } from "../ui/ansi.js"; import { UI_COPY } from "../ui/copy.js"; @@ -264,6 +293,7 @@ type BackendSettingsHubAction = type SettingsHubAction = | { type: "account-list" } + | { type: "sync-center" } | { type: "summary-fields" } | { type: "behavior" } | { type: "theme" } @@ -271,6 +301,20 @@ type SettingsHubAction = | { type: "backend" } | { type: "back" }; +type SyncCenterAction = + | { type: "refresh" } + | { type: "apply" } + | { type: "back" }; + +interface SyncCenterOverviewContext { + accountsPath: string; + authPath: string; + configPath: string; + sourceAccountCount: number | null; + liveSync: LiveAccountSyncSnapshot; + syncEnabled: boolean; +} + type ExperimentalSettingsAction = | { type: "sync" } | { type: "backup" } @@ -280,7 +324,6 @@ type ExperimentalSettingsAction = | { type: "apply" } | { type: "save" } | { type: "back" }; - const BACKEND_TOGGLE_OPTIONS: BackendToggleSettingOption[] = [ { key: "liveAccountSync", @@ -782,7 +825,6 @@ async function readFileWithRetry(path: string): Promise { } } } - async function persistBackendConfigSelection( selected: PluginConfig, scope: string, @@ -1237,6 +1279,163 @@ function formatMenuQuotaTtl(ttlMs: number): string { return `${ttlMs}ms`; } +function formatSyncRunTime(run: CodexCliSyncRun | null): string { + if (!run) return "No sync applied in this session."; + return new Date(run.runAt).toISOString().replace("T", " "); +} + +function formatSyncRunOutcome(run: CodexCliSyncRun | null): string { + if (!run) return "none"; + if (run.outcome === "changed") return "applied changes"; + if (run.outcome === "noop") return "already aligned"; + if (run.outcome === "disabled") return "disabled"; + if (run.outcome === "unavailable") return "source missing"; + return run.message ? `error: ${run.message}` : "error"; +} + +function formatSyncSummary(summary: CodexCliSyncSummary): string { + return [ + `add ${summary.addedAccountCount}`, + `update ${summary.updatedAccountCount}`, + `preserve ${summary.destinationOnlyPreservedCount}`, + `after ${summary.targetAccountCountAfter}`, + ].join(" | "); +} + +function formatSyncTimestamp(timestamp: number | null | undefined): string { + if ( + typeof timestamp !== "number" || + !Number.isFinite(timestamp) || + timestamp <= 0 + ) { + return "none"; + } + return new Date(timestamp).toISOString().replace("T", " "); +} + +function formatSyncMtime(mtimeMs: number | null): string { + if ( + typeof mtimeMs !== "number" || + !Number.isFinite(mtimeMs) || + mtimeMs <= 0 + ) { + return "unknown"; + } + return new Date(Math.round(mtimeMs)).toISOString().replace("T", " "); +} + +function resolveSyncCenterContext( + sourceAccountCount: number | null, +): SyncCenterOverviewContext { + return { + accountsPath: getCodexCliAccountsPath(), + authPath: getCodexCliAuthPath(), + configPath: getCodexCliConfigPath(), + sourceAccountCount, + liveSync: getLastLiveAccountSyncSnapshot(), + syncEnabled: isCodexCliSyncEnabled(), + }; +} + +function formatSyncSourceLabel( + preview: CodexCliSyncPreview, + context: SyncCenterOverviewContext, +): string { + const normalizedSourcePath = normalizePathForComparison(preview.sourcePath); + const normalizedAccountsPath = normalizePathForComparison(context.accountsPath); + const normalizedAuthPath = normalizePathForComparison(context.authPath); + if (!context.syncEnabled) return "disabled by environment override"; + if (!normalizedSourcePath) return "not available"; + if (normalizedSourcePath === normalizedAccountsPath) + return "accounts.json active"; + if (normalizedSourcePath === normalizedAuthPath) + return "auth.json fallback active"; + return "custom source path active"; +} + +function normalizePathForComparison( + path: string | null | undefined, +): string | null { + if (typeof path !== "string" || path.length === 0) { + return null; + } + const normalized = path.replace(/\\/g, "/").replace(/\/+/g, "/"); + const trimmed = + normalized.length > 1 ? normalized.replace(/\/+$/, "") : normalized; + const isWindowsPath = path.includes("\\") || /^[a-z]:\//i.test(trimmed); + return isWindowsPath ? trimmed.toLowerCase() : trimmed; +} + +function buildSyncCenterOverview( + preview: CodexCliSyncPreview, + context: SyncCenterOverviewContext = resolveSyncCenterContext(null), +): Array<{ label: string; hint?: string }> { + const lastSync = preview.lastSync; + const activeSourceLabel = formatSyncSourceLabel(preview, context); + const liveSync = context.liveSync; + const liveSyncLabel = liveSync.running ? "running" : "idle"; + const liveSyncHint = liveSync.running + ? `Watching ${liveSync.path ?? preview.targetPath}. Reloads ${liveSync.reloadCount}, errors ${liveSync.errorCount}, last reload ${formatSyncTimestamp(liveSync.lastSyncAt)}, last seen mtime ${formatSyncMtime(liveSync.lastKnownMtimeMs)}.` + : `No live watcher is active in this process. When plugin mode runs with live sync enabled, it watches ${preview.targetPath} and reloads accounts after file changes.`; + const sourceStateHint = [ + `Active source: ${activeSourceLabel}.`, + `Accounts path: ${context.accountsPath}`, + `Auth path: ${context.authPath}`, + `Config path: ${context.configPath}`, + context.sourceAccountCount !== null + ? `Visible source accounts: ${context.sourceAccountCount}.` + : "No readable Codex CLI source is visible right now.", + ].join("\n"); + const selectionHint = preview.summary.selectionChanged + ? "When the Codex CLI source is newer, target selection follows activeAccountId first, then activeEmail or the active snapshot email. If local storage or a local Codex selection write is newer, the target keeps the local selection." + : "Selection precedence stays accountId first, then email, with newer local target state preserving its own active selection instead of being overwritten."; + return [ + { + label: `Status: ${preview.status}`, + hint: `${preview.statusDetail}\nLast sync: ${formatSyncRunOutcome(lastSync)} at ${formatSyncRunTime(lastSync)}`, + }, + { + label: `Target path: ${preview.targetPath}`, + hint: preview.sourcePath + ? `Source path: ${preview.sourcePath}` + : "Source path: not available", + }, + { + label: `Codex CLI source visibility: ${activeSourceLabel}`, + hint: sourceStateHint, + }, + { + label: `Live watcher: ${liveSyncLabel}`, + hint: liveSyncHint, + }, + { + label: "Preview mode: read-only until apply", + hint: "Refresh only re-reads the Codex CLI source and recomputes the one-way result. Apply writes the latest preview snapshot into the target path; refresh before apply if the Codex CLI files may have changed. It does not create a bidirectional merge.", + }, + { + label: `Preview summary: ${formatSyncSummary(preview.summary)}`, + hint: preview.summary.selectionChanged + ? "Active selection also updates to match the current Codex CLI source when that source is newer." + : "Active selection already matches the one-way sync result.", + }, + { + label: + "Selection precedence: accountId -> email -> preserve newer local choice", + hint: selectionHint, + }, + { + label: `Destination-only preservation: keep ${preview.summary.destinationOnlyPreservedCount} target-only account(s)`, + hint: "One-way sync never deletes accounts that exist only in the target storage.", + }, + { + label: `Pre-sync backup and rollback: ${preview.backup.enabled ? "enabled" : "disabled"}`, + hint: preview.backup.enabled + ? `Before apply, target writes can create ${preview.backup.rollbackPaths.join(", ")} so rollback has explicit recovery context if the sync result is not what you expected.` + : "Storage backups are currently disabled, so apply writes rely on the direct target write only.", + }, + ]; +} + function clampBackendNumberForTests(settingKey: string, value: number): number { const option = BACKEND_NUMBER_OPTION_BY_KEY.get( settingKey as BackendNumberSettingKey, @@ -1277,6 +1476,7 @@ const __testOnly = { clampBackendNumber: clampBackendNumberForTests, formatMenuLayoutMode, cloneDashboardSettings, + buildSyncCenterOverview, withQueuedRetry: withQueuedRetryForTests, loadExperimentalSyncTarget, promptExperimentalSettings, @@ -1291,6 +1491,7 @@ const __testOnly = { promptBehaviorSettings, promptThemeSettings, promptBackendSettings, + promptSyncCenter, }; /* c8 ignore start - interactive prompt flows are covered by integration tests */ @@ -2474,6 +2675,221 @@ async function promptBackendSettings( } } +async function promptSyncCenter(config: PluginConfig): Promise { + if (!input.isTTY || !output.isTTY) return; + const ui = getUiRuntimeOptions(); + const buildPreview = async ( + forceRefresh = false, + ): Promise<{ + preview: CodexCliSyncPreview; + context: SyncCenterOverviewContext; + sourceState: CodexCliState | null; + }> => { + const current = await loadAccounts(); + const sourceState = isCodexCliSyncEnabled() + ? await loadCodexCliState({ forceRefresh }) + : null; + const preview = await previewCodexCliSync(current, { + forceRefresh, + sourceState, + storageBackupEnabled: getStorageBackupEnabled(config), + }); + return { + preview, + context: resolveSyncCenterContext(preview.sourceAccountCount), + sourceState, + }; + }; + const buildErrorState = ( + message: string, + previousPreview?: CodexCliSyncPreview, + ): { + preview: CodexCliSyncPreview; + context: SyncCenterOverviewContext; + sourceState: CodexCliState | null; + } => { + if (previousPreview) { + return { + preview: { + ...previousPreview, + lastSync: getLastCodexCliSyncRun(), + status: "error", + statusDetail: message, + }, + context: resolveSyncCenterContext(previousPreview.sourceAccountCount), + sourceState: null, + }; + } + + const targetPath = getStoragePath(); + const emptySummary: CodexCliSyncSummary = { + sourceAccountCount: 0, + targetAccountCountBefore: 0, + targetAccountCountAfter: 0, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }; + return { + preview: { + status: "error", + statusDetail: message, + sourcePath: null, + sourceAccountCount: null, + targetPath, + summary: emptySummary, + backup: { + enabled: getStorageBackupEnabled(config), + targetPath, + rollbackPaths: formatRollbackPaths(targetPath), + }, + lastSync: getLastCodexCliSyncRun(), + }, + context: resolveSyncCenterContext(null), + sourceState: null, + }; + }; + const buildPreviewSafely = async ( + forceRefresh = false, + previousPreview?: CodexCliSyncPreview, + ): Promise<{ + preview: CodexCliSyncPreview; + context: SyncCenterOverviewContext; + sourceState: CodexCliState | null; + }> => { + try { + return await withQueuedRetry(getStoragePath(), async () => + buildPreview(forceRefresh), + ); + } catch (error) { + return buildErrorState( + `Failed to refresh sync center: ${ + error instanceof Error ? error.message : String(error) + }`, + previousPreview, + ); + } + }; + + let { preview, context, sourceState } = await buildPreviewSafely(true); + while (true) { + const overview = buildSyncCenterOverview(preview, context); + const items: MenuItem[] = [ + { + label: UI_COPY.settings.syncCenterOverviewHeading, + value: { type: "back" }, + kind: "heading", + }, + ...overview.map((item) => ({ + label: item.label, + hint: item.hint, + value: { type: "back" } as SyncCenterAction, + disabled: true, + color: "green" as const, + hideUnavailableSuffix: true, + })), + { label: "", value: { type: "back" }, separator: true }, + { + label: UI_COPY.settings.syncCenterActionsHeading, + value: { type: "back" }, + kind: "heading", + }, + { + label: UI_COPY.settings.syncCenterApply, + hint: "Applies the current preview to the target storage path.", + value: { type: "apply" }, + color: preview.status === "ready" ? "green" : "yellow", + disabled: preview.status !== "ready", + }, + { + label: UI_COPY.settings.syncCenterRefresh, + hint: "Re-read the source files and rebuild the sync preview.", + value: { type: "refresh" }, + color: "yellow", + }, + { + label: UI_COPY.settings.syncCenterBack, + value: { type: "back" }, + color: "red", + }, + ]; + + const result = await select(items, { + message: UI_COPY.settings.syncCenterTitle, + subtitle: UI_COPY.settings.syncCenterSubtitle, + help: UI_COPY.settings.syncCenterHelp, + clearScreen: true, + theme: ui.theme, + selectedEmphasis: "minimal", + onInput: (raw) => { + const lower = raw.toLowerCase(); + if (lower === "q") return { type: "back" }; + if (lower === "r") return { type: "refresh" }; + if (lower === "a" && preview.status === "ready") { + return { type: "apply" }; + } + return undefined; + }, + }); + + if (!result || result.type === "back") return; + if (result.type === "refresh") { + ({ preview, context, sourceState } = await buildPreviewSafely( + true, + preview, + )); + continue; + } + + try { + const synced = await withQueuedRetry(preview.targetPath, async () => { + const current = await loadAccounts(); + return applyCodexCliSyncToStorage(current, { + sourceState, + }); + }); + const storageBackupEnabled = getStorageBackupEnabled(config); + if (synced.changed && synced.storage) { + const syncedStorage = synced.storage; + try { + await withQueuedRetry(preview.targetPath, async () => + saveAccounts(syncedStorage, { + backupEnabled: storageBackupEnabled, + }), + ); + commitPendingCodexCliSyncRun(synced.pendingRun); + } catch (error) { + commitCodexCliSyncRunFailure(synced.pendingRun, error); + preview = { + ...preview, + lastSync: getLastCodexCliSyncRun(), + status: "error", + statusDetail: `Failed to save synced storage: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + continue; + } + } + ({ preview, context, sourceState } = await buildPreviewSafely( + true, + preview, + )); + } catch (error) { + preview = { + ...preview, + status: "error", + lastSync: getLastCodexCliSyncRun(), + statusDetail: `Failed to refresh sync center: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + } +} + async function loadExperimentalSyncTarget(): Promise< | { kind: "blocked-ambiguous"; @@ -2878,35 +3294,51 @@ async function promptSettingsHub( }, { label: UI_COPY.settings.accountList, + hint: UI_COPY.settings.accountListHint, value: { type: "account-list" }, color: "green", }, { label: UI_COPY.settings.summaryFields, + hint: UI_COPY.settings.summaryFieldsHint, value: { type: "summary-fields" }, color: "green", }, { label: UI_COPY.settings.behavior, + hint: UI_COPY.settings.behaviorHint, value: { type: "behavior" }, color: "green", }, - { label: UI_COPY.settings.theme, value: { type: "theme" }, color: "green" }, + { + label: UI_COPY.settings.theme, + hint: UI_COPY.settings.themeHint, + value: { type: "theme" }, + color: "green", + }, { label: "", value: { type: "back" }, separator: true }, { label: UI_COPY.settings.advancedTitle, value: { type: "back" }, kind: "heading", }, + { + label: UI_COPY.settings.syncCenter, + hint: UI_COPY.settings.syncCenterHint, + value: { type: "sync-center" }, + color: "yellow", + }, { label: UI_COPY.settings.experimental, + hint: UI_COPY.settings.experimentalHint, value: { type: "experimental" }, color: "yellow", }, { label: UI_COPY.settings.backend, + hint: UI_COPY.settings.backendHint, value: { type: "backend" }, - color: "green", + color: "yellow", }, { label: "", value: { type: "back" }, separator: true }, { @@ -2958,6 +3390,10 @@ async function configureUnifiedSettings( current = await configureDashboardDisplaySettings(current); continue; } + if (action.type === "sync-center") { + await promptSyncCenter(backendConfig); + continue; + } if (action.type === "summary-fields") { current = await configureStatuslineSettings(current); continue; diff --git a/lib/destructive-actions.ts b/lib/destructive-actions.ts new file mode 100644 index 00000000..66a8571e --- /dev/null +++ b/lib/destructive-actions.ts @@ -0,0 +1,196 @@ +import { clearCodexCliStateCache } from "./codex-cli/state.js"; +import { MODEL_FAMILIES } from "./prompts/codex.js"; +import { clearQuotaCache } from "./quota-cache.js"; +import { + type AccountMetadataV3, + type AccountStorageV3, + clearAccounts, + clearFlaggedAccounts, + type FlaggedAccountStorageV1, + loadFlaggedAccounts, + saveAccounts, + saveFlaggedAccounts, +} from "./storage.js"; + +export const DESTRUCTIVE_ACTION_COPY = { + deleteSavedAccounts: { + label: "Delete Saved Accounts", + typedConfirm: + "Type DELETE to delete saved accounts only (saved accounts: delete; flagged/problem accounts, settings, and Codex CLI sync state: keep): ", + confirm: + "Delete saved accounts? (Saved accounts: delete. Flagged/problem accounts: keep. Settings: keep. Codex CLI sync state: keep.)", + stage: "Deleting saved accounts only", + completed: + "Deleted saved accounts. Saved accounts deleted; flagged/problem accounts, settings, and Codex CLI sync state kept.", + }, + resetLocalState: { + label: "Reset Local State", + typedConfirm: + "Type RESET to reset local state (saved accounts + flagged/problem accounts: delete; settings + Codex CLI sync state: keep; quota cache: clear): ", + confirm: + "Reset local state? (Saved accounts: delete. Flagged/problem accounts: delete. Settings: keep. Codex CLI sync state: keep. Quota cache: clear.)", + stage: "Clearing saved accounts, flagged/problem accounts, and quota cache", + completed: + "Reset local state. Saved accounts, flagged/problem accounts, and quota cache cleared; settings and Codex CLI sync state kept.", + }, +} as const; + +export function clampActiveIndices(storage: AccountStorageV3): void { + const count = storage.accounts.length; + const baseIndex = + typeof storage.activeIndex === "number" && + Number.isFinite(storage.activeIndex) + ? storage.activeIndex + : 0; + + if (count === 0) { + storage.activeIndex = 0; + storage.activeIndexByFamily = {}; + return; + } + + storage.activeIndex = Math.max(0, Math.min(baseIndex, count - 1)); + const activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + const rawIndex = activeIndexByFamily[family]; + const fallback = storage.activeIndex; + const clamped = Math.max( + 0, + Math.min( + typeof rawIndex === "number" && Number.isFinite(rawIndex) + ? rawIndex + : fallback, + count - 1, + ), + ); + activeIndexByFamily[family] = clamped; + } + storage.activeIndexByFamily = activeIndexByFamily; +} + +function rebaseActiveIndicesAfterDelete( + storage: AccountStorageV3, + removedIndex: number, +): void { + if (storage.activeIndex > removedIndex) { + storage.activeIndex -= 1; + } + const activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + const rawIndex = activeIndexByFamily[family]; + if (typeof rawIndex === "number" && Number.isFinite(rawIndex) && rawIndex > removedIndex) { + activeIndexByFamily[family] = rawIndex - 1; + } + } + storage.activeIndexByFamily = activeIndexByFamily; +} + +export interface DeleteAccountResult { + storage: AccountStorageV3; + flagged: FlaggedAccountStorageV1; + removedAccount: AccountMetadataV3; + removedFlaggedCount: number; +} + +export interface DestructiveActionResult { + accountsCleared: boolean; + flaggedCleared: boolean; + quotaCacheCleared: boolean; +} + +function asError(error: unknown, fallbackMessage: string): Error { + return error instanceof Error + ? error + : new Error(`${fallbackMessage}: ${String(error)}`); +} + +export async function deleteAccountAtIndex(options: { + storage: AccountStorageV3; + index: number; +}): Promise { + const target = options.storage.accounts.at(options.index); + if (!target) return null; + const flagged = await loadFlaggedAccounts(); + const nextStorage: AccountStorageV3 = { + ...options.storage, + accounts: options.storage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...(options.storage.activeIndexByFamily ?? {}) }, + }; + const previousStorage: AccountStorageV3 = { + ...options.storage, + accounts: options.storage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...(options.storage.activeIndexByFamily ?? {}) }, + }; + + nextStorage.accounts.splice(options.index, 1); + rebaseActiveIndicesAfterDelete(nextStorage, options.index); + clampActiveIndices(nextStorage); + await saveAccounts(nextStorage); + + const remainingFlagged = flagged.accounts.filter( + (account) => account.refreshToken !== target.refreshToken, + ); + const removedFlaggedCount = flagged.accounts.length - remainingFlagged.length; + let updatedFlagged = flagged; + if (removedFlaggedCount > 0) { + updatedFlagged = { ...flagged, accounts: remainingFlagged }; + try { + await saveFlaggedAccounts(updatedFlagged); + } catch (error) { + const originalError = asError( + error, + "Failed to save flagged account storage after deleting an account", + ); + try { + await saveAccounts(previousStorage); + } catch (rollbackError) { + throw new AggregateError( + [ + originalError, + asError( + rollbackError, + "Failed to roll back account storage after flagged save failure", + ), + ], + "Deleting the account partially failed and rollback also failed.", + ); + } + throw originalError; + } + } + + return { + storage: nextStorage, + flagged: updatedFlagged, + removedAccount: target, + removedFlaggedCount, + }; +} + +/** + * Delete saved accounts without touching flagged/problem accounts, settings, or Codex CLI sync state. + * Removes the accounts WAL and backups via the underlying storage helper. + */ +export async function deleteSavedAccounts(): Promise { + return { + accountsCleared: await clearAccounts(), + flaggedCleared: false, + quotaCacheCleared: false, + }; +} + +/** + * Reset local multi-auth state: clears saved accounts, flagged/problem accounts, and quota cache. + * Keeps unified settings and on-disk Codex CLI sync state; only the in-memory Codex CLI cache is cleared. + */ +export async function resetLocalState(): Promise { + const accountsCleared = await clearAccounts(); + const flaggedCleared = await clearFlaggedAccounts(); + const quotaCacheCleared = await clearQuotaCache(); + clearCodexCliStateCache(); + return { + accountsCleared, + flaggedCleared, + quotaCacheCleared, + }; +} diff --git a/lib/live-account-sync.ts b/lib/live-account-sync.ts index 245be892..b6e9816a 100644 --- a/lib/live-account-sync.ts +++ b/lib/live-account-sync.ts @@ -1,4 +1,4 @@ -import { promises as fs, watch as fsWatch, type FSWatcher } from "node:fs"; +import { type FSWatcher, promises as fs, watch as fsWatch } from "node:fs"; import { basename, dirname } from "node:path"; import { createLogger } from "./logger.js"; @@ -18,13 +18,66 @@ export interface LiveAccountSyncSnapshot { errorCount: number; } +const EMPTY_LIVE_ACCOUNT_SYNC_SNAPSHOT: LiveAccountSyncSnapshot = { + path: null, + running: false, + lastKnownMtimeMs: null, + lastSyncAt: null, + reloadCount: 0, + errorCount: 0, +}; + +let lastLiveAccountSyncSnapshot: LiveAccountSyncSnapshot = { + ...EMPTY_LIVE_ACCOUNT_SYNC_SNAPSHOT, +}; +const activeLiveAccountSyncSnapshots = new Map(); +let lastStoppedLiveAccountSyncSnapshot: + | { instanceId: number; snapshot: LiveAccountSyncSnapshot } + | null = null; +let nextLiveAccountSyncInstanceId = 0; + +function refreshLastLiveAccountSyncSnapshot(): void { + let latestActiveInstanceId = -1; + let latestActiveSnapshot: LiveAccountSyncSnapshot | null = null; + for (const [instanceId, snapshot] of activeLiveAccountSyncSnapshots.entries()) { + if (instanceId > latestActiveInstanceId) { + latestActiveInstanceId = instanceId; + latestActiveSnapshot = snapshot; + } + } + if (latestActiveSnapshot) { + lastLiveAccountSyncSnapshot = { ...latestActiveSnapshot }; + return; + } + if (lastStoppedLiveAccountSyncSnapshot) { + lastLiveAccountSyncSnapshot = { + ...lastStoppedLiveAccountSyncSnapshot.snapshot, + }; + return; + } + lastLiveAccountSyncSnapshot = { ...EMPTY_LIVE_ACCOUNT_SYNC_SNAPSHOT }; +} + +export function getLastLiveAccountSyncSnapshot(): LiveAccountSyncSnapshot { + return { ...lastLiveAccountSyncSnapshot }; +} + +export function __resetLastLiveAccountSyncSnapshotForTests(): void { + lastLiveAccountSyncSnapshot = { ...EMPTY_LIVE_ACCOUNT_SYNC_SNAPSHOT }; + activeLiveAccountSyncSnapshots.clear(); + lastStoppedLiveAccountSyncSnapshot = null; + nextLiveAccountSyncInstanceId = 0; +} + /** * Convert an fs.watch filename value to a UTF-8 string or null. * * @param filename - The value supplied by fs.watch listeners; may be a `string`, `Buffer`, or `null`. Buffers are decoded as UTF-8. * @returns `filename` as a UTF-8 string, or `null` when the input is `null`. */ -function normalizeFsWatchFilename(filename: string | Buffer | null): string | null { +function normalizeFsWatchFilename( + filename: string | Buffer | null, +): string | null { if (filename === null) return null; if (typeof filename === "string") return filename; return filename.toString("utf-8"); @@ -63,6 +116,7 @@ function summarizeWatchPath(path: string | null): string { * changes. Uses fs.watch + polling fallback for Windows reliability. */ export class LiveAccountSync { + private readonly instanceId: number; private readonly reload: () => Promise; private readonly debounceMs: number; private readonly pollIntervalMs: number; @@ -75,36 +129,54 @@ export class LiveAccountSync { private lastSyncAt: number | null = null; private reloadCount = 0; private errorCount = 0; - private reloadInFlight: Promise | null = null; + private generation = 0; + private reloadInFlight: { generation: number; promise: Promise } | null = + null; + private reloadQueued = false; - constructor(reload: () => Promise, options: LiveAccountSyncOptions = {}) { + constructor( + reload: () => Promise, + options: LiveAccountSyncOptions = {}, + ) { + this.instanceId = ++nextLiveAccountSyncInstanceId; this.reload = reload; this.debounceMs = Math.max(50, Math.floor(options.debounceMs ?? 250)); - this.pollIntervalMs = Math.max(500, Math.floor(options.pollIntervalMs ?? 2_000)); + this.pollIntervalMs = Math.max( + 500, + Math.floor(options.pollIntervalMs ?? 2_000), + ); } async syncToPath(path: string): Promise { if (!path) return; if (this.currentPath === path && this.running) return; this.stop(); - + const generation = this.generation; + const nextMtimeMs = await readMtimeMs(path); + if (generation !== this.generation) { + return; + } this.currentPath = path; - this.lastKnownMtimeMs = await readMtimeMs(path); + this.lastKnownMtimeMs = nextMtimeMs; const targetDir = dirname(path); const targetName = basename(path); try { - this.watcher = fsWatch(targetDir, { persistent: false }, (_eventType, filename) => { - const name = normalizeFsWatchFilename(filename); - if (!name) { - this.scheduleReload("watch"); - return; - } + this.watcher = fsWatch( + targetDir, + { persistent: false }, + (_eventType, filename) => { + const name = normalizeFsWatchFilename(filename); + if (!name) { + this.scheduleReload("watch"); + return; + } - if (name === targetName || name.startsWith(`${targetName}.`)) { - this.scheduleReload("watch"); - } - }); + if (name === targetName || name.startsWith(`${targetName}.`)) { + this.scheduleReload("watch"); + } + }, + ); } catch (error) { this.errorCount += 1; log.warn("Failed to start fs.watch for account storage", { @@ -116,14 +188,21 @@ export class LiveAccountSync { this.pollTimer = setInterval(() => { void this.pollOnce(); }, this.pollIntervalMs); - if (typeof this.pollTimer === "object" && "unref" in this.pollTimer && typeof this.pollTimer.unref === "function") { + if ( + typeof this.pollTimer === "object" && + "unref" in this.pollTimer && + typeof this.pollTimer.unref === "function" + ) { this.pollTimer.unref(); } this.running = true; + this.publishSnapshot(); } stop(): void { + this.generation += 1; + this.reloadQueued = false; this.running = false; if (this.watcher) { this.watcher.close(); @@ -137,6 +216,7 @@ export class LiveAccountSync { clearTimeout(this.debounceTimer); this.debounceTimer = null; } + this.publishSnapshot(); } getSnapshot(): LiveAccountSyncSnapshot { @@ -150,6 +230,25 @@ export class LiveAccountSync { }; } + private publishSnapshot(): void { + const snapshot = this.getSnapshot(); + if (snapshot.running) { + activeLiveAccountSyncSnapshots.set(this.instanceId, snapshot); + } else { + activeLiveAccountSyncSnapshots.delete(this.instanceId); + if ( + !lastStoppedLiveAccountSyncSnapshot || + this.instanceId >= lastStoppedLiveAccountSyncSnapshot.instanceId + ) { + lastStoppedLiveAccountSyncSnapshot = { + instanceId: this.instanceId, + snapshot, + }; + } + } + refreshLastLiveAccountSyncSnapshot(); + } + private scheduleReload(reason: "watch" | "poll"): void { if (!this.running) return; if (this.debounceTimer) { @@ -174,41 +273,76 @@ export class LiveAccountSync { path: summarizeWatchPath(this.currentPath), error: error instanceof Error ? error.message : String(error), }); + this.publishSnapshot(); } } private async runReload(reason: "watch" | "poll"): Promise { if (!this.running || !this.currentPath) return; const targetPath = this.currentPath; + const generation = this.generation; + if (this.reloadInFlight) { - await this.reloadInFlight; - return; + const inFlightReload = this.reloadInFlight; + if (inFlightReload.generation === generation) { + this.reloadQueued = true; + return; + } + await inFlightReload.promise; + if (this.reloadInFlight?.promise === inFlightReload.promise) { + this.reloadInFlight = null; + } + if (!this.running || !this.currentPath) return; + if (generation !== this.generation || targetPath !== this.currentPath) { + return; + } } - this.reloadInFlight = (async () => { + do { + this.reloadQueued = false; + const promise = (async () => { + try { + await this.reload(); + if (generation !== this.generation || targetPath !== this.currentPath) { + return; + } + this.lastSyncAt = Date.now(); + this.reloadCount += 1; + this.lastKnownMtimeMs = await readMtimeMs(targetPath); + if (generation !== this.generation || targetPath !== this.currentPath) { + return; + } + log.debug("Reloaded account manager from live storage update", { + reason, + path: summarizeWatchPath(targetPath), + }); + } catch (error) { + if (generation !== this.generation || targetPath !== this.currentPath) { + return; + } + this.errorCount += 1; + log.warn("Live account sync reload failed", { + reason, + path: summarizeWatchPath(targetPath), + error: error instanceof Error ? error.message : String(error), + }); + } + })(); + this.reloadInFlight = { generation, promise }; + try { - await this.reload(); - this.lastSyncAt = Date.now(); - this.reloadCount += 1; - this.lastKnownMtimeMs = await readMtimeMs(targetPath); - log.debug("Reloaded account manager from live storage update", { - reason, - path: summarizeWatchPath(targetPath), - }); - } catch (error) { - this.errorCount += 1; - log.warn("Live account sync reload failed", { - reason, - path: summarizeWatchPath(targetPath), - error: error instanceof Error ? error.message : String(error), - }); + await promise; + } finally { + if (this.reloadInFlight?.promise === promise) { + this.reloadInFlight = null; + this.publishSnapshot(); + } } - })(); - try { - await this.reloadInFlight; - } finally { - this.reloadInFlight = null; - } + if (!this.running || !this.currentPath) return; + if (generation !== this.generation || targetPath !== this.currentPath) { + return; + } + } while (this.reloadQueued); } } diff --git a/lib/quota-cache.ts b/lib/quota-cache.ts index 9870a2b6..0fa647c6 100644 --- a/lib/quota-cache.ts +++ b/lib/quota-cache.ts @@ -30,8 +30,7 @@ interface QuotaCacheFile { byEmail: Record; } -const QUOTA_CACHE_PATH = join(getCodexMultiAuthDir(), "quota-cache.json"); -const QUOTA_CACHE_LABEL = basename(QUOTA_CACHE_PATH); +const QUOTA_CACHE_FILE_NAME = "quota-cache.json"; const RETRYABLE_FS_CODES = new Set(["EBUSY", "EPERM"]); function isRetryableFsError(error: unknown): boolean { @@ -46,7 +45,9 @@ function isRetryableFsError(error: unknown): boolean { * @returns The input as a finite number, or `undefined` if the value is not a finite number */ function normalizeNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; } /** @@ -104,7 +105,7 @@ function normalizeEntry(value: unknown): QuotaCacheEntry | null { * * @param value - Parsed JSON value (typically an object) containing raw entries keyed by identifier; non-objects, empty keys, or invalid entries are ignored. * @returns A record mapping valid string keys to normalized `QuotaCacheEntry` objects; malformed entries are omitted. - * + * * Note: This function is pure and performs no filesystem I/O. Callers are responsible for any filesystem concurrency or Windows-specific behavior when loading/saving the on-disk cache, and for redacting any sensitive tokens before logging or persisting. */ function normalizeEntryMap(value: unknown): Record { @@ -132,7 +133,9 @@ async function readCacheFileWithRetry(path: string): Promise { await sleep(10 * 2 ** attempt); } } - throw lastError instanceof Error ? lastError : new Error("quota cache read retry exhausted"); + throw lastError instanceof Error + ? lastError + : new Error("quota cache read retry exhausted"); } /** @@ -146,7 +149,11 @@ async function readCacheFileWithRetry(path: string): Promise { * @returns The absolute path to the quota-cache.json file */ export function getQuotaCachePath(): string { - return QUOTA_CACHE_PATH; + return join(getCodexMultiAuthDir(), QUOTA_CACHE_FILE_NAME); +} + +function getQuotaCacheLabel(path: string): string { + return basename(path); } /** @@ -168,18 +175,21 @@ export function getQuotaCachePath(): string { * will be empty if the on-disk file is absent, malformed, or could not be read. */ export async function loadQuotaCache(): Promise { - if (!existsSync(QUOTA_CACHE_PATH)) { + const quotaCachePath = getQuotaCachePath(); + if (!existsSync(quotaCachePath)) { return { byAccountId: {}, byEmail: {} }; } try { - const content = await readCacheFileWithRetry(QUOTA_CACHE_PATH); + const content = await readCacheFileWithRetry(quotaCachePath); const parsed = JSON.parse(content) as unknown; if (!isRecord(parsed)) { return { byAccountId: {}, byEmail: {} }; } if (parsed.version !== 1) { - logWarn(`Quota cache rejected due to version mismatch: ${String(parsed.version)}`); + logWarn( + `Quota cache rejected due to version mismatch: ${String(parsed.version)}`, + ); return { byAccountId: {}, byEmail: {} }; } @@ -189,7 +199,7 @@ export async function loadQuotaCache(): Promise { }; } catch (error) { logWarn( - `Failed to load quota cache from ${QUOTA_CACHE_LABEL}: ${ + `Failed to load quota cache from ${getQuotaCacheLabel(quotaCachePath)}: ${ error instanceof Error ? error.message : String(error) }`, ); @@ -222,10 +232,11 @@ export async function saveQuotaCache(data: QuotaCacheData): Promise { byAccountId: data.byAccountId, byEmail: data.byEmail, }; + const quotaCachePath = getQuotaCachePath(); try { await fs.mkdir(getCodexMultiAuthDir(), { recursive: true }); - const tempPath = `${QUOTA_CACHE_PATH}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; + const tempPath = `${quotaCachePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; await fs.writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600, @@ -234,7 +245,7 @@ export async function saveQuotaCache(data: QuotaCacheData): Promise { try { for (let attempt = 0; attempt < 5; attempt += 1) { try { - await fs.rename(tempPath, QUOTA_CACHE_PATH); + await fs.rename(tempPath, quotaCachePath); renamed = true; break; } catch (error) { @@ -253,9 +264,38 @@ export async function saveQuotaCache(data: QuotaCacheData): Promise { } } catch (error) { logWarn( - `Failed to save quota cache to ${QUOTA_CACHE_LABEL}: ${ + `Failed to save quota cache to ${getQuotaCacheLabel(quotaCachePath)}: ${ error instanceof Error ? error.message : String(error) }`, ); } } + +/** + * Deletes the on-disk quota cache file, ignoring missing files and logging non-ENOENT errors. + */ +export async function clearQuotaCache(): Promise { + const quotaCachePath = getQuotaCachePath(); + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.unlink(quotaCachePath); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === "ENOENT") { + return true; + } + if (!isRetryableFsError(error) || attempt >= 4) { + logWarn( + `Failed to clear quota cache ${getQuotaCacheLabel(quotaCachePath)}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return false; + } + await sleep(10 * 2 ** attempt); + } + } + + return false; +} diff --git a/lib/storage.ts b/lib/storage.ts index 6af0725e..c2e846ea 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -6,6 +6,7 @@ import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { exportNamedBackupFile, + getNamedBackupRoot, resolveNamedBackupPath, } from "./named-backup-export.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; @@ -46,6 +47,7 @@ const ACCOUNTS_WAL_SUFFIX = ".wal"; const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3; const BACKUP_COPY_MAX_ATTEMPTS = 5; const BACKUP_COPY_BASE_DELAY_MS = 10; +export const NAMED_BACKUP_LIST_CONCURRENCY = 8; const RESET_MARKER_SUFFIX = ".reset-intent"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -114,6 +116,100 @@ export type RestoreAssessment = { backupMetadata: BackupMetadata; }; +export interface NamedBackupMetadata { + name: string; + path: string; + createdAt: number | null; + updatedAt: number | null; + sizeBytes: number | null; + version: number | null; + accountCount: number | null; + schemaErrors: string[]; + valid: boolean; + loadError?: string; +} + +export interface BackupRestoreAssessment { + backup: NamedBackupMetadata; + currentAccountCount: number; + mergedAccountCount: number | null; + imported: number | null; + skipped: number | null; + wouldExceedLimit: boolean; + eligibleForRestore: boolean; + error?: string; +} + +export interface ActionableNamedBackupRecoveries { + assessments: BackupRestoreAssessment[]; + allAssessments: BackupRestoreAssessment[]; + totalBackups: number; +} + +interface LoadedBackupCandidate { + normalized: AccountStorageV3 | null; + storedVersion: unknown; + schemaErrors: string[]; + error?: string; +} + +interface NamedBackupScanEntry { + backup: NamedBackupMetadata; + candidate: LoadedBackupCandidate; +} + +interface NamedBackupScanResult { + backups: NamedBackupScanEntry[]; + totalBackups: number; +} + +interface NamedBackupMetadataListingResult { + backups: NamedBackupMetadata[]; + totalBackups: number; +} + +function createUnloadedBackupCandidate(): LoadedBackupCandidate { + return { + normalized: null, + storedVersion: null, + schemaErrors: [], + }; +} + +export function getRedactedFilesystemErrorLabel(error: unknown): string { + const code = (error as NodeJS.ErrnoException).code; + if (typeof code === "string" && code.trim().length > 0) { + return code; + } + if (error instanceof Error && error.name && error.name !== "Error") { + return error.name; + } + return "UNKNOWN"; +} + +function buildFailedBackupRestoreAssessment( + backup: NamedBackupMetadata, + currentStorage: AccountStorageV3 | null, + error: unknown, +): BackupRestoreAssessment { + return { + backup, + currentAccountCount: currentStorage?.accounts.length ?? 0, + mergedAccountCount: null, + imported: null, + skipped: null, + wouldExceedLimit: false, + eligibleForRestore: false, + error: getRedactedFilesystemErrorLabel(error), + }; +} + +function normalizeBackupUpdatedAt(updatedAt: number | null | undefined): number { + return typeof updatedAt === "number" && Number.isFinite(updatedAt) && updatedAt !== 0 + ? updatedAt + : 0; +} + /** * Custom error class for storage operations with platform-aware hints. */ @@ -168,6 +264,7 @@ let storageMutex: Promise = Promise.resolve(); const transactionSnapshotContext = new AsyncLocalStorage<{ snapshot: AccountStorageV3 | null; active: boolean; + storagePath: string; }>(); function withStorageLock(fn: () => Promise): Promise { @@ -179,6 +276,26 @@ function withStorageLock(fn: () => Promise): Promise { return previousMutex.then(fn).finally(() => releaseLock()); } +async function unlinkWithRetry(path: string): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.unlink(path); + return; + } catch (error) { + const unlinkError = error as NodeJS.ErrnoException; + const code = unlinkError.code; + if (code === "ENOENT") { + return; + } + if ((code === "EPERM" || code === "EBUSY" || code === "EAGAIN") && attempt < 4) { + await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); + continue; + } + throw unlinkError; + } + } +} + type AnyAccountStorage = AccountStorageV1 | AccountStorageV3; type AccountLike = { @@ -271,6 +388,10 @@ export function setStorageBackupEnabled(enabled: boolean): void { storageBackupEnabled = enabled; } +export function isStorageBackupEnabled(): boolean { + return storageBackupEnabled; +} + function getAccountsBackupPath(path: string): string { return `${path}${ACCOUNTS_BACKUP_SUFFIX}`; } @@ -1166,7 +1287,7 @@ function findCompatibleRefreshTokenMatchIndex( matchingAccount = account; continue; } - const newest = selectNewestAccount(matchingAccount, account); + const newest: T = selectNewestAccount(matchingAccount, account); if (newest === account) { matchingIndex = i; matchingAccount = account; @@ -1531,6 +1652,350 @@ export async function getRestoreAssessment(): Promise { }; } +async function scanNamedBackups(): Promise { + const backupRoot = getNamedBackupRoot(getStoragePath()); + try { + const entries = await retryTransientFilesystemOperation(() => + fs.readdir(backupRoot, { withFileTypes: true }), + ); + const backupEntries = entries + .filter((entry) => entry.isFile() && !entry.isSymbolicLink()) + .filter((entry) => entry.name.toLowerCase().endsWith(".json")); + const backups: NamedBackupScanEntry[] = []; + const totalBackups = backupEntries.length; + for ( + let index = 0; + index < backupEntries.length; + index += NAMED_BACKUP_LIST_CONCURRENCY + ) { + const chunk = backupEntries.slice( + index, + index + NAMED_BACKUP_LIST_CONCURRENCY, + ); + backups.push( + ...(await Promise.all( + chunk.map(async (entry) => { + const path = resolvePath(join(backupRoot, entry.name)); + const name = entry.name.slice(0, -".json".length); + try { + const candidate = await loadBackupCandidate(path); + const backup = await buildNamedBackupMetadata(name, path, { + candidate, + }); + return { backup, candidate }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to scan named backup", { + name, + path, + error: String(error), + }); + } + return null; + } + }), + )).filter( + (entry): entry is NamedBackupScanEntry => entry !== null, + ), + ); + } + return { + backups: backups.sort( + (left, right) => + normalizeBackupUpdatedAt(right.backup.updatedAt) - + normalizeBackupUpdatedAt(left.backup.updatedAt), + ), + totalBackups, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return { backups: [], totalBackups: 0 }; + } + log.warn("Failed to list named backups", { + path: backupRoot, + error: String(error), + }); + throw error; + } +} + +async function listNamedBackupsWithoutLoading(): Promise { + const backupRoot = getNamedBackupRoot(getStoragePath()); + try { + const entries = await retryTransientFilesystemOperation(() => + fs.readdir(backupRoot, { withFileTypes: true }), + ); + const backups: NamedBackupMetadata[] = []; + let totalBackups = 0; + for (const entry of entries) { + if (!entry.isFile() || entry.isSymbolicLink()) continue; + if (!entry.name.toLowerCase().endsWith(".json")) continue; + totalBackups += 1; + + const path = resolvePath(join(backupRoot, entry.name)); + const name = entry.name.slice(0, -".json".length); + try { + backups.push( + await buildNamedBackupMetadata(name, path, { + candidate: createUnloadedBackupCandidate(), + }), + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to build named backup metadata", { + name, + path, + error: String(error), + }); + } + } + } + + return { + backups: backups.sort( + (left, right) => + normalizeBackupUpdatedAt(right.updatedAt) - + normalizeBackupUpdatedAt(left.updatedAt), + ), + totalBackups, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to list named backups", { + path: backupRoot, + error: String(error), + }); + } + return { backups: [], totalBackups: 0 }; + } +} + +export async function listNamedBackups(): Promise { + const scanResult = await scanNamedBackups(); + return scanResult.backups.map((entry) => entry.backup); +} + +function isRetryableFilesystemErrorCode( + code: string | undefined, +): code is "EPERM" | "EBUSY" | "EAGAIN" { + if (code === "EBUSY" || code === "EAGAIN") { + return true; + } + return code === "EPERM" && process.platform === "win32"; +} + +async function retryTransientFilesystemOperation( + operation: () => Promise, +): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + return await operation(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (!isRetryableFilesystemErrorCode(code) || attempt === 4) { + throw error; + } + await new Promise((resolve) => + setTimeout(resolve, 10 * 2 ** attempt + Math.floor(Math.random() * 10)), + ); + } + } + + throw new Error("Retry loop exhausted unexpectedly"); +} + +export function getNamedBackupsDirectoryPath(): string { + return getNamedBackupRoot(getStoragePath()); +} + +export async function getActionableNamedBackupRestores( + options: { + currentStorage?: AccountStorageV3 | null; + backups?: NamedBackupMetadata[]; + assess?: typeof assessNamedBackupRestore; + } = {}, +): Promise { + const usesFastPath = + options.backups === undefined && options.assess === undefined; + const scannedBackupResult = usesFastPath + ? await scanNamedBackups() + : { backups: [], totalBackups: 0 }; + const listedBackupResult = + !usesFastPath && options.backups === undefined + ? await listNamedBackupsWithoutLoading() + : { backups: [], totalBackups: 0 }; + const scannedBackups = scannedBackupResult.backups; + const backups = + options.backups ?? + (usesFastPath + ? scannedBackups.map((entry) => entry.backup) + : listedBackupResult.backups); + const totalBackups = usesFastPath + ? scannedBackupResult.totalBackups + : options.backups?.length ?? listedBackupResult.totalBackups; + if (totalBackups === 0) { + return { assessments: [], allAssessments: [], totalBackups: 0 }; + } + if (usesFastPath && scannedBackups.length === 0) { + return { assessments: [], allAssessments: [], totalBackups }; + } + + const currentStorage = + options.currentStorage === undefined + ? await loadAccounts() + : options.currentStorage; + const actionable: BackupRestoreAssessment[] = []; + const allAssessments: BackupRestoreAssessment[] = []; + const maybePushActionable = (assessment: BackupRestoreAssessment): void => { + if ( + assessment.eligibleForRestore && + !assessment.wouldExceedLimit && + assessment.imported !== null && + assessment.imported > 0 + ) { + actionable.push(assessment); + } + }; + const recordAssessment = (assessment: BackupRestoreAssessment): void => { + allAssessments.push(assessment); + maybePushActionable(assessment); + }; + + if (usesFastPath) { + for (const entry of scannedBackups) { + try { + const assessment = assessNamedBackupRestoreCandidate( + entry.backup, + entry.candidate, + currentStorage, + ); + recordAssessment(assessment); + } catch (error) { + log.warn("Failed to assess named backup restore candidate", { + name: entry.backup.name, + path: entry.backup.path, + error: String(error), + }); + allAssessments.push( + buildFailedBackupRestoreAssessment( + entry.backup, + currentStorage, + error, + ), + ); + } + } + return { assessments: actionable, allAssessments, totalBackups }; + } + + const assess = options.assess ?? assessNamedBackupRestore; + for (const backup of backups) { + try { + const assessment = await assess(backup.name, { currentStorage }); + recordAssessment(assessment); + } catch (error) { + log.warn("Failed to assess named backup restore candidate", { + name: backup.name, + path: backup.path, + error: String(error), + }); + allAssessments.push( + buildFailedBackupRestoreAssessment(backup, currentStorage, error), + ); + } + } + + return { assessments: actionable, allAssessments, totalBackups }; +} + +export async function createNamedBackup( + name: string, + options: { force?: boolean } = {}, +): 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 } = {}, +): Promise { + const backupPath = await resolveNamedBackupRestorePath(name); + const candidate = await loadBackupCandidate(backupPath); + const backup = await buildNamedBackupMetadata( + basename(backupPath).slice(0, -".json".length), + backupPath, + { candidate }, + ); + const currentStorage = + options.currentStorage !== undefined + ? options.currentStorage + : await loadAccounts(); + return assessNamedBackupRestoreCandidate(backup, candidate, currentStorage); +} + +function assessNamedBackupRestoreCandidate( + backup: NamedBackupMetadata, + candidate: LoadedBackupCandidate, + currentStorage: AccountStorageV3 | null, +): BackupRestoreAssessment { + const currentAccounts = currentStorage?.accounts ?? []; + + if (!candidate.normalized || !backup.accountCount || backup.accountCount <= 0) { + return { + backup, + currentAccountCount: currentAccounts.length, + mergedAccountCount: null, + imported: null, + skipped: null, + wouldExceedLimit: false, + eligibleForRestore: false, + error: backup.loadError ?? "Backup is empty or invalid", + }; + } + + const mergedAccounts = deduplicateAccounts([ + ...currentAccounts, + ...candidate.normalized.accounts, + ]); + const wouldExceedLimit = mergedAccounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS; + const imported = wouldExceedLimit + ? null + : mergedAccounts.length - currentAccounts.length; + const skipped = wouldExceedLimit + ? null + : Math.max(0, candidate.normalized.accounts.length - (imported ?? 0)); + + return { + backup, + currentAccountCount: currentAccounts.length, + mergedAccountCount: mergedAccounts.length, + imported, + skipped, + wouldExceedLimit, + eligibleForRestore: !wouldExceedLimit, + error: wouldExceedLimit + ? `Restore would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` + : undefined, + }; +} + +export async function restoreNamedBackup( + name: string, +): Promise<{ imported: number; total: number; skipped: number }> { + const backupPath = await resolveNamedBackupRestorePath(name); + return importAccounts(backupPath); +} + function parseAndNormalizeStorage(data: unknown): { normalized: AccountStorageV3 | null; storedVersion: unknown; @@ -1554,6 +2019,113 @@ async function loadAccountsFromPath(path: string): Promise<{ return parseAndNormalizeStorage(data); } +async function loadBackupCandidate(path: string): Promise { + try { + return await retryTransientFilesystemOperation(() => + loadAccountsFromPath(path), + ); + } catch (error) { + return { + normalized: null, + storedVersion: undefined, + schemaErrors: [], + error: String(error), + }; + } +} + +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); + + try { + const entries = await retryTransientFilesystemOperation(() => + fs.readdir(backupRoot, { withFileTypes: true }), + ); + for (const entry of entries) { + if (!entry.name.toLowerCase().endsWith(".json")) continue; + const entryBaseName = stripNamedBackupJsonExtension(entry.name); + const matchesRequestedEntry = + equalsNamedBackupEntry(entry.name, requested) || + equalsNamedBackupEntry(entry.name, requestedWithExtension) || + equalsNamedBackupEntry(entryBaseName, requestedBaseName); + if (!matchesRequestedEntry) { + continue; + } + if (entry.isSymbolicLink() || !entry.isFile()) { + throw new Error( + `Named backup "${entryBaseName}" is not a regular backup file`, + ); + } + return resolvePath(join(backupRoot, entry.name)); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return undefined; + } + log.warn("Failed to read named backup directory", { + path: backupRoot, + error: String(error), + }); + throw error; + } + + return undefined; +} + +async function resolveNamedBackupRestorePath(name: string): Promise { + const existingPath = await findExistingNamedBackupPath(name); + if (existingPath) { + return existingPath; + } + const requested = (name ?? "").trim(); + const backupRoot = getNamedBackupRoot(getStoragePath()); + const requestedWithExtension = requested.toLowerCase().endsWith(".json") + ? requested + : `${requested}.json`; + try { + return buildNamedBackupPath(name); + } catch (error) { + const baseName = requestedWithExtension.toLowerCase().endsWith(".json") + ? requestedWithExtension.slice(0, -".json".length) + : requestedWithExtension; + 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; + } +} + async function loadAccountsFromJournal( path: string, ): Promise { @@ -1762,12 +2334,68 @@ async function loadAccountsInternal( } } -async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { +async function buildNamedBackupMetadata( + name: string, + path: string, + opts: { candidate?: LoadedBackupCandidate } = {}, +): 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, + }; +} + +/** + * Optional per-call overrides for account storage persistence. + * When omitted, `saveAccounts` uses the module-level backup policy. + */ +export interface SaveAccountsOptions { + backupEnabled?: boolean; +} + +async function saveAccountsUnlocked( + storage: AccountStorageV3, + options: SaveAccountsOptions = {}, +): Promise { const path = getStoragePath(); const resetMarkerPath = getIntentionalResetMarkerPath(path); const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; const tempPath = `${path}.${uniqueSuffix}.tmp`; const walPath = getAccountsWalPath(path); + const backupEnabled = options.backupEnabled ?? storageBackupEnabled; try { await fs.mkdir(dirname(path), { recursive: true }); @@ -1799,7 +2427,7 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { } } - if (storageBackupEnabled && existsSync(path)) { + if (backupEnabled && existsSync(path)) { try { await createRotatingAccountsBackup(path); } catch (backupError) { @@ -1834,34 +2462,19 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { throw emptyError; } - // Retry rename with exponential backoff for Windows EPERM/EBUSY - let lastError: NodeJS.ErrnoException | null = null; - for (let attempt = 0; attempt < 5; attempt++) { - try { - await fs.rename(tempPath, path); - try { - await fs.unlink(resetMarkerPath); - } catch { - // Best effort cleanup. - } - lastAccountsSaveTimestamp = Date.now(); - try { - await fs.unlink(walPath); - } catch { - // Best effort cleanup. - } - return; - } catch (renameError) { - const code = (renameError as NodeJS.ErrnoException).code; - if (code === "EPERM" || code === "EBUSY") { - lastError = renameError as NodeJS.ErrnoException; - await new Promise((r) => setTimeout(r, 10 * 2 ** attempt)); - continue; - } - throw renameError; - } + await renameFileWithRetry(tempPath, path); + try { + await fs.unlink(resetMarkerPath); + } catch { + // Best effort cleanup. } - if (lastError) throw lastError; + lastAccountsSaveTimestamp = Date.now(); + try { + await fs.unlink(walPath); + } catch { + // Best effort cleanup. + } + return; } catch (error) { try { await fs.unlink(tempPath); @@ -1912,9 +2525,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 => { @@ -1937,8 +2552,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 ( @@ -1983,11 +2601,16 @@ export async function withAccountAndFlaggedStorageTransaction( * Creates the Codex multi-auth storage directory if it doesn't exist. * Verifies file was written correctly and provides detailed error messages. * @param storage - Account storage data to save + * @param options - Optional per-call persistence overrides. Set `backupEnabled` + * to override the module-level backup policy for this save only. * @throws StorageError with platform-aware hints on failure */ -export async function saveAccounts(storage: AccountStorageV3): Promise { +export async function saveAccounts( + storage: AccountStorageV3, + options: SaveAccountsOptions = {}, +): Promise { return withStorageLock(async () => { - await saveAccountsUnlocked(storage); + await saveAccountsUnlocked(storage, options); }); } @@ -1995,24 +2618,34 @@ export async function saveAccounts(storage: AccountStorageV3): Promise { * Deletes the account storage file from disk. * Silently ignores if file doesn't exist. */ -export async function clearAccounts(): Promise { +export async function clearAccounts(): Promise { return withStorageLock(async () => { const path = getStoragePath(); const resetMarkerPath = getIntentionalResetMarkerPath(path); const walPath = getAccountsWalPath(path); const backupPaths = await getAccountsBackupRecoveryCandidatesWithDiscovery(path); + const legacyPaths = Array.from( + new Set( + [currentLegacyProjectStoragePath, currentLegacyWorktreeStoragePath].filter( + (candidate): candidate is string => + typeof candidate === "string" && candidate.length > 0, + ), + ), + ); await fs.writeFile( resetMarkerPath, JSON.stringify({ version: 1, createdAt: Date.now() }), { encoding: "utf-8", mode: 0o600 }, ); + let hadError = false; const clearPath = async (targetPath: string): Promise => { try { - await fs.unlink(targetPath); + await unlinkWithRetry(targetPath); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") { + hadError = true; log.error("Failed to clear account storage artifact", { path: targetPath, error: String(error), @@ -2022,14 +2655,13 @@ export async function clearAccounts(): Promise { }; try { - await Promise.all([ - clearPath(path), - clearPath(walPath), - ...backupPaths.map(clearPath), - ]); + const artifacts = Array.from(new Set([path, walPath, ...backupPaths, ...legacyPaths])); + await Promise.all(artifacts.map(clearPath)); } catch { // Individual path cleanup is already best-effort with per-artifact logging. } + + return !hadError; }); } @@ -2254,7 +2886,7 @@ export async function saveFlaggedAccounts( }); } -export async function clearFlaggedAccounts(): Promise { +export async function clearFlaggedAccounts(): Promise { return withStorageLock(async () => { const path = getFlaggedAccountsPath(); const markerPath = getIntentionalResetMarkerPath(path); @@ -2273,22 +2905,37 @@ export async function clearFlaggedAccounts(): Promise { } const backupPaths = await getAccountsBackupRecoveryCandidatesWithDiscovery(path); - for (const candidate of [path, ...backupPaths, markerPath]) { + let hadError = false; + for (const candidate of [path, ...backupPaths]) { try { - await fs.unlink(candidate); + await unlinkWithRetry(candidate); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") { + hadError = true; log.error("Failed to clear flagged account storage", { path: candidate, error: String(error), }); - if (candidate === path) { - throw error; - } } } } + if (!hadError) { + try { + await unlinkWithRetry(markerPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.error("Failed to clear flagged reset marker", { + path, + markerPath, + error: String(error), + }); + hadError = true; + } + } + } + return !hadError; }); } @@ -2310,11 +2957,16 @@ export async function exportAccounts( } const transactionState = transactionSnapshotContext.getStore(); + const currentStoragePath = getStoragePath(); const storage = transactionState?.active - ? transactionState.snapshot - : await withAccountStorageTransaction((current) => - Promise.resolve(current), - ); + ? 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"); } diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index b7c7708f..5f77ad62 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -1,11 +1,15 @@ -import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "node:readline/promises"; import { ANSI, isTTY } from "./ansi.js"; import { confirm } from "./confirm.js"; +import { formatCheckFlaggedLabel, UI_COPY } from "./copy.js"; +import { + formatUiBadge, + paintUiText, + quotaToneFromLeftPercent, +} from "./format.js"; import { getUiRuntimeOptions } from "./runtime.js"; -import { select, type MenuItem } from "./select.js"; -import { paintUiText, formatUiBadge, quotaToneFromLeftPercent } from "./format.js"; -import { UI_COPY, formatCheckFlaggedLabel } from "./copy.js"; +import { type MenuItem, select } from "./select.js"; export type AccountStatus = | "active" @@ -56,9 +60,11 @@ export type AuthMenuAction = | { type: "fix" } | { type: "settings" } | { type: "fresh" } + | { type: "reset-all" } | { 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 } @@ -68,7 +74,16 @@ export type AuthMenuAction = | { type: "delete-all" } | { type: "cancel" }; -export type AccountAction = "back" | "delete" | "refresh" | "toggle" | "set-current" | "cancel"; +export type AccountAction = + | "back" + | "delete" + | "refresh" + | "toggle" + | "set-current" + | "cancel"; + +const ANSI_ESCAPE_PATTERN = new RegExp("\\u001b\\[[0-?]*[ -/]*[@-~]", "g"); +const CONTROL_CHAR_PATTERN = new RegExp("[\\u0000-\\u001f\\u007f]", "g"); function resolveCliVersionLabel(): string | null { const raw = (process.env.CODEX_MULTI_AUTH_CLI_VERSION ?? "").trim(); @@ -85,8 +100,8 @@ function mainMenuTitleWithVersion(): string { function sanitizeTerminalText(value: string | undefined): string | undefined { if (!value) return undefined; return value - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/[\u0000-\u001f\u007f]/g, "") + .replace(ANSI_ESCAPE_PATTERN, "") + .replace(CONTROL_CHAR_PATTERN, "") .trim(); } @@ -112,10 +127,14 @@ function statusBadge(status: AccountStatus | undefined): string { tone: "accent" | "success" | "warning" | "danger" | "muted", ): string => { if (ui.v2Enabled) return formatUiBadge(ui, label, tone); - if (tone === "accent") return `${ANSI.bgGreen}${ANSI.black}[${label}]${ANSI.reset}`; - if (tone === "success") return `${ANSI.bgGreen}${ANSI.black}[${label}]${ANSI.reset}`; - if (tone === "warning") return `${ANSI.bgYellow}${ANSI.black}[${label}]${ANSI.reset}`; - if (tone === "danger") return `${ANSI.bgRed}${ANSI.white}[${label}]${ANSI.reset}`; + if (tone === "accent") + return `${ANSI.bgGreen}${ANSI.black}[${label}]${ANSI.reset}`; + if (tone === "success") + return `${ANSI.bgGreen}${ANSI.black}[${label}]${ANSI.reset}`; + if (tone === "warning") + return `${ANSI.bgYellow}${ANSI.black}[${label}]${ANSI.reset}`; + if (tone === "danger") + return `${ANSI.bgRed}${ANSI.white}[${label}]${ANSI.reset}`; return `${ANSI.inverse}[${label}]${ANSI.reset}`; }; @@ -161,7 +180,7 @@ function statusBadge(status: AccountStatus | undefined): string { } function accountTitle(account: AccountInfo): string { - const accountNumber = account.quickSwitchNumber ?? (account.index + 1); + const accountNumber = account.quickSwitchNumber ?? account.index + 1; const base = sanitizeTerminalText(account.email) || sanitizeTerminalText(account.accountLabel) || @@ -175,15 +194,21 @@ function accountSearchText(account: AccountInfo): string { sanitizeTerminalText(account.email), sanitizeTerminalText(account.accountLabel), sanitizeTerminalText(account.accountId), - String(account.quickSwitchNumber ?? (account.index + 1)), + String(account.quickSwitchNumber ?? account.index + 1), ] - .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .filter( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ) .join(" ") .toLowerCase(); } -function accountRowColor(account: AccountInfo): MenuItem["color"] { - if (account.isCurrentAccount && account.highlightCurrentRow !== false) return "green"; +function accountRowColor( + account: AccountInfo, +): MenuItem["color"] { + if (account.isCurrentAccount && account.highlightCurrentRow !== false) + return "green"; switch (account.status) { case "active": case "ok": @@ -200,7 +225,9 @@ function accountRowColor(account: AccountInfo): MenuItem["color" } } -function statusTone(status: AccountStatus | undefined): "success" | "warning" | "danger" | "muted" { +function statusTone( + status: AccountStatus | undefined, +): "success" | "warning" | "danger" | "muted" { switch (status) { case "active": case "ok": @@ -226,12 +253,16 @@ function normalizeQuotaPercent(value: number | undefined): number | null { return Math.max(0, Math.min(100, Math.round(value))); } -function parseLeftPercentFromSummary(summary: string, windowLabel: "5h" | "7d"): number | null { +function parseLeftPercentFromSummary( + summary: string, + windowLabel: "5h" | "7d", +): number | null { const segments = summary.split("|"); for (const segment of segments) { const trimmed = segment.trim().toLowerCase(); if (!trimmed.startsWith(`${windowLabel} `)) continue; - const percentToken = trimmed.slice(windowLabel.length).trim().split(/\s+/)[0] ?? ""; + const percentToken = + trimmed.slice(windowLabel.length).trim().split(/\s+/)[0] ?? ""; const parsed = Number.parseInt(percentToken.replace("%", ""), 10); if (!Number.isFinite(parsed)) continue; return Math.max(0, Math.min(100, parsed)); @@ -274,15 +305,21 @@ function formatQuotaBar( const filledText = "█".repeat(filled); const emptyText = "▒".repeat(width - filled); if (ui.v2Enabled) { - const tone = leftPercent === null ? "muted" : quotaToneFromLeftPercent(leftPercent); - const filledSegment = filledText.length > 0 ? paintUiText(ui, filledText, tone) : ""; - const emptySegment = emptyText.length > 0 ? paintUiText(ui, emptyText, "muted") : ""; + const tone = + leftPercent === null ? "muted" : quotaToneFromLeftPercent(leftPercent); + const filledSegment = + filledText.length > 0 ? paintUiText(ui, filledText, tone) : ""; + const emptySegment = + emptyText.length > 0 ? paintUiText(ui, emptyText, "muted") : ""; return `${filledSegment}${emptySegment}`; } if (leftPercent === null) return `${ANSI.dim}${emptyText}${ANSI.reset}`; - const color = leftPercent <= 15 ? ANSI.red : leftPercent <= 35 ? ANSI.yellow : ANSI.green; - const filledSegment = filledText.length > 0 ? `${color}${filledText}${ANSI.reset}` : ""; - const emptySegment = emptyText.length > 0 ? `${ANSI.dim}${emptyText}${ANSI.reset}` : ""; + const color = + leftPercent <= 15 ? ANSI.red : leftPercent <= 35 ? ANSI.yellow : ANSI.green; + const filledSegment = + filledText.length > 0 ? `${color}${filledText}${ANSI.reset}` : ""; + const emptySegment = + emptyText.length > 0 ? `${ANSI.dim}${emptyText}${ANSI.reset}` : ""; return `${filledSegment}${emptySegment}`; } @@ -293,7 +330,12 @@ function formatQuotaPercent( if (leftPercent === null) return null; const percentText = `${leftPercent}%`; if (!ui.v2Enabled) { - const color = leftPercent <= 15 ? ANSI.red : leftPercent <= 35 ? ANSI.yellow : ANSI.green; + const color = + leftPercent <= 15 + ? ANSI.red + : leftPercent <= 35 + ? ANSI.yellow + : ANSI.green; return `${color}${percentText}${ANSI.reset}`; } const tone = quotaToneFromLeftPercent(leftPercent); @@ -317,28 +359,60 @@ function formatQuotaWindow( if (!cooldown) { return percent ? `${labelText} ${bar} ${percent}` : `${labelText} ${bar}`; } - const cooldownText = ui.v2Enabled ? paintUiText(ui, cooldown, "muted") : cooldown; + const cooldownText = ui.v2Enabled + ? paintUiText(ui, cooldown, "muted") + : cooldown; if (!percent) { return `${labelText} ${bar} ${cooldownText}`; } return `${labelText} ${bar} ${percent} ${cooldownText}`; } -function formatQuotaSummary(account: AccountInfo, ui: ReturnType): string { +function formatQuotaSummary( + account: AccountInfo, + ui: ReturnType, +): string { const summary = account.quotaSummary ?? ""; const showCooldown = account.showQuotaCooldown !== false; - const left5h = normalizeQuotaPercent(account.quota5hLeftPercent) ?? parseLeftPercentFromSummary(summary, "5h"); - const left7d = normalizeQuotaPercent(account.quota7dLeftPercent) ?? parseLeftPercentFromSummary(summary, "7d"); + const left5h = + normalizeQuotaPercent(account.quota5hLeftPercent) ?? + parseLeftPercentFromSummary(summary, "5h"); + const left7d = + normalizeQuotaPercent(account.quota7dLeftPercent) ?? + parseLeftPercentFromSummary(summary, "7d"); const segments: string[] = []; if (left5h !== null || typeof account.quota5hResetAtMs === "number") { - segments.push(formatQuotaWindow("5h", left5h, account.quota5hResetAtMs, showCooldown, ui)); + segments.push( + formatQuotaWindow( + "5h", + left5h, + account.quota5hResetAtMs, + showCooldown, + ui, + ), + ); } if (left7d !== null || typeof account.quota7dResetAtMs === "number") { - segments.push(formatQuotaWindow("7d", left7d, account.quota7dResetAtMs, showCooldown, ui)); + segments.push( + formatQuotaWindow( + "7d", + left7d, + account.quota7dResetAtMs, + showCooldown, + ui, + ), + ); } - if (account.quotaRateLimited || summary.toLowerCase().includes("rate-limited")) { - segments.push(ui.v2Enabled ? paintUiText(ui, "rate-limited", "danger") : `${ANSI.red}rate-limited${ANSI.reset}`); + if ( + account.quotaRateLimited || + summary.toLowerCase().includes("rate-limited") + ) { + segments.push( + ui.v2Enabled + ? paintUiText(ui, "rate-limited", "danger") + : `${ANSI.red}rate-limited${ANSI.reset}`, + ); } if (segments.length === 0) { @@ -350,7 +424,10 @@ function formatQuotaSummary(account: AccountInfo, ui: ReturnType): string { +function formatAccountHint( + account: AccountInfo, + ui: ReturnType, +): string { const withKey = ( key: string, value: string, @@ -365,19 +442,30 @@ function formatAccountHint(account: AccountInfo, ui: ReturnType(); if (account.showStatusBadge === false) { - partsByKey.set("status", withKey("Status:", statusText(account.status), statusTone(account.status))); + partsByKey.set( + "status", + withKey( + "Status:", + statusText(account.status), + statusTone(account.status), + ), + ); } if (account.showLastUsed !== false) { - partsByKey.set("last-used", withKey("Last used:", formatRelativeTime(account.lastUsed), "heading")); + partsByKey.set( + "last-used", + withKey("Last used:", formatRelativeTime(account.lastUsed), "heading"), + ); } const quotaSummaryText = formatQuotaSummary(account, ui); if (quotaSummaryText) { partsByKey.set("limits", withKey("Limits:", quotaSummaryText, "accent")); } - const fields = account.statuslineFields && account.statuslineFields.length > 0 - ? account.statuslineFields - : ["last-used", "limits", "status"]; + const fields = + account.statuslineFields && account.statuslineFields.length > 0 + ? account.statuslineFields + : ["last-used", "limits", "status"]; const orderedParts: string[] = []; for (const field of fields) { const part = partsByKey.get(field); @@ -407,7 +495,7 @@ async function promptSearchQuery(current: string): Promise { try { const suffix = current ? ` (${current})` : ""; const answer = await rl.question(`Search${suffix} (blank clears): `); - return answer.trim().toLowerCase(); + return (sanitizeTerminalText(answer) ?? "").toLowerCase(); } finally { rl.close(); } @@ -426,9 +514,11 @@ function authMenuFocusKey(action: AuthMenuAction): string { case "fix": case "settings": case "fresh": + case "reset-all": case "check": case "deep-check": case "verify-flagged": + case "restore-backup": case "search": case "delete-all": case "cancel": @@ -448,13 +538,16 @@ export async function showAuthMenu( let focusKey = "action:add"; while (true) { const normalizedSearch = searchQuery.trim().toLowerCase(); - const visibleAccounts = normalizedSearch.length > 0 - ? accounts.filter((account) => accountSearchText(account).includes(normalizedSearch)) - : accounts; + const visibleAccounts = + normalizedSearch.length > 0 + ? accounts.filter((account) => + accountSearchText(account).includes(normalizedSearch), + ) + : accounts; const visibleByNumber = new Map(); const duplicateQuickSwitchNumbers = new Set(); for (const account of visibleAccounts) { - const quickSwitchNumber = account.quickSwitchNumber ?? (account.index + 1); + const quickSwitchNumber = account.quickSwitchNumber ?? account.index + 1; if (visibleByNumber.has(quickSwitchNumber)) { duplicateQuickSwitchNumbers.add(quickSwitchNumber); continue; @@ -463,18 +556,58 @@ export async function showAuthMenu( } const items: MenuItem[] = [ - { label: UI_COPY.mainMenu.quickStart, value: { type: "cancel" }, kind: "heading" }, - { label: UI_COPY.mainMenu.addAccount, value: { type: "add" }, color: "green" }, - { label: UI_COPY.mainMenu.checkAccounts, value: { type: "check" }, color: "green" }, - { label: UI_COPY.mainMenu.bestAccount, value: { type: "forecast" }, color: "green" }, - { label: UI_COPY.mainMenu.fixIssues, value: { type: "fix" }, color: "green" }, - { label: UI_COPY.mainMenu.settings, value: { type: "settings" }, color: "green" }, + { + label: UI_COPY.mainMenu.quickStart, + value: { type: "cancel" }, + kind: "heading", + }, + { + label: UI_COPY.mainMenu.addAccount, + value: { type: "add" }, + color: "green", + }, + { + label: UI_COPY.mainMenu.checkAccounts, + value: { type: "check" }, + color: "green", + }, + { + label: UI_COPY.mainMenu.bestAccount, + value: { type: "forecast" }, + color: "green", + }, + { + label: UI_COPY.mainMenu.fixIssues, + value: { type: "fix" }, + color: "green", + }, + { + label: UI_COPY.mainMenu.settings, + value: { type: "settings" }, + color: "green", + }, { label: "", value: { type: "cancel" }, separator: true }, - { label: UI_COPY.mainMenu.moreChecks, value: { type: "cancel" }, kind: "heading" }, - { label: UI_COPY.mainMenu.refreshChecks, value: { type: "deep-check" }, color: "green" }, - { label: verifyLabel, value: { type: "verify-flagged" }, color: flaggedCount > 0 ? "red" : "yellow" }, + { + label: UI_COPY.mainMenu.moreChecks, + value: { type: "cancel" }, + kind: "heading", + }, + { + label: UI_COPY.mainMenu.refreshChecks, + value: { type: "deep-check" }, + color: "green", + }, + { + label: verifyLabel, + value: { type: "verify-flagged" }, + color: flaggedCount > 0 ? "red" : "yellow", + }, { label: "", value: { type: "cancel" }, separator: true }, - { label: UI_COPY.mainMenu.accounts, value: { type: "cancel" }, kind: "heading" }, + { + label: UI_COPY.mainMenu.accounts, + value: { type: "cancel" }, + kind: "heading", + }, ]; if (visibleAccounts.length === 0) { @@ -486,20 +619,34 @@ export async function showAuthMenu( } else { items.push( ...visibleAccounts.map((account) => { - const currentBadge = account.isCurrentAccount && account.showCurrentBadge !== false - ? (ui.v2Enabled ? ` ${formatUiBadge(ui, "current", "accent")}` : ` ${ANSI.cyan}[current]${ANSI.reset}`) - : ""; - const badge = account.showStatusBadge === false ? "" : statusBadge(account.status); + const currentBadge = + account.isCurrentAccount && account.showCurrentBadge !== false + ? ui.v2Enabled + ? ` ${formatUiBadge(ui, "current", "accent")}` + : ` ${ANSI.cyan}[current]${ANSI.reset}` + : ""; + const badge = + account.showStatusBadge === false + ? "" + : statusBadge(account.status); const statusSuffix = badge ? ` ${badge}` : ""; const title = ui.v2Enabled - ? paintUiText(ui, accountTitle(account), account.isCurrentAccount ? "accent" : "heading") + ? paintUiText( + ui, + accountTitle(account), + account.isCurrentAccount ? "accent" : "heading", + ) : accountTitle(account); const label = `${title}${currentBadge}${statusSuffix}`; const hint = formatAccountHint(account, ui); const hasHint = hint.length > 0; const hintText = ui.v2Enabled - ? (hasHint ? hint : undefined) - : (hasHint ? hint : undefined); + ? hasHint + ? hint + : undefined + : hasHint + ? hint + : undefined; return { label, hint: hintText, @@ -511,27 +658,56 @@ export async function showAuthMenu( } items.push({ label: "", value: { type: "cancel" }, separator: true }); - items.push({ label: UI_COPY.mainMenu.dangerZone, value: { type: "cancel" }, kind: "heading" }); - items.push({ label: UI_COPY.mainMenu.removeAllAccounts, value: { type: "delete-all" }, color: "red" }); + items.push({ + label: UI_COPY.mainMenu.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, + value: { type: "cancel" }, + kind: "heading", + }); + items.push({ + label: UI_COPY.mainMenu.removeAllAccounts, + value: { type: "delete-all" }, + color: "red", + }); + items.push({ + label: UI_COPY.mainMenu.resetLocalState, + value: { type: "reset-all" }, + color: "red", + }); const compactHelp = UI_COPY.mainMenu.helpCompact; const detailedHelp = UI_COPY.mainMenu.helpDetailed; - const showHintsForUnselectedRows = visibleAccounts[0]?.showHintsForUnselectedRows ?? + const showHintsForUnselectedRows = + visibleAccounts[0]?.showHintsForUnselectedRows ?? accounts[0]?.showHintsForUnselectedRows ?? false; - const focusStyle = visibleAccounts[0]?.focusStyle ?? - accounts[0]?.focusStyle ?? - "row-invert"; + const focusStyle = + visibleAccounts[0]?.focusStyle ?? accounts[0]?.focusStyle ?? "row-invert"; const resolveStatusMessage = (): string | undefined => { - const raw = typeof options.statusMessage === "function" - ? options.statusMessage() - : options.statusMessage; - return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : undefined; + const raw = + typeof options.statusMessage === "function" + ? options.statusMessage() + : options.statusMessage; + const sanitized = typeof raw === "string" ? sanitizeTerminalText(raw) : undefined; + return sanitized && sanitized.length > 0 ? sanitized : undefined; }; const buildSubtitle = (): string | undefined => { const parts: string[] = []; - if (normalizedSearch.length > 0) { - parts.push(`${UI_COPY.mainMenu.searchSubtitlePrefix} ${normalizedSearch}`); + const safeSearch = sanitizeTerminalText(normalizedSearch); + if (safeSearch && safeSearch.length > 0) { + parts.push( + `${UI_COPY.mainMenu.searchSubtitlePrefix} ${safeSearch}`, + ); } const statusText = resolveStatusMessage(); if (statusText) { @@ -541,7 +717,8 @@ export async function showAuthMenu( return parts.join(" | "); }; const initialCursor = items.findIndex((item) => { - if (item.separator || item.disabled || item.kind === "heading") return false; + if (item.separator || item.disabled || item.kind === "heading") + return false; return authMenuFocusKey(item.value) === focusKey; }); @@ -582,7 +759,12 @@ export async function showAuthMenu( } const selected = context.items[context.cursor]; - if (!selected || selected.separator || selected.disabled || selected.kind === "heading") { + if ( + !selected || + selected.separator || + selected.disabled || + selected.kind === "heading" + ) { return undefined; } if (selected.value.type !== "select-account") return undefined; @@ -590,7 +772,13 @@ export async function showAuthMenu( }, onCursorChange: ({ cursor }) => { const selected = items[cursor]; - if (!selected || selected.separator || selected.disabled || selected.kind === "heading") return; + if ( + !selected || + selected.separator || + selected.disabled || + selected.kind === "heading" + ) + return; focusKey = authMenuFocusKey(selected.value); }, }); @@ -601,16 +789,16 @@ export async function showAuthMenu( focusKey = "action:search"; continue; } - if (result.type === "delete-all") { - const confirmed = await confirm("Delete all accounts?"); - if (!confirmed) continue; - } if (result.type === "delete-account") { - const confirmed = await confirm(`Delete ${accountTitle(result.account)}?`); + const confirmed = await confirm( + `Delete ${accountTitle(result.account)}?`, + ); if (!confirmed) continue; } if (result.type === "refresh-account") { - const confirmed = await confirm(`Re-authenticate ${accountTitle(result.account)}?`); + const confirmed = await confirm( + `Re-authenticate ${accountTitle(result.account)}?`, + ); if (!confirmed) continue; } focusKey = authMenuFocusKey(result); @@ -618,14 +806,16 @@ export async function showAuthMenu( } } -export async function showAccountDetails(account: AccountInfo): Promise { +export async function showAccountDetails( + account: AccountInfo, +): Promise { const ui = getUiRuntimeOptions(); const header = `${accountTitle(account)} ${statusBadge(account.status)}` + (account.enabled === false - ? (ui.v2Enabled + ? ui.v2Enabled ? ` ${formatUiBadge(ui, "disabled", "danger")}` - : ` ${ANSI.red}[disabled]${ANSI.reset}`) + : ` ${ANSI.red}[disabled]${ANSI.reset}` : ""); const statusLabel = account.status ?? "unknown"; const subtitle = `Added: ${formatDate(account.addedAt)} | Used: ${formatRelativeTime(account.lastUsed)} | Status: ${statusLabel}`; @@ -635,7 +825,10 @@ export async function showAccountDetails(account: AccountInfo): Promise[] = [ { label: UI_COPY.accountDetails.back, value: "back" }, { - label: account.enabled === false ? UI_COPY.accountDetails.enable : UI_COPY.accountDetails.disable, + label: + account.enabled === false + ? UI_COPY.accountDetails.enable + : UI_COPY.accountDetails.disable, value: "toggle", color: account.enabled === false ? "green" : "yellow", }, @@ -644,7 +837,11 @@ export async function showAccountDetails(account: AccountInfo): Promise item.value === focusAction); @@ -668,7 +865,13 @@ export async function showAccountDetails(account: AccountInfo): Promise { const selected = items[cursor]; - if (!selected || selected.separator || selected.disabled || selected.kind === "heading") return; + if ( + !selected || + selected.separator || + selected.disabled || + selected.kind === "heading" + ) + return; focusAction = selected.value; }, }); @@ -680,7 +883,9 @@ export async function showAccountDetails(account: AccountInfo): Promise `Returning in ${seconds}s... Press any key to pause.`, + autoReturn: (seconds: number) => + `Returning in ${seconds}s... Press any key to pause.`, paused: "Paused. Press any key to continue.", working: "Running...", done: "Done.", @@ -57,16 +62,28 @@ export const UI_COPY = { }, settings: { title: "Settings", - subtitle: "Customize menu, behavior, backend, and experiments", + subtitle: + "Start with everyday dashboard settings. Advanced operator controls stay separate.", help: "↑↓ Move | Enter Select | Q Back", - sectionTitle: "Basic", - advancedTitle: "Advanced", + sectionTitle: "Everyday Settings", + advancedTitle: "Advanced & Operator", exitTitle: "Back", - accountList: "Account List View", - summaryFields: "Summary Line", - behavior: "Menu Behavior", - theme: "Color Theme", + accountList: "List Appearance", + accountListHint: + "Show badges, sorting, and how much detail each account row shows.", + syncCenter: "Codex CLI Sync", + syncCenterHint: + "Preview and apply one-way sync from Codex CLI account files.", + summaryFields: "Details Line", + summaryFieldsHint: "Choose which details appear under each account row.", + behavior: "Results & Refresh", + behaviorHint: + "Control auto-return timing and background limit refresh behavior.", + theme: "Colors", + themeHint: "Pick the base palette and accent color.", experimental: "Experimental", + experimentalHint: + "Preview sync and backup actions before they become stable.", experimentalTitle: "Experimental", experimentalSubtitle: "Preview sync and backup actions before they become stable", experimentalHelpMenu: "Enter Select | Q Back", @@ -80,31 +97,47 @@ export const UI_COPY = { experimentalRefreshInterval: "Refresh Guard Interval", experimentalDecreaseInterval: "Decrease Refresh Interval", experimentalIncreaseInterval: "Increase Refresh Interval", - backend: "Backend Controls", + backend: "Advanced Backend Controls", + backendHint: "Tune retry, quota, sync, recovery, and timeout internals.", back: "Back", previewHeading: "Live Preview", displayHeading: "Options", resetDefault: "Reset to Default", saveAndBack: "Save and Back", backNoSave: "Back Without Saving", - accountListTitle: "Account List View", - accountListSubtitle: "Choose row details and optional smart sorting", - accountListHelp: "Enter Toggle | Number Toggle | M Sort | L Layout | S Save | Q Back (No Save)", - summaryTitle: "Account Details Row", - summarySubtitle: "Choose and order detail fields", - summaryHelp: "Enter Toggle | 1-3 Toggle | [ ] Reorder | S Save | Q Back (No Save)", - behaviorTitle: "Return Behavior", - behaviorSubtitle: "Control how result screens return", - behaviorHelp: "Enter Select | 1-3 Delay | P Pause | L AutoFetch | F Status | T TTL | S Save | Q Back (No Save)", - themeTitle: "Color Theme", - themeSubtitle: "Pick base color and accent", + accountListTitle: "List Appearance", + accountListSubtitle: "Choose badges, sorting, and row layout", + accountListHelp: + "Enter Toggle | Number Toggle | M Sort | L Layout | S Save | Q Back (No Save)", + summaryTitle: "Details Line", + summarySubtitle: "Choose and order the details shown under each account", + summaryHelp: + "Enter Toggle | 1-3 Toggle | [ ] Reorder | S Save | Q Back (No Save)", + behaviorTitle: "Results & Refresh", + behaviorSubtitle: "Control auto-return and limit refresh behavior", + behaviorHelp: + "Enter Select | 1-3 Delay | P Pause | L AutoFetch | F Status | T TTL | S Save | Q Back (No Save)", + themeTitle: "Colors", + themeSubtitle: "Pick the base palette and accent color", themeHelp: "Enter Select | 1-2 Base | S Save | Q Back (No Save)", - backendTitle: "Backend Controls", - backendSubtitle: "Tune sync, retry, and limit behavior", - backendHelp: "Enter Open | 1-4 Category | S Save | R Reset | Q Back (No Save)", + backendTitle: "Advanced Backend Controls", + backendSubtitle: + "Expert settings for sync, retry, quota, and timeout behavior", + backendHelp: + "Enter Open | 1-4 Category | S Save | R Reset | Q Back (No Save)", + syncCenterTitle: "Codex CLI Sync", + syncCenterSubtitle: + "Inspect source files and preview one-way sync before applying it", + syncCenterHelp: "Enter Select | A Apply | R Refresh | Q Back", + syncCenterOverviewHeading: "Sync Overview", + syncCenterActionsHeading: "Actions", + syncCenterRefresh: "Refresh Preview", + syncCenterApply: "Apply Preview To Target", + syncCenterBack: "Back", backendCategoriesHeading: "Categories", backendCategoryTitle: "Backend Category", - backendCategoryHelp: "Enter Toggle/Adjust | +/- or [ ] Number | 1-9 Toggle | R Reset | Q Back", + backendCategoryHelp: + "Enter Toggle/Adjust | +/- or [ ] Number | 1-9 Toggle | R Reset | Q Back", backendToggleHeading: "Switches", backendNumberHeading: "Numbers", backendDecrease: "Decrease Focused Value", @@ -118,11 +151,13 @@ export const UI_COPY = { moveDown: "Move Focused Field Down", }, fallback: { - addAnotherTip: "Tip: Use private mode or sign out before adding another account.", - addAnotherQuestion: (count: number) => `Add another account? (${count} added) (y/n): `, + addAnotherTip: + "Tip: Use private mode or sign out before adding another account.", + addAnotherQuestion: (count: number) => + `Add another account? (${count} added) (y/n): `, selectModePrompt: - "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (f) fresh, (q) back [a/c/b/x/s/d/g/f/q]: ", - invalidModePrompt: "Use one of: a, c, b, x, s, d, g, f, q.", + "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (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/accounts-edge.test.ts b/test/accounts-edge.test.ts index 31c34b07..8bf88950 100644 --- a/test/accounts-edge.test.ts +++ b/test/accounts-edge.test.ts @@ -5,6 +5,8 @@ const mockLoadAccounts = vi.fn(); const mockSaveAccounts = vi.fn(); const mockLoadCodexCliState = vi.fn(); const mockSyncAccountStorageFromCodexCli = vi.fn(); +const mockCommitPendingCodexCliSyncRun = vi.fn(); +const mockCommitCodexCliSyncRunFailure = vi.fn(); const mockSetCodexCliActiveSelection = vi.fn(); const mockSelectHybridAccount = vi.fn(); @@ -27,6 +29,8 @@ vi.mock("../lib/codex-cli/state.js", async (importOriginal) => { }); vi.mock("../lib/codex-cli/sync.js", () => ({ + commitPendingCodexCliSyncRun: mockCommitPendingCodexCliSyncRun, + commitCodexCliSyncRunFailure: mockCommitCodexCliSyncRunFailure, syncAccountStorageFromCodexCli: mockSyncAccountStorageFromCodexCli, })); @@ -79,6 +83,8 @@ async function importAccountsModule() { describe("accounts edge branches", () => { beforeEach(() => { vi.clearAllMocks(); + mockCommitPendingCodexCliSyncRun.mockReset(); + mockCommitCodexCliSyncRunFailure.mockReset(); mockLoadAccounts.mockResolvedValue(null); mockSaveAccounts.mockResolvedValue(undefined); mockLoadCodexCliState.mockResolvedValue(null); diff --git a/test/accounts-load-from-disk.test.ts b/test/accounts-load-from-disk.test.ts index 61c2b8b0..498a4d4c 100644 --- a/test/accounts-load-from-disk.test.ts +++ b/test/accounts-load-from-disk.test.ts @@ -1,6 +1,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { AccountManager } from "../lib/accounts.js"; +const { + commitPendingCodexCliSyncRunMock, + commitCodexCliSyncRunFailureMock, +} = vi.hoisted(() => ({ + commitPendingCodexCliSyncRunMock: vi.fn(), + commitCodexCliSyncRunFailureMock: vi.fn(), +})); + vi.mock("../lib/storage.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -11,6 +19,8 @@ vi.mock("../lib/storage.js", async (importOriginal) => { }); vi.mock("../lib/codex-cli/sync.js", () => ({ + commitPendingCodexCliSyncRun: commitPendingCodexCliSyncRunMock, + commitCodexCliSyncRunFailure: commitCodexCliSyncRunFailureMock, syncAccountStorageFromCodexCli: vi.fn(), })); @@ -30,11 +40,14 @@ import { setCodexCliActiveSelection } from "../lib/codex-cli/writer.js"; describe("AccountManager loadFromDisk", () => { beforeEach(() => { vi.clearAllMocks(); + commitPendingCodexCliSyncRunMock.mockReset(); + commitCodexCliSyncRunFailureMock.mockReset(); vi.mocked(loadAccounts).mockResolvedValue(null); vi.mocked(saveAccounts).mockResolvedValue(undefined); vi.mocked(syncAccountStorageFromCodexCli).mockResolvedValue({ changed: false, storage: null, + pendingRun: null, }); vi.mocked(loadCodexCliState).mockResolvedValue(null); vi.mocked(setCodexCliActiveSelection).mockResolvedValue(undefined); @@ -42,6 +55,25 @@ describe("AccountManager loadFromDisk", () => { it("persists Codex CLI source-of-truth storage when sync reports change", async () => { const now = Date.now(); + const pendingRun = { + revision: 1, + run: { + outcome: "changed" as const, + runAt: now, + sourcePath: "source.json", + targetPath: "target.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }; const stored = { version: 3 as const, activeIndex: 0, @@ -60,17 +92,39 @@ describe("AccountManager loadFromDisk", () => { vi.mocked(syncAccountStorageFromCodexCli).mockResolvedValue({ changed: true, storage: synced, + pendingRun, }); const manager = await AccountManager.loadFromDisk(); expect(saveAccounts).toHaveBeenCalledWith(synced); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledWith(pendingRun); + expect(commitCodexCliSyncRunFailureMock).not.toHaveBeenCalled(); expect(manager.getAccountCount()).toBe(2); expect(manager.getCurrentAccount()?.refreshToken).toBe("stored-refresh"); }); it("swallows source-of-truth persist failures and still returns a manager", async () => { const now = Date.now(); + const pendingRun = { + revision: 2, + run: { + outcome: "changed" as const, + runAt: now, + sourcePath: "source.json", + targetPath: "target.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 0, + targetAccountCountAfter: 1, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + }, + }; const synced = { version: 3 as const, activeIndex: 0, @@ -80,11 +134,18 @@ describe("AccountManager loadFromDisk", () => { vi.mocked(syncAccountStorageFromCodexCli).mockResolvedValue({ changed: true, storage: synced, + pendingRun, }); - vi.mocked(saveAccounts).mockRejectedValueOnce(new Error("forced persist failure")); + const saveError = new Error("forced persist failure"); + vi.mocked(saveAccounts).mockRejectedValueOnce(saveError); const manager = await AccountManager.loadFromDisk(); + expect(commitPendingCodexCliSyncRunMock).not.toHaveBeenCalled(); + expect(commitCodexCliSyncRunFailureMock).toHaveBeenCalledWith( + pendingRun, + saveError, + ); expect(manager.getAccountCount()).toBe(1); expect(manager.getCurrentAccount()?.refreshToken).toBe("synced-refresh"); }); diff --git a/test/accounts.test.ts b/test/accounts.test.ts index 5cf8f613..444e430e 100644 --- a/test/accounts.test.ts +++ b/test/accounts.test.ts @@ -1,4 +1,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +const { + loadAccountsMock, + syncAccountStorageFromCodexCliMock, + commitPendingCodexCliSyncRunMock, + commitCodexCliSyncRunFailureMock, + loadCodexCliStateMock, +} = vi.hoisted(() => ({ + loadAccountsMock: vi.fn(), + syncAccountStorageFromCodexCliMock: vi.fn(), + commitPendingCodexCliSyncRunMock: vi.fn(), + commitCodexCliSyncRunFailureMock: vi.fn(), + loadCodexCliStateMock: vi.fn(), +})); import { AccountManager, extractAccountEmail, @@ -17,10 +30,47 @@ vi.mock("../lib/storage.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + loadAccounts: loadAccountsMock, saveAccounts: vi.fn().mockResolvedValue(undefined), }; }); +vi.mock("../lib/codex-cli/sync.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + commitCodexCliSyncRunFailure: commitCodexCliSyncRunFailureMock, + commitPendingCodexCliSyncRun: commitPendingCodexCliSyncRunMock, + syncAccountStorageFromCodexCli: syncAccountStorageFromCodexCliMock, + }; +}); + +vi.mock("../lib/codex-cli/state.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadCodexCliState: loadCodexCliStateMock, + }; +}); + +beforeEach(async () => { + const { saveAccounts } = await import("../lib/storage.js"); + loadAccountsMock.mockReset(); + syncAccountStorageFromCodexCliMock.mockReset(); + commitPendingCodexCliSyncRunMock.mockReset(); + commitCodexCliSyncRunFailureMock.mockReset(); + loadCodexCliStateMock.mockReset(); + vi.mocked(saveAccounts).mockReset(); + vi.mocked(saveAccounts).mockResolvedValue(undefined); + loadAccountsMock.mockResolvedValue(null); + syncAccountStorageFromCodexCliMock.mockResolvedValue({ + storage: null, + changed: false, + pendingRun: null, + }); + loadCodexCliStateMock.mockResolvedValue(null); +}); + describe("parseRateLimitReason", () => { it("returns quota for quota-related codes", () => { expect(parseRateLimitReason("usage_limit_reached")).toBe("quota"); @@ -190,6 +240,119 @@ describe("getAccountIdCandidates", () => { }); describe("AccountManager", () => { + it("commits a pending Codex CLI sync run only after loadFromDisk persists storage", async () => { + const now = Date.now(); + const stored = { + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "token-1", addedAt: now, lastUsed: now }, + ], + }; + const syncedStorage = { + ...stored, + accounts: [ + ...stored.accounts, + { refreshToken: "token-2", addedAt: now + 1, lastUsed: now + 1 }, + ], + }; + const pendingRun = { + revision: 1, + run: { + outcome: "changed" as const, + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }; + loadAccountsMock.mockResolvedValue(stored); + syncAccountStorageFromCodexCliMock.mockResolvedValue({ + storage: syncedStorage, + changed: true, + pendingRun, + }); + + const { saveAccounts } = await import("../lib/storage.js"); + const mockSaveAccounts = vi.mocked(saveAccounts); + + await AccountManager.loadFromDisk(); + + expect(mockSaveAccounts).toHaveBeenCalledTimes(1); + expect(mockSaveAccounts).toHaveBeenCalledWith(syncedStorage); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledTimes(1); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledWith(pendingRun); + expect(mockSaveAccounts.mock.invocationCallOrder[0]!).toBeLessThan( + commitPendingCodexCliSyncRunMock.mock.invocationCallOrder[0]!, + ); + expect(commitCodexCliSyncRunFailureMock).not.toHaveBeenCalled(); + }); + + it("records loadFromDisk save failures as sync-run failures instead of successes", async () => { + const now = Date.now(); + const stored = { + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "token-1", addedAt: now, lastUsed: now }, + ], + }; + const syncedStorage = { + ...stored, + accounts: [ + ...stored.accounts, + { refreshToken: "token-2", addedAt: now + 1, lastUsed: now + 1 }, + ], + }; + const pendingRun = { + revision: 2, + run: { + outcome: "changed" as const, + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }; + const saveError = new Error("save busy"); + loadAccountsMock.mockResolvedValue(stored); + syncAccountStorageFromCodexCliMock.mockResolvedValue({ + storage: syncedStorage, + changed: true, + pendingRun, + }); + + const { saveAccounts } = await import("../lib/storage.js"); + const mockSaveAccounts = vi.mocked(saveAccounts); + mockSaveAccounts.mockRejectedValueOnce(saveError); + + await AccountManager.loadFromDisk(); + + expect(commitPendingCodexCliSyncRunMock).not.toHaveBeenCalled(); + expect(commitCodexCliSyncRunFailureMock).toHaveBeenCalledWith( + pendingRun, + saveError, + ); + }); + it("seeds from fallback auth when no storage exists", () => { const auth: OAuthAuthDetails = { type: "oauth", diff --git a/test/auth-menu-hotkeys.test.ts b/test/auth-menu-hotkeys.test.ts index 75ec2269..91413196 100644 --- a/test/auth-menu-hotkeys.test.ts +++ b/test/auth-menu-hotkeys.test.ts @@ -3,6 +3,8 @@ import type { AccountInfo } from "../lib/ui/auth-menu.js"; const selectMock = vi.fn(); const confirmMock = vi.fn(async () => true); +const searchQuestionMock = vi.fn(); +const searchCloseMock = vi.fn(); vi.mock("../lib/ui/select.js", () => ({ select: selectMock, @@ -12,6 +14,13 @@ vi.mock("../lib/ui/confirm.js", () => ({ confirm: confirmMock, })); +vi.mock("node:readline/promises", () => ({ + createInterface: vi.fn(() => ({ + question: searchQuestionMock, + close: searchCloseMock, + })), +})); + function createAccounts(): AccountInfo[] { const baseTime = 1_700_000_000_000; return [ @@ -27,6 +36,8 @@ describe("auth-menu hotkeys", () => { vi.resetModules(); selectMock.mockReset(); confirmMock.mockReset(); + searchQuestionMock.mockReset(); + searchCloseMock.mockReset(); confirmMock.mockResolvedValue(true); previousCliVersion = process.env.CODEX_MULTI_AUTH_CLI_VERSION; delete process.env.CODEX_MULTI_AUTH_CLI_VERSION; @@ -138,6 +149,34 @@ describe("auth-menu hotkeys", () => { expect(selectMock).toHaveBeenCalledTimes(2); }); + it("sanitizes search subtitles and status messages", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + searchQuestionMock.mockResolvedValueOnce(" \u001b[31mNeedle\u0007 "); + selectMock + .mockImplementationOnce( + async (items: unknown[], options: { onInput?: (...args: unknown[]) => unknown }) => { + if (!options.onInput) return null; + return options.onInput("/", { + cursor: 0, + items, + requestRerender: () => undefined, + }); + }, + ) + .mockResolvedValueOnce({ type: "cancel" }); + + const { showAuthMenu } = await import("../lib/ui/auth-menu.js"); + const result = await showAuthMenu(createAccounts(), { + statusMessage: () => "\u001b[32mNeeds\u0000 attention\u0007 ", + }); + + expect(result).toEqual({ type: "cancel" }); + expect(searchCloseMock).toHaveBeenCalledTimes(1); + const options = selectMock.mock.calls[1]?.[1] as { subtitle?: string }; + expect(options.subtitle).toBe("Search: needle | Needs attention"); + }); + it("supports help toggle hotkey (?) and requests rerender", async () => { let rerenderCalls = 0; selectMock.mockImplementationOnce(async (items: unknown[], options: { onInput?: (...args: unknown[]) => unknown }) => { @@ -197,4 +236,68 @@ describe("auth-menu hotkeys", () => { const options = selectMock.mock.calls[0]?.[1] as { message?: string }; expect(options?.message).toBe("Accounts Dashboard (v0.1.6)"); }); + + it("returns delete-all without an extra confirm prompt", async () => { + selectMock.mockResolvedValueOnce({ type: "delete-all" }); + + const { showAuthMenu } = await import("../lib/ui/auth-menu.js"); + const result = await showAuthMenu(createAccounts()); + + expect(result).toEqual({ type: "delete-all" }); + expect(confirmMock).not.toHaveBeenCalled(); + }); + + it("returns reset-all without an extra confirm prompt", async () => { + selectMock.mockResolvedValueOnce({ type: "reset-all" }); + + const { showAuthMenu } = await import("../lib/ui/auth-menu.js"); + const result = await showAuthMenu(createAccounts()); + + expect(result).toEqual({ type: "reset-all" }); + expect(confirmMock).not.toHaveBeenCalled(); + }); + + it("sanitizes ANSI and control characters in rendered account labels", async () => { + const baseTime = 1_700_000_000_000; + selectMock.mockResolvedValueOnce({ type: "cancel" }); + + const { showAuthMenu } = await import("../lib/ui/auth-menu.js"); + await showAuthMenu([ + { + index: 0, + email: "\u001b[31mfirst@example.com\u0000", + status: "ok", + lastUsed: baseTime, + }, + { + index: 1, + accountLabel: "\u001b[32mFriendly \r\nLabel\u007f", + status: "ok", + lastUsed: baseTime, + }, + { + index: 2, + email: "", + accountLabel: " \r\n ", + accountId: "\u001b[33macc-id-42\u0007", + status: "ok", + lastUsed: baseTime, + }, + ]); + + const items = (selectMock.mock.calls[0]?.[0] as Array<{ + label: string; + value: { type: string }; + }>).filter((item) => item.value.type === "select-account"); + const labels = items.map((item) => item.label); + const strippedLabels = labels.map((label) => + label.replace(new RegExp("\\u001b\\[[0-?]*[ -/]*[@-~]", "g"), ""), + ); + + expect(strippedLabels[0]).toContain("1. first@example.com"); + expect(strippedLabels[1]).toContain("2. Friendly Label"); + expect(strippedLabels[2]).toContain("3. acc-id-42"); + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional test assertion + expect(strippedLabels.join("")).not.toMatch(/[\u0000\u0007\u007f]/); + }); }); diff --git a/test/cli-auth-menu.test.ts b/test/cli-auth-menu.test.ts index 0f06f2c3..cf76445c 100644 --- a/test/cli-auth-menu.test.ts +++ b/test/cli-auth-menu.test.ts @@ -290,7 +290,35 @@ describe("CLI auth menu shortcuts", () => { const result = await promptLoginMode([{ index: 0 }]); expect(result).toEqual({ mode: "cancel" }); - expect(consoleSpy).toHaveBeenCalledWith("\nDelete all cancelled.\n"); + expect(consoleSpy).toHaveBeenCalledWith("\nDelete saved accounts cancelled.\n"); + consoleSpy.mockRestore(); + }); + + it("returns reset mode when reset-all is confirmed", async () => { + mockRl.question.mockResolvedValueOnce("RESET"); + showAuthMenu.mockResolvedValueOnce({ type: "reset-all" }); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "reset" }); + expect(mockRl.close).toHaveBeenCalled(); + }); + + it("cancels reset-all when typed confirmation is not RESET", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + mockRl.question.mockResolvedValueOnce("nope"); + showAuthMenu + .mockResolvedValueOnce({ type: "reset-all" }) + .mockResolvedValueOnce({ type: "cancel" }); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "cancel" }); + expect(consoleSpy).toHaveBeenCalledWith( + "\nReset local state cancelled.\n", + ); consoleSpy.mockRestore(); }); @@ -305,7 +333,7 @@ describe("CLI auth menu shortcuts", () => { const result = await promptLoginMode([{ index: 0 }]); expect(result).toEqual({ mode: "cancel" }); - expect(consoleSpy).toHaveBeenCalledWith("\nDelete all cancelled.\n"); + expect(consoleSpy).toHaveBeenCalledWith("\nDelete saved accounts cancelled.\n"); consoleSpy.mockRestore(); }); diff --git a/test/cli.test.ts b/test/cli.test.ts index 39f1b753..efbffdce 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,274 +1,443 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createInterface } from "node:readline/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("node:readline/promises", () => ({ - createInterface: vi.fn(), + createInterface: vi.fn(), })); const mockRl = { - question: vi.fn(), - close: vi.fn(), + question: vi.fn(), + close: vi.fn(), }; describe("CLI Module", () => { - beforeEach(() => { - vi.resetModules(); - process.env.FORCE_INTERACTIVE_MODE = "1"; - mockRl.question.mockReset(); - mockRl.close.mockReset(); - vi.mocked(createInterface).mockReturnValue(mockRl as any); - vi.spyOn(console, "log").mockImplementation(() => {}); - }); - - afterEach(() => { - delete process.env.FORCE_INTERACTIVE_MODE; - vi.restoreAllMocks(); - }); - - describe("promptAddAnotherAccount", () => { - it("returns true for 'y' input", async () => { - mockRl.question.mockResolvedValueOnce("y"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(true); - expect(mockRl.close).toHaveBeenCalled(); - }); - - it("returns true for 'yes' input", async () => { - mockRl.question.mockResolvedValueOnce("yes"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(2); - - expect(result).toBe(true); - }); - - it("returns true for 'Y' input (case insensitive)", async () => { - mockRl.question.mockResolvedValueOnce("Y"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(true); - }); - - it("returns false for 'n' input", async () => { - mockRl.question.mockResolvedValueOnce("n"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(false); - }); - - it("returns false for empty input", async () => { - mockRl.question.mockResolvedValueOnce(""); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(false); - }); - - it("returns false for random input", async () => { - mockRl.question.mockResolvedValueOnce("maybe"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(false); - }); - - it("includes current count in prompt", async () => { - mockRl.question.mockResolvedValueOnce("n"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - await promptAddAnotherAccount(5); - - expect(mockRl.question).toHaveBeenCalledWith( - expect.stringContaining("5 added") - ); - }); - - it("always closes readline interface", async () => { - mockRl.question.mockRejectedValueOnce(new Error("test error")); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - - await expect(promptAddAnotherAccount(1)).rejects.toThrow("test error"); - expect(mockRl.close).toHaveBeenCalled(); - }); - }); - - describe("promptLoginMode", () => { - it("returns 'add' for 'a' input", async () => { - mockRl.question.mockResolvedValueOnce("a"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([ - { index: 0, email: "test@example.com" }, - ]); - - expect(result).toEqual({ mode: "add" }); - expect(mockRl.close).toHaveBeenCalled(); - }); - - it("returns 'add' for 'add' input", async () => { - mockRl.question.mockResolvedValueOnce("add"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "add" }); - }); - - it("returns 'forecast' for 'p' input", async () => { - mockRl.question.mockResolvedValueOnce("p"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "forecast" }); - }); - - it("returns 'fix' for 'x' input", async () => { - mockRl.question.mockResolvedValueOnce("x"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "fix" }); - }); - - it("returns 'settings' for 's' input", async () => { - mockRl.question.mockResolvedValueOnce("s"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "settings" }); - }); - - it("returns 'fresh' for 'f' input", async () => { - mockRl.question.mockResolvedValueOnce("f"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "fresh", deleteAll: true }); - }); - - it("returns 'fresh' for 'fresh' input", async () => { - mockRl.question.mockResolvedValueOnce("fresh"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "fresh", deleteAll: true }); - }); - - it("returns 'verify-flagged' for 'g' input", async () => { - mockRl.question.mockResolvedValueOnce("g"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "verify-flagged" }); - }); - - it("accepts uppercase quick shortcuts for advanced actions", async () => { - const { promptLoginMode } = await import("../lib/cli.js"); - - mockRl.question.mockResolvedValueOnce("P"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "forecast" }); - - mockRl.question.mockResolvedValueOnce("X"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "fix" }); - - mockRl.question.mockResolvedValueOnce("S"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "settings" }); - - mockRl.question.mockResolvedValueOnce("G"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "verify-flagged" }); - }); - - it("is case insensitive", async () => { - mockRl.question.mockResolvedValueOnce("A"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "add" }); - }); - - it("re-prompts on invalid input then accepts valid", async () => { - mockRl.question - .mockResolvedValueOnce("invalid") - .mockResolvedValueOnce("zzz") - .mockResolvedValueOnce("a"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "add" }); - expect(mockRl.question).toHaveBeenCalledTimes(3); - }); - - it("displays account list with email", async () => { - mockRl.question.mockResolvedValueOnce("a"); - const consoleSpy = vi.spyOn(console, "log"); - - const { promptLoginMode } = await import("../lib/cli.js"); - await promptLoginMode([ - { index: 0, email: "user1@example.com" }, - { index: 1, email: "user2@example.com" }, - ]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("2 account(s)")); - }); - - it("displays account with accountId suffix when no email", async () => { - mockRl.question.mockResolvedValueOnce("f"); - const consoleSpy = vi.spyOn(console, "log"); - - const { promptLoginMode } = await import("../lib/cli.js"); - await promptLoginMode([ - { index: 0, accountId: "acc_1234567890" }, - ]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringMatching(/1\.\s*567890/)); - }); + beforeEach(() => { + vi.resetModules(); + process.env.FORCE_INTERACTIVE_MODE = "1"; + mockRl.question.mockReset(); + mockRl.close.mockReset(); + vi.mocked(createInterface).mockReturnValue(mockRl as any); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + delete process.env.FORCE_INTERACTIVE_MODE; + vi.restoreAllMocks(); + }); + + describe("promptAddAnotherAccount", () => { + it("returns true for 'y' input", async () => { + mockRl.question.mockResolvedValueOnce("y"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(true); + expect(mockRl.close).toHaveBeenCalled(); + }); + + it("returns true for 'yes' input", async () => { + mockRl.question.mockResolvedValueOnce("yes"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(2); + + expect(result).toBe(true); + }); + + it("returns true for 'Y' input (case insensitive)", async () => { + mockRl.question.mockResolvedValueOnce("Y"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(true); + }); + + it("returns false for 'n' input", async () => { + mockRl.question.mockResolvedValueOnce("n"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(false); + }); + + it("returns false for empty input", async () => { + mockRl.question.mockResolvedValueOnce(""); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(false); + }); + + it("returns false for random input", async () => { + mockRl.question.mockResolvedValueOnce("maybe"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(false); + }); + + it("includes current count in prompt", async () => { + mockRl.question.mockResolvedValueOnce("n"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + await promptAddAnotherAccount(5); + + expect(mockRl.question).toHaveBeenCalledWith( + expect.stringContaining("5 added"), + ); + }); + + it("always closes readline interface", async () => { + mockRl.question.mockRejectedValueOnce(new Error("test error")); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + + await expect(promptAddAnotherAccount(1)).rejects.toThrow("test error"); + expect(mockRl.close).toHaveBeenCalled(); + }); + }); + + describe("promptLoginMode", () => { + it("returns 'add' for 'a' input", async () => { + mockRl.question.mockResolvedValueOnce("a"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([ + { index: 0, email: "test@example.com" }, + ]); + + expect(result).toEqual({ mode: "add" }); + expect(mockRl.close).toHaveBeenCalled(); + }); + + it("returns 'add' for 'add' input", async () => { + mockRl.question.mockResolvedValueOnce("add"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + }); + + it("returns 'forecast' for 'p' input", async () => { + mockRl.question.mockResolvedValueOnce("p"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "forecast" }); + }); + + it("returns 'fix' for 'x' input", async () => { + mockRl.question.mockResolvedValueOnce("x"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "fix" }); + }); + + it("returns 'settings' for 's' input", async () => { + mockRl.question.mockResolvedValueOnce("s"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "settings" }); + }); + + it("returns 'fresh' for 'f' input", async () => { + mockRl.question + .mockResolvedValueOnce("f") + .mockResolvedValueOnce("DELETE"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "fresh", deleteAll: true }); + }); + + it("returns 'fresh' for 'fresh' input", async () => { + mockRl.question + .mockResolvedValueOnce("fresh") + .mockResolvedValueOnce("DELETE"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "fresh", deleteAll: true }); + }); + + it("returns 'reset' for fallback reset confirmation", async () => { + mockRl.question + .mockResolvedValueOnce("reset") + .mockResolvedValueOnce("RESET"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "reset" }); + }); + + it("cancels fallback delete-all when typed confirmation does not match", async () => { + mockRl.question + .mockResolvedValueOnce("fresh") + .mockResolvedValueOnce("nope") + .mockResolvedValueOnce("a"); + const consoleSpy = vi.spyOn(console, "log"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + expect(consoleSpy).toHaveBeenCalledWith( + "\nDelete saved accounts cancelled.\n", + ); + }); + + it("cancels fallback reset when typed confirmation does not match", async () => { + mockRl.question + .mockResolvedValueOnce("reset") + .mockResolvedValueOnce("nope") + .mockResolvedValueOnce("a"); + const consoleSpy = vi.spyOn(console, "log"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + expect(consoleSpy).toHaveBeenCalledWith( + "\nReset local state cancelled.\n", + ); + }); + + it("returns reset for TTY reset-all confirmation", async () => { + const { stdin, stdout } = await import("node:process"); + const origInputTTY = stdin.isTTY; + const origOutputTTY = stdout.isTTY; + const showAuthMenuMock = vi.fn().mockResolvedValue({ type: "reset-all" }); + + Object.defineProperty(stdin, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + + try { + vi.resetModules(); + vi.doMock("../lib/ui/auth-menu.js", async () => { + const actual = await vi.importActual("../lib/ui/auth-menu.js"); + return { + ...(actual as Record), + isTTY: vi.fn(() => true), + showAuthMenu: showAuthMenuMock, + }; + }); + mockRl.question.mockResolvedValueOnce("RESET"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "reset" }); + expect(showAuthMenuMock).toHaveBeenCalledTimes(1); + } finally { + vi.doUnmock("../lib/ui/auth-menu.js"); + Object.defineProperty(stdin, "isTTY", { + value: origInputTTY, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: origOutputTTY, + writable: true, + configurable: true, + }); + } + }); + + it("uses reset local state cancellation copy in TTY reset-all flow", async () => { + const { stdin, stdout } = await import("node:process"); + const origInputTTY = stdin.isTTY; + const origOutputTTY = stdout.isTTY; + const showAuthMenuMock = vi + .fn() + .mockResolvedValueOnce({ type: "reset-all" }) + .mockResolvedValueOnce({ type: "add" }); + const consoleSpy = vi.spyOn(console, "log"); + + Object.defineProperty(stdin, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + + try { + vi.resetModules(); + vi.doMock("../lib/ui/auth-menu.js", async () => { + const actual = await vi.importActual("../lib/ui/auth-menu.js"); + return { + ...(actual as Record), + isTTY: vi.fn(() => true), + showAuthMenu: showAuthMenuMock, + }; + }); + mockRl.question.mockResolvedValueOnce("nope"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + expect(consoleSpy).toHaveBeenCalledWith( + "\nReset local state cancelled.\n", + ); + } finally { + vi.doUnmock("../lib/ui/auth-menu.js"); + Object.defineProperty(stdin, "isTTY", { + value: origInputTTY, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: origOutputTTY, + writable: true, + configurable: true, + }); + } + }); + + it("returns 'verify-flagged' for 'g' input", async () => { + mockRl.question.mockResolvedValueOnce("g"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "verify-flagged" }); + }); + + it("accepts uppercase quick shortcuts for advanced actions", async () => { + const { promptLoginMode } = await import("../lib/cli.js"); + + mockRl.question.mockResolvedValueOnce("P"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "forecast", + }); + + mockRl.question.mockResolvedValueOnce("X"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "fix", + }); + + mockRl.question.mockResolvedValueOnce("S"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "settings", + }); + + mockRl.question.mockResolvedValueOnce("G"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "verify-flagged", + }); + }); + + it("is case insensitive", async () => { + mockRl.question.mockResolvedValueOnce("A"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + }); + + it("re-prompts on invalid input then accepts valid", async () => { + mockRl.question + .mockResolvedValueOnce("invalid") + .mockResolvedValueOnce("zzz") + .mockResolvedValueOnce("a"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + expect(mockRl.question).toHaveBeenCalledTimes(3); + }); + + it("displays account list with email", async () => { + mockRl.question.mockResolvedValueOnce("a"); + const consoleSpy = vi.spyOn(console, "log"); + + const { promptLoginMode } = await import("../lib/cli.js"); + await promptLoginMode([ + { index: 0, email: "user1@example.com" }, + { index: 1, email: "user2@example.com" }, + ]); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("2 account(s)"), + ); + }); + + it("displays account with accountId suffix when no email", async () => { + mockRl.question.mockResolvedValueOnce("a"); + const consoleSpy = vi.spyOn(console, "log"); + + const { promptLoginMode } = await import("../lib/cli.js"); + await promptLoginMode([{ index: 0, accountId: "acc_1234567890" }]); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/1\.\s*567890/), + ); + }); it("displays plain Account N when no email or accountId", async () => { - mockRl.question.mockResolvedValueOnce("f"); + mockRl.question.mockResolvedValueOnce("a"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptLoginMode } = await import("../lib/cli.js"); await promptLoginMode([{ index: 0 }]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("1. Account")); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("1. Account"), + ); }); it("displays label with email when both present", async () => { mockRl.question.mockResolvedValueOnce("a"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptLoginMode } = await import("../lib/cli.js"); - await promptLoginMode([{ index: 0, accountLabel: "Work", email: "work@example.com" }]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringMatching(/Work.*work@example\.com/)); + await promptLoginMode([ + { index: 0, accountLabel: "Work", email: "work@example.com" }, + ]); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/Work.*work@example\.com/), + ); }); it("displays only label when no email", async () => { mockRl.question.mockResolvedValueOnce("a"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptLoginMode } = await import("../lib/cli.js"); await promptLoginMode([{ index: 0, accountLabel: "Personal" }]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("1. Personal")); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("1. Personal"), + ); }); }); @@ -321,16 +490,32 @@ describe("CLI Module", () => { const { stdin, stdout } = await import("node:process"); const origInputTTY = stdin.isTTY; const origOutputTTY = stdout.isTTY; - - Object.defineProperty(stdin, "isTTY", { value: true, writable: true, configurable: true }); - Object.defineProperty(stdout, "isTTY", { value: true, writable: true, configurable: true }); - + + Object.defineProperty(stdin, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + try { const { isNonInteractiveMode } = await import("../lib/cli.js"); expect(isNonInteractiveMode()).toBe(false); } finally { - Object.defineProperty(stdin, "isTTY", { value: origInputTTY, writable: true, configurable: true }); - Object.defineProperty(stdout, "isTTY", { value: origOutputTTY, writable: true, configurable: true }); + Object.defineProperty(stdin, "isTTY", { + value: origInputTTY, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: origOutputTTY, + writable: true, + configurable: true, + }); } }); }); @@ -344,63 +529,63 @@ describe("CLI Module", () => { it("returns first candidate by selection", async () => { mockRl.question.mockResolvedValueOnce("1"); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; const result = await promptAccountSelection(candidates); - + expect(result).toEqual(candidates[0]); expect(mockRl.close).toHaveBeenCalled(); }); it("returns second candidate by selection", async () => { mockRl.question.mockResolvedValueOnce("2"); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; const result = await promptAccountSelection(candidates); - + expect(result).toEqual(candidates[1]); }); it("returns default on empty input", async () => { mockRl.question.mockResolvedValueOnce(""); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; - const result = await promptAccountSelection(candidates, { defaultIndex: 1 }); - + const result = await promptAccountSelection(candidates, { + defaultIndex: 1, + }); + expect(result).toEqual(candidates[1]); }); it("returns default on quit input", async () => { mockRl.question.mockResolvedValueOnce("q"); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [{ accountId: "acc1", label: "Account 1" }]; const result = await promptAccountSelection(candidates); - + expect(result).toEqual(candidates[0]); }); it("re-prompts on invalid selection", async () => { - mockRl.question - .mockResolvedValueOnce("99") - .mockResolvedValueOnce("1"); - + mockRl.question.mockResolvedValueOnce("99").mockResolvedValueOnce("1"); + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [{ accountId: "acc1", label: "Account 1" }]; const result = await promptAccountSelection(candidates); - + expect(result).toEqual(candidates[0]); expect(mockRl.question).toHaveBeenCalledTimes(2); }); @@ -408,51 +593,59 @@ describe("CLI Module", () => { it("displays custom title", async () => { mockRl.question.mockResolvedValueOnce("1"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptAccountSelection } = await import("../lib/cli.js"); await promptAccountSelection( [{ accountId: "acc1", label: "Account 1" }], - { title: "Custom Title" } + { title: "Custom Title" }, + ); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Custom Title"), ); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Custom Title")); }); it("shows default marker for default candidates", async () => { mockRl.question.mockResolvedValueOnce("1"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptAccountSelection } = await import("../lib/cli.js"); await promptAccountSelection([ { accountId: "acc1", label: "Account 1", isDefault: true }, ]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("(default)")); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("(default)"), + ); }); it("clamps defaultIndex to valid range", async () => { mockRl.question.mockResolvedValueOnce(""); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; - const result = await promptAccountSelection(candidates, { defaultIndex: 999 }); - + const result = await promptAccountSelection(candidates, { + defaultIndex: 999, + }); + expect(result).toEqual(candidates[1]); }); it("handles negative defaultIndex", async () => { mockRl.question.mockResolvedValueOnce(""); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; - const result = await promptAccountSelection(candidates, { defaultIndex: -5 }); - + const result = await promptAccountSelection(candidates, { + defaultIndex: -5, + }); + expect(result).toEqual(candidates[0]); }); }); @@ -485,7 +678,9 @@ describe("CLI Module", () => { { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; - const result = await promptAccountSelection(candidates, { defaultIndex: 1 }); + const result = await promptAccountSelection(candidates, { + defaultIndex: 1, + }); expect(result).toEqual(candidates[1]); }); }); @@ -494,13 +689,43 @@ describe("CLI Module", () => { const { promptLoginMode } = await import("../lib/cli.js"); mockRl.question.mockResolvedValueOnce("check"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "check" }); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "check", + }); mockRl.question.mockResolvedValueOnce("deep"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "deep-check" }); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "deep-check", + }); mockRl.question.mockResolvedValueOnce("quit"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "cancel" }); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "cancel", + }); + }); + + it("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 () => { @@ -508,8 +733,16 @@ describe("CLI Module", () => { const { stdin, stdout } = await import("node:process"); const origInputTTY = stdin.isTTY; const origOutputTTY = stdout.isTTY; - Object.defineProperty(stdin, "isTTY", { value: true, writable: true, configurable: true }); - Object.defineProperty(stdout, "isTTY", { value: true, writable: true, configurable: true }); + Object.defineProperty(stdin, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); try { process.env.CODEX_TUI = "1"; @@ -536,8 +769,16 @@ describe("CLI Module", () => { delete process.env.CODEX_DESKTOP; delete process.env.TERM_PROGRAM; delete process.env.ELECTRON_RUN_AS_NODE; - Object.defineProperty(stdin, "isTTY", { value: origInputTTY, writable: true, configurable: true }); - Object.defineProperty(stdout, "isTTY", { value: origOutputTTY, writable: true, configurable: true }); + Object.defineProperty(stdin, "isTTY", { + value: origInputTTY, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: origOutputTTY, + writable: true, + configurable: true, + }); } }); }); diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 7d414a57..39ef4c25 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -1,14 +1,23 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AccountStorageV3 } from "../lib/storage.js"; +import * as storageModule from "../lib/storage.js"; import * as codexCliState from "../lib/codex-cli/state.js"; import { clearCodexCliStateCache } from "../lib/codex-cli/state.js"; import { + __resetLastCodexCliSyncRunForTests, + applyCodexCliSyncToStorage, + commitCodexCliSyncRunFailure, + commitPendingCodexCliSyncRun, getActiveSelectionForFamily, + getLastCodexCliSyncRun, + previewCodexCliSync, + SELECTION_TIMESTAMP_READ_MAX_ATTEMPTS, syncAccountStorageFromCodexCli, } from "../lib/codex-cli/sync.js"; +import * as writerModule from "../lib/codex-cli/writer.js"; import { setCodexCliActiveSelection } from "../lib/codex-cli/writer.js"; import { MODEL_FAMILIES } from "../lib/prompts/codex.js"; @@ -40,6 +49,7 @@ describe("codex-cli sync", () => { let accountsPath: string; let authPath: string; let configPath: string; + let targetStoragePath: string; let previousPath: string | undefined; let previousAuthPath: string | undefined; let previousConfigPath: string | undefined; @@ -57,16 +67,22 @@ describe("codex-cli sync", () => { accountsPath = join(tempDir, "accounts.json"); authPath = join(tempDir, "auth.json"); configPath = join(tempDir, "config.toml"); + targetStoragePath = join(tempDir, "openai-codex-accounts.json"); process.env.CODEX_CLI_ACCOUNTS_PATH = accountsPath; process.env.CODEX_CLI_AUTH_PATH = authPath; process.env.CODEX_CLI_CONFIG_PATH = configPath; process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "1"; process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = "1"; + vi.spyOn(storageModule, "getStoragePath").mockReturnValue(targetStoragePath); clearCodexCliStateCache(); + __resetLastCodexCliSyncRunForTests(); }); afterEach(async () => { + vi.restoreAllMocks(); + vi.resetModules(); clearCodexCliStateCache(); + __resetLastCodexCliSyncRunForTests(); if (previousPath === undefined) delete process.env.CODEX_CLI_ACCOUNTS_PATH; else process.env.CODEX_CLI_ACCOUNTS_PATH = previousPath; if (previousAuthPath === undefined) delete process.env.CODEX_CLI_AUTH_PATH; @@ -237,6 +253,1593 @@ describe("codex-cli sync", () => { expect(result.storage?.activeIndex).toBe(0); }); + it("previews one-way manual sync changes without mutating canonical storage", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_c", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a-new", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_c", + email: "c@example.com", + auth: { + tokens: { + access_token: "access-c", + refresh_token: "refresh-c", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a-old", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const snapshot = JSON.parse(JSON.stringify(current)) as AccountStorageV3; + + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + storageBackupEnabled: true, + }); + + expect(preview.status).toBe("ready"); + expect(preview.sourcePath).toBe(accountsPath); + expect(preview.summary.addedAccountCount).toBe(1); + expect(preview.summary.updatedAccountCount).toBe(1); + expect(preview.summary.destinationOnlyPreservedCount).toBe(1); + expect(preview.summary.selectionChanged).toBe(true); + expect(preview.backup.enabled).toBe(true); + expect(preview.backup.rollbackPaths).toContain(`${preview.targetPath}.bak`); + expect(preview.backup.rollbackPaths).toContain(`${preview.targetPath}.wal`); + const serializedPreview = JSON.stringify(preview); + expect(serializedPreview).not.toContain("access-a-new"); + expect(serializedPreview).not.toContain("refresh-a"); + expect(serializedPreview).not.toContain("access-c"); + expect(serializedPreview).not.toContain("refresh-c"); + expect(current).toEqual(snapshot); + }); + + it("skips ambiguous duplicate-email source matches instead of overwriting a local account", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + email: "dup@example.com", + auth: { + tokens: { + access_token: "access-new", + refresh_token: "refresh-new", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "dup@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "dup@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const result = await applyCodexCliSyncToStorage(current, { + forceRefresh: true, + }); + + expect(result.changed).toBe(false); + expect(result.pendingRun).toBeNull(); + expect(result.storage?.accounts).toEqual(current.accounts); + }); + + it("skips ambiguous duplicate-accountId source matches instead of overwriting a local account", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "shared-id", + auth: { + tokens: { + access_token: "access-new", + refresh_token: "refresh-new", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "shared-id", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "shared-id", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const result = await applyCodexCliSyncToStorage(current, { + forceRefresh: true, + }); + + expect(result.changed).toBe(false); + expect(result.pendingRun).toBeNull(); + expect(result.storage?.accounts).toEqual(current.accounts); + }); + + it("reports skipped ambiguous source snapshots in the preview summary", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "shared-id", + auth: { + tokens: { + access_token: "access-new", + refresh_token: "refresh-new", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "shared-id", + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "shared-id", + email: "second@example.com", + refreshToken: "refresh-second", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(preview.status).toBe("noop"); + expect(preview.summary.sourceAccountCount).toBe(1); + expect(preview.statusDetail).toContain("1 source account skipped"); + }); + + it("preserves the current selection when Codex CLI source has no active marker", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(preview.status).toBe("noop"); + expect(preview.summary.selectionChanged).toBe(false); + }); + + it("preserves a newer persisted local selection after restart when the target mtime is only 500ms newer", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const sourceTime = new Date("2026-03-13T00:00:00.000Z"); + const targetTime = new Date("2026-03-13T00:00:00.500Z"); + await utimes(accountsPath, sourceTime, sourceTime); + await writeFile(targetStoragePath, "{\"version\":3}", "utf-8"); + await utimes(targetStoragePath, targetTime, targetTime); + + vi.spyOn(storageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); + vi.spyOn(writerModule, "getLastCodexCliSelectionWriteTimestamp").mockReturnValue( + 0, + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(preview.status).toBe("noop"); + expect(preview.summary.selectionChanged).toBe(false); + }); + + it("preserves a newer persisted local selection on apply when the target mtime is only 500ms newer", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const sourceTime = new Date("2026-03-13T00:00:00.000Z"); + const targetTime = new Date("2026-03-13T00:00:00.500Z"); + await utimes(accountsPath, sourceTime, sourceTime); + await writeFile(targetStoragePath, "{\"version\":3}", "utf-8"); + await utimes(targetStoragePath, targetTime, targetTime); + + vi.spyOn(storageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); + vi.spyOn(writerModule, "getLastCodexCliSelectionWriteTimestamp").mockReturnValue( + 0, + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const result = await applyCodexCliSyncToStorage(current, { + forceRefresh: true, + }); + + expect(result.changed).toBe(false); + expect(result.pendingRun).toBeNull(); + expect(result.storage?.activeIndex).toBe(1); + expect(result.storage?.activeIndexByFamily?.codex).toBe(1); + }); + + it("preserves a newer local selection when Codex state has no timestamp metadata", async () => { + const state = { + path: accountsPath, + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + accessToken: "access-a", + refreshToken: "refresh-a", + isActive: true, + }, + { + accountId: "acc_b", + email: "b@example.com", + accessToken: "access-b", + refreshToken: "refresh-b", + }, + ], + }; + const targetTime = new Date("2026-03-13T00:00:05.000Z"); + await writeFile(targetStoragePath, "{\"version\":3}", "utf-8"); + await utimes(targetStoragePath, targetTime, targetTime); + + vi.spyOn(storageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); + vi.spyOn(writerModule, "getLastCodexCliSelectionWriteTimestamp").mockReturnValue( + 0, + ); + const loadStateSpy = vi + .spyOn(codexCliState, "loadCodexCliState") + .mockResolvedValue(state); + + try { + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(preview.status).toBe("noop"); + expect(preview.summary.selectionChanged).toBe(false); + } finally { + loadStateSpy.mockRestore(); + } + }); + + it("preserves the local selection when the persisted target timestamp is temporarily unreadable", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + vi.spyOn(storageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); + vi.spyOn(writerModule, "getLastCodexCliSelectionWriteTimestamp").mockReturnValue( + 0, + ); + vi.spyOn(storageModule, "getStoragePath").mockReturnValue("\0busy-target"); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(preview.status).toBe("noop"); + expect(preview.summary.selectionChanged).toBe(false); + }); + + it.each(["EBUSY", "EPERM"] as const)( + "preserves the local selection when reading the persisted target timestamp fails with %s", + async (code) => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + vi.spyOn(storageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); + vi.spyOn( + writerModule, + "getLastCodexCliSelectionWriteTimestamp", + ).mockReturnValue(0); + vi.spyOn(storageModule, "getStoragePath").mockReturnValue(targetStoragePath); + const statError = new Error(`${code.toLowerCase()} target`) as NodeJS.ErrnoException; + statError.code = code; + const nodeFs = await import("node:fs"); + const originalStat = nodeFs.promises.stat.bind(nodeFs.promises); + let targetStatCalls = 0; + const statSpy = vi + .spyOn(nodeFs.promises, "stat") + .mockImplementation(async (...args: Parameters) => { + if (args[0] === targetStoragePath) { + targetStatCalls += 1; + throw statError; + } + return originalStat(...args); + }); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + try { + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(preview.status).toBe("noop"); + expect(preview.summary.selectionChanged).toBe(false); + expect(targetStatCalls).toBe(SELECTION_TIMESTAMP_READ_MAX_ATTEMPTS); + } finally { + statSpy.mockRestore(); + } + }, + ); + + it.each(["EBUSY", "EPERM"] as const)( + "does not let zero-version Codex state overwrite local selection when the target timestamp is unreadable with %s", + async (code) => { + const sourceState = { + path: accountsPath, + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + accessToken: "access-a", + refreshToken: "refresh-a", + isActive: true, + }, + { + accountId: "acc_b", + email: "b@example.com", + accessToken: "access-b", + refreshToken: "refresh-b", + }, + ], + }; + + vi.spyOn(storageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); + vi.spyOn( + writerModule, + "getLastCodexCliSelectionWriteTimestamp", + ).mockReturnValue(0); + vi.spyOn(storageModule, "getStoragePath").mockReturnValue(targetStoragePath); + const nodeFs = await import("node:fs"); + const originalStat = nodeFs.promises.stat.bind(nodeFs.promises); + let targetStatCalls = 0; + const statSpy = vi + .spyOn(nodeFs.promises, "stat") + .mockImplementation(async (...args: Parameters) => { + if (args[0] === targetStoragePath) { + targetStatCalls += 1; + const error = new Error(`${code.toLowerCase()} target`) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return originalStat(...args); + }); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + try { + const result = await applyCodexCliSyncToStorage(current, { + sourceState, + }); + + expect(result.changed).toBe(false); + expect(result.pendingRun).toBeNull(); + expect(result.storage?.activeIndex).toBe(1); + expect(result.storage?.activeIndexByFamily?.codex).toBe(1); + expect(targetStatCalls).toBe(SELECTION_TIMESTAMP_READ_MAX_ATTEMPTS); + } finally { + statSpy.mockRestore(); + } + }, + ); + + it.each(["EBUSY", "EPERM"] as const)( + "logs exhausted retries when reading the persisted target timestamp fails with %s", + async (code) => { + const debugSpy = vi.fn(); + vi.resetModules(); + vi.doMock("../lib/logger.js", async () => { + const actual = await vi.importActual( + "../lib/logger.js", + ); + return { + ...actual, + createLogger: () => ({ + debug: debugSpy, + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + time: () => () => 0, + timeEnd: () => undefined, + }), + }; + }); + + try { + const freshStorageModule = await import("../lib/storage.js"); + const freshStateModule = await import("../lib/codex-cli/state.js"); + const freshWriterModule = await import("../lib/codex-cli/writer.js"); + const freshSyncModule = await import("../lib/codex-cli/sync.js"); + freshStateModule.clearCodexCliStateCache(); + freshSyncModule.__resetLastCodexCliSyncRunForTests(); + + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + vi.spyOn(freshStorageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); + vi.spyOn( + freshWriterModule, + "getLastCodexCliSelectionWriteTimestamp", + ).mockReturnValue(0); + vi.spyOn(freshStorageModule, "getStoragePath").mockReturnValue(targetStoragePath); + + const nodeFs = await import("node:fs"); + const originalStat = nodeFs.promises.stat.bind(nodeFs.promises); + const statSpy = vi + .spyOn(nodeFs.promises, "stat") + .mockImplementation(async (...args: Parameters) => { + if (args[0] === targetStoragePath) { + const error = new Error(`${code.toLowerCase()} target`) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return originalStat(...args); + }); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + try { + const preview = await freshSyncModule.previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(preview.status).toBe("noop"); + expect(debugSpy).toHaveBeenCalledWith( + "Exhausted retries reading persisted local selection timestamp", + { + error: `${code.toLowerCase()} target`, + }, + ); + } finally { + statSpy.mockRestore(); + } + } finally { + vi.doUnmock("../lib/logger.js"); + vi.resetModules(); + } + }, + ); + + it("retries a transient persisted-target EBUSY before applying the Codex selection", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const sourceTime = new Date("2026-03-13T00:00:05.000Z"); + const targetTime = new Date("2026-03-13T00:00:00.000Z"); + await utimes(accountsPath, sourceTime, sourceTime); + await writeFile(targetStoragePath, "{\"version\":3}", "utf-8"); + await utimes(targetStoragePath, targetTime, targetTime); + + vi.spyOn(storageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); + vi.spyOn(writerModule, "getLastCodexCliSelectionWriteTimestamp").mockReturnValue( + 0, + ); + vi.spyOn(storageModule, "getStoragePath").mockReturnValue(targetStoragePath); + + const nodeFs = await import("node:fs"); + const originalStat = nodeFs.promises.stat.bind(nodeFs.promises); + let targetStatCalls = 0; + const statSpy = vi + .spyOn(nodeFs.promises, "stat") + .mockImplementation(async (...args: Parameters) => { + if (args[0] === targetStoragePath) { + targetStatCalls += 1; + if (targetStatCalls === 1) { + const error = new Error("busy target") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + } + return originalStat(...args); + }); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + try { + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(targetStatCalls).toBe(2); + expect(preview.status).toBe("ready"); + expect(preview.summary.selectionChanged).toBe(true); + } finally { + statSpy.mockRestore(); + } + }); + + it("records a changed manual sync only after the caller commits persistence", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await applyCodexCliSyncToStorage(current); + expect(result.changed).toBe(true); + expect(result.pendingRun).not.toBeNull(); + expect(result.storage?.accounts).toHaveLength(2); + expect(getLastCodexCliSyncRun()).toBeNull(); + + commitPendingCodexCliSyncRun(result.pendingRun); + + const lastRun = getLastCodexCliSyncRun(); + expect(lastRun?.outcome).toBe("changed"); + expect(lastRun?.sourcePath).toBe(accountsPath); + expect(lastRun?.summary.addedAccountCount).toBe(1); + expect(lastRun?.summary.destinationOnlyPreservedCount).toBe(1); + }); + + it("re-reads Codex CLI state on apply when forceRefresh is requested", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + await previewCodexCliSync(current, { forceRefresh: true }); + + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_c", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + { + accountId: "acc_c", + email: "c@example.com", + auth: { + tokens: { + access_token: "access-c", + refresh_token: "refresh-c", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const loadSpy = vi.spyOn(codexCliState, "loadCodexCliState"); + try { + const result = await applyCodexCliSyncToStorage(current, { + forceRefresh: true, + }); + expect(loadSpy).toHaveBeenCalledWith( + expect.objectContaining({ forceRefresh: true }), + ); + expect(result.changed).toBe(true); + expect(result.storage?.accounts.map((account) => account.accountId)).toEqual([ + "acc_a", + "acc_b", + "acc_c", + ]); + expect(result.storage?.activeIndex).toBe(2); + } finally { + loadSpy.mockRestore(); + } + }); + + it("preserves explicit per-family selections when Codex CLI updates the global selection", async () => { + const alternateFamily = MODEL_FAMILIES.find((family) => family !== "codex"); + expect(alternateFamily).toBeDefined(); + if (!alternateFamily) { + return; + } + + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + { + accountId: "acc_c", + email: "c@example.com", + auth: { + tokens: { + access_token: "access-c", + refresh_token: "refresh-c", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_c", + email: "c@example.com", + refreshToken: "refresh-c", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { + codex: 0, + [alternateFamily]: 2, + }, + }; + + const result = await applyCodexCliSyncToStorage(current, { + forceRefresh: true, + }); + + expect(result.changed).toBe(true); + expect(result.storage?.activeIndex).toBe(1); + expect(result.storage?.activeIndexByFamily?.codex).toBe(1); + expect(result.storage?.activeIndexByFamily?.[alternateFamily]).toBe(2); + }); + + it("forces a fresh Codex CLI state read on apply when forceRefresh is omitted", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + await previewCodexCliSync(current, { forceRefresh: true }); + + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_c", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + { + accountId: "acc_c", + email: "c@example.com", + auth: { + tokens: { + access_token: "access-c", + refresh_token: "refresh-c", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const loadSpy = vi.spyOn(codexCliState, "loadCodexCliState"); + try { + const result = await applyCodexCliSyncToStorage(current); + expect(loadSpy).toHaveBeenCalledWith( + expect.objectContaining({ forceRefresh: true }), + ); + expect(result.changed).toBe(true); + expect(result.storage?.accounts.map((account) => account.accountId)).toEqual([ + "acc_a", + "acc_b", + "acc_c", + ]); + expect(result.storage?.activeIndex).toBe(2); + } finally { + loadSpy.mockRestore(); + } + }); + + it("returns isolated pending runs for concurrent apply attempts", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const [first, second] = await Promise.all([ + applyCodexCliSyncToStorage(current), + applyCodexCliSyncToStorage(current), + ]); + + expect(first.changed).toBe(true); + expect(second.changed).toBe(true); + expect(first.pendingRun).not.toBeNull(); + expect(second.pendingRun).not.toBeNull(); + expect(first.pendingRun?.revision).not.toBe(second.pendingRun?.revision); + expect(first.storage?.accounts.map((account) => account.accountId)).toEqual([ + "acc_a", + "acc_b", + ]); + expect(second.storage?.accounts.map((account) => account.accountId)).toEqual([ + "acc_a", + "acc_b", + ]); + expect(getLastCodexCliSyncRun()).toBeNull(); + }); + + it("records a manual sync save failure over a pending changed run", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await applyCodexCliSyncToStorage(current); + expect(result.pendingRun).not.toBeNull(); + + commitCodexCliSyncRunFailure(result.pendingRun, new Error("save busy")); + + const lastRun = getLastCodexCliSyncRun(); + expect(lastRun?.outcome).toBe("error"); + expect(lastRun?.message).toBe("save busy"); + expect(lastRun?.summary.addedAccountCount).toBe(1); + }); + + it("publishes the completion that finishes last even when it started earlier", async () => { + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + const firstSourceState = { + path: accountsPath, + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + accessToken: "access-b", + refreshToken: "refresh-b", + isActive: true, + }, + ], + }; + const secondSourceState = { + path: accountsPath, + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + accessToken: "access-a", + refreshToken: "refresh-a", + isActive: true, + }, + ], + }; + + const first = await applyCodexCliSyncToStorage(current, { + sourceState: firstSourceState, + }); + const second = await applyCodexCliSyncToStorage(current, { + sourceState: secondSourceState, + }); + + expect(first.pendingRun).not.toBeNull(); + expect(second.pendingRun).not.toBeNull(); + + commitCodexCliSyncRunFailure(second.pendingRun, new Error("later run failed")); + expect(getLastCodexCliSyncRun()?.outcome).toBe("error"); + + commitPendingCodexCliSyncRun(first.pendingRun); + + expect(getLastCodexCliSyncRun()).toEqual( + expect.objectContaining({ + outcome: "changed", + sourcePath: accountsPath, + targetPath: targetStoragePath, + summary: expect.objectContaining({ + addedAccountCount: 1, + }), + }), + ); + }); + + it("ignores a duplicate sync-run publish for the same revision", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await applyCodexCliSyncToStorage(current); + expect(result.pendingRun).not.toBeNull(); + + commitPendingCodexCliSyncRun(result.pendingRun); + const committedRun = getLastCodexCliSyncRun(); + + commitCodexCliSyncRunFailure( + result.pendingRun, + new Error("should not overwrite committed run"), + ); + + expect(getLastCodexCliSyncRun()).toEqual(committedRun); + expect(getLastCodexCliSyncRun()?.outcome).toBe("changed"); + }); + it("serializes concurrent active-selection writes to keep accounts/auth aligned", async () => { await writeFile( accountsPath, diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 4340b503..8f07bdd9 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1,23 +1,54 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const createAuthorizationFlowMock = vi.fn(); +const exchangeAuthorizationCodeMock = vi.fn(); +const startLocalOAuthServerMock = vi.fn(); const loadAccountsMock = vi.fn(); const loadFlaggedAccountsMock = vi.fn(); const saveAccountsMock = vi.fn(); const saveFlaggedAccountsMock = vi.fn(); const setStoragePathMock = vi.fn(); const getStoragePathMock = vi.fn(() => "/mock/openai-codex-accounts.json"); +const getActionableNamedBackupRestoresMock = vi.fn(); +const listNamedBackupsMock = vi.fn(); +const assessNamedBackupRestoreMock = vi.fn(); +const getNamedBackupsDirectoryPathMock = vi.fn(); +const restoreNamedBackupMock = vi.fn(); const queuedRefreshMock = vi.fn(); const setCodexCliActiveSelectionMock = vi.fn(); const promptAddAnotherAccountMock = vi.fn(); +const isInteractiveLoginMenuAvailableMock = vi.fn(() => true); const promptLoginModeMock = vi.fn(); const fetchCodexQuotaSnapshotMock = vi.fn(); const loadDashboardDisplaySettingsMock = vi.fn(); const saveDashboardDisplaySettingsMock = vi.fn(); const loadQuotaCacheMock = vi.fn(); const saveQuotaCacheMock = vi.fn(); +const clearQuotaCacheMock = vi.fn(); const loadPluginConfigMock = vi.fn(); const savePluginConfigMock = vi.fn(); +const previewCodexCliSyncMock = vi.fn(); +const applyCodexCliSyncToStorageMock = vi.fn(); +const commitPendingCodexCliSyncRunMock = vi.fn(); +const commitCodexCliSyncRunFailureMock = vi.fn(); +const formatRollbackPathsMock = vi.fn((targetPath: string) => [ + `${targetPath}.bak`, + `${targetPath}.bak.1`, + `${targetPath}.bak.2`, + `${targetPath}.wal`, +]); +const getLastCodexCliSyncRunMock = vi.fn(); +const getCodexCliAccountsPathMock = vi.fn(() => "/mock/codex/accounts.json"); +const getCodexCliAuthPathMock = vi.fn(() => "/mock/codex/auth.json"); +const getCodexCliConfigPathMock = vi.fn(() => "/mock/codex/config.toml"); +const clearCodexCliStateCacheMock = vi.fn(); +const isCodexCliSyncEnabledMock = vi.fn(() => true); +const loadCodexCliStateMock = vi.fn(); +const getLastLiveAccountSyncSnapshotMock = vi.fn(); const selectMock = vi.fn(); +const deleteSavedAccountsMock = vi.fn(); +const resetLocalStateMock = vi.fn(); +const deleteAccountAtIndexMock = vi.fn(); const planOcChatgptSyncMock = vi.fn(); const applyOcChatgptSyncMock = vi.fn(); const runNamedBackupExportMock = vi.fn(); @@ -39,8 +70,8 @@ vi.mock("../lib/logger.js", () => ({ })); vi.mock("../lib/auth/auth.js", () => ({ - createAuthorizationFlow: vi.fn(), - exchangeAuthorizationCode: vi.fn(), + createAuthorizationFlow: createAuthorizationFlowMock, + exchangeAuthorizationCode: exchangeAuthorizationCodeMock, parseAuthorizationInput: vi.fn(), REDIRECT_URI: "http://localhost:1455/auth/callback", })); @@ -51,10 +82,11 @@ vi.mock("../lib/auth/browser.js", () => ({ })); vi.mock("../lib/auth/server.js", () => ({ - startLocalOAuthServer: vi.fn(), + startLocalOAuthServer: startLocalOAuthServerMock, })); vi.mock("../lib/cli.js", () => ({ + isInteractiveLoginMenuAvailable: isInteractiveLoginMenuAvailableMock, promptAddAnotherAccount: promptAddAnotherAccountMock, promptLoginMode: promptLoginModeMock, })); @@ -100,6 +132,11 @@ vi.mock("../lib/storage.js", async () => { withAccountStorageTransaction: withAccountStorageTransactionMock, setStoragePath: setStoragePathMock, getStoragePath: getStoragePathMock, + getActionableNamedBackupRestores: getActionableNamedBackupRestoresMock, + listNamedBackups: listNamedBackupsMock, + assessNamedBackupRestore: assessNamedBackupRestoreMock, + getNamedBackupsDirectoryPath: getNamedBackupsDirectoryPathMock, + restoreNamedBackup: restoreNamedBackupMock, exportNamedBackup: exportNamedBackupMock, normalizeAccountStorage: normalizeAccountStorageMock, }; @@ -113,6 +150,28 @@ vi.mock("../lib/codex-cli/writer.js", () => ({ setCodexCliActiveSelection: setCodexCliActiveSelectionMock, })); +vi.mock("../lib/codex-cli/sync.js", () => ({ + applyCodexCliSyncToStorage: applyCodexCliSyncToStorageMock, + commitCodexCliSyncRunFailure: commitCodexCliSyncRunFailureMock, + commitPendingCodexCliSyncRun: commitPendingCodexCliSyncRunMock, + formatRollbackPaths: formatRollbackPathsMock, + getLastCodexCliSyncRun: getLastCodexCliSyncRunMock, + previewCodexCliSync: previewCodexCliSyncMock, +})); + +vi.mock("../lib/codex-cli/state.js", () => ({ + clearCodexCliStateCache: clearCodexCliStateCacheMock, + getCodexCliAccountsPath: getCodexCliAccountsPathMock, + getCodexCliAuthPath: getCodexCliAuthPathMock, + getCodexCliConfigPath: getCodexCliConfigPathMock, + isCodexCliSyncEnabled: isCodexCliSyncEnabledMock, + loadCodexCliState: loadCodexCliStateMock, +})); + +vi.mock("../lib/live-account-sync.js", () => ({ + getLastLiveAccountSyncSnapshot: getLastLiveAccountSyncSnapshotMock, +})); + vi.mock("../lib/quota-probe.js", () => ({ fetchCodexQuotaSnapshot: fetchCodexQuotaSnapshotMock, formatQuotaSnapshotLine: vi.fn(() => "probe-ok"), @@ -146,14 +205,50 @@ vi.mock("../lib/config.js", async () => { }); vi.mock("../lib/quota-cache.js", () => ({ + clearQuotaCache: clearQuotaCacheMock, loadQuotaCache: loadQuotaCacheMock, saveQuotaCache: saveQuotaCacheMock, })); +vi.mock("../lib/destructive-actions.js", () => ({ + DESTRUCTIVE_ACTION_COPY: { + deleteSavedAccounts: { + label: "Delete Saved Accounts", + typedConfirm: + "Type DELETE to delete saved accounts only (saved accounts: delete; flagged/problem accounts, settings, and Codex CLI sync state: keep): ", + confirm: + "Delete saved accounts? (Saved accounts: delete. Flagged/problem accounts: keep. Settings: keep. Codex CLI sync state: keep.)", + stage: "Deleting saved accounts only", + completed: + "Deleted saved accounts. Saved accounts deleted; flagged/problem accounts, settings, and Codex CLI sync state kept.", + }, + resetLocalState: { + label: "Reset Local State", + typedConfirm: + "Type RESET to reset local state (saved accounts + flagged/problem accounts: delete; settings + Codex CLI sync state: keep; quota cache: clear): ", + confirm: + "Reset local state? (Saved accounts: delete. Flagged/problem accounts: delete. Settings: keep. Codex CLI sync state: keep. Quota cache: clear.)", + stage: + "Clearing saved accounts, flagged/problem accounts, and quota cache", + completed: + "Reset local state. Saved accounts, flagged/problem accounts, and quota cache cleared; settings and Codex CLI sync state kept.", + }, + }, + deleteSavedAccounts: deleteSavedAccountsMock, + resetLocalState: resetLocalStateMock, + deleteAccountAtIndex: deleteAccountAtIndexMock, +})); + vi.mock("../lib/ui/select.js", () => ({ select: selectMock, })); +const confirmMock = vi.fn(); + +vi.mock("../lib/ui/confirm.js", () => ({ + confirm: confirmMock, +})); + vi.mock("../lib/oc-chatgpt-orchestrator.js", () => ({ planOcChatgptSync: planOcChatgptSyncMock, applyOcChatgptSync: applyOcChatgptSyncMock, @@ -224,6 +319,42 @@ function makeErrnoError(message: string, code: string): NodeJS.ErrnoException { return error; } +async function configureSuccessfulOAuthFlow(now = Date.now()): Promise { + const authModule = await import("../lib/auth/auth.js"); + const browserModule = await import("../lib/auth/browser.js"); + const serverModule = await import("../lib/auth/server.js"); + const mockedCreateAuthorizationFlow = vi.mocked( + authModule.createAuthorizationFlow, + ); + const mockedExchangeAuthorizationCode = vi.mocked( + authModule.exchangeAuthorizationCode, + ); + const mockedOpenBrowserUrl = vi.mocked(browserModule.openBrowserUrl); + const mockedStartLocalOAuthServer = vi.mocked( + serverModule.startLocalOAuthServer, + ); + + mockedCreateAuthorizationFlow.mockResolvedValue({ + pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" }, + state: "oauth-state", + url: "https://auth.openai.com/mock", + }); + mockedExchangeAuthorizationCode.mockResolvedValue({ + type: "success", + access: "access-new", + refresh: "refresh-new", + expires: now + 7_200_000, + idToken: "id-token-new", + multiAccount: true, + }); + mockedOpenBrowserUrl.mockReturnValue(true); + mockedStartLocalOAuthServer.mockResolvedValue({ + ready: true, + waitForCode: vi.fn(async () => ({ code: "oauth-code" })), + close: vi.fn(), + }); +} + type SettingsTestAccount = { email: string; accountId: string; @@ -268,6 +399,7 @@ const SETTINGS_HUB_MENU_ORDER = [ "summary-fields", "behavior", "theme", + "sync-center", "experimental", "backend", ] as const; @@ -418,17 +550,64 @@ describe("codex manager cli commands", () => { withAccountAndFlaggedStorageTransactionMock.mockReset(); withAccountStorageTransactionMock.mockReset(); queuedRefreshMock.mockReset(); + createAuthorizationFlowMock.mockReset(); + exchangeAuthorizationCodeMock.mockReset(); + startLocalOAuthServerMock.mockReset(); setCodexCliActiveSelectionMock.mockReset(); promptAddAnotherAccountMock.mockReset(); + isInteractiveLoginMenuAvailableMock.mockReset(); + isInteractiveLoginMenuAvailableMock.mockReturnValue(true); promptLoginModeMock.mockReset(); fetchCodexQuotaSnapshotMock.mockReset(); loadDashboardDisplaySettingsMock.mockReset(); saveDashboardDisplaySettingsMock.mockReset(); loadQuotaCacheMock.mockReset(); saveQuotaCacheMock.mockReset(); + clearQuotaCacheMock.mockReset(); loadPluginConfigMock.mockReset(); savePluginConfigMock.mockReset(); + previewCodexCliSyncMock.mockReset(); + applyCodexCliSyncToStorageMock.mockReset(); + commitPendingCodexCliSyncRunMock.mockReset(); + commitCodexCliSyncRunFailureMock.mockReset(); + formatRollbackPathsMock.mockReset(); + formatRollbackPathsMock.mockImplementation((targetPath: string) => [ + `${targetPath}.bak`, + `${targetPath}.bak.1`, + `${targetPath}.bak.2`, + `${targetPath}.wal`, + ]); + getLastCodexCliSyncRunMock.mockReset(); + getCodexCliAccountsPathMock.mockReset(); + getCodexCliAuthPathMock.mockReset(); + getCodexCliConfigPathMock.mockReset(); + isCodexCliSyncEnabledMock.mockReset(); + loadCodexCliStateMock.mockReset(); + clearCodexCliStateCacheMock.mockReset(); + getLastLiveAccountSyncSnapshotMock.mockReset(); selectMock.mockReset(); + planOcChatgptSyncMock.mockReset(); + applyOcChatgptSyncMock.mockReset(); + runNamedBackupExportMock.mockReset(); + exportNamedBackupMock.mockReset(); + promptQuestionMock.mockReset(); + detectOcChatgptMultiAuthTargetMock.mockReset(); + normalizeAccountStorageMock.mockReset(); + normalizeAccountStorageMock.mockImplementation((value) => value); + deleteSavedAccountsMock.mockReset(); + resetLocalStateMock.mockReset(); + deleteAccountAtIndexMock.mockReset(); + deleteAccountAtIndexMock.mockResolvedValue(null); + deleteSavedAccountsMock.mockResolvedValue({ + accountsCleared: true, + flaggedCleared: false, + quotaCacheCleared: false, + }); + resetLocalStateMock.mockResolvedValue({ + accountsCleared: true, + flaggedCleared: true, + quotaCacheCleared: true, + }); fetchCodexQuotaSnapshotMock.mockResolvedValue({ status: 200, model: "gpt-5-codex", @@ -443,6 +622,46 @@ describe("codex manager cli commands", () => { version: 1, accounts: [], }); + listNamedBackupsMock.mockReset(); + assessNamedBackupRestoreMock.mockReset(); + getNamedBackupsDirectoryPathMock.mockReset(); + restoreNamedBackupMock.mockReset(); + confirmMock.mockReset(); + getActionableNamedBackupRestoresMock.mockReset(); + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [], + allAssessments: [], + totalBackups: 0, + }); + listNamedBackupsMock.mockResolvedValue([]); + assessNamedBackupRestoreMock.mockResolvedValue({ + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + 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/backups"); + restoreNamedBackupMock.mockResolvedValue({ + imported: 1, + skipped: 0, + total: 1, + }); + confirmMock.mockResolvedValue(true); withAccountStorageTransactionMock.mockImplementation( async (handler) => { const current = await loadAccountsMock(); @@ -501,7 +720,84 @@ describe("codex manager cli commands", () => { }); loadPluginConfigMock.mockReturnValue({}); savePluginConfigMock.mockResolvedValue(undefined); + getLastCodexCliSyncRunMock.mockReturnValue(null); + previewCodexCliSyncMock.mockResolvedValue({ + status: "unavailable", + statusDetail: "No Codex CLI sync source was found.", + sourcePath: null, + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 0, + targetAccountCountBefore: 0, + targetAccountCountAfter: 0, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: [ + "/mock/openai-codex-accounts.json.bak", + "/mock/openai-codex-accounts.json.wal", + ], + }, + lastSync: null, + }); + applyCodexCliSyncToStorageMock.mockResolvedValue({ + changed: false, + storage: null, + pendingRun: null, + }); + getCodexCliAccountsPathMock.mockReturnValue("/mock/codex/accounts.json"); + getCodexCliAuthPathMock.mockReturnValue("/mock/codex/auth.json"); + getCodexCliConfigPathMock.mockReturnValue("/mock/codex/config.toml"); + isCodexCliSyncEnabledMock.mockReturnValue(true); + loadCodexCliStateMock.mockResolvedValue(null); + getLastLiveAccountSyncSnapshotMock.mockReturnValue({ + path: null, + running: false, + lastKnownMtimeMs: null, + lastSyncAt: null, + reloadCount: 0, + errorCount: 0, + }); selectMock.mockResolvedValue(undefined); + createAuthorizationFlowMock.mockResolvedValue({ + pkce: { verifier: "test-verifier" }, + state: "test-state", + url: "https://example.com/oauth", + }); + exchangeAuthorizationCodeMock.mockResolvedValue({ + type: "failed", + reason: "unknown", + message: "not configured", + }); + startLocalOAuthServerMock.mockResolvedValue({ + ready: false, + waitForCode: vi.fn(), + close: vi.fn(), + }); + planOcChatgptSyncMock.mockResolvedValue({ + kind: "blocked-none", + detection: { kind: "none", reason: "No oc-chatgpt target found." }, + }); + applyOcChatgptSyncMock.mockResolvedValue({ + kind: "blocked-none", + detection: { kind: "none", reason: "No oc-chatgpt target found." }, + }); + runNamedBackupExportMock.mockResolvedValue({ + kind: "exported", + path: "/mock/backups/demo.json", + }); + promptQuestionMock.mockResolvedValue("demo"); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ + kind: "none", + reason: "No oc-chatgpt target found.", + }); restoreTTYDescriptors(); setStoragePathMock.mockReset(); getStoragePathMock.mockReturnValue("/mock/openai-codex-accounts.json"); @@ -536,6 +832,7 @@ describe("codex manager cli commands", () => { }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -556,6 +853,7 @@ describe("codex manager cli commands", () => { it("prints implemented 40-feature matrix", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -574,6 +872,7 @@ describe("codex manager cli commands", () => { it("prints auth help when subcommand is --help", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -583,6 +882,102 @@ describe("codex manager cli commands", () => { expect(logSpy.mock.calls[0]?.[0]).toContain("Codex Multi-Auth CLI"); }); + it("restores a named backup from direct auth restore-backup command", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue(null); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + + expect(exitCode).toBe(0); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ currentStorage: null }), + ); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + }); + + it("returns a non-zero exit code when the direct restore-backup command fails", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + restoreNamedBackupMock.mockRejectedValueOnce(new Error("backup locked")); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + + expect(exitCode).toBe(1); + expect(promptLoginModeMock).not.toHaveBeenCalled(); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + }); + it("restores healthy flagged accounts into active storage", async () => { const now = Date.now(); loadFlaggedAccountsMock.mockResolvedValueOnce({ @@ -1570,163 +1965,971 @@ describe("codex manager cli commands", () => { expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); }); - it("writes shared workspace quota cache entries by email without reusing bare accountId keys", async () => { + it("offers backup recovery before OAuth when actionable backups exist", async () => { + setInteractiveTTY(true); const now = Date.now(); - loadAccountsMock.mockResolvedValue({ + let storageState: { + version: number; + activeIndex: number; + activeIndexByFamily: { codex: number }; + accounts: Array<{ + email?: string; + refreshToken: string; + addedAt: number; + lastUsed: number; + enabled?: boolean; + }>; + } | null = null; + loadAccountsMock.mockImplementation(async () => + storageState ? structuredClone(storageState) : null, + ); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: now - 1_000, + updatedAt: now - 1_000, + sizeBytes: 512, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 2, + }); + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + restoreNamedBackupMock.mockImplementation(async () => { + storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "restored@example.com", + refreshToken: "refresh-restored", + addedAt: now, + lastUsed: now, + enabled: true, + }, + ], + }; + return { imported: 1, skipped: 0, total: 1 }; + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + createAuthorizationFlowMock.mockRejectedValue( + new Error("oauth flow should be skipped when restoring backup"), + ); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalled(); + expect(confirmMock).toHaveBeenCalledTimes(2); + expect(selectMock).toHaveBeenCalled(); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + }); + + it("continues into OAuth when startup recovery is declined", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { version: 3, activeIndex: 0, activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "alpha@example.com", - accountId: "shared-workspace", - refreshToken: "refresh-alpha", - accessToken: "access-alpha", - expiresAt: now + 60 * 60 * 1000, - addedAt: now - 2_000, - lastUsed: now - 2_000, - enabled: true, - }, - { - email: "beta@example.com", - accountId: "shared-workspace", - refreshToken: "refresh-beta", - accessToken: "access-beta", - expiresAt: now + 60 * 60 * 1000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); }); - loadDashboardDisplaySettingsMock.mockResolvedValue({ - showPerAccountRows: true, - showQuotaDetails: true, - showForecastReasons: true, - showRecommendations: true, - showLiveProbeNotes: true, - menuAutoFetchLimits: true, - menuSortEnabled: false, - menuSortMode: "manual", - menuSortPinCurrent: true, - menuSortQuickSwitchVisibleRow: true, + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + const secondAssessment = { + ...assessment, + backup: { + ...assessment.backup, + name: "startup-backup-2", + path: "/mock/backups/startup-backup-2.json", + }, + }; + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [assessment, secondAssessment], + allAssessments: [assessment, secondAssessment], + totalBackups: 2, }); - fetchCodexQuotaSnapshotMock - .mockResolvedValueOnce({ - status: 200, - model: "gpt-5-codex", - primary: { - usedPercent: 20, - windowMinutes: 300, - resetAtMs: now + 1_000, - }, - secondary: { - usedPercent: 10, - windowMinutes: 10080, - resetAtMs: now + 2_000, - }, - }) - .mockResolvedValueOnce({ - status: 200, - model: "gpt-5-codex", - primary: { - usedPercent: 70, - windowMinutes: 300, - resetAtMs: now + 3_000, - }, - secondary: { - usedPercent: 40, - windowMinutes: 10080, - resetAtMs: now + 4_000, - }, - }); + confirmMock.mockResolvedValue(false); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); - expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); - expect(saveQuotaCacheMock).toHaveBeenCalledWith({ - byAccountId: {}, - byEmail: { - "alpha@example.com": { - updatedAt: expect.any(Number), - status: 200, - model: "gpt-5-codex", - primary: { - usedPercent: 20, - windowMinutes: 300, - resetAtMs: now + 1_000, - }, - secondary: { - usedPercent: 10, - windowMinutes: 10080, - resetAtMs: now + 2_000, - }, - }, - "beta@example.com": { - updatedAt: expect.any(Number), - status: 200, - model: "gpt-5-codex", - primary: { - usedPercent: 70, - windowMinutes: 300, - resetAtMs: now + 3_000, - }, - secondary: { - usedPercent: 40, - windowMinutes: 10080, - resetAtMs: now + 4_000, - }, - }, - }, - }); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledWith( + "Found 2 recoverable backups out of 2 total (2 backups) in /mock/backups. Restore now?", + ); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); }); - it("keeps login loop running when settings action is selected", async () => { + it("shows the empty storage menu before OAuth when startup recovery finds backups but none are actionable", async () => { + setInteractiveTTY(true); const now = Date.now(); - const storage = { + let storageState = { version: 3, activeIndex: 0, activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "a@example.com", - accountId: "acc_a", - refreshToken: "refresh-a", - accessToken: "access-a", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], + accounts: [], }; - loadAccountsMock.mockResolvedValue(storage); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "settings" }) - .mockResolvedValueOnce({ mode: "cancel" }); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [], + allAssessments: [], + totalBackups: 2, + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "add" }); + await configureSuccessfulOAuthFlow(now); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + expect(exitCode).toBe(0); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(promptLoginModeMock).toHaveBeenCalledTimes(1); + expect(promptLoginModeMock.mock.calls[0]?.[0]).toEqual([]); + expect(confirmMock).not.toHaveBeenCalled(); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(selectMock.mock.calls[0]?.[1]).toMatchObject({ + message: "Sign-In Method", + }); + expect(getNamedBackupsDirectoryPathMock).not.toHaveBeenCalled(); + expect(listNamedBackupsMock).not.toHaveBeenCalled(); + expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + expect(promptLoginModeMock.mock.invocationCallOrder[0]).toBeLessThan( + createAuthorizationFlowMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); }); - it("passes smart-sorted accounts to auth menu while preserving source index mapping", async () => { + it("shows all startup-scanned backups in the restore manager before re-prompting", async () => { + setInteractiveTTY(true); const now = Date.now(); - const storage = { + let storageState = { version: 3, - activeIndex: 2, - activeIndexByFamily: { codex: 2 }, - accounts: [ - { - email: "a@example.com", - accountId: "acc_a", - refreshToken: "refresh-a", - accessToken: "access-a", + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + const invalidAssessment = { + backup: { + name: "stale-backup", + path: "/mock/backups/stale-backup.json", + createdAt: null, + updatedAt: now - 60_000, + sizeBytes: 64, + version: 3, + accountCount: 0, + schemaErrors: [], + valid: false, + loadError: "Backup is empty or invalid", + }, + currentAccountCount: 0, + mergedAccountCount: null, + imported: null, + skipped: null, + wouldExceedLimit: false, + eligibleForRestore: false, + error: "Backup is empty or invalid", + }; + getActionableNamedBackupRestoresMock.mockResolvedValueOnce({ + assessments: [assessment], + allAssessments: [assessment, invalidAssessment], + totalBackups: 2, + }); + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + selectMock.mockResolvedValueOnce({ type: "back" }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(2); + const restoreManagerCall = selectMock.mock.calls.find( + ([, options]) => options?.message === "Restore From Backup", + ); + expect(restoreManagerCall).toBeDefined(); + expect(restoreManagerCall?.[1]).toMatchObject({ + message: "Restore From Backup", + }); + expect(restoreManagerCall?.[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: "startup-backup", + disabled: false, + }), + expect.objectContaining({ + label: "stale-backup", + disabled: true, + }), + ]), + ); + expect(listNamedBackupsMock).not.toHaveBeenCalled(); + expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + }); + + it("re-prompts startup recovery after backing out of the backup browser", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValueOnce({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 2, + }); + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + selectMock.mockResolvedValueOnce({ type: "back" }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(2); + expect(selectMock).toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + }); + + it("re-prompts startup recovery after cancelling restore inside the backup browser", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValueOnce({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 2, + }); + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + confirmMock + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(3); + expect(selectMock).toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + }); + + it("re-prompts startup recovery after restore fails inside the backup browser", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValueOnce({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 2, + }); + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + confirmMock + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + restoreNamedBackupMock.mockRejectedValueOnce( + makeErrnoError("resource busy", "EBUSY"), + ); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(3); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("startup-backup"); + expect(warnSpy).toHaveBeenCalledWith( + 'Failed to restore backup "startup-backup" (EBUSY).', + ); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + it("skips startup restore prompt in fallback login mode", async () => { + setInteractiveTTY(true); + isInteractiveLoginMenuAvailableMock.mockReturnValue(false); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }); + selectMock.mockResolvedValueOnce("cancel"); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + }); + + it("skips startup restore prompt when login starts non-interactive", async () => { + setInteractiveTTY(false); + isInteractiveLoginMenuAvailableMock.mockReturnValue(false); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + }); + + it("falls back to OAuth when startup recovery scan throws EBUSY", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + getActionableNamedBackupRestoresMock.mockRejectedValueOnce( + makeErrnoError("resource busy", "EBUSY"), + ); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + await configureSuccessfulOAuthFlow(now); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + "Startup recovery scan failed (EBUSY). Continuing with OAuth.", + ); + warnSpy.mockRestore(); + }); + + it("falls back to OAuth when startup recovery re-assessment throws EBUSY", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValueOnce({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 1, + }); + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + assessNamedBackupRestoreMock.mockRejectedValueOnce( + makeErrnoError("resource busy", "EBUSY"), + ); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + await configureSuccessfulOAuthFlow(now); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "startup-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ accounts: [] }), + }), + ); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'Failed to re-assess backup "startup-backup" before restore (EBUSY).', + ); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + it("falls back to OAuth when the startup recovery prompt throws", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 2, + }); + confirmMock.mockRejectedValueOnce( + makeErrnoError( + "no such file or directory, open '/mock/settings.json'", + "ENOENT", + ), + ); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + "Startup recovery prompt failed (ENOENT). Continuing with OAuth.", + ); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + it("falls back to OAuth when startup recovery display settings load fails before confirm", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 2, + }); + loadDashboardDisplaySettingsMock + .mockResolvedValueOnce({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }) + .mockImplementationOnce(async () => { + throw makeErrnoError( + "no such file or directory, open '/mock/dashboard-settings.json'", + "ENOENT", + ); + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(getNamedBackupsDirectoryPathMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + "Startup recovery prompt failed (ENOENT). Continuing with OAuth.", + ); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + it.each([ + { mode: "fresh", action: deleteSavedAccountsMock }, + { mode: "reset", action: resetLocalStateMock }, + ] as const)( + "suppresses startup restore prompt after deliberate $mode action in the same login session", + async ({ mode, action }) => { + setInteractiveTTY(true); + const now = Date.now(); + const populatedStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "existing@example.com", + refreshToken: "existing-refresh", + addedAt: now, + lastUsed: now, + }, + ], + }; + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + let loadCount = 0; + loadAccountsMock.mockImplementation(async () => { + loadCount += 1; + return loadCount <= 2 + ? structuredClone(populatedStorage) + : structuredClone(emptyStorage); + }); + promptLoginModeMock.mockResolvedValueOnce( + mode === "fresh" + ? { mode: "fresh", deleteAll: true } + : { mode: "reset" }, + ); + selectMock.mockResolvedValueOnce("cancel"); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(action).toHaveBeenCalledTimes(1); + expect(getActionableNamedBackupRestoresMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + }, + ); + + it("writes shared workspace quota cache entries by email without reusing bare accountId keys", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "alpha@example.com", + accountId: "shared-workspace", + refreshToken: "refresh-alpha", + accessToken: "access-alpha", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "beta@example.com", + accountId: "shared-workspace", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: false, + menuSortMode: "manual", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + fetchCodexQuotaSnapshotMock + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }) + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: {}, + byEmail: { + "alpha@example.com": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + "beta@example.com": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, + }, + }, + }); + }); + + it("keeps login loop running when settings action is selected", async () => { + const now = Date.now(); + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + accountId: "acc_a", + refreshToken: "refresh-a", + accessToken: "access-a", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }; + loadAccountsMock.mockResolvedValue(storage); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + }); + + it("passes smart-sorted accounts to auth menu while preserving source index mapping", async () => { + const now = Date.now(); + const storage = { + version: 3, + activeIndex: 2, + activeIndexByFamily: { codex: 2 }, + accounts: [ + { + email: "a@example.com", + accountId: "acc_a", + refreshToken: "refresh-a", + accessToken: "access-a", expiresAt: now + 3_600_000, addedAt: now - 3_000, lastUsed: now - 3_000, @@ -2254,21 +3457,654 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(readSettingsHubPanelContract()).toEqual( - SETTINGS_HUB_MENU_ORDER, - ); - expect(selectSequence.remaining()).toBe(0); - expect(saveDashboardDisplaySettingsMock).toHaveBeenCalled(); - expect(savePluginConfigMock).toHaveBeenCalledTimes(1); - expect(savePluginConfigMock).toHaveBeenCalledWith( - expect.objectContaining({ - preemptiveQuotaEnabled: expect.any(Boolean), - preemptiveQuotaRemainingPercent5h: expect.any(Number), - }), + expect(readSettingsHubPanelContract()).toEqual( + SETTINGS_HUB_MENU_ORDER, + ); + expect(selectSequence.remaining()).toBe(0); + expect(saveDashboardDisplaySettingsMock).toHaveBeenCalled(); + expect(savePluginConfigMock).toHaveBeenCalledTimes(1); + expect(savePluginConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + preemptiveQuotaEnabled: expect.any(Boolean), + preemptiveQuotaRemainingPercent5h: expect.any(Number), + }), + ); + }); + + it("restores a named backup from the login recovery menu", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + 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(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + }); + + it("offers backup restore from the login menu when no accounts are saved", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue(null); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + 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(promptLoginModeMock.mock.calls[0]?.[0]).toEqual([]); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ currentStorage: null }), + ); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + }); + + 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: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + 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(restoreNamedBackupMock).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: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + restoreNamedBackupMock.mockRejectedValueOnce( + new Error("Import file not found: /mock/backups/named-backup.json"), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + 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(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(warnSpy).toHaveBeenCalledWith( + 'Failed to restore backup "named-backup" (UNKNOWN).', + ); + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining("/mock/backups/named-backup.json"), + ); + } finally { + warnSpy.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(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Could not read backup directory: EPERM: operation not permitted", + ), + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it("keeps healthy backups selectable when one assessment fails", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const now = Date.now(); + const healthyAssessment = { + backup: { + name: "healthy-backup", + path: "/mock/backups/healthy-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([ + { + ...healthyAssessment.backup, + name: "broken-backup", + path: "/mock/backups/broken-backup.json", + }, + 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(restoreNamedBackupMock).toHaveBeenCalledWith("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_LIST_CONCURRENCY } = + await vi.importActual( + "../lib/storage.js", + ); + const backups = Array.from({ length: 9 }, (_value, index) => ({ + name: `named-backup-${index + 1}`, + path: `/mock/backups/named-backup-${index + 1}.json`, + 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_LIST_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: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + 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("into 3 current (4 after dedupe)"), + ); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + }); + + 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: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([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 warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to re-assess backup "named-backup" before restore (EBUSY).', + ), + ); + }); + + it("shows epoch backup timestamps in restore hints", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const assessment = { + backup: { + name: "epoch-backup", + path: "/mock/backups/epoch-backup.json", + 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( + `updated ${new Date(0).toLocaleDateString()}`, ); }); - it("shows experimental settings in the settings hub", async () => { + it("shows productized everyday and advanced settings in the hub", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); queueSettingsSelectSequence([{ type: "back" }]); @@ -2278,6 +4114,44 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(readSettingsHubPanelContract()).toEqual(SETTINGS_HUB_MENU_ORDER); + const firstMenuItems = (selectMock.mock.calls[0]?.[0] ?? []) as Array<{ + label?: string; + hint?: string; + }>; + const firstMenuOptions = selectMock.mock.calls[0]?.[1] as + | { + message?: string; + subtitle?: string; + } + | undefined; + expect(firstMenuOptions?.message).toBe("Settings"); + expect(firstMenuOptions?.subtitle).toContain( + "everyday dashboard settings", + ); + const menuText = firstMenuItems + .map((item) => `${item.label ?? ""}\n${item.hint ?? ""}`) + .join("\n"); + expect(menuText).toContain("Everyday Settings"); + expect(menuText).toContain("List Appearance"); + expect(menuText).toContain("Details Line"); + expect(menuText).toContain("Results & Refresh"); + expect(menuText).toContain("Colors"); + expect(menuText).toContain("Advanced & Operator"); + expect(menuText).toContain("Codex CLI Sync"); + expect(menuText).toContain("Experimental"); + expect(menuText).toContain("Advanced Backend Controls"); + expect(menuText).toContain( + "Show badges, sorting, and how much detail each account row shows.", + ); + expect(menuText).toContain( + "Preview and apply one-way sync from Codex CLI account files.", + ); + expect(menuText).toContain( + "Preview sync and backup actions before they become stable.", + ); + expect(menuText).toContain( + "Tune retry, quota, sync, recovery, and timeout internals.", + ); }); it("runs experimental oc sync with mandatory preview before apply", async () => { @@ -2334,130 +4208,1126 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(selectSequence.remaining()).toBe(0); - expect(planOcChatgptSyncMock).toHaveBeenCalledOnce(); - expect(applyOcChatgptSyncMock).toHaveBeenCalledOnce(); - expect(selectMock).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ label: expect.stringContaining("Active selection: preserve-destination") }), - ]), - expect.any(Object), + expect(selectSequence.remaining()).toBe(0); + expect(planOcChatgptSyncMock).toHaveBeenCalledOnce(); + expect(applyOcChatgptSyncMock).toHaveBeenCalledOnce(); + expect(selectMock).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ label: expect.stringContaining("Active selection: preserve-destination") }), + ]), + expect.any(Object), + ); + }); + + it("shows guidance when experimental oc sync target is ambiguous or unreadable", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); + planOcChatgptSyncMock.mockResolvedValue({ + kind: "blocked-ambiguous", + detection: { kind: "ambiguous", reason: "multiple targets", candidates: [] }, + }); + const selectSequence = queueSettingsSelectSequence([ + { type: "experimental" }, + { type: "sync" }, + { type: "back" }, + { type: "back" }, + { type: "back" }, + ]); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectSequence.remaining()).toBe(0); + expect(planOcChatgptSyncMock).toHaveBeenCalledOnce(); + expect(applyOcChatgptSyncMock).not.toHaveBeenCalled(); + }); + + + it("exports named pool backup from experimental settings", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + promptQuestionMock.mockResolvedValueOnce("backup-2026-03-10"); + runNamedBackupExportMock.mockResolvedValueOnce({ kind: "exported", path: "/mock/backups/backup-2026-03-10.json" }); + const selectSequence = queueSettingsSelectSequence([ + { type: "experimental" }, + { type: "backup" }, + { type: "back" }, + { type: "back" }, + { type: "back" }, + ]); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectSequence.remaining()).toBe(0); + expect(promptQuestionMock).toHaveBeenCalledOnce(); + expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "backup-2026-03-10" }); + }); + + it("rejects invalid or colliding experimental backup filenames", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + promptQuestionMock.mockResolvedValueOnce("../bad-name"); + runNamedBackupExportMock.mockResolvedValueOnce({ kind: "collision", path: "/mock/backups/bad-name.json" }); + const selectSequence = queueSettingsSelectSequence([ + { type: "experimental" }, + { type: "backup" }, + { type: "back" }, + { type: "back" }, + { type: "back" }, + ]); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectSequence.remaining()).toBe(0); + expect(promptQuestionMock).toHaveBeenCalledOnce(); + expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "../bad-name" }); + }); + + it("backs out of experimental sync preview without applying", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); + normalizeAccountStorageMock.mockReturnValue({ version: 3, accounts: [], activeIndex: 0 }); + planOcChatgptSyncMock.mockResolvedValue({ + kind: "ready", + target: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" }, + preview: { payload: { version: 3, accounts: [], activeIndex: 0 }, merged: { version: 3, accounts: [], activeIndex: 0 }, toAdd: [], toUpdate: [], toSkip: [], unchangedDestinationOnly: [], activeSelectionBehavior: "preserve-destination" }, + payload: { version: 3, accounts: [], activeIndex: 0 }, + destination: { version: 3, accounts: [], activeIndex: 0 }, + }); + const selectSequence = queueSettingsSelectSequence([ + { type: "experimental" }, + { type: "sync" }, + { type: "back" }, + { type: "back" }, + { type: "back" }, + ]); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + expect(exitCode).toBe(0); + expect(selectSequence.remaining()).toBe(0); + expect(planOcChatgptSyncMock).toHaveBeenCalledOnce(); + expect(applyOcChatgptSyncMock).not.toHaveBeenCalled(); + }); + + it("cancels experimental backup prompt on blank or q input", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + promptQuestionMock.mockResolvedValueOnce("q"); + const selectSequence = queueSettingsSelectSequence([ + { type: "experimental" }, + { type: "backup" }, + { 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(runNamedBackupExportMock).not.toHaveBeenCalled(); + }); + + it("honors the disabled sync-center apply hotkey", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "sync@example.com", + accountId: "acc_sync", + refreshToken: "refresh-sync", + accessToken: "access-sync", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + + let selectCall = 0; + selectMock.mockImplementation(async (_items, options) => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) { + const onInput = ( + options as { onInput?: (raw: string) => unknown } | undefined + )?.onInput; + expect(onInput?.("a")).toBeUndefined(); + return { type: "back" }; + } + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(applyCodexCliSyncToStorageMock).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + }); + + it("keeps sync-center recoverable when its initial preview load fails", async () => { + setInteractiveTTY(true); + const storage = createSettingsStorage(Date.now()); + loadAccountsMock.mockResolvedValue(storage); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock.mockResolvedValue({ + status: "error", + statusDetail: "busy", + sourcePath: null, + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 0, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + + let selectCall = 0; + selectMock.mockImplementation(async (items) => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) { + const text = (items as Array<{ label?: string; hint?: string }>) + .map((item) => `${item.label ?? ""}\n${item.hint ?? ""}`) + .join("\n"); + expect(text).toContain("Status: error"); + expect(text).toContain("busy"); + return { type: "back" }; + } + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(1); + }); + + it("keeps sync-center recoverable when refresh preview rebuild fails", async () => { + setInteractiveTTY(true); + const now = Date.now(); + const storage = createSettingsStorage(now); + loadAccountsMock.mockResolvedValue(storage); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock + .mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }) + .mockResolvedValueOnce({ + status: "error", + statusDetail: "busy", + sourcePath: null, + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 0, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + + let selectCall = 0; + selectMock.mockImplementation(async (items) => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) return { type: "refresh" }; + if (selectCall === 3) { + const text = (items as Array<{ label?: string; hint?: string }>) + .map((item) => `${item.label ?? ""}\n${item.hint ?? ""}`) + .join("\n"); + expect(text).toContain("Status: error"); + expect(text).toContain("busy"); + return { type: "back" }; + } + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); + }); + + it("applies sync-center writes with storage backups disabled when configured", async () => { + setInteractiveTTY(true); + const now = Date.now(); + const sourceState = { + path: "/mock/codex/accounts.json", + accounts: [ + { + accountId: "acc_codex", + email: "codex@example.com", + accessToken: "access-codex", + refreshToken: "refresh-codex", + }, + ], + }; + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "sync@example.com", + accountId: "acc_sync", + refreshToken: "refresh-sync", + accessToken: "access-sync", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }; + loadAccountsMock.mockResolvedValue(storage); + loadCodexCliStateMock.mockResolvedValue(sourceState); + loadPluginConfigMock.mockReturnValue({ storageBackupEnabled: false }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock + .mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: false, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }) + .mockResolvedValueOnce({ + status: "noop", + statusDetail: "Target already matches the current one-way sync result.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 2, + targetAccountCountAfter: 2, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: false, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + applyCodexCliSyncToStorageMock.mockResolvedValueOnce({ + changed: true, + storage: { + ...storage, + accounts: [ + ...storage.accounts, + { + email: "codex@example.com", + accountId: "acc_codex", + refreshToken: "refresh-codex", + addedAt: now, + lastUsed: now, + }, + ], + }, + pendingRun: { + revision: 2, + run: { + outcome: "changed", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }, + }); + let selectCall = 0; + selectMock.mockImplementation(async () => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) return { type: "apply" }; + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(applyCodexCliSyncToStorageMock).toHaveBeenCalledWith( + storage, + expect.objectContaining({ sourceState }), ); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ backupEnabled: false }), + ); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledTimes(1); + expect(commitCodexCliSyncRunFailureMock).not.toHaveBeenCalled(); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); }); - it("shows guidance when experimental oc sync target is ambiguous or unreadable", async () => { + it("rebuilds the sync-center preview from reloaded disk storage after apply", async () => { + setInteractiveTTY(true); const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); - planOcChatgptSyncMock.mockResolvedValue({ - kind: "blocked-ambiguous", - detection: { kind: "ambiguous", reason: "multiple targets", candidates: [] }, + const storage = createSettingsStorage(now); + const syncedStorage = { + ...storage, + accounts: [ + ...storage.accounts, + { + email: "codex@example.com", + accountId: "acc_codex", + refreshToken: "refresh-codex", + addedAt: now, + lastUsed: now, + }, + ], + }; + const persistedStorage = { + ...syncedStorage, + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + loadAccountsMock.mockImplementation(async () => + saveAccountsMock.mock.calls.length > 0 ? persistedStorage : storage, + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock + .mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: 1, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: true, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }) + .mockResolvedValueOnce({ + status: "noop", + statusDetail: "Target already matches the current one-way sync result.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: 1, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 2, + targetAccountCountAfter: 2, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + applyCodexCliSyncToStorageMock.mockResolvedValueOnce({ + changed: true, + storage: syncedStorage, + pendingRun: { + revision: 6, + run: { + outcome: "changed", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: true, + }, + }, + }, + }); + + let selectCall = 0; + selectMock.mockImplementation(async () => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) return { type: "apply" }; + return { type: "back" }; }); - 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(); + expect(loadAccountsMock.mock.calls.length).toBeGreaterThanOrEqual(3); + expect(saveAccountsMock).toHaveBeenCalledWith( + syncedStorage, + expect.objectContaining({ backupEnabled: true }), + ); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); + expect(previewCodexCliSyncMock.mock.calls[1]?.[0]).toBe(persistedStorage); + expect(previewCodexCliSyncMock.mock.calls[1]?.[0]).not.toBe(syncedStorage); }); - - it("exports named pool backup from experimental settings", async () => { + it("retries transient sync-center save failures before committing the sync run", 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" }, - ]); + const storage = createSettingsStorage(now); + const sourceState = { + path: "/mock/codex/accounts.json", + accounts: [ + { + accountId: "acc_codex", + email: "codex@example.com", + accessToken: "access-codex", + refreshToken: "refresh-codex", + }, + ], + }; + loadAccountsMock.mockResolvedValue(storage); + loadCodexCliStateMock.mockResolvedValue(sourceState); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock + .mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }) + .mockResolvedValueOnce({ + status: "noop", + statusDetail: "Target already matches the current one-way sync result.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 2, + targetAccountCountAfter: 2, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + applyCodexCliSyncToStorageMock.mockResolvedValueOnce({ + changed: true, + storage: { + ...storage, + accounts: [ + ...storage.accounts, + { + email: "codex@example.com", + accountId: "acc_codex", + refreshToken: "refresh-codex", + addedAt: now, + lastUsed: now, + }, + ], + }, + pendingRun: { + revision: 4, + run: { + outcome: "changed", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }, + }); + const secondSaveStarted = createDeferred(); + const secondSaveFinished = createDeferred(); + saveAccountsMock + .mockRejectedValueOnce(makeErrnoError("busy", "EBUSY")) + .mockImplementationOnce(async () => { + secondSaveStarted.resolve(undefined); + await secondSaveFinished.promise; + }); + + let selectCall = 0; + selectMock.mockImplementation(async () => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) return { type: "apply" }; + return { type: "back" }; + }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + const runPromise = runCodexMultiAuthCli(["auth", "login"]); + + await secondSaveStarted.promise; + expect(commitPendingCodexCliSyncRunMock).not.toHaveBeenCalled(); + secondSaveFinished.resolve(undefined); + const exitCode = await runPromise; expect(exitCode).toBe(0); - expect(selectSequence.remaining()).toBe(0); - expect(promptQuestionMock).toHaveBeenCalledOnce(); - expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "backup-2026-03-10" }); + expect(applyCodexCliSyncToStorageMock).toHaveBeenCalledWith( + storage, + expect.objectContaining({ sourceState }), + ); + expect(saveAccountsMock).toHaveBeenCalledTimes(2); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledTimes(1); + expect(commitCodexCliSyncRunFailureMock).not.toHaveBeenCalled(); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); }); - it("rejects invalid or colliding experimental backup filenames", async () => { + it("retries transient sync-center apply-time reads before running the sync", 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 storage = createSettingsStorage(now); + const sourceState = { + path: "/mock/codex/accounts.json", + accounts: [ + { + accountId: "acc_codex", + email: "codex@example.com", + accessToken: "access-codex", + refreshToken: "refresh-codex", + }, + ], + }; + let failNextApplyRead = false; + loadAccountsMock.mockImplementation(async () => { + if (failNextApplyRead) { + failNextApplyRead = false; + throw makeErrnoError("busy", "EBUSY"); + } + return structuredClone(storage); + }); + loadCodexCliStateMock.mockResolvedValue(sourceState); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock + .mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }) + .mockResolvedValueOnce({ + status: "noop", + statusDetail: "Target already matches the current one-way sync result.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 2, + targetAccountCountAfter: 2, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + applyCodexCliSyncToStorageMock.mockResolvedValueOnce({ + changed: true, + storage: { + ...storage, + accounts: [ + ...storage.accounts, + { + email: "codex@example.com", + accountId: "acc_codex", + refreshToken: "refresh-codex", + addedAt: now, + lastUsed: now, + }, + ], + }, + pendingRun: { + revision: 5, + run: { + outcome: "changed", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }, + }); + + let selectCall = 0; + selectMock.mockImplementation(async () => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) { + failNextApplyRead = true; + return { type: "apply" }; + } + return { 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" }); + expect(loadAccountsMock.mock.calls.length).toBeGreaterThanOrEqual(3); + expect(applyCodexCliSyncToStorageMock).toHaveBeenCalledWith( + storage, + expect.objectContaining({ sourceState }), + ); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledTimes(1); + expect(commitCodexCliSyncRunFailureMock).not.toHaveBeenCalled(); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); }); - it("backs out of experimental sync preview without applying", async () => { + it("retries transient sync-center apply-time reconcile failures before saving", async () => { + setInteractiveTTY(true); const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); - normalizeAccountStorageMock.mockReturnValue({ version: 3, accounts: [], activeIndex: 0 }); - planOcChatgptSyncMock.mockResolvedValue({ - kind: "ready", - target: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" }, - preview: { payload: { version: 3, accounts: [], activeIndex: 0 }, merged: { version: 3, accounts: [], activeIndex: 0 }, toAdd: [], toUpdate: [], toSkip: [], unchangedDestinationOnly: [], activeSelectionBehavior: "preserve-destination" }, - payload: { version: 3, accounts: [], activeIndex: 0 }, - destination: { version: 3, accounts: [], activeIndex: 0 }, + const storage = createSettingsStorage(now); + const sourceState = { + path: "/mock/codex/accounts.json", + accounts: [ + { + accountId: "acc_codex", + email: "codex@example.com", + accessToken: "access-codex", + refreshToken: "refresh-codex", + }, + ], + }; + loadAccountsMock.mockResolvedValue(storage); + loadCodexCliStateMock.mockResolvedValue(sourceState); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock + .mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }) + .mockResolvedValueOnce({ + status: "noop", + statusDetail: "Target already matches the current one-way sync result.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 2, + targetAccountCountAfter: 2, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + applyCodexCliSyncToStorageMock + .mockRejectedValueOnce(makeErrnoError("busy", "EBUSY")) + .mockResolvedValueOnce({ + changed: true, + storage: { + ...storage, + accounts: [ + ...storage.accounts, + { + email: "codex@example.com", + accountId: "acc_codex", + refreshToken: "refresh-codex", + addedAt: now, + lastUsed: now, + }, + ], + }, + pendingRun: { + revision: 7, + run: { + outcome: "changed", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }, + }); + + let selectCall = 0; + selectMock.mockImplementation(async () => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) return { type: "apply" }; + return { type: "back" }; }); - 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(); + expect(applyCodexCliSyncToStorageMock).toHaveBeenCalledTimes(2); + expect(applyCodexCliSyncToStorageMock).toHaveBeenNthCalledWith( + 1, + storage, + expect.objectContaining({ sourceState }), + ); + expect(applyCodexCliSyncToStorageMock).toHaveBeenNthCalledWith( + 2, + storage, + expect.objectContaining({ sourceState }), + ); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledTimes(1); + expect(commitCodexCliSyncRunFailureMock).not.toHaveBeenCalled(); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); }); - it("cancels experimental backup prompt on blank or q input", async () => { + it("surfaces sync-center save failures distinctly from reconcile failures", async () => { + setInteractiveTTY(true); const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - promptQuestionMock.mockResolvedValueOnce("q"); - const selectSequence = queueSettingsSelectSequence([ - { type: "experimental" }, - { type: "backup" }, - { type: "back" }, - { type: "back" }, - ]); + const sourceState = { + path: "/mock/codex/accounts.json", + accounts: [ + { + accountId: "acc_codex", + email: "codex@example.com", + accessToken: "access-codex", + refreshToken: "refresh-codex", + }, + ], + }; + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "sync@example.com", + accountId: "acc_sync", + refreshToken: "refresh-sync", + accessToken: "access-sync", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }; + loadAccountsMock.mockResolvedValue(storage); + loadCodexCliStateMock.mockResolvedValue(sourceState); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock.mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + applyCodexCliSyncToStorageMock.mockResolvedValueOnce({ + changed: true, + storage: { + ...storage, + accounts: [ + ...storage.accounts, + { + email: "codex@example.com", + accountId: "acc_codex", + refreshToken: "refresh-codex", + addedAt: now, + lastUsed: now, + }, + ], + }, + pendingRun: { + revision: 3, + run: { + outcome: "changed", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }, + }); + getLastCodexCliSyncRunMock.mockReturnValue({ + outcome: "error", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + message: "busy", + }); + saveAccountsMock.mockRejectedValue(makeErrnoError("busy", "EBUSY")); + + let selectCall = 0; + selectMock.mockImplementation(async (items) => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) return { type: "apply" }; + if (selectCall === 3) { + const text = (items as Array<{ label?: string; hint?: string }>) + .map((item) => `${item.label ?? ""}\n${item.hint ?? ""}`) + .join("\n"); + expect(text).toContain("Failed to save synced storage: busy"); + return { type: "back" }; + } + return { 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(runNamedBackupExportMock).not.toHaveBeenCalled(); + expect(applyCodexCliSyncToStorageMock).toHaveBeenCalledWith( + storage, + expect.objectContaining({ sourceState }), + ); + expect(saveAccountsMock).toHaveBeenCalledTimes(4); + expect(commitPendingCodexCliSyncRunMock).not.toHaveBeenCalled(); + expect(commitCodexCliSyncRunFailureMock).toHaveBeenCalledTimes(1); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(1); }); + it("drives current settings panels through representative hotkeys and persists each section", async () => { const now = Date.now(); setupInteractiveSettingsLogin( @@ -3018,132 +5888,525 @@ describe("codex manager cli commands", () => { ); }); - it("keeps last account enabled during fix to avoid lockout", async () => { + it("keeps last account enabled during fix to avoid lockout", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "solo@example.com", + refreshToken: "refresh-solo", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "failed", + reason: "http_error", + statusCode: 401, + message: "unauthorized", + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "fix", "--json"]); + expect(exitCode).toBe(0); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.enabled).toBe( + true, + ); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + reports: Array<{ outcome: string; message: string }>; + }; + expect(payload.reports[0]?.outcome).toBe("warning-soft-failure"); + expect(payload.reports[0]?.message).toContain("avoid lockout"); + }); + + it("runs live fix path with probe success and probe fallback warning", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "live-ok@example.com", + accountId: "acc_live_ok", + refreshToken: "refresh-live-ok", + accessToken: "access-live-ok", + expiresAt: now + 3_600_000, + addedAt: now - 5_000, + lastUsed: now - 5_000, + enabled: true, + }, + { + email: "live-warn@example.com", + accountId: "acc_live_warn", + refreshToken: "refresh-live-warn", + accessToken: "access-live-warn", + expiresAt: now - 5_000, + addedAt: now - 4_000, + lastUsed: now - 4_000, + enabled: true, + }, + ], + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-live-warn-next", + refresh: "refresh-live-warn-next", + expires: now + 7_200_000, + }); + fetchCodexQuotaSnapshotMock + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }) + .mockRejectedValueOnce(new Error("live probe temporary failure")); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "fix", + "--live", + "--json", + ]); + + expect(exitCode).toBe(0); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); + expect(queuedRefreshMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + reports: Array<{ outcome: string; message: string }>; + }; + expect( + payload.reports.some( + (report) => + report.outcome === "healthy" && + report.message.includes("live session OK"), + ), + ).toBe(true); + expect( + payload.reports.some( + (report) => + report.outcome === "warning-soft-failure" && + report.message.includes("refresh succeeded but live probe failed"), + ), + ).toBe(true); + }); + + it("deletes an account from manage mode and persists storage", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "second@example.com", + refreshToken: "refresh-second", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "manage", deleteAccountIndex: 1 }) + .mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(deleteAccountAtIndexMock).toHaveBeenCalledTimes(1); + expect(deleteAccountAtIndexMock.mock.calls[0]?.[0]?.index).toBe(1); + }); + + it("toggles account enabled state from manage mode", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "toggle@example.com", + refreshToken: "refresh-toggle", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "manage", toggleAccountIndex: 0 }) + .mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.enabled).toBe( + false, + ); + }); + + it("skips destructive work when user cancels from menu", async () => { + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "keep@example.com", + refreshToken: "keep-refresh", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(deleteSavedAccountsMock).not.toHaveBeenCalled(); + expect(resetLocalStateMock).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + }); + + it("deletes saved accounts only when requested", async () => { + const now = Date.now(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now, + lastUsed: now, + }, + ], + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "fresh", deleteAll: true }) + .mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(deleteSavedAccountsMock).toHaveBeenCalledTimes(1); + expect(resetLocalStateMock).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + "Deleted saved accounts. Saved accounts deleted; flagged/problem accounts, settings, and Codex CLI sync state kept.", + ); + logSpy.mockRestore(); + }); + + it("resets local state when reset mode is chosen", async () => { + const now = Date.now(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now, + lastUsed: now, + }, + ], + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "reset" }) + .mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(resetLocalStateMock).toHaveBeenCalledTimes(1); + expect(deleteSavedAccountsMock).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + "Reset local state. Saved accounts, flagged/problem accounts, and quota cache cleared; settings and Codex CLI sync state kept.", + ); + logSpy.mockRestore(); + }); + + it("waits for an in-flight menu quota refresh before starting quick check", async () => { const now = Date.now(); - loadAccountsMock.mockResolvedValueOnce({ - version: 3, + const menuStorage = { + version: 3 as const, activeIndex: 0, activeIndexByFamily: { codex: 0 }, accounts: [ { - email: "solo@example.com", - refreshToken: "refresh-solo", - addedAt: now - 1_000, - lastUsed: now - 1_000, + 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, + ); }); - queuedRefreshMock.mockResolvedValueOnce({ - type: "failed", - reason: "http_error", - statusCode: 401, - message: "unauthorized", + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuShowFetchStatus: true, + menuQuotaTtlMs: 60_000, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + let currentQuotaCache: { + byAccountId: Record; + byEmail: Record; + } = { + byAccountId: {}, + byEmail: {}, + }; + loadQuotaCacheMock.mockImplementation(async () => + structuredClone(currentQuotaCache), + ); + saveQuotaCacheMock.mockImplementation(async (value: typeof currentQuotaCache) => { + currentQuotaCache = structuredClone(value); }); + const firstFetchStarted = createDeferred(); + const secondFetchStarted = createDeferred(); + const releaseFirstFetch = createDeferred(); + const releaseSecondFetch = createDeferred(); + let secondFetchObserved = false; + let fetchCallCount = 0; + fetchCodexQuotaSnapshotMock.mockImplementation( + async (input: { accountId: string }) => { + fetchCallCount += 1; + if (fetchCallCount === 1) { + firstFetchStarted.resolve(); + await releaseFirstFetch.promise; + } else if (fetchCallCount === 2) { + secondFetchStarted.resolve(input.accountId); + await releaseSecondFetch.promise; + } + return { + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }; + }, + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "check" }) + .mockResolvedValueOnce({ mode: "cancel" }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "fix", "--json"]); - expect(exitCode).toBe(0); - expect(saveAccountsMock).toHaveBeenCalledTimes(1); - expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.enabled).toBe( - true, - ); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const runPromise = runCodexMultiAuthCli(["auth", "login"]); + void secondFetchStarted.promise.then(() => { + secondFetchObserved = true; + }); - const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { - reports: Array<{ outcome: string; message: string }>; - }; - expect(payload.reports[0]?.outcome).toBe("warning-soft-failure"); - expect(payload.reports[0]?.message).toContain("avoid lockout"); + await firstFetchStarted.promise; + await Promise.resolve(); + + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(1); + expect(secondFetchObserved).toBe(false); + + releaseFirstFetch.resolve(); + + const secondAccountId = await secondFetchStarted.promise; + expect(secondAccountId).toBe("acc-alpha"); + + releaseSecondFetch.resolve(); + + const exitCode = await runPromise; + + expect(exitCode).toBe(0); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); + expect(Object.keys(currentQuotaCache.byEmail)).toEqual(["alpha@example.com"]); + } finally { + logSpy.mockRestore(); + } }); - it("runs live fix path with probe success and probe fallback warning", async () => { + it("waits for an in-flight menu quota refresh before resetting local state", async () => { const now = Date.now(); - loadAccountsMock.mockResolvedValueOnce({ + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const fetchStarted = createDeferred(); + const fetchDeferred = createDeferred<{ + status: number; + model: string; + primary: Record; + secondary: Record; + }>(); + + loadAccountsMock.mockResolvedValue({ version: 3, activeIndex: 0, activeIndexByFamily: { codex: 0 }, accounts: [ { - email: "live-ok@example.com", - accountId: "acc_live_ok", - refreshToken: "refresh-live-ok", - accessToken: "access-live-ok", + email: "first@example.com", + accountId: "acc-first", + accessToken: "access-first", expiresAt: now + 3_600_000, - addedAt: now - 5_000, - lastUsed: now - 5_000, - enabled: true, - }, - { - email: "live-warn@example.com", - accountId: "acc_live_warn", - refreshToken: "refresh-live-warn", - accessToken: "access-live-warn", - expiresAt: now - 5_000, - addedAt: now - 4_000, - lastUsed: now - 4_000, + refreshToken: "refresh-first", + addedAt: now, + lastUsed: now, enabled: true, }, ], }); - queuedRefreshMock.mockResolvedValueOnce({ - type: "success", - access: "access-live-warn-next", - refresh: "refresh-live-warn-next", - expires: now + 7_200_000, + 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, }); - fetchCodexQuotaSnapshotMock - .mockResolvedValueOnce({ - status: 200, - model: "gpt-5-codex", - primary: { - usedPercent: 20, - windowMinutes: 300, - resetAtMs: now + 1_000, - }, - secondary: { - usedPercent: 10, - windowMinutes: 10080, - resetAtMs: now + 2_000, - }, - }) - .mockRejectedValueOnce(new Error("live probe temporary failure")); + loadQuotaCacheMock.mockResolvedValue({ + byAccountId: {}, + byEmail: {}, + }); + fetchCodexQuotaSnapshotMock.mockImplementation(async () => { + fetchStarted.resolve(); + return fetchDeferred.promise; + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "reset" }) + .mockResolvedValueOnce({ mode: "cancel" }); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli([ - "auth", - "fix", - "--live", - "--json", - ]); + const runPromise = runCodexMultiAuthCli(["auth", "login"]); + + await fetchStarted.promise; + await Promise.resolve(); + + expect(resetLocalStateMock).not.toHaveBeenCalled(); + + fetchDeferred.resolve({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }); + + const exitCode = await runPromise; expect(exitCode).toBe(0); - expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); - expect(queuedRefreshMock).toHaveBeenCalledTimes(1); expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); - - const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { - reports: Array<{ outcome: string; message: string }>; - }; - expect( - payload.reports.some( - (report) => - report.outcome === "healthy" && - report.message.includes("live session OK"), - ), - ).toBe(true); - expect( - payload.reports.some( - (report) => - report.outcome === "warning-soft-failure" && - report.message.includes("refresh succeeded but live probe failed"), - ), - ).toBe(true); + expect(resetLocalStateMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock.mock.invocationCallOrder[0]).toBeLessThan( + resetLocalStateMock.mock.invocationCallOrder[0] ?? + Number.POSITIVE_INFINITY, + ); + expect(logSpy).toHaveBeenCalledWith( + "Reset local state. Saved accounts, flagged/problem accounts, and quota cache cleared; settings and Codex CLI sync state kept.", + ); + logSpy.mockRestore(); }); - it("deletes an account from manage mode and persists storage", async () => { + it("waits for an in-flight menu quota refresh before opening backup restore manager", async () => { const now = Date.now(); + const fetchStarted = createDeferred(); + const fetchDeferred = createDeferred<{ + status: number; + model: string; + primary: Record; + secondary: Record; + }>(); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + loadAccountsMock.mockResolvedValue({ version: 3, activeIndex: 0, @@ -3151,63 +6414,142 @@ describe("codex manager cli commands", () => { accounts: [ { email: "first@example.com", + accountId: "acc-first", + accessToken: "access-first", + expiresAt: now + 3_600_000, refreshToken: "refresh-first", - addedAt: now - 2_000, - lastUsed: now - 2_000, - enabled: true, - }, - { - email: "second@example.com", - refreshToken: "refresh-second", - addedAt: now - 1_000, - lastUsed: now - 1_000, + addedAt: now, + lastUsed: now, enabled: true, }, ], }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuShowFetchStatus: true, + menuQuotaTtlMs: 60_000, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + loadQuotaCacheMock.mockResolvedValue({ + byAccountId: {}, + byEmail: {}, + }); + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + fetchCodexQuotaSnapshotMock.mockImplementation(async () => { + fetchStarted.resolve(); + return fetchDeferred.promise; + }); promptLoginModeMock - .mockResolvedValueOnce({ mode: "manage", deleteAccountIndex: 1 }) + .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"]); + const runPromise = runCodexMultiAuthCli(["auth", "login"]); + + await fetchStarted.promise; + await Promise.resolve(); + + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + + fetchDeferred.resolve({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }); + + const exitCode = await runPromise; expect(exitCode).toBe(0); - expect(saveAccountsMock).toHaveBeenCalledTimes(1); - expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts).toHaveLength(1); - expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.email).toBe( - "first@example.com", + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(restoreNamedBackupMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock.mock.invocationCallOrder[0]).toBeLessThan( + restoreNamedBackupMock.mock.invocationCallOrder[0] ?? + Number.POSITIVE_INFINITY, ); }); - it("toggles account enabled state from manage mode", async () => { + it("skips a second destructive action while reset is already running", async () => { const now = Date.now(); - loadAccountsMock.mockResolvedValue({ + const skipMessage = + "Another destructive action is already running. Wait for it to finish."; + const secondMenuAttempted = createDeferred(); + const skipLogged = createDeferred(); + const logSpy = vi.spyOn(console, "log").mockImplementation((message?: unknown) => { + if (message === skipMessage) { + skipLogged.resolve(); + } + }); + const firstResetStarted = createDeferred(); + const allowFirstResetToFinish = createDeferred(); + let menuPromptCall = 0; + + loadAccountsMock.mockImplementation(async () => ({ version: 3, activeIndex: 0, activeIndexByFamily: { codex: 0 }, accounts: [ { - email: "toggle@example.com", - refreshToken: "refresh-toggle", - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now, + lastUsed: now, }, ], + })); + promptLoginModeMock.mockImplementation(async () => { + menuPromptCall += 1; + if (menuPromptCall === 2) { + secondMenuAttempted.resolve(); + } + if (menuPromptCall <= 2) { + return { mode: "reset" }; + } + return { mode: "cancel" }; + }); + resetLocalStateMock.mockImplementationOnce(async () => { + firstResetStarted.resolve(); + await allowFirstResetToFinish.promise; + return { + accountsCleared: true, + flaggedCleared: true, + quotaCacheCleared: true, + }; }); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "manage", toggleAccountIndex: 0 }) - .mockResolvedValueOnce({ mode: "cancel" }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + const firstRunPromise = runCodexMultiAuthCli(["auth", "login"]); - expect(exitCode).toBe(0); - expect(saveAccountsMock).toHaveBeenCalledTimes(1); - expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.enabled).toBe( - false, - ); + await firstResetStarted.promise; + + const secondRunPromise = runCodexMultiAuthCli(["auth", "login"]); + await secondMenuAttempted.promise; + await skipLogged.promise; + + expect(resetLocalStateMock).toHaveBeenCalledTimes(1); + + allowFirstResetToFinish.resolve(); + + const [firstExitCode, secondExitCode] = await Promise.all([ + firstRunPromise, + secondRunPromise, + ]); + + expect(firstExitCode).toBe(0); + expect(secondExitCode).toBe(0); + expect(resetLocalStateMock).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith(skipMessage); + logSpy.mockRestore(); }); it("keeps settings unchanged in non-interactive mode and returns to menu", async () => { diff --git a/test/destructive-actions.test.ts b/test/destructive-actions.test.ts new file mode 100644 index 00000000..50083d9d --- /dev/null +++ b/test/destructive-actions.test.ts @@ -0,0 +1,301 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const clearAccountsMock = vi.fn(); +const clearFlaggedAccountsMock = vi.fn(); +const clearQuotaCacheMock = vi.fn(); +const clearCodexCliStateCacheMock = vi.fn(); +const loadFlaggedAccountsMock = vi.fn(); +const saveAccountsMock = vi.fn(); +const saveFlaggedAccountsMock = vi.fn(); + +vi.mock("../lib/codex-cli/state.js", () => ({ + clearCodexCliStateCache: clearCodexCliStateCacheMock, +})); + +vi.mock("../lib/prompts/codex.js", () => ({ + MODEL_FAMILIES: ["codex", "gpt-5.x"] as const, +})); + +vi.mock("../lib/quota-cache.js", () => ({ + clearQuotaCache: clearQuotaCacheMock, +})); + +vi.mock("../lib/storage.js", () => ({ + clearAccounts: clearAccountsMock, + clearFlaggedAccounts: clearFlaggedAccountsMock, + loadFlaggedAccounts: loadFlaggedAccountsMock, + saveAccounts: saveAccountsMock, + saveFlaggedAccounts: saveFlaggedAccountsMock, +})); + +describe("destructive actions", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + clearAccountsMock.mockResolvedValue(true); + clearFlaggedAccountsMock.mockResolvedValue(true); + clearQuotaCacheMock.mockResolvedValue(true); + loadFlaggedAccountsMock.mockResolvedValue({ version: 1, accounts: [] }); + saveAccountsMock.mockResolvedValue(undefined); + saveFlaggedAccountsMock.mockResolvedValue(undefined); + }); + + it("returns delete-only results without pretending kept data was cleared", async () => { + const { deleteSavedAccounts } = await import( + "../lib/destructive-actions.js" + ); + + await expect(deleteSavedAccounts()).resolves.toEqual({ + accountsCleared: true, + flaggedCleared: false, + quotaCacheCleared: false, + }); + expect(clearAccountsMock).toHaveBeenCalledTimes(1); + expect(clearFlaggedAccountsMock).not.toHaveBeenCalled(); + expect(clearQuotaCacheMock).not.toHaveBeenCalled(); + expect(clearCodexCliStateCacheMock).not.toHaveBeenCalled(); + }); + + it("returns reset results and clears Codex CLI state", async () => { + clearAccountsMock.mockResolvedValueOnce(true); + clearFlaggedAccountsMock.mockResolvedValueOnce(false); + clearQuotaCacheMock.mockResolvedValueOnce(true); + + const { resetLocalState } = await import("../lib/destructive-actions.js"); + + await expect(resetLocalState()).resolves.toEqual({ + accountsCleared: true, + flaggedCleared: false, + quotaCacheCleared: true, + }); + expect(clearAccountsMock).toHaveBeenCalledTimes(1); + expect(clearFlaggedAccountsMock).toHaveBeenCalledTimes(1); + expect(clearQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(clearCodexCliStateCacheMock).toHaveBeenCalledTimes(1); + }); + + it("does not clear Codex CLI state when resetLocalState aborts on an exception", async () => { + const resetError = Object.assign(new Error("flagged clear failed"), { + code: "EPERM", + }); + clearFlaggedAccountsMock.mockRejectedValueOnce(resetError); + + const { resetLocalState } = await import("../lib/destructive-actions.js"); + + await expect(resetLocalState()).rejects.toBe(resetError); + expect(clearAccountsMock).toHaveBeenCalledTimes(1); + expect(clearFlaggedAccountsMock).toHaveBeenCalledTimes(1); + expect(clearQuotaCacheMock).not.toHaveBeenCalled(); + expect(clearCodexCliStateCacheMock).not.toHaveBeenCalled(); + }); + + it("re-bases active indices before clamping when deleting an earlier account", async () => { + const { deleteAccountAtIndex } = await import( + "../lib/destructive-actions.js" + ); + + const storage = { + version: 3, + activeIndex: 1, + activeIndexByFamily: { codex: 2, "gpt-5.x": 1 }, + accounts: [ + { + refreshToken: "refresh-remove", + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "refresh-active", + addedAt: 2, + lastUsed: 2, + }, + { + refreshToken: "refresh-other", + addedAt: 3, + lastUsed: 3, + }, + ], + }; + + const deleted = await deleteAccountAtIndex({ storage, index: 0 }); + + expect(deleted).not.toBeNull(); + expect(deleted?.storage.accounts.map((account) => account.refreshToken)).toEqual([ + "refresh-active", + "refresh-other", + ]); + expect(deleted?.storage.activeIndex).toBe(0); + expect(deleted?.storage.activeIndexByFamily).toEqual({ + codex: 1, + "gpt-5.x": 0, + }); + expect(saveAccountsMock).toHaveBeenCalledWith( + expect.objectContaining({ + activeIndex: 0, + activeIndexByFamily: { codex: 1, "gpt-5.x": 0 }, + }), + ); + }); + + it("reloads flagged storage at delete time so newer flagged entries are preserved", async () => { + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [ + { + refreshToken: "refresh-remove", + addedAt: 2, + lastUsed: 2, + flaggedAt: 2, + }, + { + refreshToken: "refresh-newer", + addedAt: 3, + lastUsed: 3, + flaggedAt: 3, + }, + ], + }); + + const { deleteAccountAtIndex } = await import( + "../lib/destructive-actions.js" + ); + + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + refreshToken: "refresh-keep", + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "refresh-remove", + addedAt: 2, + lastUsed: 2, + }, + ], + }; + + const deleted = await deleteAccountAtIndex({ storage, index: 1 }); + + expect(deleted).not.toBeNull(); + expect(deleted?.flagged.accounts).toEqual([ + expect.objectContaining({ refreshToken: "refresh-newer" }), + ]); + expect(saveFlaggedAccountsMock).toHaveBeenCalledWith({ + version: 1, + accounts: [expect.objectContaining({ refreshToken: "refresh-newer" })], + }); + }); + + it("rethrows the original flagged-save failure after a successful rollback", async () => { + const flaggedSaveError = Object.assign(new Error("flagged save failed"), { + code: "EPERM", + }); + saveFlaggedAccountsMock.mockRejectedValueOnce(flaggedSaveError); + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [ + { + refreshToken: "refresh-remove", + addedAt: 1, + lastUsed: 1, + flaggedAt: 1, + }, + ], + }); + + const { deleteAccountAtIndex } = await import( + "../lib/destructive-actions.js" + ); + + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + refreshToken: "refresh-keep", + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "refresh-remove", + addedAt: 2, + lastUsed: 2, + }, + ], + }; + + await expect(deleteAccountAtIndex({ storage, index: 1 })).rejects.toBe( + flaggedSaveError, + ); + expect(saveAccountsMock).toHaveBeenCalledTimes(2); + expect(storage.accounts).toHaveLength(2); + }); + + it("preserves both the flagged-save failure and rollback failure", async () => { + const flaggedSaveError = Object.assign(new Error("flagged save failed"), { + code: "EPERM", + }); + const rollbackError = Object.assign(new Error("rollback failed"), { + code: "EPERM", + }); + saveAccountsMock + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(rollbackError); + saveFlaggedAccountsMock.mockRejectedValueOnce(flaggedSaveError); + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [ + { + refreshToken: "refresh-remove", + addedAt: 1, + lastUsed: 1, + flaggedAt: 1, + }, + ], + }); + + const { deleteAccountAtIndex } = await import( + "../lib/destructive-actions.js" + ); + + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + refreshToken: "refresh-keep", + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "refresh-remove", + addedAt: 2, + lastUsed: 2, + }, + ], + }; + + try { + await deleteAccountAtIndex({ storage, index: 1 }); + throw new Error("expected deleteAccountAtIndex to throw"); + } catch (error) { + expect(error).toBeInstanceOf(AggregateError); + const aggregateError = error as AggregateError; + expect(aggregateError.message).toBe( + "Deleting the account partially failed and rollback also failed.", + ); + expect(aggregateError.errors).toEqual([ + flaggedSaveError, + rollbackError, + ]); + } + expect(saveAccountsMock).toHaveBeenCalledTimes(2); + expect(storage.accounts).toHaveLength(2); + }); +}); diff --git a/test/documentation.test.ts b/test/documentation.test.ts index 1c696d36..db45078d 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -288,15 +288,37 @@ describe("Documentation Integrity", () => { it("locks the current Experimental settings menu labels and help text", () => { expect(UI_COPY.settings.title).toBe("Settings"); expect(UI_COPY.settings.subtitle).toBe( - "Customize menu, behavior, backend, and experiments", + "Start with everyday dashboard settings. Advanced operator controls stay separate.", ); expect(UI_COPY.settings.help).toBe("↑↓ Move | Enter Select | Q Back"); - expect(UI_COPY.settings.accountList).toBe("Account List View"); - expect(UI_COPY.settings.summaryFields).toBe("Summary Line"); - expect(UI_COPY.settings.behavior).toBe("Menu Behavior"); - expect(UI_COPY.settings.theme).toBe("Color Theme"); + expect(UI_COPY.settings.accountList).toBe("List Appearance"); + expect(UI_COPY.settings.accountListHint).toBe( + "Show badges, sorting, and how much detail each account row shows.", + ); + expect(UI_COPY.settings.syncCenter).toBe("Codex CLI Sync"); + expect(UI_COPY.settings.syncCenterHint).toBe( + "Preview and apply one-way sync from Codex CLI account files.", + ); + expect(UI_COPY.settings.summaryFields).toBe("Details Line"); + expect(UI_COPY.settings.summaryFieldsHint).toBe( + "Choose which details appear under each account row.", + ); + expect(UI_COPY.settings.behavior).toBe("Results & Refresh"); + expect(UI_COPY.settings.behaviorHint).toBe( + "Control auto-return timing and background limit refresh behavior.", + ); + expect(UI_COPY.settings.theme).toBe("Colors"); + expect(UI_COPY.settings.themeHint).toBe( + "Pick the base palette and accent color.", + ); expect(UI_COPY.settings.experimental).toBe("Experimental"); - expect(UI_COPY.settings.backend).toBe("Backend Controls"); + expect(UI_COPY.settings.experimentalHint).toBe( + "Preview sync and backup actions before they become stable.", + ); + expect(UI_COPY.settings.backend).toBe("Advanced Backend Controls"); + expect(UI_COPY.settings.backendHint).toBe( + "Tune retry, quota, sync, recovery, and timeout internals.", + ); expect(UI_COPY.settings.accountListHelp).toBe( "Enter Toggle | Number Toggle | M Sort | L Layout | S Save | Q Back (No Save)", ); @@ -317,12 +339,16 @@ describe("Documentation Integrity", () => { it("keeps settings reference sections aligned with current menu labels and backend categories", () => { const settingsRef = read("docs/reference/settings.md"); - expect(settingsRef).toContain(`## ${UI_COPY.settings.accountList}`); - expect(settingsRef).toContain(`## ${UI_COPY.settings.summaryFields}`); - expect(settingsRef).toContain(`## ${UI_COPY.settings.behavior}`); - expect(settingsRef).toContain(`## ${UI_COPY.settings.theme}`); - expect(settingsRef).toContain(`## ${UI_COPY.settings.experimental}`); - expect(settingsRef).toContain(`## ${UI_COPY.settings.backend}`); + expect(settingsRef).toContain("## Everyday Settings"); + expect(settingsRef).toContain(`### ${UI_COPY.settings.accountList}`); + expect(settingsRef).toContain(`### ${UI_COPY.settings.summaryFields}`); + expect(settingsRef).toContain(`### ${UI_COPY.settings.behavior}`); + expect(settingsRef).toContain(`### ${UI_COPY.settings.theme}`); + expect(settingsRef).toContain("## Advanced & Operator"); + expect(settingsRef).toContain(`### ${UI_COPY.settings.syncCenter}`); + expect(settingsRef).toContain(`### ${UI_COPY.settings.experimental}`); + expect(settingsRef).toContain(`### ${UI_COPY.settings.backend}`); + expect(settingsRef).toContain("## Backend Categories"); expect(settingsRef).toContain("### Session & Sync"); expect(settingsRef).toContain("### Rotation & Quota"); expect(settingsRef).toContain("### Refresh & Recovery"); @@ -335,6 +361,23 @@ describe("Documentation Integrity", () => { expect(settingsRef).toContain("- `menuStatuslineFields`"); }); + it("keeps getting-started and troubleshooting docs aligned with current restore and sync flows", () => { + const gettingStarted = read("docs/getting-started.md"); + const troubleshooting = read("docs/troubleshooting.md"); + + expect(gettingStarted).toContain("## Restore Or Start Fresh"); + expect(gettingStarted).toContain("## Sync And Settings"); + expect(gettingStarted).toContain("Restore From Backup"); + expect(troubleshooting).toContain("## Backup Restore Problems"); + expect(troubleshooting).toContain("## Codex CLI Sync Problems"); + expect(troubleshooting).toContain( + "`codex auth login` -> `Settings` -> `Advanced & Operator` -> `Codex CLI Sync`", + ); + expect(troubleshooting).toContain( + "`codex auth login` -> `Settings` -> `Advanced & Operator` -> `Advanced Backend Controls`", + ); + }); + it("keeps changelog aligned with canonical 0.x release policy", () => { const changelog = read("CHANGELOG.md"); expect(changelog).toContain("## [0.1.8] - 2026-03-11"); diff --git a/test/live-account-sync.test.ts b/test/live-account-sync.test.ts index fa51e52b..7a619f01 100644 --- a/test/live-account-sync.test.ts +++ b/test/live-account-sync.test.ts @@ -1,8 +1,55 @@ -import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { promises as fs } from "node:fs"; -import { join } from "node:path"; import { tmpdir } from "node:os"; -import { LiveAccountSync } from "../lib/live-account-sync.js"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + __resetLastLiveAccountSyncSnapshotForTests, + getLastLiveAccountSyncSnapshot, + LiveAccountSync, +} from "../lib/live-account-sync.js"; + +const RETRYABLE_REMOVE_CODES = new Set([ + "EBUSY", + "EPERM", + "ENOTEMPTY", + "EACCES", + "ETIMEDOUT", +]); + +async function removeWithRetry( + targetPath: string, + options: { recursive?: boolean; force?: boolean }, +): Promise { + for (let attempt = 0; attempt < 6; attempt += 1) { + try { + await fs.rm(targetPath, options); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return; + } + if (!code || !RETRYABLE_REMOVE_CODES.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + +function createDeferred(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} describe("live-account-sync", () => { let workDir = ""; @@ -11,23 +58,153 @@ describe("live-account-sync", () => { beforeEach(async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-02-26T12:00:00.000Z")); - workDir = join(tmpdir(), `codex-live-sync-${Date.now()}-${Math.random().toString(36).slice(2)}`); + __resetLastLiveAccountSyncSnapshotForTests(); + workDir = join( + tmpdir(), + `codex-live-sync-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); storagePath = join(workDir, "openai-codex-accounts.json"); await fs.mkdir(workDir, { recursive: true }); - await fs.writeFile(storagePath, JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), "utf-8"); + await fs.writeFile( + storagePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); }); afterEach(async () => { vi.useRealTimers(); - await fs.rm(workDir, { recursive: true, force: true }); + __resetLastLiveAccountSyncSnapshotForTests(); + await removeWithRetry(workDir, { recursive: true, force: true }); + }); + + it("publishes watcher state for sync-center status surfaces", async () => { + const reload = vi.fn(async () => undefined); + const sync = new LiveAccountSync(reload, { + pollIntervalMs: 500, + debounceMs: 50, + }); + + expect(getLastLiveAccountSyncSnapshot().running).toBe(false); + await sync.syncToPath(storagePath); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: storagePath, + running: true, + }), + ); + + sync.stop(); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: storagePath, + running: false, + }), + ); + }); + + it("keeps the newest watcher snapshot published when older instances stop later", async () => { + const secondStoragePath = join(workDir, "openai-codex-accounts-secondary.json"); + await fs.writeFile( + secondStoragePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); + const first = new LiveAccountSync(async () => undefined, { + pollIntervalMs: 500, + debounceMs: 50, + }); + const second = new LiveAccountSync(async () => undefined, { + pollIntervalMs: 500, + debounceMs: 50, + }); + + await first.syncToPath(storagePath); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: storagePath, + running: true, + }), + ); + + await second.syncToPath(secondStoragePath); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: secondStoragePath, + running: true, + }), + ); + + first.stop(); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: secondStoragePath, + running: true, + }), + ); + + second.stop(); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: secondStoragePath, + running: false, + }), + ); + }); + + it("falls back to the still-running older watcher when a newer watcher stops", async () => { + const secondStoragePath = join(workDir, "openai-codex-accounts-tertiary.json"); + await fs.writeFile( + secondStoragePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); + const first = new LiveAccountSync(async () => undefined, { + pollIntervalMs: 500, + debounceMs: 50, + }); + const second = new LiveAccountSync(async () => undefined, { + pollIntervalMs: 500, + debounceMs: 50, + }); + + await first.syncToPath(storagePath); + await second.syncToPath(secondStoragePath); + + second.stop(); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: storagePath, + running: true, + }), + ); + + first.stop(); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: secondStoragePath, + running: false, + }), + ); }); it("reloads when file changes are detected by polling", async () => { const reload = vi.fn(async () => undefined); - const sync = new LiveAccountSync(reload, { pollIntervalMs: 500, debounceMs: 50 }); + const sync = new LiveAccountSync(reload, { + pollIntervalMs: 500, + debounceMs: 50, + }); await sync.syncToPath(storagePath); - await fs.writeFile(storagePath, JSON.stringify({ version: 3, activeIndex: 0, accounts: [{ refreshToken: "a" }] }), "utf-8"); + await fs.writeFile( + storagePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "a" }], + }), + "utf-8", + ); const bumped = new Date(Date.now() + 1_000); await fs.utimes(storagePath, bumped, bumped); @@ -44,10 +221,21 @@ describe("live-account-sync", () => { const reload = vi.fn(async () => { throw new Error("reload failed"); }); - const sync = new LiveAccountSync(reload, { pollIntervalMs: 500, debounceMs: 50 }); + const sync = new LiveAccountSync(reload, { + pollIntervalMs: 500, + debounceMs: 50, + }); await sync.syncToPath(storagePath); - await fs.writeFile(storagePath, JSON.stringify({ version: 3, activeIndex: 0, accounts: [{ refreshToken: "b" }] }), "utf-8"); + await fs.writeFile( + storagePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "b" }], + }), + "utf-8", + ); const bumped = new Date(Date.now() + 2_000); await fs.utimes(storagePath, bumped, bumped); @@ -61,11 +249,22 @@ describe("live-account-sync", () => { it("stops watching cleanly and prevents further reloads", async () => { const reload = vi.fn(async () => undefined); - const sync = new LiveAccountSync(reload, { pollIntervalMs: 500, debounceMs: 50 }); + const sync = new LiveAccountSync(reload, { + pollIntervalMs: 500, + debounceMs: 50, + }); await sync.syncToPath(storagePath); sync.stop(); - await fs.writeFile(storagePath, JSON.stringify({ version: 3, activeIndex: 0, accounts: [{ refreshToken: "c" }] }), "utf-8"); + await fs.writeFile( + storagePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "c" }], + }), + "utf-8", + ); const bumped = new Date(Date.now() + 3_000); await fs.utimes(storagePath, bumped, bumped); @@ -77,11 +276,16 @@ describe("live-account-sync", () => { it("counts poll errors when stat throws non-retryable errors", async () => { const reload = vi.fn(async () => undefined); - const sync = new LiveAccountSync(reload, { pollIntervalMs: 500, debounceMs: 50 }); + const sync = new LiveAccountSync(reload, { + pollIntervalMs: 500, + debounceMs: 50, + }); await sync.syncToPath(storagePath); const statSpy = vi.spyOn(fs, "stat"); - statSpy.mockRejectedValueOnce(Object.assign(new Error("disk fault"), { code: "EIO" })); + statSpy.mockRejectedValueOnce( + Object.assign(new Error("disk fault"), { code: "EIO" }), + ); await vi.advanceTimersByTimeAsync(600); @@ -90,25 +294,308 @@ describe("live-account-sync", () => { sync.stop(); }); - it("coalesces overlapping reload attempts into a single in-flight reload", async () => { - let resolveReload: (() => void) | undefined; - const reloadStarted = new Promise((resolve) => { - resolveReload = resolve; + it("runs a follow-up reload when writes land during an in-flight reload", async () => { + const firstReloadStarted = createDeferred(); + const firstReloadFinished = createDeferred(); + const secondReloadStarted = createDeferred(); + let reloadCalls = 0; + const reload = vi.fn(async () => { + reloadCalls += 1; + if (reloadCalls === 1) { + firstReloadStarted.resolve(undefined); + await firstReloadFinished.promise; + return; + } + secondReloadStarted.resolve(undefined); + }); + const sync = new LiveAccountSync(reload, { + pollIntervalMs: 500, + debounceMs: 50, }); - const reload = vi.fn(async () => reloadStarted); - const sync = new LiveAccountSync(reload, { pollIntervalMs: 500, debounceMs: 50 }); await sync.syncToPath(storagePath); - const runReload = Reflect.get(sync, "runReload") as (reason: "watch" | "poll") => Promise; + const runReload = Reflect.get(sync, "runReload") as ( + reason: "watch" | "poll", + ) => Promise; const invoke = (reason: "watch" | "poll") => - Reflect.apply(runReload as (...args: unknown[]) => unknown, sync as object, [reason]) as Promise; + Reflect.apply( + runReload as (...args: unknown[]) => unknown, + sync as object, + [reason], + ) as Promise; const first = invoke("poll"); + await firstReloadStarted.promise; + + await fs.writeFile( + storagePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "during-first-reload-a" }], + }), + "utf-8", + ); + const firstBump = new Date(Date.now() + 1_000); + await fs.utimes(storagePath, firstBump, firstBump); const second = invoke("watch"); - resolveReload?.(); - await Promise.all([first, second]); + await fs.writeFile( + storagePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "during-first-reload-b" }], + }), + "utf-8", + ); + const secondBump = new Date(Date.now() + 2_000); + await fs.utimes(storagePath, secondBump, secondBump); + const third = invoke("watch"); + + await vi.advanceTimersByTimeAsync(0); expect(reload).toHaveBeenCalledTimes(1); + + firstReloadFinished.resolve(undefined); + await secondReloadStarted.promise; + await Promise.all([first, second, third]); + + expect(reload).toHaveBeenCalledTimes(2); + expect(sync.getSnapshot().reloadCount).toBe(2); sync.stop(); }); -}); + it("drops stale reload completions after switching to a new path", async () => { + const secondStoragePath = join(workDir, "openai-codex-accounts-second.json"); + await fs.writeFile( + secondStoragePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); + + const firstReloadStarted = createDeferred(); + const firstReloadFinished = createDeferred(); + const secondReloadStarted = createDeferred(); + const secondReloadFinished = createDeferred(); + const seenPaths: string[] = []; + let reloadCall = 0; + let sync: LiveAccountSync; + + const reload = vi.fn(async () => { + reloadCall += 1; + const currentPath = Reflect.get(sync, "currentPath") as string | null; + seenPaths.push(currentPath ?? ""); + if (reloadCall === 1) { + firstReloadStarted.resolve(undefined); + await firstReloadFinished.promise; + return; + } + secondReloadStarted.resolve(undefined); + await secondReloadFinished.promise; + }); + + sync = new LiveAccountSync(reload, { + pollIntervalMs: 500, + debounceMs: 50, + }); + await sync.syncToPath(storagePath); + + const runReload = Reflect.get(sync, "runReload") as ( + reason: "watch" | "poll", + ) => Promise; + const invoke = (reason: "watch" | "poll") => + Reflect.apply( + runReload as (...args: unknown[]) => unknown, + sync as object, + [reason], + ) as Promise; + + const first = invoke("poll"); + await firstReloadStarted.promise; + + await sync.syncToPath(secondStoragePath); + const second = invoke("watch"); + await vi.advanceTimersByTimeAsync(0); + expect(reload).toHaveBeenCalledTimes(1); + + firstReloadFinished.resolve(undefined); + await secondReloadStarted.promise; + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: secondStoragePath, + running: true, + reloadCount: 0, + errorCount: 0, + }), + ); + + secondReloadFinished.resolve(undefined); + await Promise.all([first, second]); + + expect(seenPaths).toEqual([storagePath, secondStoragePath]); + expect(reload).toHaveBeenCalledTimes(2); + expect(sync.getSnapshot()).toEqual( + expect.objectContaining({ + path: secondStoragePath, + running: true, + reloadCount: 1, + errorCount: 0, + }), + ); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: secondStoragePath, + running: true, + reloadCount: 1, + errorCount: 0, + }), + ); + + sync.stop(); + }); + + it("keeps the prior path published when stop aborts a path switch mid-start", async () => { + const secondStoragePath = join( + workDir, + "openai-codex-accounts-aborted-switch.json", + ); + await fs.writeFile( + secondStoragePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); + + const sync = new LiveAccountSync(async () => undefined, { + pollIntervalMs: 500, + debounceMs: 50, + }); + await sync.syncToPath(storagePath); + + const originalStat = fs.stat; + const secondStatStarted = createDeferred(); + const releaseSecondStat = createDeferred(); + const statSpy = vi + .spyOn(fs, "stat") + .mockImplementation(async (...args: Parameters) => { + if (args[0] === secondStoragePath) { + secondStatStarted.resolve(undefined); + await releaseSecondStat.promise; + } + return originalStat(...args); + }); + + try { + const pendingSwitch = sync.syncToPath(secondStoragePath); + await secondStatStarted.promise; + + sync.stop(); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: storagePath, + running: false, + }), + ); + + releaseSecondStat.resolve(undefined); + await pendingSwitch; + + expect(sync.getSnapshot()).toEqual( + expect.objectContaining({ + path: storagePath, + running: false, + }), + ); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: storagePath, + running: false, + }), + ); + } finally { + statSpy.mockRestore(); + } + }); + + it("waits for the prior path reload before counting the next path as synced", async () => { + const secondStoragePath = join(workDir, "openai-codex-accounts-third.json"); + await fs.writeFile( + secondStoragePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); + + const firstReloadStarted = createDeferred(); + const firstReloadFinished = createDeferred(); + const secondReloadStarted = createDeferred(); + const secondReloadFinished = createDeferred(); + const startedReloadPaths: string[] = []; + let sharedReloadPromise: Promise | null = null; + let sync: LiveAccountSync; + + const reload = vi.fn(async () => { + if (sharedReloadPromise) { + return sharedReloadPromise; + } + const currentPath = Reflect.get(sync, "currentPath") as string | null; + startedReloadPaths.push(currentPath ?? ""); + if (startedReloadPaths.length === 1) { + sharedReloadPromise = (async () => { + firstReloadStarted.resolve(undefined); + await firstReloadFinished.promise; + sharedReloadPromise = null; + })(); + return sharedReloadPromise; + } + sharedReloadPromise = (async () => { + secondReloadStarted.resolve(undefined); + await secondReloadFinished.promise; + sharedReloadPromise = null; + })(); + return sharedReloadPromise; + }); + + sync = new LiveAccountSync(reload, { + pollIntervalMs: 500, + debounceMs: 50, + }); + await sync.syncToPath(storagePath); + + const runReload = Reflect.get(sync, "runReload") as ( + reason: "watch" | "poll", + ) => Promise; + const invoke = (reason: "watch" | "poll") => + Reflect.apply( + runReload as (...args: unknown[]) => unknown, + sync as object, + [reason], + ) as Promise; + + const first = invoke("poll"); + await firstReloadStarted.promise; + + await sync.syncToPath(secondStoragePath); + const second = invoke("watch"); + + await vi.advanceTimersByTimeAsync(0); + expect(startedReloadPaths).toEqual([storagePath]); + expect(sync.getSnapshot().reloadCount).toBe(0); + + firstReloadFinished.resolve(undefined); + await secondReloadStarted.promise; + expect(startedReloadPaths).toEqual([storagePath, secondStoragePath]); + expect(sync.getSnapshot().reloadCount).toBe(0); + + secondReloadFinished.resolve(undefined); + await Promise.all([first, second]); + + expect(sync.getSnapshot()).toEqual( + expect.objectContaining({ + path: secondStoragePath, + running: true, + reloadCount: 1, + errorCount: 0, + }), + ); + + sync.stop(); + }); +}); diff --git a/test/quota-cache.test.ts b/test/quota-cache.test.ts index 54b5ffb6..fd712784 100644 --- a/test/quota-cache.test.ts +++ b/test/quota-cache.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { promises as fs } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; describe("quota cache", () => { let tempDir: string; @@ -20,7 +21,7 @@ describe("quota cache", () => { } else { process.env.CODEX_MULTI_AUTH_DIR = originalDir; } - await fs.rm(tempDir, { recursive: true, force: true }); + await removeWithRetry(tempDir, { recursive: true, force: true }); }); it("returns empty cache by default", async () => { @@ -79,6 +80,109 @@ describe("quota cache", () => { expect(loaded).toEqual({ byAccountId: {}, byEmail: {} }); }); + it("resolves the quota cache path from the current CODEX_MULTI_AUTH_DIR on each call", async () => { + const { clearQuotaCache, getQuotaCachePath, saveQuotaCache } = + await import("../lib/quota-cache.js"); + const firstPath = getQuotaCachePath(); + const nextTempDir = await fs.mkdtemp( + join(tmpdir(), "codex-multi-auth-quota-next-"), + ); + + try { + process.env.CODEX_MULTI_AUTH_DIR = nextTempDir; + const nextPath = getQuotaCachePath(); + + expect(nextPath).not.toBe(firstPath); + expect(nextPath).toBe(join(nextTempDir, "quota-cache.json")); + + await saveQuotaCache({ byAccountId: {}, byEmail: {} }); + await expect(fs.access(nextPath)).resolves.toBeUndefined(); + + await clearQuotaCache(); + await expect(fs.access(nextPath)).rejects.toThrow(); + } finally { + await removeWithRetry(nextTempDir, { recursive: true, force: true }); + } + }); + + it.each(["EBUSY", "EPERM"] as const)( + "retries transient %s while clearing cache", + async (code) => { + const { clearQuotaCache, getQuotaCachePath, saveQuotaCache } = + await import("../lib/quota-cache.js"); + await saveQuotaCache({ byAccountId: {}, byEmail: {} }); + const quotaCachePath = getQuotaCachePath(); + const realUnlink = fs.unlink.bind(fs); + let attempts = 0; + const unlinkSpy = vi.spyOn(fs, "unlink"); + unlinkSpy.mockImplementation(async (...args) => { + if (String(args[0]) === quotaCachePath) { + attempts += 1; + if (attempts < 3) { + const error = new Error( + `unlink failed: ${code}`, + ) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + } + return realUnlink(...args); + }); + + try { + await expect(clearQuotaCache()).resolves.toBe(true); + await expect(fs.access(quotaCachePath)).rejects.toThrow(); + expect(attempts).toBe(3); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + + it.each(["EBUSY", "EPERM"] as const)( + "returns false when clearQuotaCache exhausts %s retries", + async (code) => { + vi.resetModules(); + const warnMock = vi.fn(); + vi.doMock("../lib/logger.js", () => ({ + logWarn: warnMock, + })); + + try { + const { clearQuotaCache, getQuotaCachePath, saveQuotaCache } = + await import("../lib/quota-cache.js"); + await saveQuotaCache({ byAccountId: {}, byEmail: {} }); + const quotaCachePath = getQuotaCachePath(); + let attempts = 0; + const unlinkSpy = vi.spyOn(fs, "unlink"); + unlinkSpy.mockImplementation(async (...args) => { + if (String(args[0]) === quotaCachePath) { + attempts += 1; + const error = new Error(`locked: ${code}`) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return Promise.resolve(); + }); + + try { + await expect(clearQuotaCache()).resolves.toBe(false); + expect(attempts).toBe(5); + await expect(fs.access(quotaCachePath)).resolves.toBeUndefined(); + expect(warnMock).toHaveBeenCalledWith( + expect.stringContaining( + `Failed to clear quota cache quota-cache.json: locked: ${code}`, + ), + ); + } finally { + unlinkSpy.mockRestore(); + } + } finally { + vi.doUnmock("../lib/logger.js"); + } + }, + ); + it("retries transient EBUSY while loading cache", async () => { const { loadQuotaCache, getQuotaCachePath } = await import("../lib/quota-cache.js"); diff --git a/test/recovery.test.ts b/test/recovery.test.ts index 6176d6ac..54f572c8 100644 --- a/test/recovery.test.ts +++ b/test/recovery.test.ts @@ -1,1119 +1,1900 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - detectErrorType, - isRecoverableError, - getRecoveryToastContent, - getRecoverySuccessToast, - getRecoveryFailureToast, - createSessionRecoveryHook, + createSessionRecoveryHook, + detectErrorType, + getRecoveryFailureToast, + getRecoverySuccessToast, + getRecoveryToastContent, + isRecoverableError, } from "../lib/recovery"; +afterEach(() => { + vi.restoreAllMocks(); +}); + vi.mock("../lib/recovery/storage.js", () => ({ - readParts: vi.fn(() => []), - findMessagesWithThinkingBlocks: vi.fn(() => []), - findMessagesWithOrphanThinking: vi.fn(() => []), - findMessageByIndexNeedingThinking: vi.fn(() => null), - prependThinkingPart: vi.fn(() => false), - stripThinkingParts: vi.fn(() => false), + readParts: vi.fn(() => []), + findMessagesWithThinkingBlocks: vi.fn(() => []), + findMessagesWithOrphanThinking: vi.fn(() => []), + findMessageByIndexNeedingThinking: vi.fn(() => null), + prependThinkingPart: vi.fn(() => false), + stripThinkingParts: vi.fn(() => false), })); import { - readParts, - findMessagesWithThinkingBlocks, - findMessagesWithOrphanThinking, - findMessageByIndexNeedingThinking, - prependThinkingPart, - stripThinkingParts, + findMessageByIndexNeedingThinking, + findMessagesWithOrphanThinking, + findMessagesWithThinkingBlocks, + prependThinkingPart, + readParts, + stripThinkingParts, } from "../lib/recovery/storage.js"; const mockedReadParts = vi.mocked(readParts); -const mockedFindMessagesWithThinkingBlocks = vi.mocked(findMessagesWithThinkingBlocks); -const mockedFindMessagesWithOrphanThinking = vi.mocked(findMessagesWithOrphanThinking); -const mockedFindMessageByIndexNeedingThinking = vi.mocked(findMessageByIndexNeedingThinking); +const mockedFindMessagesWithThinkingBlocks = vi.mocked( + findMessagesWithThinkingBlocks, +); +const mockedFindMessagesWithOrphanThinking = vi.mocked( + findMessagesWithOrphanThinking, +); +const mockedFindMessageByIndexNeedingThinking = vi.mocked( + findMessageByIndexNeedingThinking, +); const mockedPrependThinkingPart = vi.mocked(prependThinkingPart); const mockedStripThinkingParts = vi.mocked(stripThinkingParts); +async function removeWithRetry(targetPath: string): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.rm(targetPath, { recursive: true, force: true }); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + (code !== "EBUSY" && code !== "EPERM" && code !== "ENOTEMPTY") || + attempt === 4 + ) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + function createMockClient() { - return { - session: { - prompt: vi.fn().mockResolvedValue({}), - abort: vi.fn().mockResolvedValue({}), - messages: vi.fn().mockResolvedValue({ data: [] }), - }, - tui: { - showToast: vi.fn().mockResolvedValue({}), - }, - }; + return { + session: { + prompt: vi.fn().mockResolvedValue({}), + abort: vi.fn().mockResolvedValue({}), + messages: vi.fn().mockResolvedValue({ data: [] }), + }, + tui: { + showToast: vi.fn().mockResolvedValue({}), + }, + }; } describe("detectErrorType", () => { - describe("tool_result_missing detection", () => { - it("detects tool_use without tool_result error", () => { - const error = { - type: "invalid_request_error", - message: "messages.105: `tool_use` ids were found without `tool_result` blocks immediately after: tool-call-59" - }; - expect(detectErrorType(error)).toBe("tool_result_missing"); - }); - - it("detects tool_use/tool_result mismatch error", () => { - const error = "Each `tool_use` block must have a corresponding `tool_result` block in the next message."; - expect(detectErrorType(error)).toBe("tool_result_missing"); - }); - - it("detects error from string message", () => { - const error = "tool_use without matching tool_result"; - expect(detectErrorType(error)).toBe("tool_result_missing"); - }); - }); - - describe("thinking_block_order detection", () => { - it("detects thinking first block error", () => { - const error = "thinking must be the first block in the message"; - expect(detectErrorType(error)).toBe("thinking_block_order"); - }); - - it("detects thinking must start with error", () => { - const error = "Response must start with thinking block"; - expect(detectErrorType(error)).toBe("thinking_block_order"); - }); - - it("detects thinking preceeding error", () => { - const error = "thinking block preceeding tool use is required"; - expect(detectErrorType(error)).toBe("thinking_block_order"); - }); - - it("detects thinking expected/found error", () => { - const error = "Expected thinking block but found text"; - expect(detectErrorType(error)).toBe("thinking_block_order"); - }); - }); - - describe("thinking_disabled_violation detection", () => { - it("detects thinking disabled error", () => { - const error = "thinking is disabled for this model and cannot contain thinking blocks"; - expect(detectErrorType(error)).toBe("thinking_disabled_violation"); - }); - }); - - describe("non-recoverable errors", () => { - it("returns null for prompt too long error", () => { - const error = { message: "Prompt is too long" }; - expect(detectErrorType(error)).toBeNull(); - }); - - it("returns null for context length exceeded error", () => { - const error = "context length exceeded"; - expect(detectErrorType(error)).toBeNull(); - }); - - it("returns null for generic errors", () => { - expect(detectErrorType("Something went wrong")).toBeNull(); - expect(detectErrorType({ message: "Unknown error" })).toBeNull(); - expect(detectErrorType(null)).toBeNull(); - expect(detectErrorType(undefined)).toBeNull(); - }); - - it("returns null for rate limit errors", () => { - const error = { message: "Rate limit exceeded. Retry after 5s" }; - expect(detectErrorType(error)).toBeNull(); - }); - - it("handles error with circular reference gracefully (line 50 coverage)", () => { - const circularError: Record = { name: "CircularError" }; - circularError.self = circularError; - expect(detectErrorType(circularError)).toBeNull(); - }); - }); + describe("tool_result_missing detection", () => { + it("detects tool_use without tool_result error", () => { + const error = { + type: "invalid_request_error", + message: + "messages.105: `tool_use` ids were found without `tool_result` blocks immediately after: tool-call-59", + }; + expect(detectErrorType(error)).toBe("tool_result_missing"); + }); + + it("detects tool_use/tool_result mismatch error", () => { + const error = + "Each `tool_use` block must have a corresponding `tool_result` block in the next message."; + expect(detectErrorType(error)).toBe("tool_result_missing"); + }); + + it("detects error from string message", () => { + const error = "tool_use without matching tool_result"; + expect(detectErrorType(error)).toBe("tool_result_missing"); + }); + }); + + describe("thinking_block_order detection", () => { + it("detects thinking first block error", () => { + const error = "thinking must be the first block in the message"; + expect(detectErrorType(error)).toBe("thinking_block_order"); + }); + + it("detects thinking must start with error", () => { + const error = "Response must start with thinking block"; + expect(detectErrorType(error)).toBe("thinking_block_order"); + }); + + it("detects thinking preceeding error", () => { + const error = "thinking block preceeding tool use is required"; + expect(detectErrorType(error)).toBe("thinking_block_order"); + }); + + it("detects thinking expected/found error", () => { + const error = "Expected thinking block but found text"; + expect(detectErrorType(error)).toBe("thinking_block_order"); + }); + }); + + describe("thinking_disabled_violation detection", () => { + it("detects thinking disabled error", () => { + const error = + "thinking is disabled for this model and cannot contain thinking blocks"; + expect(detectErrorType(error)).toBe("thinking_disabled_violation"); + }); + }); + + describe("non-recoverable errors", () => { + it("returns null for prompt too long error", () => { + const error = { message: "Prompt is too long" }; + expect(detectErrorType(error)).toBeNull(); + }); + + it("returns null for context length exceeded error", () => { + const error = "context length exceeded"; + expect(detectErrorType(error)).toBeNull(); + }); + + it("returns null for generic errors", () => { + expect(detectErrorType("Something went wrong")).toBeNull(); + expect(detectErrorType({ message: "Unknown error" })).toBeNull(); + expect(detectErrorType(null)).toBeNull(); + expect(detectErrorType(undefined)).toBeNull(); + }); + + it("returns null for rate limit errors", () => { + const error = { message: "Rate limit exceeded. Retry after 5s" }; + expect(detectErrorType(error)).toBeNull(); + }); + + it("handles error with circular reference gracefully (line 50 coverage)", () => { + const circularError: Record = { name: "CircularError" }; + circularError.self = circularError; + expect(detectErrorType(circularError)).toBeNull(); + }); + }); +}); + +describe("getActionableNamedBackupRestores (override)", () => { + it("accepts injected backups and assessor", async () => { + const storage = await import("../lib/storage.js"); + const mockBackups = [ + { + name: "invalid-backup", + path: "/mock/backups/invalid.json", + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: 0, + schemaErrors: [], + valid: false, + loadError: "invalid", + }, + { + name: "valid-backup", + path: "/mock/backups/valid.json", + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + ]; + + const result = await storage.getActionableNamedBackupRestores({ + backups: mockBackups, + assess: async (name: string) => { + if (name === "valid-backup") { + return { + backup: mockBackups[1], + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + } + + return { + backup: mockBackups[0], + currentAccountCount: 0, + mergedAccountCount: null, + imported: null, + skipped: null, + wouldExceedLimit: false, + eligibleForRestore: false, + error: "invalid", + }; + }, + }); + + expect(result.totalBackups).toBe(mockBackups.length); + expect(result.assessments.map((item) => item.backup.name)).toEqual([ + "valid-backup", + ]); + }); + it("passes explicit null currentStorage through without reloading accounts", async () => { + const storage = await import("../lib/storage.js"); + const loadAccountsSpy = vi.spyOn(storage, "loadAccounts"); + const mockBackups = [ + { + name: "valid-backup", + path: "/mock/backups/valid.json", + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + ]; + const assess = vi.fn(async (_name: string, options?: { currentStorage?: unknown }) => ({ + backup: mockBackups[0], + currentAccountCount: options?.currentStorage === null ? 0 : 99, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + })); + + const result = await storage.getActionableNamedBackupRestores({ + backups: mockBackups, + assess, + currentStorage: null, + }); + + expect(result.assessments).toHaveLength(1); + expect(assess).toHaveBeenCalledWith("valid-backup", { + currentStorage: null, + }); + expect(loadAccountsSpy).not.toHaveBeenCalled(); + loadAccountsSpy.mockRestore(); + }); + + it("keeps actionable backups when another assessment throws", async () => { + const storage = await import("../lib/storage.js"); + const mockBackups = [ + { + name: "broken-backup", + path: "/mock/backups/broken.json", + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + { + name: "valid-backup", + path: "/mock/backups/valid.json", + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + ]; + const assess = vi.fn(async (name: string) => { + if (name === "broken-backup") { + throw new Error("backup locked"); + } + + return { + backup: mockBackups[1], + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + }); + + const result = await storage.getActionableNamedBackupRestores({ + backups: mockBackups, + assess, + currentStorage: null, + }); + + expect(result.totalBackups).toBe(mockBackups.length); + expect(result.assessments.map((item) => item.backup.name)).toEqual([ + "valid-backup", + ]); + expect(assess).toHaveBeenCalledTimes(2); + }); + +}); + +describe("getActionableNamedBackupRestores (storage-backed paths)", () => { + let testWorkDir: string; + let testStoragePath: string; + + beforeEach(async () => { + testWorkDir = await fs.mkdtemp(join(tmpdir(), "recovery-backups-")); + testStoragePath = join(testWorkDir, "accounts.json"); + const storage = await import("../lib/storage.js"); + storage.setStoragePathDirect(testStoragePath); + }); + + afterEach(async () => { + const storage = await import("../lib/storage.js"); + storage.setStoragePathDirect(null); + await removeWithRetry(testWorkDir); + }); + + it("scans named backups by default and returns actionable restores", async () => { + const storage = await import("../lib/storage.js"); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "restored@example.com", + refreshToken: "restore-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await storage.createNamedBackup("startup-fast-path"); + await storage.saveAccounts(emptyStorage); + + const result = await storage.getActionableNamedBackupRestores(); + + expect(result.totalBackups).toBe(1); + expect(result.assessments.map((item) => item.backup.name)).toEqual([ + "startup-fast-path", + ]); + expect(result.assessments[0]?.imported).toBe(1); + }); + + it("keeps actionable backups when fast-path scan hits EBUSY", async () => { + const storage = await import("../lib/storage.js"); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "locked@example.com", + refreshToken: "locked-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await storage.createNamedBackup("locked-backup"); + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "valid@example.com", + refreshToken: "valid-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await storage.createNamedBackup("valid-backup"); + await storage.saveAccounts(emptyStorage); + + const backups = await storage.listNamedBackups(); + const lockedBackup = backups.find((backup) => backup.name === "locked-backup"); + const validBackup = backups.find((backup) => backup.name === "valid-backup"); + expect(lockedBackup).toBeDefined(); + expect(validBackup).toBeDefined(); + + const originalReadFile = fs.readFile.bind(fs); + const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation( + (async (...args: Parameters) => { + const [path] = args; + if (path === lockedBackup?.path) { + const error = new Error("resource busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalReadFile(...args); + }) as typeof fs.readFile, + ); + + try { + const result = await storage.getActionableNamedBackupRestores({ + currentStorage: emptyStorage, + }); + + expect(result.totalBackups).toBe(2); + expect(result.assessments.map((item) => item.backup.name)).toEqual([ + "valid-backup", + ]); + expect(result.assessments[0]?.imported).toBe(1); + expect(readFileSpy.mock.calls.map(([path]) => path)).toEqual( + expect.arrayContaining([lockedBackup?.path, validBackup?.path]), + ); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("keeps actionable backups when fast-path metadata stat hits EBUSY", async () => { + const storage = await import("../lib/storage.js"); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "locked@example.com", + refreshToken: "locked-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await storage.createNamedBackup("locked-backup"); + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "valid@example.com", + refreshToken: "valid-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await storage.createNamedBackup("valid-backup"); + await storage.saveAccounts(emptyStorage); + + const backupDir = storage.getNamedBackupsDirectoryPath(); + const lockedBackupPath = join(backupDir, "locked-backup.json"); + const validBackupPath = join(backupDir, "valid-backup.json"); + const originalStat = fs.stat.bind(fs); + const statSpy = vi.spyOn(fs, "stat").mockImplementation( + (async (...args: Parameters) => { + const [path] = args; + const normalizedPath = + typeof path === "string" ? path.replaceAll("\\", "/") : String(path); + if ( + path === lockedBackupPath || + normalizedPath.endsWith("/locked-backup.json") + ) { + const error = new Error("resource busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalStat(...args); + }) as typeof fs.stat, + ); + + try { + const result = await storage.getActionableNamedBackupRestores({ + currentStorage: emptyStorage, + }); + + expect(result.totalBackups).toBe(2); + expect(result.assessments.map((item) => item.backup.name)).toEqual([ + "valid-backup", + "locked-backup", + ]); + expect(result.assessments.map((item) => item.imported)).toEqual([1, 1]); + expect(statSpy.mock.calls.map(([path]) => path)).toEqual( + expect.arrayContaining([lockedBackupPath, validBackupPath]), + ); + } finally { + statSpy.mockRestore(); + } + }); + + it("does not pre-read backups when a custom assessor is injected", async () => { + const storage = await import("../lib/storage.js"); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "first-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await storage.createNamedBackup("first-backup"); + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "second@example.com", + refreshToken: "second-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await storage.createNamedBackup("second-backup"); + await storage.saveAccounts(emptyStorage); + + const backups = await storage.listNamedBackups(); + const backupByName = new Map(backups.map((backup) => [backup.name, backup])); + const assess = vi.fn(async (name: string) => ({ + backup: backupByName.get(name)!, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + })); + + const readFileSpy = vi.spyOn(fs, "readFile"); + try { + const result = await storage.getActionableNamedBackupRestores({ + assess, + currentStorage: emptyStorage, + }); + + expect(result.totalBackups).toBe(2); + expect(result.assessments).toHaveLength(2); + expect( + assess.mock.calls.map(([name]) => name).sort((a, b) => a.localeCompare(b)), + ).toEqual(["first-backup", "second-backup"]); + expect(readFileSpy).not.toHaveBeenCalled(); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("keeps injected-assessor backups when metadata stat hits EBUSY", async () => { + const storage = await import("../lib/storage.js"); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "first-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await storage.createNamedBackup("first-backup"); + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "second@example.com", + refreshToken: "second-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await storage.createNamedBackup("second-backup"); + await storage.saveAccounts(emptyStorage); + + const backups = await storage.listNamedBackups(); + const backupByName = new Map(backups.map((backup) => [backup.name, backup])); + const lockedBackup = backupByName.get("first-backup"); + const secondBackup = backupByName.get("second-backup"); + expect(lockedBackup).toBeDefined(); + expect(secondBackup).toBeDefined(); + + const originalStat = fs.stat.bind(fs); + const statSpy = vi.spyOn(fs, "stat").mockImplementation( + (async (...args: Parameters) => { + const [path] = args; + const normalizedPath = + typeof path === "string" ? path.replaceAll("\\", "/") : String(path); + if ( + path === lockedBackup?.path || + normalizedPath.endsWith("/locked-backup.json") + ) { + const error = new Error("resource busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalStat(...args); + }) as typeof fs.stat, + ); + const readFileSpy = vi.spyOn(fs, "readFile"); + const assess = vi.fn(async (name: string) => ({ + backup: backupByName.get(name)!, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + })); + + try { + const result = await storage.getActionableNamedBackupRestores({ + assess, + currentStorage: emptyStorage, + }); + + expect(result.totalBackups).toBe(2); + expect(result.assessments).toHaveLength(2); + expect( + assess.mock.calls.map(([name]) => name).sort((a, b) => a.localeCompare(b)), + ).toEqual(["first-backup", "second-backup"]); + expect(readFileSpy).not.toHaveBeenCalled(); + expect(statSpy.mock.calls.map(([path]) => path)).toEqual( + expect.arrayContaining([lockedBackup?.path, secondBackup?.path]), + ); + } finally { + statSpy.mockRestore(); + readFileSpy.mockRestore(); + } + }); + + it("keeps actionable backups when default assessment hits EBUSY", async () => { + const storage = await import("../lib/storage.js"); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "locked@example.com", + refreshToken: "locked-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await storage.createNamedBackup("locked-backup"); + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "valid@example.com", + refreshToken: "valid-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await storage.createNamedBackup("valid-backup"); + await storage.saveAccounts(emptyStorage); + const backups = await storage.listNamedBackups(); + const lockedBackup = backups.find((backup) => backup.name === "locked-backup"); + const validBackup = backups.find((backup) => backup.name === "valid-backup"); + expect(lockedBackup).toBeDefined(); + expect(validBackup).toBeDefined(); + + const originalReadFile = fs.readFile.bind(fs); + const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation( + (async (...args: Parameters) => { + const [path] = args; + if (path === lockedBackup?.path) { + const error = new Error("resource busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalReadFile(...args); + }) as typeof fs.readFile, + ); + + const result = await storage.getActionableNamedBackupRestores({ + backups, + currentStorage: emptyStorage, + }); + + expect(result.totalBackups).toBe(2); + expect(result.assessments.map((item) => item.backup.name)).toEqual([ + "valid-backup", + ]); + expect( + result.allAssessments + .map((item) => item.backup.name) + .sort((left, right) => left.localeCompare(right)), + ).toEqual(["locked-backup", "valid-backup"]); + expect( + result.allAssessments.find((item) => item.backup.name === "locked-backup"), + ).toMatchObject({ + eligibleForRestore: false, + error: expect.stringContaining("busy"), + }); + const readPaths = readFileSpy.mock.calls.map(([path]) => path); + expect(readPaths).toEqual( + expect.arrayContaining([lockedBackup?.path, validBackup?.path]), + ); + expect(readPaths.filter((path) => path === lockedBackup?.path)).toHaveLength(5); + expect(readPaths.filter((path) => path === validBackup?.path)).toHaveLength(1); + }); + +}); + +describe("resolveStartupRecoveryAction", () => { + it("re-enters the empty storage menu instead of OAuth when backups exist but none are actionable", async () => { + const { resolveStartupRecoveryAction } = await import( + "../lib/codex-manager.js" + ); + + expect( + resolveStartupRecoveryAction( + { assessments: [], totalBackups: 2 }, + false, + ), + ).toBe("open-empty-storage-menu"); + expect( + resolveStartupRecoveryAction( + { assessments: [], totalBackups: 2 }, + false, + ), + ).not.toBe("continue-with-oauth"); + }); + + it("falls through to OAuth when the startup recovery scan itself failed", async () => { + const { resolveStartupRecoveryAction } = await import( + "../lib/codex-manager.js" + ); + + expect( + resolveStartupRecoveryAction( + { assessments: [], totalBackups: 0 }, + true, + ), + ).toBe("continue-with-oauth"); + }); }); describe("isRecoverableError", () => { - it("returns true for tool_result_missing", () => { - const error = "tool_use without tool_result"; - expect(isRecoverableError(error)).toBe(true); - }); - - it("returns true for thinking_block_order", () => { - const error = "thinking must be the first block"; - expect(isRecoverableError(error)).toBe(true); - }); - - it("returns true for thinking_disabled_violation", () => { - const error = "thinking is disabled and cannot contain thinking"; - expect(isRecoverableError(error)).toBe(true); - }); - - it("returns false for non-recoverable errors", () => { - expect(isRecoverableError("Prompt is too long")).toBe(false); - expect(isRecoverableError("context length exceeded")).toBe(false); - expect(isRecoverableError("Generic error")).toBe(false); - expect(isRecoverableError(null)).toBe(false); - }); + it("returns true for tool_result_missing", () => { + const error = "tool_use without tool_result"; + expect(isRecoverableError(error)).toBe(true); + }); + + it("returns true for thinking_block_order", () => { + const error = "thinking must be the first block"; + expect(isRecoverableError(error)).toBe(true); + }); + + it("returns true for thinking_disabled_violation", () => { + const error = "thinking is disabled and cannot contain thinking"; + expect(isRecoverableError(error)).toBe(true); + }); + + it("returns false for non-recoverable errors", () => { + expect(isRecoverableError("Prompt is too long")).toBe(false); + expect(isRecoverableError("context length exceeded")).toBe(false); + expect(isRecoverableError("Generic error")).toBe(false); + expect(isRecoverableError(null)).toBe(false); + }); }); describe("context error message patterns", () => { - describe("prompt too long patterns", () => { - const promptTooLongPatterns = [ - "Prompt is too long", - "prompt is too long for this model", - "The prompt is too long", - ]; - - it.each(promptTooLongPatterns)("'%s' is not a recoverable error", (msg) => { - expect(isRecoverableError(msg)).toBe(false); - expect(detectErrorType(msg)).toBeNull(); - }); - }); - - describe("context length exceeded patterns", () => { - const contextLengthPatterns = [ - "context length exceeded", - "context_length_exceeded", - "maximum context length", - "exceeds the maximum context window", - ]; - - it.each(contextLengthPatterns)("'%s' is not a recoverable error", (msg) => { - expect(isRecoverableError(msg)).toBe(false); - expect(detectErrorType(msg)).toBeNull(); - }); - }); - - describe("tool pairing error patterns", () => { - const toolPairingPatterns = [ - "tool_use ids were found without tool_result blocks immediately after", - "Each tool_use block must have a corresponding tool_result", - "tool_use without matching tool_result", - ]; - - it.each(toolPairingPatterns)("'%s' is detected as tool_result_missing", (msg) => { - expect(detectErrorType(msg)).toBe("tool_result_missing"); - expect(isRecoverableError(msg)).toBe(true); - }); - }); + describe("prompt too long patterns", () => { + const promptTooLongPatterns = [ + "Prompt is too long", + "prompt is too long for this model", + "The prompt is too long", + ]; + + it.each(promptTooLongPatterns)("'%s' is not a recoverable error", (msg) => { + expect(isRecoverableError(msg)).toBe(false); + expect(detectErrorType(msg)).toBeNull(); + }); + }); + + describe("context length exceeded patterns", () => { + const contextLengthPatterns = [ + "context length exceeded", + "context_length_exceeded", + "maximum context length", + "exceeds the maximum context window", + ]; + + it.each(contextLengthPatterns)("'%s' is not a recoverable error", (msg) => { + expect(isRecoverableError(msg)).toBe(false); + expect(detectErrorType(msg)).toBeNull(); + }); + }); + + describe("tool pairing error patterns", () => { + const toolPairingPatterns = [ + "tool_use ids were found without tool_result blocks immediately after", + "Each tool_use block must have a corresponding tool_result", + "tool_use without matching tool_result", + ]; + + it.each( + toolPairingPatterns, + )("'%s' is detected as tool_result_missing", (msg) => { + expect(detectErrorType(msg)).toBe("tool_result_missing"); + expect(isRecoverableError(msg)).toBe(true); + }); + }); }); describe("getRecoveryToastContent", () => { - it("returns tool crash recovery for tool_result_missing", () => { - const content = getRecoveryToastContent("tool_result_missing"); - expect(content.title).toBe("Tool Crash Recovery"); - expect(content.message).toBe("Injecting cancelled tool results..."); - }); - - it("returns thinking block recovery for thinking_block_order", () => { - const content = getRecoveryToastContent("thinking_block_order"); - expect(content.title).toBe("Thinking Block Recovery"); - expect(content.message).toBe("Fixing message structure..."); - }); - - it("returns thinking strip recovery for thinking_disabled_violation", () => { - const content = getRecoveryToastContent("thinking_disabled_violation"); - expect(content.title).toBe("Thinking Strip Recovery"); - expect(content.message).toBe("Stripping thinking blocks..."); - }); - - it("returns generic recovery for null error type", () => { - const content = getRecoveryToastContent(null); - expect(content.title).toBe("Session Recovery"); - expect(content.message).toBe("Attempting to recover session..."); - }); + it("returns tool crash recovery for tool_result_missing", () => { + const content = getRecoveryToastContent("tool_result_missing"); + expect(content.title).toBe("Tool Crash Recovery"); + expect(content.message).toBe("Injecting cancelled tool results..."); + }); + + it("returns thinking block recovery for thinking_block_order", () => { + const content = getRecoveryToastContent("thinking_block_order"); + expect(content.title).toBe("Thinking Block Recovery"); + expect(content.message).toBe("Fixing message structure..."); + }); + + it("returns thinking strip recovery for thinking_disabled_violation", () => { + const content = getRecoveryToastContent("thinking_disabled_violation"); + expect(content.title).toBe("Thinking Strip Recovery"); + expect(content.message).toBe("Stripping thinking blocks..."); + }); + + it("returns generic recovery for null error type", () => { + const content = getRecoveryToastContent(null); + expect(content.title).toBe("Session Recovery"); + expect(content.message).toBe("Attempting to recover session..."); + }); }); describe("getRecoverySuccessToast", () => { - it("returns success toast content", () => { - const content = getRecoverySuccessToast(); - expect(content.title).toBe("Session Recovered"); - expect(content.message).toBe("Continuing where you left off..."); - }); + it("returns success toast content", () => { + const content = getRecoverySuccessToast(); + expect(content.title).toBe("Session Recovered"); + expect(content.message).toBe("Continuing where you left off..."); + }); }); describe("getRecoveryFailureToast", () => { - it("returns failure toast content", () => { - const content = getRecoveryFailureToast(); - expect(content.title).toBe("Recovery Failed"); - expect(content.message).toBe("Please retry or start a new session."); - }); + it("returns failure toast content", () => { + const content = getRecoveryFailureToast(); + expect(content.title).toBe("Recovery Failed"); + expect(content.message).toBe("Please retry or start a new session."); + }); }); describe("createSessionRecoveryHook", () => { - it("returns null when sessionRecovery is disabled", () => { - const ctx = { client: {} as never, directory: "/test" }; - const config = { sessionRecovery: false, autoResume: false }; - const hook = createSessionRecoveryHook(ctx, config); - expect(hook).toBeNull(); - }); - - it("returns hook object when sessionRecovery is enabled", () => { - const ctx = { client: {} as never, directory: "/test" }; - const config = { sessionRecovery: true, autoResume: false }; - const hook = createSessionRecoveryHook(ctx, config); - expect(hook).not.toBeNull(); - expect(hook?.handleSessionRecovery).toBeTypeOf("function"); - expect(hook?.isRecoverableError).toBeTypeOf("function"); - expect(hook?.setOnAbortCallback).toBeTypeOf("function"); - expect(hook?.setOnRecoveryCompleteCallback).toBeTypeOf("function"); - }); - - it("hook.isRecoverableError delegates to module function", () => { - const ctx = { client: {} as never, directory: "/test" }; - const config = { sessionRecovery: true, autoResume: false }; - const hook = createSessionRecoveryHook(ctx, config); - expect(hook?.isRecoverableError("tool_use without tool_result")).toBe(true); - expect(hook?.isRecoverableError("generic error")).toBe(false); - }); + it("returns null when sessionRecovery is disabled", () => { + const ctx = { client: {} as never, directory: "/test" }; + const config = { sessionRecovery: false, autoResume: false }; + const hook = createSessionRecoveryHook(ctx, config); + expect(hook).toBeNull(); + }); + + it("returns hook object when sessionRecovery is enabled", () => { + const ctx = { client: {} as never, directory: "/test" }; + const config = { sessionRecovery: true, autoResume: false }; + const hook = createSessionRecoveryHook(ctx, config); + expect(hook).not.toBeNull(); + expect(hook?.handleSessionRecovery).toBeTypeOf("function"); + expect(hook?.isRecoverableError).toBeTypeOf("function"); + expect(hook?.setOnAbortCallback).toBeTypeOf("function"); + expect(hook?.setOnRecoveryCompleteCallback).toBeTypeOf("function"); + }); + + it("hook.isRecoverableError delegates to module function", () => { + const ctx = { client: {} as never, directory: "/test" }; + const config = { sessionRecovery: true, autoResume: false }; + const hook = createSessionRecoveryHook(ctx, config); + expect(hook?.isRecoverableError("tool_use without tool_result")).toBe(true); + expect(hook?.isRecoverableError("generic error")).toBe(false); + }); }); describe("error message extraction edge cases", () => { - it("handles nested error.data.error structure", () => { - const error = { - data: { - error: { - message: "tool_use without tool_result found" - } - } - }; - expect(detectErrorType(error)).toBe("tool_result_missing"); - }); - - it("handles error.data.message structure", () => { - const error = { - data: { - message: "thinking must be the first block" - } - }; - expect(detectErrorType(error)).toBe("thinking_block_order"); - }); - - it("handles deeply nested error objects", () => { - const error = { - error: { - message: "thinking is disabled and cannot contain thinking blocks" - } - }; - expect(detectErrorType(error)).toBe("thinking_disabled_violation"); - }); - - it("falls back to JSON stringify for non-standard errors", () => { - const error = { custom: "tool_use without tool_result" }; - expect(detectErrorType(error)).toBe("tool_result_missing"); - }); - - it("handles empty object", () => { - expect(detectErrorType({})).toBeNull(); - }); - - it("handles number input", () => { - expect(detectErrorType(42)).toBeNull(); - }); - - it("handles array input", () => { - expect(detectErrorType(["tool_use", "tool_result"])).toBe("tool_result_missing"); - }); + it("handles nested error.data.error structure", () => { + const error = { + data: { + error: { + message: "tool_use without tool_result found", + }, + }, + }; + expect(detectErrorType(error)).toBe("tool_result_missing"); + }); + + it("handles error.data.message structure", () => { + const error = { + data: { + message: "thinking must be the first block", + }, + }; + expect(detectErrorType(error)).toBe("thinking_block_order"); + }); + + it("handles deeply nested error objects", () => { + const error = { + error: { + message: "thinking is disabled and cannot contain thinking blocks", + }, + }; + expect(detectErrorType(error)).toBe("thinking_disabled_violation"); + }); + + it("falls back to JSON stringify for non-standard errors", () => { + const error = { custom: "tool_use without tool_result" }; + expect(detectErrorType(error)).toBe("tool_result_missing"); + }); + + it("handles empty object", () => { + expect(detectErrorType({})).toBeNull(); + }); + + it("handles number input", () => { + expect(detectErrorType(42)).toBeNull(); + }); + + it("handles array input", () => { + expect(detectErrorType(["tool_use", "tool_result"])).toBe( + "tool_result_missing", + ); + }); }); describe("handleSessionRecovery", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("returns false when info is null", async () => { - const client = createMockClient(); - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - const result = await hook?.handleSessionRecovery(null as never); - expect(result).toBe(false); - }); - - it("returns false when role is not assistant", async () => { - const client = createMockClient(); - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - const result = await hook?.handleSessionRecovery({ - role: "user", - error: "tool_use without tool_result", - sessionID: "session-1", - } as never); - expect(result).toBe(false); - }); - - it("returns false when no error property", async () => { - const client = createMockClient(); - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - const result = await hook?.handleSessionRecovery({ - role: "assistant", - sessionID: "session-1", - } as never); - expect(result).toBe(false); - }); - - it("returns false when error is not recoverable", async () => { - const client = createMockClient(); - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "generic error that is not recoverable", - sessionID: "session-1", - } as never); - expect(result).toBe(false); - }); - - it("returns false when sessionID is missing", async () => { - const client = createMockClient(); - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - } as never); - expect(result).toBe(false); - }); - - it("calls onAbortCallback when set", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const abortCallback = vi.fn(); - hook?.setOnAbortCallback(abortCallback); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(abortCallback).toHaveBeenCalledWith("session-1"); - }); - - it("calls session.abort on recovery", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(client.session.abort).toHaveBeenCalledWith({ path: { id: "session-1" } }); - }); - - it("shows toast notification on recovery attempt", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(client.tui.showToast).toHaveBeenCalledWith({ - body: { - title: "Tool Crash Recovery", - message: "Injecting cancelled tool results...", - variant: "warning", - }, - }); - }); - - describe("tool_result_missing recovery", () => { - it("injects tool_result parts for tool_use parts in message", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [ - { type: "tool_use", id: "tool-1", name: "read" }, - { type: "tool_use", id: "tool-2", name: "write" }, - ], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(true); - expect(client.session.prompt).toHaveBeenCalledWith({ - path: { id: "session-1" }, - body: { - parts: [ - { type: "tool_result", tool_use_id: "tool-1", content: "Operation cancelled by user (ESC pressed)" }, - { type: "tool_result", tool_use_id: "tool-2", content: "Operation cancelled by user (ESC pressed)" }, - ], - }, - }); - }); - - it("reads parts from storage when parts array is empty", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - mockedReadParts.mockReturnValue([ - { type: "tool", callID: "tool-1", tool: "read" }, - { type: "tool", callID: "tool-2", tool: "write" }, - ] as never); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(mockedReadParts).toHaveBeenCalledWith("msg-1"); - expect(result).toBe(true); - }); - - it("returns false when no tool_use parts found", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [{ type: "text", text: "Hello" }], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - }); - - it("returns false when prompt injection fails", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [{ type: "tool_use", id: "tool-1", name: "read" }], - }], - }); - client.session.prompt.mockRejectedValue(new Error("Prompt failed")); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - }); - }); - - describe("thinking_block_order recovery", () => { - it("uses message index from error to find target message", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); - mockedPrependThinkingPart.mockReturnValue(true); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "messages.5: thinking must be the first block", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(mockedFindMessageByIndexNeedingThinking).toHaveBeenCalledWith("session-1", 5); - expect(mockedPrependThinkingPart).toHaveBeenCalledWith("session-1", "msg-target"); - expect(result).toBe(true); - }); - - it("falls back to findMessagesWithOrphanThinking when no index", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - mockedFindMessageByIndexNeedingThinking.mockReturnValue(null); - mockedFindMessagesWithOrphanThinking.mockReturnValue(["orphan-1", "orphan-2"]); - mockedPrependThinkingPart.mockReturnValue(true); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "thinking must be the first block", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(mockedFindMessagesWithOrphanThinking).toHaveBeenCalledWith("session-1"); - expect(mockedPrependThinkingPart).toHaveBeenCalledTimes(2); - expect(result).toBe(true); - }); - - it("returns false when no orphan messages found", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - mockedFindMessageByIndexNeedingThinking.mockReturnValue(null); - mockedFindMessagesWithOrphanThinking.mockReturnValue([]); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "thinking must be the first block", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - }); - - it("resumes session when autoResume is enabled", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-0", role: "user", agent: "build", model: "gpt-5" } }, - { info: { id: "msg-1", role: "assistant" }, parts: [] }, - ], - }); - - mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); - mockedPrependThinkingPart.mockReturnValue(true); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: true } - ); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "messages.1: thinking must be the first block", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(client.session.prompt).toHaveBeenCalledWith({ - path: { id: "session-1" }, - body: { - parts: [{ type: "text", text: "[session recovered - continuing previous task]" }], - agent: "build", - model: "gpt-5", - }, - query: { directory: "/test" }, - }); - }); - }); - - describe("thinking_disabled_violation recovery", () => { - it("strips thinking blocks from messages", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - mockedFindMessagesWithThinkingBlocks.mockReturnValue(["msg-with-thinking-1", "msg-with-thinking-2"]); - mockedStripThinkingParts.mockReturnValue(true); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "thinking is disabled and cannot contain thinking blocks", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(mockedFindMessagesWithThinkingBlocks).toHaveBeenCalledWith("session-1"); - expect(mockedStripThinkingParts).toHaveBeenCalledTimes(2); - expect(result).toBe(true); - }); - - it("returns false when no messages with thinking blocks found", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - mockedFindMessagesWithThinkingBlocks.mockReturnValue([]); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "thinking is disabled and cannot contain thinking blocks", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - }); - - it("resumes session when autoResume is enabled", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-0", role: "user", agent: "explore", model: "gpt-5.1" } }, - { info: { id: "msg-1", role: "assistant" }, parts: [] }, - ], - }); - - mockedFindMessagesWithThinkingBlocks.mockReturnValue(["msg-1"]); - mockedStripThinkingParts.mockReturnValue(true); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: true } - ); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "thinking is disabled and cannot contain thinking blocks", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(client.session.prompt).toHaveBeenCalledWith({ - path: { id: "session-1" }, - body: { - parts: [{ type: "text", text: "[session recovered - continuing previous task]" }], - agent: "explore", - model: "gpt-5.1", - }, - query: { directory: "/test" }, - }); - }); - }); - - describe("callback handling", () => { - it("calls onRecoveryCompleteCallback on success", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [{ type: "tool_use", id: "tool-1", name: "read" }], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const completeCallback = vi.fn(); - hook?.setOnRecoveryCompleteCallback(completeCallback); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(completeCallback).toHaveBeenCalledWith("session-1"); - }); - - it("calls onRecoveryCompleteCallback on failure", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const completeCallback = vi.fn(); - hook?.setOnRecoveryCompleteCallback(completeCallback); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(completeCallback).toHaveBeenCalledWith("session-1"); - }); - }); - - describe("deduplication", () => { - it("prevents duplicate processing of same message ID", async () => { - const client = createMockClient(); - - let resolveFirst: () => void; - const firstPromise = new Promise((r) => { resolveFirst = r; }); - - client.session.messages.mockImplementation(async () => { - await firstPromise; - return { - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [{ type: "tool_use", id: "tool-1", name: "read" }], - }], - }; - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const info = { - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never; - - const first = hook?.handleSessionRecovery(info); - const second = hook?.handleSessionRecovery(info); - - resolveFirst!(); - - const [result1, result2] = await Promise.all([first, second]); - - expect(result1).toBe(true); - expect(result2).toBe(false); - }); - }); - - describe("error handling", () => { - it("returns false when failed message not found in session", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "different-msg", role: "assistant" }, - parts: [], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - }); - - it("finds assistant message ID from session when not provided", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-user", role: "user" }, parts: [] }, - { info: { id: "msg-assistant", role: "assistant" }, parts: [{ type: "tool_use", id: "tool-1" }] }, - ], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - } as never); - - expect(result).toBe(true); - }); - - it("returns false when no assistant message found and none in session", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-user", role: "user" }, parts: [] }, - ], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - } as never); - - expect(result).toBe(false); - }); - - it("handles exception in recovery logic gracefully", async () => { - const client = createMockClient(); - client.session.abort.mockResolvedValue({}); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [{ type: "tool_use", id: "tool-1", name: "read" }], - }], - }); - client.tui.showToast.mockRejectedValue(new Error("Toast error")); - client.session.prompt.mockRejectedValue(new Error("Prompt error")); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - }); - - it("filters out tool_use parts with falsy id (line 98 coverage)", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [ - { type: "tool_use", id: "", name: "read" }, - { type: "tool_use", name: "write" }, - { type: "tool_use", id: null, name: "delete" }, - { type: "tool_use", id: "valid-id", name: "exec" }, - ], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(true); - expect(client.session.prompt).toHaveBeenCalledWith({ - path: { id: "session-1" }, - body: { - parts: [ - { type: "tool_result", tool_use_id: "valid-id", content: "Operation cancelled by user (ESC pressed)" }, - ], - }, - }); - }); - - it("continues recovery when resumeSession fails (line 226 coverage)", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-0", role: "user", agent: "build", model: "gpt-5" } }, - { info: { id: "msg-1", role: "assistant" }, parts: [] }, - ], - }); - - mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); - mockedPrependThinkingPart.mockReturnValue(true); - client.session.prompt.mockRejectedValue(new Error("Resume prompt failed")); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: true } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "messages.1: thinking must be the first block", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(mockedPrependThinkingPart).toHaveBeenCalled(); - expect(client.session.prompt).toHaveBeenCalled(); - expect(result).toBe(true); - }); - - it("handles session with no user messages (line 198 coverage)", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-0", role: "assistant" }, parts: [] }, - { info: { id: "msg-1", role: "assistant" }, parts: [] }, - ], - }); - - mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); - mockedPrependThinkingPart.mockReturnValue(true); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: true } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "messages.1: thinking must be the first block", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(true); - const promptCall = client.session.prompt.mock.calls[0]; - expect(promptCall[0].body.agent).toBeUndefined(); - expect(promptCall[0].body.model).toBeUndefined(); - }); - - it("returns false when thinking_disabled_violation recovery throws (lines 401-402 coverage)", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-1", role: "assistant" }, parts: [] }, - ], - }); - - mockedFindMessagesWithThinkingBlocks.mockImplementation(() => { - throw new Error("Storage access error"); - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "thinking is disabled and cannot contain thinking blocks", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - mockedFindMessagesWithThinkingBlocks.mockReturnValue([]); - }); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns false when info is null", async () => { + const client = createMockClient(); + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + const result = await hook?.handleSessionRecovery(null as never); + expect(result).toBe(false); + }); + + it("returns false when role is not assistant", async () => { + const client = createMockClient(); + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + const result = await hook?.handleSessionRecovery({ + role: "user", + error: "tool_use without tool_result", + sessionID: "session-1", + } as never); + expect(result).toBe(false); + }); + + it("returns false when no error property", async () => { + const client = createMockClient(); + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + const result = await hook?.handleSessionRecovery({ + role: "assistant", + sessionID: "session-1", + } as never); + expect(result).toBe(false); + }); + + it("returns false when error is not recoverable", async () => { + const client = createMockClient(); + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "generic error that is not recoverable", + sessionID: "session-1", + } as never); + expect(result).toBe(false); + }); + + it("returns false when sessionID is missing", async () => { + const client = createMockClient(); + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + } as never); + expect(result).toBe(false); + }); + + it("calls onAbortCallback when set", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const abortCallback = vi.fn(); + hook?.setOnAbortCallback(abortCallback); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(abortCallback).toHaveBeenCalledWith("session-1"); + }); + + it("calls session.abort on recovery", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(client.session.abort).toHaveBeenCalledWith({ + path: { id: "session-1" }, + }); + }); + + it("shows toast notification on recovery attempt", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(client.tui.showToast).toHaveBeenCalledWith({ + body: { + title: "Tool Crash Recovery", + message: "Injecting cancelled tool results...", + variant: "warning", + }, + }); + }); + + describe("tool_result_missing recovery", () => { + it("injects tool_result parts for tool_use parts in message", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [ + { type: "tool_use", id: "tool-1", name: "read" }, + { type: "tool_use", id: "tool-2", name: "write" }, + ], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(true); + expect(client.session.prompt).toHaveBeenCalledWith({ + path: { id: "session-1" }, + body: { + parts: [ + { + type: "tool_result", + tool_use_id: "tool-1", + content: "Operation cancelled by user (ESC pressed)", + }, + { + type: "tool_result", + tool_use_id: "tool-2", + content: "Operation cancelled by user (ESC pressed)", + }, + ], + }, + }); + }); + + it("reads parts from storage when parts array is empty", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + mockedReadParts.mockReturnValue([ + { type: "tool", callID: "tool-1", tool: "read" }, + { type: "tool", callID: "tool-2", tool: "write" }, + ] as never); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(mockedReadParts).toHaveBeenCalledWith("msg-1"); + expect(result).toBe(true); + }); + + it("returns false when no tool_use parts found", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [{ type: "text", text: "Hello" }], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + }); + + it("returns false when prompt injection fails", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [{ type: "tool_use", id: "tool-1", name: "read" }], + }, + ], + }); + client.session.prompt.mockRejectedValue(new Error("Prompt failed")); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + }); + }); + + describe("thinking_block_order recovery", () => { + it("uses message index from error to find target message", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); + mockedPrependThinkingPart.mockReturnValue(true); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "messages.5: thinking must be the first block", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(mockedFindMessageByIndexNeedingThinking).toHaveBeenCalledWith( + "session-1", + 5, + ); + expect(mockedPrependThinkingPart).toHaveBeenCalledWith( + "session-1", + "msg-target", + ); + expect(result).toBe(true); + }); + + it("falls back to findMessagesWithOrphanThinking when no index", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + mockedFindMessageByIndexNeedingThinking.mockReturnValue(null); + mockedFindMessagesWithOrphanThinking.mockReturnValue([ + "orphan-1", + "orphan-2", + ]); + mockedPrependThinkingPart.mockReturnValue(true); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "thinking must be the first block", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(mockedFindMessagesWithOrphanThinking).toHaveBeenCalledWith( + "session-1", + ); + expect(mockedPrependThinkingPart).toHaveBeenCalledTimes(2); + expect(result).toBe(true); + }); + + it("returns false when no orphan messages found", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + mockedFindMessageByIndexNeedingThinking.mockReturnValue(null); + mockedFindMessagesWithOrphanThinking.mockReturnValue([]); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "thinking must be the first block", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + }); + + it("resumes session when autoResume is enabled", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-0", role: "user", agent: "build", model: "gpt-5" }, + }, + { info: { id: "msg-1", role: "assistant" }, parts: [] }, + ], + }); + + mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); + mockedPrependThinkingPart.mockReturnValue(true); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: true }, + ); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "messages.1: thinking must be the first block", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(client.session.prompt).toHaveBeenCalledWith({ + path: { id: "session-1" }, + body: { + parts: [ + { + type: "text", + text: "[session recovered - continuing previous task]", + }, + ], + agent: "build", + model: "gpt-5", + }, + query: { directory: "/test" }, + }); + }); + }); + + describe("thinking_disabled_violation recovery", () => { + it("strips thinking blocks from messages", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + mockedFindMessagesWithThinkingBlocks.mockReturnValue([ + "msg-with-thinking-1", + "msg-with-thinking-2", + ]); + mockedStripThinkingParts.mockReturnValue(true); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "thinking is disabled and cannot contain thinking blocks", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(mockedFindMessagesWithThinkingBlocks).toHaveBeenCalledWith( + "session-1", + ); + expect(mockedStripThinkingParts).toHaveBeenCalledTimes(2); + expect(result).toBe(true); + }); + + it("returns false when no messages with thinking blocks found", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + mockedFindMessagesWithThinkingBlocks.mockReturnValue([]); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "thinking is disabled and cannot contain thinking blocks", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + }); + + it("resumes session when autoResume is enabled", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { + id: "msg-0", + role: "user", + agent: "explore", + model: "gpt-5.1", + }, + }, + { info: { id: "msg-1", role: "assistant" }, parts: [] }, + ], + }); + + mockedFindMessagesWithThinkingBlocks.mockReturnValue(["msg-1"]); + mockedStripThinkingParts.mockReturnValue(true); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: true }, + ); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "thinking is disabled and cannot contain thinking blocks", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(client.session.prompt).toHaveBeenCalledWith({ + path: { id: "session-1" }, + body: { + parts: [ + { + type: "text", + text: "[session recovered - continuing previous task]", + }, + ], + agent: "explore", + model: "gpt-5.1", + }, + query: { directory: "/test" }, + }); + }); + }); + + describe("callback handling", () => { + it("calls onRecoveryCompleteCallback on success", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [{ type: "tool_use", id: "tool-1", name: "read" }], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const completeCallback = vi.fn(); + hook?.setOnRecoveryCompleteCallback(completeCallback); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(completeCallback).toHaveBeenCalledWith("session-1"); + }); + + it("calls onRecoveryCompleteCallback on failure", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const completeCallback = vi.fn(); + hook?.setOnRecoveryCompleteCallback(completeCallback); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(completeCallback).toHaveBeenCalledWith("session-1"); + }); + }); + + describe("deduplication", () => { + it("prevents duplicate processing of same message ID", async () => { + const client = createMockClient(); + + let resolveFirst: () => void; + const firstPromise = new Promise((r) => { + resolveFirst = r; + }); + + client.session.messages.mockImplementation(async () => { + await firstPromise; + return { + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [{ type: "tool_use", id: "tool-1", name: "read" }], + }, + ], + }; + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const info = { + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never; + + const first = hook?.handleSessionRecovery(info); + const second = hook?.handleSessionRecovery(info); + + resolveFirst!(); + + const [result1, result2] = await Promise.all([first, second]); + + expect(result1).toBe(true); + expect(result2).toBe(false); + }); + }); + + describe("error handling", () => { + it("returns false when failed message not found in session", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "different-msg", role: "assistant" }, + parts: [], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + }); + + it("finds assistant message ID from session when not provided", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { info: { id: "msg-user", role: "user" }, parts: [] }, + { + info: { id: "msg-assistant", role: "assistant" }, + parts: [{ type: "tool_use", id: "tool-1" }], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + } as never); + + expect(result).toBe(true); + }); + + it("returns false when no assistant message found and none in session", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [{ info: { id: "msg-user", role: "user" }, parts: [] }], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + } as never); + + expect(result).toBe(false); + }); + + it("handles exception in recovery logic gracefully", async () => { + const client = createMockClient(); + client.session.abort.mockResolvedValue({}); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [{ type: "tool_use", id: "tool-1", name: "read" }], + }, + ], + }); + client.tui.showToast.mockRejectedValue(new Error("Toast error")); + client.session.prompt.mockRejectedValue(new Error("Prompt error")); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + }); + + it("filters out tool_use parts with falsy id (line 98 coverage)", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [ + { type: "tool_use", id: "", name: "read" }, + { type: "tool_use", name: "write" }, + { type: "tool_use", id: null, name: "delete" }, + { type: "tool_use", id: "valid-id", name: "exec" }, + ], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(true); + expect(client.session.prompt).toHaveBeenCalledWith({ + path: { id: "session-1" }, + body: { + parts: [ + { + type: "tool_result", + tool_use_id: "valid-id", + content: "Operation cancelled by user (ESC pressed)", + }, + ], + }, + }); + }); + + it("continues recovery when resumeSession fails (line 226 coverage)", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-0", role: "user", agent: "build", model: "gpt-5" }, + }, + { info: { id: "msg-1", role: "assistant" }, parts: [] }, + ], + }); + + mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); + mockedPrependThinkingPart.mockReturnValue(true); + client.session.prompt.mockRejectedValue( + new Error("Resume prompt failed"), + ); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: true }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "messages.1: thinking must be the first block", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(mockedPrependThinkingPart).toHaveBeenCalled(); + expect(client.session.prompt).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("handles session with no user messages (line 198 coverage)", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { info: { id: "msg-0", role: "assistant" }, parts: [] }, + { info: { id: "msg-1", role: "assistant" }, parts: [] }, + ], + }); + + mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); + mockedPrependThinkingPart.mockReturnValue(true); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: true }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "messages.1: thinking must be the first block", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(true); + const promptCall = client.session.prompt.mock.calls[0]; + expect(promptCall[0].body.agent).toBeUndefined(); + expect(promptCall[0].body.model).toBeUndefined(); + }); + + it("returns false when thinking_disabled_violation recovery throws (lines 401-402 coverage)", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [{ info: { id: "msg-1", role: "assistant" }, parts: [] }], + }); + + mockedFindMessagesWithThinkingBlocks.mockImplementation(() => { + throw new Error("Storage access error"); + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "thinking is disabled and cannot contain thinking blocks", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + mockedFindMessagesWithThinkingBlocks.mockReturnValue([]); + }); + }); }); diff --git a/test/release-main-prs-regression.test.ts b/test/release-main-prs-regression.test.ts index 6eb7929b..bd4d680b 100644 --- a/test/release-main-prs-regression.test.ts +++ b/test/release-main-prs-regression.test.ts @@ -199,7 +199,7 @@ describe("release-main-prs regressions", () => { return originalUnlink(targetPath); }); - await expect(clearFlaggedAccounts()).rejects.toThrow("EPERM primary delete"); + await expect(clearFlaggedAccounts()).resolves.toBe(false); const flagged = await loadFlaggedAccounts(); const syncResult = await syncAccountStorageFromCodexCli(null); diff --git a/test/settings-hub-utils.test.ts b/test/settings-hub-utils.test.ts index 2c56244b..e983221b 100644 --- a/test/settings-hub-utils.test.ts +++ b/test/settings-hub-utils.test.ts @@ -16,6 +16,45 @@ import { import type { MenuItem } from "../lib/ui/select.js"; type SettingsHubTestApi = { + buildSyncCenterOverview: ( + preview: { + status: "ready" | "noop" | "disabled" | "unavailable" | "error"; + statusDetail: string; + sourcePath: string | null; + targetPath: string; + summary: { + addedAccountCount: number; + updatedAccountCount: number; + destinationOnlyPreservedCount: number; + targetAccountCountAfter: number; + selectionChanged: boolean; + }; + backup: { + enabled: boolean; + rollbackPaths: string[]; + }; + lastSync: { + outcome: "changed" | "noop" | "disabled" | "unavailable" | "error"; + runAt: number; + message?: string; + } | null; + }, + context?: { + accountsPath: string; + authPath: string; + configPath: string; + sourceAccountCount: number | null; + liveSync: { + path: string | null; + running: boolean; + lastKnownMtimeMs: number | null; + lastSyncAt: number | null; + reloadCount: number; + errorCount: number; + }; + syncEnabled: boolean; + }, + ) => Array<{ label: string; hint?: string }>; clampBackendNumber: (settingKey: string, value: number) => number; formatMenuLayoutMode: (mode: "compact-details" | "expanded-rows") => string; cloneDashboardSettings: ( @@ -71,6 +110,7 @@ type SettingsHubTestApi = { promptExperimentalSettings: ( initial: PluginConfig, ) => Promise; + promptSyncCenter: (config: PluginConfig) => Promise; }; type UiRuntimeOptions = ReturnType; @@ -205,6 +245,176 @@ describe("settings-hub utility coverage", () => { ); }); + it("builds sync-center overview text with preservation and rollback details", async () => { + const api = await loadSettingsHubTestApi(); + const overview = api.buildSyncCenterOverview( + { + status: "ready", + statusDetail: "Preview ready", + sourcePath: "/tmp/source/accounts.json", + targetPath: "/tmp/target/openai-codex-accounts.json", + summary: { + addedAccountCount: 1, + updatedAccountCount: 2, + destinationOnlyPreservedCount: 3, + targetAccountCountAfter: 6, + selectionChanged: true, + }, + backup: { + enabled: true, + rollbackPaths: [ + "/tmp/target/openai-codex-accounts.json.bak", + "/tmp/target/openai-codex-accounts.json.wal", + ], + }, + lastSync: { + outcome: "changed", + runAt: Date.parse("2026-03-01T00:00:00.000Z"), + }, + }, + { + accountsPath: "/tmp/source/accounts.json", + authPath: "/tmp/source/auth.json", + configPath: "/tmp/source/config.toml", + sourceAccountCount: 1, + liveSync: { + path: "/tmp/target/openai-codex-accounts.json", + running: true, + lastKnownMtimeMs: Date.parse("2026-03-01T00:01:00.000Z"), + lastSyncAt: Date.parse("2026-03-01T00:02:00.000Z"), + reloadCount: 2, + errorCount: 0, + }, + syncEnabled: true, + }, + ); + + expect(overview[0]?.label).toContain("Status: ready"); + expect(overview[1]?.hint).toContain("/tmp/source/accounts.json"); + expect(overview[2]?.hint).toContain("/tmp/source/auth.json"); + expect(overview[2]?.hint).toContain("/tmp/source/config.toml"); + expect(overview[3]?.label).toContain("Live watcher: running"); + expect(overview[4]?.label).toContain("Preview mode: read-only until apply"); + expect(overview[4]?.hint).toContain("latest preview snapshot"); + expect(overview[4]?.hint).toContain("refresh before apply"); + expect(overview[5]?.label).toContain( + "add 1 | update 2 | preserve 3 | after 6", + ); + expect(overview[6]?.hint).toContain("activeAccountId first"); + expect(overview[7]?.hint).toContain("never deletes"); + expect(overview[8]?.hint).toContain(".bak"); + expect(overview[8]?.hint).toContain(".wal"); + }); + + it.each([ + { + outcome: "disabled" as const, + statusLabel: "Status: disabled", + lastSyncLabel: "Last sync: disabled", + }, + { + outcome: "unavailable" as const, + statusLabel: "Status: unavailable", + lastSyncLabel: "Last sync: source missing", + }, + { + outcome: "error" as const, + statusLabel: "Status: error", + lastSyncLabel: "Last sync: error: save busy", + message: "save busy", + }, + ])( + "formats sync-center overview status text for $outcome last-sync outcomes", + async ({ outcome, statusLabel, lastSyncLabel, message }) => { + const api = await loadSettingsHubTestApi(); + const overview = api.buildSyncCenterOverview( + { + status: outcome, + statusDetail: `Status ${outcome}`, + sourcePath: null, + targetPath: "/tmp/target/openai-codex-accounts.json", + summary: { + addedAccountCount: 0, + updatedAccountCount: 0, + destinationOnlyPreservedCount: 0, + targetAccountCountAfter: 0, + selectionChanged: false, + }, + backup: { + enabled: false, + rollbackPaths: [], + }, + lastSync: { + outcome, + runAt: Date.parse("2026-03-01T00:00:00.000Z"), + message, + }, + }, + { + accountsPath: "/tmp/source/accounts.json", + authPath: "/tmp/source/auth.json", + configPath: "/tmp/source/config.toml", + sourceAccountCount: null, + liveSync: { + path: null, + running: false, + lastKnownMtimeMs: null, + lastSyncAt: null, + reloadCount: 0, + errorCount: 0, + }, + syncEnabled: outcome !== "disabled", + }, + ); + + expect(overview[0]?.label).toContain(statusLabel); + expect(overview[0]?.hint).toContain(lastSyncLabel); + }, + ); + + it("matches windows-style source paths when labeling the active sync source", async () => { + const api = await loadSettingsHubTestApi(); + const overview = api.buildSyncCenterOverview( + { + status: "ready", + statusDetail: "Preview ready", + sourcePath: "C:\\Users\\Neil\\.codex\\Accounts.json", + targetPath: "C:\\Users\\Neil\\.codex\\openai-codex-accounts.json", + summary: { + addedAccountCount: 0, + updatedAccountCount: 0, + destinationOnlyPreservedCount: 1, + targetAccountCountAfter: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + rollbackPaths: [ + "C:\\Users\\Neil\\.codex\\openai-codex-accounts.json.bak", + ], + }, + lastSync: null, + }, + { + accountsPath: "c:/users/neil/.codex/accounts.json", + authPath: "c:/users/neil/.codex/auth.json", + configPath: "c:/users/neil/.codex/config.toml", + sourceAccountCount: 1, + liveSync: { + path: null, + running: false, + lastKnownMtimeMs: null, + lastSyncAt: null, + reloadCount: 0, + errorCount: 0, + }, + syncEnabled: true, + }, + ); + + expect(overview[2]?.label).toContain("accounts.json active"); + }); + it("formats layout mode labels", async () => { const api = await loadSettingsHubTestApi(); expect(api.formatMenuLayoutMode("expanded-rows")).toBe("Expanded Rows"); @@ -285,6 +495,128 @@ describe("settings-hub utility coverage", () => { expect(attempts).toBe(3); }); + it("retries sync-center preview loading when loadAccounts hits a retryable lock", async () => { + const api = await loadSettingsHubTestApi(); + const storageModule = await import("../lib/storage.js"); + const codexCliState = await import("../lib/codex-cli/state.js"); + let loadAttempts = 0; + const loadAccountsSpy = vi + .spyOn(storageModule, "loadAccounts") + .mockImplementation(async () => { + loadAttempts += 1; + if (loadAttempts === 1) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; + }); + const loadStateSpy = vi + .spyOn(codexCliState, "loadCodexCliState") + .mockResolvedValue({ + path: "/tmp/source/accounts.json", + accounts: [], + }); + + queueSelectResults({ type: "back" }); + + try { + await api.promptSyncCenter({}); + expect(loadAccountsSpy).toHaveBeenCalledTimes(2); + expect(loadStateSpy).toHaveBeenCalledTimes(1); + } finally { + loadAccountsSpy.mockRestore(); + loadStateSpy.mockRestore(); + } + }); + + it("retries sync-center preview loading when loadCodexCliState hits a retryable lock", async () => { + const api = await loadSettingsHubTestApi(); + const storageModule = await import("../lib/storage.js"); + const codexCliState = await import("../lib/codex-cli/state.js"); + const loadAccountsSpy = vi.spyOn(storageModule, "loadAccounts").mockResolvedValue({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }); + let loadStateAttempts = 0; + const loadStateSpy = vi + .spyOn(codexCliState, "loadCodexCliState") + .mockImplementation(async () => { + loadStateAttempts += 1; + if (loadStateAttempts === 1) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return { + path: "/tmp/source/accounts.json", + accounts: [], + }; + }); + + queueSelectResults({ type: "back" }); + + try { + await api.promptSyncCenter({}); + expect(loadStateSpy).toHaveBeenCalledTimes(2); + expect(loadAccountsSpy).toHaveBeenCalledTimes(2); + } finally { + loadAccountsSpy.mockRestore(); + loadStateSpy.mockRestore(); + } + }); + + it("retries sync-center preview loading when loadCodexCliState returns 429 once", async () => { + const api = await loadSettingsHubTestApi(); + const storageModule = await import("../lib/storage.js"); + const codexCliState = await import("../lib/codex-cli/state.js"); + const loadAccountsSpy = vi.spyOn(storageModule, "loadAccounts").mockResolvedValue({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }); + const retryAfterMs = 1; + let loadStateAttempts = 0; + const loadStateSpy = vi + .spyOn(codexCliState, "loadCodexCliState") + .mockImplementation(async () => { + loadStateAttempts += 1; + if (loadStateAttempts === 1) { + const error = new Error("rate limited") as Error & { + status: number; + retryAfterMs: number; + }; + error.status = 429; + error.retryAfterMs = retryAfterMs; + throw error; + } + return { + path: "/tmp/source/accounts.json", + accounts: [], + }; + }); + + queueSelectResults({ type: "back" }); + + try { + await api.promptSyncCenter({}); + + expect(loadStateSpy).toHaveBeenCalledTimes(2); + expect(loadAccountsSpy).toHaveBeenCalledTimes(2); + } finally { + loadAccountsSpy.mockRestore(); + loadStateSpy.mockRestore(); + } + }); + it("propagates non-retryable filesystem errors immediately", async () => { const api = await loadSettingsHubTestApi(); let attempts = 0; diff --git a/test/storage-flagged.test.ts b/test/storage-flagged.test.ts index 3fda43a5..4c071622 100644 --- a/test/storage-flagged.test.ts +++ b/test/storage-flagged.test.ts @@ -188,8 +188,8 @@ describe("flagged account storage", () => { expect(existsSync(getFlaggedAccountsPath())).toBe(true); expect(existsSync(`${getFlaggedAccountsPath()}.bak`)).toBe(true); - await clearFlaggedAccounts(); - await clearFlaggedAccounts(); + await expect(clearFlaggedAccounts()).resolves.toBe(true); + await expect(clearFlaggedAccounts()).resolves.toBe(true); expect(existsSync(getFlaggedAccountsPath())).toBe(false); expect(existsSync(`${getFlaggedAccountsPath()}.bak`)).toBe(false); @@ -226,50 +226,100 @@ describe("flagged account storage", () => { ], }); - await clearFlaggedAccounts(); + await expect(clearFlaggedAccounts()).resolves.toBe(true); const flagged = await loadFlaggedAccounts(); expect(flagged.accounts).toHaveLength(0); }); - it("suppresses flagged accounts when clear cannot delete the primary file after writing the reset marker", async () => { - await saveFlaggedAccounts({ - version: 1, - accounts: [ - { - refreshToken: "stale-primary", - flaggedAt: 1, - addedAt: 1, - lastUsed: 1, - }, - ], - }); - - const flaggedPath = getFlaggedAccountsPath(); - const originalUnlink = fs.unlink.bind(fs); - const unlinkSpy = vi - .spyOn(fs, "unlink") - .mockImplementation(async (targetPath) => { - if (targetPath === flaggedPath) { - const error = new Error( - "EPERM primary delete", - ) as NodeJS.ErrnoException; - error.code = "EPERM"; - throw error; - } - return originalUnlink(targetPath); + it.each(["EPERM", "EBUSY"] as const)( + "retries transient %s when clearing flagged storage", + async (code) => { + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "stale-primary", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], }); - await expect(clearFlaggedAccounts()).rejects.toThrow( - "EPERM primary delete", - ); + const flaggedPath = getFlaggedAccountsPath(); + const realUnlink = fs.unlink.bind(fs); + let failedOnce = false; + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === flaggedPath && !failedOnce) { + failedOnce = true; + const error = new Error("locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return realUnlink(targetPath); + }); + + try { + await expect(clearFlaggedAccounts()).resolves.toBe(true); + expect(existsSync(flaggedPath)).toBe(false); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === flaggedPath, + ), + ).toHaveLength(2); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); - const flagged = await loadFlaggedAccounts(); - expect(existsSync(flaggedPath)).toBe(true); - expect(flagged.accounts).toHaveLength(0); + it.each(["EPERM", "EBUSY"] as const)( + "returns false when clearing flagged storage exhausts retryable %s failures", + async (code) => { + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "stuck-flagged", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }); - unlinkSpy.mockRestore(); - }); + const flaggedPath = getFlaggedAccountsPath(); + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === flaggedPath) { + const error = new Error( + "still locked", + ) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + const error = new Error("missing") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }); + + try { + await expect(clearFlaggedAccounts()).resolves.toBe(false); + expect(existsSync(flaggedPath)).toBe(true); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === flaggedPath, + ), + ).toHaveLength(5); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); it("does not recover flagged backups when the primary file exists but read fails", async () => { await saveFlaggedAccounts({ @@ -335,7 +385,7 @@ describe("flagged account storage", () => { const manualBackupPath = `${getFlaggedAccountsPath()}.manual-checkpoint`; await fs.copyFile(getFlaggedAccountsPath(), manualBackupPath); - await clearFlaggedAccounts(); + await expect(clearFlaggedAccounts()).resolves.toBe(true); const flagged = await loadFlaggedAccounts(); expect(existsSync(manualBackupPath)).toBe(false); @@ -383,7 +433,7 @@ describe("flagged account storage", () => { return originalUnlink(targetPath); }); - await clearFlaggedAccounts(); + await expect(clearFlaggedAccounts()).resolves.toBe(false); const flagged = await loadFlaggedAccounts(); expect(existsSync(backupPath)).toBe(true); @@ -392,45 +442,6 @@ describe("flagged account storage", () => { unlinkSpy.mockRestore(); }); - it("suppresses flagged accounts when clear cannot delete the primary file after writing the reset marker", async () => { - await saveFlaggedAccounts({ - version: 1, - accounts: [ - { - refreshToken: "stale-primary", - flaggedAt: 1, - addedAt: 1, - lastUsed: 1, - }, - ], - }); - - const flaggedPath = getFlaggedAccountsPath(); - const originalUnlink = fs.unlink.bind(fs); - const unlinkSpy = vi - .spyOn(fs, "unlink") - .mockImplementation(async (targetPath) => { - if (targetPath === flaggedPath) { - const error = new Error( - "EPERM primary delete", - ) as NodeJS.ErrnoException; - error.code = "EPERM"; - throw error; - } - return originalUnlink(targetPath); - }); - - await expect(clearFlaggedAccounts()).rejects.toThrow( - "EPERM primary delete", - ); - - const flagged = await loadFlaggedAccounts(); - expect(existsSync(flaggedPath)).toBe(true); - expect(flagged.accounts).toHaveLength(0); - - unlinkSpy.mockRestore(); - }); - it("emits snapshot metadata for flagged account backups", async () => { await saveFlaggedAccounts({ version: 1, diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index 264bf494..08032032 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -735,6 +735,12 @@ describe("storage recovery paths", () => { }), "utf-8", ); + const manualCheckpointMtime = new Date(Date.now() + 5_000); + await fs.utimes( + `${storagePath}.manual-meta-checkpoint`, + manualCheckpointMtime, + manualCheckpointMtime, + ); const metadata = await getBackupMetadata(); const accountSnapshots = metadata.accounts.snapshots; diff --git a/test/storage.test.ts b/test/storage.test.ts index 14c13ecd..a3d73657 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2,10 +2,16 @@ import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ACCOUNT_LIMITS } from "../lib/constants.js"; +import { clearQuotaCache, getQuotaCachePath } from "../lib/quota-cache.js"; import { getConfigDir, getProjectStorageKey } from "../lib/storage/paths.js"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; import { + assessNamedBackupRestore, buildNamedBackupPath, clearAccounts, + clearFlaggedAccounts, + createNamedBackup, deduplicateAccounts, deduplicateAccountsByEmail, exportAccounts, @@ -13,11 +19,14 @@ import { findMatchingAccountIndex, formatStorageErrorHint, getFlaggedAccountsPath, + NAMED_BACKUP_LIST_CONCURRENCY, getStoragePath, importAccounts, + listNamedBackups, loadAccounts, loadFlaggedAccounts, normalizeAccountStorage, + restoreNamedBackup, resolveAccountSelectionIndex, saveFlaggedAccounts, StorageError, @@ -325,13 +334,10 @@ 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 () => { - // @ts-expect-error - exportAccounts doesn't exist yet - const { exportAccounts } = await import("../lib/storage.js"); - const storage = { version: 3, activeIndex: 0, @@ -351,8 +357,6 @@ describe("storage", () => { }); it("should fail export if file exists and force is false", async () => { - // @ts-expect-error - const { exportAccounts } = await import("../lib/storage.js"); await fs.writeFile(exportPath, "exists"); // @ts-expect-error @@ -361,10 +365,35 @@ describe("storage", () => { ); }); - it("should import accounts from a file and merge", async () => { - // @ts-expect-error - const { importAccounts } = await import("../lib/storage.js"); + 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("should import accounts from a file and merge", async () => { const existing = { version: 3, activeIndex: 0, @@ -398,7 +427,6 @@ describe("storage", () => { }); it("should preserve distinct shared-accountId imports when the imported row has no email", async () => { - const { importAccounts } = await import("../lib/storage.js"); const existing = { version: 3, activeIndex: 1, @@ -454,7 +482,6 @@ describe("storage", () => { }); it("should preserve distinct accountId plus email pairs during import", async () => { - const { importAccounts } = await import("../lib/storage.js"); await saveAccounts({ version: 3, activeIndex: 0, @@ -502,7 +529,6 @@ describe("storage", () => { }); it("should preserve duplicate shared accountId entries when imported rows lack email", async () => { - const { importAccounts } = await import("../lib/storage.js"); await saveAccounts({ version: 3, activeIndex: 0, @@ -895,9 +921,6 @@ describe("storage", () => { }); it("should enforce MAX_ACCOUNTS during import", async () => { - // @ts-expect-error - const { importAccounts } = await import("../lib/storage.js"); - const manyAccounts = Array.from({ length: 21 }, (_, i) => ({ accountId: `acct${i}`, refreshToken: `ref${i}`, @@ -919,15 +942,36 @@ describe("storage", () => { }); it("should fail export when no accounts exist", async () => { - const { exportAccounts } = await import("../lib/storage.js"); - setStoragePathDirect(testStoragePath); - await expect(exportAccounts(exportPath)).rejects.toThrow( - /No accounts to export/, + const isolatedStorageDir = join( + testWorkDir, + "empty-export-" + Math.random().toString(36).slice(2), ); + const isolatedStoragePath = join(isolatedStorageDir, "accounts.json"); + const isolatedExportPath = join(isolatedStorageDir, "export.json"); + await fs.mkdir(isolatedStorageDir, { recursive: true }); + vi.resetModules(); + const isolatedStorageModule = await import("../lib/storage.js"); + isolatedStorageModule.setStoragePathDirect(isolatedStoragePath); + try { + await fs.writeFile( + isolatedStoragePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [], + }), + ); + await expect( + isolatedStorageModule.exportAccounts(isolatedExportPath), + ).rejects.toThrow(/No accounts to export/); + } finally { + isolatedStorageModule.setStoragePathDirect(null); + vi.resetModules(); + } }); it("should fail import when file does not exist", async () => { - const { importAccounts } = await import("../lib/storage.js"); const nonexistentPath = join(testWorkDir, "nonexistent-file.json"); await expect(importAccounts(nonexistentPath)).rejects.toThrow( /Import file not found/, @@ -935,13 +979,11 @@ describe("storage", () => { }); it("should fail import when file contains invalid JSON", async () => { - const { importAccounts } = await import("../lib/storage.js"); await fs.writeFile(exportPath, "not valid json {["); await expect(importAccounts(exportPath)).rejects.toThrow(/Invalid JSON/); }); it("should fail import when file contains invalid format", async () => { - const { importAccounts } = await import("../lib/storage.js"); await fs.writeFile(exportPath, JSON.stringify({ invalid: "format" })); await expect(importAccounts(exportPath)).rejects.toThrow( /Invalid account storage format/, @@ -1001,75 +1043,801 @@ describe("storage", () => { await expect(exportNamedBackup(backupName)).rejects.toThrow( /already exists/, ); - }); + }); + + it("writes the named backup using the safe path", async () => { + const backupName = "backup-2026-03-09"; + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { accountId: "test", refreshToken: "ref", addedAt: 1, lastUsed: 2 }, + ], + }); + const backupPath = await exportNamedBackup(backupName); + expect(existsSync(backupPath)).toBe(true); + expect(backupPath).toBe(buildNamedBackupPath(backupName)); + }); + + it("overwrites an existing named backup when force is true", async () => { + const backupName = "backup-2026-03-10"; + await saveAccounts({ + version: 3, + activeIndex: 1, + accounts: [ + { + accountId: "first", + refreshToken: "ref-1", + addedAt: 1, + lastUsed: 2, + }, + { + accountId: "second", + refreshToken: "ref-2", + addedAt: 3, + lastUsed: 4, + }, + ], + }); + + const initialPath = await exportNamedBackup(backupName); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "replacement", + refreshToken: "ref-3", + addedAt: 5, + lastUsed: 6, + }, + ], + }); + + const overwrittenPath = await exportNamedBackup(backupName, { + force: true, + }); + const exported = JSON.parse( + await fs.readFile(overwrittenPath, "utf-8"), + ); + + expect(overwrittenPath).toBe(initialPath); + expect(exported.activeIndex).toBe(0); + expect(exported.accounts).toHaveLength(1); + expect(exported.accounts[0].accountId).toBe("replacement"); + }); + + it("propagates export errors when no accounts exist for a named backup", async () => { + await expect(exportNamedBackup("backup-2026-03-11")).rejects.toThrow( + /No accounts to export/, + ); + }); + }); + + it("creates and lists named backups with metadata", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "acct-backup", + refreshToken: "ref-backup", + addedAt: 1, + lastUsed: 2, + }, + ], + }); + + const backup = await createNamedBackup("backup-2026-03-12"); + const backups = await listNamedBackups(); + + expect(backup.name).toBe("backup-2026-03-12"); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "backup-2026-03-12", + accountCount: 1, + valid: true, + }), + ]), + ); + }); + + it("assesses eligibility and restores a named backup", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await createNamedBackup("restore-me"); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("restore-me"); + expect(assessment.eligibleForRestore).toBe(true); + expect(assessment.wouldExceedLimit).toBe(false); + + const restoreResult = await restoreNamedBackup("restore-me"); + expect(restoreResult.total).toBe(1); + + const restored = await loadAccounts(); + expect(restored?.accounts[0]?.accountId).toBe("primary"); + }); + + it("honors explicit null currentStorage when assessing a named backup", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "backup-account", + refreshToken: "ref-backup-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("explicit-null-current-storage"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "current-account", + refreshToken: "ref-current-account", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + const assessment = await assessNamedBackupRestore( + "explicit-null-current-storage", + { currentStorage: null }, + ); + + expect(assessment.currentAccountCount).toBe(0); + expect(assessment.mergedAccountCount).toBe(1); + expect(assessment.imported).toBe(1); + expect(assessment.skipped).toBe(0); + expect(assessment.eligibleForRestore).toBe(true); + }); + + it("restores manually named backups that already exist inside the backups directory", async () => { + const backupPath = join( + dirname(testStoragePath), + "backups", + "Manual Backup.json", + ); + await fs.mkdir(dirname(backupPath), { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "manual", + refreshToken: "ref-manual", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "Manual Backup", valid: true }), + ]), + ); + + await clearAccounts(); + const assessment = await assessNamedBackupRestore("Manual Backup"); + expect(assessment.eligibleForRestore).toBe(true); + expect(assessment.backup.name).toBe("Manual Backup"); + + const restoreResult = await restoreNamedBackup("Manual Backup"); + expect(restoreResult.total).toBe(1); + }); + + it("restores manually named backups with uppercase JSON extensions", async () => { + const backupPath = join( + dirname(testStoragePath), + "backups", + "Manual Backup.JSON", + ); + await fs.mkdir(dirname(backupPath), { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "manual-uppercase", + refreshToken: "ref-manual-uppercase", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "Manual Backup", valid: true }), + ]), + ); + + await clearAccounts(); + const assessment = await assessNamedBackupRestore("Manual Backup"); + expect(assessment.eligibleForRestore).toBe(true); + expect(assessment.backup.name).toBe("Manual Backup"); + + const restoreResult = await restoreNamedBackup("Manual Backup"); + expect(restoreResult.total).toBe(1); + }); + + it("throws when a named backup is deleted after assessment", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "deleted-backup", + refreshToken: "ref-deleted-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const backup = await createNamedBackup("deleted-after-assessment"); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("deleted-after-assessment"); + expect(assessment.eligibleForRestore).toBe(true); + + await removeWithRetry(backup.path, { force: true }); + + await expect( + restoreNamedBackup("deleted-after-assessment"), + ).rejects.toThrow(/Import file not found/); + expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); + }); + + it("throws when a named backup becomes invalid JSON after assessment", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "invalid-backup", + refreshToken: "ref-invalid-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const backup = await createNamedBackup("invalid-after-assessment"); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("invalid-after-assessment"); + expect(assessment.eligibleForRestore).toBe(true); + + await fs.writeFile(backup.path, "not valid json {[", "utf-8"); + + await expect( + restoreNamedBackup("invalid-after-assessment"), + ).rejects.toThrow(/Invalid JSON in import file/); + expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); + }); + + it.each(["../openai-codex-accounts", String.raw`..\openai-codex-accounts`])( + "rejects backup names that escape the backups directory: %s", + async (input) => { + await expect(assessNamedBackupRestore(input)).rejects.toThrow( + /must not contain path separators/i, + ); + await expect(restoreNamedBackup(input)).rejects.toThrow( + /must not contain path separators/i, + ); + }, + ); + + it("ignores symlink-like named backup entries that point outside the backups root", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const externalBackupPath = join(testWorkDir, "outside-backup.json"); + await fs.mkdir(backupRoot, { recursive: true }); + await fs.writeFile( + externalBackupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "outside-manual-backup", + refreshToken: "ref-outside-manual-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + const originalReaddir = fs.readdir.bind(fs); + const readdirSpy = vi.spyOn(fs, "readdir"); + const escapedEntry = { + name: "escaped-link.json", + isFile: () => true, + isSymbolicLink: () => true, + } as unknown as Awaited< + ReturnType + >[number]; + readdirSpy.mockImplementation(async (...args) => { + const [path, options] = args; + if ( + String(path) === backupRoot && + typeof options === "object" && + options?.withFileTypes === true + ) { + return [escapedEntry] as Awaited>; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toEqual([]); + await expect(assessNamedBackupRestore("escaped-link")).rejects.toThrow( + /not a regular backup file/i, + ); + await expect(restoreNamedBackup("escaped-link")).rejects.toThrow( + /not a regular backup file/i, + ); + } finally { + readdirSpy.mockRestore(); + } + }); + + it("rethrows unreadable backup directory errors while listing backups", async () => { + 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" }); + } finally { + readdirSpy.mockRestore(); + } + }); + + it("rethrows unreadable backup directory errors after one attempt on non-Windows platforms", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("linux"); + const readdirSpy = vi.spyOn(fs, "readdir"); + const error = new Error("backup directory locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + readdirSpy.mockRejectedValue(error); + + try { + await expect(listNamedBackups()).rejects.toMatchObject({ code: "EPERM" }); + expect(readdirSpy).toHaveBeenCalledTimes(1); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("rethrows unreadable backup directory errors while restoring backups", async () => { + const readdirSpy = vi.spyOn(fs, "readdir"); + const error = new Error("backup directory locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + readdirSpy.mockRejectedValue(error); + + try { + await expect(restoreNamedBackup("Manual Backup")).rejects.toMatchObject({ + code: "EPERM", + }); + } finally { + readdirSpy.mockRestore(); + } + }); + + it("retries transient backup directory errors while listing backups", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-list-dir", + refreshToken: "ref-retry-list-dir", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("retry-list-dir"); + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + let busyFailures = 0; + const readdirSpy = vi + .spyOn(fs, "readdir") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backupRoot && busyFailures === 0) { + busyFailures += 1; + const error = new Error( + "backup directory busy", + ) as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "retry-list-dir", valid: true }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + readdirSpy.mockRestore(); + } + }); + + it("retries transient 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); + + await removeWithRetry(backupPath, { force: true }); + + await expect(restoreNamedBackup("Manual Backup")).rejects.toThrow( + /Import file not found/, + ); + }); + + it("retries transient backup read errors while listing backups", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-read", + refreshToken: "ref-retry-read", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const backup = await createNamedBackup("retry-read"); + const originalReadFile = fs.readFile.bind(fs); + let busyFailures = 0; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backup.path && busyFailures === 0) { + busyFailures += 1; + const error = new Error("backup file busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalReadFile(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "retry-read", valid: true }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("retries transient backup stat errors while listing backups", async () => { + 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; + const 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)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "retry-stat", valid: true }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + statSpy.mockRestore(); + } + }); - it("writes the named backup using the safe path", async () => { - const backupName = "backup-2026-03-09"; - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { accountId: "test", refreshToken: "ref", addedAt: 1, lastUsed: 2 }, - ], - }); - const backupPath = await exportNamedBackup(backupName); - expect(existsSync(backupPath)).toBe(true); - expect(backupPath).toBe(buildNamedBackupPath(backupName)); + 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; }); - it("overwrites an existing named backup when force is true", async () => { - const backupName = "backup-2026-03-10"; - await saveAccounts({ - version: 3, - activeIndex: 1, - accounts: [ - { - accountId: "first", - refreshToken: "ref-1", - addedAt: 1, - lastUsed: 2, - }, - { - accountId: "second", - refreshToken: "ref-2", - addedAt: 3, - lastUsed: 4, - }, - ], - }); + try { + const backups = await listNamedBackups(); + expect(backups.map((backup) => backup.name)).toEqual([ + validBackup.name, + nanBackup.name, + ]); + } finally { + statSpy.mockRestore(); + } + }); - const initialPath = await exportNamedBackup(backupName); + it("limits concurrent backup reads while listing backups", async () => { + const backupPaths: string[] = []; + for (let index = 0; index < 12; index += 1) { await saveAccounts({ version: 3, activeIndex: 0, accounts: [ { - accountId: "replacement", - refreshToken: "ref-3", - addedAt: 5, - lastUsed: 6, + accountId: `concurrency-${index}`, + refreshToken: `ref-concurrency-${index}`, + addedAt: index + 1, + lastUsed: index + 1, }, ], }); + const backup = await createNamedBackup(`concurrency-${index}`); + backupPaths.push(backup.path); + } - const overwrittenPath = await exportNamedBackup(backupName, { - force: true, + 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)); }); - const exported = JSON.parse( - await fs.readFile(overwrittenPath, "utf-8"), + + try { + const backups = await listNamedBackups(); + expect(backups).toHaveLength(12); + expect(peakReads).toBeLessThanOrEqual( + NAMED_BACKUP_LIST_CONCURRENCY, ); + } finally { + readFileSpy.mockRestore(); + } + }); - expect(overwrittenPath).toBe(initialPath); - expect(exported.activeIndex).toBe(0); - expect(exported.accounts).toHaveLength(1); - expect(exported.accounts[0].accountId).toBe("replacement"); + 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"); - it("propagates export errors when no accounts exist for a named backup", async () => { - await expect(exportNamedBackup("backup-2026-03-11")).rejects.toThrow( - /No accounts to export/, - ); + 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); }); }); @@ -1782,6 +2550,130 @@ describe("storage", () => { it("does not throw when file does not exist", async () => { await expect(clearAccounts()).resolves.not.toThrow(); }); + + it.each(["EPERM", "EBUSY", "EAGAIN"] as const)( + "retries transient %s when clearing saved account artifacts", + async (code) => { + await fs.writeFile(testStoragePath, "{}"); + const walPath = `${testStoragePath}.wal`; + await fs.writeFile(walPath, "{}"); + + const realUnlink = fs.unlink.bind(fs); + let failedOnce = false; + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === testStoragePath && !failedOnce) { + failedOnce = true; + const error = new Error("locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return realUnlink(targetPath); + }); + + try { + await expect(clearAccounts()).resolves.toBe(true); + expect(existsSync(testStoragePath)).toBe(false); + expect(existsSync(walPath)).toBe(false); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === testStoragePath, + ), + ).toHaveLength(2); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + }); + + describe("clearFlaggedAccounts", () => { + const testWorkDir = join( + tmpdir(), + "codex-clear-flagged-test-" + Math.random().toString(36).slice(2), + ); + let testStoragePath: string; + + beforeEach(async () => { + await fs.mkdir(testWorkDir, { recursive: true }); + testStoragePath = join(testWorkDir, "accounts.json"); + setStoragePathDirect(testStoragePath); + }); + + afterEach(async () => { + setStoragePathDirect(null); + await fs.rm(testWorkDir, { recursive: true, force: true }); + }); + + it.each(["EPERM", "EBUSY"] as const)( + "retries transient %s when clearing flagged account storage", + async (code) => { + const flaggedPath = getFlaggedAccountsPath(); + await fs.mkdir(dirname(flaggedPath), { recursive: true }); + await fs.writeFile(flaggedPath, "{}"); + + const realUnlink = fs.unlink.bind(fs); + let failedOnce = false; + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === flaggedPath && !failedOnce) { + failedOnce = true; + const error = new Error("locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return realUnlink(targetPath); + }); + + try { + await expect(clearFlaggedAccounts()).resolves.toBe(true); + expect(existsSync(flaggedPath)).toBe(false); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === flaggedPath, + ), + ).toHaveLength(2); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + + it.each(["EPERM", "EBUSY"] as const)( + "returns false when clearing flagged account storage exhausts retryable %s failures", + async (code) => { + const flaggedPath = getFlaggedAccountsPath(); + await fs.mkdir(dirname(flaggedPath), { recursive: true }); + await fs.writeFile(flaggedPath, "{}"); + + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === flaggedPath) { + const error = new Error("still locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + const error = new Error("missing") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }); + + try { + await expect(clearFlaggedAccounts()).resolves.toBe(false); + expect(existsSync(flaggedPath)).toBe(true); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === flaggedPath, + ), + ).toHaveLength(5); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); }); describe("setStoragePath", () => { @@ -1943,6 +2835,37 @@ describe("storage", () => { await expect(clearAccounts()).resolves.not.toThrow(); }); + + it.each(["EPERM", "EBUSY"] as const)( + "returns false when clearing saved accounts exhausts retryable %s failures", + async (code) => { + await fs.writeFile(testStoragePath, "{}"); + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === testStoragePath) { + const error = new Error("still locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + const error = new Error("missing") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }); + + try { + await expect(clearAccounts()).resolves.toBe(false); + expect(existsSync(testStoragePath)).toBe(true); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === testStoragePath, + ), + ).toHaveLength(5); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); }); describe("StorageError with cause", () => { @@ -2356,6 +3279,36 @@ describe("storage", () => { expect(existsSync(legacyWorktreePath)).toBe(false); }); + it("clearAccounts removes legacy project and worktree account files for linked worktrees", async () => { + const { worktreeRepo } = await prepareWorktreeFixture(); + + setStoragePath(worktreeRepo); + const canonicalPath = getStoragePath(); + const legacyProjectPath = join(worktreeRepo, ".codex", "openai-codex-accounts.json"); + const legacyWorktreePath = join( + getConfigDir(), + "projects", + getProjectStorageKey(worktreeRepo), + "openai-codex-accounts.json", + ); + const storage = buildStorage([accountFromLegacy]); + + await fs.mkdir(dirname(canonicalPath), { recursive: true }); + await fs.mkdir(dirname(legacyProjectPath), { recursive: true }); + await fs.mkdir(dirname(legacyWorktreePath), { recursive: true }); + await Promise.all([ + fs.writeFile(canonicalPath, JSON.stringify(storage), "utf-8"), + fs.writeFile(legacyProjectPath, JSON.stringify(storage), "utf-8"), + fs.writeFile(legacyWorktreePath, JSON.stringify(storage), "utf-8"), + ]); + + await expect(clearAccounts()).resolves.toBe(true); + + expect(existsSync(canonicalPath)).toBe(false); + expect(existsSync(legacyProjectPath)).toBe(false); + expect(existsSync(legacyWorktreePath)).toBe(false); + }); + it("keeps legacy worktree file when migration persist fails", async () => { const { worktreeRepo } = await prepareWorktreeFixture(); @@ -2642,6 +3595,37 @@ describe("storage", () => { renameSpy.mockRestore(); }); + it("retries on EAGAIN and cleans up the WAL after rename succeeds", async () => { + const now = Date.now(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + const walPath = `${testStoragePath}.wal`; + + const originalRename = fs.rename.bind(fs); + let attemptCount = 0; + const renameSpy = vi + .spyOn(fs, "rename") + .mockImplementation(async (oldPath, newPath) => { + attemptCount++; + if (attemptCount === 1) { + const err = new Error("EAGAIN error") as NodeJS.ErrnoException; + err.code = "EAGAIN"; + throw err; + } + return originalRename(oldPath as string, newPath as string); + }); + + await saveAccounts(storage); + expect(attemptCount).toBe(2); + expect(existsSync(testStoragePath)).toBe(true); + expect(existsSync(walPath)).toBe(false); + + renameSpy.mockRestore(); + }); + it("throws after 5 failed EPERM retries", async () => { const now = Date.now(); const storage = { @@ -3095,12 +4079,111 @@ describe("storage", () => { Object.assign(new Error("EACCES error"), { code: "EACCES" }), ); - await clearAccounts(); + await expect(clearAccounts()).resolves.toBe(false); expect(unlinkSpy).toHaveBeenCalled(); unlinkSpy.mockRestore(); }); }); + + describe("clearQuotaCache", () => { + const tmpRoot = join( + tmpdir(), + `quota-cache-test-${Math.random().toString(36).slice(2)}`, + ); + let originalDir: string | undefined; + + beforeEach(async () => { + originalDir = process.env.CODEX_MULTI_AUTH_DIR; + process.env.CODEX_MULTI_AUTH_DIR = tmpRoot; + await fs.mkdir(tmpRoot, { recursive: true }); + }); + + afterEach(async () => { + if (originalDir === undefined) delete process.env.CODEX_MULTI_AUTH_DIR; + else process.env.CODEX_MULTI_AUTH_DIR = originalDir; + await fs.rm(tmpRoot, { recursive: true, force: true }); + }); + + it("removes only the quota cache file", async () => { + const quotaPath = getQuotaCachePath(); + const accountsPath = join(tmpRoot, "openai-codex-accounts.json"); + await fs.mkdir(dirname(quotaPath), { recursive: true }); + await fs.writeFile(quotaPath, "{}", "utf-8"); + await fs.writeFile(accountsPath, "{}", "utf-8"); + + expect(existsSync(quotaPath)).toBe(true); + expect(existsSync(accountsPath)).toBe(true); + + await expect(clearQuotaCache()).resolves.toBe(true); + + expect(existsSync(quotaPath)).toBe(false); + expect(existsSync(accountsPath)).toBe(true); + }); + + it("ignores missing quota cache file", async () => { + await expect(clearQuotaCache()).resolves.toBe(true); + }); + + it.each(["EPERM", "EBUSY"] as const)( + "retries transient %s when clearing the quota cache", + async (code) => { + const quotaPath = getQuotaCachePath(); + await fs.mkdir(dirname(quotaPath), { recursive: true }); + await fs.writeFile(quotaPath, "{}", "utf-8"); + + const realUnlink = fs.unlink.bind(fs); + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (target) => { + if (target === quotaPath && unlinkSpy.mock.calls.length === 1) { + const err = new Error("locked") as NodeJS.ErrnoException; + err.code = code; + throw err; + } + return realUnlink(target); + }); + + try { + await expect(clearQuotaCache()).resolves.toBe(true); + expect(existsSync(quotaPath)).toBe(false); + expect(unlinkSpy).toHaveBeenCalledTimes(2); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + + it.each(["EPERM", "EBUSY"] as const)( + "returns false when quota-cache clear exhausts retryable %s failures", + async (code) => { + const quotaPath = getQuotaCachePath(); + await fs.mkdir(dirname(quotaPath), { recursive: true }); + await fs.writeFile(quotaPath, "{}", "utf-8"); + + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (target) => { + if (target === quotaPath) { + const err = new Error("still locked") as NodeJS.ErrnoException; + err.code = code; + throw err; + } + const err = new Error("missing") as NodeJS.ErrnoException; + err.code = "ENOENT"; + throw err; + }); + + try { + await expect(clearQuotaCache()).resolves.toBe(false); + expect(existsSync(quotaPath)).toBe(true); + expect(unlinkSpy).toHaveBeenCalledTimes(5); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + }); }); it("clearAccounts removes discovered backup artifacts as well as fixed slots", async () => {