From d3c4c9d25971c44c0b603bfaf70a1ea5eb99b026 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:33:25 +0800 Subject: [PATCH 1/3] fix: unblock dev storage export and docs links --- README.md | 6 +++--- docs/README.md | 8 ++++---- lib/storage.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f4e5e25f..d7cd064b 100644 --- a/README.md +++ b/README.md @@ -291,9 +291,9 @@ codex auth doctor --json ## Release Notes -- Current stable: [docs/releases/v1.2.0.md](docs/releases/v1.2.0.md) -- Previous stable: [docs/releases/v1.1.10.md](docs/releases/v1.1.10.md) -- Earlier stable: [docs/releases/v0.1.9.md](docs/releases/v0.1.9.md) +- Current stable: [docs/releases/v1.1.10.md](docs/releases/v1.1.10.md) +- Previous stable: [docs/releases/v0.1.9.md](docs/releases/v0.1.9.md) +- Earlier stable: [docs/releases/v0.1.8.md](docs/releases/v0.1.8.md) - Archived prerelease: [docs/releases/v0.1.0-beta.0.md](docs/releases/v0.1.0-beta.0.md) ## License diff --git a/docs/README.md b/docs/README.md index f63480fb..926e5675 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,9 +26,9 @@ Public documentation for `codex-multi-auth`. | [troubleshooting.md](troubleshooting.md) | Recovery playbooks for install, login, switching, and stale state | | [privacy.md](privacy.md) | Data handling and local storage behavior | | [upgrade.md](upgrade.md) | Migration from legacy package and path history | -| [releases/v1.2.0.md](releases/v1.2.0.md) | Stable release notes | -| [releases/v1.1.10.md](releases/v1.1.10.md) | Previous stable release notes | -| [releases/v0.1.9.md](releases/v0.1.9.md) | Earlier stable release notes | +| [releases/v1.1.10.md](releases/v1.1.10.md) | Stable release notes | +| [releases/v0.1.9.md](releases/v0.1.9.md) | Previous stable release notes | +| [releases/v0.1.8.md](releases/v0.1.8.md) | Earlier stable release notes | | [releases/v0.1.7.md](releases/v0.1.7.md) | Archived stable release notes | | [releases/v0.1.6.md](releases/v0.1.6.md) | Archived stable release notes | | [releases/v0.1.5.md](releases/v0.1.5.md) | Archived stable release notes | @@ -45,7 +45,7 @@ Public documentation for `codex-multi-auth`. | [reference/storage-paths.md](reference/storage-paths.md) | Canonical and compatibility storage paths | | [reference/public-api.md](reference/public-api.md) | Public API stability and semver contract | | [reference/error-contracts.md](reference/error-contracts.md) | CLI, JSON, and helper error semantics | -| [releases/v1.2.0.md](releases/v1.2.0.md) | Current stable release notes | +| [releases/v1.1.10.md](releases/v1.1.10.md) | Current stable release notes | | [releases/v0.1.0-beta.0.md](releases/v0.1.0-beta.0.md) | Archived prerelease reference | | [User Guides release notes](#user-guides) | Stable, previous, and archived release notes | | [releases/legacy-pre-0.1-history.md](releases/legacy-pre-0.1-history.md) | Archived pre-0.1 changelog history | diff --git a/lib/storage.ts b/lib/storage.ts index 0509925d..bf2ba006 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1926,6 +1926,54 @@ async function loadAccountsInternal( } } +async function loadAccountsForExport(): Promise { + const path = getStoragePath(); + const resetMarkerPath = getIntentionalResetMarkerPath(path); + await cleanupStaleRotatingBackupArtifacts(path); + const migratedLegacyStorage = + await migrateLegacyProjectStorageIfNeeded(saveAccountsUnlocked); + + if (existsSync(resetMarkerPath)) { + return createEmptyStorageWithMetadata(false, "intentional-reset"); + } + + try { + const { normalized, storedVersion, schemaErrors } = + await loadAccountsFromPath(path); + if (schemaErrors.length > 0) { + log.warn("Account storage schema validation warnings", { + errors: schemaErrors.slice(0, 5), + }); + } + if (normalized && storedVersion !== normalized.version) { + log.info("Migrating account storage to v3", { + from: storedVersion, + to: normalized.version, + }); + try { + await saveAccountsUnlocked(normalized); + } catch (saveError) { + log.warn("Failed to persist migrated storage", { + error: String(saveError), + }); + } + } + if (existsSync(resetMarkerPath)) { + return createEmptyStorageWithMetadata(false, "intentional-reset"); + } + return normalized; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (existsSync(resetMarkerPath)) { + return createEmptyStorageWithMetadata(false, "intentional-reset"); + } + if (code === "ENOENT") { + return migratedLegacyStorage; + } + throw error; + } +} + async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { const path = getStoragePath(); const resetMarkerPath = getIntentionalResetMarkerPath(path); @@ -2489,10 +2537,8 @@ export async function exportAccounts( transactionState.storagePath === currentStoragePath ? transactionState.snapshot : transactionState?.active - ? await loadAccountsInternal(saveAccountsUnlocked) - : await withAccountStorageTransaction((current) => - Promise.resolve(current), - ); + ? await loadAccountsForExport() + : await withStorageLock(loadAccountsForExport); if (!storage || storage.accounts.length === 0) { throw new Error("No accounts to export"); } From e92d1f84c0f8a6529a4424087b2fd47c83213804 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:41:05 +0800 Subject: [PATCH 2/3] fix-export-primary-storage-only --- lib/storage.ts | 3 +++ test/storage.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/lib/storage.ts b/lib/storage.ts index bf2ba006..67fd9c36 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1936,6 +1936,9 @@ async function loadAccountsForExport(): Promise { if (existsSync(resetMarkerPath)) { return createEmptyStorageWithMetadata(false, "intentional-reset"); } + if (!existsSync(path)) { + return createEmptyStorageWithMetadata(true, "missing-storage"); + } try { const { normalized, storedVersion, schemaErrors } = diff --git a/test/storage.test.ts b/test/storage.test.ts index ccca65c0..a06b3553 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -956,6 +956,33 @@ describe("storage", () => { ); }); + it("should fail export when only backup storage exists", async () => { + const { exportAccounts } = await import("../lib/storage.js"); + const backupPath = `${testStoragePath}.bak`; + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "backup-only", + refreshToken: "backup-refresh", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + setStoragePathDirect(testStoragePath); + await expect(exportAccounts(exportPath)).rejects.toThrow( + /No accounts to export/, + ); + expect(existsSync(exportPath)).toBe(false); + }); + it("should fail import when file does not exist", async () => { const { importAccounts } = await import("../lib/storage.js"); const nonexistentPath = join(testWorkDir, "nonexistent-file.json"); From f096ed7300520c6591ed94739c6c32bdc1d8f393 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:13:28 +0800 Subject: [PATCH 3/3] fix: restore export recovery paths --- docs/README.md | 2 +- lib/storage.ts | 11 ++++ test/storage.test.ts | 137 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 147 insertions(+), 3 deletions(-) diff --git a/docs/README.md b/docs/README.md index 926e5675..139ac2fd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,7 +26,7 @@ Public documentation for `codex-multi-auth`. | [troubleshooting.md](troubleshooting.md) | Recovery playbooks for install, login, switching, and stale state | | [privacy.md](privacy.md) | Data handling and local storage behavior | | [upgrade.md](upgrade.md) | Migration from legacy package and path history | -| [releases/v1.1.10.md](releases/v1.1.10.md) | Stable release notes | +| [releases/v1.1.10.md](releases/v1.1.10.md) | Current stable release notes | | [releases/v0.1.9.md](releases/v0.1.9.md) | Previous stable release notes | | [releases/v0.1.8.md](releases/v0.1.8.md) | Earlier stable release notes | | [releases/v0.1.7.md](releases/v0.1.7.md) | Archived stable release notes | diff --git a/lib/storage.ts b/lib/storage.ts index 67fd9c36..c479ee52 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1937,6 +1937,13 @@ async function loadAccountsForExport(): Promise { return createEmptyStorageWithMetadata(false, "intentional-reset"); } if (!existsSync(path)) { + if (migratedLegacyStorage) { + return migratedLegacyStorage; + } + const recoveredFromWal = await loadAccountsFromJournal(path); + if (recoveredFromWal) { + return recoveredFromWal; + } return createEmptyStorageWithMetadata(true, "missing-storage"); } @@ -1970,6 +1977,10 @@ async function loadAccountsForExport(): Promise { if (existsSync(resetMarkerPath)) { return createEmptyStorageWithMetadata(false, "intentional-reset"); } + const recoveredFromWal = await loadAccountsFromJournal(path); + if (recoveredFromWal) { + return recoveredFromWal; + } if (code === "ENOENT") { return migratedLegacyStorage; } diff --git a/test/storage.test.ts b/test/storage.test.ts index a06b3553..751ceede 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -949,7 +950,6 @@ describe("storage", () => { }); it("should fail export when no accounts exist", async () => { - const { exportAccounts } = await import("../lib/storage.js"); setStoragePathDirect(testStoragePath); await expect(exportAccounts(exportPath)).rejects.toThrow( /No accounts to export/, @@ -957,7 +957,6 @@ describe("storage", () => { }); it("should fail export when only backup storage exists", async () => { - const { exportAccounts } = await import("../lib/storage.js"); const backupPath = `${testStoragePath}.bak`; await fs.writeFile( backupPath, @@ -983,6 +982,140 @@ describe("storage", () => { expect(existsSync(exportPath)).toBe(false); }); + it("should export from WAL recovery when primary storage is missing", async () => { + const walPath = `${testStoragePath}.wal`; + const storage = { + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "wal-only", + refreshToken: "wal-refresh", + addedAt: 1, + lastUsed: 1, + }, + ], + }; + const content = JSON.stringify(storage, null, 2); + const checksum = createHash("sha256").update(content).digest("hex"); + await fs.writeFile( + walPath, + JSON.stringify({ + version: 1, + createdAt: 1, + path: testStoragePath, + checksum, + content, + }), + "utf-8", + ); + + await exportAccounts(exportPath); + + const exported = JSON.parse(await fs.readFile(exportPath, "utf-8")) as { + accounts: Array<{ accountId: string; refreshToken: string }>; + }; + expect(exported.accounts).toEqual([ + expect.objectContaining({ + accountId: "wal-only", + refreshToken: "wal-refresh", + }), + ]); + }); + + it("should export from migrated legacy project storage when primary storage is missing", async () => { + const fakeHome = join(testWorkDir, "legacy-home"); + const projectDir = join(testWorkDir, "legacy-project"); + const legacyProjectConfigDir = join(projectDir, ".codex"); + const legacyStoragePath = join( + legacyProjectConfigDir, + "openai-codex-accounts.json", + ); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + try { + await fs.mkdir(fakeHome, { recursive: true }); + await fs.mkdir(join(projectDir, ".git"), { recursive: true }); + await fs.mkdir(legacyProjectConfigDir, { recursive: true }); + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + setStoragePath(projectDir); + await fs.writeFile( + legacyStoragePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "legacy-only", + refreshToken: "legacy-refresh", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + await exportAccounts(exportPath); + + const exported = JSON.parse(await fs.readFile(exportPath, "utf-8")) as { + accounts: Array<{ accountId: string; refreshToken: string }>; + }; + expect(exported.accounts).toEqual([ + expect.objectContaining({ + accountId: "legacy-only", + refreshToken: "legacy-refresh", + }), + ]); + expect(existsSync(legacyStoragePath)).toBe(false); + expect(existsSync(getStoragePath())).toBe(true); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + setStoragePathDirect(testStoragePath); + } + }); + + it("should fail export from backup-only storage on a different path during an active transaction", async () => { + const alternateStoragePath = join( + testWorkDir, + `accounts-alt-${Math.random().toString(36).slice(2)}.json`, + ); + const alternateBackupPath = `${alternateStoragePath}.bak`; + await fs.writeFile( + alternateBackupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "backup-only", + refreshToken: "backup-refresh", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + await withAccountStorageTransaction(async () => { + setStoragePathDirect(alternateStoragePath); + try { + await expect(exportAccounts(exportPath)).rejects.toThrow( + /No accounts to export/, + ); + } finally { + setStoragePathDirect(testStoragePath); + } + }); + + expect(existsSync(exportPath)).toBe(false); + }); + it("should fail import when file does not exist", async () => { const { importAccounts } = await import("../lib/storage.js"); const nonexistentPath = join(testWorkDir, "nonexistent-file.json");