diff --git a/lib/destructive-actions.ts b/lib/destructive-actions.ts index f8e436ef..e58d7bf5 100644 --- a/lib/destructive-actions.ts +++ b/lib/destructive-actions.ts @@ -10,6 +10,7 @@ import { loadFlaggedAccounts, saveAccounts, saveFlaggedAccounts, + snapshotAccountStorage, } from "./storage.js"; export const DESTRUCTIVE_ACTION_COPY = { @@ -111,6 +112,7 @@ export async function deleteAccountAtIndex(options: { * Removes the accounts WAL and backups via the underlying storage helper. */ export async function deleteSavedAccounts(): Promise { + await snapshotAccountStorage({ reason: "delete-saved-accounts" }); await clearAccounts(); } @@ -119,6 +121,7 @@ export async function deleteSavedAccounts(): Promise { * Keeps unified settings and on-disk Codex CLI sync state; only the in-memory Codex CLI cache is cleared. */ export async function resetLocalState(): Promise { + await snapshotAccountStorage({ reason: "reset-local-state" }); await clearAccounts(); await clearFlaggedAccounts(); await clearQuotaCache(); diff --git a/lib/storage.ts b/lib/storage.ts index 6c779ce6..60d0deb2 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -627,6 +627,21 @@ export interface ActionableNamedBackupRecoveries { totalBackups: number; } +type AccountSnapshotReason = + | "delete-saved-accounts" + | "reset-local-state" + | "import-accounts"; + +export type AccountSnapshotFailurePolicy = "warn" | "error"; + +export interface AccountSnapshotOptions { + reason: AccountSnapshotReason; + now?: number; + force?: boolean; + failurePolicy?: AccountSnapshotFailurePolicy; + createBackup?: typeof createNamedBackup; +} + export function getLastAccountsSaveTimestamp(): number { return lastAccountsSaveTimestamp; } @@ -1532,6 +1547,58 @@ export async function createNamedBackup( return buildNamedBackupMetadata(normalizedName, backupPath, { candidate }); } +function formatTimestampForSnapshot(timestamp: number): string { + const date = new Date(timestamp); + const pad = (value: number): string => value.toString().padStart(2, "0"); + const year = date.getUTCFullYear(); + const month = pad(date.getUTCMonth() + 1); + const day = pad(date.getUTCDate()); + const hours = pad(date.getUTCHours()); + const minutes = pad(date.getUTCMinutes()); + const seconds = pad(date.getUTCSeconds()); + return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`; +} + +function buildAccountSnapshotName( + reason: AccountSnapshotReason, + timestamp: number, +): string { + return `accounts-${reason}-snapshot-${formatTimestampForSnapshot(timestamp)}`; +} + +export async function snapshotAccountStorage( + options: AccountSnapshotOptions, +): Promise { + const { + reason, + now = Date.now(), + force = true, + failurePolicy = "warn", + createBackup = createNamedBackup, + } = options; + + const currentStorage = await loadAccounts(); + if (!currentStorage || currentStorage.accounts.length === 0) { + return null; + } + + const backupName = buildAccountSnapshotName(reason, now); + + try { + return await createBackup(backupName, { force }); + } catch (error) { + if (failurePolicy === "error") { + throw error; + } + log.warn("Failed to create account storage snapshot", { + reason, + backupName, + error: String(error), + }); + return null; + } +} + export async function assessNamedBackupRestore( name: string, options: { currentStorage?: AccountStorageV3 | null } = {}, @@ -2285,6 +2352,8 @@ export async function importAccounts( throw new Error("Invalid account storage format"); } + await snapshotAccountStorage({ reason: "import-accounts" }); + const { imported: importedCount, total, diff --git a/test/storage.test.ts b/test/storage.test.ts index 93beaac1..665543c7 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2,6 +2,10 @@ import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + deleteSavedAccounts, + resetLocalState, +} from "../lib/destructive-actions.js"; import { clearQuotaCache, getQuotaCachePath } from "../lib/quota-cache.js"; import { getConfigDir, getProjectStorageKey } from "../lib/storage/paths.js"; import { @@ -12,6 +16,7 @@ import { deduplicateAccountsByEmail, exportAccounts, formatStorageErrorHint, + getNamedBackupsDirectoryPath, getStoragePath, importAccounts, listNamedBackups, @@ -23,6 +28,7 @@ import { saveAccounts, setStoragePath, setStoragePathDirect, + snapshotAccountStorage, withAccountStorageTransaction, } from "../lib/storage.js"; @@ -656,6 +662,221 @@ describe("storage", () => { }); }); + describe("account storage snapshots", () => { + const testWorkDir = join( + tmpdir(), + "codex-snapshot-" + Math.random().toString(36).slice(2), + ); + let testStoragePath = ""; + + beforeEach(async () => { + await fs.mkdir(testWorkDir, { recursive: true }); + testStoragePath = join(testWorkDir, "openai-codex-accounts.json"); + setStoragePathDirect(testStoragePath); + }); + + afterEach(async () => { + setStoragePathDirect(null); + vi.restoreAllMocks(); + await fs.rm(testWorkDir, { recursive: true, force: true }); + }); + + it("creates deterministic named backup when accounts exist", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const fixedNow = Date.UTC(2024, 0, 2, 3, 4, 5); + const snapshot = await snapshotAccountStorage({ + reason: "delete-saved-accounts", + now: fixedNow, + }); + + expect(snapshot?.name).toBe( + "accounts-delete-saved-accounts-snapshot-2024-01-02_03-04-05", + ); + expect(snapshot?.path && existsSync(snapshot.path)).toBe(true); + }); + + it("skips snapshot when no accounts exist", async () => { + const snapshot = await snapshotAccountStorage({ + reason: "delete-saved-accounts", + }); + expect(snapshot).toBeNull(); + expect(existsSync(getNamedBackupsDirectoryPath())).toBe(false); + }); + + it("warn failure policy returns null when snapshot creation fails", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const failingBackup = vi + .fn() + .mockRejectedValue(new Error("snapshot failed")); + + await expect( + snapshotAccountStorage({ + reason: "reset-local-state", + createBackup: failingBackup, + }), + ).resolves.toBeNull(); + }); + + it("propagates when failure policy is error", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const failingBackup = vi + .fn() + .mockRejectedValue(new Error("snapshot failed")); + + await expect( + snapshotAccountStorage({ + reason: "reset-local-state", + failurePolicy: "error", + createBackup: failingBackup, + }), + ).rejects.toThrow(/snapshot failed/); + }); + + it("creates snapshot before deleteSavedAccounts and preserves named backup", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await deleteSavedAccounts(); + + const backupsDir = getNamedBackupsDirectoryPath(); + const entries = existsSync(backupsDir) + ? await fs.readdir(backupsDir) + : []; + expect( + entries.some((name) => + name.startsWith("accounts-delete-saved-accounts-snapshot-"), + ), + ).toBe(true); + expect(await loadAccounts()).toBeNull(); + }); + + it("creates snapshot before resetLocalState", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await resetLocalState(); + + const backupsDir = getNamedBackupsDirectoryPath(); + const entries = existsSync(backupsDir) + ? await fs.readdir(backupsDir) + : []; + expect( + entries.some((name) => + name.startsWith("accounts-reset-local-state-snapshot-"), + ), + ).toBe(true); + expect(await loadAccounts()).toBeNull(); + }); + + it("captures snapshot before importAccounts when accounts already exist", async () => { + const importPath = join(testWorkDir, "import.json"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await fs.writeFile( + importPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "imported", + refreshToken: "ref-import", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + "utf-8", + ); + + await importAccounts(importPath); + + const backupsDir = getNamedBackupsDirectoryPath(); + const entries = existsSync(backupsDir) + ? await fs.readdir(backupsDir) + : []; + expect( + entries.some((name) => + name.startsWith("accounts-import-accounts-snapshot-"), + ), + ).toBe(true); + + const loaded = await loadAccounts(); + expect(loaded?.accounts).toHaveLength(2); + expect(loaded?.accounts.map((account) => account.accountId)).toEqual( + expect.arrayContaining(["existing", "imported"]), + ); + }); + }); + describe("filename migration (TDD)", () => { it("should migrate from old filename to new filename", async () => { // This test is tricky because it depends on the internal state of getStoragePath()