From 21aaa890777bd4a1d277fd479cf243915bc50799 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:04:21 +0800 Subject: [PATCH 1/3] refactor: extract storage file path helpers --- lib/storage.ts | 43 +++++++++++------------------------ lib/storage/file-paths.ts | 48 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 30 deletions(-) create mode 100644 lib/storage/file-paths.ts diff --git a/lib/storage.ts b/lib/storage.ts index 12c77dac..bd98cfd1 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -20,6 +20,14 @@ export { normalizeEmailKey, } from "./storage/identity.js"; +import { + getFlaggedAccountsPath as buildFlaggedAccountsPath, + getLegacyFlaggedAccountsPath as buildLegacyFlaggedAccountsPath, + getAccountsBackupPath, + getAccountsBackupRecoveryCandidates, + getAccountsWalPath, + getIntentionalResetMarkerPath, +} from "./storage/file-paths.js"; import { type AccountIdentityRef, toAccountIdentityRef, @@ -57,7 +65,6 @@ const FLAGGED_ACCOUNTS_FILE_NAME = "openai-codex-flagged-accounts.json"; const LEGACY_FLAGGED_ACCOUNTS_FILE_NAME = "openai-codex-blocked-accounts.json"; const ACCOUNTS_BACKUP_SUFFIX = ".bak"; const ACCOUNTS_WAL_SUFFIX = ".wal"; -const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3; const BACKUP_COPY_MAX_ATTEMPTS = 5; const BACKUP_COPY_BASE_DELAY_MS = 10; const RESET_MARKER_SUFFIX = ".reset-intent"; @@ -356,25 +363,6 @@ export function setStorageBackupEnabled(enabled: boolean): void { storageBackupEnabled = enabled; } -function getAccountsBackupPath(path: string): string { - return `${path}${ACCOUNTS_BACKUP_SUFFIX}`; -} - -function getAccountsBackupPathAtIndex(path: string, index: number): string { - if (index <= 0) { - return getAccountsBackupPath(path); - } - return `${path}${ACCOUNTS_BACKUP_SUFFIX}.${index}`; -} - -function getAccountsBackupRecoveryCandidates(path: string): string[] { - const candidates: string[] = []; - for (let i = 0; i < ACCOUNTS_BACKUP_HISTORY_DEPTH; i += 1) { - candidates.push(getAccountsBackupPathAtIndex(path, i)); - } - return candidates; -} - async function getAccountsBackupRecoveryCandidatesWithDiscovery( path: string, ): Promise { @@ -414,10 +402,6 @@ async function getAccountsBackupRecoveryCandidatesWithDiscovery( return [...knownCandidates, ...discoveredOrdered]; } -function getAccountsWalPath(path: string): string { - return `${path}${ACCOUNTS_WAL_SUFFIX}`; -} - async function copyFileWithRetry( sourcePath: string, destinationPath: string, @@ -594,10 +578,6 @@ function computeSha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } -function getIntentionalResetMarkerPath(path: string): string { - return `${path}${RESET_MARKER_SUFFIX}`; -} - function createEmptyStorageWithMetadata( restoreEligible: boolean, restoreReason: RestoreReason, @@ -985,11 +965,14 @@ export async function exportNamedBackup( } export function getFlaggedAccountsPath(): string { - return join(dirname(getStoragePath()), FLAGGED_ACCOUNTS_FILE_NAME); + return buildFlaggedAccountsPath(getStoragePath(), FLAGGED_ACCOUNTS_FILE_NAME); } function getLegacyFlaggedAccountsPath(): string { - return join(dirname(getStoragePath()), LEGACY_FLAGGED_ACCOUNTS_FILE_NAME); + return buildLegacyFlaggedAccountsPath( + getStoragePath(), + LEGACY_FLAGGED_ACCOUNTS_FILE_NAME, + ); } async function migrateLegacyProjectStorageIfNeeded( diff --git a/lib/storage/file-paths.ts b/lib/storage/file-paths.ts new file mode 100644 index 00000000..64047df1 --- /dev/null +++ b/lib/storage/file-paths.ts @@ -0,0 +1,48 @@ +import { dirname, join } from "node:path"; + +const ACCOUNTS_BACKUP_SUFFIX = ".bak"; +const ACCOUNTS_WAL_SUFFIX = ".wal"; +const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3; +const RESET_MARKER_SUFFIX = ".reset-intent"; + +export function getAccountsBackupPath(path: string): string { + return `${path}${ACCOUNTS_BACKUP_SUFFIX}`; +} + +export function getAccountsBackupPathAtIndex( + path: string, + index: number, +): string { + if (index <= 0) return getAccountsBackupPath(path); + return `${path}${ACCOUNTS_BACKUP_SUFFIX}.${index}`; +} + +export function getAccountsBackupRecoveryCandidates(path: string): string[] { + const candidates: string[] = []; + for (let i = 0; i < ACCOUNTS_BACKUP_HISTORY_DEPTH; i += 1) { + candidates.push(getAccountsBackupPathAtIndex(path, i)); + } + return candidates; +} + +export function getAccountsWalPath(path: string): string { + return `${path}${ACCOUNTS_WAL_SUFFIX}`; +} + +export function getIntentionalResetMarkerPath(path: string): string { + return `${path}${RESET_MARKER_SUFFIX}`; +} + +export function getFlaggedAccountsPath( + storagePath: string, + fileName: string, +): string { + return join(dirname(storagePath), fileName); +} + +export function getLegacyFlaggedAccountsPath( + storagePath: string, + legacyFileName: string, +): string { + return join(dirname(storagePath), legacyFileName); +} From ca9f9a6db558c810e636f160d2f1939e7a389c6c Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:10:06 +0800 Subject: [PATCH 2/3] refactor: extract storage path state --- lib/storage.ts | 29 ++++------------------------- lib/storage/path-state.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 25 deletions(-) create mode 100644 lib/storage/path-state.ts diff --git a/lib/storage.ts b/lib/storage.ts index bd98cfd1..a70da79d 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -41,6 +41,10 @@ import { migrateV1ToV3, type RateLimitStateV3, } from "./storage/migrations.js"; +import { + getStoragePathState, + setStoragePathState, +} from "./storage/path-state.js"; import { findProjectRoot, getConfigDir, @@ -334,31 +338,6 @@ async function ensureGitignore(storagePath: string): Promise { } } -type StoragePathState = { - currentStoragePath: string | null; - currentLegacyProjectStoragePath: string | null; - currentLegacyWorktreeStoragePath: string | null; - currentProjectRoot: string | null; -}; - -let currentStorageState: StoragePathState = { - currentStoragePath: null, - currentLegacyProjectStoragePath: null, - currentLegacyWorktreeStoragePath: null, - currentProjectRoot: null, -}; - -const storagePathStateContext = new AsyncLocalStorage(); - -function getStoragePathState(): StoragePathState { - return storagePathStateContext.getStore() ?? currentStorageState; -} - -function setStoragePathState(state: StoragePathState): void { - currentStorageState = state; - storagePathStateContext.enterWith(state); -} - export function setStorageBackupEnabled(enabled: boolean): void { storageBackupEnabled = enabled; } diff --git a/lib/storage/path-state.ts b/lib/storage/path-state.ts new file mode 100644 index 00000000..6c708d2c --- /dev/null +++ b/lib/storage/path-state.ts @@ -0,0 +1,26 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export type StoragePathState = { + currentStoragePath: string | null; + currentLegacyProjectStoragePath: string | null; + currentLegacyWorktreeStoragePath: string | null; + currentProjectRoot: string | null; +}; + +const storagePathStateContext = new AsyncLocalStorage(); + +let currentStorageState: StoragePathState = { + currentStoragePath: null, + currentLegacyProjectStoragePath: null, + currentLegacyWorktreeStoragePath: null, + currentProjectRoot: null, +}; + +export function getStoragePathState(): StoragePathState { + return storagePathStateContext.getStore() ?? currentStorageState; +} + +export function setStoragePathState(state: StoragePathState): void { + currentStorageState = state; + storagePathStateContext.enterWith(state); +} From 45cda798ab410a2c9fd3b4ce788034f47a797691 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:16:06 +0800 Subject: [PATCH 3/3] refactor: extract named backup discovery --- lib/storage.ts | 89 +++++------------------------------- lib/storage/named-backups.ts | 85 ++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 78 deletions(-) create mode 100644 lib/storage/named-backups.ts diff --git a/lib/storage.ts b/lib/storage.ts index a70da79d..ec257578 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -11,14 +11,18 @@ import { } from "./named-backup-export.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; - import { formatStorageErrorHint } from "./storage/error-hints.js"; +import { + collectNamedBackups, + type NamedBackupSummary, +} from "./storage/named-backups.js"; export { formatStorageErrorHint } from "./storage/error-hints.js"; export { getAccountIdentityKey, normalizeEmailKey, } from "./storage/identity.js"; +export type { NamedBackupSummary } from "./storage/named-backups.js"; import { getFlaggedAccountsPath as buildFlaggedAccountsPath, @@ -139,82 +143,6 @@ export type RestoreAssessment = { backupMetadata: BackupMetadata; }; -export interface NamedBackupSummary { - path: string; - fileName: string; - accountCount: number; - mtimeMs: number; -} - -async function collectNamedBackups( - storagePath: string, -): Promise { - const backupRoot = getNamedBackupRoot(storagePath); - let entries: Array<{ isFile(): boolean; name: string }>; - try { - entries = await fs.readdir(backupRoot, { - withFileTypes: true, - encoding: "utf8", - }); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") return []; - throw error; - } - - const candidates: NamedBackupSummary[] = []; - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!entry.name.toLowerCase().endsWith(".json")) continue; - const candidatePath = join(backupRoot, entry.name); - try { - const statsBefore = await fs.stat(candidatePath); - const { normalized } = await loadAccountsFromPath(candidatePath); - if (!normalized || normalized.accounts.length === 0) continue; - const statsAfter = await fs.stat(candidatePath).catch(() => null); - if (statsAfter && statsAfter.mtimeMs !== statsBefore.mtimeMs) { - log.debug( - "backup file changed between stat and load, mtime may be stale", - { - candidatePath, - fileName: entry.name, - beforeMtimeMs: statsBefore.mtimeMs, - afterMtimeMs: statsAfter.mtimeMs, - }, - ); - } - candidates.push({ - path: candidatePath, - fileName: entry.name, - accountCount: normalized.accounts.length, - mtimeMs: statsBefore.mtimeMs, - }); - } catch (error) { - log.debug( - "Skipping named backup candidate after loadAccountsFromPath/fs.stat failure", - { - candidatePath, - fileName: entry.name, - error: - error instanceof Error - ? { - message: error.message, - stack: error.stack, - } - : String(error), - }, - ); - } - } - - candidates.sort((left, right) => { - const mtimeDelta = right.mtimeMs - left.mtimeMs; - if (mtimeDelta !== 0) return mtimeDelta; - return left.fileName.localeCompare(right.fileName); - }); - return candidates; -} - /** * Custom error class for storage operations with platform-aware hints. */ @@ -868,7 +796,12 @@ export function buildNamedBackupPath(name: string): string { } export async function getNamedBackups(): Promise { - return collectNamedBackups(getStoragePath()); + return collectNamedBackups(getStoragePath(), { + readDir: fs.readdir, + stat: fs.stat, + loadAccountsFromPath, + logDebug: (message, meta) => log.debug(message, meta), + }); } export async function restoreAccountsFromBackup( diff --git a/lib/storage/named-backups.ts b/lib/storage/named-backups.ts new file mode 100644 index 00000000..a14a30c2 --- /dev/null +++ b/lib/storage/named-backups.ts @@ -0,0 +1,85 @@ +import { join } from "node:path"; +import { getNamedBackupRoot } from "../named-backup-export.js"; + +export interface NamedBackupSummary { + path: string; + fileName: string; + accountCount: number; + mtimeMs: number; +} + +export interface CollectNamedBackupsDeps { + readDir: typeof import("node:fs").promises.readdir; + stat: typeof import("node:fs").promises.stat; + loadAccountsFromPath: ( + path: string, + ) => Promise<{ normalized: { accounts: unknown[] } | null }>; + logDebug?: (message: string, meta: Record) => void; +} + +export async function collectNamedBackups( + storagePath: string, + deps: CollectNamedBackupsDeps, +): Promise { + const backupRoot = getNamedBackupRoot(storagePath); + let entries: Array<{ isFile(): boolean; name: string }>; + try { + entries = await deps.readDir(backupRoot, { + withFileTypes: true, + encoding: "utf8", + }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return []; + throw error; + } + + const candidates: NamedBackupSummary[] = []; + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.toLowerCase().endsWith(".json")) continue; + const candidatePath = join(backupRoot, entry.name); + try { + const statsBefore = await deps.stat(candidatePath); + const { normalized } = await deps.loadAccountsFromPath(candidatePath); + if (!normalized || normalized.accounts.length === 0) continue; + const statsAfter = await deps.stat(candidatePath).catch(() => null); + if (statsAfter && statsAfter.mtimeMs !== statsBefore.mtimeMs) { + deps.logDebug?.( + "backup file changed between stat and load, mtime may be stale", + { + candidatePath, + fileName: entry.name, + beforeMtimeMs: statsBefore.mtimeMs, + afterMtimeMs: statsAfter.mtimeMs, + }, + ); + } + candidates.push({ + path: candidatePath, + fileName: entry.name, + accountCount: normalized.accounts.length, + mtimeMs: statsBefore.mtimeMs, + }); + } catch (error) { + deps.logDebug?.( + "Skipping named backup candidate after loadAccountsFromPath/fs.stat failure", + { + candidatePath, + fileName: entry.name, + error: + error instanceof Error + ? { message: error.message, stack: error.stack } + : String(error), + }, + ); + } + } + + candidates.sort((left, right) => { + const mtimeDelta = right.mtimeMs - left.mtimeMs; + if (mtimeDelta !== 0) return mtimeDelta; + return left.fileName.localeCompare(right.fileName); + }); + return candidates; +}