From 3f06c8e9d58993699a2b48cb0cbb50175e793cae Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 20:34:18 +0800 Subject: [PATCH 1/5] feat(auth): harden opencode import flow --- docs/getting-started.md | 2 + lib/cli.ts | 4 + lib/codex-manager.ts | 58 ++- lib/storage.ts | 272 +++++++++++++- lib/ui/copy.ts | 4 +- test/cli.test.ts | 14 + test/codex-manager-cli.test.ts | 623 ++++++++++++++++++++------------- test/documentation.test.ts | 5 +- test/storage.test.ts | 289 ++++++++++++++- 9 files changed, 1010 insertions(+), 261 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 8caf3fb4..2d2fa281 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -82,6 +82,8 @@ Use the restore path when you already have named backup files and want to recove The restore manager shows each backup name, account count, freshness, and whether the restore would exceed the account limit before it lets you apply anything. +If you already have an OpenCode account pool on the same machine, use the recovery import option from the login dashboard to preview and import those saved accounts before creating new OAuth sessions. The detector checks the standard OpenCode account file and honors `CODEX_OPENCODE_POOL_PATH` when you need to point at a specific file. + --- ## Sync And Settings diff --git a/lib/cli.ts b/lib/cli.ts index dd728c9a..ec853ee9 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -62,6 +62,7 @@ export type LoginMode = | "check" | "deep-check" | "verify-flagged" + | "import-opencode" | "restore-backup" | "cancel"; @@ -248,6 +249,9 @@ async function promptLoginModeFallback( ) { return { mode: "restore-backup" }; } + if (normalized === "i" || normalized === "import-opencode") { + return { mode: "import-opencode" }; + } if (normalized === "q" || normalized === "quit") return { mode: "cancel" }; console.log(UI_COPY.fallback.invalidModePrompt); diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index d64dcb32..633e0248 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; import { promises as fs, existsSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { basename, dirname, resolve } from "node:path"; import { createAuthorizationFlow, exchangeAuthorizationCode, @@ -65,9 +65,13 @@ import { } from "./quota-cache.js"; import { assessNamedBackupRestore, + assessOpencodeAccountPool, + type BackupRestoreAssessment, + formatRedactedFilesystemError, getActionableNamedBackupRestores, getRedactedFilesystemErrorLabel, getNamedBackupsDirectoryPath, + importAccounts, listNamedBackups, listRotatingBackups, NAMED_BACKUP_LIST_CONCURRENCY, @@ -4429,6 +4433,58 @@ async function runAuthLogin(): Promise { } continue; } + if (menuResult.mode === "import-opencode") { + let assessment: BackupRestoreAssessment | null; + try { + assessment = await assessOpencodeAccountPool({ + currentStorage, + }); + } catch (error) { + const errorLabel = formatRedactedFilesystemError(error); + console.error(`Import assessment failed: ${errorLabel}`); + continue; + } + if (!assessment) { + console.log("No OpenCode account pool was detected."); + continue; + } + if (!assessment.backup.valid || !assessment.eligibleForRestore) { + const assessmentErrorLabel = + assessment.error || "OpenCode account pool is not importable."; + console.log(assessmentErrorLabel); + continue; + } + if (assessment.wouldExceedLimit) { + console.log( + `Import would exceed the account limit (${assessment.currentAccountCount ?? "?"} current, ${assessment.mergedAccountCount ?? "?"} after import). Remove accounts first.`, + ); + continue; + } + const backupLabel = basename(assessment.backup.path); + const confirmed = await confirm( + `Import OpenCode accounts from ${backupLabel}?`, + ); + if (!confirmed) { + continue; + } + try { + await runActionPanel( + "Import OpenCode Accounts", + `Importing from ${backupLabel}`, + async () => { + const imported = await importAccounts(assessment.backup.path); + console.log( + `Imported ${imported.imported} account${imported.imported === 1 ? "" : "s"}. Skipped ${imported.skipped}. Total accounts: ${imported.total}.`, + ); + }, + displaySettings, + ); + } catch (error) { + const errorLabel = formatRedactedFilesystemError(error); + console.error(`Import failed: ${errorLabel}`); + } + continue; + } if (menuResult.mode === "fresh" && menuResult.deleteAll) { if (destructiveActionInFlight) { console.log("Another destructive action is already running. Wait for it to finish."); diff --git a/lib/storage.ts b/lib/storage.ts index 481cbc64..0d2e92e8 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,7 +1,13 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; import { existsSync, promises as fs } from "node:fs"; -import { basename, dirname, join } from "node:path"; +import { homedir } from "node:os"; +import { + basename, + dirname, + join, + resolve as resolveFilesystemPath, +} from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { @@ -49,6 +55,7 @@ const BACKUP_COPY_MAX_ATTEMPTS = 5; const BACKUP_COPY_BASE_DELAY_MS = 10; const ROTATING_BACKUP_STALE_ARTIFACT_MAX_AGE_MS = 60_000; export const NAMED_BACKUP_LIST_CONCURRENCY = 8; +export const ACCOUNT_SNAPSHOT_RETENTION_PER_REASON = 3; const RESET_MARKER_SUFFIX = ".reset-intent"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -2000,6 +2007,11 @@ export async function listNamedBackups(): Promise { return scanResult.backups.map((entry) => entry.backup); } +export async function listAccountSnapshots(): Promise { + const backups = await listNamedBackups(); + return backups.filter((backup) => isAccountSnapshotName(backup.name)); +} + export async function listRotatingBackups(): Promise { let storagePath: string | null = null; try { @@ -2071,6 +2083,42 @@ export function getNamedBackupsDirectoryPath(): string { return getNamedBackupRoot(getStoragePath()); } +export function detectOpencodeAccountPoolPath(): string | null { + const candidates = new Set(); + const explicit = process.env.CODEX_OPENCODE_POOL_PATH; + if (explicit?.trim()) { + const explicitPath = resolvePath(explicit.trim()); + return existsSync(explicitPath) ? explicitPath : null; + } + + const appDataBases = [process.env.LOCALAPPDATA, process.env.APPDATA].filter( + (base): base is string => !!base && base.trim().length > 0, + ); + for (const base of appDataBases) { + candidates.add( + resolveFilesystemPath(join(base, "OpenCode", ACCOUNTS_FILE_NAME)), + ); + candidates.add( + resolveFilesystemPath(join(base, "opencode", ACCOUNTS_FILE_NAME)), + ); + } + + const home = homedir(); + if (home) { + candidates.add( + resolveFilesystemPath(join(home, ".opencode", ACCOUNTS_FILE_NAME)), + ); + } + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + + return null; +} + async function scanNamedBackupsForActionableRestores(): Promise { try { return await scanNamedBackups(); @@ -2268,6 +2316,147 @@ function buildAccountSnapshotName( return `accounts-${reason}-snapshot-${formatTimestampForSnapshot(timestamp)}`; } +export function isAccountSnapshotName(name: string): boolean { + return /^accounts-(?[a-z0-9-]+)-snapshot-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_\d{3}$/i.test( + name, + ); +} + +function getAccountSnapshotReason(name: string): string | null { + const match = name.match( + /^accounts-(?[a-z0-9-]+)-snapshot-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_\d{3}$/i, + ); + return match?.groups?.reason?.toLowerCase() ?? null; +} + +type AutoSnapshotDetails = { + backup: NamedBackupMetadata; + name: string; + reason: string; + sortTimestamp: number; +}; + +function parseAutoSnapshot( + backup: NamedBackupMetadata, +): AutoSnapshotDetails | null { + const reason = getAccountSnapshotReason(backup.name); + if (!reason) { + return null; + } + return { + backup, + name: backup.name, + reason, + sortTimestamp: backup.updatedAt ?? backup.createdAt ?? 0, + }; +} + +async function getLatestManualCodexCliRollbackSnapshotNames(): Promise< + Set +> { + try { + const syncHistoryModule = await import("./sync-history.js"); + if (typeof syncHistoryModule.readSyncHistory !== "function") { + return new Set(); + } + const history = await syncHistoryModule.readSyncHistory({ + kind: "codex-cli-sync", + }); + for (let index = history.length - 1; index >= 0; index -= 1) { + const entry = history[index]; + if (!entry || entry.kind !== "codex-cli-sync") { + continue; + } + const run = entry.run; + if (run?.trigger !== "manual" || run.outcome !== "changed") { + continue; + } + const snapshotName = run.rollbackSnapshot?.name?.trim(); + if (!snapshotName) { + break; + } + return new Set([snapshotName]); + } + } catch (error) { + log.debug("Failed to load rollback snapshot names for retention", { + error: String(error), + }); + } + return new Set(); +} + +export interface AutoSnapshotPruneOptions { + backups?: NamedBackupMetadata[]; + preserveNames?: Iterable; + keepLatestPerReason?: number; +} + +export interface AutoSnapshotPruneResult { + pruned: NamedBackupMetadata[]; + kept: NamedBackupMetadata[]; +} + +export async function pruneAutoGeneratedSnapshots( + options: AutoSnapshotPruneOptions = {}, +): Promise { + const backups = options.backups ?? (await listNamedBackups()); + const keepLatestPerReason = Math.max( + 1, + options.keepLatestPerReason ?? 1, + ); + const preserveNames = new Set(options.preserveNames ?? []); + for (const rollbackName of await getLatestManualCodexCliRollbackSnapshotNames()) { + preserveNames.add(rollbackName); + } + + const autoSnapshots = backups + .map((backup) => parseAutoSnapshot(backup)) + .filter((snapshot): snapshot is AutoSnapshotDetails => snapshot !== null); + if (autoSnapshots.length === 0) { + return { pruned: [], kept: [] }; + } + + const keepSet = new Set(preserveNames); + const snapshotsByReason = new Map(); + for (const snapshot of autoSnapshots) { + const bucket = snapshotsByReason.get(snapshot.reason) ?? []; + bucket.push(snapshot); + snapshotsByReason.set(snapshot.reason, bucket); + } + + for (const snapshots of snapshotsByReason.values()) { + snapshots.sort((left, right) => right.sortTimestamp - left.sortTimestamp); + for (const snapshot of snapshots.slice(0, keepLatestPerReason)) { + keepSet.add(snapshot.name); + } + } + + const keptNames = new Set(keepSet); + const pruned: NamedBackupMetadata[] = []; + for (const snapshot of autoSnapshots) { + if (keepSet.has(snapshot.name)) { + continue; + } + try { + await unlinkWithRetry(snapshot.backup.path); + pruned.push(snapshot.backup); + } catch (error) { + keptNames.add(snapshot.name); + log.warn("Failed to prune auto-generated snapshot", { + name: snapshot.name, + path: snapshot.backup.path, + error: String(error), + }); + } + } + + const kept = autoSnapshots + .filter((snapshot) => keptNames.has(snapshot.name)) + .map((snapshot) => snapshot.backup); + + return { pruned, kept }; +} + function extractPathTail(pathValue: string): string { const segments = pathValue.split(/[\\/]+/).filter(Boolean); return segments.at(-1) ?? pathValue; @@ -2275,11 +2464,25 @@ function extractPathTail(pathValue: string): string { function redactFilesystemDetails(value: string): string { return value.replace( - /(?:[A-Za-z]:)?[\\/][^"'`\r\n]+(?:[\\/][^"'`\r\n]+)+/g, + /(?:[A-Za-z]:)?[\\/][^"'`\r\n]+(?:[\\/][^"'`\r\n]+)*/g, (pathValue) => extractPathTail(pathValue), ); } +export function formatRedactedFilesystemError(error: unknown): string { + const code = + typeof (error as NodeJS.ErrnoException | undefined)?.code === "string" + ? (error as NodeJS.ErrnoException).code + : undefined; + const rawMessage = + error instanceof Error ? error.message : String(error ?? "unknown error"); + const redactedMessage = redactFilesystemDetails(rawMessage); + if (code && !redactedMessage.includes(code)) { + return `${code}: ${redactedMessage}`; + } + return redactedMessage; +} + function formatSnapshotErrorForLog(error: unknown): string { const code = typeof (error as NodeJS.ErrnoException | undefined)?.code === "string" @@ -2294,6 +2497,12 @@ function formatSnapshotErrorForLog(error: unknown): string { return redactedMessage; } +async function enforceSnapshotRetention(): Promise { + await pruneAutoGeneratedSnapshots({ + keepLatestPerReason: ACCOUNT_SNAPSHOT_RETENTION_PER_REASON, + }); +} + export async function snapshotAccountStorage( options: AccountSnapshotOptions, ): Promise { @@ -2316,8 +2525,9 @@ export async function snapshotAccountStorage( } const backupName = buildAccountSnapshotName(reason, now); + let snapshot: NamedBackupMetadata; try { - return await createBackup(backupName, { + snapshot = await createBackup(backupName, { force, storage: currentStorage, storagePath: resolvedStoragePath, @@ -2333,6 +2543,18 @@ export async function snapshotAccountStorage( }); return null; } + + try { + await enforceSnapshotRetention(); + } catch (error) { + log.warn("Failed to enforce account snapshot retention", { + reason, + backupName, + error: formatSnapshotErrorForLog(error), + }); + } + + return snapshot; } export async function assessNamedBackupRestore( @@ -2358,6 +2580,39 @@ export async function assessNamedBackupRestore( ); } +export async function assessOpencodeAccountPool( + options: { currentStorage?: AccountStorageV3 | null; path?: string } = {}, +): Promise { + const resolvedPath = options.path?.trim() + ? resolvePath(options.path.trim()) + : detectOpencodeAccountPoolPath(); + + if (!resolvedPath) { + return null; + } + + if (equalsResolvedStoragePath(resolvedPath, getStoragePath())) { + throw new Error("Import source cannot be the active storage file."); + } + + const candidate = await loadBackupCandidate(resolvedPath); + const baseBackup = await buildBackupFileMetadata(resolvedPath, { candidate }); + const backup: NamedBackupMetadata = { + name: basename(resolvedPath), + ...baseBackup, + }; + const currentStorage = + options.currentStorage !== undefined + ? options.currentStorage + : await loadAccounts(); + return assessNamedBackupRestoreCandidate( + backup, + candidate, + currentStorage, + candidate.rawAccounts ?? [], + ); +} + function assessNamedBackupRestoreCandidate( backup: NamedBackupMetadata, candidate: LoadedBackupCandidate, @@ -2799,7 +3054,7 @@ async function loadBackupCandidate(path: string): Promise storedVersion: undefined, schemaErrors: [], rawAccounts: [], - error: String(error), + error: formatRedactedFilesystemError(error), errorCode, }; } @@ -2811,6 +3066,12 @@ function equalsNamedBackupEntry(left: string, right: string): boolean { : left === right; } +function equalsResolvedStoragePath(left: string, right: string): boolean { + return process.platform === "win32" + ? left.toLowerCase() === right.toLowerCase() + : left === right; +} + function stripNamedBackupJsonExtension(name: string): string { return name.toLowerCase().endsWith(".json") ? name.slice(0, -".json".length) @@ -3860,6 +4121,9 @@ export async function importAccounts( filePath: string, ): Promise<{ imported: number; total: number; skipped: number }> { const resolvedPath = resolvePath(filePath); + if (equalsResolvedStoragePath(resolvedPath, getStoragePath())) { + throw new Error("Import source cannot be the active storage file."); + } const candidate = await loadImportableBackupCandidate(resolvedPath); return importNormalizedAccounts(candidate.normalized, resolvedPath, { snapshotReason: "import-accounts", diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index b4fcebad..25e5b998 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -158,8 +158,8 @@ export const UI_COPY = { addAnotherQuestion: (count: number) => `Add another account? (${count} added) (y/n): `, selectModePrompt: - "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (u) restore backup, (f) fresh, (r) reset, (q) back [a/c/b/x/s/d/g/u/f/r/q]: ", - invalidModePrompt: "Use one of: a, c, b, x, s, d, g, u, f, r, q.", + "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (u) restore backup, (i) import OpenCode, (f) fresh, (r) reset, (q) back [a/c/b/x/s/d/g/u/i/f/r/q]: ", + invalidModePrompt: "Use one of: a, c, b, x, s, d, g, u, i, f, r, q.", }, } as const; diff --git a/test/cli.test.ts b/test/cli.test.ts index efbffdce..f035efa9 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -728,6 +728,20 @@ describe("CLI Module", () => { }); }); + it("returns import-opencode for fallback import aliases", async () => { + const { promptLoginMode } = await import("../lib/cli.js"); + + mockRl.question.mockResolvedValueOnce("i"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "import-opencode", + }); + + mockRl.question.mockResolvedValueOnce("import-opencode"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "import-opencode", + }); + }); + it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { delete process.env.FORCE_INTERACTIVE_MODE; const { stdin, stdout } = await import("node:process"); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 099f3554..bce76f1a 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -13,7 +13,9 @@ const getActionableNamedBackupRestoresMock = vi.fn(); const listNamedBackupsMock = vi.fn(); const listRotatingBackupsMock = vi.fn(); const assessNamedBackupRestoreMock = vi.fn(); +const assessOpencodeAccountPoolMock = vi.fn(); const getNamedBackupsDirectoryPathMock = vi.fn(); +const importAccountsMock = vi.fn(); const restoreNamedBackupMock = vi.fn(); const queuedRefreshMock = vi.fn(); const setCodexCliActiveSelectionMock = vi.fn(); @@ -152,7 +154,9 @@ vi.mock("../lib/storage.js", async () => { listNamedBackups: listNamedBackupsMock, listRotatingBackups: listRotatingBackupsMock, assessNamedBackupRestore: assessNamedBackupRestoreMock, + assessOpencodeAccountPool: assessOpencodeAccountPoolMock, getNamedBackupsDirectoryPath: getNamedBackupsDirectoryPathMock, + importAccounts: importAccountsMock, restoreNamedBackup: restoreNamedBackupMock, exportNamedBackup: exportNamedBackupMock, normalizeAccountStorage: normalizeAccountStorageMock, @@ -646,7 +650,9 @@ describe("codex manager cli commands", () => { listNamedBackupsMock.mockReset(); listRotatingBackupsMock.mockReset(); assessNamedBackupRestoreMock.mockReset(); + assessOpencodeAccountPoolMock.mockReset(); getNamedBackupsDirectoryPathMock.mockReset(); + importAccountsMock.mockReset(); restoreNamedBackupMock.mockReset(); confirmMock.mockReset(); getActionableNamedBackupRestoresMock.mockReset(); @@ -678,7 +684,13 @@ describe("codex manager cli commands", () => { eligibleForRestore: true, error: undefined, }); + assessOpencodeAccountPoolMock.mockResolvedValue(null); getNamedBackupsDirectoryPathMock.mockReturnValue("/mock/backups"); + importAccountsMock.mockResolvedValue({ + imported: 0, + skipped: 0, + total: 0, + }); restoreNamedBackupMock.mockResolvedValue({ imported: 1, skipped: 0, @@ -1066,6 +1078,54 @@ describe("codex manager cli commands", () => { ); }); + it("imports opencode accounts from the login menu", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }); + assessOpencodeAccountPoolMock.mockResolvedValue({ + backup: { + name: "OpenCode account pool", + path: "/mock/.opencode/openai-codex-accounts.json", + createdAt: null, + updatedAt: Date.now(), + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + eligibleForRestore: true, + wouldExceedLimit: false, + error: undefined, + }); + importAccountsMock.mockResolvedValue({ + imported: 1, + skipped: 0, + total: 2, + }); + confirmMock.mockResolvedValueOnce(true); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "import-opencode" }) + .mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(assessOpencodeAccountPoolMock).toHaveBeenCalledTimes(1); + expect(importAccountsMock).toHaveBeenCalledWith( + "/mock/.opencode/openai-codex-accounts.json", + ); + expect(confirmMock).toHaveBeenCalledWith( + "Import OpenCode accounts from openai-codex-accounts.json?", + ); + }); + it("returns a non-zero exit code when the direct restore-backup command fails", async () => { setInteractiveTTY(true); const now = Date.now(); @@ -1124,214 +1184,94 @@ describe("codex manager cli commands", () => { ); }); - it("runs restore preview before applying a replace-only named backup", async () => { + it("skips OpenCode import when confirmation is declined", async () => { setInteractiveTTY(true); - const now = Date.now(); loadAccountsMock.mockResolvedValue({ version: 3, activeIndex: 0, activeIndexByFamily: { codex: 0 }, accounts: [ { - email: "current@example.com", - accountId: "current-account", - refreshToken: "refresh-current", - accessToken: "access-current", - expiresAt: now + 3_600_000, - addedAt: now - 2_000, - lastUsed: now - 2_000, - enabled: true, + email: "existing@example.com", + refreshToken: "existing-refresh", + addedAt: Date.now(), + lastUsed: Date.now(), }, ], }); - const assessment = { + assessOpencodeAccountPoolMock.mockResolvedValue({ backup: { - name: "named-backup", - path: "/mock/backups/named-backup.json", + name: "openai-codex-accounts.json", + path: "/mock/.opencode/openai-codex-accounts.json", createdAt: null, - updatedAt: now, - sizeBytes: 128, + updatedAt: Date.now(), + sizeBytes: 256, version: 3, accountCount: 1, schemaErrors: [], valid: true, - loadError: undefined, + loadError: "", }, - backupAccountCount: 1, - dedupedBackupAccountCount: 1, - conflictsWithinBackup: 0, - conflictsWithExisting: 0, - replacedExistingCount: 1, - keptExistingCount: 0, - keptBackupCount: 1, currentAccountCount: 1, - mergedAccountCount: 1, - imported: 0, + mergedAccountCount: 2, + imported: 1, skipped: 0, wouldExceedLimit: false, eligibleForRestore: true, nextActiveIndex: 0, - nextActiveEmail: "current@example.com", - nextActiveAccountId: "current-account", - currentActiveIndex: 0, - currentActiveEmail: "current@example.com", - currentActiveAccountId: "current-account", - activeAccountOutcome: "unchanged", + nextActiveEmail: "existing@example.com", + nextActiveAccountId: undefined, activeAccountChanged: false, - activeAccountPreview: { - current: { - index: 0, - email: "current@example.com", - accountId: "current-account", - }, - next: { - index: 0, - email: "current@example.com", - accountId: "current-account", - }, - outcome: "unchanged", - changed: false, - }, - namedBackupRestorePreview: { - conflicts: [ - { - conflict: { - backupIndex: 0, - currentIndex: 0, - reasons: ["accountId", "email"], - resolution: "backup-kept", - }, - backup: { - index: 0, - email: "current@example.com", - accountId: "current-account", - }, - current: { - index: 0, - email: "current@example.com", - accountId: "current-account", - }, - }, - ], - activeAccount: { - current: { - index: 0, - email: "current@example.com", - accountId: "current-account", - }, - next: { - index: 0, - email: "current@example.com", - accountId: "current-account", - }, - outcome: "unchanged", - changed: false, - }, - }, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock - .mockResolvedValueOnce(assessment) - .mockResolvedValueOnce(assessment); - selectMock - .mockResolvedValueOnce({ - type: "inspect", - entry: { - kind: "named", - label: assessment.backup.name, - backup: assessment.backup, - assessment, - }, - }) - .mockResolvedValueOnce("preview-restore"); - confirmMock.mockResolvedValueOnce(true); - restoreNamedBackupMock.mockResolvedValueOnce({ - imported: 0, + error: "", + }); + importAccountsMock.mockResolvedValue({ + imported: 1, skipped: 0, - total: 1, + total: 2, }); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + confirmMock.mockResolvedValueOnce(false); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "import-opencode" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - expect(exitCode).toBe(0); - expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); - expect(selectMock).toHaveBeenCalledTimes(2); - expect(selectMock.mock.calls[0]?.[1]).toMatchObject({ - message: "Backup Browser", - }); - expect(selectMock.mock.calls[1]?.[1]).toMatchObject({ - message: "Backup Actions", - subtitle: "named-backup", - }); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 1, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), - }), - }), - ); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 2, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), - }), - }), - ); - expect(confirmMock).toHaveBeenCalledWith( - "Restore named-backup? Import 0 new accounts for 1 total. Replacing 1 current account.", - ); - expect(restoreNamedBackupMock).toHaveBeenCalledWith( - "named-backup", - expect.objectContaining({ assessment }), - ); - expect(logSpy).toHaveBeenCalledWith( - "Replaced 1 current account. Total accounts: 1.", - ); - } finally { - logSpy.mockRestore(); - } + expect(exitCode).toBe(0); + expect(assessOpencodeAccountPoolMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); }); - it("returns a non-zero exit code when the direct restore-backup command fails", async () => { + it("returns to the dashboard when OpenCode import fails", async () => { setInteractiveTTY(true); - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ + const currentStorage = { version: 3, activeIndex: 0, activeIndexByFamily: { codex: 0 }, accounts: [ { - email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, + email: "existing@example.com", + refreshToken: "existing-refresh", + addedAt: Date.now(), + lastUsed: Date.now(), }, ], - }); + }; + loadAccountsMock.mockResolvedValue(currentStorage); const assessment = { backup: { - name: "named-backup", - path: "/mock/backups/named-backup.json", + name: "openai-codex-accounts.json", + path: "/mock/.opencode/openai-codex-accounts.json", createdAt: null, - updatedAt: now, - sizeBytes: 128, + updatedAt: Date.now(), + sizeBytes: 256, version: 3, accountCount: 1, schemaErrors: [], valid: true, - loadError: undefined, + loadError: "", }, currentAccountCount: 1, mergedAccountCount: 2, @@ -1339,81 +1279,246 @@ describe("codex manager cli commands", () => { skipped: 0, wouldExceedLimit: false, eligibleForRestore: true, - error: undefined, + nextActiveIndex: 0, + nextActiveEmail: "existing@example.com", + nextActiveAccountId: undefined, + activeAccountChanged: false, + error: "", }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - restoreNamedBackupMock.mockRejectedValueOnce(new Error("backup locked")); + assessOpencodeAccountPoolMock.mockResolvedValue(assessment); + const importError = makeErrnoError( + "operation not permitted, open 'C:\\Users\\alice\\AppData\\Local\\OpenCode\\openai-codex-accounts.json'", + "EPERM", + ); + importAccountsMock.mockRejectedValueOnce(importError); + confirmMock.mockResolvedValueOnce(true); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "import-opencode" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + expect(exitCode).toBe(0); + expect(importAccountsMock).toHaveBeenCalledWith( + "/mock/.opencode/openai-codex-accounts.json", + ); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(errorSpy).toHaveBeenCalledWith( + "Import failed: EPERM: operation not permitted, open 'openai-codex-accounts.json'", + ); + expect( + errorSpy.mock.calls.some(([message]) => + String(message).includes("C:\\Users\\alice\\AppData\\Local\\OpenCode"), + ), + ).toBe(false); + errorSpy.mockRestore(); + }); + + it("returns to the dashboard when OpenCode import assessment fails", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }); + assessOpencodeAccountPoolMock.mockRejectedValueOnce( + makeErrnoError("assessment locked", "EPERM"), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "import-opencode" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); - expect(exitCode).toBe(1); - expect(promptLoginModeMock).not.toHaveBeenCalled(); - expect(confirmMock).toHaveBeenCalledOnce(); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup", { - assessment, + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(assessOpencodeAccountPoolMock).toHaveBeenCalledTimes(1); + expect(confirmMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(errorSpy).toHaveBeenCalledWith( + "Import assessment failed: EPERM: assessment locked", + ); + errorSpy.mockRestore(); + }); + + it("returns to the dashboard when no OpenCode account pool is detected", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], }); + assessOpencodeAccountPoolMock.mockResolvedValueOnce(null); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "import-opencode" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(assessOpencodeAccountPoolMock).toHaveBeenCalledTimes(1); + expect(confirmMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(logSpy).toHaveBeenCalledWith( + "No OpenCode account pool was detected.", + ); + logSpy.mockRestore(); }); - it("returns a non-zero exit code when the direct restore-backup command fails", async () => { + it.each([ + { + label: "would exceed the account limit", + expectedLog: + "Import would exceed the account limit (2 current, 5 after import). Remove accounts first.", + assessment: { + backup: { + name: "openai-codex-accounts.json", + path: "/mock/.opencode/openai-codex-accounts.json", + createdAt: null, + updatedAt: Date.now(), + sizeBytes: 256, + version: 3, + accountCount: 3, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 2, + mergedAccountCount: 5, + imported: 3, + skipped: 0, + wouldExceedLimit: true, + eligibleForRestore: true, + nextActiveIndex: 0, + nextActiveEmail: "existing@example.com", + nextActiveAccountId: undefined, + activeAccountChanged: false, + error: "", + }, + }, + { + label: "is not eligible for restore", + expectedLog: "OpenCode account pool is not importable.", + assessment: { + backup: { + name: "openai-codex-accounts.json", + path: "/mock/.opencode/openai-codex-accounts.json", + createdAt: null, + updatedAt: Date.now(), + sizeBytes: 256, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 1, + mergedAccountCount: null, + imported: null, + skipped: null, + wouldExceedLimit: false, + eligibleForRestore: false, + nextActiveIndex: null, + nextActiveEmail: undefined, + nextActiveAccountId: undefined, + activeAccountChanged: false, + error: "OpenCode account pool is not importable.", + }, + }, + ])( + "skips confirmation and import when the OpenCode assessment $label", + async ({ assessment, expectedLog }) => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "existing@example.com", + refreshToken: "existing-refresh", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + }); + assessOpencodeAccountPoolMock.mockResolvedValue(assessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "import-opencode" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(assessOpencodeAccountPoolMock).toHaveBeenCalledTimes(1); + expect(confirmMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(logSpy).toHaveBeenCalledWith(expectedLog); + logSpy.mockRestore(); + }, + ); + + it("redacts filesystem details when the OpenCode assessment is invalid", async () => { setInteractiveTTY(true); - const now = Date.now(); loadAccountsMock.mockResolvedValue({ version: 3, activeIndex: 0, activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], + accounts: [], }); - const assessment = { + assessOpencodeAccountPoolMock.mockResolvedValue({ backup: { - name: "named-backup", - path: "/mock/backups/named-backup.json", + name: "openai-codex-accounts.json", + path: "/mock/.opencode/openai-codex-accounts.json", createdAt: null, - updatedAt: now, - sizeBytes: 128, + updatedAt: Date.now(), + sizeBytes: 256, version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, + accountCount: 0, + schemaErrors: ["invalid"], + valid: false, + loadError: "ENOENT: openai-codex-accounts.json", }, - currentAccountCount: 1, - mergedAccountCount: 2, - imported: 1, - skipped: 0, + currentAccountCount: 0, + mergedAccountCount: null, + imported: null, + skipped: null, wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - restoreNamedBackupMock.mockRejectedValueOnce(new Error("backup locked")); - + eligibleForRestore: false, + nextActiveIndex: null, + nextActiveEmail: undefined, + nextActiveAccountId: undefined, + activeAccountChanged: false, + error: "ENOENT: openai-codex-accounts.json", + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "import-opencode" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); - expect(exitCode).toBe(1); - expect(promptLoginModeMock).not.toHaveBeenCalled(); - expect(confirmMock).toHaveBeenCalledOnce(); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup", { - assessment, - }); - }); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + expect(exitCode).toBe(0); + expect(confirmMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + "ENOENT: openai-codex-accounts.json", + ); + logSpy.mockRestore(); + }); it("runs restore preview before applying a replace-only named backup", async () => { setInteractiveTTY(true); const now = Date.now(); @@ -1536,44 +1641,58 @@ describe("codex manager cli commands", () => { }) .mockResolvedValueOnce("preview-restore"); confirmMock.mockResolvedValueOnce(true); + restoreNamedBackupMock.mockResolvedValueOnce({ + imported: 0, + skipped: 0, + total: 1, + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); - expect(exitCode).toBe(0); - expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); - expect(selectMock).toHaveBeenCalledTimes(2); - expect(selectMock.mock.calls[0]?.[1]).toMatchObject({ - message: "Backup Browser", - }); - expect(selectMock.mock.calls[1]?.[1]).toMatchObject({ - message: "Backup Actions", - subtitle: "named-backup", - }); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 1, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), + expect(exitCode).toBe(0); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(selectMock).toHaveBeenCalledTimes(2); + expect(selectMock.mock.calls[0]?.[1]).toMatchObject({ + message: "Backup Browser", + }); + expect(selectMock.mock.calls[1]?.[1]).toMatchObject({ + message: "Backup Actions", + subtitle: "named-backup", + }); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), }), - }), - ); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 2, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), }), - }), - ); - expect(confirmMock).toHaveBeenCalledWith( - "Restore named-backup? Import 0 new accounts for 1 total. Replacing 1 current account.", - ); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup", { - assessment, - }); + ); + expect(confirmMock).toHaveBeenCalledWith( + "Restore named-backup? Import 0 new accounts for 1 total. Replacing 1 current account.", + ); + expect(restoreNamedBackupMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ assessment }), + ); + expect(logSpy).toHaveBeenCalledWith( + "Replaced 1 current account. Total accounts: 1.", + ); + } finally { + logSpy.mockRestore(); + } }); it("restores healthy flagged accounts into active storage", async () => { diff --git a/test/documentation.test.ts b/test/documentation.test.ts index eb06a930..abb5bdf7 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -166,7 +166,10 @@ describe("Documentation Integrity", () => { }); it("does not include opencode wording in user docs", () => { - const allowedOpencodeFiles = new Set(["docs/reference/storage-paths.md"]); + const allowedOpencodeFiles = new Set([ + "docs/getting-started.md", + "docs/reference/storage-paths.md", + ]); for (const filePath of userDocs) { const content = read(filePath).toLowerCase(); const hasLegacyHostWord = content.includes("opencode"); diff --git a/test/storage.test.ts b/test/storage.test.ts index 2470f97f..b6aabb4f 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1,6 +1,6 @@ import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, relative, resolve as resolvePath } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ACCOUNT_LIMITS } from "../lib/constants.js"; import { @@ -13,16 +13,19 @@ import { removeWithRetry } from "./helpers/remove-with-retry.js"; import { assessNamedBackupRestore, buildNamedBackupPath, + assessOpencodeAccountPool, clearAccounts, clearFlaggedAccounts, createNamedBackup, deduplicateAccounts, deduplicateAccountsByEmail, + detectOpencodeAccountPoolPath, exportAccounts, exportNamedBackup, findMatchingAccountIndex, formatStorageErrorHint, getActionableNamedBackupRestores, + formatRedactedFilesystemError, getFlaggedAccountsPath, getNamedBackupsDirectoryPath, NAMED_BACKUP_LIST_CONCURRENCY, @@ -1149,6 +1152,27 @@ describe("storage", () => { await expect(importAccounts(exportPath)).rejects.toThrow(/Invalid JSON/); }); + it("should fail import when file is the active storage file", async () => { + await fs.writeFile( + testStoragePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "current-account", + refreshToken: "ref-current", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + await expect(importAccounts(testStoragePath)).rejects.toThrow( + /Import source cannot be the active storage file/, + ); + }); + it("should fail import when file contains invalid format", async () => { await fs.writeFile(exportPath, JSON.stringify({ invalid: "format" })); await expect(importAccounts(exportPath)).rejects.toThrow( @@ -5690,6 +5714,269 @@ describe("storage", () => { }); }); + describe("opencode account pool detection", () => { + const originalLocalAppData = process.env.LOCALAPPDATA; + const originalAppData = process.env.APPDATA; + const originalHome = process.env.HOME; + const originalPoolPath = process.env.CODEX_OPENCODE_POOL_PATH; + const originalUserProfile = process.env.USERPROFILE; + let tempRoot = ""; + let poolPath = ""; + + beforeEach(async () => { + tempRoot = join( + tmpdir(), + "opencode-pool-" + Math.random().toString(36).slice(2), + ); + poolPath = join(tempRoot, "OpenCode", "openai-codex-accounts.json"); + process.env.LOCALAPPDATA = tempRoot; + process.env.APPDATA = tempRoot; + delete process.env.CODEX_OPENCODE_POOL_PATH; + await fs.mkdir(dirname(poolPath), { recursive: true }); + setStoragePathDirect(join(tempRoot, "current-storage.json")); + }); + + afterEach(async () => { + if (originalLocalAppData === undefined) delete process.env.LOCALAPPDATA; + else process.env.LOCALAPPDATA = originalLocalAppData; + if (originalAppData === undefined) delete process.env.APPDATA; + else process.env.APPDATA = originalAppData; + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalPoolPath === undefined) + delete process.env.CODEX_OPENCODE_POOL_PATH; + else process.env.CODEX_OPENCODE_POOL_PATH = originalPoolPath; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + setStoragePathDirect(null); + if (tempRoot) { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + + it("detects and assesses a valid opencode pool source", async () => { + const poolStorage = { + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "pool-account", + refreshToken: "ref-pool", + addedAt: 1, + lastUsed: 1, + }, + ], + }; + await fs.writeFile(poolPath, JSON.stringify(poolStorage)); + + const detected = detectOpencodeAccountPoolPath(); + expect(detected).toBe(poolPath); + + const assessment = await assessOpencodeAccountPool(); + expect(assessment).not.toBeNull(); + expect(assessment?.backup.path).toBe(poolPath); + expect(assessment?.backup.valid).toBe(true); + expect(assessment?.eligibleForRestore).toBe(true); + expect(assessment?.imported).toBe(1); + }); + + it("prefers an explicit CODEX_OPENCODE_POOL_PATH override", async () => { + const explicitPoolPath = join(tempRoot, "explicit", "pool.json"); + await fs.mkdir(dirname(explicitPoolPath), { recursive: true }); + await fs.writeFile( + explicitPoolPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "explicit-account", + refreshToken: "ref-explicit", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + await fs.writeFile( + poolPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "fallback-account", + refreshToken: "ref-fallback", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + process.env.CODEX_OPENCODE_POOL_PATH = ` ${explicitPoolPath} `; + + const detected = detectOpencodeAccountPoolPath(); + expect(detected).toBe(explicitPoolPath); + + const assessment = await assessOpencodeAccountPool(); + expect(assessment?.backup.path).toBe(explicitPoolPath); + expect(assessment?.imported).toBe(1); + }); + + it("does not fall back to auto-detection when an explicit CODEX_OPENCODE_POOL_PATH override is missing", async () => { + await fs.writeFile( + poolPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "fallback-account", + refreshToken: "ref-fallback", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + process.env.CODEX_OPENCODE_POOL_PATH = join( + tempRoot, + "explicit", + "missing-pool.json", + ); + + expect(detectOpencodeAccountPoolPath()).toBeNull(); + await expect(assessOpencodeAccountPool()).resolves.toBeNull(); + }); + + it("falls back to homedir when LOCALAPPDATA and APPDATA are not set", async () => { + delete process.env.LOCALAPPDATA; + delete process.env.APPDATA; + const fakeHome = join(tempRoot, "fake-home"); + const homedirPoolPath = join( + fakeHome, + ".opencode", + "openai-codex-accounts.json", + ); + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + await fs.mkdir(dirname(homedirPoolPath), { recursive: true }); + await fs.writeFile( + homedirPoolPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "homedir-account", + refreshToken: "ref-homedir", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + + expect(detectOpencodeAccountPoolPath()).toBe(homedirPoolPath); + const assessment = await assessOpencodeAccountPool(); + expect(assessment?.backup.path).toBe(homedirPoolPath); + expect(assessment?.eligibleForRestore).toBe(true); + expect(assessment?.imported).toBe(1); + }); + + it("resolves relative app data candidates before detection", async () => { + const relativeBase = relative( + process.cwd(), + join(tempRoot, "relative-appdata"), + ); + const resolvedPoolPath = resolvePath( + join(relativeBase, "OpenCode", "openai-codex-accounts.json"), + ); + delete process.env.LOCALAPPDATA; + process.env.APPDATA = relativeBase; + await fs.mkdir(dirname(resolvedPoolPath), { recursive: true }); + await fs.writeFile( + resolvedPoolPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "relative-appdata-account", + refreshToken: "ref-relative", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + + expect(detectOpencodeAccountPoolPath()).toBe(resolvedPoolPath); + const assessment = await assessOpencodeAccountPool(); + expect(assessment?.backup.path).toBe(resolvedPoolPath); + expect(assessment?.eligibleForRestore).toBe(true); + expect(assessment?.imported).toBe(1); + }); + + it("redacts single-segment filesystem paths in error messages", async () => { + const error = new Error( + "EPERM: operation not permitted, open 'C:\\accounts.json'", + ) as NodeJS.ErrnoException; + error.code = "EPERM"; + + expect(formatRedactedFilesystemError(error)).toBe( + "EPERM: operation not permitted, open 'accounts.json'", + ); + }); + + it("refuses malformed opencode source before any mutation", async () => { + await fs.writeFile(poolPath, "not valid json"); + + const detected = detectOpencodeAccountPoolPath(); + expect(detected).toBe(poolPath); + + const assessment = await assessOpencodeAccountPool(); + expect(assessment).not.toBeNull(); + expect(assessment?.backup.valid).toBe(false); + expect(assessment?.eligibleForRestore).toBe(false); + expect(assessment?.imported).toBeNull(); + const current = await loadAccounts(); + expect(current).toMatchObject({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [], + restoreEligible: true, + restoreReason: "missing-storage", + }); + }); + + it("rejects using the active storage file as the opencode import source", async () => { + const activeStoragePath = getStoragePath(); + await fs.writeFile( + activeStoragePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "current-account", + refreshToken: "ref-current", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + process.env.CODEX_OPENCODE_POOL_PATH = activeStoragePath; + + expect(detectOpencodeAccountPoolPath()).toBe(activeStoragePath); + await expect(assessOpencodeAccountPool()).rejects.toThrow( + "Import source cannot be the active storage file.", + ); + }); + }); + describe("clearAccounts edge cases", () => { it("removes primary, backup, and wal artifacts", async () => { const now = Date.now(); From fd189fe72d5d30bcccce75ade51b167b0f5e890c Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 20:49:01 +0800 Subject: [PATCH 2/5] feat(auth): add first-run setup wizard --- docs/getting-started.md | 2 + lib/codex-manager.ts | 253 ++++++++++- lib/storage.ts | 21 +- lib/sync-history.ts | 240 ++++++++-- lib/ui/auth-menu.ts | 59 +++ lib/ui/copy.ts | 21 + test/codex-manager-cli.test.ts | 805 ++++++++++++++++++++++++++++----- test/storage.test.ts | 49 +- test/sync-history.test.ts | 508 ++++++++++++++------- 9 files changed, 1575 insertions(+), 383 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 2d2fa281..ff637391 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -49,6 +49,8 @@ Expected flow: 4. Return to the terminal when the browser step completes. 5. Confirm the account appears in the saved account list. +On a brand-new install (no `openai-codex-accounts.json` yet), `codex auth login` opens a first-run setup screen before OAuth. Use it to restore from backups, import accounts from a companion app pool, run doctor/check paths, open settings and Codex CLI sync, or skip straight to login. All actions reuse the existing backup browser, import, doctor, and settings flows. Upgrade note: no npm scripts changed, so existing automation remains compatible. + If interactive `codex auth login` starts with zero saved accounts and recoverable named backups in your `backups/` directory, the login flow will prompt you to restore before opening OAuth. Confirm to launch the existing restore manager; skip to proceed with a fresh login. The prompt is suppressed in non-interactive/fallback flows and after same-session `fresh` or `reset` actions. Verify the new account: diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 633e0248..77ec2ee4 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -101,6 +101,10 @@ import { } from "./codex-cli/sync.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { ANSI } from "./ui/ansi.js"; +import { + type FirstRunWizardOptions, + showFirstRunWizard, +} from "./ui/auth-menu.js"; import { confirm } from "./ui/confirm.js"; import { UI_COPY } from "./ui/copy.js"; import { paintUiText, quotaToneFromLeftPercent } from "./ui/format.js"; @@ -1324,7 +1328,7 @@ async function runActionPanel( await action(); } catch (error) { failed = error; - capture("x ", [error instanceof Error ? error.message : String(error)]); + capture("x ", [collapseWhitespace(formatRedactedFilesystemError(error))]); } finally { running = false; if (timer) { @@ -4220,6 +4224,14 @@ async function buildLoginMenuHealthSummary( }; } +function formatOpencodeImportFailure(error: unknown): string { + if (typeof error !== "string") { + return "OpenCode account pool is not importable."; + } + const normalized = collapseWhitespace(formatRedactedFilesystemError(error)); + return normalized || "OpenCode account pool is not importable."; +} + async function handleManageAction( storage: AccountStorageV3, menuResult: Awaited>, @@ -4297,6 +4309,150 @@ export function resolveStartupRecoveryAction( : "open-empty-storage-menu"; } +function shouldShowFirstRunWizard(storage: AccountStorageV3 | null): boolean { + return isInteractiveLoginMenuAvailable() && storage === null; +} + +async function buildFirstRunWizardOptions(): Promise { + let namedBackupCount = 0; + let rotatingBackupCount = 0; + let hasOpencodeSource = false; + + try { + const namedBackups = await listNamedBackups(); + namedBackupCount = namedBackups.length; + } catch (error) { + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn(`Failed to list named backups (${errorLabel}).`); + } + try { + const rotatingBackups = await listRotatingBackups(); + rotatingBackupCount = rotatingBackups.length; + } catch (error) { + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn(`Failed to list rotating backups (${errorLabel}).`); + } + try { + hasOpencodeSource = (await assessOpencodeAccountPool()) !== null; + } catch (error) { + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn( + `Failed to detect OpenCode import source (${errorLabel}).`, + ); + } + + return { + storagePath: getStoragePath(), + namedBackupCount, + rotatingBackupCount, + hasOpencodeSource, + }; +} + +type FirstRunWizardResult = + | { outcome: "cancelled" } + | { outcome: "continue"; latestStorage: AccountStorageV3 | null }; + +async function runFirstRunWizard( + displaySettings: DashboardDisplaySettings, +): Promise { + while (true) { + const action = await showFirstRunWizard(await buildFirstRunWizardOptions()); + switch (action.type) { + case "cancel": + console.log("Cancelled."); + return { outcome: "cancelled" }; + case "login": + case "skip": + return { outcome: "continue", latestStorage: null }; + case "restore": + await runBackupBrowserManager(displaySettings); + break; + case "import-opencode": { + let assessment: BackupRestoreAssessment | null; + try { + assessment = await assessOpencodeAccountPool(); + } catch (error) { + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn( + `Failed to detect OpenCode import source (${errorLabel}).`, + ); + break; + } + if (!assessment) { + console.log("No OpenCode account pool was detected."); + break; + } + if (!assessment.backup.valid || !assessment.eligibleForRestore) { + console.log(formatOpencodeImportFailure(assessment.error)); + break; + } + if (assessment.wouldExceedLimit) { + console.log( + `Import would exceed the account limit (${assessment.currentAccountCount ?? "?"} current, ${assessment.mergedAccountCount ?? "?"} after import). Remove accounts first.`, + ); + break; + } + const backupLabel = basename(assessment.backup.path); + const confirmed = await confirm( + `Import OpenCode accounts from ${backupLabel}?`, + ); + if (!confirmed) { + break; + } + try { + await runActionPanel( + "Import OpenCode Accounts", + `Importing from ${backupLabel}`, + async () => { + const imported = await importAccounts(assessment.backup.path); + console.log( + `Imported ${imported.imported} account${imported.imported === 1 ? "" : "s"}. Skipped ${imported.skipped}. Total accounts: ${imported.total}.`, + ); + }, + displaySettings, + ); + } catch (error) { + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn(`OpenCode import failed (${errorLabel}).`); + } + break; + } + case "settings": + await configureUnifiedSettings(displaySettings); + break; + case "doctor": + try { + await runActionPanel( + "Doctor", + "Checking storage and sync paths", + async () => { + await runDoctor(["--json"]); + }, + displaySettings, + ); + } catch (error) { + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn(`Doctor check failed (${errorLabel}).`); + } + break; + } + + let latestStorage: AccountStorageV3 | null = null; + try { + latestStorage = await loadAccounts(); + } catch (error) { + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn( + `Failed to refresh saved accounts after first-run action (${errorLabel}).`, + ); + } + if (latestStorage && latestStorage.accounts.length > 0) { + return { outcome: "continue", latestStorage }; + } + } +} + async function runAuthLogin(): Promise { setStoragePath(null); let suppressRecoveryPrompt = false; @@ -4307,9 +4463,47 @@ async function runAuthLogin(): Promise { > | null = null; let pendingMenuQuotaRefresh: Promise | null = null; let menuQuotaRefreshStatus: string | undefined; + let skipEmptyStorageRecoveryMenu = false; + let firstRunWizardShownInLoop = false; + const initialStorage = await loadAccounts(); + const startedFromMissingStorage = shouldShowFirstRunWizard(initialStorage); + let cachedInitialStorage: AccountStorageV3 | null | undefined = + initialStorage; + + if (startedFromMissingStorage) { + const displaySettings = await loadDashboardDisplaySettings(); + applyUiThemeFromDashboardSettings(displaySettings); + const wizardResult = await runFirstRunWizard(displaySettings); + firstRunWizardShownInLoop = true; + if (wizardResult.outcome === "cancelled") { + return 0; + } + cachedInitialStorage = wizardResult.latestStorage; + if (cachedInitialStorage === null) { + try { + cachedInitialStorage = await loadAccounts(); + } catch (error) { + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn( + `Failed to refresh saved accounts after first-run wizard (${errorLabel}).`, + ); + cachedInitialStorage = null; + } + } + if (!(cachedInitialStorage && cachedInitialStorage.accounts.length > 0)) { + skipEmptyStorageRecoveryMenu = true; + } + } + loginFlow: while (true) { - let existingStorage = await loadAccounts(); + let existingStorage: AccountStorageV3 | null; + if (cachedInitialStorage !== undefined) { + existingStorage = cachedInitialStorage; + cachedInitialStorage = undefined; + } else { + existingStorage = await loadAccounts(); + } const canOpenEmptyStorageMenu = allowEmptyStorageMenu && isInteractiveLoginMenuAvailable(); if ( @@ -4368,7 +4562,7 @@ async function runAuthLogin(): Promise { }, ); - if (menuResult.mode === "cancel") { + if (!menuResult || menuResult.mode === "cancel") { console.log("Cancelled."); return 0; } @@ -4449,8 +4643,9 @@ async function runAuthLogin(): Promise { continue; } if (!assessment.backup.valid || !assessment.eligibleForRestore) { - const assessmentErrorLabel = - assessment.error || "OpenCode account pool is not importable."; + const assessmentErrorLabel = assessment.error + ? formatRedactedFilesystemError(assessment.error) + : "OpenCode account pool is not importable."; console.log(assessmentErrorLabel); continue; } @@ -4480,7 +4675,9 @@ async function runAuthLogin(): Promise { displaySettings, ); } catch (error) { - const errorLabel = formatRedactedFilesystemError(error); + const errorLabel = collapseWhitespace( + formatRedactedFilesystemError(error), + ); console.error(`Import failed: ${errorLabel}`); } continue; @@ -4597,7 +4794,8 @@ async function runAuthLogin(): Promise { recoveryState = scannedRecoveryState; if ( resolveStartupRecoveryAction(scannedRecoveryState, recoveryScanFailed) === - "open-empty-storage-menu" + "open-empty-storage-menu" && + !skipEmptyStorageRecoveryMenu ) { allowEmptyStorageMenu = true; continue loginFlow; @@ -4633,6 +4831,10 @@ async function runAuthLogin(): Promise { } continue; } + if (startedFromMissingStorage) { + allowEmptyStorageMenu = true; + continue loginFlow; + } } catch (error) { if (!promptWasShown) { recoveryPromptAttempted = false; @@ -4644,6 +4846,35 @@ async function runAuthLogin(): Promise { } } } + if ( + startedFromMissingStorage && + !firstRunWizardShownInLoop && + existingCount === 0 && + isInteractiveLoginMenuAvailable() + ) { + firstRunWizardShownInLoop = true; + const displaySettings = await loadDashboardDisplaySettings(); + applyUiThemeFromDashboardSettings(displaySettings); + const firstRunResult = await runFirstRunWizard(displaySettings); + if (firstRunResult.outcome === "cancelled") { + return 0; + } + let refreshedAfterWizard = firstRunResult.latestStorage; + if (refreshedAfterWizard === null) { + try { + refreshedAfterWizard = await loadAccounts(); + } catch (error) { + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn( + `Failed to refresh saved accounts after first-run wizard (${errorLabel}).`, + ); + refreshedAfterWizard = null; + } + } + if ((refreshedAfterWizard?.accounts.length ?? 0) > 0) { + continue; + } + } let forceNewLogin = existingCount > 0; while (true) { const tokenResult = await runOAuthFlow(forceNewLogin); @@ -5322,7 +5553,13 @@ function buildRestoreAssessmentLines( assessment.currentActiveIndex, )}`, `${stylePromptText("Active after restore:", "muted")} ${formatActiveAccountOutcome(assessment)}`, - `${stylePromptText("Eligibility:", "muted")} ${assessment.wouldExceedLimit ? `Would exceed ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` : assessment.eligibleForRestore ? "Recoverable" : (assessment.error ?? "Unavailable")}`, + `${stylePromptText("Eligibility:", "muted")} ${ + assessment.wouldExceedLimit + ? `Would exceed ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` + : assessment.eligibleForRestore + ? "Recoverable" + : assessment.error?.trim() || "Unavailable" + }`, ]; } diff --git a/lib/storage.ts b/lib/storage.ts index 0d2e92e8..2fcedfaf 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2,12 +2,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; import { existsSync, promises as fs } from "node:fs"; import { homedir } from "node:os"; -import { - basename, - dirname, - join, - resolve as resolveFilesystemPath, -} from "node:path"; +import { basename, dirname, join } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { @@ -2095,19 +2090,13 @@ export function detectOpencodeAccountPoolPath(): string | null { (base): base is string => !!base && base.trim().length > 0, ); for (const base of appDataBases) { - candidates.add( - resolveFilesystemPath(join(base, "OpenCode", ACCOUNTS_FILE_NAME)), - ); - candidates.add( - resolveFilesystemPath(join(base, "opencode", ACCOUNTS_FILE_NAME)), - ); + candidates.add(join(base, "OpenCode", ACCOUNTS_FILE_NAME)); + candidates.add(join(base, "opencode", ACCOUNTS_FILE_NAME)); } const home = homedir(); if (home) { - candidates.add( - resolveFilesystemPath(join(home, ".opencode", ACCOUNTS_FILE_NAME)), - ); + candidates.add(join(home, ".opencode", ACCOUNTS_FILE_NAME)); } for (const candidate of candidates) { @@ -2464,7 +2453,7 @@ function extractPathTail(pathValue: string): string { function redactFilesystemDetails(value: string): string { return value.replace( - /(?:[A-Za-z]:)?[\\/][^"'`\r\n]+(?:[\\/][^"'`\r\n]+)*/g, + /(?:[A-Za-z]:)?[\\/][^"'`\r\n]+(?:[\\/][^"'`\r\n]+)+/g, (pathValue) => extractPathTail(pathValue), ); } diff --git a/lib/sync-history.ts b/lib/sync-history.ts index cc749171..0b06ed11 100644 --- a/lib/sync-history.ts +++ b/lib/sync-history.ts @@ -11,6 +11,7 @@ const HISTORY_FILE_NAME = "sync-history.ndjson"; const LATEST_FILE_NAME = "sync-history-latest.json"; const MAX_HISTORY_ENTRIES = 200; const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); +const RETRYABLE_WRITE_CODES = new Set(["EBUSY", "EPERM"]); type SyncHistoryKind = "codex-cli-sync" | "live-account-sync"; @@ -44,6 +45,7 @@ let historyDirOverride: string | null = null; let historyMutex: Promise = Promise.resolve(); let lastAppendError: string | null = null; let lastAppendPaths: SyncHistoryPaths | null = null; +let historyEntryCountEstimate: number | null = null; const pendingHistoryWrites = new Set>(); function getHistoryDirectory(): string { @@ -68,6 +70,12 @@ function serializeEntry(entry: SyncHistoryEntry): string { return JSON.stringify(entry); } +export interface PrunedSyncHistory { + entries: SyncHistoryEntry[]; + removed: number; + latest: SyncHistoryEntry | null; +} + function withHistoryLock(fn: () => Promise): Promise { const previous = historyMutex; let release: () => void = () => {}; @@ -78,15 +86,35 @@ function withHistoryLock(fn: () => Promise): Promise { } async function waitForPendingHistoryWrites(): Promise { - while (pendingHistoryWrites.size > 0) { - await Promise.allSettled(Array.from(pendingHistoryWrites)); - } + if (pendingHistoryWrites.size === 0) return; + await Promise.allSettled(Array.from(pendingHistoryWrites)); } async function ensureHistoryDir(directory: string): Promise { await fs.mkdir(directory, { recursive: true, mode: 0o700 }); } +async function withRetryableHistoryWrite( + operation: () => Promise, +): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + return await operation(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + !code || + !RETRYABLE_WRITE_CODES.has(code) || + attempt === 4 + ) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } + throw new Error("Retryable sync-history write loop exhausted unexpectedly."); +} + function isSyncHistoryEntry(value: unknown): value is SyncHistoryEntry { if (!value || typeof value !== "object") { return false; @@ -113,6 +141,31 @@ function parseEntry(line: string): SyncHistoryEntry | null { } } +function parseHistoryContent(content: string): SyncHistoryEntry[] { + return content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => parseEntry(line)) + .filter((entry): entry is SyncHistoryEntry => entry !== null); +} + +async function loadHistoryEntriesFromDisk( + paths: SyncHistoryPaths, +): Promise { + const content = await fs.readFile(paths.historyPath, "utf8").catch((error) => { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return ""; + } + throw error; + }); + if (!content) { + return []; + } + return parseHistoryContent(content); +} + async function readHistoryTail( historyPath: string, options: { limit: number; kind?: SyncHistoryKind }, @@ -168,26 +221,93 @@ async function readHistoryTail( } } -async function trimHistoryFileIfNeeded(paths: SyncHistoryPaths): Promise { - const content = await fs.readFile(paths.historyPath, "utf8").catch((error) => { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return ""; +export function pruneSyncHistoryEntries( + entries: SyncHistoryEntry[], + maxEntries: number = MAX_HISTORY_ENTRIES, +): PrunedSyncHistory { + if (entries.length === 0) { + return { entries: [], removed: 0, latest: null }; + } + + const boundedMaxEntries = Math.max(0, maxEntries); + const latestByKind = new Map(); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (!entry || latestByKind.has(entry.kind)) { + continue; + } + latestByKind.set(entry.kind, entry); + } + + const requiredEntries = new Set(latestByKind.values()); + const keptEntries: SyncHistoryEntry[] = []; + const seenEntries = new Set(); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (!entry || seenEntries.has(entry)) { + continue; + } + if ( + keptEntries.length < boundedMaxEntries || + requiredEntries.has(entry) + ) { + keptEntries.push(entry); + seenEntries.add(entry); } - throw error; - }); - if (!content) { - return; } - const lines = content.split(/\r?\n/).filter(Boolean); - if (lines.length <= MAX_HISTORY_ENTRIES) { + + const chronologicalEntries = keptEntries + .reverse() + .map((entry) => cloneEntry(entry)); + const latest = cloneEntry(chronologicalEntries.at(-1) ?? null); + return { + entries: chronologicalEntries, + removed: entries.length - chronologicalEntries.length, + latest, + }; +} + +async function rewriteLatestEntry( + latest: SyncHistoryEntry | null, + paths: SyncHistoryPaths, +): Promise { + if (!latest) { + await withRetryableHistoryWrite(() => + fs.rm(paths.latestPath, { force: true }), + ); return; } - const trimmedContent = `${lines.slice(-MAX_HISTORY_ENTRIES).join("\n")}\n`; - await fs.writeFile(paths.historyPath, trimmedContent, { - encoding: "utf8", - mode: 0o600, - }); + await withRetryableHistoryWrite(() => + fs.writeFile(paths.latestPath, `${JSON.stringify(latest, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }), + ); +} + +async function trimHistoryFileIfNeeded(paths: SyncHistoryPaths): Promise { + const entries = await loadHistoryEntriesFromDisk(paths); + const result = pruneSyncHistoryEntries(entries, MAX_HISTORY_ENTRIES); + if (result.removed === 0) { + return result; + } + if (result.entries.length === 0) { + await withRetryableHistoryWrite(() => + fs.rm(paths.historyPath, { force: true }), + ); + return result; + } + await withRetryableHistoryWrite(() => + fs.writeFile( + paths.historyPath, + `${result.entries.map((entry) => serializeEntry(entry)).join("\n")}\n`, + { + encoding: "utf8", + mode: 0o600, + }, + ), + ); + return result; } export async function appendSyncHistoryEntry( @@ -197,15 +317,28 @@ export async function appendSyncHistoryEntry( const paths = getSyncHistoryPaths(); lastAppendPaths = paths; await ensureHistoryDir(paths.directory); - await fs.appendFile(paths.historyPath, `${serializeEntry(entry)}\n`, { - encoding: "utf8", - mode: 0o600, - }); - await trimHistoryFileIfNeeded(paths); - await fs.writeFile(paths.latestPath, `${JSON.stringify(entry, null, 2)}\n`, { - encoding: "utf8", - mode: 0o600, - }); + if (historyEntryCountEstimate === null) { + historyEntryCountEstimate = (await loadHistoryEntriesFromDisk(paths)).length; + } + await withRetryableHistoryWrite(() => + fs.appendFile(paths.historyPath, `${serializeEntry(entry)}\n`, { + encoding: "utf8", + mode: 0o600, + }), + ); + historyEntryCountEstimate += 1; + const shouldTrim = historyEntryCountEstimate > MAX_HISTORY_ENTRIES; + const prunedHistory = shouldTrim + ? await trimHistoryFileIfNeeded(paths) + : { + entries: [], + removed: 0, + latest: entry, + }; + if (shouldTrim) { + historyEntryCountEstimate = prunedHistory.entries.length; + } + await rewriteLatestEntry(prunedHistory.latest ?? entry, paths); lastAppendError = null; }); pendingHistoryWrites.add(writePromise); @@ -228,19 +361,14 @@ export async function readSyncHistory( const { kind, limit } = options; await waitForPendingHistoryWrites(); try { + const paths = getSyncHistoryPaths(); if (typeof limit === "number" && limit > 0) { - return await readHistoryTail(getSyncHistoryPaths().historyPath, { + return readHistoryTail(paths.historyPath, { kind, limit, }); } - const content = await fs.readFile(getSyncHistoryPaths().historyPath, "utf8"); - const parsed = content - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => parseEntry(line)) - .filter((entry): entry is SyncHistoryEntry => entry !== null); + const parsed = await loadHistoryEntriesFromDisk(paths); const filtered = kind ? parsed.filter((entry) => entry.kind === kind) : parsed; @@ -272,6 +400,44 @@ export function readLatestSyncHistorySync(): SyncHistoryEntry | null { } } +export async function pruneSyncHistory( + options: { maxEntries?: number } = {}, +): Promise<{ removed: number; kept: number; latest: SyncHistoryEntry | null }> { + const maxEntries = options.maxEntries ?? MAX_HISTORY_ENTRIES; + await waitForPendingHistoryWrites(); + return withHistoryLock(async () => { + const paths = getSyncHistoryPaths(); + await ensureHistoryDir(paths.directory); + const entries = await loadHistoryEntriesFromDisk(paths); + const result = pruneSyncHistoryEntries(entries, maxEntries); + if (result.entries.length === 0) { + await withRetryableHistoryWrite(() => + fs.rm(paths.historyPath, { force: true }), + ); + } else { + await withRetryableHistoryWrite(() => + fs.writeFile( + paths.historyPath, + `${result.entries.map((entry) => serializeEntry(entry)).join("\n")}\n`, + { + encoding: "utf8", + mode: 0o600, + }, + ), + ); + } + await rewriteLatestEntry(result.latest, paths); + lastAppendPaths = paths; + lastAppendError = null; + historyEntryCountEstimate = result.entries.length; + return { + removed: result.removed, + kept: result.entries.length, + latest: result.latest, + }; + }); +} + export function cloneSyncHistoryEntry( entry: SyncHistoryEntry | null, ): SyncHistoryEntry | null { @@ -280,6 +446,7 @@ export function cloneSyncHistoryEntry( export function configureSyncHistoryForTests(directory: string | null): void { historyDirOverride = directory ? directory.trim() : null; + historyEntryCountEstimate = null; } export async function __resetSyncHistoryForTests(): Promise { @@ -309,6 +476,7 @@ export async function __resetSyncHistoryForTests(): Promise { }); lastAppendError = null; lastAppendPaths = null; + historyEntryCountEstimate = 0; // Files were just deleted; no disk reread is needed on the next append. } export function __getLastSyncHistoryErrorForTests(): string | null { diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index b4cbd07e..322ddf99 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -80,6 +80,22 @@ export type AuthMenuAction = | { type: "delete-all" } | { type: "cancel" }; +export interface FirstRunWizardOptions { + storagePath: string; + namedBackupCount: number; + rotatingBackupCount: number; + hasOpencodeSource: boolean; +} + +export type FirstRunWizardAction = + | { type: "login" } + | { type: "restore" } + | { type: "import-opencode" } + | { type: "settings" } + | { type: "doctor" } + | { type: "skip" } + | { type: "cancel" }; + export type AccountAction = | "back" | "delete" @@ -918,4 +934,47 @@ export async function showAccountDetails( } } +export async function showFirstRunWizard( + options: FirstRunWizardOptions, +): Promise { + const ui = getUiRuntimeOptions(); + const items: MenuItem[] = [ + { + label: UI_COPY.firstRun.restore, + hint: UI_COPY.firstRun.backupSummary( + options.namedBackupCount, + options.rotatingBackupCount, + ), + value: { type: "restore" }, + color: "yellow", + }, + ...(options.hasOpencodeSource + ? [ + { + label: UI_COPY.firstRun.importOpencode, + value: { type: "import-opencode" as const }, + color: "yellow" as const, + }, + ] + : []), + { label: UI_COPY.firstRun.login, value: { type: "login" }, color: "green" }, + { label: UI_COPY.firstRun.settings, value: { type: "settings" } }, + { label: UI_COPY.firstRun.doctor, value: { type: "doctor" } }, + { label: UI_COPY.firstRun.skip, value: { type: "skip" } }, + { label: UI_COPY.firstRun.cancel, value: { type: "cancel" }, color: "red" }, + ]; + + const result = await select(items, { + message: UI_COPY.firstRun.title, + subtitle: UI_COPY.firstRun.subtitle(options.storagePath), + help: UI_COPY.firstRun.help, + clearScreen: true, + selectedEmphasis: "minimal", + focusStyle: ui.v2Enabled ? "chip" : "row-invert", + theme: ui.theme, + }); + + return result ?? { type: "cancel" }; +} + export { isTTY }; diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 25e5b998..5568d01b 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -161,6 +161,27 @@ export const UI_COPY = { "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (u) restore backup, (i) import OpenCode, (f) fresh, (r) reset, (q) back [a/c/b/x/s/d/g/u/i/f/r/q]: ", invalidModePrompt: "Use one of: a, c, b, x, s, d, g, u, i, f, r, q.", }, + firstRun: { + title: "First-Run Setup", + subtitle: (storagePath: string) => + `No saved accounts detected. Storage will be created at ${storagePath}.`, + backupSummary: (named: number, rotating: number) => { + const total = named + rotating; + if (total === 0) return "No backups detected yet"; + if (named > 0 && rotating > 0) + return `Named backups: ${named}, rotating backups: ${rotating}`; + if (named > 0) return `Named backups: ${named}`; + return `Rotating backups: ${rotating}`; + }, + restore: "Open Backup Browser", + importOpencode: "Import OpenCode Accounts", + login: "Add or Log In", + settings: "Open Settings & Sync", + doctor: "Run Doctor / Check Paths", + skip: "Skip setup and continue", + cancel: "Exit", + help: "↑↓ Move | Enter Select | Q Exit", + }, } as const; /** diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index bce76f1a..b13f2933 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -64,6 +64,14 @@ const normalizeAccountStorageMock = vi.fn((value) => value); const withAccountStorageTransactionMock = vi.fn(); const withAccountAndFlaggedStorageTransactionMock = vi.fn(); +function flattenMockCallArgs(call: unknown[]): string { + return call + .map((arg) => + arg instanceof Error ? `${arg.name}: ${arg.message}` : String(arg), + ) + .join(" "); +} + vi.mock("../lib/logger.js", () => ({ createLogger: vi.fn(() => ({ debug: vi.fn(), @@ -877,24 +885,580 @@ describe("codex manager cli commands", () => { ], }); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "forecast", "--json"]); + expect(exitCode).toBe(0); + expect(errorSpy).not.toHaveBeenCalled(); + expect(queuedRefreshMock).not.toHaveBeenCalled(); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + command: string; + summary: { total: number }; + recommendation: { recommendedIndex: number | null }; + }; + expect(payload.command).toBe("forecast"); + expect(payload.summary.total).toBe(2); + expect(payload.recommendation.recommendedIndex).toBe(0); + }); + + it("prints implemented feature matrix", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "features"]); + expect(exitCode).toBe(0); + expect(errorSpy).not.toHaveBeenCalled(); + expect(logSpy.mock.calls[0]?.[0]).toBe("Implemented features (41)"); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes( + "40. OAuth browser-first flow with manual callback fallback", + ), + ), + ).toBe(true); + }); + + it("prints auth help when subcommand is --help", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "--help"]); + expect(exitCode).toBe(0); + expect(errorSpy).not.toHaveBeenCalled(); + expect(logSpy.mock.calls[0]?.[0]).toContain("Codex Multi-Auth CLI"); + }); + + it("shows first-run wizard before OAuth when storage file is missing", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const authMenu = await import("../lib/ui/auth-menu.js"); + const wizardSpy = vi + .spyOn(authMenu, "showFirstRunWizard") + .mockResolvedValue({ type: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(wizardSpy).toHaveBeenCalledTimes(1); + expect(wizardSpy).toHaveBeenCalledWith( + expect.objectContaining({ + storagePath: "/mock/openai-codex-accounts.json", + }), + ); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + }); + + it("redacts first-run backup listing warnings", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + listNamedBackupsMock.mockRejectedValueOnce( + new Error("EPERM: C:\\Users\\alice\\AppData\\Local\\Codex\\named-backups"), + ); + listRotatingBackupsMock.mockRejectedValueOnce( + new Error("EBUSY: C:\\Users\\alice\\AppData\\Local\\Codex\\rotating-backups"), + ); + assessOpencodeAccountPoolMock.mockResolvedValueOnce(null); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const authMenu = await import("../lib/ui/auth-menu.js"); + vi.spyOn(authMenu, "showFirstRunWizard").mockResolvedValue({ + type: "cancel", + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + const warningLines = warnSpy.mock.calls.map(flattenMockCallArgs); + expect(exitCode).toBe(0); + expect( + warningLines.some((line) => + line.includes("Failed to list named backups") && + !line.includes("alice"), + ), + ).toBe(true); + expect( + warningLines.some((line) => + line.includes("Failed to list rotating backups") && + !line.includes("alice"), + ), + ).toBe(true); + }); + + it("continues into OAuth when first-run wizard chooses login", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const authMenu = await import("../lib/ui/auth-menu.js"); + const wizardSpy = vi.spyOn(authMenu, "showFirstRunWizard").mockResolvedValue({ + type: "login", + }); + await configureSuccessfulOAuthFlow(); + promptAddAnotherAccountMock.mockResolvedValue(false); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(wizardSpy).toHaveBeenCalledTimes(1); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + }); + + it("loops back to first-run wizard after opening settings without creating accounts", async () => { + setInteractiveTTY(true); + const settingsHub = await import("../lib/codex-manager/settings-hub.js"); + const authMenu = await import("../lib/ui/auth-menu.js"); + vi.spyOn(authMenu, "showFirstRunWizard") + .mockResolvedValueOnce({ type: "settings" }) + .mockResolvedValueOnce({ type: "cancel" }); + const configureUnifiedSettingsSpy = vi + .spyOn(settingsHub, "configureUnifiedSettings") + .mockResolvedValue(undefined); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + let loadCount = 0; + loadAccountsMock.mockImplementation(async () => { + loadCount += 1; + return loadCount === 1 ? null : structuredClone(emptyStorage); + }); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(configureUnifiedSettingsSpy).toHaveBeenCalledTimes(1); + expect(vi.mocked(authMenu.showFirstRunWizard)).toHaveBeenCalledTimes(2); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + }); + + it("loops back to first-run wizard after running doctor without creating accounts", async () => { + setInteractiveTTY(true); + const authMenu = await import("../lib/ui/auth-menu.js"); + vi.spyOn(authMenu, "showFirstRunWizard") + .mockResolvedValueOnce({ type: "doctor" }) + .mockResolvedValueOnce({ type: "cancel" }); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + let loadCount = 0; + loadAccountsMock.mockImplementation(async () => { + loadCount += 1; + return loadCount === 1 ? null : structuredClone(emptyStorage); + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(vi.mocked(authMenu.showFirstRunWizard)).toHaveBeenCalledTimes(2); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + }); + + it("keeps the first-run wizard open when doctor fails", async () => { + setInteractiveTTY(true); + const authMenu = await import("../lib/ui/auth-menu.js"); + const wizardSpy = vi + .spyOn(authMenu, "showFirstRunWizard") + .mockResolvedValueOnce({ type: "doctor" }) + .mockResolvedValueOnce({ type: "cancel" }); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + let loadCount = 0; + loadAccountsMock.mockImplementation(async () => { + loadCount += 1; + return loadCount === 1 ? null : structuredClone(emptyStorage); + }); + loadCodexCliStateMock.mockRejectedValueOnce( + makeErrnoError( + "resource busy, open 'C:\\Users\\alice\\AppData\\Local\\Codex\\auth.json'", + "EBUSY", + ), + ); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(wizardSpy).toHaveBeenCalledTimes(2); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Doctor check failed"), + ); + expect( + warnSpy.mock.calls.every((call) => !flattenMockCallArgs(call).includes("alice")), + ).toBe(true); + }); + + it("imports OpenCode accounts from the first-run wizard", async () => { + setInteractiveTTY(true); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const authMenu = await import("../lib/ui/auth-menu.js"); + vi.spyOn(authMenu, "showFirstRunWizard").mockResolvedValue({ + type: "import-opencode", + }); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + const restoredStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "imported@example.com", + refreshToken: "refresh-imported", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + }; + let loadCount = 0; + loadAccountsMock.mockImplementation(async () => { + loadCount += 1; + if (loadCount === 1) return null; + return loadCount === 2 + ? structuredClone(emptyStorage) + : structuredClone(restoredStorage); + }); + listNamedBackupsMock.mockResolvedValue([]); + listRotatingBackupsMock.mockResolvedValue([]); + assessOpencodeAccountPoolMock.mockResolvedValue({ + backup: { + name: "openai-codex-accounts.json", + path: "/mock/.opencode/openai-codex-accounts.json", + createdAt: null, + updatedAt: Date.now(), + sizeBytes: 256, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + valid: true, + nextActiveIndex: 0, + nextActiveEmail: "imported@example.com", + nextActiveAccountId: undefined, + activeAccountChanged: true, + error: "", + }); + importAccountsMock.mockResolvedValue({ imported: 1, skipped: 0, total: 1 }); + confirmMock.mockResolvedValueOnce(true); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(importAccountsMock).toHaveBeenCalledWith( + "/mock/.opencode/openai-codex-accounts.json", + ); + expect(confirmMock).toHaveBeenCalledWith( + "Import OpenCode accounts from openai-codex-accounts.json?", + ); + const logLines = logSpy.mock.calls.map(flattenMockCallArgs); + expect( + logLines.some((line) => + line.includes("Importing from openai-codex-accounts.json"), + ), + ).toBe(true); + expect(logLines.join("\n")).not.toContain( + "/mock/.opencode/openai-codex-accounts.json", + ); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + expect(promptLoginModeMock).toHaveBeenCalledTimes(1); + expect(promptLoginModeMock.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: "imported@example.com" }), + ]), + ); + }); + + it("keeps the first-run wizard open when the post-action storage reload fails", async () => { + setInteractiveTTY(true); + const authMenu = await import("../lib/ui/auth-menu.js"); + const wizardSpy = vi + .spyOn(authMenu, "showFirstRunWizard") + .mockResolvedValueOnce({ type: "import-opencode" }) + .mockResolvedValueOnce({ type: "cancel" }); + loadAccountsMock + .mockResolvedValueOnce(null) + .mockRejectedValueOnce( + makeErrnoError( + "resource busy, open 'C:\\Users\\alice\\AppData\\Local\\OpenCode\\openai-codex-accounts.json'", + "EBUSY", + ), + ); + listNamedBackupsMock.mockResolvedValue([]); + listRotatingBackupsMock.mockResolvedValue([]); + assessOpencodeAccountPoolMock.mockResolvedValue({ + backup: { + name: "openai-codex-accounts.json", + path: "/mock/.opencode/openai-codex-accounts.json", + createdAt: null, + updatedAt: Date.now(), + sizeBytes: 256, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + nextActiveIndex: 0, + nextActiveEmail: "imported@example.com", + nextActiveAccountId: undefined, + activeAccountChanged: true, + error: "", + }); + importAccountsMock.mockResolvedValue({ imported: 1, skipped: 0, total: 1 }); + confirmMock.mockResolvedValueOnce(true); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(importAccountsMock).toHaveBeenCalledWith( + "/mock/.opencode/openai-codex-accounts.json", + ); + expect(wizardSpy).toHaveBeenCalledTimes(2); + expect( + warnSpy.mock.calls.some(([message]) => + String(message).includes( + "Failed to refresh saved accounts after first-run action", + ), + ), + ).toBe(true); + expect( + warnSpy.mock.calls.every((call) => !String(call[0]).includes("alice")), + ).toBe(true); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + }); + + it("keeps the first-run wizard open when OpenCode import probing fails", async () => { + setInteractiveTTY(true); + const authMenu = await import("../lib/ui/auth-menu.js"); + const wizardSpy = vi + .spyOn(authMenu, "showFirstRunWizard") + .mockResolvedValueOnce({ type: "import-opencode" }) + .mockResolvedValueOnce({ type: "cancel" }); + loadAccountsMock.mockResolvedValue(null); + listNamedBackupsMock.mockResolvedValue([]); + listRotatingBackupsMock.mockResolvedValue([]); + assessOpencodeAccountPoolMock.mockRejectedValueOnce( + new Error("EBUSY: C:\\Users\\alice\\AppData\\Local\\opencode\\openai-codex-accounts.json"), + ); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(wizardSpy).toHaveBeenCalledTimes(2); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to detect OpenCode import source"), + ); + expect( + warnSpy.mock.calls.every((call) => !String(call[0]).includes("alice")), + ).toBe(true); + }); + + it("keeps the first-run wizard open when action-time OpenCode assessment fails", async () => { + setInteractiveTTY(true); + const authMenu = await import("../lib/ui/auth-menu.js"); + const wizardSpy = vi + .spyOn(authMenu, "showFirstRunWizard") + .mockResolvedValueOnce({ type: "import-opencode" }) + .mockResolvedValueOnce({ type: "cancel" }); + loadAccountsMock.mockResolvedValue(null); + listNamedBackupsMock.mockResolvedValue([]); + listRotatingBackupsMock.mockResolvedValue([]); + assessOpencodeAccountPoolMock + .mockResolvedValueOnce({ + backup: { + name: "openai-codex-accounts.json", + path: "/mock/.opencode/openai-codex-accounts.json", + createdAt: null, + updatedAt: Date.now(), + sizeBytes: 256, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + nextActiveIndex: 0, + nextActiveEmail: "imported@example.com", + nextActiveAccountId: undefined, + activeAccountChanged: true, + error: "", + }) + .mockRejectedValueOnce( + new Error( + "EBUSY: C:\\Users\\alice\\AppData\\Local\\opencode\\openai-codex-accounts.json", + ), + ); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(wizardSpy).toHaveBeenCalledTimes(2); + expect(confirmMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to detect OpenCode import source"), + ); + expect( + warnSpy.mock.calls.every((call) => !String(call[0]).includes("alice")), + ).toBe(true); + expect( + warnSpy.mock.calls.every( + (call) => !String(call[0]).includes("OpenCode import failed"), + ), + ).toBe(true); + }); + + it("keeps the first-run wizard open when OpenCode backup assessment is invalid", async () => { + setInteractiveTTY(true); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const authMenu = await import("../lib/ui/auth-menu.js"); + const wizardSpy = vi + .spyOn(authMenu, "showFirstRunWizard") + .mockResolvedValueOnce({ type: "import-opencode" }) + .mockResolvedValueOnce({ type: "cancel" }); + loadAccountsMock.mockResolvedValue(null); + listNamedBackupsMock.mockResolvedValue([]); + listRotatingBackupsMock.mockResolvedValue([]); + const invalidAssessment = { + backup: { + name: "openai-codex-accounts.json", + path: "/mock/.opencode/openai-codex-accounts.json", + createdAt: null, + updatedAt: Date.now(), + sizeBytes: 256, + version: 3, + accountCount: 1, + schemaErrors: ["invalid"], + valid: false, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + nextActiveIndex: 0, + nextActiveEmail: "imported@example.com", + nextActiveAccountId: undefined, + activeAccountChanged: true, + error: "OpenCode backup is invalid.", + }; + assessOpencodeAccountPoolMock.mockResolvedValue(invalidAssessment); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - const exitCode = await runCodexMultiAuthCli(["auth", "forecast", "--json"]); expect(exitCode).toBe(0); - expect(errorSpy).not.toHaveBeenCalled(); - expect(queuedRefreshMock).not.toHaveBeenCalled(); + expect(wizardSpy).toHaveBeenCalledTimes(2); + expect(confirmMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith("OpenCode backup is invalid."); + }); - const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { - command: string; - summary: { total: number }; - recommendation: { recommendedIndex: number | null }; + it("keeps the first-run wizard open when OpenCode import would exceed the account limit", async () => { + setInteractiveTTY(true); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const authMenu = await import("../lib/ui/auth-menu.js"); + const wizardSpy = vi + .spyOn(authMenu, "showFirstRunWizard") + .mockResolvedValueOnce({ type: "import-opencode" }) + .mockResolvedValueOnce({ type: "cancel" }); + loadAccountsMock.mockResolvedValue(null); + listNamedBackupsMock.mockResolvedValue([]); + listRotatingBackupsMock.mockResolvedValue([]); + const limitAssessment = { + backup: { + name: "openai-codex-accounts.json", + path: "/mock/.opencode/openai-codex-accounts.json", + createdAt: null, + updatedAt: Date.now(), + sizeBytes: 256, + version: 3, + accountCount: 3, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 2, + mergedAccountCount: 5, + imported: 3, + skipped: 0, + wouldExceedLimit: true, + eligibleForRestore: true, + nextActiveIndex: 0, + nextActiveEmail: "existing@example.com", + nextActiveAccountId: undefined, + activeAccountChanged: false, + error: "", }; - expect(payload.command).toBe("forecast"); - expect(payload.summary.total).toBe(2); - expect(payload.recommendation.recommendedIndex).toBe(0); + assessOpencodeAccountPoolMock + .mockResolvedValueOnce(limitAssessment) + .mockResolvedValueOnce(limitAssessment); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(wizardSpy).toHaveBeenCalledTimes(2); + expect(confirmMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + "Import would exceed the account limit (2 current, 5 after import). Remove accounts first.", + ); }); it("prints implemented 41-feature matrix", async () => { @@ -916,16 +1480,75 @@ describe("codex manager cli commands", () => { ).toBe(true); }); - it("prints auth help when subcommand is --help", async () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + it("offers startup backup browser after first-run login when interactive login starts empty", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState: { + version: 3; + activeIndex: number; + activeIndexByFamily: { codex: number }; + accounts: Array<{ + email: string; + accountId?: string; + refreshToken: string; + accessToken?: string; + expiresAt?: number; + addedAt: number; + lastUsed: number; + enabled: boolean; + }>; + } | null = null; + loadAccountsMock.mockImplementation(async () => + storageState ? structuredClone(storageState) : null, + ); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 1, + }); + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + listRotatingBackupsMock.mockResolvedValue([]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + selectMock + .mockResolvedValueOnce({ type: "login" }) + .mockResolvedValueOnce({ type: "back" }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - const exitCode = await runCodexMultiAuthCli(["auth", "--help"]); expect(exitCode).toBe(0); - expect(errorSpy).not.toHaveBeenCalled(); - expect(logSpy.mock.calls[0]?.[0]).toContain("Codex Multi-Auth CLI"); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalled(); + expect(confirmMock).toHaveBeenCalledTimes(2); + expect(selectMock.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); }); it("prints best help without mutating storage", async () => { @@ -1184,66 +1807,6 @@ describe("codex manager cli commands", () => { ); }); - it("skips OpenCode import when confirmation is declined", async () => { - setInteractiveTTY(true); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "existing@example.com", - refreshToken: "existing-refresh", - addedAt: Date.now(), - lastUsed: Date.now(), - }, - ], - }); - assessOpencodeAccountPoolMock.mockResolvedValue({ - backup: { - name: "openai-codex-accounts.json", - path: "/mock/.opencode/openai-codex-accounts.json", - createdAt: null, - updatedAt: Date.now(), - sizeBytes: 256, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: "", - }, - currentAccountCount: 1, - mergedAccountCount: 2, - imported: 1, - skipped: 0, - wouldExceedLimit: false, - eligibleForRestore: true, - nextActiveIndex: 0, - nextActiveEmail: "existing@example.com", - nextActiveAccountId: undefined, - activeAccountChanged: false, - error: "", - }); - importAccountsMock.mockResolvedValue({ - imported: 1, - skipped: 0, - total: 2, - }); - confirmMock.mockResolvedValueOnce(false); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "import-opencode" }) - .mockResolvedValueOnce({ mode: "cancel" }); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(assessOpencodeAccountPoolMock).toHaveBeenCalledTimes(1); - expect(confirmMock).toHaveBeenCalledOnce(); - expect(importAccountsMock).not.toHaveBeenCalled(); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - }); - it("returns to the dashboard when OpenCode import fails", async () => { setInteractiveTTY(true); const currentStorage = { @@ -1295,6 +1858,7 @@ describe("codex manager cli commands", () => { promptLoginModeMock .mockResolvedValueOnce({ mode: "import-opencode" }) .mockResolvedValueOnce({ mode: "cancel" }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -1313,6 +1877,10 @@ describe("codex manager cli commands", () => { String(message).includes("C:\\Users\\alice\\AppData\\Local\\OpenCode"), ), ).toBe(false); + expect(logSpy.mock.calls.map(flattenMockCallArgs).join("\n")).not.toContain( + "C:\\Users\\alice\\AppData\\Local\\OpenCode", + ); + logSpy.mockRestore(); errorSpy.mockRestore(); }); @@ -1325,7 +1893,10 @@ describe("codex manager cli commands", () => { accounts: [], }); assessOpencodeAccountPoolMock.mockRejectedValueOnce( - makeErrnoError("assessment locked", "EPERM"), + makeErrnoError( + "operation not permitted, open 'C:\\Users\\alice\\AppData\\Local\\OpenCode\\openai-codex-accounts.json'", + "EPERM", + ), ); promptLoginModeMock .mockResolvedValueOnce({ mode: "import-opencode" }) @@ -1341,39 +1912,11 @@ describe("codex manager cli commands", () => { expect(importAccountsMock).not.toHaveBeenCalled(); expect(promptLoginModeMock).toHaveBeenCalledTimes(2); expect(errorSpy).toHaveBeenCalledWith( - "Import assessment failed: EPERM: assessment locked", + "Import assessment failed: EPERM: operation not permitted, open 'openai-codex-accounts.json'", ); errorSpy.mockRestore(); }); - it("returns to the dashboard when no OpenCode account pool is detected", async () => { - setInteractiveTTY(true); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [], - }); - assessOpencodeAccountPoolMock.mockResolvedValueOnce(null); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "import-opencode" }) - .mockResolvedValueOnce({ mode: "cancel" }); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(assessOpencodeAccountPoolMock).toHaveBeenCalledTimes(1); - expect(confirmMock).not.toHaveBeenCalled(); - expect(importAccountsMock).not.toHaveBeenCalled(); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(logSpy).toHaveBeenCalledWith( - "No OpenCode account pool was detected.", - ); - logSpy.mockRestore(); - }); - it.each([ { label: "would exceed the account limit", @@ -3616,7 +4159,9 @@ describe("codex manager cli commands", () => { listNamedBackupsMock.mockResolvedValue([assessment.backup]); assessNamedBackupRestoreMock.mockResolvedValue(assessment); confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(true); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + selectMock + .mockResolvedValueOnce({ type: "login" }) + .mockResolvedValueOnce({ type: "restore", assessment }); restoreNamedBackupMock.mockImplementation(async () => { storageState = { version: 3, @@ -4641,7 +5186,7 @@ describe("codex manager cli commands", () => { ], }); getActionableNamedBackupRestoresMock.mockRejectedValue( - new Error("EBUSY backups"), + new Error("EBUSY: C:\\Users\\alice\\AppData\\Local\\Codex\\named-backups"), ); getLatestCodexCliSyncRollbackPlanMock.mockRejectedValue( new Error("EBUSY rollback at C:\\sensitive\\rollback.json"), @@ -4686,10 +5231,12 @@ describe("codex manager cli commands", () => { accounts: [], }); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + const warningLines = warnSpy.mock.calls.map(flattenMockCallArgs); expect(exitCode).toBe(0); // The single restore scan comes from the startup recovery prompt guard, not the health summary path. expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); @@ -4701,6 +5248,8 @@ describe("codex manager cli commands", () => { healthSummary: undefined, }), ); + expect(warningLines).toHaveLength(0); + warnSpy.mockRestore(); }); it("renders health summary as a disabled dashboard row", async () => { @@ -5369,7 +5918,12 @@ describe("codex manager cli commands", () => { it("offers backup restore from the login menu when no accounts are saved", async () => { setInteractiveTTY(true); const now = Date.now(); - loadAccountsMock.mockResolvedValue(null); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }); const assessment = { backup: { name: "named-backup", @@ -5406,7 +5960,9 @@ describe("codex manager cli commands", () => { expect(listNamedBackupsMock).toHaveBeenCalled(); expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( "named-backup", - expect.objectContaining({ currentStorage: null }), + expect.objectContaining({ + currentStorage: expect.objectContaining({ accounts: [] }), + }), ); expect(restoreNamedBackupMock).toHaveBeenCalledWith( "named-backup", @@ -5587,7 +6143,12 @@ describe("codex manager cli commands", () => { it("keeps healthy backups selectable when one assessment fails", async () => { setInteractiveTTY(true); - loadAccountsMock.mockResolvedValue(null); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }); const now = Date.now(); const healthyAssessment = { backup: { @@ -5673,7 +6234,12 @@ describe("codex manager cli commands", () => { it("limits concurrent backup assessments in the restore menu", async () => { setInteractiveTTY(true); - loadAccountsMock.mockResolvedValue(null); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }); const { NAMED_BACKUP_LIST_CONCURRENCY } = await vi.importActual( "../lib/storage.js", @@ -5956,7 +6522,12 @@ describe("codex manager cli commands", () => { it("shows epoch backup timestamps in restore hints", async () => { setInteractiveTTY(true); - loadAccountsMock.mockResolvedValue(null); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }); const assessment = { backup: { name: "epoch-backup", diff --git a/test/storage.test.ts b/test/storage.test.ts index b6aabb4f..2bac3601 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1,6 +1,6 @@ import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join, relative, resolve as resolvePath } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ACCOUNT_LIMITS } from "../lib/constants.js"; import { @@ -25,7 +25,6 @@ import { findMatchingAccountIndex, formatStorageErrorHint, getActionableNamedBackupRestores, - formatRedactedFilesystemError, getFlaggedAccountsPath, getNamedBackupsDirectoryPath, NAMED_BACKUP_LIST_CONCURRENCY, @@ -5883,52 +5882,6 @@ describe("storage", () => { expect(assessment?.eligibleForRestore).toBe(true); expect(assessment?.imported).toBe(1); }); - - it("resolves relative app data candidates before detection", async () => { - const relativeBase = relative( - process.cwd(), - join(tempRoot, "relative-appdata"), - ); - const resolvedPoolPath = resolvePath( - join(relativeBase, "OpenCode", "openai-codex-accounts.json"), - ); - delete process.env.LOCALAPPDATA; - process.env.APPDATA = relativeBase; - await fs.mkdir(dirname(resolvedPoolPath), { recursive: true }); - await fs.writeFile( - resolvedPoolPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "relative-appdata-account", - refreshToken: "ref-relative", - addedAt: 1, - lastUsed: 1, - }, - ], - }), - ); - - expect(detectOpencodeAccountPoolPath()).toBe(resolvedPoolPath); - const assessment = await assessOpencodeAccountPool(); - expect(assessment?.backup.path).toBe(resolvedPoolPath); - expect(assessment?.eligibleForRestore).toBe(true); - expect(assessment?.imported).toBe(1); - }); - - it("redacts single-segment filesystem paths in error messages", async () => { - const error = new Error( - "EPERM: operation not permitted, open 'C:\\accounts.json'", - ) as NodeJS.ErrnoException; - error.code = "EPERM"; - - expect(formatRedactedFilesystemError(error)).toBe( - "EPERM: operation not permitted, open 'accounts.json'", - ); - }); - it("refuses malformed opencode source before any mutation", async () => { await fs.writeFile(poolPath, "not valid json"); diff --git a/test/sync-history.test.ts b/test/sync-history.test.ts index b03b65ae..e4a5c103 100644 --- a/test/sync-history.test.ts +++ b/test/sync-history.test.ts @@ -1,54 +1,34 @@ -import { promises as nodeFs } from "node:fs"; +import { promises as fs } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CodexCliSyncRun } from "../lib/codex-cli/sync.js"; import { __resetLastCodexCliSyncRunForTests, getLastCodexCliSyncRun, } from "../lib/codex-cli/sync.js"; +import type { LiveAccountSyncSnapshot } from "../lib/live-account-sync.js"; import { __resetSyncHistoryForTests, appendSyncHistoryEntry, configureSyncHistoryForTests, getSyncHistoryPaths, + pruneSyncHistory, + pruneSyncHistoryEntries, + readLatestSyncHistorySync, readSyncHistory, } from "../lib/sync-history.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 nodeFs.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)); - } - } -} +import { removeWithRetry } from "./helpers/remove-with-retry.js"; describe("sync history", () => { let workDir = ""; + let logDir = ""; beforeEach(async () => { - workDir = await nodeFs.mkdtemp(join(tmpdir(), "codex-sync-history-")); - configureSyncHistoryForTests(join(workDir, "logs")); + workDir = await fs.mkdtemp(join(tmpdir(), "codex-sync-history-")); + logDir = join(workDir, "logs"); + configureSyncHistoryForTests(logDir); + await fs.mkdir(logDir, { recursive: true }); await __resetSyncHistoryForTests(); __resetLastCodexCliSyncRunForTests(); }); @@ -61,26 +41,63 @@ describe("sync history", () => { vi.restoreAllMocks(); }); + function createSummary( + overrides: Partial = {}, + ): CodexCliSyncRun["summary"] { + return { + sourceAccountCount: 0, + targetAccountCountBefore: 0, + targetAccountCountAfter: 0, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + ...overrides, + }; + } + + function createCodexRun( + runAt: number, + targetPath: string, + options: { + outcome?: CodexCliSyncRun["outcome"]; + sourcePath?: string | null; + summary?: Partial; + } = {}, + ): CodexCliSyncRun { + return { + outcome: options.outcome ?? "noop", + runAt, + sourcePath: options.sourcePath ?? null, + targetPath, + summary: createSummary(options.summary), + trigger: "manual", + rollbackSnapshot: null, + }; + } + + function createLiveSnapshot( + now: number, + path: string = `/live-${now}`, + ): LiveAccountSyncSnapshot { + return { + path, + running: true, + lastKnownMtimeMs: now, + lastSyncAt: now, + reloadCount: 1, + errorCount: 0, + }; + } + it("reads the last matching history entry without loading the whole file", async () => { await appendSyncHistoryEntry({ kind: "codex-cli-sync", recordedAt: 1, - run: { - outcome: "noop", - runAt: 1, + run: createCodexRun(1, "target.json", { sourcePath: "source-1.json", - targetPath: "target.json", - summary: { - sourceAccountCount: 0, - targetAccountCountBefore: 0, - targetAccountCountAfter: 0, - addedAccountCount: 0, - updatedAccountCount: 0, - unchangedAccountCount: 0, - destinationOnlyPreservedCount: 0, - selectionChanged: false, - }, - }, + }), }); await appendSyncHistoryEntry({ kind: "live-account-sync", @@ -88,40 +105,24 @@ describe("sync history", () => { reason: "watch", outcome: "success", path: "openai-codex-accounts.json", - snapshot: { - path: "openai-codex-accounts.json", - running: true, - lastReason: "watch", - lastError: null, - lastSuccessAt: 2, - lastAttemptAt: 2, - reloadCount: 1, - errorCount: 0, - }, + snapshot: createLiveSnapshot(2, "openai-codex-accounts.json"), }); await appendSyncHistoryEntry({ kind: "codex-cli-sync", recordedAt: 3, - run: { + run: createCodexRun(3, "target.json", { outcome: "changed", - runAt: 3, sourcePath: "source-2.json", - targetPath: "target.json", summary: { sourceAccountCount: 1, - targetAccountCountBefore: 0, targetAccountCountAfter: 1, addedAccountCount: 1, - updatedAccountCount: 0, - unchangedAccountCount: 0, - destinationOnlyPreservedCount: 0, selectionChanged: true, }, - }, + }), }); - const readFileSpy = vi.spyOn(nodeFs, "readFile"); - + const readFileSpy = vi.spyOn(fs, "readFile"); const history = await readSyncHistory({ kind: "codex-cli-sync", limit: 1 }); expect(history).toHaveLength(1); @@ -140,25 +141,19 @@ describe("sync history", () => { await appendSyncHistoryEntry({ kind: "codex-cli-sync", recordedAt: 11, - run: { + run: createCodexRun(11, "target.json", { outcome: "changed", - runAt: 11, sourcePath: "source.json", - targetPath: "target.json", summary: { sourceAccountCount: 1, - targetAccountCountBefore: 0, targetAccountCountAfter: 1, addedAccountCount: 1, - updatedAccountCount: 0, - unchangedAccountCount: 0, - destinationOnlyPreservedCount: 0, selectionChanged: true, }, - }, + }), }); - await nodeFs.rm(getSyncHistoryPaths().latestPath, { force: true }); + await fs.rm(getSyncHistoryPaths().latestPath, { force: true }); __resetLastCodexCliSyncRunForTests(); expect(getLastCodexCliSyncRun()).toBeNull(); @@ -175,105 +170,302 @@ describe("sync history", () => { }); }); - it("waits for writes queued while history reads are draining", async () => { - let releaseFirstAppend!: () => void; - let releaseSecondAppend!: () => void; - let resolveFirstStarted!: () => void; - let resolveSecondStarted!: () => void; - const firstAppendStarted = new Promise((resolve) => { - resolveFirstStarted = resolve; + it("keeps the latest entry for each kind when trimming aggressively", () => { + const entries = [ + { + kind: "codex-cli-sync" as const, + recordedAt: 1, + run: createCodexRun(1, "/first"), + }, + { + kind: "live-account-sync" as const, + recordedAt: 2, + reason: "watch" as const, + outcome: "success" as const, + path: "/live/first", + snapshot: createLiveSnapshot(2, "/live/first"), + }, + { + kind: "codex-cli-sync" as const, + recordedAt: 3, + run: createCodexRun(3, "/second"), + }, + { + kind: "live-account-sync" as const, + recordedAt: 4, + reason: "poll" as const, + outcome: "error" as const, + path: "/live/second", + snapshot: createLiveSnapshot(4, "/live/second"), + }, + ]; + + const result = pruneSyncHistoryEntries(entries, 1); + + expect(result.entries.map((entry) => entry.recordedAt)).toEqual([3, 4]); + expect(result.removed).toBe(2); + expect(result.latest?.recordedAt).toBe(4); + }); + + it("prunes history on disk while keeping latest pointers aligned", async () => { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 1, + run: createCodexRun(1, "/source-1"), }); - const secondAppendStarted = new Promise((resolve) => { - resolveSecondStarted = resolve; + await appendSyncHistoryEntry({ + kind: "live-account-sync", + recordedAt: 2, + reason: "watch", + outcome: "success", + path: "/watch-1", + snapshot: createLiveSnapshot(2, "/watch-1"), }); - const firstAppendGate = new Promise((resolve) => { - releaseFirstAppend = resolve; + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 3, + run: createCodexRun(3, "/source-2"), }); - const secondAppendGate = new Promise((resolve) => { - releaseSecondAppend = resolve; + + const result = await pruneSyncHistory({ maxEntries: 1 }); + const latestOnDisk = readLatestSyncHistorySync(); + const history = await readSyncHistory(); + + expect(result.kept).toBe(2); + expect(history.map((entry) => entry.recordedAt)).toEqual([2, 3]); + expect(history.at(-1)).toEqual(result.latest); + expect(latestOnDisk).toEqual(result.latest); + }); + + it("preserves the latest entry per kind when appends exceed the default cap", async () => { + for (let index = 0; index < 205; index += 1) { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: index + 1, + run: createCodexRun(index + 1, `/codex-${index + 1}`), + }); + } + await appendSyncHistoryEntry({ + kind: "live-account-sync", + recordedAt: 10_000, + reason: "poll", + outcome: "success", + path: "/live-latest", + snapshot: createLiveSnapshot(10_000, "/live-latest"), }); - const originalAppendFile = nodeFs.appendFile; - let appendCallCount = 0; - vi.spyOn(nodeFs, "appendFile").mockImplementation( - async (...args: Parameters) => { - appendCallCount += 1; - if (appendCallCount === 1) { - resolveFirstStarted(); - await firstAppendGate; - } else if (appendCallCount === 2) { - resolveSecondStarted(); - await secondAppendGate; + + const history = await readSyncHistory(); + const latestCodex = history + .filter((entry) => entry.kind === "codex-cli-sync") + .at(-1); + const latestLive = history + .filter((entry) => entry.kind === "live-account-sync") + .at(-1); + + expect(history.length).toBeLessThanOrEqual(201); + expect(latestCodex?.recordedAt).toBe(205); + expect(latestLive?.recordedAt).toBe(10_000); + expect(readLatestSyncHistorySync()?.recordedAt).toBe(10_000); + }); + + it("skips trim reads while append count stays within the default cap", async () => { + const historyPath = getSyncHistoryPaths().historyPath; + const readFileSpy = vi.spyOn(fs, "readFile"); + + for (let index = 0; index < 200; index += 1) { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: index + 1, + run: createCodexRun(index + 1, `/codex-${index + 1}`), + }); + } + + const historyReads = readFileSpy.mock.calls.filter( + ([target]) => target === historyPath, + ); + expect(historyReads).toHaveLength(0); + }); + + it("reloads and trims once when append count crosses the default cap", async () => { + const historyPath = getSyncHistoryPaths().historyPath; + const readFileSpy = vi.spyOn(fs, "readFile"); + + for (let index = 0; index < 201; index += 1) { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: index + 1, + run: createCodexRun(index + 1, `/codex-${index + 1}`), + }); + } + + const historyReads = readFileSpy.mock.calls.filter( + ([target]) => target === historyPath, + ); + expect(historyReads).toHaveLength(1); + expect((await readSyncHistory()).length).toBeLessThanOrEqual(200); + }); + + it("resets the cached append estimate when trim reload finds an externally cleared file", async () => { + const historyPath = getSyncHistoryPaths().historyPath; + const originalReadFile = fs.readFile.bind(fs); + let truncateOnNextTrimRead = false; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (path, ...args) => { + if ( + truncateOnNextTrimRead && + path === historyPath + ) { + truncateOnNextTrimRead = false; + await fs.writeFile(historyPath, "", "utf8"); } - return originalAppendFile(...args); - }, + return originalReadFile(path, ...args); + }); + + for (let index = 0; index < 200; index += 1) { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: index + 1, + run: createCodexRun(index + 1, `/codex-${index + 1}`), + }); + } + + truncateOnNextTrimRead = true; + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 201, + run: createCodexRun(201, "/codex-201"), + }); + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 202, + run: createCodexRun(202, "/codex-202"), + }); + + const historyReads = readFileSpy.mock.calls.filter( + ([target]) => target === historyPath, ); + expect(historyReads).toHaveLength(1); + }); + + it("retries transient append and latest-write lock errors during append", async () => { + const paths = getSyncHistoryPaths(); + const originalAppendFile = fs.appendFile.bind(fs); + const originalWriteFile = fs.writeFile.bind(fs); + let appendFailures = 0; + let latestFailures = 0; + const setTimeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation(((handler: TimerHandler, _timeout?: number, ...args: unknown[]) => { + if (typeof handler !== "function") { + throw new Error("Expected function timer handler in sync-history retry test."); + } + handler(...args); + return 0 as ReturnType; + }) as typeof setTimeout); + const appendFileSpy = vi + .spyOn(fs, "appendFile") + .mockImplementation(async (path, data, options) => { + if (path === paths.historyPath && appendFailures === 0) { + appendFailures += 1; + const error = new Error("history locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return originalAppendFile(path, data, options); + }); + const writeFileSpy = vi + .spyOn(fs, "writeFile") + .mockImplementation(async (path, data, options) => { + if (path === paths.latestPath && latestFailures === 0) { + latestFailures += 1; + const error = new Error("latest locked") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalWriteFile(path, data, options); + }); - const firstWrite = appendSyncHistoryEntry({ + await appendSyncHistoryEntry({ kind: "codex-cli-sync", recordedAt: 1, - run: { - outcome: "changed", - runAt: 1, - sourcePath: "source-1.json", - targetPath: "target-1.json", - summary: { - sourceAccountCount: 1, - targetAccountCountBefore: 0, - targetAccountCountAfter: 1, - addedAccountCount: 1, - updatedAccountCount: 0, - unchangedAccountCount: 0, - destinationOnlyPreservedCount: 0, - selectionChanged: true, - }, - }, + run: createCodexRun(1, "/retry-append"), }); - await firstAppendStarted; - let readResolved = false; - const readPromise = readSyncHistory({ kind: "codex-cli-sync" }).then((history) => { - readResolved = true; - return history; + expect(setTimeoutSpy).toHaveBeenCalledTimes(2); + expect(appendFileSpy.mock.calls.filter(([path]) => path === paths.historyPath)).toHaveLength(2); + expect(writeFileSpy.mock.calls.filter(([path]) => path === paths.latestPath)).toHaveLength(2); + expect((await readSyncHistory()).map((entry) => entry.recordedAt)).toEqual([1]); + expect(readLatestSyncHistorySync()?.recordedAt).toBe(1); + }); + + it("retries transient trim rewrite lock errors when the history cap is exceeded", async () => { + const paths = getSyncHistoryPaths(); + const originalWriteFile = fs.writeFile.bind(fs); + let trimWriteFailures = 0; + const setTimeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation(((handler: TimerHandler, _timeout?: number, ...args: unknown[]) => { + if (typeof handler !== "function") { + throw new Error("Expected function timer handler in sync-history retry test."); + } + handler(...args); + return 0 as ReturnType; + }) as typeof setTimeout); + + for (let index = 0; index < 200; index += 1) { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: index + 1, + run: createCodexRun(index + 1, `/seed-${index + 1}`), + }); + } + + const writeFileSpy = vi + .spyOn(fs, "writeFile") + .mockImplementation(async (path, data, options) => { + if (path === paths.historyPath && trimWriteFailures === 0) { + trimWriteFailures += 1; + const error = new Error("trim locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return originalWriteFile(path, data, options); + }); + + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 201, + run: createCodexRun(201, "/seed-201"), }); - const secondWrite = appendSyncHistoryEntry({ + expect(setTimeoutSpy).toHaveBeenCalledTimes(1); + expect(writeFileSpy.mock.calls.filter(([path]) => path === paths.historyPath)).toHaveLength(2); + const history = await readSyncHistory(); + expect(history.length).toBeLessThanOrEqual(200); + expect(history.at(-1)?.recordedAt).toBe(201); + expect(readLatestSyncHistorySync()?.recordedAt).toBe(201); + }); + + it("re-reads seeded history after configureSyncHistoryForTests resets the estimate to null", async () => { + await appendSyncHistoryEntry({ kind: "codex-cli-sync", - recordedAt: 2, - run: { - outcome: "noop", - runAt: 2, - sourcePath: "source-2.json", - targetPath: "target-2.json", - summary: { - sourceAccountCount: 1, - targetAccountCountBefore: 1, - targetAccountCountAfter: 1, - addedAccountCount: 0, - updatedAccountCount: 0, - unchangedAccountCount: 1, - destinationOnlyPreservedCount: 0, - selectionChanged: false, - }, - }, + recordedAt: 1, + run: createCodexRun(1, "/seeded"), }); - releaseFirstAppend(); - await secondAppendStarted; - await Promise.resolve(); - await Promise.resolve(); - expect(readResolved).toBe(false); - - releaseSecondAppend(); - const history = await readPromise; - await firstWrite; - await secondWrite; - - expect(history).toHaveLength(2); - expect(history.at(-1)).toMatchObject({ + configureSyncHistoryForTests(logDir); + const historyPath = getSyncHistoryPaths().historyPath; + const readFileSpy = vi.spyOn(fs, "readFile"); + + await appendSyncHistoryEntry({ kind: "codex-cli-sync", recordedAt: 2, - run: expect.objectContaining({ - sourcePath: "source-2.json", - }), + run: createCodexRun(2, "/after-reset"), }); + + const historyReads = readFileSpy.mock.calls.filter( + ([target]) => target === historyPath, + ); + expect(historyReads).toHaveLength(1); }); }); From 6fcc80955227c0588963042754c2a0910599b07f Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 23:05:30 +0800 Subject: [PATCH 3/5] fix(auth): harden first-run wizard follow-ups --- lib/codex-manager.ts | 15 +++++++--- lib/storage.ts | 2 +- lib/sync-history.ts | 5 ++-- test/storage.test.ts | 61 +++++++++++++++++++++++++++++++++++++++ test/sync-history.test.ts | 43 +++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 7 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 77ec2ee4..e1cf082b 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -103,6 +103,7 @@ import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { ANSI } from "./ui/ansi.js"; import { type FirstRunWizardOptions, + type FirstRunWizardAction, showFirstRunWizard, } from "./ui/auth-menu.js"; import { confirm } from "./ui/confirm.js"; @@ -4351,7 +4352,11 @@ async function buildFirstRunWizardOptions(): Promise { type FirstRunWizardResult = | { outcome: "cancelled" } - | { outcome: "continue"; latestStorage: AccountStorageV3 | null }; + | { + outcome: "continue"; + latestStorage: AccountStorageV3 | null; + firstAction: FirstRunWizardAction["type"]; + }; async function runFirstRunWizard( displaySettings: DashboardDisplaySettings, @@ -4364,7 +4369,7 @@ async function runFirstRunWizard( return { outcome: "cancelled" }; case "login": case "skip": - return { outcome: "continue", latestStorage: null }; + return { outcome: "continue", latestStorage: null, firstAction: action.type }; case "restore": await runBackupBrowserManager(displaySettings); break; @@ -4448,7 +4453,7 @@ async function runFirstRunWizard( ); } if (latestStorage && latestStorage.accounts.length > 0) { - return { outcome: "continue", latestStorage }; + return { outcome: "continue", latestStorage, firstAction: action.type }; } } } @@ -4474,7 +4479,9 @@ async function runAuthLogin(): Promise { const displaySettings = await loadDashboardDisplaySettings(); applyUiThemeFromDashboardSettings(displaySettings); const wizardResult = await runFirstRunWizard(displaySettings); - firstRunWizardShownInLoop = true; + firstRunWizardShownInLoop = + wizardResult.outcome === "continue" && + (wizardResult.firstAction === "login" || wizardResult.firstAction === "skip"); if (wizardResult.outcome === "cancelled") { return 0; } diff --git a/lib/storage.ts b/lib/storage.ts index 2fcedfaf..097404db 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2453,7 +2453,7 @@ function extractPathTail(pathValue: string): string { function redactFilesystemDetails(value: string): string { return value.replace( - /(?:[A-Za-z]:)?[\\/][^"'`\r\n]+(?:[\\/][^"'`\r\n]+)+/g, + /(?:[A-Za-z]:)?[\\/][^"'`\r\n]+(?:[\\/][^"'`\r\n]+)*/g, (pathValue) => extractPathTail(pathValue), ); } diff --git a/lib/sync-history.ts b/lib/sync-history.ts index 0b06ed11..4420bf5d 100644 --- a/lib/sync-history.ts +++ b/lib/sync-history.ts @@ -86,8 +86,9 @@ function withHistoryLock(fn: () => Promise): Promise { } async function waitForPendingHistoryWrites(): Promise { - if (pendingHistoryWrites.size === 0) return; - await Promise.allSettled(Array.from(pendingHistoryWrites)); + while (pendingHistoryWrites.size > 0) { + await Promise.allSettled(Array.from(pendingHistoryWrites)); + } } async function ensureHistoryDir(directory: string): Promise { diff --git a/test/storage.test.ts b/test/storage.test.ts index 2bac3601..a7fe286b 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2509,6 +2509,67 @@ describe("storage", () => { } }); + it("redacts shallow filesystem paths from snapshot warning logs", async () => { + const warnMock = vi.fn(); + vi.resetModules(); + vi.doMock("../lib/logger.js", () => ({ + createLogger: vi.fn(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: warnMock, + error: vi.fn(), + })), + logWarn: vi.fn(), + })); + + try { + const isolatedStorageModule = await import("../lib/storage.js"); + isolatedStorageModule.setStoragePathDirect(testStoragePath); + await isolatedStorageModule.saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const leakingPath = `C:\\token.json`; + const failingBackup = vi.fn().mockRejectedValue( + Object.assign( + new Error( + `EPERM: operation not permitted, open '${leakingPath}'`, + ), + { code: "EPERM" }, + ), + ); + + await expect( + isolatedStorageModule.snapshotAccountStorage({ + reason: "reset-local-state", + createBackup: failingBackup, + }), + ).resolves.toBeNull(); + + const payload = warnMock.mock.calls[0]?.[1] as + | { error?: string; backupName?: string } + | undefined; + expect(payload?.backupName).toContain( + "accounts-reset-local-state-snapshot-", + ); + expect(payload?.error).toContain("token.json"); + expect(payload?.error).not.toContain("C:\\"); + } finally { + vi.doUnmock("../lib/logger.js"); + vi.resetModules(); + setStoragePathDirect(testStoragePath); + } + }); + it("propagates snapshot failure when policy is error", async () => { await saveAccounts({ version: 3, diff --git a/test/sync-history.test.ts b/test/sync-history.test.ts index e4a5c103..9a37b899 100644 --- a/test/sync-history.test.ts +++ b/test/sync-history.test.ts @@ -348,6 +348,49 @@ describe("sync history", () => { expect(historyReads).toHaveLength(1); }); + it("waits for writes queued while history reads are draining", async () => { + const paths = getSyncHistoryPaths(); + const originalAppendFile = fs.appendFile.bind(fs); + let appendCalls = 0; + let releaseFirstAppend: (() => void) | null = null; + let secondAppendPromise: Promise | null = null; + const firstAppendGate = new Promise((resolve) => { + releaseFirstAppend = resolve; + }); + const appendFileSpy = vi.spyOn(fs, "appendFile").mockImplementation( + async (path, data, options) => { + if (path === paths.historyPath && appendCalls === 0) { + appendCalls += 1; + queueMicrotask(() => { + secondAppendPromise = appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 2, + run: createCodexRun(2, "/sync-history-second"), + }); + }); + await firstAppendGate; + } + return originalAppendFile(path, data, options); + }, + ); + + const firstAppendPromise = appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 1, + run: createCodexRun(1, "/sync-history-first"), + }); + const readPromise = readSyncHistory(); + await Promise.resolve(); + releaseFirstAppend?.(); + + await firstAppendPromise; + await secondAppendPromise; + const history = await readPromise; + + expect(history.map((entry) => entry.recordedAt)).toEqual([1, 2]); + expect(appendFileSpy).toHaveBeenCalled(); + }); + it("retries transient append and latest-write lock errors during append", async () => { const paths = getSyncHistoryPaths(); const originalAppendFile = fs.appendFile.bind(fs); From d22d9e1dff2b7d59ed233c4298e93965dbb90e4a Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 23:22:55 +0800 Subject: [PATCH 4/5] fix: harden first-run storage and sync history edges --- lib/storage.ts | 9 ++++++-- lib/sync-history.ts | 7 ++++++- test/storage.test.ts | 39 +++++++++++++++++++++++++++++++++- test/sync-history.test.ts | 44 +++++++++++++++++++++++++++++++++++---- 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 097404db..e6852799 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2090,8 +2090,13 @@ export function detectOpencodeAccountPoolPath(): string | null { (base): base is string => !!base && base.trim().length > 0, ); for (const base of appDataBases) { - candidates.add(join(base, "OpenCode", ACCOUNTS_FILE_NAME)); - candidates.add(join(base, "opencode", ACCOUNTS_FILE_NAME)); + for (const candidateBase of ["OpenCode", "opencode"]) { + try { + candidates.add(resolvePath(join(base, candidateBase, ACCOUNTS_FILE_NAME))); + } catch { + // Ignore env paths outside the supported roots and continue probing others. + } + } } const home = homedir(); diff --git a/lib/sync-history.ts b/lib/sync-history.ts index 4420bf5d..c4ac8d48 100644 --- a/lib/sync-history.ts +++ b/lib/sync-history.ts @@ -10,6 +10,7 @@ const log = createLogger("sync-history"); const HISTORY_FILE_NAME = "sync-history.ndjson"; const LATEST_FILE_NAME = "sync-history-latest.json"; const MAX_HISTORY_ENTRIES = 200; +const HISTORY_TRIM_RELOAD_BUFFER = 20; const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); const RETRYABLE_WRITE_CODES = new Set(["EBUSY", "EPERM"]); @@ -318,7 +319,11 @@ export async function appendSyncHistoryEntry( const paths = getSyncHistoryPaths(); lastAppendPaths = paths; await ensureHistoryDir(paths.directory); - if (historyEntryCountEstimate === null) { + if ( + historyEntryCountEstimate === null || + historyEntryCountEstimate >= + MAX_HISTORY_ENTRIES - HISTORY_TRIM_RELOAD_BUFFER + ) { historyEntryCountEstimate = (await loadHistoryEntriesFromDisk(paths)).length; } await withRetryableHistoryWrite(() => diff --git a/test/storage.test.ts b/test/storage.test.ts index a7fe286b..a35ae7ec 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1,6 +1,6 @@ import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ACCOUNT_LIMITS } from "../lib/constants.js"; import { @@ -5883,6 +5883,43 @@ describe("storage", () => { expect(assessment?.imported).toBe(1); }); + it("normalizes relative app data candidates before probing", async () => { + process.env.LOCALAPPDATA = "relative-appdata"; + delete process.env.APPDATA; + const existsSyncMock = vi.fn().mockReturnValue(false); + vi.resetModules(); + vi.doMock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + existsSync: existsSyncMock, + }; + }); + + try { + const isolatedStorageModule = await import("../lib/storage.js"); + expect(isolatedStorageModule.detectOpencodeAccountPoolPath()).toBeNull(); + + const candidate = existsSyncMock.mock.calls.find(([path]) => + String(path).endsWith( + join("OpenCode", "openai-codex-accounts.json"), + ), + )?.[0]; + expect(typeof candidate).toBe("string"); + expect(candidate).toBe( + resolve( + "relative-appdata", + "OpenCode", + "openai-codex-accounts.json", + ), + ); + } finally { + vi.doUnmock("node:fs"); + vi.resetModules(); + setStoragePathDirect(join(tempRoot, "current-storage.json")); + } + }); + it("does not fall back to auto-detection when an explicit CODEX_OPENCODE_POOL_PATH override is missing", async () => { await fs.writeFile( poolPath, diff --git a/test/sync-history.test.ts b/test/sync-history.test.ts index 9a37b899..08de9323 100644 --- a/test/sync-history.test.ts +++ b/test/sync-history.test.ts @@ -268,11 +268,11 @@ describe("sync history", () => { expect(readLatestSyncHistorySync()?.recordedAt).toBe(10_000); }); - it("skips trim reads while append count stays within the default cap", async () => { + it("skips trim reads until the reload guard band is reached", async () => { const historyPath = getSyncHistoryPaths().historyPath; const readFileSpy = vi.spyOn(fs, "readFile"); - for (let index = 0; index < 200; index += 1) { + for (let index = 0; index < 179; index += 1) { await appendSyncHistoryEntry({ kind: "codex-cli-sync", recordedAt: index + 1, @@ -301,7 +301,39 @@ describe("sync history", () => { const historyReads = readFileSpy.mock.calls.filter( ([target]) => target === historyPath, ); - expect(historyReads).toHaveLength(1); + expect(historyReads.length).toBeGreaterThan(0); + expect((await readSyncHistory()).length).toBeLessThanOrEqual(200); + }); + + it("reloads near the trim threshold before appending when external writes grew the file", async () => { + const historyPath = getSyncHistoryPaths().historyPath; + + for (let index = 0; index < 181; index += 1) { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: index + 1, + run: createCodexRun(index + 1, `/codex-${index + 1}`), + }); + } + + const existingLines = (await fs.readFile(historyPath, "utf8")) + .split("\n") + .filter((line) => line.length > 0); + const replayLine = existingLines.at(-1); + expect(replayLine).toBeTruthy(); + + await fs.appendFile( + historyPath, + Array.from({ length: 25 }, () => replayLine).join("\n") + "\n", + "utf8", + ); + + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 999, + run: createCodexRun(999, "/codex-999"), + }); + expect((await readSyncHistory()).length).toBeLessThanOrEqual(200); }); @@ -345,7 +377,11 @@ describe("sync history", () => { const historyReads = readFileSpy.mock.calls.filter( ([target]) => target === historyPath, ); - expect(historyReads).toHaveLength(1); + expect(historyReads.length).toBeGreaterThan(0); + expect((await readSyncHistory()).map((entry) => entry.recordedAt)).toEqual([ + 201, + 202, + ]); }); it("waits for writes queued while history reads are draining", async () => { From 7f8296b9817bb1c6d554b740943effcfd8003c08 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 00:04:19 +0800 Subject: [PATCH 5/5] fix(sync-history): retry transient windows write access errors --- lib/sync-history.ts | 2 +- test/sync-history.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/sync-history.ts b/lib/sync-history.ts index c4ac8d48..4e8ca2cf 100644 --- a/lib/sync-history.ts +++ b/lib/sync-history.ts @@ -12,7 +12,7 @@ const LATEST_FILE_NAME = "sync-history-latest.json"; const MAX_HISTORY_ENTRIES = 200; const HISTORY_TRIM_RELOAD_BUFFER = 20; const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); -const RETRYABLE_WRITE_CODES = new Set(["EBUSY", "EPERM"]); +const RETRYABLE_WRITE_CODES = new Set(["EBUSY", "EPERM", "EACCES"]); type SyncHistoryKind = "codex-cli-sync" | "live-account-sync"; diff --git a/test/sync-history.test.ts b/test/sync-history.test.ts index 08de9323..f885919a 100644 --- a/test/sync-history.test.ts +++ b/test/sync-history.test.ts @@ -448,7 +448,7 @@ describe("sync history", () => { if (path === paths.historyPath && appendFailures === 0) { appendFailures += 1; const error = new Error("history locked") as NodeJS.ErrnoException; - error.code = "EPERM"; + error.code = "EACCES"; throw error; } return originalAppendFile(path, data, options);