diff --git a/README.md b/README.md index 9b642ac8..058976ad 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,13 @@ Multi-account OAuth for the official `@openai/codex` CLI. -`codex-multi-auth` adds a local account manager, `codex auth ...` workflow, and recovery tooling on top of the official Codex CLI. It keeps the normal `codex` command path intact, makes account switching explicit, and can optionally power a plugin runtime with account rotation and failover. +`codex-multi-auth` adds a local account manager, `codex auth ...` workflow, backup and recovery tooling, and a productized settings surface on top of the official Codex CLI. It keeps the normal `codex` command path intact, makes account switching explicit, and can optionally power a plugin runtime with account rotation and failover. - Uses the official `@openai/codex` CLI instead of replacing it - Adds `codex auth login`, `list`, `switch`, `check`, `forecast`, `report`, `fix`, and `doctor` - Stores accounts locally, including project-scoped account pools for repo-specific workflows -- Provides an interactive terminal dashboard for login, switching, and settings -- Includes recovery and health tooling for stale tokens, bad sync state, and routing problems +- Provides an interactive terminal dashboard for login, restore, switching, sync preview, and settings +- Includes recovery and diagnostics tooling for stale tokens, backup restore, bad sync state, and routing problems ## Quick Example @@ -52,8 +52,10 @@ This project wraps the official `codex` binary and intercepts the account-manage That gives you a stable workflow for: - signing into more than one ChatGPT-authenticated Codex account +- restoring recoverable named backups before starting a fresh login - switching the active account by index instead of by hidden state - checking account health before a session +- previewing Codex CLI account sync before applying one-way changes - repairing common local auth and storage problems - keeping separate project-level account pools when needed @@ -64,10 +66,13 @@ The official Codex CLI is the right base tool, but a single opaque auth state is ## Features - Multi-account OAuth login through the official browser-based flow +- Backup restore manager for recoverable named backups in `~/.codex/multi-auth/backups/` - Canonical `codex auth ...` command family for day-to-day account operations -- Interactive dashboard with quick switch, search, and settings +- Interactive dashboard with quick switch, search, restore, sync center, and settings - Project-scoped storage under `~/.codex/multi-auth/projects//...` +- Startup recovery prompt that offers restore before OAuth when recoverable named backups are available - Health checks, flagged-account verification, live forecast, JSON reports, and safe repair commands +- Settings split into stable everyday preferences and advanced operator controls - Optional plugin runtime for request transformation, token refresh, retry, failover, session affinity, and quota-aware account selection ## Example Usage @@ -95,6 +100,11 @@ codex auth fix --live --model gpt-5-codex codex auth doctor --fix ``` +Open the interactive restore and sync workflows: + +- `codex auth login` -> `Restore From Backup` +- `codex auth login` -> `Settings` -> `Codex CLI Sync` + ## Architecture / How It Works 1. `scripts/codex.js` becomes the `codex` wrapper entrypoint. @@ -108,10 +118,12 @@ For a short public overview, see [docs/architecture.md](docs/architecture.md). F ## Common Workflows - First login: `codex auth login` +- Restore recoverable named backups before a fresh login: `codex auth login`, then confirm the startup restore prompt or choose `Restore From Backup` - Review the saved account pool: `codex auth list` - Verify account health before coding: `codex auth check` - Choose the best account for the next run: `codex auth forecast --live` - Switch the active account explicitly: `codex auth switch ` +- Preview one-way Codex CLI sync with rollback context: `codex auth login` -> `Settings` -> `Codex CLI Sync` - Gather machine-readable diagnostics: `codex auth report --live --json` - Repair local state safely: `codex auth fix --dry-run` or `codex auth doctor --fix` @@ -180,6 +192,7 @@ Common first-run issues: - OAuth callback on port `1455` fails: free the port and retry `codex auth login` - The wrong account stays active after a switch: rerun `codex auth switch ` and restart the session - A worktree asks you to log in again: run `codex auth list` once in that worktree to trigger repo-shared storage migration +- You expected a restore prompt but got OAuth instead: place named backup files in `~/.codex/multi-auth/backups/`, then rerun `codex auth login` in an interactive terminal Full recovery guidance lives in [docs/troubleshooting.md](docs/troubleshooting.md). diff --git a/docs/README.md b/docs/README.md index 5b8cd888..0c6c05a5 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.7.md](releases/v0.1.7.md) | Stable release notes | @@ -37,8 +37,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..39df4112 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` | @@ -32,6 +34,7 @@ User-facing capability map for `codex-multi-auth`. | --- | --- | --- | | Safe repair workflow | Detects and repairs known local storage inconsistencies | `codex auth fix` | | Diagnostics with optional repair | One command to inspect and optionally fix common failures | `codex auth doctor` | +| JSON diagnostics pack | Machine-readable state for support, bug reports, and deeper inspection | `codex auth report --live --json` | | Backup and WAL recovery | Safer persistence when local writes are interrupted or partially applied | storage runtime | --- @@ -53,6 +56,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..e1bcb74d 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 you have named backups in `~/.codex/multi-auth/backups/` and no active accounts, the login flow can prompt you to restore before opening OAuth. Confirm to open `Restore From Backup`, review the recoverable backup list, and restore the entries you want. Skip the prompt to continue with a fresh login. + 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` 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 @@ -119,6 +144,12 @@ codex auth doctor --fix codex auth check ``` +If you need a broader diagnostics snapshot: + +```bash +codex auth report --live --json +``` + --- ## Next diff --git a/docs/index.md b/docs/index.md index fb4074f8..ec09eff4 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` -> `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 43c877fa..7981f417 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -20,7 +20,7 @@ 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 diagnostics entry points | | `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 | @@ -94,6 +94,16 @@ Compatibility aliases are supported: ## Workflow Packs +Interactive dashboard workflows: + +- Backup restore: `codex auth login` -> `Restore From Backup` +- Startup recovery prompt: `codex auth login`, then confirm restore when recoverable named backups are found before OAuth +- Sync preview and apply: `codex auth login` -> `Settings` -> `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 diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 1466374b..2692aab3 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,9 +19,11 @@ When `CODEX_MULTI_AUTH_DIR` is set, this root moves accordingly. --- -## Dashboard Display Settings +## Everyday Settings -### Account List View +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: @@ -34,7 +36,7 @@ Controls account-row display and sorting behavior: - smart sort enable and mode - compact versus expanded layout mode -### Summary Fields +### Details Line Controls detail-line fields and order: @@ -42,7 +44,7 @@ Controls detail-line fields and order: - `limits` - `status` -### Behavior +### Results and Refresh Controls result-screen and fetch behavior: @@ -51,7 +53,7 @@ Controls result-screen and fetch behavior: - auto-fetch limits - fetch TTL -### Theme +### Colors Controls display style: @@ -61,6 +63,36 @@ Controls display style: --- +## Advanced and Operator Controls + +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`) + +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 + +--- + +### 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 and Sync @@ -152,6 +184,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 @@ -175,4 +208,4 @@ codex auth forecast --live - [commands.md](commands.md) - [storage-paths.md](storage-paths.md) -- [../configuration.md](../configuration.md) \ No newline at end of file +- [../configuration.md](../configuration.md) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index fbbcf1cc..02c5db3d 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 named backups are present, you will be prompted to restore before OAuth. This prompt only appears in interactive terminals and is skipped after intentional 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 | Put named backup files in `~/.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 | Place backup files in `~/.codex/multi-auth/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` -> `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 | +| A target-only account would be lost | 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,6 +115,11 @@ codex auth report --live --json codex auth doctor --json ``` +Interactive diagnostics path: + +- `codex auth login` -> `Settings` -> `Codex CLI Sync` for preview-based sync diagnostics +- `codex auth login` -> `Settings` -> `Advanced Backend Controls` for sync, retry, quota, recovery, and timeout tuning + --- ## Reset Options diff --git a/lib/cli.ts b/lib/cli.ts index c6522f46..11bec09d 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -26,6 +26,10 @@ export function isNonInteractiveMode(): boolean { return false; } +export function isInteractiveLoginMenuAvailable(): boolean { + return !isNonInteractiveMode() && isTTY(); +} + export async function promptAddAnotherAccount( currentCount: number, ): Promise { @@ -243,7 +247,7 @@ export async function promptLoginMode( return { mode: "add" }; } - if (!isTTY()) { + if (!isInteractiveLoginMenuAvailable()) { return promptLoginModeFallback(existingAccounts); } diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 38e66050..eef9ace2 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -1,15 +1,16 @@ +import { createLogger } from "../logger.js"; +import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; import { - getLastAccountsSaveTimestamp, type AccountMetadataV3, type AccountStorageV3, + getLastAccountsSaveTimestamp, + getStoragePath, } from "../storage.js"; -import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; -import { createLogger } from "../logger.js"; -import { loadCodexCliState, type CodexCliAccountSnapshot } from "./state.js"; import { incrementCodexCliMetric, makeAccountFingerprint, } from "./observability.js"; +import { type CodexCliAccountSnapshot, loadCodexCliState } from "./state.js"; import { getLastCodexCliSelectionWriteTimestamp } from "./writer.js"; const log = createLogger("codex-cli-sync"); @@ -40,7 +41,99 @@ function cloneStorage(storage: AccountStorageV3): AccountStorageV3 { }; } -function buildIndexByAccountId(accounts: AccountMetadataV3[]): Map { +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; + 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; +} + +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; + +function createEmptySyncSummary(): CodexCliSyncSummary { + return { + sourceAccountCount: 0, + targetAccountCountBefore: 0, + targetAccountCountAfter: 0, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }; +} + +function setLastCodexCliSyncRun(run: CodexCliSyncRun): void { + lastCodexCliSyncRun = run; +} + +export function getLastCodexCliSyncRun(): CodexCliSyncRun | null { + return lastCodexCliSyncRun + ? { + ...lastCodexCliSyncRun, + summary: { ...lastCodexCliSyncRun.summary }, + } + : null; +} + +export function __resetLastCodexCliSyncRunForTests(): void { + lastCodexCliSyncRun = null; +} + +function buildIndexByAccountId( + accounts: AccountMetadataV3[], +): Map { const map = new Map(); for (let i = 0; i < accounts.length; i += 1) { const account = accounts[i]; @@ -50,7 +143,9 @@ function buildIndexByAccountId(accounts: AccountMetadataV3[]): Map { +function buildIndexByRefresh( + accounts: AccountMetadataV3[], +): Map { const map = new Map(); for (let i = 0; i < accounts.length; i += 1) { const account = accounts[i]; @@ -70,7 +165,9 @@ function buildIndexByEmail(accounts: AccountMetadataV3[]): Map { return map; } -function toStorageAccount(snapshot: CodexCliAccountSnapshot): AccountMetadataV3 | null { +function toStorageAccount( + snapshot: CodexCliAccountSnapshot, +): AccountMetadataV3 | null { if (!snapshot.refreshToken) return null; const now = Date.now(); return { @@ -89,9 +186,9 @@ function toStorageAccount(snapshot: CodexCliAccountSnapshot): AccountMetadataV3 function upsertFromSnapshot( accounts: AccountMetadataV3[], snapshot: CodexCliAccountSnapshot, -): boolean { +): UpsertResult { const nextAccount = toStorageAccount(snapshot); - if (!nextAccount) return false; + if (!nextAccount) return { action: "skipped" }; const byAccountId = buildIndexByAccountId(accounts); const byRefresh = buildIndexByRefresh(accounts); @@ -109,19 +206,18 @@ function upsertFromSnapshot( if (targetIndex === undefined) { accounts.push(nextAccount); - return true; + return { action: "added" }; } const current = accounts[targetIndex]; - if (!current) return false; + if (!current) return { action: "skipped" }; const merged: AccountMetadataV3 = { ...current, accountId: snapshot.accountId ?? current.accountId, - accountIdSource: - snapshot.accountId - ? current.accountIdSource ?? "token" - : current.accountIdSource, + accountIdSource: snapshot.accountId + ? (current.accountIdSource ?? "token") + : current.accountIdSource, email: snapshot.email ?? current.email, refreshToken: snapshot.refreshToken ?? current.refreshToken, accessToken: snapshot.accessToken ?? current.accessToken, @@ -132,7 +228,10 @@ function upsertFromSnapshot( if (changed) { accounts[targetIndex] = merged; } - return changed; + return { + action: changed ? "updated" : "unchanged", + matchedIndex: targetIndex, + }; } function resolveActiveIndex( @@ -143,7 +242,9 @@ function resolveActiveIndex( if (accounts.length === 0) return 0; if (activeAccountId) { - const byId = accounts.findIndex((account) => account.accountId === activeAccountId); + const byId = accounts.findIndex( + (account) => account.accountId === activeAccountId, + ); if (byId >= 0) return byId; } @@ -158,10 +259,7 @@ function resolveActiveIndex( return 0; } -function writeFamilyIndexes( - storage: AccountStorageV3, - index: number, -): void { +function writeFamilyIndexes(storage: AccountStorageV3, index: number): void { storage.activeIndex = index; storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; for (const family of MODEL_FAMILIES) { @@ -186,7 +284,8 @@ function writeFamilyIndexes( */ function normalizeStoredFamilyIndexes(storage: AccountStorageV3): void { const count = storage.accounts.length; - const clamped = count === 0 ? 0 : Math.max(0, Math.min(storage.activeIndex, count - 1)); + const clamped = + count === 0 ? 0 : Math.max(0, Math.min(storage.activeIndex, count - 1)); if (storage.activeIndex !== clamped) { storage.activeIndex = clamped; } @@ -194,7 +293,9 @@ function normalizeStoredFamilyIndexes(storage: AccountStorageV3): void { for (const family of MODEL_FAMILIES) { const raw = storage.activeIndexByFamily[family]; const resolved = - typeof raw === "number" && Number.isFinite(raw) ? raw : storage.activeIndex; + typeof raw === "number" && Number.isFinite(raw) + ? raw + : storage.activeIndex; storage.activeIndexByFamily[family] = count === 0 ? 0 : Math.max(0, Math.min(resolved, count - 1)); } @@ -210,9 +311,10 @@ function normalizeStoredFamilyIndexes(storage: AccountStorageV3): void { * 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 } { +function readActiveFromSnapshots(snapshots: CodexCliAccountSnapshot[]): { + accountId?: string; + email?: string; +} { const active = snapshots.find((snapshot) => snapshot.isActive); return { accountId: active?.accountId, @@ -231,13 +333,16 @@ function readActiveFromSnapshots( * @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>): boolean { +function shouldApplyCodexCliSelection( + state: Awaited>, +): boolean { if (!state) return false; const hasSyncVersion = typeof state.syncVersion === "number" && Number.isFinite(state.syncVersion); const codexVersion = hasSyncVersion ? (state.syncVersion as number) - : typeof state.sourceUpdatedAtMs === "number" && Number.isFinite(state.sourceUpdatedAtMs) + : typeof state.sourceUpdatedAtMs === "number" && + Number.isFinite(state.sourceUpdatedAtMs) ? state.sourceUpdatedAtMs : 0; const localVersion = Math.max( @@ -250,6 +355,150 @@ function shouldApplyCodexCliSelection(state: Awaited= localVersion - toleranceMs; } +function reconcileCodexCliState( + current: AccountStorageV3 | null, + state: NonNullable>>, +): ReconcileResult { + const next = current ? cloneStorage(current) : createEmptyStorage(); + const targetAccountCountBefore = next.accounts.length; + const matchedExistingIndexes = new Set(); + const summary = createEmptySyncSummary(); + summary.targetAccountCountBefore = targetAccountCountBefore; + + let changed = false; + for (const snapshot of state.accounts) { + const result = upsertFromSnapshot(next.accounts, snapshot); + if (result.action === "skipped") continue; + summary.sourceAccountCount += 1; + 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); + if (applyActiveFromCodex) { + const desiredIndex = resolveActiveIndex( + next.accounts, + state.activeAccountId ?? activeFromSnapshots.accountId, + state.activeEmail ?? activeFromSnapshots.email, + ); + writeFamilyIndexes(next, desiredIndex); + } 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 } = {}, +): Promise { + const targetPath = getStoragePath(); + 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 { + const state = await loadCodexCliState({ + forceRefresh: options.forceRefresh, + }); + if ((process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI ?? "").trim() === "0") { + return { + status: "disabled", + statusDetail: "Codex CLI sync is disabled by environment override.", + sourcePath: null, + targetPath, + summary: emptySummary, + backup, + lastSync, + }; + } + if (!state) { + return { + status: "unavailable", + statusDetail: "No Codex CLI sync source was found.", + sourcePath: null, + targetPath, + summary: emptySummary, + backup, + lastSync, + }; + } + + const reconciled = reconcileCodexCliState(current, state); + const status = reconciled.changed ? "ready" : "noop"; + const statusDetail = reconciled.changed + ? `Preview ready: ${reconciled.summary.addedAccountCount} add, ${reconciled.summary.updatedAccountCount} update, ${reconciled.summary.destinationOnlyPreservedCount} destination-only preserved.` + : "Target already matches the current one-way sync result."; + return { + status, + statusDetail, + sourcePath: state.path, + targetPath, + summary: reconciled.summary, + backup, + lastSync, + }; + } catch (error) { + return { + status: "error", + statusDetail: error instanceof Error ? error.message : String(error), + sourcePath: 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. * @@ -273,23 +522,45 @@ export async function syncAccountStorageFromCodexCli( current: AccountStorageV3 | null, ): Promise<{ storage: AccountStorageV3 | null; changed: boolean }> { incrementCodexCliMetric("reconcileAttempts"); + const targetPath = getStoragePath(); try { const state = await loadCodexCliState(); if (!state) { incrementCodexCliMetric("reconcileNoops"); + setLastCodexCliSyncRun({ + outcome: + (process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI ?? "").trim() === "0" + ? "disabled" + : "unavailable", + runAt: Date.now(), + sourcePath: null, + targetPath, + summary: { + ...createEmptySyncSummary(), + targetAccountCountBefore: current?.accounts.length ?? 0, + targetAccountCountAfter: current?.accounts.length ?? 0, + }, + message: + (process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI ?? "").trim() === "0" + ? "Codex CLI sync disabled by environment override." + : "No Codex CLI sync source was available.", + }); return { storage: current, changed: false }; } - const next = current ? cloneStorage(current) : createEmptyStorage(); - let changed = false; - - for (const snapshot of state.accounts) { - const updated = upsertFromSnapshot(next.accounts, snapshot); - if (updated) changed = true; - } + const reconciled = reconcileCodexCliState(current, state); + const next = reconciled.next; + const changed = reconciled.changed; if (next.accounts.length === 0) { incrementCodexCliMetric(changed ? "reconcileChanges" : "reconcileNoops"); + setLastCodexCliSyncRun({ + outcome: changed ? "changed" : "noop", + runAt: Date.now(), + sourcePath: state.path, + targetPath, + summary: reconciled.summary, + }); log.debug("Codex CLI reconcile completed", { operation: "reconcile-storage", outcome: changed ? "changed" : "noop", @@ -301,42 +572,15 @@ export async function syncAccountStorageFromCodexCli( }; } - const activeFromSnapshots = readActiveFromSnapshots(state.accounts); - const applyActiveFromCodex = shouldApplyCodexCliSelection(state); - if (applyActiveFromCodex) { - const desiredIndex = resolveActiveIndex( - next.accounts, - state.activeAccountId ?? activeFromSnapshots.accountId, - state.activeEmail ?? activeFromSnapshots.email, - ); - - const previousActive = next.activeIndex; - const previousFamilies = JSON.stringify(next.activeIndexByFamily ?? {}); - writeFamilyIndexes(next, desiredIndex); - normalizeStoredFamilyIndexes(next); - if (previousActive !== next.activeIndex) { - changed = true; - } - if (previousFamilies !== JSON.stringify(next.activeIndexByFamily ?? {})) { - changed = true; - } - } else { - const previousActive = next.activeIndex; - const previousFamilies = JSON.stringify(next.activeIndexByFamily ?? {}); - normalizeStoredFamilyIndexes(next); - if (previousActive !== next.activeIndex) { - changed = true; - } - if (previousFamilies !== JSON.stringify(next.activeIndexByFamily ?? {})) { - changed = true; - } - log.debug("Skipped Codex CLI active selection overwrite due to newer local state", { - operation: "reconcile-storage", - outcome: "local-newer", - }); - } - incrementCodexCliMetric(changed ? "reconcileChanges" : "reconcileNoops"); + const activeFromSnapshots = readActiveFromSnapshots(state.accounts); + setLastCodexCliSyncRun({ + outcome: changed ? "changed" : "noop", + runAt: Date.now(), + sourcePath: state.path, + targetPath, + summary: reconciled.summary, + }); log.debug("Codex CLI reconcile completed", { operation: "reconcile-storage", outcome: changed ? "changed" : "noop", @@ -352,6 +596,18 @@ export async function syncAccountStorageFromCodexCli( }; } catch (error) { incrementCodexCliMetric("reconcileFailures"); + setLastCodexCliSyncRun({ + outcome: "error", + runAt: Date.now(), + sourcePath: null, + targetPath, + summary: { + ...createEmptySyncSummary(), + targetAccountCountBefore: current?.accounts.length ?? 0, + targetAccountCountAfter: current?.accounts.length ?? 0, + }, + message: error instanceof Error ? error.message : String(error), + }); log.warn("Codex CLI reconcile failed", { operation: "reconcile-storage", outcome: "error", @@ -368,6 +624,7 @@ export function getActiveSelectionForFamily( const count = storage.accounts.length; if (count === 0) return 0; const raw = storage.activeIndexByFamily?.[family]; - const candidate = typeof raw === "number" && Number.isFinite(raw) ? raw : storage.activeIndex; + const candidate = + typeof raw === "number" && Number.isFinite(raw) ? raw : storage.activeIndex; return Math.max(0, Math.min(candidate, count - 1)); } diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 52b09ec7..17dd506f 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -23,6 +23,7 @@ import { copyTextToClipboard, openBrowserUrl } from "./auth/browser.js"; import { startLocalOAuthServer } from "./auth/server.js"; import { type ExistingAccountInfo, + isInteractiveLoginMenuAvailable, promptAddAnotherAccount, promptLoginMode, } from "./cli.js"; @@ -76,6 +77,7 @@ import { assessNamedBackupRestore, type FlaggedAccountMetadataV1, type FlaggedAccountStorageV1, + getActionableNamedBackupRestores, getNamedBackupsDirectoryPath, getStoragePath, listNamedBackups, @@ -4176,6 +4178,8 @@ async function runBackupRestoreManager( async function runAuthLogin(): Promise { setStoragePath(null); + let suppressRecoveryPrompt = false; + let recoveryPromptAttempted = false; let pendingMenuQuotaRefresh: Promise | null = null; let menuQuotaRefreshStatus: string | undefined; loginFlow: while (true) { @@ -4302,6 +4306,7 @@ async function runAuthLogin(): Promise { continue; } if (menuResult.mode === "fresh" && menuResult.deleteAll) { + suppressRecoveryPrompt = true; await runActionPanel( DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.label, DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.stage, @@ -4316,6 +4321,7 @@ async function runAuthLogin(): Promise { continue; } if (menuResult.mode === "reset") { + suppressRecoveryPrompt = true; await runActionPanel( DESTRUCTIVE_ACTION_COPY.resetLocalState.label, DESTRUCTIVE_ACTION_COPY.resetLocalState.stage, @@ -4360,6 +4366,37 @@ async function runAuthLogin(): Promise { const refreshedStorage = await loadAccounts(); const existingCount = refreshedStorage?.accounts.length ?? 0; + const canPromptForRecovery = + !suppressRecoveryPrompt && + !recoveryPromptAttempted && + existingCount === 0 && + isInteractiveLoginMenuAvailable(); + if (canPromptForRecovery) { + recoveryPromptAttempted = true; + const recoveryState = await getActionableNamedBackupRestores({ + currentStorage: refreshedStorage, + }); + if (recoveryState.assessments.length > 0) { + const displaySettings = await loadDashboardDisplaySettings(); + applyUiThemeFromDashboardSettings(displaySettings); + const backupDir = getNamedBackupsDirectoryPath(); + const sample = recoveryState.assessments[0]; + const backupLabel = + sample?.backup.name ?? + `${recoveryState.assessments.length} backup${ + recoveryState.assessments.length === 1 ? "" : "s" + }`; + const restoreNow = await confirm( + `Found ${recoveryState.assessments.length} recoverable backup${ + recoveryState.assessments.length === 1 ? "" : "s" + } (${backupLabel}) in ${backupDir}. Restore now?`, + ); + if (restoreNow) { + await runBackupRestoreManager(displaySettings); + continue; + } + } + } let forceNewLogin = existingCount > 0; while (true) { const tokenResult = await runOAuthFlow(forceNewLogin); diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 99cbdae4..500dabf0 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -1,24 +1,48 @@ import { stdin as input, stdout as output } from "node:process"; import { - loadDashboardDisplaySettings, - saveDashboardDisplaySettings, - getDashboardSettingsPath, - DEFAULT_DASHBOARD_DISPLAY_SETTINGS, - type DashboardDisplaySettings, - type DashboardThemePreset, - type DashboardAccentColor, - type DashboardAccountSortMode, - type DashboardStatuslineField, + type CodexCliState, + getCodexCliAccountsPath, + getCodexCliAuthPath, + getCodexCliConfigPath, + isCodexCliSyncEnabled, + loadCodexCliState, +} from "../codex-cli/state.js"; +import { + type CodexCliSyncPreview, + type CodexCliSyncRun, + type CodexCliSyncSummary, + previewCodexCliSync, + syncAccountStorageFromCodexCli, +} from "../codex-cli/sync.js"; +import { + getDefaultPluginConfig, + getStorageBackupEnabled, + loadPluginConfig, + savePluginConfig, +} from "../config.js"; +import { + type DashboardAccentColor, + type DashboardAccountSortMode, + type DashboardDisplaySettings, + type DashboardStatuslineField, + type DashboardThemePreset, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + getDashboardSettingsPath, + loadDashboardDisplaySettings, + saveDashboardDisplaySettings, } from "../dashboard-settings.js"; -import { getDefaultPluginConfig, loadPluginConfig, savePluginConfig } from "../config.js"; -import { getUnifiedSettingsPath } from "../unified-settings.js"; +import { + getLastLiveAccountSyncSnapshot, + type LiveAccountSyncSnapshot, +} from "../live-account-sync.js"; +import { loadAccounts, saveAccounts } from "../storage.js"; import type { PluginConfig } from "../types.js"; -import { sleep } from "../utils.js"; import { ANSI } from "../ui/ansi.js"; import { UI_COPY } from "../ui/copy.js"; import { getUiRuntimeOptions, setUiRuntimeOptions } from "../ui/runtime.js"; -import { select, type MenuItem } from "../ui/select.js"; - +import { type MenuItem, select } from "../ui/select.js"; +import { getUnifiedSettingsPath } from "../unified-settings.js"; +import { sleep } from "../utils.js"; type DashboardDisplaySettingKey = | "menuShowStatusBadge" @@ -92,7 +116,11 @@ const DASHBOARD_DISPLAY_OPTIONS: DashboardDisplaySettingOption[] = [ }, ]; -const DEFAULT_STATUSLINE_FIELDS: DashboardStatuslineField[] = ["last-used", "limits", "status"]; +const DEFAULT_STATUSLINE_FIELDS: DashboardStatuslineField[] = [ + "last-used", + "limits", + "status", +]; const STATUSLINE_FIELD_OPTIONS: Array<{ key: DashboardStatuslineField; label: string; @@ -117,7 +145,12 @@ const STATUSLINE_FIELD_OPTIONS: Array<{ const AUTO_RETURN_OPTIONS_MS = [1_000, 2_000, 4_000] as const; const MENU_QUOTA_TTL_OPTIONS_MS = [60_000, 5 * 60_000, 10 * 60_000] as const; const THEME_PRESET_OPTIONS: DashboardThemePreset[] = ["green", "blue"]; -const ACCENT_COLOR_OPTIONS: DashboardAccentColor[] = ["green", "cyan", "blue", "yellow"]; +const ACCENT_COLOR_OPTIONS: DashboardAccentColor[] = [ + "green", + "cyan", + "blue", + "yellow", +]; const PREVIEW_ACCOUNT_EMAIL = "demo@example.com"; const PREVIEW_LAST_USED = "today"; const PREVIEW_STATUS = "active"; @@ -194,7 +227,10 @@ type BackendNumberSettingKey = | "preemptiveQuotaRemainingPercent7d" | "preemptiveQuotaMaxDeferralMs"; -type BackendSettingFocusKey = BackendToggleSettingKey | BackendNumberSettingKey | null; +type BackendSettingFocusKey = + | BackendToggleSettingKey + | BackendNumberSettingKey + | null; interface BackendToggleSettingOption { key: BackendToggleSettingKey; @@ -240,12 +276,27 @@ type BackendSettingsHubAction = type SettingsHubAction = | { type: "account-list" } + | { type: "sync-center" } | { type: "summary-fields" } | { type: "behavior" } | { type: "theme" } | { type: "backend" } | { type: "back" }; +type SyncCenterAction = + | { type: "refresh" } + | { type: "apply" } + | { type: "back" }; + +interface SyncCenterOverviewContext { + accountsPath: string; + authPath: string; + configPath: string; + state: CodexCliState | null; + liveSync: LiveAccountSyncSnapshot; + syncEnabled: boolean; +} + const BACKEND_TOGGLE_OPTIONS: BackendToggleSettingOption[] = [ { key: "liveAccountSync", @@ -452,12 +503,14 @@ const BACKEND_NUMBER_OPTIONS: BackendNumberSettingOption[] = [ ]; const BACKEND_DEFAULTS = getDefaultPluginConfig(); -const BACKEND_TOGGLE_OPTION_BY_KEY = new Map( - BACKEND_TOGGLE_OPTIONS.map((option) => [option.key, option]), -); -const BACKEND_NUMBER_OPTION_BY_KEY = new Map( - BACKEND_NUMBER_OPTIONS.map((option) => [option.key, option]), -); +const BACKEND_TOGGLE_OPTION_BY_KEY = new Map< + BackendToggleSettingKey, + BackendToggleSettingOption +>(BACKEND_TOGGLE_OPTIONS.map((option) => [option.key, option])); +const BACKEND_NUMBER_OPTION_BY_KEY = new Map< + BackendNumberSettingKey, + BackendNumberSettingOption +>(BACKEND_NUMBER_OPTIONS.map((option) => [option.key, option])); const BACKEND_CATEGORY_OPTIONS: BackendCategoryOption[] = [ { key: "session-sync", @@ -517,7 +570,13 @@ const BACKEND_CATEGORY_OPTIONS: BackendCategoryOption[] = [ type DashboardSettingKey = keyof DashboardDisplaySettings; -const RETRYABLE_SETTINGS_WRITE_CODES = new Set(["EBUSY", "EPERM", "EAGAIN", "ENOTEMPTY", "EACCES"]); +const RETRYABLE_SETTINGS_WRITE_CODES = new Set([ + "EBUSY", + "EPERM", + "EAGAIN", + "ENOTEMPTY", + "EACCES", +]); const SETTINGS_WRITE_MAX_ATTEMPTS = 4; const SETTINGS_WRITE_BASE_DELAY_MS = 20; const SETTINGS_WRITE_MAX_DELAY_MS = 30_000; @@ -539,7 +598,9 @@ const ACCOUNT_LIST_PANEL_KEYS = [ "menuLayoutMode", ] as const satisfies readonly DashboardSettingKey[]; -const STATUSLINE_PANEL_KEYS = ["menuStatuslineFields"] as const satisfies readonly DashboardSettingKey[]; +const STATUSLINE_PANEL_KEYS = [ + "menuStatuslineFields", +] as const satisfies readonly DashboardSettingKey[]; const BEHAVIOR_PANEL_KEYS = [ "actionAutoReturnMs", "actionPauseOnKey", @@ -547,7 +608,10 @@ const BEHAVIOR_PANEL_KEYS = [ "menuShowFetchStatus", "menuQuotaTtlMs", ] as const satisfies readonly DashboardSettingKey[]; -const THEME_PANEL_KEYS = ["uiThemePreset", "uiAccentColor"] as const satisfies readonly DashboardSettingKey[]; +const THEME_PANEL_KEYS = [ + "uiThemePreset", + "uiAccentColor", +] as const satisfies readonly DashboardSettingKey[]; function readErrorNumber(value: unknown): number | undefined { if (typeof value === "number" && Number.isFinite(value)) return value; @@ -584,13 +648,26 @@ function isRetryableSettingsWriteError(error: unknown): boolean { function resolveRetryDelayMs(error: unknown, attempt: number): number { const retryAfterMs = getRetryAfterMs(error); - if (typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs > 0) { - return Math.max(10, Math.min(SETTINGS_WRITE_MAX_DELAY_MS, Math.round(retryAfterMs))); + if ( + typeof retryAfterMs === "number" && + Number.isFinite(retryAfterMs) && + retryAfterMs > 0 + ) { + return Math.max( + 10, + Math.min(SETTINGS_WRITE_MAX_DELAY_MS, Math.round(retryAfterMs)), + ); } - return Math.min(SETTINGS_WRITE_MAX_DELAY_MS, SETTINGS_WRITE_BASE_DELAY_MS * 2 ** attempt); + return Math.min( + SETTINGS_WRITE_MAX_DELAY_MS, + SETTINGS_WRITE_BASE_DELAY_MS * 2 ** attempt, + ); } -async function enqueueSettingsWrite(pathKey: string, task: () => Promise): Promise { +async function enqueueSettingsWrite( + pathKey: string, + task: () => Promise, +): Promise { const previous = settingsWriteQueues.get(pathKey) ?? Promise.resolve(); const queued = previous.catch(() => {}).then(task); const queueTail = queued.then( @@ -607,7 +684,10 @@ async function enqueueSettingsWrite(pathKey: string, task: () => Promise): } } -async function withQueuedRetry(pathKey: string, task: () => Promise): Promise { +async function withQueuedRetry( + pathKey: string, + task: () => Promise, +): Promise { return enqueueSettingsWrite(pathKey, async () => { let lastError: unknown; for (let attempt = 0; attempt < SETTINGS_WRITE_MAX_ATTEMPTS; attempt += 1) { @@ -615,13 +695,18 @@ async function withQueuedRetry(pathKey: string, task: () => Promise): Prom return await task(); } catch (error) { lastError = error; - if (!isRetryableSettingsWriteError(error) || attempt + 1 >= SETTINGS_WRITE_MAX_ATTEMPTS) { + if ( + !isRetryableSettingsWriteError(error) || + attempt + 1 >= SETTINGS_WRITE_MAX_ATTEMPTS + ) { throw error; } await sleep(resolveRetryDelayMs(error, attempt)); } } - throw lastError instanceof Error ? lastError : new Error("settings save retry exhausted"); + throw lastError instanceof Error + ? lastError + : new Error("settings save retry exhausted"); }); } @@ -631,7 +716,9 @@ function copyDashboardSettingValue( key: DashboardSettingKey, ): void { const value = source[key]; - (target as unknown as Record)[key] = Array.isArray(value) ? [...value] : value; + (target as unknown as Record)[key] = Array.isArray(value) + ? [...value] + : value; } function applyDashboardDefaultsForKeys( @@ -669,7 +756,9 @@ function formatPersistError(error: unknown): string { } function warnPersistFailure(scope: string, error: unknown): void { - console.warn(`Settings save failed (${scope}) after retries: ${formatPersistError(error)}`); + console.warn( + `Settings save failed (${scope}) after retries: ${formatPersistError(error)}`, + ); } async function persistDashboardSettingsSelection( @@ -680,7 +769,9 @@ async function persistDashboardSettingsSelection( const fallback = cloneDashboardSettings(selected); try { return await withQueuedRetry(getDashboardSettingsPath(), async () => { - const latest = cloneDashboardSettings(await loadDashboardDisplaySettings()); + const latest = cloneDashboardSettings( + await loadDashboardDisplaySettings(), + ); const merged = mergeDashboardSettingsForKeys(latest, selected, keys); await saveDashboardDisplaySettings(merged); return merged; @@ -691,7 +782,10 @@ async function persistDashboardSettingsSelection( } } -async function persistBackendConfigSelection(selected: PluginConfig, scope: string): Promise { +async function persistBackendConfigSelection( + selected: PluginConfig, + scope: string, +): Promise { const fallback = cloneBackendPluginConfig(selected); try { await withQueuedRetry(resolvePluginConfigSavePathKey(), async () => { @@ -757,7 +851,9 @@ function isCurrentRowPreviewFocus(focus: PreviewFocusKey): boolean { } function isExpandedRowsPreviewFocus(focus: PreviewFocusKey): boolean { - return focus === "menuShowDetailsForUnselectedRows" || focus === "menuLayoutMode"; + return ( + focus === "menuShowDetailsForUnselectedRows" || focus === "menuLayoutMode" + ); } function buildSummaryPreviewText( @@ -774,9 +870,10 @@ function buildSummaryPreviewText( ); } if (settings.menuShowQuotaSummary !== false) { - const limitsText = settings.menuShowQuotaCooldown === false - ? PREVIEW_LIMITS - : `${PREVIEW_LIMITS} | ${PREVIEW_LIMIT_COOLDOWNS}`; + const limitsText = + settings.menuShowQuotaCooldown === false + ? PREVIEW_LIMITS + : `${PREVIEW_LIMITS} | ${PREVIEW_LIMIT_COOLDOWNS}`; const part = `limits: ${limitsText}`; partsByField.set( "limits", @@ -795,12 +892,16 @@ function buildSummaryPreviewText( const orderedParts = normalizeStatuslineFields(settings.menuStatuslineFields) .map((field) => partsByField.get(field)) - .filter((part): part is string => typeof part === "string" && part.length > 0); + .filter( + (part): part is string => typeof part === "string" && part.length > 0, + ); if (orderedParts.length > 0) { return orderedParts.join(" | "); } - const showsStatusField = normalizeStatuslineFields(settings.menuStatuslineFields).includes("status"); + const showsStatusField = normalizeStatuslineFields( + settings.menuStatuslineFields, + ).includes("status"); if (showsStatusField && settings.menuShowStatusBadge !== false) { const note = "status text appears only when status badges are hidden"; return isStatusPreviewFocus(focus) ? highlightPreviewToken(note, ui) : note; @@ -817,22 +918,27 @@ function buildAccountListPreview( if (settings.menuShowCurrentBadge !== false) { const currentBadge = "[current]"; badges.push( - isCurrentBadgePreviewFocus(focus) ? highlightPreviewToken(currentBadge, ui) : currentBadge, + isCurrentBadgePreviewFocus(focus) + ? highlightPreviewToken(currentBadge, ui) + : currentBadge, ); } if (settings.menuShowStatusBadge !== false) { const statusBadge = "[active]"; badges.push( - isStatusPreviewFocus(focus) ? highlightPreviewToken(statusBadge, ui) : statusBadge, + isStatusPreviewFocus(focus) + ? highlightPreviewToken(statusBadge, ui) + : statusBadge, ); } const badgeSuffix = badges.length > 0 ? ` ${badges.join(" ")}` : ""; const accountEmail = isCurrentRowPreviewFocus(focus) ? highlightPreviewToken(PREVIEW_ACCOUNT_EMAIL, ui) : PREVIEW_ACCOUNT_EMAIL; - const rowDetailMode = resolveMenuLayoutMode(settings) === "expanded-rows" - ? "details shown on all rows" - : "details shown on selected row only"; + const rowDetailMode = + resolveMenuLayoutMode(settings) === "expanded-rows" + ? "details shown on all rows" + : "details shown on selected row only"; const detailModeText = isExpandedRowsPreviewFocus(focus) ? highlightPreviewToken(rowDetailMode, ui) : rowDetailMode; @@ -842,7 +948,9 @@ function buildAccountListPreview( }; } -function cloneDashboardSettings(settings: DashboardDisplaySettings): DashboardDisplaySettings { +function cloneDashboardSettings( + settings: DashboardDisplaySettings, +): DashboardDisplaySettings { const layoutMode = resolveMenuLayoutMode(settings); return { showPerAccountRows: settings.showPerAccountRows, @@ -854,13 +962,19 @@ function cloneDashboardSettings(settings: DashboardDisplaySettings): DashboardDi actionPauseOnKey: settings.actionPauseOnKey ?? true, menuAutoFetchLimits: settings.menuAutoFetchLimits ?? true, menuSortEnabled: - settings.menuSortEnabled ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? true), + settings.menuSortEnabled ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? + true, menuSortMode: - settings.menuSortMode ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? "ready-first"), + settings.menuSortMode ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? + "ready-first", menuSortPinCurrent: settings.menuSortPinCurrent ?? - (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? false), - menuSortQuickSwitchVisibleRow: settings.menuSortQuickSwitchVisibleRow ?? true, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? + false, + menuSortQuickSwitchVisibleRow: + settings.menuSortQuickSwitchVisibleRow ?? true, uiThemePreset: settings.uiThemePreset ?? "green", uiAccentColor: settings.uiAccentColor ?? "green", menuShowStatusBadge: settings.menuShowStatusBadge ?? true, @@ -874,7 +988,9 @@ function cloneDashboardSettings(settings: DashboardDisplaySettings): DashboardDi menuQuotaTtlMs: settings.menuQuotaTtlMs ?? 5 * 60_000, menuFocusStyle: settings.menuFocusStyle ?? "row-invert", menuHighlightCurrentRow: settings.menuHighlightCurrentRow ?? true, - menuStatuslineFields: [...normalizeStatuslineFields(settings.menuStatuslineFields)], + menuStatuslineFields: [ + ...normalizeStatuslineFields(settings.menuStatuslineFields), + ], }; } @@ -888,29 +1004,51 @@ function dashboardSettingsEqual( left.showForecastReasons === right.showForecastReasons && left.showRecommendations === right.showRecommendations && left.showLiveProbeNotes === right.showLiveProbeNotes && - (left.actionAutoReturnMs ?? 2_000) === (right.actionAutoReturnMs ?? 2_000) && + (left.actionAutoReturnMs ?? 2_000) === + (right.actionAutoReturnMs ?? 2_000) && (left.actionPauseOnKey ?? true) === (right.actionPauseOnKey ?? true) && - (left.menuAutoFetchLimits ?? true) === (right.menuAutoFetchLimits ?? true) && - (left.menuSortEnabled ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? true)) === - (right.menuSortEnabled ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? true)) && - (left.menuSortMode ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? "ready-first")) === - (right.menuSortMode ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? "ready-first")) && - (left.menuSortPinCurrent ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? false)) === - (right.menuSortPinCurrent ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? false)) && + (left.menuAutoFetchLimits ?? true) === + (right.menuAutoFetchLimits ?? true) && + (left.menuSortEnabled ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? + true) === + (right.menuSortEnabled ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? + true) && + (left.menuSortMode ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? + "ready-first") === + (right.menuSortMode ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? + "ready-first") && + (left.menuSortPinCurrent ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? + false) === + (right.menuSortPinCurrent ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? + false) && (left.menuSortQuickSwitchVisibleRow ?? true) === (right.menuSortQuickSwitchVisibleRow ?? true) && (left.uiThemePreset ?? "green") === (right.uiThemePreset ?? "green") && (left.uiAccentColor ?? "green") === (right.uiAccentColor ?? "green") && - (left.menuShowStatusBadge ?? true) === (right.menuShowStatusBadge ?? true) && - (left.menuShowCurrentBadge ?? true) === (right.menuShowCurrentBadge ?? true) && + (left.menuShowStatusBadge ?? true) === + (right.menuShowStatusBadge ?? true) && + (left.menuShowCurrentBadge ?? true) === + (right.menuShowCurrentBadge ?? true) && (left.menuShowLastUsed ?? true) === (right.menuShowLastUsed ?? true) && - (left.menuShowQuotaSummary ?? true) === (right.menuShowQuotaSummary ?? true) && - (left.menuShowQuotaCooldown ?? true) === (right.menuShowQuotaCooldown ?? true) && - (left.menuShowFetchStatus ?? true) === (right.menuShowFetchStatus ?? true) && + (left.menuShowQuotaSummary ?? true) === + (right.menuShowQuotaSummary ?? true) && + (left.menuShowQuotaCooldown ?? true) === + (right.menuShowQuotaCooldown ?? true) && + (left.menuShowFetchStatus ?? true) === + (right.menuShowFetchStatus ?? true) && resolveMenuLayoutMode(left) === resolveMenuLayoutMode(right) && - (left.menuQuotaTtlMs ?? 5 * 60_000) === (right.menuQuotaTtlMs ?? 5 * 60_000) && - (left.menuFocusStyle ?? "row-invert") === (right.menuFocusStyle ?? "row-invert") && - (left.menuHighlightCurrentRow ?? true) === (right.menuHighlightCurrentRow ?? true) && + (left.menuQuotaTtlMs ?? 5 * 60_000) === + (right.menuQuotaTtlMs ?? 5 * 60_000) && + (left.menuFocusStyle ?? "row-invert") === + (right.menuFocusStyle ?? "row-invert") && + (left.menuHighlightCurrentRow ?? true) === + (right.menuHighlightCurrentRow ?? true) && JSON.stringify(normalizeStatuslineFields(left.menuStatuslineFields)) === JSON.stringify(normalizeStatuslineFields(right.menuStatuslineFields)) ); @@ -921,28 +1059,42 @@ function cloneBackendPluginConfig(config: PluginConfig): PluginConfig { return { ...BACKEND_DEFAULTS, ...config, - unsupportedCodexFallbackChain: fallbackChain && typeof fallbackChain === "object" - ? { ...fallbackChain } - : {}, + unsupportedCodexFallbackChain: + fallbackChain && typeof fallbackChain === "object" + ? { ...fallbackChain } + : {}, }; } -function backendSettingsSnapshot(config: PluginConfig): Record { +function backendSettingsSnapshot( + config: PluginConfig, +): Record { const snapshot: Record = {}; for (const option of BACKEND_TOGGLE_OPTIONS) { - snapshot[option.key] = config[option.key] ?? BACKEND_DEFAULTS[option.key] ?? false; + snapshot[option.key] = + config[option.key] ?? BACKEND_DEFAULTS[option.key] ?? false; } for (const option of BACKEND_NUMBER_OPTIONS) { - snapshot[option.key] = config[option.key] ?? BACKEND_DEFAULTS[option.key] ?? option.min; + snapshot[option.key] = + config[option.key] ?? BACKEND_DEFAULTS[option.key] ?? option.min; } return snapshot; } -function backendSettingsEqual(left: PluginConfig, right: PluginConfig): boolean { - return JSON.stringify(backendSettingsSnapshot(left)) === JSON.stringify(backendSettingsSnapshot(right)); +function backendSettingsEqual( + left: PluginConfig, + right: PluginConfig, +): boolean { + return ( + JSON.stringify(backendSettingsSnapshot(left)) === + JSON.stringify(backendSettingsSnapshot(right)) + ); } -function formatBackendNumberValue(option: BackendNumberSettingOption, value: number): string { +function formatBackendNumberValue( + option: BackendNumberSettingOption, + value: number, +): string { if (option.unit === "percent") return `${Math.round(value)}%`; if (option.unit === "count") return `${Math.round(value)}`; if (value >= 60_000 && value % 60_000 === 0) { @@ -954,7 +1106,10 @@ function formatBackendNumberValue(option: BackendNumberSettingOption, value: num return `${Math.round(value)}ms`; } -function clampBackendNumber(option: BackendNumberSettingOption, value: number): number { +function clampBackendNumber( + option: BackendNumberSettingOption, + value: number, +): number { return Math.max(option.min, Math.min(option.max, Math.round(value))); } @@ -963,9 +1118,14 @@ function buildBackendSettingsPreview( ui: ReturnType, focus: BackendSettingFocusKey = null, ): { label: string; hint: string } { - const liveSync = config.liveAccountSync ?? BACKEND_DEFAULTS.liveAccountSync ?? true; - const affinity = config.sessionAffinity ?? BACKEND_DEFAULTS.sessionAffinity ?? true; - const preemptive = config.preemptiveQuotaEnabled ?? BACKEND_DEFAULTS.preemptiveQuotaEnabled ?? true; + const liveSync = + config.liveAccountSync ?? BACKEND_DEFAULTS.liveAccountSync ?? true; + const affinity = + config.sessionAffinity ?? BACKEND_DEFAULTS.sessionAffinity ?? true; + const preemptive = + config.preemptiveQuotaEnabled ?? + BACKEND_DEFAULTS.preemptiveQuotaEnabled ?? + true; const threshold5h = config.preemptiveQuotaRemainingPercent5h ?? BACKEND_DEFAULTS.preemptiveQuotaRemainingPercent5h ?? @@ -974,12 +1134,21 @@ function buildBackendSettingsPreview( config.preemptiveQuotaRemainingPercent7d ?? BACKEND_DEFAULTS.preemptiveQuotaRemainingPercent7d ?? 5; - const fetchTimeout = config.fetchTimeoutMs ?? BACKEND_DEFAULTS.fetchTimeoutMs ?? 60_000; - const stallTimeout = config.streamStallTimeoutMs ?? BACKEND_DEFAULTS.streamStallTimeoutMs ?? 45_000; + const fetchTimeout = + config.fetchTimeoutMs ?? BACKEND_DEFAULTS.fetchTimeoutMs ?? 60_000; + const stallTimeout = + config.streamStallTimeoutMs ?? + BACKEND_DEFAULTS.streamStallTimeoutMs ?? + 45_000; const fetchTimeoutOption = BACKEND_NUMBER_OPTION_BY_KEY.get("fetchTimeoutMs"); - const stallTimeoutOption = BACKEND_NUMBER_OPTION_BY_KEY.get("streamStallTimeoutMs"); + const stallTimeoutOption = BACKEND_NUMBER_OPTION_BY_KEY.get( + "streamStallTimeoutMs", + ); - const highlightIfFocused = (key: BackendSettingFocusKey, text: string): string => { + const highlightIfFocused = ( + key: BackendSettingFocusKey, + text: string, + ): string => { if (focus !== key) return text; return highlightPreviewToken(text, ui); }; @@ -1016,7 +1185,9 @@ function buildBackendConfigPatch(config: PluginConfig): Partial { return patch; } -function applyUiThemeFromDashboardSettings(settings: DashboardDisplaySettings): void { +function applyUiThemeFromDashboardSettings( + settings: DashboardDisplaySettings, +): void { const current = getUiRuntimeOptions(); setUiRuntimeOptions({ v2Enabled: current.v2Enabled, @@ -1044,10 +1215,14 @@ function resolveMenuLayoutMode( if (settings.menuLayoutMode === "compact-details") { return "compact-details"; } - return settings.menuShowDetailsForUnselectedRows === true ? "expanded-rows" : "compact-details"; + return settings.menuShowDetailsForUnselectedRows === true + ? "expanded-rows" + : "compact-details"; } -function formatMenuLayoutMode(mode: "compact-details" | "expanded-rows"): string { +function formatMenuLayoutMode( + mode: "compact-details" | "expanded-rows", +): string { return mode === "expanded-rows" ? "Expanded Rows" : "Compact + Details Pane"; } @@ -1061,8 +1236,151 @@ 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( + state: CodexCliState | null, +): SyncCenterOverviewContext { + return { + accountsPath: getCodexCliAccountsPath(), + authPath: getCodexCliAuthPath(), + configPath: getCodexCliConfigPath(), + state, + liveSync: getLastLiveAccountSyncSnapshot(), + syncEnabled: isCodexCliSyncEnabled(), + }; +} + +function formatSyncSourceLabel( + preview: CodexCliSyncPreview, + context: SyncCenterOverviewContext, +): string { + if (!context.syncEnabled) return "disabled by environment override"; + if (!preview.sourcePath) return "not available"; + if (preview.sourcePath === context.accountsPath) + return "accounts.json active"; + if (preview.sourcePath === context.authPath) + return "auth.json fallback active"; + return "custom source path active"; +} + +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.state + ? `Visible source accounts: ${context.state.accounts.length}.` + : "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 that preview into the target path; 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); + const option = BACKEND_NUMBER_OPTION_BY_KEY.get( + settingKey as BackendNumberSettingKey, + ); if (!option) { throw new Error(`Unknown backend numeric setting key: ${settingKey}`); } @@ -1081,7 +1399,11 @@ async function persistDashboardSettingsSelectionForTests( keys: ReadonlyArray, scope: string, ): Promise { - return persistDashboardSettingsSelection(selected, keys as readonly DashboardSettingKey[], scope); + return persistDashboardSettingsSelection( + selected, + keys as readonly DashboardSettingKey[], + scope, + ); } async function persistBackendConfigSelectionForTests( @@ -1095,6 +1417,7 @@ const __testOnly = { clampBackendNumber: clampBackendNumberForTests, formatMenuLayoutMode, cloneDashboardSettings, + buildSyncCenterOverview, withQueuedRetry: withQueuedRetryForTests, persistDashboardSettingsSelection: persistDashboardSettingsSelectionForTests, persistBackendConfigSelection: persistBackendConfigSelectionForTests, @@ -1114,18 +1437,24 @@ async function promptDashboardDisplaySettings( DASHBOARD_DISPLAY_OPTIONS[0]?.key ?? "menuShowStatusBadge"; while (true) { const preview = buildAccountListPreview(draft, ui, focusKey); - const optionItems: MenuItem[] = DASHBOARD_DISPLAY_OPTIONS.map((option, index) => { - const enabled = draft[option.key] ?? true; - const label = `${formatDashboardSettingState(enabled)} ${index + 1}. ${option.label}`; - const color: MenuItem["color"] = enabled ? "green" : "yellow"; - return { - label, - hint: option.description, - value: { type: "toggle", key: option.key } as DashboardConfigAction, - color, - }; - }); - const sortMode = draft.menuSortMode ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? "ready-first"); + const optionItems: MenuItem[] = + DASHBOARD_DISPLAY_OPTIONS.map((option, index) => { + const enabled = draft[option.key] ?? true; + const label = `${formatDashboardSettingState(enabled)} ${index + 1}. ${option.label}`; + const color: MenuItem["color"] = enabled + ? "green" + : "yellow"; + return { + label, + hint: option.description, + value: { type: "toggle", key: option.key } as DashboardConfigAction, + color, + }; + }); + const sortMode = + draft.menuSortMode ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? + "ready-first"; const sortModeItem: MenuItem = { label: `Sort mode: ${formatMenuSortMode(sortMode)}`, hint: "Applies when smart sort is enabled.", @@ -1140,7 +1469,11 @@ async function promptDashboardDisplaySettings( color: layoutMode === "compact-details" ? "green" : "yellow", }; const items: MenuItem[] = [ - { label: UI_COPY.settings.previewHeading, value: { type: "cancel" }, kind: "heading" }, + { + label: UI_COPY.settings.previewHeading, + value: { type: "cancel" }, + kind: "heading", + }, { label: preview.label, hint: preview.hint, @@ -1150,19 +1483,38 @@ async function promptDashboardDisplaySettings( hideUnavailableSuffix: true, }, { label: "", value: { type: "cancel" }, separator: true }, - { label: UI_COPY.settings.displayHeading, value: { type: "cancel" }, kind: "heading" }, + { + label: UI_COPY.settings.displayHeading, + value: { type: "cancel" }, + kind: "heading", + }, ...optionItems, sortModeItem, layoutModeItem, { label: "", value: { type: "cancel" }, separator: true }, - { label: UI_COPY.settings.resetDefault, value: { type: "reset" }, color: "yellow" }, - { label: UI_COPY.settings.saveAndBack, value: { type: "save" }, color: "green" }, - { label: UI_COPY.settings.backNoSave, value: { type: "cancel" }, color: "red" }, + { + label: UI_COPY.settings.resetDefault, + value: { type: "reset" }, + color: "yellow", + }, + { + label: UI_COPY.settings.saveAndBack, + value: { type: "save" }, + color: "green", + }, + { + label: UI_COPY.settings.backNoSave, + value: { type: "cancel" }, + color: "red", + }, ]; - const initialCursor = items.findIndex((item) => - (item.value.type === "toggle" && item.value.key === focusKey) || - (item.value.type === "cycle-sort-mode" && focusKey === "menuSortMode") || - (item.value.type === "cycle-layout-mode" && focusKey === "menuLayoutMode") + const initialCursor = items.findIndex( + (item) => + (item.value.type === "toggle" && item.value.key === focusKey) || + (item.value.type === "cycle-sort-mode" && + focusKey === "menuSortMode") || + (item.value.type === "cycle-layout-mode" && + focusKey === "menuLayoutMode"), ); const updateFocusedPreview = (cursor: number) => { @@ -1174,7 +1526,7 @@ async function promptDashboardDisplaySettings( ? "menuSortMode" : focusedItem?.value.type === "cycle-layout-mode" ? "menuLayoutMode" - : focusKey; + : focusKey; const nextPreview = buildAccountListPreview(draft, ui, focusedKey); const previewItem = items[1]; if (!previewItem) return; @@ -1182,27 +1534,25 @@ async function promptDashboardDisplaySettings( previewItem.hint = nextPreview.hint; }; - const result = await select( - items, - { - message: UI_COPY.settings.accountListTitle, - subtitle: UI_COPY.settings.accountListSubtitle, - help: UI_COPY.settings.accountListHelp, - clearScreen: true, - theme: ui.theme, - selectedEmphasis: "minimal", - initialCursor: initialCursor >= 0 ? initialCursor : undefined, - onCursorChange: ({ cursor }) => { - const focusedItem = items[cursor]; - if (focusedItem?.value.type === "toggle") { - focusKey = focusedItem.value.key; - } else if (focusedItem?.value.type === "cycle-sort-mode") { - focusKey = "menuSortMode"; - } else if (focusedItem?.value.type === "cycle-layout-mode") { - focusKey = "menuLayoutMode"; - } - updateFocusedPreview(cursor); - }, + const result = await select(items, { + message: UI_COPY.settings.accountListTitle, + subtitle: UI_COPY.settings.accountListSubtitle, + help: UI_COPY.settings.accountListHelp, + clearScreen: true, + theme: ui.theme, + selectedEmphasis: "minimal", + initialCursor: initialCursor >= 0 ? initialCursor : undefined, + onCursorChange: ({ cursor }) => { + const focusedItem = items[cursor]; + if (focusedItem?.value.type === "toggle") { + focusKey = focusedItem.value.key; + } else if (focusedItem?.value.type === "cycle-sort-mode") { + focusKey = "menuSortMode"; + } else if (focusedItem?.value.type === "cycle-layout-mode") { + focusKey = "menuLayoutMode"; + } + updateFocusedPreview(cursor); + }, onInput: (raw) => { const lower = raw.toLowerCase(); if (lower === "q") return { type: "cancel" }; @@ -1210,23 +1560,26 @@ async function promptDashboardDisplaySettings( if (lower === "r") return { type: "reset" }; if (lower === "m") return { type: "cycle-sort-mode" }; if (lower === "l") return { type: "cycle-layout-mode" }; - const parsed = Number.parseInt(raw, 10); - if (Number.isFinite(parsed) && parsed >= 1 && parsed <= DASHBOARD_DISPLAY_OPTIONS.length) { - const target = DASHBOARD_DISPLAY_OPTIONS[parsed - 1]; - if (target) { - return { type: "toggle", key: target.key }; - } - } - if (parsed === DASHBOARD_DISPLAY_OPTIONS.length + 1) { - return { type: "cycle-sort-mode" }; - } - if (parsed === DASHBOARD_DISPLAY_OPTIONS.length + 2) { - return { type: "cycle-layout-mode" }; + const parsed = Number.parseInt(raw, 10); + if ( + Number.isFinite(parsed) && + parsed >= 1 && + parsed <= DASHBOARD_DISPLAY_OPTIONS.length + ) { + const target = DASHBOARD_DISPLAY_OPTIONS[parsed - 1]; + if (target) { + return { type: "toggle", key: target.key }; } - return undefined; - }, + } + if (parsed === DASHBOARD_DISPLAY_OPTIONS.length + 1) { + return { type: "cycle-sort-mode" }; + } + if (parsed === DASHBOARD_DISPLAY_OPTIONS.length + 2) { + return { type: "cycle-layout-mode" }; + } + return undefined; }, - ); + }); if (!result || result.type === "cancel") { return null; @@ -1240,23 +1593,31 @@ async function promptDashboardDisplaySettings( continue; } if (result.type === "cycle-sort-mode") { - const currentMode = draft.menuSortMode ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? "ready-first"); - const nextMode: DashboardAccountSortMode = currentMode === "ready-first" - ? "manual" - : "ready-first"; + const currentMode = + draft.menuSortMode ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? + "ready-first"; + const nextMode: DashboardAccountSortMode = + currentMode === "ready-first" ? "manual" : "ready-first"; draft = { ...draft, menuSortMode: nextMode, - menuSortEnabled: nextMode === "ready-first" - ? true - : (draft.menuSortEnabled ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? true)), + menuSortEnabled: + nextMode === "ready-first" + ? true + : (draft.menuSortEnabled ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? + true), }; focusKey = "menuSortMode"; continue; } if (result.type === "cycle-layout-mode") { const currentLayout = resolveMenuLayoutMode(draft); - const nextLayout = currentLayout === "compact-details" ? "expanded-rows" : "compact-details"; + const nextLayout = + currentLayout === "compact-details" + ? "expanded-rows" + : "compact-details"; draft = { ...draft, menuLayoutMode: nextLayout, @@ -1276,7 +1637,7 @@ async function promptDashboardDisplaySettings( async function configureDashboardDisplaySettings( currentSettings?: DashboardDisplaySettings, ): Promise { - const current = currentSettings ?? await loadDashboardDisplaySettings(); + const current = currentSettings ?? (await loadDashboardDisplaySettings()); if (!input.isTTY || !output.isTTY) { console.log("Settings require interactive mode."); console.log(`Settings file: ${getDashboardSettingsPath()}`); @@ -1287,7 +1648,11 @@ async function configureDashboardDisplaySettings( if (!selected) return current; if (dashboardSettingsEqual(current, selected)) return current; - const merged = await persistDashboardSettingsSelection(selected, ACCOUNT_LIST_PANEL_KEYS, "account-list"); + const merged = await persistDashboardSettingsSelection( + selected, + ACCOUNT_LIST_PANEL_KEYS, + "account-list", + ); applyUiThemeFromDashboardSettings(merged); return merged; } @@ -1319,10 +1684,13 @@ async function promptStatuslineSettings( const ui = getUiRuntimeOptions(); let draft = cloneDashboardSettings(initial); - let focusKey: DashboardStatuslineField = draft.menuStatuslineFields?.[0] ?? "last-used"; + let focusKey: DashboardStatuslineField = + draft.menuStatuslineFields?.[0] ?? "last-used"; while (true) { const preview = buildAccountListPreview(draft, ui, focusKey); - const selectedSet = new Set(normalizeStatuslineFields(draft.menuStatuslineFields)); + const selectedSet = new Set( + normalizeStatuslineFields(draft.menuStatuslineFields), + ); const ordered = normalizeStatuslineFields(draft.menuStatuslineFields); const orderMap = new Map(); for (let index = 0; index < ordered.length; index += 1) { @@ -1330,20 +1698,25 @@ async function promptStatuslineSettings( if (key) orderMap.set(key, index + 1); } - const optionItems: MenuItem[] = STATUSLINE_FIELD_OPTIONS.map((option, index) => { - const enabled = selectedSet.has(option.key); - const rank = orderMap.get(option.key); - const label = `${formatDashboardSettingState(enabled)} ${index + 1}. ${option.label}${rank ? ` (order ${rank})` : ""}`; - return { - label, - hint: option.description, - value: { type: "toggle", key: option.key }, - color: enabled ? "green" : "yellow", - }; - }); + const optionItems: MenuItem[] = + STATUSLINE_FIELD_OPTIONS.map((option, index) => { + const enabled = selectedSet.has(option.key); + const rank = orderMap.get(option.key); + const label = `${formatDashboardSettingState(enabled)} ${index + 1}. ${option.label}${rank ? ` (order ${rank})` : ""}`; + return { + label, + hint: option.description, + value: { type: "toggle", key: option.key }, + color: enabled ? "green" : "yellow", + }; + }); const items: MenuItem[] = [ - { label: UI_COPY.settings.previewHeading, value: { type: "cancel" }, kind: "heading" }, + { + label: UI_COPY.settings.previewHeading, + value: { type: "cancel" }, + kind: "heading", + }, { label: preview.label, hint: preview.hint, @@ -1353,15 +1726,39 @@ async function promptStatuslineSettings( hideUnavailableSuffix: true, }, { label: "", value: { type: "cancel" }, separator: true }, - { label: UI_COPY.settings.displayHeading, value: { type: "cancel" }, kind: "heading" }, + { + label: UI_COPY.settings.displayHeading, + value: { type: "cancel" }, + kind: "heading", + }, ...optionItems, { label: "", value: { type: "cancel" }, separator: true }, - { label: UI_COPY.settings.moveUp, value: { type: "move-up", key: focusKey }, color: "green" }, - { label: UI_COPY.settings.moveDown, value: { type: "move-down", key: focusKey }, color: "green" }, + { + label: UI_COPY.settings.moveUp, + value: { type: "move-up", key: focusKey }, + color: "green", + }, + { + label: UI_COPY.settings.moveDown, + value: { type: "move-down", key: focusKey }, + color: "green", + }, { label: "", value: { type: "cancel" }, separator: true }, - { label: UI_COPY.settings.resetDefault, value: { type: "reset" }, color: "yellow" }, - { label: UI_COPY.settings.saveAndBack, value: { type: "save" }, color: "green" }, - { label: UI_COPY.settings.backNoSave, value: { type: "cancel" }, color: "red" }, + { + label: UI_COPY.settings.resetDefault, + value: { type: "reset" }, + color: "yellow", + }, + { + label: UI_COPY.settings.saveAndBack, + value: { type: "save" }, + color: "green", + }, + { + label: UI_COPY.settings.backNoSave, + value: { type: "cancel" }, + color: "red", + }, ]; const initialCursor = items.findIndex( @@ -1402,7 +1799,11 @@ async function promptStatuslineSettings( if (lower === "[") return { type: "move-up", key: focusKey }; if (lower === "]") return { type: "move-down", key: focusKey }; const parsed = Number.parseInt(raw, 10); - if (Number.isFinite(parsed) && parsed >= 1 && parsed <= STATUSLINE_FIELD_OPTIONS.length) { + if ( + Number.isFinite(parsed) && + parsed >= 1 && + parsed <= STATUSLINE_FIELD_OPTIONS.length + ) { const target = STATUSLINE_FIELD_OPTIONS[parsed - 1]; if (target) { return { type: "toggle", key: target.key }; @@ -1469,7 +1870,7 @@ async function promptStatuslineSettings( async function configureStatuslineSettings( currentSettings?: DashboardDisplaySettings, ): Promise { - const current = currentSettings ?? await loadDashboardDisplaySettings(); + const current = currentSettings ?? (await loadDashboardDisplaySettings()); if (!input.isTTY || !output.isTTY) { console.log("Settings require interactive mode."); console.log(`Settings file: ${getDashboardSettingsPath()}`); @@ -1480,13 +1881,19 @@ async function configureStatuslineSettings( if (!selected) return current; if (dashboardSettingsEqual(current, selected)) return current; - const merged = await persistDashboardSettingsSelection(selected, STATUSLINE_PANEL_KEYS, "summary-fields"); + const merged = await persistDashboardSettingsSelection( + selected, + STATUSLINE_PANEL_KEYS, + "summary-fields", + ); applyUiThemeFromDashboardSettings(merged); return merged; } function formatDelayLabel(delayMs: number): string { - return delayMs <= 0 ? "Instant return" : `${Math.round(delayMs / 1000)}s auto-return`; + return delayMs <= 0 + ? "Instant return" + : `${Math.round(delayMs / 1000)}s auto-return`; } async function promptBehaviorSettings( @@ -1506,23 +1913,31 @@ async function promptBehaviorSettings( const autoFetchLimits = draft.menuAutoFetchLimits ?? true; const fetchStatusVisible = draft.menuShowFetchStatus ?? true; const menuQuotaTtlMs = draft.menuQuotaTtlMs ?? 5 * 60_000; - const delayItems: MenuItem[] = AUTO_RETURN_OPTIONS_MS.map((delayMs) => { - const color: MenuItem["color"] = currentDelay === delayMs ? "green" : "yellow"; - return { - label: `${currentDelay === delayMs ? "[x]" : "[ ]"} ${formatDelayLabel(delayMs)}`, - hint: - delayMs === 1_000 - ? "Fastest loop for frequent actions." - : delayMs === 2_000 - ? "Balanced default for most users." - : "More time to read action output.", - value: { type: "set-delay", delayMs }, - color, - }; - }); - const pauseColor: MenuItem["color"] = pauseOnKey ? "green" : "yellow"; + const delayItems: MenuItem[] = + AUTO_RETURN_OPTIONS_MS.map((delayMs) => { + const color: MenuItem["color"] = + currentDelay === delayMs ? "green" : "yellow"; + return { + label: `${currentDelay === delayMs ? "[x]" : "[ ]"} ${formatDelayLabel(delayMs)}`, + hint: + delayMs === 1_000 + ? "Fastest loop for frequent actions." + : delayMs === 2_000 + ? "Balanced default for most users." + : "More time to read action output.", + value: { type: "set-delay", delayMs }, + color, + }; + }); + const pauseColor: MenuItem["color"] = pauseOnKey + ? "green" + : "yellow"; const items: MenuItem[] = [ - { label: UI_COPY.settings.actionTiming, value: { type: "cancel" }, kind: "heading" }, + { + label: UI_COPY.settings.actionTiming, + value: { type: "cancel" }, + kind: "heading", + }, ...delayItems, { label: "", value: { type: "cancel" }, separator: true }, { @@ -1550,9 +1965,21 @@ async function promptBehaviorSettings( color: "yellow", }, { label: "", value: { type: "cancel" }, separator: true }, - { label: UI_COPY.settings.resetDefault, value: { type: "reset" }, color: "yellow" }, - { label: UI_COPY.settings.saveAndBack, value: { type: "save" }, color: "green" }, - { label: UI_COPY.settings.backNoSave, value: { type: "cancel" }, color: "red" }, + { + label: UI_COPY.settings.resetDefault, + value: { type: "reset" }, + color: "yellow", + }, + { + label: UI_COPY.settings.saveAndBack, + value: { type: "save" }, + color: "green", + }, + { + label: UI_COPY.settings.backNoSave, + value: { type: "cancel" }, + color: "red", + }, ]; const initialCursor = items.findIndex((item) => { const value = item.value; @@ -1585,11 +2012,17 @@ async function promptBehaviorSettings( if (lower === "p") return { type: "toggle-pause" }; if (lower === "l") return { type: "toggle-menu-limit-fetch" }; if (lower === "f") return { type: "toggle-menu-fetch-status" }; - if (lower === "t") return { type: "set-menu-quota-ttl", ttlMs: menuQuotaTtlMs }; + if (lower === "t") + return { type: "set-menu-quota-ttl", ttlMs: menuQuotaTtlMs }; const parsed = Number.parseInt(raw, 10); - if (Number.isFinite(parsed) && parsed >= 1 && parsed <= AUTO_RETURN_OPTIONS_MS.length) { + if ( + Number.isFinite(parsed) && + parsed >= 1 && + parsed <= AUTO_RETURN_OPTIONS_MS.length + ) { const delayMs = AUTO_RETURN_OPTIONS_MS[parsed - 1]; - if (typeof delayMs === "number") return { type: "set-delay", delayMs }; + if (typeof delayMs === "number") + return { type: "set-delay", delayMs }; } return undefined; }, @@ -1627,11 +2060,17 @@ async function promptBehaviorSettings( continue; } if (result.type === "set-menu-quota-ttl") { - const currentIndex = MENU_QUOTA_TTL_OPTIONS_MS.findIndex((value) => value === menuQuotaTtlMs); - const nextIndex = currentIndex < 0 - ? 0 - : (currentIndex + 1) % MENU_QUOTA_TTL_OPTIONS_MS.length; - const nextTtl = MENU_QUOTA_TTL_OPTIONS_MS[nextIndex] ?? MENU_QUOTA_TTL_OPTIONS_MS[0] ?? menuQuotaTtlMs; + const currentIndex = MENU_QUOTA_TTL_OPTIONS_MS.findIndex( + (value) => value === menuQuotaTtlMs, + ); + const nextIndex = + currentIndex < 0 + ? 0 + : (currentIndex + 1) % MENU_QUOTA_TTL_OPTIONS_MS.length; + const nextTtl = + MENU_QUOTA_TTL_OPTIONS_MS[nextIndex] ?? + MENU_QUOTA_TTL_OPTIONS_MS[0] ?? + menuQuotaTtlMs; draft = { ...draft, menuQuotaTtlMs: nextTtl, @@ -1661,33 +2100,61 @@ async function promptThemeSettings( const ui = getUiRuntimeOptions(); const palette = draft.uiThemePreset ?? "green"; const accent = draft.uiAccentColor ?? "green"; - const paletteItems: MenuItem[] = THEME_PRESET_OPTIONS.map((candidate, index) => { - const color: MenuItem["color"] = palette === candidate ? "green" : "yellow"; - return { - label: `${palette === candidate ? "[x]" : "[ ]"} ${index + 1}. ${candidate === "green" ? "Green base" : "Blue base"}`, - hint: candidate === "green" ? "High-contrast default." : "Codex-style blue look.", - value: { type: "set-palette", palette: candidate }, - color, - }; - }); - const accentItems: MenuItem[] = ACCENT_COLOR_OPTIONS.map((candidate) => { - const color: MenuItem["color"] = accent === candidate ? "green" : "yellow"; - return { - label: `${accent === candidate ? "[x]" : "[ ]"} ${candidate}`, - value: { type: "set-accent", accent: candidate }, - color, - }; - }); + const paletteItems: MenuItem[] = + THEME_PRESET_OPTIONS.map((candidate, index) => { + const color: MenuItem["color"] = + palette === candidate ? "green" : "yellow"; + return { + label: `${palette === candidate ? "[x]" : "[ ]"} ${index + 1}. ${candidate === "green" ? "Green base" : "Blue base"}`, + hint: + candidate === "green" + ? "High-contrast default." + : "Codex-style blue look.", + value: { type: "set-palette", palette: candidate }, + color, + }; + }); + const accentItems: MenuItem[] = ACCENT_COLOR_OPTIONS.map( + (candidate) => { + const color: MenuItem["color"] = + accent === candidate ? "green" : "yellow"; + return { + label: `${accent === candidate ? "[x]" : "[ ]"} ${candidate}`, + value: { type: "set-accent", accent: candidate }, + color, + }; + }, + ); const items: MenuItem[] = [ - { label: UI_COPY.settings.baseTheme, value: { type: "cancel" }, kind: "heading" }, + { + label: UI_COPY.settings.baseTheme, + value: { type: "cancel" }, + kind: "heading", + }, ...paletteItems, { label: "", value: { type: "cancel" }, separator: true }, - { label: UI_COPY.settings.accentColor, value: { type: "cancel" }, kind: "heading" }, + { + label: UI_COPY.settings.accentColor, + value: { type: "cancel" }, + kind: "heading", + }, ...accentItems, { label: "", value: { type: "cancel" }, separator: true }, - { label: UI_COPY.settings.resetDefault, value: { type: "reset" }, color: "yellow" }, - { label: UI_COPY.settings.saveAndBack, value: { type: "save" }, color: "green" }, - { label: UI_COPY.settings.backNoSave, value: { type: "cancel" }, color: "red" }, + { + label: UI_COPY.settings.resetDefault, + value: { type: "reset" }, + color: "yellow", + }, + { + label: UI_COPY.settings.saveAndBack, + value: { type: "save" }, + color: "green", + }, + { + label: UI_COPY.settings.backNoSave, + value: { type: "cancel" }, + color: "red", + }, ]; const initialCursor = items.findIndex((item) => { const value = item.value; @@ -1751,18 +2218,26 @@ function resolveFocusedBackendNumberKey( focus: BackendSettingFocusKey, numberOptions: BackendNumberSettingOption[] = BACKEND_NUMBER_OPTIONS, ): BackendNumberSettingKey { - const numberKeys = new Set(numberOptions.map((option) => option.key)); + const numberKeys = new Set( + numberOptions.map((option) => option.key), + ); if (focus && numberKeys.has(focus as BackendNumberSettingKey)) { return focus as BackendNumberSettingKey; } return numberOptions[0]?.key ?? "fetchTimeoutMs"; } -function getBackendCategory(key: BackendCategoryKey): BackendCategoryOption | null { - return BACKEND_CATEGORY_OPTIONS.find((category) => category.key === key) ?? null; +function getBackendCategory( + key: BackendCategoryKey, +): BackendCategoryOption | null { + return ( + BACKEND_CATEGORY_OPTIONS.find((category) => category.key === key) ?? null + ); } -function getBackendCategoryInitialFocus(category: BackendCategoryOption): BackendSettingFocusKey { +function getBackendCategoryInitialFocus( + category: BackendCategoryOption, +): BackendSettingFocusKey { const firstToggle = category.toggleKeys[0]; if (firstToggle) return firstToggle; return category.numberKeys[0] ?? null; @@ -1809,33 +2284,45 @@ async function promptBackendCategorySettings( while (true) { const preview = buildBackendSettingsPreview(draft, ui, focusKey); - const toggleItems: MenuItem[] = toggleOptions.map((option, index) => { - const enabled = draft[option.key] ?? BACKEND_DEFAULTS[option.key] ?? false; - return { - label: `${formatDashboardSettingState(enabled)} ${index + 1}. ${option.label}`, - hint: option.description, - value: { type: "toggle", key: option.key }, - color: enabled ? "green" : "yellow", - }; - }); - const numberItems: MenuItem[] = numberOptions.map((option) => { - const rawValue = draft[option.key] ?? BACKEND_DEFAULTS[option.key] ?? option.min; - const numericValue = typeof rawValue === "number" && Number.isFinite(rawValue) - ? rawValue - : option.min; - const clampedValue = clampBackendNumber(option, numericValue); - const valueLabel = formatBackendNumberValue(option, clampedValue); - return { - label: `${option.label}: ${valueLabel}`, - hint: `${option.description} Step ${formatBackendNumberValue(option, option.step)}.`, - value: { type: "bump", key: option.key, direction: 1 }, - color: "yellow", - }; - }); + const toggleItems: MenuItem[] = + toggleOptions.map((option, index) => { + const enabled = + draft[option.key] ?? BACKEND_DEFAULTS[option.key] ?? false; + return { + label: `${formatDashboardSettingState(enabled)} ${index + 1}. ${option.label}`, + hint: option.description, + value: { type: "toggle", key: option.key }, + color: enabled ? "green" : "yellow", + }; + }); + const numberItems: MenuItem[] = + numberOptions.map((option) => { + const rawValue = + draft[option.key] ?? BACKEND_DEFAULTS[option.key] ?? option.min; + const numericValue = + typeof rawValue === "number" && Number.isFinite(rawValue) + ? rawValue + : option.min; + const clampedValue = clampBackendNumber(option, numericValue); + const valueLabel = formatBackendNumberValue(option, clampedValue); + return { + label: `${option.label}: ${valueLabel}`, + hint: `${option.description} Step ${formatBackendNumberValue(option, option.step)}.`, + value: { type: "bump", key: option.key, direction: 1 }, + color: "yellow", + }; + }); - const focusedNumberKey = resolveFocusedBackendNumberKey(focusKey, numberOptions); + const focusedNumberKey = resolveFocusedBackendNumberKey( + focusKey, + numberOptions, + ); const items: MenuItem[] = [ - { label: UI_COPY.settings.previewHeading, value: { type: "back" }, kind: "heading" }, + { + label: UI_COPY.settings.previewHeading, + value: { type: "back" }, + kind: "heading", + }, { label: preview.label, hint: preview.hint, @@ -1845,10 +2332,18 @@ async function promptBackendCategorySettings( hideUnavailableSuffix: true, }, { label: "", value: { type: "back" }, separator: true }, - { label: UI_COPY.settings.backendToggleHeading, value: { type: "back" }, kind: "heading" }, + { + label: UI_COPY.settings.backendToggleHeading, + value: { type: "back" }, + kind: "heading", + }, ...toggleItems, { label: "", value: { type: "back" }, separator: true }, - { label: UI_COPY.settings.backendNumberHeading, value: { type: "back" }, kind: "heading" }, + { + label: UI_COPY.settings.backendNumberHeading, + value: { type: "back" }, + kind: "heading", + }, ...numberItems, ]; @@ -1867,13 +2362,24 @@ async function promptBackendCategorySettings( } items.push({ label: "", value: { type: "back" }, separator: true }); - items.push({ label: UI_COPY.settings.backendResetCategory, value: { type: "reset-category" }, color: "yellow" }); - items.push({ label: UI_COPY.settings.backendBackToCategories, value: { type: "back" }, color: "red" }); + items.push({ + label: UI_COPY.settings.backendResetCategory, + value: { type: "reset-category" }, + color: "yellow", + }); + items.push({ + label: UI_COPY.settings.backendBackToCategories, + value: { type: "back" }, + color: "red", + }); const initialCursor = items.findIndex((item) => { - if (item.separator || item.disabled || item.kind === "heading") return false; - if (item.value.type === "toggle" && focusKey === item.value.key) return true; - if (item.value.type === "bump" && focusKey === item.value.key) return true; + if (item.separator || item.disabled || item.kind === "heading") + return false; + if (item.value.type === "toggle" && focusKey === item.value.key) + return true; + if (item.value.type === "bump" && focusKey === item.value.key) + return true; return false; }); @@ -1887,7 +2393,10 @@ async function promptBackendCategorySettings( initialCursor: initialCursor >= 0 ? initialCursor : undefined, onCursorChange: ({ cursor }) => { const focusedItem = items[cursor]; - if (focusedItem?.value.type === "toggle" || focusedItem?.value.type === "bump") { + if ( + focusedItem?.value.type === "toggle" || + focusedItem?.value.type === "bump" + ) { focusKey = focusedItem.value.key; } }, @@ -1895,14 +2404,32 @@ async function promptBackendCategorySettings( const lower = raw.toLowerCase(); if (lower === "q") return { type: "back" }; if (lower === "r") return { type: "reset-category" }; - if (numberOptions.length > 0 && (lower === "+" || lower === "=" || lower === "]" || lower === "d")) { - return { type: "bump", key: resolveFocusedBackendNumberKey(focusKey, numberOptions), direction: 1 }; + if ( + numberOptions.length > 0 && + (lower === "+" || lower === "=" || lower === "]" || lower === "d") + ) { + return { + type: "bump", + key: resolveFocusedBackendNumberKey(focusKey, numberOptions), + direction: 1, + }; } - if (numberOptions.length > 0 && (lower === "-" || lower === "[" || lower === "a")) { - return { type: "bump", key: resolveFocusedBackendNumberKey(focusKey, numberOptions), direction: -1 }; + if ( + numberOptions.length > 0 && + (lower === "-" || lower === "[" || lower === "a") + ) { + return { + type: "bump", + key: resolveFocusedBackendNumberKey(focusKey, numberOptions), + direction: -1, + }; } const parsed = Number.parseInt(raw, 10); - if (Number.isFinite(parsed) && parsed >= 1 && parsed <= toggleOptions.length) { + if ( + Number.isFinite(parsed) && + parsed >= 1 && + parsed <= toggleOptions.length + ) { const target = toggleOptions[parsed - 1]; if (target) return { type: "toggle", key: target.key }; } @@ -1919,7 +2446,8 @@ async function promptBackendCategorySettings( continue; } if (result.type === "toggle") { - const currentValue = draft[result.key] ?? BACKEND_DEFAULTS[result.key] ?? false; + const currentValue = + draft[result.key] ?? BACKEND_DEFAULTS[result.key] ?? false; draft = { ...draft, [result.key]: !currentValue }; focusKey = result.key; continue; @@ -1927,13 +2455,18 @@ async function promptBackendCategorySettings( const option = BACKEND_NUMBER_OPTION_BY_KEY.get(result.key); if (!option) continue; - const currentValue = draft[result.key] ?? BACKEND_DEFAULTS[result.key] ?? option.min; - const numericCurrent = typeof currentValue === "number" && Number.isFinite(currentValue) - ? currentValue - : option.min; + const currentValue = + draft[result.key] ?? BACKEND_DEFAULTS[result.key] ?? option.min; + const numericCurrent = + typeof currentValue === "number" && Number.isFinite(currentValue) + ? currentValue + : option.min; draft = { ...draft, - [result.key]: clampBackendNumber(option, numericCurrent + option.step * result.direction), + [result.key]: clampBackendNumber( + option, + numericCurrent + option.step * result.direction, + ), }; focusKey = result.key; } @@ -1947,7 +2480,9 @@ async function promptBackendSettings( const ui = getUiRuntimeOptions(); let draft = cloneBackendPluginConfig(initial); let activeCategory = BACKEND_CATEGORY_OPTIONS[0]?.key ?? "session-sync"; - const focusByCategory: Partial> = {}; + const focusByCategory: Partial< + Record + > = {}; for (const category of BACKEND_CATEGORY_OPTIONS) { focusByCategory[category.key] = getBackendCategoryInitialFocus(category); } @@ -1955,17 +2490,22 @@ async function promptBackendSettings( while (true) { const previewFocus = focusByCategory[activeCategory] ?? null; const preview = buildBackendSettingsPreview(draft, ui, previewFocus); - const categoryItems: MenuItem[] = BACKEND_CATEGORY_OPTIONS.map((category, index) => { - return { - label: `${index + 1}. ${category.label}`, - hint: category.description, - value: { type: "open-category", key: category.key }, - color: "green", - }; - }); + const categoryItems: MenuItem[] = + BACKEND_CATEGORY_OPTIONS.map((category, index) => { + return { + label: `${index + 1}. ${category.label}`, + hint: category.description, + value: { type: "open-category", key: category.key }, + color: "green", + }; + }); const items: MenuItem[] = [ - { label: UI_COPY.settings.previewHeading, value: { type: "cancel" }, kind: "heading" }, + { + label: UI_COPY.settings.previewHeading, + value: { type: "cancel" }, + kind: "heading", + }, { label: preview.label, hint: preview.hint, @@ -1975,17 +2515,36 @@ async function promptBackendSettings( hideUnavailableSuffix: true, }, { label: "", value: { type: "cancel" }, separator: true }, - { label: UI_COPY.settings.backendCategoriesHeading, value: { type: "cancel" }, kind: "heading" }, + { + label: UI_COPY.settings.backendCategoriesHeading, + value: { type: "cancel" }, + kind: "heading", + }, ...categoryItems, { label: "", value: { type: "cancel" }, separator: true }, - { label: UI_COPY.settings.resetDefault, value: { type: "reset" }, color: "yellow" }, - { label: UI_COPY.settings.saveAndBack, value: { type: "save" }, color: "green" }, - { label: UI_COPY.settings.backNoSave, value: { type: "cancel" }, color: "red" }, + { + label: UI_COPY.settings.resetDefault, + value: { type: "reset" }, + color: "yellow", + }, + { + label: UI_COPY.settings.saveAndBack, + value: { type: "save" }, + color: "green", + }, + { + label: UI_COPY.settings.backNoSave, + value: { type: "cancel" }, + color: "red", + }, ]; const initialCursor = items.findIndex((item) => { - if (item.separator || item.disabled || item.kind === "heading") return false; - return item.value.type === "open-category" && item.value.key === activeCategory; + if (item.separator || item.disabled || item.kind === "heading") + return false; + return ( + item.value.type === "open-category" && item.value.key === activeCategory + ); }); const result = await select(items, { @@ -2008,7 +2567,11 @@ async function promptBackendSettings( if (lower === "s") return { type: "save" }; if (lower === "r") return { type: "reset" }; const parsed = Number.parseInt(raw, 10); - if (Number.isFinite(parsed) && parsed >= 1 && parsed <= BACKEND_CATEGORY_OPTIONS.length) { + if ( + Number.isFinite(parsed) && + parsed >= 1 && + parsed <= BACKEND_CATEGORY_OPTIONS.length + ) { const target = BACKEND_CATEGORY_OPTIONS[parsed - 1]; if (target) return { type: "open-category", key: target.key }; } @@ -2021,7 +2584,8 @@ async function promptBackendSettings( if (result.type === "reset") { draft = cloneBackendPluginConfig(BACKEND_DEFAULTS); for (const category of BACKEND_CATEGORY_OPTIONS) { - focusByCategory[category.key] = getBackendCategoryInitialFocus(category); + focusByCategory[category.key] = + getBackendCategoryInitialFocus(category); } activeCategory = BACKEND_CATEGORY_OPTIONS[0]?.key ?? activeCategory; continue; @@ -2040,6 +2604,115 @@ 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; + }> => { + const current = await loadAccounts(); + const state = await loadCodexCliState({ forceRefresh }); + const preview = await previewCodexCliSync(current, { + forceRefresh, + storageBackupEnabled: getStorageBackupEnabled(config), + }); + return { + preview, + context: resolveSyncCenterContext(state), + }; + }; + + let { preview, context } = await buildPreview(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") return { type: "apply" }; + return undefined; + }, + }); + + if (!result || result.type === "back") return; + if (result.type === "refresh") { + ({ preview, context } = await buildPreview(true)); + continue; + } + + try { + const current = await loadAccounts(); + const synced = await syncAccountStorageFromCodexCli(current); + if (synced.changed && synced.storage) { + await saveAccounts(synced.storage); + } + const state = await loadCodexCliState({ forceRefresh: true }); + preview = await previewCodexCliSync(synced.storage ?? current, { + forceRefresh: true, + storageBackupEnabled: getStorageBackupEnabled(config), + }); + context = resolveSyncCenterContext(state); + } catch (error) { + preview = { + ...preview, + status: "error", + statusDetail: error instanceof Error ? error.message : String(error), + }; + context = resolveSyncCenterContext(null); + } + } +} + async function configureBackendSettings( currentConfig?: PluginConfig, ): Promise { @@ -2062,20 +2735,64 @@ async function promptSettingsHub( if (!input.isTTY || !output.isTTY) return null; const ui = getUiRuntimeOptions(); const items: MenuItem[] = [ - { label: UI_COPY.settings.sectionTitle, value: { type: "back" }, kind: "heading" }, - { label: UI_COPY.settings.accountList, value: { type: "account-list" }, color: "green" }, - { label: UI_COPY.settings.summaryFields, value: { type: "summary-fields" }, color: "green" }, - { label: UI_COPY.settings.behavior, value: { type: "behavior" }, color: "green" }, - { label: UI_COPY.settings.theme, value: { type: "theme" }, color: "green" }, + { + label: UI_COPY.settings.sectionTitle, + value: { type: "back" }, + kind: "heading", + }, + { + 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, + 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.backend, value: { type: "backend" }, color: "green" }, + { + 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.backend, + hint: UI_COPY.settings.backendHint, + value: { type: "backend" }, + color: "yellow", + }, { label: "", value: { type: "back" }, separator: true }, - { label: UI_COPY.settings.exitTitle, value: { type: "back" }, kind: "heading" }, + { + label: UI_COPY.settings.exitTitle, + value: { type: "back" }, + kind: "heading", + }, { label: UI_COPY.settings.back, value: { type: "back" }, color: "red" }, ]; 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 item.value.type === initialFocus; }); return select(items, { @@ -2099,7 +2816,9 @@ async function promptSettingsHub( async function configureUnifiedSettings( initialSettings?: DashboardDisplaySettings, ): Promise { - let current = cloneDashboardSettings(initialSettings ?? await loadDashboardDisplaySettings()); + let current = cloneDashboardSettings( + initialSettings ?? (await loadDashboardDisplaySettings()), + ); let backendConfig = cloneBackendPluginConfig(loadPluginConfig()); applyUiThemeFromDashboardSettings(current); let hubFocus: SettingsHubAction["type"] = "account-list"; @@ -2113,6 +2832,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; @@ -2120,14 +2843,22 @@ async function configureUnifiedSettings( if (action.type === "behavior") { const selected = await promptBehaviorSettings(current); if (selected && !dashboardSettingsEqual(current, selected)) { - current = await persistDashboardSettingsSelection(selected, BEHAVIOR_PANEL_KEYS, "behavior"); + current = await persistDashboardSettingsSelection( + selected, + BEHAVIOR_PANEL_KEYS, + "behavior", + ); } continue; } if (action.type === "theme") { const selected = await promptThemeSettings(current); if (selected && !dashboardSettingsEqual(current, selected)) { - current = await persistDashboardSettingsSelection(selected, THEME_PANEL_KEYS, "theme"); + current = await persistDashboardSettingsSelection( + selected, + THEME_PANEL_KEYS, + "theme", + ); applyUiThemeFromDashboardSettings(current); } continue; @@ -2138,5 +2869,9 @@ async function configureUnifiedSettings( } } -export { configureUnifiedSettings, applyUiThemeFromDashboardSettings, resolveMenuLayoutMode, __testOnly }; - +export { + configureUnifiedSettings, + applyUiThemeFromDashboardSettings, + resolveMenuLayoutMode, + __testOnly, +}; diff --git a/lib/live-account-sync.ts b/lib/live-account-sync.ts index 245be892..24ab0dae 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,36 @@ 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, +}; + +export function getLastLiveAccountSyncSnapshot(): LiveAccountSyncSnapshot { + return { ...lastLiveAccountSyncSnapshot }; +} + +export function __resetLastLiveAccountSyncSnapshotForTests(): void { + lastLiveAccountSyncSnapshot = { ...EMPTY_LIVE_ACCOUNT_SYNC_SNAPSHOT }; +} + /** * 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"); @@ -77,10 +100,17 @@ export class LiveAccountSync { private errorCount = 0; private reloadInFlight: Promise | null = null; - constructor(reload: () => Promise, options: LiveAccountSyncOptions = {}) { + constructor( + reload: () => Promise, + options: LiveAccountSyncOptions = {}, + ) { 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), + ); + this.publishSnapshot(); } async syncToPath(path: string): Promise { @@ -94,17 +124,21 @@ export class LiveAccountSync { const targetName = basename(path); try { - 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"); - } - }); + 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"); + } + }, + ); } catch (error) { this.errorCount += 1; log.warn("Failed to start fs.watch for account storage", { @@ -116,11 +150,16 @@ 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 { @@ -137,6 +176,7 @@ export class LiveAccountSync { clearTimeout(this.debounceTimer); this.debounceTimer = null; } + this.publishSnapshot(); } getSnapshot(): LiveAccountSyncSnapshot { @@ -150,6 +190,10 @@ export class LiveAccountSync { }; } + private publishSnapshot(): void { + lastLiveAccountSyncSnapshot = this.getSnapshot(); + } + private scheduleReload(reason: "watch" | "poll"): void { if (!this.running) return; if (this.debounceTimer) { @@ -174,6 +218,7 @@ export class LiveAccountSync { path: summarizeWatchPath(this.currentPath), error: error instanceof Error ? error.message : String(error), }); + this.publishSnapshot(); } } @@ -209,6 +254,7 @@ export class LiveAccountSync { await this.reloadInFlight; } finally { this.reloadInFlight = null; + this.publishSnapshot(); } } } diff --git a/lib/storage.ts b/lib/storage.ts index 344f97b8..1fbb87fe 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -518,6 +518,11 @@ export interface BackupRestoreAssessment { error?: string; } +export interface ActionableNamedBackupRecoveries { + assessments: Awaited>[]; + totalBackups: number; +} + export function getLastAccountsSaveTimestamp(): number { return lastAccountsSaveTimestamp; } @@ -1312,6 +1317,38 @@ export function getNamedBackupsDirectoryPath(): string { return getNamedBackupsDirectory(); } +export async function getActionableNamedBackupRestores( + options: { + currentStorage?: AccountStorageV3 | null; + backups?: NamedBackupMetadata[]; + assess?: typeof assessNamedBackupRestore; + } = {}, +): Promise { + const backups = options.backups ?? (await listNamedBackups()); + if (backups.length === 0) { + return { assessments: [], totalBackups: 0 }; + } + + const currentStorage = + options.currentStorage === undefined + ? await loadAccounts() + : options.currentStorage; + const assess = options.assess ?? assessNamedBackupRestore; + const assessments = await Promise.all( + backups.map((backup) => assess(backup.name, { currentStorage })), + ); + + const actionable = assessments.filter( + (assessment) => + assessment.valid && + !assessment.wouldExceedLimit && + assessment.imported !== null && + assessment.imported > 0, + ); + + return { assessments: actionable, totalBackups: backups.length }; +} + export async function createNamedBackup( name: string, options: { force?: boolean } = {}, diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 8aec4ff3..3c71f1ef 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -62,41 +62,62 @@ export const UI_COPY = { }, settings: { title: "Settings", - subtitle: "Customize menu, behavior, and backend", + 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", - backend: "Backend Controls", + 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.", + 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", + 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: "Account Details Row", - summarySubtitle: "Choose and order detail fields", + 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: "Return Behavior", - behaviorSubtitle: "Control how result screens return", + 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: "Color Theme", - themeSubtitle: "Pick base color and accent", + 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", + 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: diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 11db5fa4..1f7add2b 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -2,881 +2,1011 @@ import { mkdtemp, readFile, rm, 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 codexCliState from "../lib/codex-cli/state.js"; import { clearCodexCliStateCache } from "../lib/codex-cli/state.js"; import { - getActiveSelectionForFamily, - syncAccountStorageFromCodexCli, + __resetLastCodexCliSyncRunForTests, + getActiveSelectionForFamily, + getLastCodexCliSyncRun, + previewCodexCliSync, + syncAccountStorageFromCodexCli, } from "../lib/codex-cli/sync.js"; import { setCodexCliActiveSelection } from "../lib/codex-cli/writer.js"; import { MODEL_FAMILIES } from "../lib/prompts/codex.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; describe("codex-cli sync", () => { - let tempDir: string; - let accountsPath: string; - let authPath: string; - let configPath: string; - let previousPath: string | undefined; - let previousAuthPath: string | undefined; - let previousConfigPath: string | undefined; - let previousSync: string | undefined; - let previousEnforceFileStore: string | undefined; - - beforeEach(async () => { - previousPath = process.env.CODEX_CLI_ACCOUNTS_PATH; - previousAuthPath = process.env.CODEX_CLI_AUTH_PATH; - previousConfigPath = process.env.CODEX_CLI_CONFIG_PATH; - previousSync = process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; - previousEnforceFileStore = - process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE; - tempDir = await mkdtemp(join(tmpdir(), "codex-multi-auth-sync-")); - accountsPath = join(tempDir, "accounts.json"); - authPath = join(tempDir, "auth.json"); - configPath = join(tempDir, "config.toml"); - process.env.CODEX_CLI_ACCOUNTS_PATH = accountsPath; - process.env.CODEX_CLI_AUTH_PATH = authPath; - process.env.CODEX_CLI_CONFIG_PATH = configPath; - process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "1"; - process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = "1"; - clearCodexCliStateCache(); - }); - - afterEach(async () => { - clearCodexCliStateCache(); - if (previousPath === undefined) delete process.env.CODEX_CLI_ACCOUNTS_PATH; - else process.env.CODEX_CLI_ACCOUNTS_PATH = previousPath; - if (previousAuthPath === undefined) delete process.env.CODEX_CLI_AUTH_PATH; - else process.env.CODEX_CLI_AUTH_PATH = previousAuthPath; - if (previousConfigPath === undefined) delete process.env.CODEX_CLI_CONFIG_PATH; - else process.env.CODEX_CLI_CONFIG_PATH = previousConfigPath; - if (previousSync === undefined) - delete process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; - else process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = previousSync; - if (previousEnforceFileStore === undefined) { - delete process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE; - } else { - process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = - previousEnforceFileStore; - } - await rm(tempDir, { recursive: true, force: true }); - }); - - it("merges Codex CLI accounts and sets active index", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - activeAccountId: "acc_c", - accounts: [ - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "b.access.token", - refresh_token: "refresh-b", - }, - }, - }, - { - accountId: "acc_c", - email: "c@example.com", - auth: { - tokens: { - access_token: "c.access.token", - refresh_token: "refresh-c", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b-old", - addedAt: 2, - lastUsed: 2, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(true); - expect(result.storage?.accounts.length).toBe(3); - - const mergedB = result.storage?.accounts.find( - (account) => account.accountId === "acc_b", - ); - expect(mergedB?.refreshToken).toBe("refresh-b"); - - const active = result.storage?.accounts[result.storage.activeIndex ?? 0]; - expect(active?.accountId).toBe("acc_c"); - }); - - it("creates storage from Codex CLI accounts when local storage is missing", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - email: "a@example.com", - active: true, - auth: { - tokens: { - access_token: "a.access.token", - refresh_token: "refresh-a", - }, - }, - }, - { - email: "b@example.com", - auth: { - tokens: { - access_token: "b.access.token", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const result = await syncAccountStorageFromCodexCli(null); - expect(result.changed).toBe(true); - expect(result.storage?.accounts.length).toBe(2); - expect(result.storage?.accounts[0]?.refreshToken).toBe("refresh-a"); - expect(result.storage?.activeIndex).toBe(0); - }); - - it("matches existing account by normalized email", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - email: "user@example.com", - auth: { - tokens: { - access_token: "new.access.token", - refresh_token: "refresh-new", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - email: "USER@EXAMPLE.COM", - refreshToken: "refresh-old", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(true); - expect(result.storage?.accounts.length).toBe(1); - expect(result.storage?.accounts[0]?.refreshToken).toBe("refresh-new"); - }); - - it("returns unchanged storage when sync is disabled", async () => { - process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "0"; - clearCodexCliStateCache(); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(false); - expect(result.storage).toBe(current); - }); - - it("keeps local active selection when local write is newer than codex snapshot", async () => { - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - tokens: { - access_token: "local.access.token", - refresh_token: "local-refresh-token", - account_id: "acc_a", - }, - }, - null, - 2, - ), - "utf-8", - ); - await setCodexCliActiveSelection({ - accountId: "acc_a", - accessToken: "local.access.token", - refreshToken: "local-refresh-token", - }); - - await writeFile( - accountsPath, - JSON.stringify( - { - codexMultiAuthSyncVersion: Date.now() - 120_000, - activeAccountId: "acc_b", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "a.access", - refresh_token: "refresh-a", - }, - }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "b.access", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - clearCodexCliStateCache(); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.storage?.activeIndex).toBe(0); - }); - - it("keeps local active selection when local state is newer by sub-second gap and syncVersion exists", async () => { - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - tokens: { - access_token: "local.access.token", - refresh_token: "local-refresh-token", - account_id: "acc_a", - }, - }, - null, - 2, - ), - "utf-8", - ); - await setCodexCliActiveSelection({ - accountId: "acc_a", - accessToken: "local.access.token", - refreshToken: "local-refresh-token", - }); - - const staleSyncVersion = Date.now() - 500; - await writeFile( - accountsPath, - JSON.stringify( - { - codexMultiAuthSyncVersion: staleSyncVersion, - activeAccountId: "acc_b", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "a.access", - refresh_token: "refresh-a", - }, - }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "b.access", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - clearCodexCliStateCache(); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.storage?.activeIndex).toBe(0); - }); - - it("marks changed when local index normalization mutates storage while codex selection is skipped", async () => { - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - tokens: { - access_token: "local.access.token", - refresh_token: "local-refresh-token", - account_id: "acc_a", - }, - }, - null, - 2, - ), - "utf-8", - ); - await setCodexCliActiveSelection({ - accountId: "acc_a", - accessToken: "local.access.token", - refreshToken: "local-refresh-token", - }); - - await writeFile( - accountsPath, - JSON.stringify( - { - codexMultiAuthSyncVersion: Date.now() - 120_000, - activeAccountId: "acc_b", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "a.access", - refresh_token: "refresh-a", - }, - }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "b.access", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - clearCodexCliStateCache(); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 99, - activeIndexByFamily: { codex: 99 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(true); - expect(result.storage?.activeIndex).toBe(1); - expect(result.storage?.activeIndexByFamily?.codex).toBe(1); - }); - - it("serializes concurrent active-selection writes to keep accounts/auth aligned", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "access-a", - id_token: "id-a", - refresh_token: "refresh-a", - }, - }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "access-b", - id_token: "id-b", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - email: "a@example.com", - tokens: { - access_token: "access-a", - id_token: "id-a", - refresh_token: "refresh-a", - account_id: "acc_a", - }, - }, - null, - 2, - ), - "utf-8", - ); - - const [first, second] = await Promise.all([ - setCodexCliActiveSelection({ accountId: "acc_a" }), - setCodexCliActiveSelection({ accountId: "acc_b" }), - ]); - expect(first).toBe(true); - expect(second).toBe(true); - - const writtenAccounts = JSON.parse( - await readFile(accountsPath, "utf-8"), - ) as { - activeAccountId?: string; - activeEmail?: string; - accounts?: Array<{ accountId?: string; active?: boolean }>; - }; - const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { - email?: string; - tokens?: { account_id?: string }; - }; - - expect(writtenAccounts.activeAccountId).toBe("acc_b"); - expect(writtenAccounts.activeEmail).toBe("b@example.com"); - expect(writtenAccounts.accounts?.[0]?.active).toBe(false); - expect(writtenAccounts.accounts?.[1]?.active).toBe(true); - expect(writtenAuth.tokens?.account_id).toBe("acc_b"); - expect(writtenAuth.email).toBe("b@example.com"); - }); - it("ignores Codex snapshots that do not include refresh tokens", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - access_token: "access-only", - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const result = await syncAccountStorageFromCodexCli(null); - expect(result.changed).toBe(false); - expect(result.storage?.accounts).toHaveLength(0); - expect(result.storage?.activeIndex).toBe(0); - }); - - it("matches existing account by refresh token when accountId is absent", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - email: "updated@example.com", - auth: { - tokens: { - access_token: "new-access", - refresh_token: "refresh-a", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - accountIdSource: "token", - email: "a@example.com", - refreshToken: "refresh-a", - accessToken: "old-access", - enabled: true, - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(true); - expect(result.storage?.accounts[0]?.accessToken).toBe("new-access"); - expect(result.storage?.accounts[0]?.email).toBe("updated@example.com"); - }); - - it("returns unchanged when Codex state and local selection are already aligned", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - activeAccountId: "acc_a", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "access-a", - refresh_token: "refresh-a", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const familyIndexes = Object.fromEntries( - MODEL_FAMILIES.map((family) => [family, 0]), - ); - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - accountIdSource: "token", - email: "a@example.com", - refreshToken: "refresh-a", - accessToken: "access-a", - enabled: true, - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: familyIndexes, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(false); - expect(result.storage).toEqual(current); - }); - - it("returns current storage when state loading throws", async () => { - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const loadSpy = vi - .spyOn(codexCliState, "loadCodexCliState") - .mockRejectedValue(new Error("forced load failure")); - - try { - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(false); - expect(result.storage).toBe(current); - } finally { - loadSpy.mockRestore(); - } - }); - - it("applies active selection using normalized email when accountId is absent", async () => { - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const loadSpy = vi - .spyOn(codexCliState, "loadCodexCliState") - .mockResolvedValue({ - path: "mock", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - accessToken: "a.access.token", - refreshToken: "refresh-a", - }, - { - accountId: "acc_b", - email: "b@example.com", - accessToken: "b.access.token", - refreshToken: "refresh-b", - }, - ], - activeEmail: " B@EXAMPLE.COM ", - }); - - try { - const result = await syncAccountStorageFromCodexCli(current); - expect(result.storage?.activeIndex).toBe(1); - } finally { - loadSpy.mockRestore(); - } - }); - - it("initializes family indexes when local storage omits activeIndexByFamily", async () => { - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 1, - }; - - const loadSpy = vi - .spyOn(codexCliState, "loadCodexCliState") - .mockResolvedValue({ - path: "mock", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - accessToken: "a.access.token", - refreshToken: "refresh-a", - }, - ], - activeAccountId: "acc_a", - syncVersion: undefined, - sourceUpdatedAtMs: undefined, - }); - - try { - const result = await syncAccountStorageFromCodexCli(current); - expect(result.storage?.activeIndex).toBe(0); - for (const family of MODEL_FAMILIES) { - expect(result.storage?.activeIndexByFamily?.[family]).toBe(0); - } - } finally { - loadSpy.mockRestore(); - } - }); - - it("clamps and defaults active selection indexes by model family", () => { - const family = MODEL_FAMILIES[0]; - expect( - getActiveSelectionForFamily( - { - version: 3, - accounts: [], - activeIndex: 99, - activeIndexByFamily: {}, - }, - family, - ), - ).toBe(0); - - expect( - getActiveSelectionForFamily( - { - version: 3, - accounts: [ - { refreshToken: "a", addedAt: 1, lastUsed: 1 }, - { refreshToken: "b", addedAt: 1, lastUsed: 1 }, - ], - activeIndex: 1, - activeIndexByFamily: { [family]: Number.NaN }, - }, - family, - ), - ).toBe(1); - - expect( - getActiveSelectionForFamily( - { - version: 3, - accounts: [ - { refreshToken: "a", addedAt: 1, lastUsed: 1 }, - { refreshToken: "b", addedAt: 1, lastUsed: 1 }, - ], - activeIndex: 1, - activeIndexByFamily: { [family]: -3 }, - }, - family, - ), - ).toBe(0); - }); + let tempDir: string; + let accountsPath: string; + let authPath: string; + let configPath: string; + let previousPath: string | undefined; + let previousAuthPath: string | undefined; + let previousConfigPath: string | undefined; + let previousSync: string | undefined; + let previousEnforceFileStore: string | undefined; + + beforeEach(async () => { + previousPath = process.env.CODEX_CLI_ACCOUNTS_PATH; + previousAuthPath = process.env.CODEX_CLI_AUTH_PATH; + previousConfigPath = process.env.CODEX_CLI_CONFIG_PATH; + previousSync = process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; + previousEnforceFileStore = + process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE; + tempDir = await mkdtemp(join(tmpdir(), "codex-multi-auth-sync-")); + accountsPath = join(tempDir, "accounts.json"); + authPath = join(tempDir, "auth.json"); + configPath = join(tempDir, "config.toml"); + process.env.CODEX_CLI_ACCOUNTS_PATH = accountsPath; + process.env.CODEX_CLI_AUTH_PATH = authPath; + process.env.CODEX_CLI_CONFIG_PATH = configPath; + process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "1"; + process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = "1"; + clearCodexCliStateCache(); + __resetLastCodexCliSyncRunForTests(); + }); + + afterEach(async () => { + 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; + else process.env.CODEX_CLI_AUTH_PATH = previousAuthPath; + if (previousConfigPath === undefined) + delete process.env.CODEX_CLI_CONFIG_PATH; + else process.env.CODEX_CLI_CONFIG_PATH = previousConfigPath; + if (previousSync === undefined) + delete process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; + else process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = previousSync; + if (previousEnforceFileStore === undefined) { + delete process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE; + } else { + process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = + previousEnforceFileStore; + } + await rm(tempDir, { recursive: true, force: true }); + }); + + it("merges Codex CLI accounts and sets active index", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_c", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "b.access.token", + refresh_token: "refresh-b", + }, + }, + }, + { + accountId: "acc_c", + email: "c@example.com", + auth: { + tokens: { + access_token: "c.access.token", + refresh_token: "refresh-c", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "b@example.com", + refreshToken: "refresh-b-old", + addedAt: 2, + lastUsed: 2, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(true); + expect(result.storage?.accounts.length).toBe(3); + + const mergedB = result.storage?.accounts.find( + (account) => account.accountId === "acc_b", + ); + expect(mergedB?.refreshToken).toBe("refresh-b"); + + const active = result.storage?.accounts[result.storage.activeIndex ?? 0]; + expect(active?.accountId).toBe("acc_c"); + }); + + it("creates storage from Codex CLI accounts when local storage is missing", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + email: "a@example.com", + active: true, + auth: { + tokens: { + access_token: "a.access.token", + refresh_token: "refresh-a", + }, + }, + }, + { + email: "b@example.com", + auth: { + tokens: { + access_token: "b.access.token", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const result = await syncAccountStorageFromCodexCli(null); + expect(result.changed).toBe(true); + expect(result.storage?.accounts.length).toBe(2); + expect(result.storage?.accounts[0]?.refreshToken).toBe("refresh-a"); + expect(result.storage?.activeIndex).toBe(0); + }); + + it("matches existing account by normalized email", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + email: "user@example.com", + auth: { + tokens: { + access_token: "new.access.token", + refresh_token: "refresh-new", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + email: "USER@EXAMPLE.COM", + refreshToken: "refresh-old", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(true); + expect(result.storage?.accounts.length).toBe(1); + expect(result.storage?.accounts[0]?.refreshToken).toBe("refresh-new"); + }); + + it("builds a preview summary with destination-only preservation and rollback context", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + 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 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`); + }); + + it("returns unchanged storage when sync is disabled", async () => { + process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "0"; + clearCodexCliStateCache(); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(false); + expect(result.storage).toBe(current); + }); + + it("keeps local active selection when local write is newer than codex snapshot", async () => { + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + access_token: "local.access.token", + refresh_token: "local-refresh-token", + account_id: "acc_a", + }, + }, + null, + 2, + ), + "utf-8", + ); + await setCodexCliActiveSelection({ + accountId: "acc_a", + accessToken: "local.access.token", + refreshToken: "local-refresh-token", + }); + + await writeFile( + accountsPath, + JSON.stringify( + { + codexMultiAuthSyncVersion: Date.now() - 120_000, + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "a.access", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "b.access", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + clearCodexCliStateCache(); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await syncAccountStorageFromCodexCli(current); + expect(result.storage?.activeIndex).toBe(0); + }); + + it("keeps local active selection when local state is newer by sub-second gap and syncVersion exists", async () => { + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + access_token: "local.access.token", + refresh_token: "local-refresh-token", + account_id: "acc_a", + }, + }, + null, + 2, + ), + "utf-8", + ); + await setCodexCliActiveSelection({ + accountId: "acc_a", + accessToken: "local.access.token", + refreshToken: "local-refresh-token", + }); + + const staleSyncVersion = Date.now() - 500; + await writeFile( + accountsPath, + JSON.stringify( + { + codexMultiAuthSyncVersion: staleSyncVersion, + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "a.access", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "b.access", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + clearCodexCliStateCache(); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await syncAccountStorageFromCodexCli(current); + expect(result.storage?.activeIndex).toBe(0); + }); + + it("marks changed when local index normalization mutates storage while codex selection is skipped", async () => { + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + access_token: "local.access.token", + refresh_token: "local-refresh-token", + account_id: "acc_a", + }, + }, + null, + 2, + ), + "utf-8", + ); + await setCodexCliActiveSelection({ + accountId: "acc_a", + accessToken: "local.access.token", + refreshToken: "local-refresh-token", + }); + + await writeFile( + accountsPath, + JSON.stringify( + { + codexMultiAuthSyncVersion: Date.now() - 120_000, + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "a.access", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "b.access", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + clearCodexCliStateCache(); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 99, + activeIndexByFamily: { codex: 99 }, + }; + + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(true); + expect(result.storage?.activeIndex).toBe(1); + expect(result.storage?.activeIndexByFamily?.codex).toBe(1); + }); + + it("serializes concurrent active-selection writes to keep accounts/auth aligned", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + id_token: "id-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + id_token: "id-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + email: "a@example.com", + tokens: { + access_token: "access-a", + id_token: "id-a", + refresh_token: "refresh-a", + account_id: "acc_a", + }, + }, + null, + 2, + ), + "utf-8", + ); + + const [first, second] = await Promise.all([ + setCodexCliActiveSelection({ accountId: "acc_a" }), + setCodexCliActiveSelection({ accountId: "acc_b" }), + ]); + expect(first).toBe(true); + expect(second).toBe(true); + + const writtenAccounts = JSON.parse( + await readFile(accountsPath, "utf-8"), + ) as { + activeAccountId?: string; + activeEmail?: string; + accounts?: Array<{ accountId?: string; active?: boolean }>; + }; + const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { + email?: string; + tokens?: { account_id?: string }; + }; + + expect(writtenAccounts.activeAccountId).toBe("acc_b"); + expect(writtenAccounts.activeEmail).toBe("b@example.com"); + expect(writtenAccounts.accounts?.[0]?.active).toBe(false); + expect(writtenAccounts.accounts?.[1]?.active).toBe(true); + expect(writtenAuth.tokens?.account_id).toBe("acc_b"); + expect(writtenAuth.email).toBe("b@example.com"); + }); + it("ignores Codex snapshots that do not include refresh tokens", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + access_token: "access-only", + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const result = await syncAccountStorageFromCodexCli(null); + expect(result.changed).toBe(false); + expect(result.storage?.accounts).toHaveLength(0); + expect(result.storage?.activeIndex).toBe(0); + }); + + it("matches existing account by refresh token when accountId is absent", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + email: "updated@example.com", + auth: { + tokens: { + access_token: "new-access", + refresh_token: "refresh-a", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "old-access", + enabled: true, + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(true); + expect(result.storage?.accounts[0]?.accessToken).toBe("new-access"); + expect(result.storage?.accounts[0]?.email).toBe("updated@example.com"); + }); + + it("returns unchanged when Codex state and local selection are already aligned", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const familyIndexes = Object.fromEntries( + MODEL_FAMILIES.map((family) => [family, 0]), + ); + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + enabled: true, + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: familyIndexes, + }; + + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(false); + expect(result.storage).toEqual(current); + }); + + it("records the last sync run summary after a reconcile", 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 syncAccountStorageFromCodexCli(current); + + 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); + expect(lastRun?.summary.selectionChanged).toBe(true); + }); + + it("returns current storage when state loading throws", async () => { + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const loadSpy = vi + .spyOn(codexCliState, "loadCodexCliState") + .mockRejectedValue(new Error("forced load failure")); + + try { + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(false); + expect(result.storage).toBe(current); + } finally { + loadSpy.mockRestore(); + } + }); + + it("applies active selection using normalized email when accountId is absent", async () => { + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const loadSpy = vi + .spyOn(codexCliState, "loadCodexCliState") + .mockResolvedValue({ + path: "mock", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + accessToken: "a.access.token", + refreshToken: "refresh-a", + }, + { + accountId: "acc_b", + email: "b@example.com", + accessToken: "b.access.token", + refreshToken: "refresh-b", + }, + ], + activeEmail: " B@EXAMPLE.COM ", + }); + + try { + const result = await syncAccountStorageFromCodexCli(current); + expect(result.storage?.activeIndex).toBe(1); + } finally { + loadSpy.mockRestore(); + } + }); + + it("initializes family indexes when local storage omits activeIndexByFamily", async () => { + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + }; + + const loadSpy = vi + .spyOn(codexCliState, "loadCodexCliState") + .mockResolvedValue({ + path: "mock", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + accessToken: "a.access.token", + refreshToken: "refresh-a", + }, + ], + activeAccountId: "acc_a", + syncVersion: undefined, + sourceUpdatedAtMs: undefined, + }); + + try { + const result = await syncAccountStorageFromCodexCli(current); + expect(result.storage?.activeIndex).toBe(0); + for (const family of MODEL_FAMILIES) { + expect(result.storage?.activeIndexByFamily?.[family]).toBe(0); + } + } finally { + loadSpy.mockRestore(); + } + }); + + it("clamps and defaults active selection indexes by model family", () => { + const family = MODEL_FAMILIES[0]; + expect( + getActiveSelectionForFamily( + { + version: 3, + accounts: [], + activeIndex: 99, + activeIndexByFamily: {}, + }, + family, + ), + ).toBe(0); + + expect( + getActiveSelectionForFamily( + { + version: 3, + accounts: [ + { refreshToken: "a", addedAt: 1, lastUsed: 1 }, + { refreshToken: "b", addedAt: 1, lastUsed: 1 }, + ], + activeIndex: 1, + activeIndexByFamily: { [family]: Number.NaN }, + }, + family, + ), + ).toBe(1); + + expect( + getActiveSelectionForFamily( + { + version: 3, + accounts: [ + { refreshToken: "a", addedAt: 1, lastUsed: 1 }, + { refreshToken: "b", addedAt: 1, lastUsed: 1 }, + ], + activeIndex: 1, + activeIndexByFamily: { [family]: -3 }, + }, + family, + ), + ).toBe(0); + }); }); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 31d03bb5..b1a66954 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1,11 +1,15 @@ 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(); @@ -13,6 +17,7 @@ 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(); @@ -21,10 +26,19 @@ const loadQuotaCacheMock = vi.fn(); const saveQuotaCacheMock = vi.fn(); const loadPluginConfigMock = vi.fn(); const savePluginConfigMock = vi.fn(); +const previewCodexCliSyncMock = vi.fn(); +const syncAccountStorageFromCodexCliMock = 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 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 confirmMock = vi.fn(); vi.mock("../lib/logger.js", () => ({ createLogger: vi.fn(() => ({ @@ -37,8 +51,8 @@ vi.mock("../lib/logger.js", () => ({ })); vi.mock("../lib/auth/auth.js", () => ({ - createAuthorizationFlow: vi.fn(), - exchangeAuthorizationCode: vi.fn(), + createAuthorizationFlow: createAuthorizationFlowMock, + exchangeAuthorizationCode: exchangeAuthorizationCodeMock, parseAuthorizationInput: vi.fn(), REDIRECT_URI: "http://localhost:1455/auth/callback", })); @@ -49,10 +63,11 @@ vi.mock("../lib/auth/browser.js", () => ({ })); vi.mock("../lib/auth/server.js", () => ({ - startLocalOAuthServer: vi.fn(), + startLocalOAuthServer: startLocalOAuthServerMock, })); vi.mock("../lib/cli.js", () => ({ + isInteractiveLoginMenuAvailable: isInteractiveLoginMenuAvailableMock, promptAddAnotherAccount: promptAddAnotherAccountMock, promptLoginMode: promptLoginModeMock, })); @@ -92,6 +107,7 @@ vi.mock("../lib/storage.js", () => ({ saveFlaggedAccounts: saveFlaggedAccountsMock, setStoragePath: setStoragePathMock, getStoragePath: getStoragePathMock, + getActionableNamedBackupRestores: getActionableNamedBackupRestoresMock, listNamedBackups: listNamedBackupsMock, assessNamedBackupRestore: assessNamedBackupRestoreMock, getNamedBackupsDirectoryPath: getNamedBackupsDirectoryPathMock, @@ -106,6 +122,23 @@ vi.mock("../lib/codex-cli/writer.js", () => ({ setCodexCliActiveSelection: setCodexCliActiveSelectionMock, })); +vi.mock("../lib/codex-cli/sync.js", () => ({ + previewCodexCliSync: previewCodexCliSyncMock, + syncAccountStorageFromCodexCli: syncAccountStorageFromCodexCliMock, +})); + +vi.mock("../lib/codex-cli/state.js", () => ({ + 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"), @@ -168,6 +201,10 @@ vi.mock("../lib/ui/select.js", () => ({ select: selectMock, })); +vi.mock("../lib/ui/confirm.js", () => ({ + confirm: confirmMock, +})); + const stdinIsTTYDescriptor = Object.getOwnPropertyDescriptor( process.stdin, "isTTY", @@ -230,8 +267,13 @@ describe("codex manager cli commands", () => { saveAccountsMock.mockReset(); saveFlaggedAccountsMock.mockReset(); queuedRefreshMock.mockReset(); + createAuthorizationFlowMock.mockReset(); + exchangeAuthorizationCodeMock.mockReset(); + startLocalOAuthServerMock.mockReset(); setCodexCliActiveSelectionMock.mockReset(); promptAddAnotherAccountMock.mockReset(); + isInteractiveLoginMenuAvailableMock.mockReset(); + isInteractiveLoginMenuAvailableMock.mockReturnValue(true); promptLoginModeMock.mockReset(); fetchCodexQuotaSnapshotMock.mockReset(); loadDashboardDisplaySettingsMock.mockReset(); @@ -240,54 +282,69 @@ describe("codex manager cli commands", () => { saveQuotaCacheMock.mockReset(); loadPluginConfigMock.mockReset(); savePluginConfigMock.mockReset(); + previewCodexCliSyncMock.mockReset(); + syncAccountStorageFromCodexCliMock.mockReset(); + getCodexCliAccountsPathMock.mockReset(); + getCodexCliAuthPathMock.mockReset(); + getCodexCliConfigPathMock.mockReset(); + isCodexCliSyncEnabledMock.mockReset(); + loadCodexCliStateMock.mockReset(); + getLastLiveAccountSyncSnapshotMock.mockReset(); selectMock.mockReset(); deleteAccountAtIndexMock.mockReset(); deleteAccountAtIndexMock.mockResolvedValue(null); - fetchCodexQuotaSnapshotMock.mockResolvedValue({ - status: 200, - model: "gpt-5-codex", - primary: {}, - secondary: {}, - }); - loadQuotaCacheMock.mockResolvedValue({ - byAccountId: {}, - byEmail: {}, - }); - loadFlaggedAccountsMock.mockResolvedValue({ - version: 1, - accounts: [], - }); + getActionableNamedBackupRestoresMock.mockReset(); listNamedBackupsMock.mockReset(); assessNamedBackupRestoreMock.mockReset(); getNamedBackupsDirectoryPathMock.mockReset(); restoreNamedBackupMock.mockReset(); + confirmMock.mockReset(); + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [], + totalBackups: 0, + }); listNamedBackupsMock.mockResolvedValue([]); assessNamedBackupRestoreMock.mockResolvedValue({ backup: { - name: "named-backup", - path: "/mock/backups/named-backup.json", + name: "", + path: "", createdAt: null, updatedAt: null, sizeBytes: null, - version: 3, - accountCount: 1, + version: null, + accountCount: null, schemaErrors: [], - valid: true, - loadError: undefined, + valid: false, + loadError: "", }, currentAccountCount: 0, - mergedAccountCount: 1, - imported: 1, - skipped: 0, + mergedAccountCount: null, + imported: null, + skipped: null, wouldExceedLimit: false, - valid: true, - error: undefined, + valid: false, + error: "", }); getNamedBackupsDirectoryPathMock.mockReturnValue("/mock/backups"); restoreNamedBackupMock.mockResolvedValue({ - imported: 1, + imported: 0, skipped: 0, - total: 1, + total: 0, + }); + confirmMock.mockResolvedValue(false); + fetchCodexQuotaSnapshotMock.mockResolvedValue({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }); + loadQuotaCacheMock.mockResolvedValue({ + byAccountId: {}, + byEmail: {}, + }); + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [], }); loadDashboardDisplaySettingsMock.mockResolvedValue({ showPerAccountRows: true, @@ -303,7 +360,64 @@ describe("codex manager cli commands", () => { }); loadPluginConfigMock.mockReturnValue({}); savePluginConfigMock.mockResolvedValue(undefined); + previewCodexCliSyncMock.mockResolvedValue({ + status: "unavailable", + statusDetail: "No Codex CLI sync source was found.", + sourcePath: 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, + }); + syncAccountStorageFromCodexCliMock.mockResolvedValue({ + changed: false, + storage: 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(), + }); restoreTTYDescriptors(); setStoragePathMock.mockReset(); getStoragePathMock.mockReturnValue("/mock/openai-codex-accounts.json"); @@ -385,6 +499,144 @@ describe("codex manager cli commands", () => { expect(logSpy.mock.calls[0]?.[0]).toContain("Codex Multi-Auth CLI"); }); + it("offers startup restore before OAuth when interactive login starts empty", async () => { + setInteractiveTTY(true); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + const restoredStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "restored@example.com", + refreshToken: "restore-refresh", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + }; + let loadCount = 0; + loadAccountsMock.mockImplementation(async () => { + loadCount += 1; + return loadCount <= 3 + ? structuredClone(emptyStorage) + : structuredClone(restoredStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: Date.now(), + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + valid: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [assessment], + totalBackups: 1, + }); + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("startup-backup"); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + }); + + 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.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("restores healthy flagged accounts into active storage", async () => { const now = Date.now(); loadFlaggedAccountsMock.mockResolvedValueOnce({ @@ -1104,6 +1356,89 @@ describe("codex manager cli commands", () => { expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); }); + it("offers backup recovery before OAuth when actionable backups exist", async () => { + setInteractiveTTY(true); + const now = Date.now(); + 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, + valid: true, + error: undefined, + }; + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [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" }); + const authModule = await import("../lib/auth/auth.js"); + const createAuthorizationFlowMock = vi.mocked( + authModule.createAuthorizationFlow, + ); + 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(createAuthorizationFlowMock).not.toHaveBeenCalled(); + }); + it("keeps login loop running when settings action is selected", async () => { const now = Date.now(); const storage = { @@ -1558,7 +1893,7 @@ describe("codex manager cli commands", () => { ); }); - it("restores a named backup from the login recovery menu", async () => { + it("separates everyday settings from advanced operator controls in the hub", async () => { setInteractiveTTY(true); const now = Date.now(); loadAccountsMock.mockResolvedValue({ @@ -1567,10 +1902,10 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [ { - email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", + email: "settings-groups@example.com", + accountId: "acc_settings_groups", + refreshToken: "refresh-settings-groups", + accessToken: "access-settings-groups", expiresAt: now + 3_600_000, addedAt: now - 1_000, lastUsed: now - 1_000, @@ -1578,51 +1913,230 @@ describe("codex manager cli commands", () => { }, ], }); - 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, - valid: true, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "settings" }) .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - const confirmMock = vi.fn().mockResolvedValue(true); - vi.doMock("../lib/ui/confirm.js", () => ({ confirm: confirmMock })); - vi.resetModules(); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + let firstMenuItems: Array<{ + label?: string; + hint?: string; + color?: string; + }> = []; + let firstMenuOptions: + | { + message?: string; + subtitle?: string; + } + | undefined; + selectMock.mockImplementation(async (items, options) => { + if (firstMenuItems.length === 0) { + firstMenuItems = items as Array<{ + label?: string; + hint?: string; + color?: string; + }>; + firstMenuOptions = options as { + message?: string; + subtitle?: string; + }; + 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(listNamedBackupsMock).toHaveBeenCalledTimes(1); - expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), - }), - }), + expect(firstMenuOptions?.message).toBe("Settings"); + expect(firstMenuOptions?.subtitle).toContain("everyday dashboard settings"); + const menuText = firstMenuItems + .map( + (item) => + `${item.label ?? ""}\n${item.hint ?? ""}\n${item.color ?? ""}`, + ) + .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("Advanced Backend Controls"); + expect(menuText).toContain("Show badges, sorting, and how much detail"); + expect(menuText).toContain("Preview and apply one-way sync"); + expect(menuText).toContain( + "Tune retry, quota, sync, recovery, and timeout internals.", ); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + }); + + it("shows sync-center source/runtime context and keeps apply one-way", async () => { + setInteractiveTTY(true); + const now = Date.now(); + 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); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + loadCodexCliStateMock + .mockResolvedValueOnce({ + path: "/mock/codex/accounts.json", + accounts: [ + { + accountId: "acc_sync", + email: "sync@example.com", + accessToken: "access-sync", + refreshToken: "refresh-sync", + }, + ], + activeAccountId: "acc_sync", + }) + .mockResolvedValueOnce({ + path: "/mock/codex/accounts.json", + accounts: [ + { + accountId: "acc_sync", + email: "sync@example.com", + accessToken: "access-sync", + refreshToken: "refresh-sync", + }, + ], + activeAccountId: "acc_sync", + }); + getLastLiveAccountSyncSnapshotMock.mockReturnValue({ + path: "/mock/openai-codex-accounts.json", + running: true, + lastKnownMtimeMs: now, + lastSyncAt: now, + reloadCount: 2, + errorCount: 0, + }); + previewCodexCliSyncMock + .mockResolvedValueOnce({ + status: "ready", + statusDetail: + "Preview ready: 1 add, 0 update, 1 destination-only preserved.", + 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, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: [ + "/mock/openai-codex-accounts.json.bak", + "/mock/openai-codex-accounts.json.wal", + ], + }, + lastSync: { + outcome: "changed", + runAt: now - 60_000, + }, + }) + .mockResolvedValueOnce({ + status: "noop", + statusDetail: "Target already matches the current one-way sync result.", + sourcePath: "/mock/codex/accounts.json", + 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", + "/mock/openai-codex-accounts.json.wal", + ], + }, + lastSync: { + outcome: "noop", + runAt: now, + }, + }); + syncAccountStorageFromCodexCliMock.mockResolvedValueOnce({ + changed: true, + storage: { + ...storage, + accounts: [ + ...storage.accounts, + { + email: "codex@example.com", + accountId: "acc_codex", + refreshToken: "refresh-codex", + addedAt: now, + lastUsed: now, + }, + ], + }, + }); + + let selectCall = 0; + selectMock.mockImplementation(async (items) => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) { + const text = items + .map( + (item: { label?: string; hint?: string }) => + `${item.label ?? ""}\n${item.hint ?? ""}`, + ) + .join("\n"); + expect(text).toContain("Target path: /mock/openai-codex-accounts.json"); + expect(text).toContain("/mock/codex/accounts.json"); + expect(text).toContain("/mock/codex/auth.json"); + expect(text).toContain("/mock/codex/config.toml"); + expect(text).toContain("Preview mode: read-only until apply"); + expect(text).toContain( + "Selection precedence: accountId -> email -> preserve newer local choice", + ); + expect(text).toContain("One-way sync never deletes accounts"); + expect(text).toContain("Live watcher: running"); + return { type: "apply" }; + } + if (selectCall === 3) 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(syncAccountStorageFromCodexCliMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); }); it.each([ diff --git a/test/live-account-sync.test.ts b/test/live-account-sync.test.ts index fa51e52b..883997f5 100644 --- a/test/live-account-sync.test.ts +++ b/test/live-account-sync.test.ts @@ -1,8 +1,12 @@ -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"; describe("live-account-sync", () => { let workDir = ""; @@ -11,23 +15,68 @@ 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(); + __resetLastLiveAccountSyncSnapshotForTests(); await fs.rm(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("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 +93,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 +121,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 +148,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); @@ -96,12 +172,21 @@ describe("live-account-sync", () => { resolveReload = resolve; }); const reload = vi.fn(async () => reloadStarted); - const sync = new LiveAccountSync(reload, { pollIntervalMs: 500, debounceMs: 50 }); + 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"); const second = invoke("watch"); resolveReload?.(); @@ -111,4 +196,3 @@ describe("live-account-sync", () => { sync.stop(); }); }); - diff --git a/test/recovery.test.ts b/test/recovery.test.ts index 6176d6ac..83ceff63 100644 --- a/test/recovery.test.ts +++ b/test/recovery.test.ts @@ -1,1119 +1,1291 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +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"; 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); 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("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([]); + }); + }); +}); + +describe("getActionableNamedBackupRestores", () => { + it("filters to actionable restores only", async () => { + 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 storage = await import("../lib/storage.js"); + const assess = vi.fn().mockImplementation(async (name: string) => { + if (name === "valid-backup") { + return { + backup: mockBackups[1], + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + valid: true, + error: undefined, + }; + } + + return { + backup: mockBackups[0], + currentAccountCount: 0, + mergedAccountCount: null, + imported: null, + skipped: null, + wouldExceedLimit: false, + valid: false, + error: "invalid", + }; + }); + 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); + }); }); diff --git a/test/settings-hub-utils.test.ts b/test/settings-hub-utils.test.ts index 24b48fbb..97128e90 100644 --- a/test/settings-hub-utils.test.ts +++ b/test/settings-hub-utils.test.ts @@ -1,21 +1,67 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DashboardDisplaySettings } from "../lib/dashboard-settings.js"; import type { PluginConfig } from "../lib/types.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; + state: { + accounts: unknown[]; + } | 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: (settings: DashboardDisplaySettings) => DashboardDisplaySettings; + cloneDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => DashboardDisplaySettings; withQueuedRetry: (pathKey: string, task: () => Promise) => Promise; persistDashboardSettingsSelection: ( selected: DashboardDisplaySettings, keys: ReadonlyArray, scope: string, ) => Promise; - persistBackendConfigSelection: (selected: PluginConfig, scope: string) => Promise; + persistBackendConfigSelection: ( + selected: PluginConfig, + scope: string, + ) => Promise; }; let tempRoot = ""; @@ -32,7 +78,10 @@ beforeEach(() => { tempRoot = mkdtempSync(join(tmpdir(), "codex-settings-hub-test-")); process.env.CODEX_HOME = tempRoot; process.env.CODEX_MULTI_AUTH_DIR = tempRoot; - process.env.CODEX_MULTI_AUTH_CONFIG_PATH = join(tempRoot, "plugin-config.json"); + process.env.CODEX_MULTI_AUTH_CONFIG_PATH = join( + tempRoot, + "plugin-config.json", + ); vi.resetModules(); }); @@ -69,10 +118,71 @@ 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", + state: { accounts: [{}] }, + 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[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("formats layout mode labels", async () => { const api = await loadSettingsHubTestApi(); expect(api.formatMenuLayoutMode("expanded-rows")).toBe("Expanded Rows"); - expect(api.formatMenuLayoutMode("compact-details")).toBe("Compact + Details Pane"); + expect(api.formatMenuLayoutMode("compact-details")).toBe( + "Compact + Details Pane", + ); }); it("clones dashboard settings and protects array references", async () => { @@ -109,25 +219,31 @@ describe("settings-hub utility coverage", () => { it("retries queued writes for EAGAIN filesystem errors", async () => { const api = await loadSettingsHubTestApi(); let attempts = 0; - const result = await api.withQueuedRetry("settings-path-eagain", async () => { - attempts += 1; - if (attempts < 3) { - const error = new Error("busy") as NodeJS.ErrnoException; - error.code = "EAGAIN"; - throw error; - } - return "ok"; - }); + const result = await api.withQueuedRetry( + "settings-path-eagain", + async () => { + attempts += 1; + if (attempts < 3) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EAGAIN"; + throw error; + } + return "ok"; + }, + ); expect(result).toBe("ok"); expect(attempts).toBe(3); }); - it.each(["ENOTEMPTY", "EACCES"] as const)( - "retries queued writes for %s filesystem errors", - async (code) => { - const api = await loadSettingsHubTestApi(); - let attempts = 0; - const result = await api.withQueuedRetry(`settings-path-${code.toLowerCase()}`, async () => { + it.each([ + "ENOTEMPTY", + "EACCES", + ] as const)("retries queued writes for %s filesystem errors", async (code) => { + const api = await loadSettingsHubTestApi(); + let attempts = 0; + const result = await api.withQueuedRetry( + `settings-path-${code.toLowerCase()}`, + async () => { attempts += 1; if (attempts < 3) { const error = new Error("busy") as NodeJS.ErrnoException; @@ -135,11 +251,11 @@ describe("settings-hub utility coverage", () => { throw error; } return "ok"; - }); - expect(result).toBe("ok"); - expect(attempts).toBe(3); - }, - ); + }, + ); + expect(result).toBe("ok"); + expect(attempts).toBe(3); + }); it("serializes concurrent writes for the same path key", async () => { const api = await loadSettingsHubTestApi(); @@ -170,7 +286,12 @@ describe("settings-hub utility coverage", () => { await expect(first).resolves.toBe("first-ok"); await expect(second).resolves.toBe("second-ok"); - expect(order).toEqual(["first:start", "first:end", "second:start", "second:end"]); + expect(order).toEqual([ + "first:start", + "first:end", + "second:start", + "second:end", + ]); }); it("allows concurrent writes for different path keys", async () => { @@ -209,19 +330,22 @@ describe("settings-hub utility coverage", () => { try { let attempts = 0; const retryAfterMs = 120; - const resultPromise = api.withQueuedRetry("settings-path-429", async () => { - attempts += 1; - if (attempts === 1) { - const error = new Error("rate limited") as Error & { - status: number; - retryAfterMs: number; - }; - error.status = 429; - error.retryAfterMs = retryAfterMs; - throw error; - } - return "ok"; - }); + const resultPromise = api.withQueuedRetry( + "settings-path-429", + async () => { + attempts += 1; + if (attempts === 1) { + const error = new Error("rate limited") as Error & { + status: number; + retryAfterMs: number; + }; + error.status = 429; + error.retryAfterMs = retryAfterMs; + throw error; + } + return "ok"; + }, + ); await Promise.resolve(); await Promise.resolve();