From 769fe338e5d97d3a0f924ba7cfaf6c319d4bb4a8 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 12 Mar 2026 22:29:40 +0800 Subject: [PATCH 1/2] feat(sync): add manual rollback --- lib/codex-cli/sync.ts | 265 ++++++++++++++++++++++++++++-- lib/codex-manager/settings-hub.ts | 60 ++++++- lib/storage.ts | 3 +- lib/ui/copy.ts | 3 +- test/codex-cli-sync.test.ts | 243 ++++++++++++++++++++++++++- 5 files changed, 555 insertions(+), 19 deletions(-) diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 53c5bc4..279ef5a 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -1,3 +1,4 @@ +import { existsSync, promises as fs } from "node:fs"; import { createLogger } from "../logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; import { @@ -5,11 +6,17 @@ import { type AccountStorageV3, getLastAccountsSaveTimestamp, getStoragePath, + type NamedBackupMetadata, + normalizeAccountStorage, + restoreNamedBackup, + saveAccounts, + snapshotAccountStorage, } from "../storage.js"; import { appendSyncHistoryEntry, cloneSyncHistoryEntry, readLatestSyncHistorySync, + readSyncHistory, } from "../sync-history.js"; import { incrementCodexCliMetric, @@ -66,6 +73,37 @@ export interface CodexCliSyncSummary { selectionChanged: boolean; } +export type CodexCliSyncTrigger = "manual" | "automatic"; + +export interface CodexCliSyncRollbackSnapshot { + name: string; + path: string; +} + +export interface CodexCliSyncRollbackExecutionResult { + snapshot: CodexCliSyncRollbackSnapshot; + restore: { + imported: number; + total: number; + skipped: number; + }; +} + +export interface CodexCliSyncRollbackPlan { + status: "ready" | "unavailable"; + reason: string; + snapshot: CodexCliSyncRollbackSnapshot | null; + accountCount?: number; + storage?: AccountStorageV3; +} + +export interface CodexCliSyncRollbackPlanResult { + status: "restored" | "unavailable" | "error"; + reason: string; + snapshot: CodexCliSyncRollbackSnapshot | null; + accountCount?: number; +} + export interface CodexCliSyncBackupContext { enabled: boolean; targetPath: string; @@ -89,6 +127,8 @@ export interface CodexCliSyncRun { targetPath: string; summary: CodexCliSyncSummary; message?: string; + trigger: CodexCliSyncTrigger; + rollbackSnapshot: CodexCliSyncRollbackSnapshot | null; } type UpsertAction = "skipped" | "added" | "updated" | "unchanged"; @@ -107,6 +147,33 @@ interface ReconcileResult { let lastCodexCliSyncRun: CodexCliSyncRun | null = null; let lastHistoryLoadAttempted = false; +function normalizeCodexCliSyncRun( + run: CodexCliSyncRun | null, +): CodexCliSyncRun | null { + if (!run) return null; + return { + ...run, + trigger: run.trigger ?? "automatic", + summary: { ...run.summary }, + rollbackSnapshot: run.rollbackSnapshot ? { ...run.rollbackSnapshot } : null, + }; +} + +function cloneCodexCliSyncRun( + run: CodexCliSyncRun | null, +): CodexCliSyncRun | null { + const normalized = normalizeCodexCliSyncRun(run); + return normalized + ? { + ...normalized, + summary: { ...normalized.summary }, + rollbackSnapshot: normalized.rollbackSnapshot + ? { ...normalized.rollbackSnapshot } + : null, + } + : null; +} + function createEmptySyncSummary(): CodexCliSyncSummary { return { sourceAccountCount: 0, @@ -120,8 +187,166 @@ function createEmptySyncSummary(): CodexCliSyncSummary { }; } +async function captureRollbackSnapshot(): Promise { + const snapshot: NamedBackupMetadata | null = await snapshotAccountStorage({ + reason: "codex-cli-sync", + failurePolicy: "warn", + }); + if (!snapshot) return null; + return { name: snapshot.name, path: snapshot.path }; +} + +async function findLatestManualCodexCliSyncRun(): Promise { + const history = await readSyncHistory({ kind: "codex-cli-sync" }); + for (let i = history.length - 1; i >= 0; i -= 1) { + const entry = history[i]; + if (!entry || entry.kind !== "codex-cli-sync") continue; + const normalized = normalizeCodexCliSyncRun(entry.run); + if (!normalized) continue; + if (normalized.trigger !== "manual") continue; + return normalized; + } + return null; +} + +export async function rollbackLastCodexCliSync(): Promise { + const lastRun = await findLatestManualCodexCliSyncRun(); + if (!lastRun) { + throw new Error( + "No manual Codex CLI sync run with a rollback snapshot is available to restore.", + ); + } + const snapshot = lastRun.rollbackSnapshot; + if (!snapshot) { + throw new Error( + "Latest manual Codex CLI sync run did not record a rollback snapshot.", + ); + } + if (!snapshot.name || snapshot.name.trim().length === 0) { + throw new Error( + "Rollback snapshot name is missing from the recorded manual sync run.", + ); + } + if (!snapshot.path || snapshot.path.trim().length === 0) { + throw new Error( + "Rollback snapshot path is missing from the recorded manual sync run.", + ); + } + if (!existsSync(snapshot.path)) { + throw new Error(`Rollback snapshot not found at ${snapshot.path}`); + } + const restoreResult = await restoreNamedBackup(snapshot.name); + return { + snapshot, + restore: restoreResult, + }; +} + +function formatRollbackPlanFromSnapshot( + snapshot: CodexCliSyncRollbackSnapshot | null, +): CodexCliSyncRollbackPlan { + if (!snapshot) { + return { + status: "unavailable", + reason: + "Latest manual Codex CLI sync did not capture a rollback checkpoint to restore.", + snapshot: null, + }; + } + + return { + status: "ready", + reason: "Rollback checkpoint is ready.", + snapshot, + }; +} + +async function loadRollbackSnapshot( + snapshot: CodexCliSyncRollbackSnapshot | null, +): Promise { + if (!snapshot) return formatRollbackPlanFromSnapshot(snapshot); + + try { + const raw = await fs.readFile(snapshot.path, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + const normalized = normalizeAccountStorage(parsed); + if (!normalized) { + return { + status: "unavailable", + reason: "Rollback checkpoint is invalid or empty.", + snapshot, + }; + } + return { + status: "ready", + reason: `Rollback checkpoint ready (${normalized.accounts.length} account(s)).`, + snapshot, + accountCount: normalized.accounts.length, + storage: normalized, + }; + } catch (error) { + const reason = + (error as NodeJS.ErrnoException).code === "ENOENT" + ? `Rollback checkpoint is missing at ${snapshot.path}.` + : `Failed to read rollback checkpoint: ${ + error instanceof Error ? error.message : String(error) + }`; + return { + status: "unavailable", + reason, + snapshot, + }; + } +} + +export async function getLatestCodexCliSyncRollbackPlan(): Promise { + const lastManualRun = await findLatestManualCodexCliSyncRun(); + if (!lastManualRun) { + return { + status: "unavailable", + reason: + "No manual Codex CLI sync with a rollback checkpoint is available.", + snapshot: null, + }; + } + return loadRollbackSnapshot(lastManualRun.rollbackSnapshot); +} + +export async function rollbackLatestCodexCliSync( + plan?: CodexCliSyncRollbackPlan, +): Promise { + const resolvedPlan = + plan && plan.status === "ready" + ? plan + : await getLatestCodexCliSyncRollbackPlan(); + if (resolvedPlan.status !== "ready" || !resolvedPlan.storage) { + return { + status: "unavailable", + reason: resolvedPlan.reason, + snapshot: resolvedPlan.snapshot, + }; + } + + try { + await saveAccounts(resolvedPlan.storage); + return { + status: "restored", + reason: resolvedPlan.reason, + snapshot: resolvedPlan.snapshot, + accountCount: + resolvedPlan.accountCount ?? resolvedPlan.storage.accounts.length, + }; + } catch (error) { + return { + status: "error", + reason: error instanceof Error ? error.message : String(error), + snapshot: resolvedPlan.snapshot, + }; + } +} + async function setLastCodexCliSyncRun(run: CodexCliSyncRun): Promise { - lastCodexCliSyncRun = run; + lastCodexCliSyncRun = normalizeCodexCliSyncRun(run); try { await appendSyncHistoryEntry({ kind: "codex-cli-sync", @@ -136,32 +361,27 @@ async function setLastCodexCliSyncRun(run: CodexCliSyncRun): Promise { } export function getLastCodexCliSyncRun(): CodexCliSyncRun | null { - if (lastCodexCliSyncRun) { - return { - ...lastCodexCliSyncRun, - summary: { ...lastCodexCliSyncRun.summary }, - }; - } + const cached = cloneCodexCliSyncRun(lastCodexCliSyncRun); + if (cached) return cached; if (!lastHistoryLoadAttempted) { lastHistoryLoadAttempted = true; const latest = readLatestSyncHistorySync(); const cloned = cloneSyncHistoryEntry(latest); if (cloned?.kind === "codex-cli-sync") { - lastCodexCliSyncRun = cloned.run; - return { - ...cloned.run, - summary: { ...cloned.run.summary }, - }; + lastCodexCliSyncRun = normalizeCodexCliSyncRun(cloned.run); + return cloneCodexCliSyncRun(lastCodexCliSyncRun); } } return null; } -export function __resetLastCodexCliSyncRunForTests(): void { - lastCodexCliSyncRun = null; - lastHistoryLoadAttempted = false; +export function __resetLastCodexCliSyncRunForTests( + run: CodexCliSyncRun | null = null, +): void { + lastCodexCliSyncRun = normalizeCodexCliSyncRun(run); + lastHistoryLoadAttempted = run !== null; } function buildIndexByAccountId( @@ -553,9 +773,12 @@ export async function previewCodexCliSync( */ export async function syncAccountStorageFromCodexCli( current: AccountStorageV3 | null, + options: { trigger?: CodexCliSyncTrigger } = {}, ): Promise<{ storage: AccountStorageV3 | null; changed: boolean }> { incrementCodexCliMetric("reconcileAttempts"); const targetPath = getStoragePath(); + const trigger: CodexCliSyncTrigger = options.trigger ?? "automatic"; + let rollbackSnapshot: CodexCliSyncRollbackSnapshot | null = null; try { const state = await loadCodexCliState(); if (!state) { @@ -577,10 +800,16 @@ export async function syncAccountStorageFromCodexCli( (process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI ?? "").trim() === "0" ? "Codex CLI sync disabled by environment override." : "No Codex CLI sync source was available.", + trigger, + rollbackSnapshot, }); return { storage: current, changed: false }; } + if (trigger === "manual") { + rollbackSnapshot = await captureRollbackSnapshot(); + } + const reconciled = reconcileCodexCliState(current, state); const next = reconciled.next; const changed = reconciled.changed; @@ -593,6 +822,8 @@ export async function syncAccountStorageFromCodexCli( sourcePath: state.path, targetPath, summary: reconciled.summary, + trigger, + rollbackSnapshot, }); log.debug("Codex CLI reconcile completed", { operation: "reconcile-storage", @@ -613,6 +844,8 @@ export async function syncAccountStorageFromCodexCli( sourcePath: state.path, targetPath, summary: reconciled.summary, + trigger, + rollbackSnapshot, }); log.debug("Codex CLI reconcile completed", { operation: "reconcile-storage", @@ -640,6 +873,8 @@ export async function syncAccountStorageFromCodexCli( targetAccountCountAfter: current?.accounts.length ?? 0, }, message: error instanceof Error ? error.message : String(error), + trigger, + rollbackSnapshot, }); log.warn("Codex CLI reconcile failed", { operation: "reconcile-storage", diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 500dabf..fea53de 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -11,7 +11,9 @@ import { type CodexCliSyncPreview, type CodexCliSyncRun, type CodexCliSyncSummary, + getLatestCodexCliSyncRollbackPlan, previewCodexCliSync, + rollbackLatestCodexCliSync, syncAccountStorageFromCodexCli, } from "../codex-cli/sync.js"; import { @@ -286,6 +288,7 @@ type SettingsHubAction = type SyncCenterAction = | { type: "refresh" } | { type: "apply" } + | { type: "rollback" } | { type: "back" }; interface SyncCenterOverviewContext { @@ -2626,6 +2629,7 @@ async function promptSyncCenter(config: PluginConfig): Promise { }; let { preview, context } = await buildPreview(true); + let rollbackPlan = await getLatestCodexCliSyncRollbackPlan(); while (true) { const overview = buildSyncCenterOverview(preview, context); const items: MenuItem[] = [ @@ -2655,6 +2659,13 @@ async function promptSyncCenter(config: PluginConfig): Promise { color: preview.status === "ready" ? "green" : "yellow", disabled: preview.status !== "ready", }, + { + label: UI_COPY.settings.syncCenterRollback, + hint: rollbackPlan.reason, + value: { type: "rollback" }, + color: rollbackPlan.status === "ready" ? "green" : "yellow", + disabled: rollbackPlan.status !== "ready", + }, { label: UI_COPY.settings.syncCenterRefresh, hint: "Re-read the source files and rebuild the sync preview.", @@ -2680,6 +2691,7 @@ async function promptSyncCenter(config: PluginConfig): Promise { if (lower === "q") return { type: "back" }; if (lower === "r") return { type: "refresh" }; if (lower === "a") return { type: "apply" }; + if (lower === "l") return { type: "rollback" }; return undefined; }, }); @@ -2687,12 +2699,56 @@ async function promptSyncCenter(config: PluginConfig): Promise { if (!result || result.type === "back") return; if (result.type === "refresh") { ({ preview, context } = await buildPreview(true)); + rollbackPlan = await getLatestCodexCliSyncRollbackPlan(); + continue; + } + if (result.type === "rollback") { + try { + if (rollbackPlan.status !== "ready" || !rollbackPlan.storage) { + preview = { + ...preview, + status: "error", + statusDetail: rollbackPlan.reason, + }; + context = resolveSyncCenterContext(await loadCodexCliState()); + rollbackPlan = await getLatestCodexCliSyncRollbackPlan(); + continue; + } + const rollbackResult = await rollbackLatestCodexCliSync(rollbackPlan); + if (rollbackResult.status !== "restored") { + preview = { + ...preview, + status: "error", + statusDetail: rollbackResult.reason, + }; + context = resolveSyncCenterContext(await loadCodexCliState()); + rollbackPlan = await getLatestCodexCliSyncRollbackPlan(); + continue; + } + const restoredStorage = await loadAccounts(); + const state = await loadCodexCliState({ forceRefresh: true }); + preview = await previewCodexCliSync(restoredStorage, { + 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); + } + rollbackPlan = await getLatestCodexCliSyncRollbackPlan(); continue; } try { const current = await loadAccounts(); - const synced = await syncAccountStorageFromCodexCli(current); + const synced = await syncAccountStorageFromCodexCli(current, { + trigger: "manual", + }); if (synced.changed && synced.storage) { await saveAccounts(synced.storage); } @@ -2702,6 +2758,7 @@ async function promptSyncCenter(config: PluginConfig): Promise { storageBackupEnabled: getStorageBackupEnabled(config), }); context = resolveSyncCenterContext(state); + rollbackPlan = await getLatestCodexCliSyncRollbackPlan(); } catch (error) { preview = { ...preview, @@ -2709,6 +2766,7 @@ async function promptSyncCenter(config: PluginConfig): Promise { statusDetail: error instanceof Error ? error.message : String(error), }; context = resolveSyncCenterContext(null); + rollbackPlan = await getLatestCodexCliSyncRollbackPlan(); } } } diff --git a/lib/storage.ts b/lib/storage.ts index 60d0deb..77889b8 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -630,7 +630,8 @@ export interface ActionableNamedBackupRecoveries { type AccountSnapshotReason = | "delete-saved-accounts" | "reset-local-state" - | "import-accounts"; + | "import-accounts" + | "codex-cli-sync"; export type AccountSnapshotFailurePolicy = "warn" | "error"; diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 3c71f1e..acb0e8a 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -112,11 +112,12 @@ export const UI_COPY = { syncCenterTitle: "Codex CLI Sync", syncCenterSubtitle: "Inspect source files and preview one-way sync before applying it", - syncCenterHelp: "Enter Select | A Apply | R Refresh | Q Back", + syncCenterHelp: "Enter Select | A Apply | L Rollback | R Refresh | Q Back", syncCenterOverviewHeading: "Sync Overview", syncCenterActionsHeading: "Actions", syncCenterRefresh: "Refresh Preview", syncCenterApply: "Apply Preview To Target", + syncCenterRollback: "Rollback Last Apply", syncCenterBack: "Back", backendCategoriesHeading: "Categories", backendCategoryTitle: "Backend Category", diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 5778d24..ff21f5d 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -1,21 +1,26 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, 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 * as codexCliState from "../lib/codex-cli/state.js"; import { clearCodexCliStateCache } from "../lib/codex-cli/state.js"; +import type { CodexCliSyncRun } from "../lib/codex-cli/sync.js"; import { __resetLastCodexCliSyncRunForTests, getActiveSelectionForFamily, getLastCodexCliSyncRun, + getLatestCodexCliSyncRollbackPlan, previewCodexCliSync, + rollbackLatestCodexCliSync, 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"; +import * as storage from "../lib/storage.js"; import { __resetSyncHistoryForTests, + appendSyncHistoryEntry, configureSyncHistoryForTests, readSyncHistory, } from "../lib/sync-history.js"; @@ -30,6 +35,7 @@ describe("codex-cli sync", () => { let previousConfigPath: string | undefined; let previousSync: string | undefined; let previousEnforceFileStore: string | undefined; + let previousMultiAuthDir: string | undefined; beforeEach(async () => { previousPath = process.env.CODEX_CLI_ACCOUNTS_PATH; @@ -38,6 +44,7 @@ describe("codex-cli sync", () => { previousSync = process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; previousEnforceFileStore = process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE; + previousMultiAuthDir = process.env.CODEX_MULTI_AUTH_DIR; tempDir = await mkdtemp(join(tmpdir(), "codex-multi-auth-sync-")); accountsPath = join(tempDir, "accounts.json"); authPath = join(tempDir, "auth.json"); @@ -851,6 +858,240 @@ describe("codex-cli sync", () => { expect(persisted?.summary.addedAccountCount).toBe(1); }); + it("records rollback snapshot metadata when a snapshot exists", async () => { + const snapshotMetadata: storage.NamedBackupMetadata = { + name: "accounts-codex-cli-sync-snapshot-2026-03-12_01-02-03", + path: join( + tempDir, + "logs", + "accounts-codex-cli-sync-snapshot-2026-03-12_01-02-03.json", + ), + createdAt: 1, + updatedAt: 1, + sizeBytes: 1, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + }; + const snapshotSpy = vi + .spyOn(storage, "snapshotAccountStorage") + .mockResolvedValue(snapshotMetadata); + try { + 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, { trigger: "manual" }); + + const expectedSnapshot = { + name: snapshotMetadata.name, + path: snapshotMetadata.path, + }; + const lastRun = getLastCodexCliSyncRun(); + expect(lastRun?.rollbackSnapshot).toEqual(expectedSnapshot); + const history = await readSyncHistory({ kind: "codex-cli-sync" }); + expect(history.at(-1)?.run.rollbackSnapshot).toEqual(expectedSnapshot); + } finally { + snapshotSpy.mockRestore(); + } + }); + + it("records null rollback snapshot metadata when no snapshot exists", async () => { + const snapshotSpy = vi + .spyOn(storage, "snapshotAccountStorage") + .mockResolvedValue(null); + try { + 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", + ); + + await syncAccountStorageFromCodexCli(null, { trigger: "manual" }); + + const lastRun = getLastCodexCliSyncRun(); + expect(lastRun?.rollbackSnapshot).toBeNull(); + const history = await readSyncHistory({ kind: "codex-cli-sync" }); + expect(history.at(-1)?.run.rollbackSnapshot).toBeNull(); + } finally { + snapshotSpy.mockRestore(); + } + }); + + it("restores the latest Codex CLI sync rollback checkpoint", async () => { + const storagePath = join(tempDir, "restore", "openai-codex-accounts.json"); + const snapshotPath = join(tempDir, "logs", "rollback-snapshot.json"); + await mkdir(join(tempDir, "restore"), { recursive: true }); + await mkdir(join(tempDir, "logs"), { recursive: true }); + storage.setStoragePathDirect(storagePath); + + try { + const snapshotStorage: AccountStorageV3 = { + version: 3, + accounts: [ + { + email: "pre-sync@example.com", + refreshToken: "refresh-pre", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + await writeFile( + snapshotPath, + JSON.stringify(snapshotStorage, null, 2), + "utf-8", + ); + + const postSyncStorage: AccountStorageV3 = { + version: 3, + accounts: [ + { + email: "post-sync@example.com", + refreshToken: "refresh-post", + addedAt: 2, + lastUsed: 2, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + await storage.saveAccounts(postSyncStorage); + + const summary = { + sourceAccountCount: 0, + targetAccountCountBefore: 0, + targetAccountCountAfter: 0, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }; + const recordedRun: CodexCliSyncRun = { + outcome: "changed", + runAt: Date.now(), + sourcePath: accountsPath, + targetPath: storagePath, + summary, + trigger: "manual", + rollbackSnapshot: { + name: "accounts-codex-cli-sync-snapshot-test", + path: snapshotPath, + }, + }; + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: recordedRun.runAt, + run: recordedRun, + }); + + const plan = await getLatestCodexCliSyncRollbackPlan(); + const result = await rollbackLatestCodexCliSync(plan); + expect(plan.status).toBe("ready"); + expect(result.status).toBe("restored"); + const restored = await storage.loadAccounts(); + expect(restored?.accounts[0]?.refreshToken).toBe("refresh-pre"); + expect(restored?.activeIndex).toBe(0); + } finally { + storage.setStoragePathDirect(null); + } + }); + + it("refuses rollback when no checkpoint exists", async () => { + const plan = await getLatestCodexCliSyncRollbackPlan(); + expect(plan.status).toBe("unavailable"); + expect(plan.reason).toMatch(/no manual codex cli sync/i); + }); + + it("refuses rollback when checkpoint file is missing", async () => { + const missingPath = join(tempDir, "logs", "missing-snapshot.json"); + const summary = { + sourceAccountCount: 0, + targetAccountCountBefore: 0, + targetAccountCountAfter: 0, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }; + const recordedRun: CodexCliSyncRun = { + outcome: "changed", + runAt: Date.now(), + sourcePath: accountsPath, + targetPath: accountsPath, + summary, + trigger: "manual", + rollbackSnapshot: { name: "missing", path: missingPath }, + }; + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: recordedRun.runAt, + run: recordedRun, + }); + + const plan = await getLatestCodexCliSyncRollbackPlan(); + expect(plan.status).toBe("unavailable"); + expect(plan.reason).toContain("missing"); + }); + it("returns current storage when state loading throws", async () => { const current: AccountStorageV3 = { version: 3, From 5444db059974edf7830dbef91309d445b37692dc Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 12 Mar 2026 22:35:59 +0800 Subject: [PATCH 2/2] feat(sync): add manual rollback --- test/codex-cli-sync.test.ts | 79 +++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index ff21f5d..aab65e2 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -11,6 +11,7 @@ import { getLastCodexCliSyncRun, getLatestCodexCliSyncRollbackPlan, previewCodexCliSync, + rollbackLastCodexCliSync, rollbackLatestCodexCliSync, syncAccountStorageFromCodexCli, } from "../lib/codex-cli/sync.js"; @@ -56,6 +57,7 @@ describe("codex-cli sync", () => { 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"; + process.env.CODEX_MULTI_AUTH_DIR = join(tempDir, "multi-auth"); clearCodexCliStateCache(); __resetLastCodexCliSyncRunForTests(); }); @@ -79,6 +81,9 @@ describe("codex-cli sync", () => { process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = previousEnforceFileStore; } + if (previousMultiAuthDir === undefined) + delete process.env.CODEX_MULTI_AUTH_DIR; + else process.env.CODEX_MULTI_AUTH_DIR = previousMultiAuthDir; await __resetSyncHistoryForTests(); configureSyncHistoryForTests(null); await rm(tempDir, { recursive: true, force: true }); @@ -1092,6 +1097,80 @@ describe("codex-cli sync", () => { expect(plan.reason).toContain("missing"); }); + it("rolls back the latest manual sync even when newer automatic runs exist", async () => { + await storage.saveAccounts({ + version: 3, + accounts: [ + { + accountId: "acc_old", + email: "old@example.com", + refreshToken: "refresh-old", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }); + + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + email: "new@example.com", + auth: { + tokens: { + access_token: "access-new", + refresh_token: "refresh-new", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const manualResult = await syncAccountStorageFromCodexCli( + await storage.loadAccounts(), + { trigger: "manual" }, + ); + expect(manualResult.changed).toBe(true); + const manualStorage = manualResult.storage; + expect(manualStorage).not.toBeNull(); + if (manualStorage) { + await storage.saveAccounts(manualStorage); + } + + await syncAccountStorageFromCodexCli(await storage.loadAccounts()); + + const postSync = await storage.loadAccounts(); + expect( + postSync?.accounts.some( + (account) => account.refreshToken === "refresh-new", + ), + ).toBe(true); + + const rollbackResult = await rollbackLastCodexCliSync(); + expect(rollbackResult.restore.imported).toBeGreaterThan(0); + + const restored = await storage.loadAccounts(); + expect( + restored?.accounts.some( + (account) => account.refreshToken === "refresh-old", + ), + ).toBe(true); + expect( + restored?.accounts.some( + (account) => account.refreshToken === "refresh-new", + ), + ).toBe(false); + }); + it("returns current storage when state loading throws", async () => { const current: AccountStorageV3 = { version: 3,