From 829a3ea73b92663aa1cb31a9b8267af1c4371916 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 12 Mar 2026 14:29:21 +0800 Subject: [PATCH 01/10] feat(sync): add sync center and status surface --- docs/reference/settings.md | 15 +- lib/codex-cli/sync.ts | 399 ++++-- lib/codex-manager/settings-hub.ts | 1349 ++++++++++++++++----- lib/live-account-sync.ts | 78 +- lib/ui/copy.ts | 10 + test/codex-cli-sync.test.ts | 1868 +++++++++++++++-------------- test/codex-manager-cli.test.ts | 243 ++++ test/live-account-sync.test.ts | 118 +- test/settings-hub-utils.test.ts | 202 +++- 9 files changed, 2957 insertions(+), 1325 deletions(-) diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 1466374b..5667864a 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -59,6 +59,19 @@ Controls display style: - accent color - focus style +### Sync Center + +The settings hub includes 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`) + --- ## Backend Categories @@ -175,4 +188,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/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/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 99cbdae4..99b87966 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,54 @@ 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.sectionTitle, + value: { type: "back" }, + kind: "heading", + }, + { + label: UI_COPY.settings.accountList, + value: { type: "account-list" }, + color: "green", + }, + { + label: UI_COPY.settings.syncCenter, + value: { type: "sync-center" }, + 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: "", 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.backend, + value: { type: "backend" }, + color: "green", + }, { 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 +2806,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 +2822,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 +2833,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 +2859,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/ui/copy.ts b/lib/ui/copy.ts index fe6280e4..981b0a45 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -66,6 +66,7 @@ export const UI_COPY = { advancedTitle: "Advanced", exitTitle: "Back", accountList: "Account List View", + syncCenter: "Sync Center", summaryFields: "Summary Line", behavior: "Menu Behavior", theme: "Color Theme", @@ -95,6 +96,15 @@ export const UI_COPY = { backendSubtitle: "Tune sync, retry, and limit behavior", backendHelp: "Enter Open | 1-4 Category | S Save | R Reset | Q Back (No Save)", + syncCenterTitle: "Sync Center", + syncCenterSubtitle: + "Inspect source paths, watcher status, and one-way preview semantics before applying sync", + 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 334a2bff..c7b99de9 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -17,6 +17,14 @@ 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(); @@ -98,6 +106,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"), @@ -232,6 +257,14 @@ 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); @@ -263,6 +296,48 @@ 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); restoreTTYDescriptors(); setStoragePathMock.mockReset(); @@ -1518,6 +1593,174 @@ describe("codex manager cli commands", () => { ); }); + 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([ { panel: "account-list", mode: "windows-ebusy" }, { panel: "summary-fields", mode: "windows-ebusy" }, 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/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(); From ada724f268e89ca978f9bd760d44eebc7f0442ad Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 21:57:26 +0800 Subject: [PATCH 02/10] fix(sync): harden sync center apply state --- lib/accounts.ts | 8 +- lib/codex-cli/sync.ts | 233 +++++++++++++------- lib/codex-manager/settings-hub.ts | 46 +++- lib/storage.ts | 4 + test/accounts.test.ts | 158 ++++++++++++++ test/codex-cli-sync.test.ts | 164 +++++++++++++- test/codex-manager-cli.test.ts | 344 ++++++++++++++++++++++++++++++ 7 files changed, 875 insertions(+), 82 deletions(-) diff --git a/lib/accounts.ts b/lib/accounts.ts index 40fe38da..e3b86679 100644 --- a/lib/accounts.ts +++ b/lib/accounts.ts @@ -21,7 +21,11 @@ import { loadCodexCliState, type CodexCliTokenCacheEntry, } from "./codex-cli/state.js"; -import { syncAccountStorageFromCodexCli } from "./codex-cli/sync.js"; +import { + commitCodexCliSyncRunFailure, + commitPendingCodexCliSyncRun, + syncAccountStorageFromCodexCli, +} from "./codex-cli/sync.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; export { @@ -115,7 +119,9 @@ export class AccountManager { if (synced.changed && sourceOfTruthStorage) { try { await saveAccounts(sourceOfTruthStorage); + commitPendingCodexCliSyncRun(synced.pendingRun); } catch (error) { + commitCodexCliSyncRunFailure(synced.pendingRun, error); log.debug("Failed to persist Codex CLI source-of-truth sync", { error: String(error), }); diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index eef9ace2..b3b935d8 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -10,7 +10,11 @@ import { incrementCodexCliMetric, makeAccountFingerprint, } from "./observability.js"; -import { type CodexCliAccountSnapshot, loadCodexCliState } from "./state.js"; +import { + type CodexCliAccountSnapshot, + isCodexCliSyncEnabled, + loadCodexCliState, +} from "./state.js"; import { getLastCodexCliSelectionWriteTimestamp } from "./writer.js"; const log = createLogger("codex-cli-sync"); @@ -86,6 +90,11 @@ export interface CodexCliSyncRun { message?: string; } +export interface PendingCodexCliSyncRun { + revision: number; + run: CodexCliSyncRun; +} + type UpsertAction = "skipped" | "added" | "updated" | "unchanged"; interface UpsertResult { @@ -100,6 +109,8 @@ interface ReconcileResult { } let lastCodexCliSyncRun: CodexCliSyncRun | null = null; +let lastCodexCliSyncRunRevision = 0; +let nextCodexCliSyncRunRevision = 0; function createEmptySyncSummary(): CodexCliSyncSummary { return { @@ -114,21 +125,88 @@ function createEmptySyncSummary(): CodexCliSyncSummary { }; } -function setLastCodexCliSyncRun(run: CodexCliSyncRun): void { - lastCodexCliSyncRun = run; +function cloneCodexCliSyncRun(run: CodexCliSyncRun): CodexCliSyncRun { + return { + ...run, + summary: { ...run.summary }, + }; +} + +function allocateCodexCliSyncRunRevision(): number { + nextCodexCliSyncRunRevision += 1; + return nextCodexCliSyncRunRevision; +} + +function publishCodexCliSyncRun( + run: CodexCliSyncRun, + revision: number, +): boolean { + if (revision < lastCodexCliSyncRunRevision) { + return false; + } + lastCodexCliSyncRunRevision = revision; + lastCodexCliSyncRun = cloneCodexCliSyncRun(run); + return true; +} + +function buildSyncRunError( + run: CodexCliSyncRun, + error: unknown, +): CodexCliSyncRun { + return { + ...run, + outcome: "error", + message: error instanceof Error ? error.message : String(error), + }; +} + +function createSyncRun( + run: Omit, +): CodexCliSyncRun { + return { + ...run, + runAt: Date.now(), + }; } export function getLastCodexCliSyncRun(): CodexCliSyncRun | null { - return lastCodexCliSyncRun - ? { - ...lastCodexCliSyncRun, - summary: { ...lastCodexCliSyncRun.summary }, - } - : null; + return lastCodexCliSyncRun ? cloneCodexCliSyncRun(lastCodexCliSyncRun) : null; +} + +export function commitPendingCodexCliSyncRun( + pendingRun: PendingCodexCliSyncRun | null | undefined, +): void { + if (!pendingRun) return; + publishCodexCliSyncRun( + { + ...pendingRun.run, + runAt: Date.now(), + }, + pendingRun.revision, + ); +} + +export function commitCodexCliSyncRunFailure( + pendingRun: PendingCodexCliSyncRun | null | undefined, + error: unknown, +): void { + if (!pendingRun) return; + publishCodexCliSyncRun( + buildSyncRunError( + { + ...pendingRun.run, + runAt: Date.now(), + }, + error, + ), + pendingRun.revision, + ); } export function __resetLastCodexCliSyncRunForTests(): void { lastCodexCliSyncRun = null; + lastCodexCliSyncRunRevision = 0; + nextCodexCliSyncRunRevision = 0; } function buildIndexByAccountId( @@ -436,6 +514,7 @@ export async function previewCodexCliSync( options: { forceRefresh?: boolean; storageBackupEnabled?: boolean } = {}, ): Promise { const targetPath = getStoragePath(); + const syncEnabled = isCodexCliSyncEnabled(); const backup = { enabled: options.storageBackupEnabled ?? true, targetPath, @@ -446,10 +525,7 @@ export async function previewCodexCliSync( 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") { + if (!syncEnabled) { return { status: "disabled", statusDetail: "Codex CLI sync is disabled by environment override.", @@ -460,6 +536,9 @@ export async function previewCodexCliSync( lastSync, }; } + const state = await loadCodexCliState({ + forceRefresh: options.forceRefresh, + }); if (!state) { return { status: "unavailable", @@ -520,67 +599,74 @@ export async function previewCodexCliSync( */ export async function syncAccountStorageFromCodexCli( current: AccountStorageV3 | null, -): Promise<{ storage: AccountStorageV3 | null; changed: boolean }> { +): Promise<{ + storage: AccountStorageV3 | null; + changed: boolean; + pendingRun: PendingCodexCliSyncRun | null; +}> { incrementCodexCliMetric("reconcileAttempts"); const targetPath = getStoragePath(); + const revision = allocateCodexCliSyncRunRevision(); try { + if (!isCodexCliSyncEnabled()) { + incrementCodexCliMetric("reconcileNoops"); + publishCodexCliSyncRun( + createSyncRun({ + outcome: "disabled", + sourcePath: null, + targetPath, + summary: { + ...createEmptySyncSummary(), + targetAccountCountBefore: current?.accounts.length ?? 0, + targetAccountCountAfter: current?.accounts.length ?? 0, + }, + message: "Codex CLI sync disabled by environment override.", + }), + revision, + ); + return { storage: current, changed: false, pendingRun: null }; + } + 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 }; + publishCodexCliSyncRun( + createSyncRun({ + outcome: "unavailable", + sourcePath: null, + targetPath, + summary: { + ...createEmptySyncSummary(), + targetAccountCountBefore: current?.accounts.length ?? 0, + targetAccountCountAfter: current?.accounts.length ?? 0, + }, + message: "No Codex CLI sync source was available.", + }), + revision, + ); + return { storage: current, changed: false, pendingRun: null }; } 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", - accountCount: next.accounts.length, - }); - return { - storage: current ?? next, - changed, - }; - } - - incrementCodexCliMetric(changed ? "reconcileChanges" : "reconcileNoops"); - const activeFromSnapshots = readActiveFromSnapshots(state.accounts); - setLastCodexCliSyncRun({ + const storage = + next.accounts.length === 0 ? (current ?? next) : next; + const syncRun = createSyncRun({ outcome: changed ? "changed" : "noop", - runAt: Date.now(), sourcePath: state.path, targetPath, summary: reconciled.summary, }); + + if (!changed) { + incrementCodexCliMetric("reconcileNoops"); + publishCodexCliSyncRun(syncRun, revision); + } else { + incrementCodexCliMetric("reconcileChanges"); + } + + const activeFromSnapshots = readActiveFromSnapshots(state.accounts); log.debug("Codex CLI reconcile completed", { operation: "reconcile-storage", outcome: changed ? "changed" : "noop", @@ -591,29 +677,32 @@ export async function syncAccountStorageFromCodexCli( }), }); return { - storage: next, + storage, changed, + pendingRun: changed ? { revision, run: syncRun } : null, }; } 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), - }); + publishCodexCliSyncRun( + createSyncRun({ + outcome: "error", + sourcePath: null, + targetPath, + summary: { + ...createEmptySyncSummary(), + targetAccountCountBefore: current?.accounts.length ?? 0, + targetAccountCountAfter: current?.accounts.length ?? 0, + }, + message: error instanceof Error ? error.message : String(error), + }), + revision, + ); log.warn("Codex CLI reconcile failed", { operation: "reconcile-storage", outcome: "error", error: String(error), }); - return { storage: current, changed: false }; + return { storage: current, changed: false, pendingRun: null }; } } diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 99b87966..97ae2ff1 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -8,9 +8,12 @@ import { loadCodexCliState, } from "../codex-cli/state.js"; import { + commitCodexCliSyncRunFailure, + commitPendingCodexCliSyncRun, type CodexCliSyncPreview, type CodexCliSyncRun, type CodexCliSyncSummary, + getLastCodexCliSyncRun, previewCodexCliSync, syncAccountStorageFromCodexCli, } from "../codex-cli/sync.js"; @@ -35,7 +38,12 @@ import { getLastLiveAccountSyncSnapshot, type LiveAccountSyncSnapshot, } from "../live-account-sync.js"; -import { loadAccounts, saveAccounts } from "../storage.js"; +import { + isStorageBackupEnabled, + loadAccounts, + saveAccounts, + setStorageBackupEnabled, +} from "../storage.js"; import type { PluginConfig } from "../types.js"; import { ANSI } from "../ui/ansi.js"; import { UI_COPY } from "../ui/copy.js"; @@ -2679,7 +2687,9 @@ async function promptSyncCenter(config: PluginConfig): Promise { const lower = raw.toLowerCase(); if (lower === "q") return { type: "back" }; if (lower === "r") return { type: "refresh" }; - if (lower === "a") return { type: "apply" }; + if (lower === "a" && preview.status === "ready") { + return { type: "apply" }; + } return undefined; }, }); @@ -2693,22 +2703,44 @@ async function promptSyncCenter(config: PluginConfig): Promise { try { const current = await loadAccounts(); const synced = await syncAccountStorageFromCodexCli(current); + const storageBackupEnabled = getStorageBackupEnabled(config); + const nextStorage = synced.storage ?? current; if (synced.changed && synced.storage) { - await saveAccounts(synced.storage); + const previousStorageBackupEnabled = isStorageBackupEnabled(); + setStorageBackupEnabled(storageBackupEnabled); + try { + await saveAccounts(synced.storage); + commitPendingCodexCliSyncRun(synced.pendingRun); + } catch (error) { + commitCodexCliSyncRunFailure(synced.pendingRun, error); + preview = { + ...preview, + lastSync: getLastCodexCliSyncRun(), + status: "error", + statusDetail: `Failed to save synced storage: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + continue; + } finally { + setStorageBackupEnabled(previousStorageBackupEnabled); + } } const state = await loadCodexCliState({ forceRefresh: true }); - preview = await previewCodexCliSync(synced.storage ?? current, { + preview = await previewCodexCliSync(nextStorage, { forceRefresh: true, - storageBackupEnabled: getStorageBackupEnabled(config), + storageBackupEnabled, }); context = resolveSyncCenterContext(state); } catch (error) { preview = { ...preview, status: "error", - statusDetail: error instanceof Error ? error.message : String(error), + lastSync: getLastCodexCliSyncRun(), + statusDetail: `Failed to refresh sync center: ${ + error instanceof Error ? error.message : String(error) + }`, }; - context = resolveSyncCenterContext(null); } } } diff --git a/lib/storage.ts b/lib/storage.ts index 3453a426..e9fd58f9 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -178,6 +178,10 @@ export function setStorageBackupEnabled(enabled: boolean): void { storageBackupEnabled = enabled; } +export function isStorageBackupEnabled(): boolean { + return storageBackupEnabled; +} + function getAccountsBackupPath(path: string): string { return `${path}${ACCOUNTS_BACKUP_SUFFIX}`; } diff --git a/test/accounts.test.ts b/test/accounts.test.ts index 8328909a..4e2a8024 100644 --- a/test/accounts.test.ts +++ b/test/accounts.test.ts @@ -1,4 +1,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +const { + loadAccountsMock, + syncAccountStorageFromCodexCliMock, + commitPendingCodexCliSyncRunMock, + commitCodexCliSyncRunFailureMock, + loadCodexCliStateMock, +} = vi.hoisted(() => ({ + loadAccountsMock: vi.fn(), + syncAccountStorageFromCodexCliMock: vi.fn(), + commitPendingCodexCliSyncRunMock: vi.fn(), + commitCodexCliSyncRunFailureMock: vi.fn(), + loadCodexCliStateMock: vi.fn(), +})); import { AccountManager, extractAccountEmail, @@ -17,10 +30,47 @@ vi.mock("../lib/storage.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + loadAccounts: loadAccountsMock, saveAccounts: vi.fn().mockResolvedValue(undefined), }; }); +vi.mock("../lib/codex-cli/sync.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + commitCodexCliSyncRunFailure: commitCodexCliSyncRunFailureMock, + commitPendingCodexCliSyncRun: commitPendingCodexCliSyncRunMock, + syncAccountStorageFromCodexCli: syncAccountStorageFromCodexCliMock, + }; +}); + +vi.mock("../lib/codex-cli/state.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadCodexCliState: loadCodexCliStateMock, + }; +}); + +beforeEach(async () => { + const { saveAccounts } = await import("../lib/storage.js"); + loadAccountsMock.mockReset(); + syncAccountStorageFromCodexCliMock.mockReset(); + commitPendingCodexCliSyncRunMock.mockReset(); + commitCodexCliSyncRunFailureMock.mockReset(); + loadCodexCliStateMock.mockReset(); + vi.mocked(saveAccounts).mockReset(); + vi.mocked(saveAccounts).mockResolvedValue(undefined); + loadAccountsMock.mockResolvedValue(null); + syncAccountStorageFromCodexCliMock.mockResolvedValue({ + storage: null, + changed: false, + pendingRun: null, + }); + loadCodexCliStateMock.mockResolvedValue(null); +}); + describe("parseRateLimitReason", () => { it("returns quota for quota-related codes", () => { expect(parseRateLimitReason("usage_limit_reached")).toBe("quota"); @@ -190,6 +240,114 @@ describe("getAccountIdCandidates", () => { }); describe("AccountManager", () => { + it("commits a pending Codex CLI sync run only after loadFromDisk persists storage", async () => { + const now = Date.now(); + const stored = { + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "token-1", addedAt: now, lastUsed: now }, + ], + }; + const syncedStorage = { + ...stored, + accounts: [ + ...stored.accounts, + { refreshToken: "token-2", addedAt: now + 1, lastUsed: now + 1 }, + ], + }; + const pendingRun = { + revision: 1, + run: { + outcome: "changed" as const, + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }; + loadAccountsMock.mockResolvedValue(stored); + syncAccountStorageFromCodexCliMock.mockResolvedValue({ + storage: syncedStorage, + changed: true, + pendingRun, + }); + + const { saveAccounts } = await import("../lib/storage.js"); + const mockSaveAccounts = vi.mocked(saveAccounts); + + await AccountManager.loadFromDisk(); + + expect(mockSaveAccounts).toHaveBeenCalledWith(syncedStorage); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledWith(pendingRun); + expect(commitCodexCliSyncRunFailureMock).not.toHaveBeenCalled(); + }); + + it("records loadFromDisk save failures as sync-run failures instead of successes", async () => { + const now = Date.now(); + const stored = { + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "token-1", addedAt: now, lastUsed: now }, + ], + }; + const syncedStorage = { + ...stored, + accounts: [ + ...stored.accounts, + { refreshToken: "token-2", addedAt: now + 1, lastUsed: now + 1 }, + ], + }; + const pendingRun = { + revision: 2, + run: { + outcome: "changed" as const, + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }; + const saveError = new Error("save busy"); + loadAccountsMock.mockResolvedValue(stored); + syncAccountStorageFromCodexCliMock.mockResolvedValue({ + storage: syncedStorage, + changed: true, + pendingRun, + }); + + const { saveAccounts } = await import("../lib/storage.js"); + const mockSaveAccounts = vi.mocked(saveAccounts); + mockSaveAccounts.mockRejectedValueOnce(saveError); + + await AccountManager.loadFromDisk(); + + expect(commitPendingCodexCliSyncRunMock).not.toHaveBeenCalled(); + expect(commitCodexCliSyncRunFailureMock).toHaveBeenCalledWith( + pendingRun, + saveError, + ); + }); + it("seeds from fallback auth when no storage exists", () => { const auth: OAuthAuthDetails = { type: "oauth", diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 1f7add2b..75f8ca04 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -6,6 +6,8 @@ import * as codexCliState from "../lib/codex-cli/state.js"; import { clearCodexCliStateCache } from "../lib/codex-cli/state.js"; import { __resetLastCodexCliSyncRunForTests, + commitCodexCliSyncRunFailure, + commitPendingCodexCliSyncRun, getActiveSelectionForFamily, getLastCodexCliSyncRun, previewCodexCliSync, @@ -15,6 +17,20 @@ import { setCodexCliActiveSelection } from "../lib/codex-cli/writer.js"; import { MODEL_FAMILIES } from "../lib/prompts/codex.js"; import type { AccountStorageV3 } from "../lib/storage.js"; +function createDeferred(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + describe("codex-cli sync", () => { let tempDir: string; let accountsPath: string; @@ -317,6 +333,25 @@ describe("codex-cli sync", () => { const result = await syncAccountStorageFromCodexCli(current); expect(result.changed).toBe(false); expect(result.storage).toBe(current); + expect(result.pendingRun).toBeNull(); + }); + + it("returns a disabled preview without reading Codex state when sync is disabled", async () => { + process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "0"; + clearCodexCliStateCache(); + + const loadSpy = vi + .spyOn(codexCliState, "loadCodexCliState") + .mockRejectedValue(new Error("forced load failure")); + + try { + const preview = await previewCodexCliSync(null, { forceRefresh: true }); + expect(preview.status).toBe("disabled"); + expect(preview.statusDetail).toContain("disabled by environment override"); + expect(loadSpy).not.toHaveBeenCalled(); + } finally { + loadSpy.mockRestore(); + } }); it("keeps local active selection when local write is newer than codex snapshot", async () => { @@ -784,7 +819,7 @@ describe("codex-cli sync", () => { expect(result.storage).toEqual(current); }); - it("records the last sync run summary after a reconcile", async () => { + it("only records a changed last sync run after the caller commits persistence", async () => { await writeFile( accountsPath, JSON.stringify( @@ -824,7 +859,12 @@ describe("codex-cli sync", () => { activeIndexByFamily: { codex: 0 }, }; - await syncAccountStorageFromCodexCli(current); + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(true); + expect(result.pendingRun).not.toBeNull(); + expect(getLastCodexCliSyncRun()).toBeNull(); + + commitPendingCodexCliSyncRun(result.pendingRun); const lastRun = getLastCodexCliSyncRun(); expect(lastRun?.outcome).toBe("changed"); @@ -834,6 +874,126 @@ describe("codex-cli sync", () => { expect(lastRun?.summary.selectionChanged).toBe(true); }); + it("records a save failure over a pending changed sync run", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await syncAccountStorageFromCodexCli(current); + expect(result.pendingRun).not.toBeNull(); + + commitCodexCliSyncRunFailure(result.pendingRun, new Error("save busy")); + + const lastRun = getLastCodexCliSyncRun(); + expect(lastRun?.outcome).toBe("error"); + expect(lastRun?.message).toBe("save busy"); + expect(lastRun?.summary.addedAccountCount).toBe(1); + }); + + it("ignores stale changed sync commits when a newer sync run already published", 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 firstDeferred = createDeferred< + NonNullable>> + >(); + const secondDeferred = createDeferred< + NonNullable>> + >(); + const loadSpy = vi + .spyOn(codexCliState, "loadCodexCliState") + .mockImplementationOnce(async () => firstDeferred.promise) + .mockImplementationOnce(async () => secondDeferred.promise); + + try { + const firstPromise = syncAccountStorageFromCodexCli(current); + const secondPromise = syncAccountStorageFromCodexCli(current); + + secondDeferred.resolve({ + path: "second", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + accessToken: "access-b", + refreshToken: "refresh-b", + }, + ], + activeAccountId: "acc_b", + }); + const second = await secondPromise; + commitPendingCodexCliSyncRun(second.pendingRun); + expect(getLastCodexCliSyncRun()?.sourcePath).toBe("second"); + + firstDeferred.resolve({ + path: "first", + accounts: [ + { + accountId: "acc_c", + email: "c@example.com", + accessToken: "access-c", + refreshToken: "refresh-c", + }, + ], + activeAccountId: "acc_c", + }); + const first = await firstPromise; + commitPendingCodexCliSyncRun(first.pendingRun); + + const lastRun = getLastCodexCliSyncRun(); + expect(lastRun?.sourcePath).toBe("second"); + expect(lastRun?.summary.addedAccountCount).toBe(1); + } finally { + loadSpy.mockRestore(); + } + }); + it("returns current storage when state loading throws", async () => { const current: AccountStorageV3 = { version: 3, diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index c7b99de9..a1bb6a86 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -19,6 +19,9 @@ const loadPluginConfigMock = vi.fn(); const savePluginConfigMock = vi.fn(); const previewCodexCliSyncMock = vi.fn(); const syncAccountStorageFromCodexCliMock = vi.fn(); +const commitPendingCodexCliSyncRunMock = vi.fn(); +const commitCodexCliSyncRunFailureMock = vi.fn(); +const getLastCodexCliSyncRunMock = vi.fn(); const getCodexCliAccountsPathMock = vi.fn(() => "/mock/codex/accounts.json"); const getCodexCliAuthPathMock = vi.fn(() => "/mock/codex/auth.json"); const getCodexCliConfigPathMock = vi.fn(() => "/mock/codex/config.toml"); @@ -29,6 +32,11 @@ const selectMock = vi.fn(); const deleteSavedAccountsMock = vi.fn(); const resetLocalStateMock = vi.fn(); const deleteAccountAtIndexMock = vi.fn(); +let storageBackupEnabledState = true; +const setStorageBackupEnabledMock = vi.fn((enabled: boolean) => { + storageBackupEnabledState = enabled; +}); +const isStorageBackupEnabledMock = vi.fn(() => storageBackupEnabledState); vi.mock("../lib/logger.js", () => ({ createLogger: vi.fn(() => ({ @@ -90,11 +98,13 @@ vi.mock("../lib/accounts.js", () => ({ })); vi.mock("../lib/storage.js", () => ({ + isStorageBackupEnabled: isStorageBackupEnabledMock, loadAccounts: loadAccountsMock, loadFlaggedAccounts: loadFlaggedAccountsMock, saveAccounts: saveAccountsMock, saveFlaggedAccounts: saveFlaggedAccountsMock, setStoragePath: setStoragePathMock, + setStorageBackupEnabled: setStorageBackupEnabledMock, getStoragePath: getStoragePathMock, })); @@ -107,6 +117,9 @@ vi.mock("../lib/codex-cli/writer.js", () => ({ })); vi.mock("../lib/codex-cli/sync.js", () => ({ + commitCodexCliSyncRunFailure: commitCodexCliSyncRunFailureMock, + commitPendingCodexCliSyncRun: commitPendingCodexCliSyncRunMock, + getLastCodexCliSyncRun: getLastCodexCliSyncRunMock, previewCodexCliSync: previewCodexCliSyncMock, syncAccountStorageFromCodexCli: syncAccountStorageFromCodexCliMock, })); @@ -259,6 +272,9 @@ describe("codex manager cli commands", () => { savePluginConfigMock.mockReset(); previewCodexCliSyncMock.mockReset(); syncAccountStorageFromCodexCliMock.mockReset(); + commitPendingCodexCliSyncRunMock.mockReset(); + commitCodexCliSyncRunFailureMock.mockReset(); + getLastCodexCliSyncRunMock.mockReset(); getCodexCliAccountsPathMock.mockReset(); getCodexCliAuthPathMock.mockReset(); getCodexCliConfigPathMock.mockReset(); @@ -266,6 +282,9 @@ describe("codex manager cli commands", () => { loadCodexCliStateMock.mockReset(); getLastLiveAccountSyncSnapshotMock.mockReset(); selectMock.mockReset(); + setStorageBackupEnabledMock.mockClear(); + isStorageBackupEnabledMock.mockClear(); + storageBackupEnabledState = true; deleteAccountAtIndexMock.mockReset(); deleteAccountAtIndexMock.mockResolvedValue(null); fetchCodexQuotaSnapshotMock.mockResolvedValue({ @@ -296,6 +315,7 @@ describe("codex manager cli commands", () => { }); loadPluginConfigMock.mockReturnValue({}); savePluginConfigMock.mockResolvedValue(undefined); + getLastCodexCliSyncRunMock.mockReturnValue(null); previewCodexCliSyncMock.mockResolvedValue({ status: "unavailable", statusDetail: "No Codex CLI sync source was found.", @@ -324,6 +344,7 @@ describe("codex manager cli commands", () => { syncAccountStorageFromCodexCliMock.mockResolvedValue({ changed: false, storage: null, + pendingRun: null, }); getCodexCliAccountsPathMock.mockReturnValue("/mock/codex/accounts.json"); getCodexCliAuthPathMock.mockReturnValue("/mock/codex/auth.json"); @@ -1723,6 +1744,25 @@ describe("codex manager cli commands", () => { }, ], }, + pendingRun: { + revision: 1, + run: { + outcome: "changed", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: true, + }, + }, + }, }); let selectCall = 0; @@ -1758,9 +1798,313 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(syncAccountStorageFromCodexCliMock).toHaveBeenCalledTimes(1); expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledTimes(1); + expect(commitCodexCliSyncRunFailureMock).not.toHaveBeenCalled(); expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); }); + it("honors the disabled sync-center apply hotkey", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "sync@example.com", + accountId: "acc_sync", + refreshToken: "refresh-sync", + accessToken: "access-sync", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + + let selectCall = 0; + selectMock.mockImplementation(async (_items, options) => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) { + const onInput = ( + options as { onInput?: (raw: string) => unknown } | undefined + )?.onInput; + expect(onInput?.("a")).toBeUndefined(); + return { type: "back" }; + } + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(syncAccountStorageFromCodexCliMock).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + }); + + it("applies sync-center writes with storage backups disabled when configured", 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); + loadPluginConfigMock.mockReturnValue({ storageBackupEnabled: false }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock + .mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: false, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }) + .mockResolvedValueOnce({ + status: "noop", + statusDetail: "Target already matches the current one-way sync result.", + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 2, + targetAccountCountAfter: 2, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: false, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + syncAccountStorageFromCodexCliMock.mockResolvedValueOnce({ + changed: true, + storage: { + ...storage, + accounts: [ + ...storage.accounts, + { + email: "codex@example.com", + accountId: "acc_codex", + refreshToken: "refresh-codex", + addedAt: now, + lastUsed: now, + }, + ], + }, + pendingRun: { + revision: 2, + run: { + outcome: "changed", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }, + }); + saveAccountsMock.mockImplementation(async () => { + expect(storageBackupEnabledState).toBe(false); + }); + + let selectCall = 0; + selectMock.mockImplementation(async () => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) return { type: "apply" }; + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(setStorageBackupEnabledMock).toHaveBeenNthCalledWith(1, false); + expect(setStorageBackupEnabledMock).toHaveBeenNthCalledWith(2, true); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledTimes(1); + expect(commitCodexCliSyncRunFailureMock).not.toHaveBeenCalled(); + }); + + it("surfaces sync-center save failures distinctly from reconcile failures", 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" }); + previewCodexCliSyncMock.mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + syncAccountStorageFromCodexCliMock.mockResolvedValueOnce({ + changed: true, + storage: { + ...storage, + accounts: [ + ...storage.accounts, + { + email: "codex@example.com", + accountId: "acc_codex", + refreshToken: "refresh-codex", + addedAt: now, + lastUsed: now, + }, + ], + }, + pendingRun: { + revision: 3, + run: { + outcome: "changed", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }, + }); + getLastCodexCliSyncRunMock.mockReturnValue({ + outcome: "error", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + message: "busy", + }); + saveAccountsMock.mockRejectedValue(makeErrnoError("busy", "EBUSY")); + + let selectCall = 0; + selectMock.mockImplementation(async (items) => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) return { type: "apply" }; + if (selectCall === 3) { + const text = items + .map( + (item: { label?: string; hint?: string }) => + `${item.label ?? ""}\n${item.hint ?? ""}`, + ) + .join("\n"); + expect(text).toContain("Failed to save synced storage: busy"); + return { type: "back" }; + } + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(commitPendingCodexCliSyncRunMock).not.toHaveBeenCalled(); + expect(commitCodexCliSyncRunFailureMock).toHaveBeenCalledTimes(1); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(1); + }); + it.each([ { panel: "account-list", mode: "windows-ebusy" }, { panel: "summary-fields", mode: "windows-ebusy" }, From c83f95fe84b4d8a1218726f82e1f5d9484d631a1 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 23:25:10 +0800 Subject: [PATCH 03/10] fix(sync): harden sync center apply flow --- lib/codex-cli/sync.ts | 11 ++++++- lib/codex-manager/settings-hub.ts | 21 +++++-------- lib/storage.ts | 19 +++++++++--- test/codex-cli-sync.test.ts | 51 +++++++++++++++++++++++++++++++ test/codex-manager-cli.test.ts | 44 ++++++++++++++++---------- 5 files changed, 112 insertions(+), 34 deletions(-) diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 42628f19..60c5f9ba 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -12,6 +12,7 @@ import { } from "./observability.js"; import { type CodexCliAccountSnapshot, + type CodexCliState, isCodexCliSyncEnabled, loadCodexCliState, } from "./state.js"; @@ -75,6 +76,7 @@ export interface CodexCliSyncPreview { status: "ready" | "noop" | "disabled" | "unavailable" | "error"; statusDetail: string; sourcePath: string | null; + sourceState: CodexCliState | null; targetPath: string; summary: CodexCliSyncSummary; backup: CodexCliSyncBackupContext; @@ -546,6 +548,7 @@ export async function previewCodexCliSync( status: "disabled", statusDetail: "Codex CLI sync is disabled by environment override.", sourcePath: null, + sourceState: null, targetPath, summary: emptySummary, backup, @@ -560,6 +563,7 @@ export async function previewCodexCliSync( status: "unavailable", statusDetail: "No Codex CLI sync source was found.", sourcePath: null, + sourceState: null, targetPath, summary: emptySummary, backup, @@ -576,6 +580,7 @@ export async function previewCodexCliSync( status, statusDetail, sourcePath: state.path, + sourceState: state, targetPath, summary: reconciled.summary, backup, @@ -586,6 +591,7 @@ export async function previewCodexCliSync( status: "error", statusDetail: error instanceof Error ? error.message : String(error), sourcePath: null, + sourceState: null, targetPath, summary: emptySummary, backup, @@ -615,6 +621,7 @@ export async function previewCodexCliSync( */ export async function applyCodexCliSyncToStorage( current: AccountStorageV3 | null, + options: { forceRefresh?: boolean } = {}, ): Promise<{ storage: AccountStorageV3 | null; changed: boolean; @@ -643,7 +650,9 @@ export async function applyCodexCliSyncToStorage( return { storage: current, changed: false, pendingRun: null }; } - const state = await loadCodexCliState(); + const state = await loadCodexCliState({ + forceRefresh: options.forceRefresh, + }); if (!state) { incrementCodexCliMetric("reconcileNoops"); publishCodexCliSyncRun( diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index d4e16718..674c313d 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -7,7 +7,6 @@ import { getCodexCliAuthPath, getCodexCliConfigPath, isCodexCliSyncEnabled, - loadCodexCliState, } from "../codex-cli/state.js"; import { applyCodexCliSyncToStorage, @@ -47,11 +46,9 @@ import { } from "../oc-chatgpt-orchestrator.js"; import { detectOcChatgptMultiAuthTarget } from "../oc-chatgpt-target-detection.js"; import { - isStorageBackupEnabled, loadAccounts, normalizeAccountStorage, saveAccounts, - setStorageBackupEnabled, } from "../storage.js"; import type { PluginConfig } from "../types.js"; import { ANSI } from "../ui/ansi.js"; @@ -2668,14 +2665,13 @@ async function promptSyncCenter(config: PluginConfig): Promise { 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), + context: resolveSyncCenterContext(preview.sourceState), }; }; @@ -2748,14 +2744,16 @@ async function promptSyncCenter(config: PluginConfig): Promise { try { const current = await loadAccounts(); - const synced = await applyCodexCliSyncToStorage(current); + const synced = await applyCodexCliSyncToStorage(current, { + forceRefresh: true, + }); const storageBackupEnabled = getStorageBackupEnabled(config); const nextStorage = synced.storage ?? current; if (synced.changed && synced.storage) { - const previousStorageBackupEnabled = isStorageBackupEnabled(); - setStorageBackupEnabled(storageBackupEnabled); try { - await saveAccounts(synced.storage); + await saveAccounts(synced.storage, { + backupEnabled: storageBackupEnabled, + }); commitPendingCodexCliSyncRun(synced.pendingRun); } catch (error) { commitCodexCliSyncRunFailure(synced.pendingRun, error); @@ -2768,16 +2766,13 @@ async function promptSyncCenter(config: PluginConfig): Promise { }`, }; continue; - } finally { - setStorageBackupEnabled(previousStorageBackupEnabled); } } - const state = await loadCodexCliState({ forceRefresh: true }); preview = await previewCodexCliSync(nextStorage, { forceRefresh: true, storageBackupEnabled, }); - context = resolveSyncCenterContext(state); + context = resolveSyncCenterContext(preview.sourceState); } catch (error) { preview = { ...preview, diff --git a/lib/storage.ts b/lib/storage.ts index 20d94fa1..7c19d5e8 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1591,12 +1591,20 @@ async function loadAccountsInternal( } } -async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { +interface SaveAccountsOptions { + backupEnabled?: boolean; +} + +async function saveAccountsUnlocked( + storage: AccountStorageV3, + options: SaveAccountsOptions = {}, +): Promise { const path = getStoragePath(); const resetMarkerPath = getIntentionalResetMarkerPath(path); const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; const tempPath = `${path}.${uniqueSuffix}.tmp`; const walPath = getAccountsWalPath(path); + const backupEnabled = options.backupEnabled ?? storageBackupEnabled; try { await fs.mkdir(dirname(path), { recursive: true }); @@ -1628,7 +1636,7 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { } } - if (storageBackupEnabled && existsSync(path)) { + if (backupEnabled && existsSync(path)) { try { await createRotatingAccountsBackup(path); } catch (backupError) { @@ -1748,9 +1756,12 @@ export async function withAccountStorageTransaction( * @param storage - Account storage data to save * @throws StorageError with platform-aware hints on failure */ -export async function saveAccounts(storage: AccountStorageV3): Promise { +export async function saveAccounts( + storage: AccountStorageV3, + options: SaveAccountsOptions = {}, +): Promise { return withStorageLock(async () => { - await saveAccountsUnlocked(storage); + await saveAccountsUnlocked(storage, options); }); } diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index e4bade6c..06baee42 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -375,6 +375,57 @@ describe("codex-cli sync", () => { expect(lastRun?.summary.destinationOnlyPreservedCount).toBe(1); }); + it("forces a fresh Codex CLI read when apply is requested with forceRefresh", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const loadSpy = vi.spyOn(codexCliState, "loadCodexCliState"); + try { + await applyCodexCliSyncToStorage(current, { forceRefresh: true }); + expect(loadSpy).toHaveBeenCalledWith( + expect.objectContaining({ forceRefresh: true }), + ); + } finally { + loadSpy.mockRestore(); + } + }); + it("records a manual sync save failure over a pending changed run", async () => { await writeFile( accountsPath, diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 2f1cc50b..fbd4b8f1 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -15,6 +15,7 @@ const loadDashboardDisplaySettingsMock = vi.fn(); const saveDashboardDisplaySettingsMock = vi.fn(); const loadQuotaCacheMock = vi.fn(); const saveQuotaCacheMock = vi.fn(); +const clearQuotaCacheMock = vi.fn(); const loadPluginConfigMock = vi.fn(); const savePluginConfigMock = vi.fn(); const previewCodexCliSyncMock = vi.fn(); @@ -25,6 +26,7 @@ const getLastCodexCliSyncRunMock = vi.fn(); const getCodexCliAccountsPathMock = vi.fn(() => "/mock/codex/accounts.json"); const getCodexCliAuthPathMock = vi.fn(() => "/mock/codex/auth.json"); const getCodexCliConfigPathMock = vi.fn(() => "/mock/codex/config.toml"); +const clearCodexCliStateCacheMock = vi.fn(); const isCodexCliSyncEnabledMock = vi.fn(() => true); const loadCodexCliStateMock = vi.fn(); const getLastLiveAccountSyncSnapshotMock = vi.fn(); @@ -36,11 +38,8 @@ const exportNamedBackupMock = vi.fn(); const promptQuestionMock = vi.fn(); const detectOcChatgptMultiAuthTargetMock = vi.fn(); const normalizeAccountStorageMock = vi.fn((value) => value); -let storageBackupEnabledState = true; -const setStorageBackupEnabledMock = vi.fn((enabled: boolean) => { - storageBackupEnabledState = enabled; -}); -const isStorageBackupEnabledMock = vi.fn(() => storageBackupEnabledState); +const clearAccountsMock = vi.fn(); +const clearFlaggedAccountsMock = vi.fn(); vi.mock("../lib/logger.js", () => ({ createLogger: vi.fn(() => ({ @@ -102,13 +101,13 @@ vi.mock("../lib/accounts.js", () => ({ })); vi.mock("../lib/storage.js", () => ({ - isStorageBackupEnabled: isStorageBackupEnabledMock, + clearAccounts: clearAccountsMock, + clearFlaggedAccounts: clearFlaggedAccountsMock, loadAccounts: loadAccountsMock, loadFlaggedAccounts: loadFlaggedAccountsMock, saveAccounts: saveAccountsMock, saveFlaggedAccounts: saveFlaggedAccountsMock, setStoragePath: setStoragePathMock, - setStorageBackupEnabled: setStorageBackupEnabledMock, getStoragePath: getStoragePathMock, exportNamedBackup: exportNamedBackupMock, normalizeAccountStorage: normalizeAccountStorageMock, @@ -131,6 +130,7 @@ vi.mock("../lib/codex-cli/sync.js", () => ({ })); vi.mock("../lib/codex-cli/state.js", () => ({ + clearCodexCliStateCache: clearCodexCliStateCacheMock, getCodexCliAccountsPath: getCodexCliAccountsPathMock, getCodexCliAuthPath: getCodexCliAuthPathMock, getCodexCliConfigPath: getCodexCliConfigPathMock, @@ -175,6 +175,7 @@ vi.mock("../lib/config.js", async () => { }); vi.mock("../lib/quota-cache.js", () => ({ + clearQuotaCache: clearQuotaCacheMock, loadQuotaCache: loadQuotaCacheMock, saveQuotaCache: saveQuotaCacheMock, })); @@ -454,6 +455,7 @@ describe("codex manager cli commands", () => { saveDashboardDisplaySettingsMock.mockReset(); loadQuotaCacheMock.mockReset(); saveQuotaCacheMock.mockReset(); + clearQuotaCacheMock.mockReset(); loadPluginConfigMock.mockReset(); savePluginConfigMock.mockReset(); previewCodexCliSyncMock.mockReset(); @@ -466,7 +468,10 @@ describe("codex manager cli commands", () => { getCodexCliConfigPathMock.mockReset(); isCodexCliSyncEnabledMock.mockReset(); loadCodexCliStateMock.mockReset(); + clearCodexCliStateCacheMock.mockReset(); getLastLiveAccountSyncSnapshotMock.mockReset(); + clearAccountsMock.mockReset(); + clearFlaggedAccountsMock.mockReset(); selectMock.mockReset(); planOcChatgptSyncMock.mockReset(); applyOcChatgptSyncMock.mockReset(); @@ -476,9 +481,6 @@ describe("codex manager cli commands", () => { detectOcChatgptMultiAuthTargetMock.mockReset(); normalizeAccountStorageMock.mockReset(); normalizeAccountStorageMock.mockImplementation((value) => value); - setStorageBackupEnabledMock.mockClear(); - isStorageBackupEnabledMock.mockClear(); - storageBackupEnabledState = true; fetchCodexQuotaSnapshotMock.mockResolvedValue({ status: 200, model: "gpt-5-codex", @@ -512,6 +514,7 @@ describe("codex manager cli commands", () => { status: "unavailable", statusDetail: "No Codex CLI sync source was found.", sourcePath: null, + sourceState: null, targetPath: "/mock/openai-codex-accounts.json", summary: { sourceAccountCount: 0, @@ -2069,6 +2072,7 @@ describe("codex manager cli commands", () => { status: "ready", statusDetail: "Preview ready.", sourcePath: "/mock/codex/accounts.json", + sourceState: null, targetPath: "/mock/openai-codex-accounts.json", summary: { sourceAccountCount: 1, @@ -2091,6 +2095,7 @@ describe("codex manager cli commands", () => { status: "noop", statusDetail: "Target already matches the current one-way sync result.", sourcePath: "/mock/codex/accounts.json", + sourceState: null, targetPath: "/mock/openai-codex-accounts.json", summary: { sourceAccountCount: 1, @@ -2144,10 +2149,6 @@ describe("codex manager cli commands", () => { }, }, }); - saveAccountsMock.mockImplementation(async () => { - expect(storageBackupEnabledState).toBe(false); - }); - let selectCall = 0; selectMock.mockImplementation(async () => { selectCall += 1; @@ -2160,9 +2161,15 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); + expect(applyCodexCliSyncToStorageMock).toHaveBeenCalledWith( + storage, + expect.objectContaining({ forceRefresh: true }), + ); expect(saveAccountsMock).toHaveBeenCalledTimes(1); - expect(setStorageBackupEnabledMock).toHaveBeenNthCalledWith(1, false); - expect(setStorageBackupEnabledMock).toHaveBeenNthCalledWith(2, true); + expect(saveAccountsMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ backupEnabled: false }), + ); expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledTimes(1); expect(commitCodexCliSyncRunFailureMock).not.toHaveBeenCalled(); expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); @@ -2196,6 +2203,7 @@ describe("codex manager cli commands", () => { status: "ready", statusDetail: "Preview ready.", sourcePath: "/mock/codex/accounts.json", + sourceState: null, targetPath: "/mock/openai-codex-accounts.json", summary: { sourceAccountCount: 1, @@ -2287,6 +2295,10 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); + expect(applyCodexCliSyncToStorageMock).toHaveBeenCalledWith( + storage, + expect.objectContaining({ forceRefresh: true }), + ); expect(saveAccountsMock).toHaveBeenCalledTimes(1); expect(commitPendingCodexCliSyncRunMock).not.toHaveBeenCalled(); expect(commitCodexCliSyncRunFailureMock).toHaveBeenCalledTimes(1); From 5515a9cd266cb804dd8e469961b6892ab88ac07e Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 23:26:11 +0800 Subject: [PATCH 04/10] test(sync): cover preview-to-apply refresh race --- test/codex-cli-sync.test.ts | 49 +++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 06baee42..2f842b2a 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -375,7 +375,7 @@ describe("codex-cli sync", () => { expect(lastRun?.summary.destinationOnlyPreservedCount).toBe(1); }); - it("forces a fresh Codex CLI read when apply is requested with forceRefresh", async () => { + it("re-reads Codex CLI state on apply when forceRefresh is requested", async () => { await writeFile( accountsPath, JSON.stringify( @@ -415,12 +415,57 @@ describe("codex-cli sync", () => { activeIndexByFamily: { codex: 0 }, }; + await previewCodexCliSync(current, { forceRefresh: true }); + + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_c", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + { + accountId: "acc_c", + email: "c@example.com", + auth: { + tokens: { + access_token: "access-c", + refresh_token: "refresh-c", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + const loadSpy = vi.spyOn(codexCliState, "loadCodexCliState"); try { - await applyCodexCliSyncToStorage(current, { forceRefresh: true }); + const result = await applyCodexCliSyncToStorage(current, { + forceRefresh: true, + }); expect(loadSpy).toHaveBeenCalledWith( expect.objectContaining({ forceRefresh: true }), ); + expect(result.changed).toBe(true); + expect(result.storage?.accounts.map((account) => account.accountId)).toEqual([ + "acc_a", + "acc_b", + "acc_c", + ]); + expect(result.storage?.activeIndex).toBe(2); } finally { loadSpy.mockRestore(); } From 96f2aa0df9d7afbd4d709688ca2b28f1d90b35fd Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 00:48:04 +0800 Subject: [PATCH 05/10] fix(sync): preserve safe local selection during sync --- lib/codex-cli/sync.ts | 149 +++++++++++---------- test/codex-cli-sync.test.ts | 258 +++++++++++++++++++++++++++++++++++- 2 files changed, 334 insertions(+), 73 deletions(-) diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 60c5f9ba..4e7b8d32 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -1,10 +1,13 @@ +import { promises as fs } from "node:fs"; import { createLogger } from "../logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; import { type AccountMetadataV3, type AccountStorageV3, + findMatchingAccountIndex, getLastAccountsSaveTimestamp, getStoragePath, + normalizeEmailKey, } from "../storage.js"; import { incrementCodexCliMetric, @@ -20,12 +23,6 @@ import { getLastCodexCliSelectionWriteTimestamp } from "./writer.js"; const log = createLogger("codex-cli-sync"); -function normalizeEmail(value: string | undefined): string | undefined { - if (!value) return undefined; - const trimmed = value.trim().toLowerCase(); - return trimmed.length > 0 ? trimmed : undefined; -} - function createEmptyStorage(): AccountStorageV3 { return { version: 3, @@ -218,38 +215,24 @@ export function __resetLastCodexCliSyncRunForTests(): void { nextCodexCliSyncRunRevision = 0; } -function buildIndexByAccountId( - accounts: AccountMetadataV3[], -): Map { - const map = new Map(); - for (let i = 0; i < accounts.length; i += 1) { - const account = accounts[i]; - if (!account?.accountId) continue; - map.set(account.accountId, i); - } - return map; -} - -function buildIndexByRefresh( +function hasConflictingIdentity( accounts: AccountMetadataV3[], -): Map { - const map = new Map(); - for (let i = 0; i < accounts.length; i += 1) { - const account = accounts[i]; - if (!account?.refreshToken) continue; - map.set(account.refreshToken, i); - } - return map; -} - -function buildIndexByEmail(accounts: AccountMetadataV3[]): Map { - const map = new Map(); - for (let i = 0; i < accounts.length; i += 1) { - const email = normalizeEmail(accounts[i]?.email); - if (!email) continue; - map.set(email, i); + snapshot: CodexCliAccountSnapshot, +): boolean { + const normalizedEmail = normalizeEmailKey(snapshot.email); + for (const account of accounts) { + if (!account) continue; + if (snapshot.accountId && account.accountId === snapshot.accountId) { + return true; + } + if (snapshot.refreshToken && account.refreshToken === snapshot.refreshToken) { + return true; + } + if (normalizedEmail && normalizeEmailKey(account.email) === normalizedEmail) { + return true; + } } - return map; + return false; } function toStorageAccount( @@ -277,21 +260,14 @@ function upsertFromSnapshot( const nextAccount = toStorageAccount(snapshot); if (!nextAccount) return { action: "skipped" }; - const byAccountId = buildIndexByAccountId(accounts); - const byRefresh = buildIndexByRefresh(accounts); - const byEmail = buildIndexByEmail(accounts); - const normalizedEmail = normalizeEmail(snapshot.email); - - let targetIndex: number | undefined; - if (snapshot.accountId && byAccountId.has(snapshot.accountId)) { - targetIndex = byAccountId.get(snapshot.accountId); - } else if (snapshot.refreshToken && byRefresh.has(snapshot.refreshToken)) { - targetIndex = byRefresh.get(snapshot.refreshToken); - } else if (normalizedEmail && byEmail.has(normalizedEmail)) { - targetIndex = byEmail.get(normalizedEmail); - } + const targetIndex = findMatchingAccountIndex(accounts, snapshot, { + allowUniqueAccountIdFallbackWithoutEmail: true, + }); if (targetIndex === undefined) { + if (hasConflictingIdentity(accounts, snapshot)) { + return { action: "skipped" }; + } accounts.push(nextAccount); return { action: "added" }; } @@ -325,25 +301,20 @@ function resolveActiveIndex( accounts: AccountMetadataV3[], activeAccountId: string | undefined, activeEmail: string | undefined, -): number { - if (accounts.length === 0) return 0; - - if (activeAccountId) { - const byId = accounts.findIndex( - (account) => account.accountId === activeAccountId, - ); - if (byId >= 0) return byId; - } - - const normalizedEmail = normalizeEmail(activeEmail); - if (normalizedEmail) { - const byEmail = accounts.findIndex( - (account) => normalizeEmail(account.email) === normalizedEmail, - ); - if (byEmail >= 0) return byEmail; - } - - return 0; +): number | undefined { + if (accounts.length === 0) return undefined; + if (!activeAccountId && !normalizeEmailKey(activeEmail)) return undefined; + return findMatchingAccountIndex( + accounts, + { + accountId: activeAccountId, + email: activeEmail, + refreshToken: undefined, + }, + { + allowUniqueAccountIdFallbackWithoutEmail: true, + }, + ); } function writeFamilyIndexes(storage: AccountStorageV3, index: number): void { @@ -354,6 +325,15 @@ function writeFamilyIndexes(storage: AccountStorageV3, index: number): void { } } +async function getPersistedLocalSelectionTimestamp(): Promise { + try { + const stats = await fs.stat(getStoragePath()); + return Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : 0; + } catch { + return 0; + } +} + /** * Normalize and clamp the global and per-family active account indexes to valid ranges. * @@ -431,6 +411,7 @@ function readActiveFromSnapshots(snapshots: CodexCliAccountSnapshot[]): { */ function shouldApplyCodexCliSelection( state: Awaited>, + persistedLocalTimestamp = 0, ): boolean { if (!state) return false; const hasSyncVersion = @@ -444,6 +425,7 @@ function shouldApplyCodexCliSelection( const localVersion = Math.max( getLastAccountsSaveTimestamp(), getLastCodexCliSelectionWriteTimestamp(), + persistedLocalTimestamp, ); if (codexVersion <= 0 || localVersion <= 0) return true; // Keep local selection when plugin wrote more recently than Codex state. @@ -454,6 +436,7 @@ function shouldApplyCodexCliSelection( function reconcileCodexCliState( current: AccountStorageV3 | null, state: NonNullable>>, + options: { persistedLocalTimestamp?: number } = {}, ): ReconcileResult { const next = current ? cloneStorage(current) : createEmptyStorage(); const targetAccountCountBefore = next.accounts.length; @@ -495,14 +478,32 @@ function reconcileCodexCliState( const activeFromSnapshots = readActiveFromSnapshots(state.accounts); const previousActive = next.activeIndex; const previousFamilies = JSON.stringify(next.activeIndexByFamily ?? {}); - const applyActiveFromCodex = shouldApplyCodexCliSelection(state); + const applyActiveFromCodex = shouldApplyCodexCliSelection( + state, + options.persistedLocalTimestamp ?? 0, + ); if (applyActiveFromCodex) { const desiredIndex = resolveActiveIndex( next.accounts, state.activeAccountId ?? activeFromSnapshots.accountId, state.activeEmail ?? activeFromSnapshots.email, ); - writeFamilyIndexes(next, desiredIndex); + if (typeof desiredIndex === "number") { + writeFamilyIndexes(next, desiredIndex); + } else if ( + state.activeAccountId || + state.activeEmail || + activeFromSnapshots.accountId || + activeFromSnapshots.email + ) { + log.debug( + "Skipped Codex CLI active selection overwrite due to ambiguous source selection", + { + operation: "reconcile-storage", + outcome: "selection-ambiguous", + }, + ); + } } else { log.debug( "Skipped Codex CLI active selection overwrite due to newer local state", @@ -571,7 +572,9 @@ export async function previewCodexCliSync( }; } - const reconciled = reconcileCodexCliState(current, state); + const reconciled = reconcileCodexCliState(current, state, { + persistedLocalTimestamp: await getPersistedLocalSelectionTimestamp(), + }); 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.` @@ -672,7 +675,9 @@ export async function applyCodexCliSyncToStorage( return { storage: current, changed: false, pendingRun: null }; } - const reconciled = reconcileCodexCliState(current, state); + const reconciled = reconcileCodexCliState(current, state, { + persistedLocalTimestamp: await getPersistedLocalSelectionTimestamp(), + }); const next = reconciled.next; const changed = reconciled.changed; const storage = diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 2f842b2a..5abada00 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -1,8 +1,9 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AccountStorageV3 } from "../lib/storage.js"; +import * as storageModule from "../lib/storage.js"; import * as codexCliState from "../lib/codex-cli/state.js"; import { clearCodexCliStateCache } from "../lib/codex-cli/state.js"; import { @@ -15,6 +16,7 @@ import { previewCodexCliSync, syncAccountStorageFromCodexCli, } from "../lib/codex-cli/sync.js"; +import * as writerModule from "../lib/codex-cli/writer.js"; import { setCodexCliActiveSelection } from "../lib/codex-cli/writer.js"; import { MODEL_FAMILIES } from "../lib/prompts/codex.js"; @@ -46,6 +48,7 @@ describe("codex-cli sync", () => { let accountsPath: string; let authPath: string; let configPath: string; + let targetStoragePath: string; let previousPath: string | undefined; let previousAuthPath: string | undefined; let previousConfigPath: string | undefined; @@ -63,16 +66,19 @@ describe("codex-cli sync", () => { accountsPath = join(tempDir, "accounts.json"); authPath = join(tempDir, "auth.json"); configPath = join(tempDir, "config.toml"); + targetStoragePath = join(tempDir, "openai-codex-accounts.json"); process.env.CODEX_CLI_ACCOUNTS_PATH = accountsPath; process.env.CODEX_CLI_AUTH_PATH = authPath; process.env.CODEX_CLI_CONFIG_PATH = configPath; process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "1"; process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = "1"; + vi.spyOn(storageModule, "getStoragePath").mockReturnValue(targetStoragePath); clearCodexCliStateCache(); __resetLastCodexCliSyncRunForTests(); }); afterEach(async () => { + vi.restoreAllMocks(); clearCodexCliStateCache(); __resetLastCodexCliSyncRunForTests(); if (previousPath === undefined) delete process.env.CODEX_CLI_ACCOUNTS_PATH; @@ -320,6 +326,256 @@ describe("codex-cli sync", () => { expect(current.accounts).toHaveLength(2); }); + it("skips ambiguous duplicate-email source matches instead of overwriting a local account", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + email: "dup@example.com", + auth: { + tokens: { + access_token: "access-new", + refresh_token: "refresh-new", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "dup@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "dup@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const result = await applyCodexCliSyncToStorage(current, { + forceRefresh: true, + }); + + expect(result.changed).toBe(false); + expect(result.pendingRun).toBeNull(); + expect(result.storage?.accounts).toEqual(current.accounts); + }); + + it("skips ambiguous duplicate-accountId source matches instead of overwriting a local account", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "shared-id", + auth: { + tokens: { + access_token: "access-new", + refresh_token: "refresh-new", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "shared-id", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "shared-id", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const result = await applyCodexCliSyncToStorage(current, { + forceRefresh: true, + }); + + expect(result.changed).toBe(false); + expect(result.pendingRun).toBeNull(); + expect(result.storage?.accounts).toEqual(current.accounts); + }); + + it("preserves the current selection when Codex CLI source has no active marker", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(preview.status).toBe("noop"); + expect(preview.summary.selectionChanged).toBe(false); + }); + + it("preserves a newer persisted local selection after restart", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const sourceTime = new Date("2026-03-13T00:00:00.000Z"); + const targetTime = new Date("2026-03-13T00:00:05.000Z"); + await utimes(accountsPath, sourceTime, sourceTime); + await writeFile(targetStoragePath, "{\"version\":3}", "utf-8"); + await utimes(targetStoragePath, targetTime, targetTime); + + vi.spyOn(storageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); + vi.spyOn(writerModule, "getLastCodexCliSelectionWriteTimestamp").mockReturnValue( + 0, + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(preview.status).toBe("noop"); + expect(preview.summary.selectionChanged).toBe(false); + }); + it("records a changed manual sync only after the caller commits persistence", async () => { await writeFile( accountsPath, From a01d01513f53bf6a8a55713979e23bced2120e18 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 00:50:56 +0800 Subject: [PATCH 06/10] fix(sync): close final review nits --- docs/reference/settings.md | 2 +- test/accounts.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/reference/settings.md b/docs/reference/settings.md index e3ce94e7..4781b957 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -64,7 +64,7 @@ Controls display style: - `uiAccentColor` - `menuFocusStyle` -### Sync Center +## Sync Center The settings hub includes a preview-first sync center for Codex CLI account sync. diff --git a/test/accounts.test.ts b/test/accounts.test.ts index 63fc2571..444e430e 100644 --- a/test/accounts.test.ts +++ b/test/accounts.test.ts @@ -287,8 +287,13 @@ describe("AccountManager", () => { await AccountManager.loadFromDisk(); + expect(mockSaveAccounts).toHaveBeenCalledTimes(1); expect(mockSaveAccounts).toHaveBeenCalledWith(syncedStorage); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledTimes(1); expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledWith(pendingRun); + expect(mockSaveAccounts.mock.invocationCallOrder[0]!).toBeLessThan( + commitPendingCodexCliSyncRunMock.mock.invocationCallOrder[0]!, + ); expect(commitCodexCliSyncRunFailureMock).not.toHaveBeenCalled(); }); From 19b098b060f9470ff81a3da7e3dae93344755e99 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 01:32:15 +0800 Subject: [PATCH 07/10] fix(sync): close remaining sync center review gaps --- lib/codex-cli/sync.ts | 45 +++++-- lib/codex-manager/settings-hub.ts | 109 ++++++++++++++-- lib/live-account-sync.ts | 11 +- test/codex-cli-sync.test.ts | 129 +++++++++++++++++++ test/codex-manager-cli.test.ts | 202 +++++++++++++++++++++++++++++- test/live-account-sync.test.ts | 80 +++++++++++- test/settings-hub-utils.test.ts | 43 +++++++ 7 files changed, 596 insertions(+), 23 deletions(-) diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 4e7b8d32..e8e3ed65 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -325,12 +325,16 @@ function writeFamilyIndexes(storage: AccountStorageV3, index: number): void { } } -async function getPersistedLocalSelectionTimestamp(): Promise { +async function getPersistedLocalSelectionTimestamp(): Promise { try { const stats = await fs.stat(getStoragePath()); return Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : 0; - } catch { - return 0; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return 0; + } + return null; } } @@ -411,7 +415,7 @@ function readActiveFromSnapshots(snapshots: CodexCliAccountSnapshot[]): { */ function shouldApplyCodexCliSelection( state: Awaited>, - persistedLocalTimestamp = 0, + persistedLocalTimestamp: number | null = 0, ): boolean { if (!state) return false; const hasSyncVersion = @@ -422,12 +426,18 @@ function shouldApplyCodexCliSelection( Number.isFinite(state.sourceUpdatedAtMs) ? state.sourceUpdatedAtMs : 0; - const localVersion = Math.max( + const inProcessLocalVersion = Math.max( getLastAccountsSaveTimestamp(), getLastCodexCliSelectionWriteTimestamp(), - persistedLocalTimestamp, ); - if (codexVersion <= 0 || localVersion <= 0) return true; + const localVersion = Math.max( + inProcessLocalVersion, + persistedLocalTimestamp ?? 0, + ); + if (codexVersion <= 0) return true; + if (localVersion <= 0) { + return persistedLocalTimestamp !== null; + } // Keep local selection when plugin wrote more recently than Codex state. const toleranceMs = hasSyncVersion ? 0 : 1_000; return codexVersion >= localVersion - toleranceMs; @@ -436,19 +446,19 @@ function shouldApplyCodexCliSelection( function reconcileCodexCliState( current: AccountStorageV3 | null, state: NonNullable>>, - options: { persistedLocalTimestamp?: number } = {}, + options: { persistedLocalTimestamp?: number | null } = {}, ): ReconcileResult { const next = current ? cloneStorage(current) : createEmptyStorage(); const targetAccountCountBefore = next.accounts.length; const matchedExistingIndexes = new Set(); const summary = createEmptySyncSummary(); + summary.sourceAccountCount = state.accounts.length; summary.targetAccountCountBefore = targetAccountCountBefore; let changed = false; for (const snapshot of state.accounts) { const result = upsertFromSnapshot(next.accounts, snapshot); if (result.action === "skipped") continue; - summary.sourceAccountCount += 1; if ( typeof result.matchedIndex === "number" && result.matchedIndex >= 0 && @@ -480,7 +490,7 @@ function reconcileCodexCliState( const previousFamilies = JSON.stringify(next.activeIndexByFamily ?? {}); const applyActiveFromCodex = shouldApplyCodexCliSelection( state, - options.persistedLocalTimestamp ?? 0, + options.persistedLocalTimestamp, ); if (applyActiveFromCodex) { const desiredIndex = resolveActiveIndex( @@ -576,9 +586,20 @@ export async function previewCodexCliSync( persistedLocalTimestamp: await getPersistedLocalSelectionTimestamp(), }); const status = reconciled.changed ? "ready" : "noop"; + const skippedAccountCount = Math.max( + 0, + reconciled.summary.sourceAccountCount - + reconciled.summary.addedAccountCount - + reconciled.summary.updatedAccountCount - + reconciled.summary.unchangedAccountCount, + ); const statusDetail = reconciled.changed - ? `Preview ready: ${reconciled.summary.addedAccountCount} add, ${reconciled.summary.updatedAccountCount} update, ${reconciled.summary.destinationOnlyPreservedCount} destination-only preserved.` - : "Target already matches the current one-way sync result."; + ? `Preview ready: ${reconciled.summary.addedAccountCount} add, ${reconciled.summary.updatedAccountCount} update, ${reconciled.summary.destinationOnlyPreservedCount} destination-only preserved${ + skippedAccountCount > 0 ? `, ${skippedAccountCount} skipped` : "" + }.` + : skippedAccountCount > 0 + ? `Target already matches the current one-way sync result. ${skippedAccountCount} source account skipped due to conflicting or incomplete identity.` + : "Target already matches the current one-way sync result."; return { status, statusDetail, diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 674c313d..fbb40dd4 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -46,6 +46,7 @@ import { } from "../oc-chatgpt-orchestrator.js"; import { detectOcChatgptMultiAuthTarget } from "../oc-chatgpt-target-detection.js"; import { + getStoragePath, loadAccounts, normalizeAccountStorage, saveAccounts, @@ -1338,15 +1339,31 @@ function formatSyncSourceLabel( preview: CodexCliSyncPreview, context: SyncCenterOverviewContext, ): string { + const normalizedSourcePath = normalizePathForComparison(preview.sourcePath); + const normalizedAccountsPath = normalizePathForComparison(context.accountsPath); + const normalizedAuthPath = normalizePathForComparison(context.authPath); if (!context.syncEnabled) return "disabled by environment override"; - if (!preview.sourcePath) return "not available"; - if (preview.sourcePath === context.accountsPath) + if (!normalizedSourcePath) return "not available"; + if (normalizedSourcePath === normalizedAccountsPath) return "accounts.json active"; - if (preview.sourcePath === context.authPath) + if (normalizedSourcePath === normalizedAuthPath) return "auth.json fallback active"; return "custom source path active"; } +function normalizePathForComparison( + path: string | null | undefined, +): string | null { + if (typeof path !== "string" || path.length === 0) { + return null; + } + const normalized = path.replace(/\\/g, "/").replace(/\/+/g, "/"); + const trimmed = + normalized.length > 1 ? normalized.replace(/\/+$/, "") : normalized; + const isWindowsPath = path.includes("\\") || /^[a-z]:\//i.test(trimmed); + return isWindowsPath ? trimmed.toLowerCase() : trimmed; +} + function buildSyncCenterOverview( preview: CodexCliSyncPreview, context: SyncCenterOverviewContext = resolveSyncCenterContext(null), @@ -2674,8 +2691,81 @@ async function promptSyncCenter(config: PluginConfig): Promise { context: resolveSyncCenterContext(preview.sourceState), }; }; + const buildErrorState = ( + message: string, + previousPreview?: CodexCliSyncPreview, + ): { + preview: CodexCliSyncPreview; + context: SyncCenterOverviewContext; + } => { + if (previousPreview) { + return { + preview: { + ...previousPreview, + lastSync: getLastCodexCliSyncRun(), + status: "error", + statusDetail: message, + }, + context: resolveSyncCenterContext(previousPreview.sourceState), + }; + } - let { preview, context } = await buildPreview(true); + const targetPath = getStoragePath(); + const emptySummary: CodexCliSyncSummary = { + sourceAccountCount: 0, + targetAccountCountBefore: 0, + targetAccountCountAfter: 0, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }; + return { + preview: { + status: "error", + statusDetail: message, + sourcePath: null, + sourceState: null, + targetPath, + summary: emptySummary, + backup: { + enabled: getStorageBackupEnabled(config), + targetPath, + rollbackPaths: [ + `${targetPath}.bak`, + `${targetPath}.bak.1`, + `${targetPath}.bak.2`, + `${targetPath}.wal`, + ], + }, + lastSync: getLastCodexCliSyncRun(), + }, + context: resolveSyncCenterContext(null), + }; + }; + const buildPreviewSafely = async ( + forceRefresh = false, + previousPreview?: CodexCliSyncPreview, + ): Promise<{ + preview: CodexCliSyncPreview; + context: SyncCenterOverviewContext; + }> => { + try { + return await withQueuedRetry(getStoragePath(), async () => + buildPreview(forceRefresh), + ); + } catch (error) { + return buildErrorState( + `Failed to refresh sync center: ${ + error instanceof Error ? error.message : String(error) + }`, + previousPreview, + ); + } + }; + + let { preview, context } = await buildPreviewSafely(true); while (true) { const overview = buildSyncCenterOverview(preview, context); const items: MenuItem[] = [ @@ -2738,7 +2828,7 @@ async function promptSyncCenter(config: PluginConfig): Promise { if (!result || result.type === "back") return; if (result.type === "refresh") { - ({ preview, context } = await buildPreview(true)); + ({ preview, context } = await buildPreviewSafely(true, preview)); continue; } @@ -2750,10 +2840,13 @@ async function promptSyncCenter(config: PluginConfig): Promise { const storageBackupEnabled = getStorageBackupEnabled(config); const nextStorage = synced.storage ?? current; if (synced.changed && synced.storage) { + const syncedStorage = synced.storage; try { - await saveAccounts(synced.storage, { - backupEnabled: storageBackupEnabled, - }); + await withQueuedRetry(preview.targetPath, async () => + saveAccounts(syncedStorage, { + backupEnabled: storageBackupEnabled, + }), + ); commitPendingCodexCliSyncRun(synced.pendingRun); } catch (error) { commitCodexCliSyncRunFailure(synced.pendingRun, error); diff --git a/lib/live-account-sync.ts b/lib/live-account-sync.ts index 24ab0dae..c8ccdbc6 100644 --- a/lib/live-account-sync.ts +++ b/lib/live-account-sync.ts @@ -30,6 +30,8 @@ const EMPTY_LIVE_ACCOUNT_SYNC_SNAPSHOT: LiveAccountSyncSnapshot = { let lastLiveAccountSyncSnapshot: LiveAccountSyncSnapshot = { ...EMPTY_LIVE_ACCOUNT_SYNC_SNAPSHOT, }; +let lastLiveAccountSyncSnapshotInstanceId = 0; +let nextLiveAccountSyncInstanceId = 0; export function getLastLiveAccountSyncSnapshot(): LiveAccountSyncSnapshot { return { ...lastLiveAccountSyncSnapshot }; @@ -37,6 +39,8 @@ export function getLastLiveAccountSyncSnapshot(): LiveAccountSyncSnapshot { export function __resetLastLiveAccountSyncSnapshotForTests(): void { lastLiveAccountSyncSnapshot = { ...EMPTY_LIVE_ACCOUNT_SYNC_SNAPSHOT }; + lastLiveAccountSyncSnapshotInstanceId = 0; + nextLiveAccountSyncInstanceId = 0; } /** @@ -86,6 +90,7 @@ function summarizeWatchPath(path: string | null): string { * changes. Uses fs.watch + polling fallback for Windows reliability. */ export class LiveAccountSync { + private readonly instanceId: number; private readonly reload: () => Promise; private readonly debounceMs: number; private readonly pollIntervalMs: number; @@ -104,13 +109,13 @@ export class LiveAccountSync { reload: () => Promise, options: LiveAccountSyncOptions = {}, ) { + this.instanceId = ++nextLiveAccountSyncInstanceId; this.reload = reload; this.debounceMs = Math.max(50, Math.floor(options.debounceMs ?? 250)); this.pollIntervalMs = Math.max( 500, Math.floor(options.pollIntervalMs ?? 2_000), ); - this.publishSnapshot(); } async syncToPath(path: string): Promise { @@ -191,6 +196,10 @@ export class LiveAccountSync { } private publishSnapshot(): void { + if (this.instanceId < lastLiveAccountSyncSnapshotInstanceId) { + return; + } + lastLiveAccountSyncSnapshotInstanceId = this.instanceId; lastLiveAccountSyncSnapshot = this.getSnapshot(); } diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 5abada00..f4d7b3e2 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -438,6 +438,60 @@ describe("codex-cli sync", () => { expect(result.storage?.accounts).toEqual(current.accounts); }); + it("reports skipped ambiguous source snapshots in the preview summary", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "shared-id", + auth: { + tokens: { + access_token: "access-new", + refresh_token: "refresh-new", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "shared-id", + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "shared-id", + email: "second@example.com", + refreshToken: "refresh-second", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(preview.status).toBe("noop"); + expect(preview.summary.sourceAccountCount).toBe(1); + expect(preview.statusDetail).toContain("1 source account skipped"); + }); + it("preserves the current selection when Codex CLI source has no active marker", async () => { await writeFile( accountsPath, @@ -576,6 +630,81 @@ describe("codex-cli sync", () => { expect(preview.summary.selectionChanged).toBe(false); }); + it("preserves the local selection when the persisted target timestamp is temporarily unreadable", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + vi.spyOn(storageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); + vi.spyOn(writerModule, "getLastCodexCliSelectionWriteTimestamp").mockReturnValue( + 0, + ); + vi.spyOn(storageModule, "getStoragePath").mockReturnValue("\0busy-target"); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(preview.status).toBe("noop"); + expect(preview.summary.selectionChanged).toBe(false); + }); + it("records a changed manual sync only after the caller commits persistence", async () => { await writeFile( accountsPath, diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 52dffe5a..8490a472 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2662,6 +2662,94 @@ describe("codex manager cli commands", () => { expect(saveAccountsMock).not.toHaveBeenCalled(); }); + it("keeps sync-center recoverable when its initial preview load fails", async () => { + setInteractiveTTY(true); + const storage = createSettingsStorage(Date.now()); + loadAccountsMock.mockResolvedValue(storage); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock.mockRejectedValue(makeErrnoError("busy", "EBUSY")); + + let selectCall = 0; + selectMock.mockImplementation(async (items) => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) { + const text = (items as Array<{ label?: string; hint?: string }>) + .map((item) => `${item.label ?? ""}\n${item.hint ?? ""}`) + .join("\n"); + expect(text).toContain("Status: error"); + expect(text).toContain("Failed to refresh sync center: busy"); + return { type: "back" }; + } + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(4); + }); + + it("keeps sync-center recoverable when refresh preview rebuild fails", async () => { + setInteractiveTTY(true); + const now = Date.now(); + const storage = createSettingsStorage(now); + loadAccountsMock.mockResolvedValue(storage); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock + .mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + sourceState: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }) + .mockRejectedValue(makeErrnoError("busy", "EBUSY")); + + let selectCall = 0; + selectMock.mockImplementation(async (items) => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) return { type: "refresh" }; + if (selectCall === 3) { + const text = (items as Array<{ label?: string; hint?: string }>) + .map((item) => `${item.label ?? ""}\n${item.hint ?? ""}`) + .join("\n"); + expect(text).toContain("Status: error"); + expect(text).toContain("Failed to refresh sync center: busy"); + return { type: "back" }; + } + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(5); + }); + it("applies sync-center writes with storage backups disabled when configured", async () => { setInteractiveTTY(true); const now = Date.now(); @@ -2795,6 +2883,118 @@ describe("codex manager cli commands", () => { expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); }); + it("retries transient sync-center save failures before committing the sync run", async () => { + setInteractiveTTY(true); + const now = Date.now(); + const storage = createSettingsStorage(now); + loadAccountsMock.mockResolvedValue(storage); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock + .mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + sourceState: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }) + .mockResolvedValueOnce({ + status: "noop", + statusDetail: "Target already matches the current one-way sync result.", + sourcePath: "/mock/codex/accounts.json", + sourceState: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 2, + targetAccountCountAfter: 2, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + applyCodexCliSyncToStorageMock.mockResolvedValueOnce({ + changed: true, + storage: { + ...storage, + accounts: [ + ...storage.accounts, + { + email: "codex@example.com", + accountId: "acc_codex", + refreshToken: "refresh-codex", + addedAt: now, + lastUsed: now, + }, + ], + }, + pendingRun: { + revision: 4, + run: { + outcome: "changed", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }, + }); + saveAccountsMock + .mockRejectedValueOnce(makeErrnoError("busy", "EBUSY")) + .mockResolvedValueOnce(undefined); + + let selectCall = 0; + selectMock.mockImplementation(async () => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) return { type: "apply" }; + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(saveAccountsMock).toHaveBeenCalledTimes(2); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledTimes(1); + expect(commitCodexCliSyncRunFailureMock).not.toHaveBeenCalled(); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); + }); + it("surfaces sync-center save failures distinctly from reconcile failures", async () => { setInteractiveTTY(true); const now = Date.now(); @@ -2919,7 +3119,7 @@ describe("codex manager cli commands", () => { storage, expect.objectContaining({ forceRefresh: true }), ); - expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledTimes(4); expect(commitPendingCodexCliSyncRunMock).not.toHaveBeenCalled(); expect(commitCodexCliSyncRunFailureMock).toHaveBeenCalledTimes(1); expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(1); diff --git a/test/live-account-sync.test.ts b/test/live-account-sync.test.ts index 883997f5..a4cefbb0 100644 --- a/test/live-account-sync.test.ts +++ b/test/live-account-sync.test.ts @@ -8,6 +8,35 @@ import { LiveAccountSync, } from "../lib/live-account-sync.js"; +const RETRYABLE_REMOVE_CODES = new Set([ + "EBUSY", + "EPERM", + "ENOTEMPTY", + "EACCES", + "ETIMEDOUT", +]); + +async function removeWithRetry( + targetPath: string, + options: { recursive?: boolean; force?: boolean }, +): Promise { + for (let attempt = 0; attempt < 6; attempt += 1) { + try { + await fs.rm(targetPath, options); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return; + } + if (!code || !RETRYABLE_REMOVE_CODES.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + describe("live-account-sync", () => { let workDir = ""; let storagePath = ""; @@ -32,7 +61,7 @@ describe("live-account-sync", () => { afterEach(async () => { vi.useRealTimers(); __resetLastLiveAccountSyncSnapshotForTests(); - await fs.rm(workDir, { recursive: true, force: true }); + await removeWithRetry(workDir, { recursive: true, force: true }); }); it("publishes watcher state for sync-center status surfaces", async () => { @@ -60,6 +89,55 @@ describe("live-account-sync", () => { ); }); + it("keeps the newest watcher snapshot published when older instances stop later", async () => { + const secondStoragePath = join(workDir, "openai-codex-accounts-secondary.json"); + await fs.writeFile( + secondStoragePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); + const first = new LiveAccountSync(async () => undefined, { + pollIntervalMs: 500, + debounceMs: 50, + }); + const second = new LiveAccountSync(async () => undefined, { + pollIntervalMs: 500, + debounceMs: 50, + }); + + await first.syncToPath(storagePath); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: storagePath, + running: true, + }), + ); + + await second.syncToPath(secondStoragePath); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: secondStoragePath, + running: true, + }), + ); + + first.stop(); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: secondStoragePath, + running: true, + }), + ); + + second.stop(); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: secondStoragePath, + running: false, + }), + ); + }); + it("reloads when file changes are detected by polling", async () => { const reload = vi.fn(async () => undefined); const sync = new LiveAccountSync(reload, { diff --git a/test/settings-hub-utils.test.ts b/test/settings-hub-utils.test.ts index d21d0b20..0a707fa0 100644 --- a/test/settings-hub-utils.test.ts +++ b/test/settings-hub-utils.test.ts @@ -305,6 +305,49 @@ describe("settings-hub utility coverage", () => { expect(overview[8]?.hint).toContain(".wal"); }); + it("matches windows-style source paths when labeling the active sync source", async () => { + const api = await loadSettingsHubTestApi(); + const overview = api.buildSyncCenterOverview( + { + status: "ready", + statusDetail: "Preview ready", + sourcePath: "C:\\Users\\Neil\\.codex\\Accounts.json", + targetPath: "C:\\Users\\Neil\\.codex\\openai-codex-accounts.json", + summary: { + addedAccountCount: 0, + updatedAccountCount: 0, + destinationOnlyPreservedCount: 1, + targetAccountCountAfter: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + rollbackPaths: [ + "C:\\Users\\Neil\\.codex\\openai-codex-accounts.json.bak", + ], + }, + lastSync: null, + }, + { + accountsPath: "c:/users/neil/.codex/accounts.json", + authPath: "c:/users/neil/.codex/auth.json", + configPath: "c:/users/neil/.codex/config.toml", + state: { accounts: [{}] }, + liveSync: { + path: null, + running: false, + lastKnownMtimeMs: null, + lastSyncAt: null, + reloadCount: 0, + errorCount: 0, + }, + syncEnabled: true, + }, + ); + + expect(overview[2]?.label).toContain("accounts.json active"); + }); + it("formats layout mode labels", async () => { const api = await loadSettingsHubTestApi(); expect(api.formatMenuLayoutMode("expanded-rows")).toBe("Expanded Rows"); From 3c70c6b7c6e37eedfde5ba12815f4002351187f2 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 02:08:14 +0800 Subject: [PATCH 08/10] fix(sync): finish remaining PR audit follow-ups --- docs/reference/settings.md | 7 + lib/codex-cli/sync.ts | 4 +- test/accounts-load-from-disk.test.ts | 3 + test/codex-cli-sync.test.ts | 210 +++++++++++++++++++++++++++ 4 files changed, 222 insertions(+), 2 deletions(-) diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 4781b957..aaede2c3 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -67,6 +67,7 @@ Controls display style: ## Sync Center The settings hub includes a preview-first sync center for Codex CLI account sync. +See [upgrade notes](../upgrade.md) for sync workflow changes. Before applying sync, it shows: @@ -77,6 +78,12 @@ Before applying sync, it shows: - destination-only preservation behavior - backup and rollback context (`.bak`, `.bak.1`, `.bak.2`, `.wal`) +Validation: + +- `npm run typecheck` +- `npm run build` +- `npm test` + --- ## Experimental diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index e8e3ed65..6e8b2658 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -147,7 +147,7 @@ function publishCodexCliSyncRun( run: CodexCliSyncRun, revision: number, ): boolean { - if (revision < lastCodexCliSyncRunRevision) { + if (revision <= lastCodexCliSyncRunRevision) { return false; } lastCodexCliSyncRunRevision = revision; @@ -675,7 +675,7 @@ export async function applyCodexCliSyncToStorage( } const state = await loadCodexCliState({ - forceRefresh: options.forceRefresh, + forceRefresh: options.forceRefresh ?? true, }); if (!state) { incrementCodexCliMetric("reconcileNoops"); diff --git a/test/accounts-load-from-disk.test.ts b/test/accounts-load-from-disk.test.ts index d69e9ada..defc840b 100644 --- a/test/accounts-load-from-disk.test.ts +++ b/test/accounts-load-from-disk.test.ts @@ -47,6 +47,7 @@ describe("AccountManager loadFromDisk", () => { vi.mocked(syncAccountStorageFromCodexCli).mockResolvedValue({ changed: false, storage: null, + pendingRun: null, }); vi.mocked(loadCodexCliState).mockResolvedValue(null); vi.mocked(setCodexCliActiveSelection).mockResolvedValue(undefined); @@ -72,6 +73,7 @@ describe("AccountManager loadFromDisk", () => { vi.mocked(syncAccountStorageFromCodexCli).mockResolvedValue({ changed: true, storage: synced, + pendingRun: null, }); const manager = await AccountManager.loadFromDisk(); @@ -92,6 +94,7 @@ describe("AccountManager loadFromDisk", () => { vi.mocked(syncAccountStorageFromCodexCli).mockResolvedValue({ changed: true, storage: synced, + pendingRun: null, }); vi.mocked(saveAccounts).mockRejectedValueOnce(new Error("forced persist failure")); diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index f4d7b3e2..9c13c2b8 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -856,6 +856,161 @@ describe("codex-cli sync", () => { } }); + it("forces a fresh Codex CLI state read on apply when forceRefresh is omitted", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + await previewCodexCliSync(current, { forceRefresh: true }); + + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_c", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + { + accountId: "acc_c", + email: "c@example.com", + auth: { + tokens: { + access_token: "access-c", + refresh_token: "refresh-c", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const loadSpy = vi.spyOn(codexCliState, "loadCodexCliState"); + try { + const result = await applyCodexCliSyncToStorage(current); + expect(loadSpy).toHaveBeenCalledWith( + expect.objectContaining({ forceRefresh: true }), + ); + expect(result.changed).toBe(true); + expect(result.storage?.accounts.map((account) => account.accountId)).toEqual([ + "acc_a", + "acc_b", + "acc_c", + ]); + expect(result.storage?.activeIndex).toBe(2); + } finally { + loadSpy.mockRestore(); + } + }); + + it("returns isolated pending runs for concurrent apply attempts", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const [first, second] = await Promise.all([ + applyCodexCliSyncToStorage(current), + applyCodexCliSyncToStorage(current), + ]); + + expect(first.changed).toBe(true); + expect(second.changed).toBe(true); + expect(first.pendingRun).not.toBeNull(); + expect(second.pendingRun).not.toBeNull(); + expect(first.pendingRun?.revision).not.toBe(second.pendingRun?.revision); + expect(first.storage?.accounts.map((account) => account.accountId)).toEqual([ + "acc_a", + "acc_b", + ]); + expect(second.storage?.accounts.map((account) => account.accountId)).toEqual([ + "acc_a", + "acc_b", + ]); + expect(getLastCodexCliSyncRun()).toBeNull(); + }); + it("records a manual sync save failure over a pending changed run", async () => { await writeFile( accountsPath, @@ -907,6 +1062,61 @@ describe("codex-cli sync", () => { expect(lastRun?.summary.addedAccountCount).toBe(1); }); + it("ignores a duplicate sync-run publish for the same revision", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await applyCodexCliSyncToStorage(current); + expect(result.pendingRun).not.toBeNull(); + + commitPendingCodexCliSyncRun(result.pendingRun); + const committedRun = getLastCodexCliSyncRun(); + + commitCodexCliSyncRunFailure( + result.pendingRun, + new Error("should not overwrite committed run"), + ); + + expect(getLastCodexCliSyncRun()).toEqual(committedRun); + expect(getLastCodexCliSyncRun()?.outcome).toBe("changed"); + }); + it("serializes concurrent active-selection writes to keep accounts/auth aligned", async () => { await writeFile( accountsPath, From d7d5ffdf1efb5e3de7afccb76a409d3c02c3c900 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 02:12:39 +0800 Subject: [PATCH 09/10] fix(sync): harden remaining sync center retries --- lib/codex-cli/sync.ts | 2 +- lib/codex-manager/settings-hub.ts | 12 ++- test/codex-manager-cli.test.ts | 138 ++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 8 deletions(-) diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 6e8b2658..b4d1fbcb 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -43,7 +43,7 @@ function cloneStorage(storage: AccountStorageV3): AccountStorageV3 { }; } -function formatRollbackPaths(targetPath: string): string[] { +export function formatRollbackPaths(targetPath: string): string[] { return [ `${targetPath}.bak`, `${targetPath}.bak.1`, diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index fbb40dd4..50e2a5c0 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -15,6 +15,7 @@ import { type CodexCliSyncPreview, type CodexCliSyncRun, type CodexCliSyncSummary, + formatRollbackPaths, getLastCodexCliSyncRun, previewCodexCliSync, } from "../codex-cli/sync.js"; @@ -2732,12 +2733,7 @@ async function promptSyncCenter(config: PluginConfig): Promise { backup: { enabled: getStorageBackupEnabled(config), targetPath, - rollbackPaths: [ - `${targetPath}.bak`, - `${targetPath}.bak.1`, - `${targetPath}.bak.2`, - `${targetPath}.wal`, - ], + rollbackPaths: formatRollbackPaths(targetPath), }, lastSync: getLastCodexCliSyncRun(), }, @@ -2833,7 +2829,9 @@ async function promptSyncCenter(config: PluginConfig): Promise { } try { - const current = await loadAccounts(); + const current = await withQueuedRetry(preview.targetPath, async () => + loadAccounts(), + ); const synced = await applyCodexCliSyncToStorage(current, { forceRefresh: true, }); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 8490a472..e132b22b 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -22,6 +22,12 @@ const previewCodexCliSyncMock = vi.fn(); const applyCodexCliSyncToStorageMock = vi.fn(); const commitPendingCodexCliSyncRunMock = vi.fn(); const commitCodexCliSyncRunFailureMock = vi.fn(); +const formatRollbackPathsMock = vi.fn((targetPath: string) => [ + `${targetPath}.bak`, + `${targetPath}.bak.1`, + `${targetPath}.bak.2`, + `${targetPath}.wal`, +]); const getLastCodexCliSyncRunMock = vi.fn(); const getCodexCliAccountsPathMock = vi.fn(() => "/mock/codex/accounts.json"); const getCodexCliAuthPathMock = vi.fn(() => "/mock/codex/auth.json"); @@ -133,6 +139,7 @@ vi.mock("../lib/codex-cli/sync.js", () => ({ applyCodexCliSyncToStorage: applyCodexCliSyncToStorageMock, commitCodexCliSyncRunFailure: commitCodexCliSyncRunFailureMock, commitPendingCodexCliSyncRun: commitPendingCodexCliSyncRunMock, + formatRollbackPaths: formatRollbackPathsMock, getLastCodexCliSyncRun: getLastCodexCliSyncRunMock, previewCodexCliSync: previewCodexCliSyncMock, })); @@ -493,6 +500,13 @@ describe("codex manager cli commands", () => { applyCodexCliSyncToStorageMock.mockReset(); commitPendingCodexCliSyncRunMock.mockReset(); commitCodexCliSyncRunFailureMock.mockReset(); + formatRollbackPathsMock.mockReset(); + formatRollbackPathsMock.mockImplementation((targetPath: string) => [ + `${targetPath}.bak`, + `${targetPath}.bak.1`, + `${targetPath}.bak.2`, + `${targetPath}.wal`, + ]); getLastCodexCliSyncRunMock.mockReset(); getCodexCliAccountsPathMock.mockReset(); getCodexCliAuthPathMock.mockReset(); @@ -2995,6 +3009,130 @@ describe("codex manager cli commands", () => { expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); }); + it("retries transient sync-center apply-time reads before running the sync", async () => { + setInteractiveTTY(true); + const now = Date.now(); + const storage = createSettingsStorage(now); + let failNextApplyRead = false; + loadAccountsMock.mockImplementation(async () => { + if (failNextApplyRead) { + failNextApplyRead = false; + throw makeErrnoError("busy", "EBUSY"); + } + return structuredClone(storage); + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock + .mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + sourceState: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }) + .mockResolvedValueOnce({ + status: "noop", + statusDetail: "Target already matches the current one-way sync result.", + sourcePath: "/mock/codex/accounts.json", + sourceState: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 2, + targetAccountCountAfter: 2, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + applyCodexCliSyncToStorageMock.mockResolvedValueOnce({ + changed: true, + storage: { + ...storage, + accounts: [ + ...storage.accounts, + { + email: "codex@example.com", + accountId: "acc_codex", + refreshToken: "refresh-codex", + addedAt: now, + lastUsed: now, + }, + ], + }, + pendingRun: { + revision: 5, + run: { + outcome: "changed", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }, + }); + + let selectCall = 0; + selectMock.mockImplementation(async () => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) { + failNextApplyRead = true; + return { type: "apply" }; + } + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(loadAccountsMock.mock.calls.length).toBeGreaterThanOrEqual(3); + expect(applyCodexCliSyncToStorageMock).toHaveBeenCalledWith( + storage, + expect.objectContaining({ forceRefresh: true }), + ); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledTimes(1); + expect(commitCodexCliSyncRunFailureMock).not.toHaveBeenCalled(); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); + }); + it("surfaces sync-center save failures distinctly from reconcile failures", async () => { setInteractiveTTY(true); const now = Date.now(); From 384f058024412d851f1151946a660930d6eaade2 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 02:45:06 +0800 Subject: [PATCH 10/10] fix(sync): close remaining review regressions --- lib/codex-cli/sync.ts | 27 +++-- lib/codex-manager/settings-hub.ts | 24 ++-- test/codex-cli-sync.test.ts | 188 ++++++++++++++++++++++++++++++ test/codex-manager-cli.test.ts | 140 ++++++++++++++++++++-- test/settings-hub-utils.test.ts | 4 +- 5 files changed, 348 insertions(+), 35 deletions(-) diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index b4d1fbcb..cb56aeaa 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -15,7 +15,6 @@ import { } from "./observability.js"; import { type CodexCliAccountSnapshot, - type CodexCliState, isCodexCliSyncEnabled, loadCodexCliState, } from "./state.js"; @@ -73,7 +72,7 @@ export interface CodexCliSyncPreview { status: "ready" | "noop" | "disabled" | "unavailable" | "error"; statusDetail: string; sourcePath: string | null; - sourceState: CodexCliState | null; + sourceAccountCount: number | null; targetPath: string; summary: CodexCliSyncSummary; backup: CodexCliSyncBackupContext; @@ -317,11 +316,21 @@ function resolveActiveIndex( ); } -function writeFamilyIndexes(storage: AccountStorageV3, index: number): void { +function applyCodexCliSelection( + storage: AccountStorageV3, + index: number, +): void { + const previousActiveIndex = normalizeIndexCandidate(storage.activeIndex, 0); storage.activeIndex = index; storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = index; + const raw = storage.activeIndexByFamily[family]; + if ( + typeof raw === "number" && + normalizeIndexCandidate(raw, previousActiveIndex) === previousActiveIndex + ) { + storage.activeIndexByFamily[family] = index; + } } } @@ -499,7 +508,7 @@ function reconcileCodexCliState( state.activeEmail ?? activeFromSnapshots.email, ); if (typeof desiredIndex === "number") { - writeFamilyIndexes(next, desiredIndex); + applyCodexCliSelection(next, desiredIndex); } else if ( state.activeAccountId || state.activeEmail || @@ -559,7 +568,7 @@ export async function previewCodexCliSync( status: "disabled", statusDetail: "Codex CLI sync is disabled by environment override.", sourcePath: null, - sourceState: null, + sourceAccountCount: null, targetPath, summary: emptySummary, backup, @@ -574,7 +583,7 @@ export async function previewCodexCliSync( status: "unavailable", statusDetail: "No Codex CLI sync source was found.", sourcePath: null, - sourceState: null, + sourceAccountCount: null, targetPath, summary: emptySummary, backup, @@ -604,7 +613,7 @@ export async function previewCodexCliSync( status, statusDetail, sourcePath: state.path, - sourceState: state, + sourceAccountCount: state.accounts.length, targetPath, summary: reconciled.summary, backup, @@ -615,7 +624,7 @@ export async function previewCodexCliSync( status: "error", statusDetail: error instanceof Error ? error.message : String(error), sourcePath: null, - sourceState: null, + sourceAccountCount: null, targetPath, summary: emptySummary, backup, diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 50e2a5c0..30ca1d3f 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -2,7 +2,6 @@ import { promises as fs } from "node:fs"; import { stdin as input, stdout as output } from "node:process"; import { createInterface } from "node:readline/promises"; import { - type CodexCliState, getCodexCliAccountsPath, getCodexCliAuthPath, getCodexCliConfigPath, @@ -309,7 +308,7 @@ interface SyncCenterOverviewContext { accountsPath: string; authPath: string; configPath: string; - state: CodexCliState | null; + sourceAccountCount: number | null; liveSync: LiveAccountSyncSnapshot; syncEnabled: boolean; } @@ -1324,13 +1323,13 @@ function formatSyncMtime(mtimeMs: number | null): string { } function resolveSyncCenterContext( - state: CodexCliState | null, + sourceAccountCount: number | null, ): SyncCenterOverviewContext { return { accountsPath: getCodexCliAccountsPath(), authPath: getCodexCliAuthPath(), configPath: getCodexCliConfigPath(), - state, + sourceAccountCount, liveSync: getLastLiveAccountSyncSnapshot(), syncEnabled: isCodexCliSyncEnabled(), }; @@ -1381,8 +1380,8 @@ function buildSyncCenterOverview( `Accounts path: ${context.accountsPath}`, `Auth path: ${context.authPath}`, `Config path: ${context.configPath}`, - context.state - ? `Visible source accounts: ${context.state.accounts.length}.` + context.sourceAccountCount !== null + ? `Visible source accounts: ${context.sourceAccountCount}.` : "No readable Codex CLI source is visible right now.", ].join("\n"); const selectionHint = preview.summary.selectionChanged @@ -2689,7 +2688,7 @@ async function promptSyncCenter(config: PluginConfig): Promise { }); return { preview, - context: resolveSyncCenterContext(preview.sourceState), + context: resolveSyncCenterContext(preview.sourceAccountCount), }; }; const buildErrorState = ( @@ -2707,7 +2706,7 @@ async function promptSyncCenter(config: PluginConfig): Promise { status: "error", statusDetail: message, }, - context: resolveSyncCenterContext(previousPreview.sourceState), + context: resolveSyncCenterContext(previousPreview.sourceAccountCount), }; } @@ -2727,7 +2726,7 @@ async function promptSyncCenter(config: PluginConfig): Promise { status: "error", statusDetail: message, sourcePath: null, - sourceState: null, + sourceAccountCount: null, targetPath, summary: emptySummary, backup: { @@ -2836,7 +2835,6 @@ async function promptSyncCenter(config: PluginConfig): Promise { forceRefresh: true, }); const storageBackupEnabled = getStorageBackupEnabled(config); - const nextStorage = synced.storage ?? current; if (synced.changed && synced.storage) { const syncedStorage = synced.storage; try { @@ -2859,11 +2857,7 @@ async function promptSyncCenter(config: PluginConfig): Promise { continue; } } - preview = await previewCodexCliSync(nextStorage, { - forceRefresh: true, - storageBackupEnabled, - }); - context = resolveSyncCenterContext(preview.sourceState); + ({ preview, context } = await buildPreviewSafely(true, preview)); } catch (error) { preview = { ...preview, diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 9c13c2b8..55092627 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -705,6 +705,101 @@ describe("codex-cli sync", () => { expect(preview.summary.selectionChanged).toBe(false); }); + it.each(["EBUSY", "EPERM"] as const)( + "preserves the local selection when reading the persisted target timestamp fails with %s", + async (code) => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + vi.spyOn(storageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); + vi.spyOn( + writerModule, + "getLastCodexCliSelectionWriteTimestamp", + ).mockReturnValue(0); + vi.spyOn(storageModule, "getStoragePath").mockReturnValue(targetStoragePath); + const statError = new Error(`${code.toLowerCase()} target`) as NodeJS.ErrnoException; + statError.code = code; + const nodeFs = await import("node:fs"); + const originalStat = nodeFs.promises.stat.bind(nodeFs.promises); + const statSpy = vi + .spyOn(nodeFs.promises, "stat") + .mockImplementation(async (...args: Parameters) => { + if (args[0] === targetStoragePath) { + throw statError; + } + return originalStat(...args); + }); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + try { + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(preview.status).toBe("noop"); + expect(preview.summary.selectionChanged).toBe(false); + } finally { + statSpy.mockRestore(); + } + }, + ); + it("records a changed manual sync only after the caller commits persistence", async () => { await writeFile( accountsPath, @@ -856,6 +951,99 @@ describe("codex-cli sync", () => { } }); + it("preserves explicit per-family selections when Codex CLI updates the global selection", async () => { + const alternateFamily = MODEL_FAMILIES.find((family) => family !== "codex"); + expect(alternateFamily).toBeDefined(); + if (!alternateFamily) { + return; + } + + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + { + accountId: "acc_c", + email: "c@example.com", + auth: { + tokens: { + access_token: "access-c", + refresh_token: "refresh-c", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_c", + email: "c@example.com", + refreshToken: "refresh-c", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { + codex: 0, + [alternateFamily]: 2, + }, + }; + + const result = await applyCodexCliSyncToStorage(current, { + forceRefresh: true, + }); + + expect(result.changed).toBe(true); + expect(result.storage?.activeIndex).toBe(1); + expect(result.storage?.activeIndexByFamily?.codex).toBe(1); + expect(result.storage?.activeIndexByFamily?.[alternateFamily]).toBe(2); + }); + it("forces a fresh Codex CLI state read on apply when forceRefresh is omitted", async () => { await writeFile( accountsPath, diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index e132b22b..7e187ccf 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -615,7 +615,7 @@ describe("codex manager cli commands", () => { status: "unavailable", statusDetail: "No Codex CLI sync source was found.", sourcePath: null, - sourceState: null, + sourceAccountCount: null, targetPath: "/mock/openai-codex-accounts.json", summary: { sourceAccountCount: 0, @@ -2720,7 +2720,7 @@ describe("codex manager cli commands", () => { status: "ready", statusDetail: "Preview ready.", sourcePath: "/mock/codex/accounts.json", - sourceState: null, + sourceAccountCount: null, targetPath: "/mock/openai-codex-accounts.json", summary: { sourceAccountCount: 1, @@ -2794,7 +2794,7 @@ describe("codex manager cli commands", () => { status: "ready", statusDetail: "Preview ready.", sourcePath: "/mock/codex/accounts.json", - sourceState: null, + sourceAccountCount: null, targetPath: "/mock/openai-codex-accounts.json", summary: { sourceAccountCount: 1, @@ -2817,7 +2817,7 @@ describe("codex manager cli commands", () => { status: "noop", statusDetail: "Target already matches the current one-way sync result.", sourcePath: "/mock/codex/accounts.json", - sourceState: null, + sourceAccountCount: null, targetPath: "/mock/openai-codex-accounts.json", summary: { sourceAccountCount: 1, @@ -2897,6 +2897,128 @@ describe("codex manager cli commands", () => { expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); }); + it("rebuilds the sync-center preview from reloaded disk storage after apply", async () => { + setInteractiveTTY(true); + const now = Date.now(); + const storage = createSettingsStorage(now); + const syncedStorage = { + ...storage, + accounts: [ + ...storage.accounts, + { + email: "codex@example.com", + accountId: "acc_codex", + refreshToken: "refresh-codex", + addedAt: now, + lastUsed: now, + }, + ], + }; + const persistedStorage = { + ...syncedStorage, + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + loadAccountsMock.mockImplementation(async () => + saveAccountsMock.mock.calls.length > 0 ? persistedStorage : storage, + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock + .mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: 1, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: true, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }) + .mockResolvedValueOnce({ + status: "noop", + statusDetail: "Target already matches the current one-way sync result.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: 1, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 2, + targetAccountCountAfter: 2, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + applyCodexCliSyncToStorageMock.mockResolvedValueOnce({ + changed: true, + storage: syncedStorage, + pendingRun: { + revision: 6, + run: { + outcome: "changed", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: true, + }, + }, + }, + }); + + let selectCall = 0; + selectMock.mockImplementation(async () => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) return { type: "apply" }; + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(loadAccountsMock.mock.calls.length).toBeGreaterThanOrEqual(3); + expect(saveAccountsMock).toHaveBeenCalledWith( + syncedStorage, + expect.objectContaining({ backupEnabled: true }), + ); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); + expect(previewCodexCliSyncMock.mock.calls[1]?.[0]).toBe(persistedStorage); + expect(previewCodexCliSyncMock.mock.calls[1]?.[0]).not.toBe(syncedStorage); + }); + it("retries transient sync-center save failures before committing the sync run", async () => { setInteractiveTTY(true); const now = Date.now(); @@ -2910,7 +3032,7 @@ describe("codex manager cli commands", () => { status: "ready", statusDetail: "Preview ready.", sourcePath: "/mock/codex/accounts.json", - sourceState: null, + sourceAccountCount: null, targetPath: "/mock/openai-codex-accounts.json", summary: { sourceAccountCount: 1, @@ -2933,7 +3055,7 @@ describe("codex manager cli commands", () => { status: "noop", statusDetail: "Target already matches the current one-way sync result.", sourcePath: "/mock/codex/accounts.json", - sourceState: null, + sourceAccountCount: null, targetPath: "/mock/openai-codex-accounts.json", summary: { sourceAccountCount: 1, @@ -3029,7 +3151,7 @@ describe("codex manager cli commands", () => { status: "ready", statusDetail: "Preview ready.", sourcePath: "/mock/codex/accounts.json", - sourceState: null, + sourceAccountCount: null, targetPath: "/mock/openai-codex-accounts.json", summary: { sourceAccountCount: 1, @@ -3052,7 +3174,7 @@ describe("codex manager cli commands", () => { status: "noop", statusDetail: "Target already matches the current one-way sync result.", sourcePath: "/mock/codex/accounts.json", - sourceState: null, + sourceAccountCount: null, targetPath: "/mock/openai-codex-accounts.json", summary: { sourceAccountCount: 1, @@ -3161,7 +3283,7 @@ describe("codex manager cli commands", () => { status: "ready", statusDetail: "Preview ready.", sourcePath: "/mock/codex/accounts.json", - sourceState: null, + sourceAccountCount: null, targetPath: "/mock/openai-codex-accounts.json", summary: { sourceAccountCount: 1, diff --git a/test/settings-hub-utils.test.ts b/test/settings-hub-utils.test.ts index 0a707fa0..55048c4e 100644 --- a/test/settings-hub-utils.test.ts +++ b/test/settings-hub-utils.test.ts @@ -277,7 +277,7 @@ describe("settings-hub utility coverage", () => { accountsPath: "/tmp/source/accounts.json", authPath: "/tmp/source/auth.json", configPath: "/tmp/source/config.toml", - state: { accounts: [{}] }, + sourceAccountCount: 1, liveSync: { path: "/tmp/target/openai-codex-accounts.json", running: true, @@ -332,7 +332,7 @@ describe("settings-hub utility coverage", () => { accountsPath: "c:/users/neil/.codex/accounts.json", authPath: "c:/users/neil/.codex/auth.json", configPath: "c:/users/neil/.codex/config.toml", - state: { accounts: [{}] }, + sourceAccountCount: 1, liveSync: { path: null, running: false,