diff --git a/docs/getting-started.md b/docs/getting-started.md index 8caf3fb4..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: @@ -82,6 +84,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..e1cf082b 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, @@ -97,6 +101,11 @@ import { } from "./codex-cli/sync.js"; 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"; import { UI_COPY } from "./ui/copy.js"; import { paintUiText, quotaToneFromLeftPercent } from "./ui/format.js"; @@ -1320,7 +1329,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) { @@ -4216,6 +4225,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>, @@ -4293,6 +4310,154 @@ 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; + firstAction: FirstRunWizardAction["type"]; + }; + +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, firstAction: action.type }; + 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, firstAction: action.type }; + } + } +} + async function runAuthLogin(): Promise { setStoragePath(null); let suppressRecoveryPrompt = false; @@ -4303,9 +4468,49 @@ 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 = + wizardResult.outcome === "continue" && + (wizardResult.firstAction === "login" || wizardResult.firstAction === "skip"); + 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 ( @@ -4364,7 +4569,7 @@ async function runAuthLogin(): Promise { }, ); - if (menuResult.mode === "cancel") { + if (!menuResult || menuResult.mode === "cancel") { console.log("Cancelled."); return 0; } @@ -4429,6 +4634,61 @@ 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 + ? formatRedactedFilesystemError(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 = collapseWhitespace( + 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."); @@ -4541,7 +4801,8 @@ async function runAuthLogin(): Promise { recoveryState = scannedRecoveryState; if ( resolveStartupRecoveryAction(scannedRecoveryState, recoveryScanFailed) === - "open-empty-storage-menu" + "open-empty-storage-menu" && + !skipEmptyStorageRecoveryMenu ) { allowEmptyStorageMenu = true; continue loginFlow; @@ -4577,6 +4838,10 @@ async function runAuthLogin(): Promise { } continue; } + if (startedFromMissingStorage) { + allowEmptyStorageMenu = true; + continue loginFlow; + } } catch (error) { if (!promptWasShown) { recoveryPromptAttempted = false; @@ -4588,6 +4853,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); @@ -5266,7 +5560,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 481cbc64..e6852799 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,6 +1,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 } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; @@ -49,6 +50,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 +2002,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 +2078,41 @@ 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) { + 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(); + if (home) { + candidates.add(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 +2310,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 +2458,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 +2491,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 +2519,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 +2537,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 +2574,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 +3048,7 @@ async function loadBackupCandidate(path: string): Promise storedVersion: undefined, schemaErrors: [], rawAccounts: [], - error: String(error), + error: formatRedactedFilesystemError(error), errorCode, }; } @@ -2811,6 +3060,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 +4115,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/sync-history.ts b/lib/sync-history.ts index cc749171..4e8ca2cf 100644 --- a/lib/sync-history.ts +++ b/lib/sync-history.ts @@ -10,7 +10,9 @@ 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", "EACCES"]); type SyncHistoryKind = "codex-cli-sync" | "live-account-sync"; @@ -44,6 +46,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 +71,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 = () => {}; @@ -87,6 +96,27 @@ 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 +143,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 +223,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; } - throw error; - }); - if (!content) { - return; + latestByKind.set(entry.kind, entry); } - const lines = content.split(/\r?\n/).filter(Boolean); - if (lines.length <= MAX_HISTORY_ENTRIES) { + + 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); + } + } + + 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 +319,32 @@ 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 >= + MAX_HISTORY_ENTRIES - HISTORY_TRIM_RELOAD_BUFFER + ) { + 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 +367,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 +406,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 +452,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 +482,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 b4fcebad..5568d01b 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -158,8 +158,29 @@ 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.", + }, + 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/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..b13f2933 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(); @@ -62,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(), @@ -152,7 +162,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 +658,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 +692,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, @@ -885,7 +905,7 @@ describe("codex manager cli commands", () => { expect(payload.recommendation.recommendedIndex).toBe(0); }); - it("prints implemented 41-feature matrix", async () => { + 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(() => {}); @@ -898,7 +918,7 @@ describe("codex manager cli commands", () => { expect( logSpy.mock.calls.some((call) => String(call[0]).includes( - "41. Auto-switch to best account command", + "40. OAuth browser-first flow with manual callback fallback", ), ), ).toBe(true); @@ -916,128 +936,224 @@ describe("codex manager cli commands", () => { expect(logSpy.mock.calls[0]?.[0]).toContain("Codex Multi-Auth CLI"); }); - it("prints best help without mutating storage", async () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + 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 exitCode = await runCodexMultiAuthCli(["auth", "best", "--help"]); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(errorSpy).not.toHaveBeenCalled(); - expect(loadAccountsMock).not.toHaveBeenCalled(); - expect(saveAccountsMock).not.toHaveBeenCalled(); - expect(queuedRefreshMock).not.toHaveBeenCalled(); - expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); - expect(logSpy.mock.calls[0]?.[0]).toContain("codex auth best"); + expect(wizardSpy).toHaveBeenCalledTimes(1); + expect(wizardSpy).toHaveBeenCalledWith( + expect.objectContaining({ + storagePath: "/mock/openai-codex-accounts.json", + }), + ); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); }); - it("rejects malformed best args before switching accounts", async () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + 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 exitCode = await runCodexMultiAuthCli(["auth", "best", "--model"]); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - expect(exitCode).toBe(1); - expect(errorSpy).toHaveBeenCalledWith("Missing value for --model"); - expect(loadAccountsMock).not.toHaveBeenCalled(); - expect(saveAccountsMock).not.toHaveBeenCalled(); - expect(queuedRefreshMock).not.toHaveBeenCalled(); - expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); - expect(logSpy.mock.calls[0]?.[0]).toContain("codex auth best"); + 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("rejects unknown best args before switching accounts", async () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + 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 exitCode = await runCodexMultiAuthCli(["auth", "best", "--bogus"]); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - expect(exitCode).toBe(1); - expect(errorSpy).toHaveBeenCalledWith("Unknown option: --bogus"); - expect(loadAccountsMock).not.toHaveBeenCalled(); - expect(saveAccountsMock).not.toHaveBeenCalled(); - expect(queuedRefreshMock).not.toHaveBeenCalled(); - expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); - expect(logSpy.mock.calls[0]?.[0]).toContain("codex auth best"); + expect(exitCode).toBe(0); + expect(wizardSpy).toHaveBeenCalledTimes(1); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); }); - it("rejects --model without --live before loading accounts", async () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + 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"]); - const exitCode = await runCodexMultiAuthCli(["auth", "best", "--model", "gpt-5.1"]); - - expect(exitCode).toBe(1); - expect(errorSpy).toHaveBeenCalledWith("--model requires --live for codex auth best"); - expect(loadAccountsMock).not.toHaveBeenCalled(); - expect(saveAccountsMock).not.toHaveBeenCalled(); - expect(fetchCodexQuotaSnapshotMock).not.toHaveBeenCalled(); - expect(queuedRefreshMock).not.toHaveBeenCalled(); - expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); - expect(logSpy.mock.calls[0]?.[0]).toContain("codex auth best"); + expect(exitCode).toBe(0); + expect(configureUnifiedSettingsSpy).toHaveBeenCalledTimes(1); + expect(vi.mocked(authMenu.showFirstRunWizard)).toHaveBeenCalledTimes(2); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); }); - it("prints json error when no accounts are configured for best", async () => { - loadAccountsMock.mockResolvedValueOnce({ + 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 logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - const exitCode = await runCodexMultiAuthCli(["auth", "best", "--json"]); + expect(exitCode).toBe(0); + expect(vi.mocked(authMenu.showFirstRunWizard)).toHaveBeenCalledTimes(2); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + }); - expect(exitCode).toBe(1); - expect(errorSpy).not.toHaveBeenCalled(); - expect(saveAccountsMock).not.toHaveBeenCalled(); - expect(queuedRefreshMock).not.toHaveBeenCalled(); - expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); - expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual({ - error: "No accounts configured.", + 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(() => {}); - it("prints json error when storage is null for best", async () => { - loadAccountsMock.mockResolvedValueOnce(null); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - const exitCode = await runCodexMultiAuthCli(["auth", "best", "--json"]); - - expect(exitCode).toBe(1); - expect(errorSpy).not.toHaveBeenCalled(); - expect(saveAccountsMock).not.toHaveBeenCalled(); - expect(queuedRefreshMock).not.toHaveBeenCalled(); - expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); - expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual({ - error: "No accounts configured.", - }); + 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("restores a named backup from direct auth restore-backup command", async () => { + it("imports OpenCode accounts from the first-run wizard", async () => { setInteractiveTTY(true); - const now = Date.now(); - loadAccountsMock.mockResolvedValue(null); - const assessment = { + 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: "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: 0, mergedAccountCount: 1, @@ -1045,105 +1161,506 @@ describe("codex manager cli commands", () => { skipped: 0, wouldExceedLimit: false, eligibleForRestore: true, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - + 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", "restore-backup"]); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); - expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( - "named-backup", - expect.objectContaining({ currentStorage: null }), + expect(importAccountsMock).toHaveBeenCalledWith( + "/mock/.opencode/openai-codex-accounts.json", ); - expect(restoreNamedBackupMock).toHaveBeenCalledWith( - "named-backup", - expect.objectContaining({ assessment }), + 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("returns a non-zero exit code when the direct restore-backup command fails", async () => { + it("keeps the first-run wizard open when the post-action storage reload fails", 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, - }, - ], - }); - const assessment = { + 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: "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, + currentAccountCount: 0, + mergedAccountCount: 1, imported: 1, skipped: 0, wouldExceedLimit: false, eligibleForRestore: true, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - restoreNamedBackupMock.mockRejectedValueOnce(new Error("backup locked")); - + 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"]); + + expect(exitCode).toBe(0); + expect(wizardSpy).toHaveBeenCalledTimes(2); + expect(confirmMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith("OpenCode backup is invalid."); + }); + + 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: "", + }; + 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 () => { + 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( + "41. Auto-switch to best account command", + ), + ), + ).toBe(true); + }); + + 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"]); + + expect(exitCode).toBe(0); + 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 () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "best", "--help"]); + + expect(exitCode).toBe(0); + expect(errorSpy).not.toHaveBeenCalled(); + expect(loadAccountsMock).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(queuedRefreshMock).not.toHaveBeenCalled(); + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + expect(logSpy.mock.calls[0]?.[0]).toContain("codex auth best"); + }); + + it("rejects malformed best args before switching accounts", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "best", "--model"]); + + expect(exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith("Missing value for --model"); + expect(loadAccountsMock).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(queuedRefreshMock).not.toHaveBeenCalled(); + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + expect(logSpy.mock.calls[0]?.[0]).toContain("codex auth best"); + }); + + it("rejects unknown best args before switching accounts", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "best", "--bogus"]); + + expect(exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith("Unknown option: --bogus"); + expect(loadAccountsMock).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(queuedRefreshMock).not.toHaveBeenCalled(); + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + expect(logSpy.mock.calls[0]?.[0]).toContain("codex auth best"); + }); + + it("rejects --model without --live before loading accounts", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + + const exitCode = await runCodexMultiAuthCli(["auth", "best", "--model", "gpt-5.1"]); expect(exitCode).toBe(1); - expect(promptLoginModeMock).not.toHaveBeenCalled(); - expect(confirmMock).toHaveBeenCalledOnce(); - expect(restoreNamedBackupMock).toHaveBeenCalledWith( - "named-backup", - expect.objectContaining({ assessment }), - ); + expect(errorSpy).toHaveBeenCalledWith("--model requires --live for codex auth best"); + expect(loadAccountsMock).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(fetchCodexQuotaSnapshotMock).not.toHaveBeenCalled(); + expect(queuedRefreshMock).not.toHaveBeenCalled(); + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + expect(logSpy.mock.calls[0]?.[0]).toContain("codex auth best"); }); - it("runs restore preview before applying a replace-only named backup", async () => { - setInteractiveTTY(true); - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ + it("prints json error when no accounts are configured for best", async () => { + loadAccountsMock.mockResolvedValueOnce({ 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, - }, - ], + accounts: [], + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "best", "--json"]); + + expect(exitCode).toBe(1); + expect(errorSpy).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(queuedRefreshMock).not.toHaveBeenCalled(); + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual({ + error: "No accounts configured.", + }); + }); + + it("prints json error when storage is null for best", async () => { + loadAccountsMock.mockResolvedValueOnce(null); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "best", "--json"]); + + expect(exitCode).toBe(1); + expect(errorSpy).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(queuedRefreshMock).not.toHaveBeenCalled(); + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual({ + error: "No accounts configured.", }); + }); + + it("restores a named backup from direct auth restore-backup command", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue(null); const assessment = { backup: { name: "named-backup", @@ -1157,147 +1674,79 @@ describe("codex manager cli commands", () => { valid: true, loadError: undefined, }, - backupAccountCount: 1, - dedupedBackupAccountCount: 1, - conflictsWithinBackup: 0, - conflictsWithExisting: 0, - replacedExistingCount: 1, - keptExistingCount: 0, - keptBackupCount: 1, - currentAccountCount: 1, + currentAccountCount: 0, mergedAccountCount: 1, - imported: 0, + 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", - 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, + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + + expect(exitCode).toBe(0); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ currentStorage: null }), + ); + expect(restoreNamedBackupMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ assessment }), + ); + }); + + 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: 1, + total: 2, }); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + confirmMock.mockResolvedValueOnce(true); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "import-opencode" }) + .mockResolvedValueOnce({ mode: "cancel" }); - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + 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(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 () => { @@ -1352,43 +1801,40 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(1); expect(promptLoginModeMock).not.toHaveBeenCalled(); expect(confirmMock).toHaveBeenCalledOnce(); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup", { - assessment, - }); + expect(restoreNamedBackupMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ assessment }), + ); }); - 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, @@ -1396,24 +1842,226 @@ 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 logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + 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(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); + expect(logSpy.mock.calls.map(flattenMockCallArgs).join("\n")).not.toContain( + "C:\\Users\\alice\\AppData\\Local\\OpenCode", + ); + logSpy.mockRestore(); + 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( + "operation not permitted, open 'C:\\Users\\alice\\AppData\\Local\\OpenCode\\openai-codex-accounts.json'", + "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", "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: operation not permitted, open 'openai-codex-accounts.json'", + ); + errorSpy.mockRestore(); }); + 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); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }); + 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: 0, + schemaErrors: ["invalid"], + valid: false, + loadError: "ENOENT: openai-codex-accounts.json", + }, + currentAccountCount: 0, + mergedAccountCount: null, + imported: null, + skipped: null, + wouldExceedLimit: false, + 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", "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 +2184,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 () => { @@ -3497,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, @@ -4522,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"), @@ -4567,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); @@ -4582,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 () => { @@ -5250,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", @@ -5287,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", @@ -5468,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: { @@ -5554,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", @@ -5837,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/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..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 { @@ -13,11 +13,13 @@ import { removeWithRetry } from "./helpers/remove-with-retry.js"; import { assessNamedBackupRestore, buildNamedBackupPath, + assessOpencodeAccountPool, clearAccounts, clearFlaggedAccounts, createNamedBackup, deduplicateAccounts, deduplicateAccountsByEmail, + detectOpencodeAccountPoolPath, exportAccounts, exportNamedBackup, findMatchingAccountIndex, @@ -1149,6 +1151,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( @@ -2486,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, @@ -5690,6 +5774,260 @@ 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("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, + 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("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(); diff --git a/test/sync-history.test.ts b/test/sync-history.test.ts index b03b65ae..f885919a 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,381 @@ 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"), + }); + await appendSyncHistoryEntry({ + kind: "live-account-sync", + recordedAt: 2, + reason: "watch", + outcome: "success", + path: "/watch-1", + snapshot: createLiveSnapshot(2, "/watch-1"), + }); + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 3, + run: createCodexRun(3, "/source-2"), + }); + + 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 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 until the reload guard band is reached", async () => { + const historyPath = getSyncHistoryPaths().historyPath; + const readFileSpy = vi.spyOn(fs, "readFile"); + + for (let index = 0; index < 179; 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.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); + }); + + 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 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"), }); - const secondAppendStarted = new Promise((resolve) => { - resolveSecondStarted = resolve; + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 202, + run: createCodexRun(202, "/codex-202"), }); + + const historyReads = readFileSpy.mock.calls.filter( + ([target]) => target === historyPath, + ); + 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 () => { + 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 secondAppendGate = new Promise((resolve) => { - releaseSecondAppend = resolve; - }); - const originalAppendFile = nodeFs.appendFile; - let appendCallCount = 0; - vi.spyOn(nodeFs, "appendFile").mockImplementation( - async (...args: Parameters) => { - appendCallCount += 1; - if (appendCallCount === 1) { - resolveFirstStarted(); + 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; - } else if (appendCallCount === 2) { - resolveSecondStarted(); - await secondAppendGate; } - return originalAppendFile(...args); + return originalAppendFile(path, data, options); }, ); - const firstWrite = appendSyncHistoryEntry({ + const firstAppendPromise = 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, "/sync-history-first"), }); - await firstAppendStarted; + const readPromise = readSyncHistory(); + await Promise.resolve(); + releaseFirstAppend?.(); + + await firstAppendPromise; + await secondAppendPromise; + const history = await readPromise; - let readResolved = false; - const readPromise = readSyncHistory({ kind: "codex-cli-sync" }).then((history) => { - readResolved = true; - return history; + 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); + 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 = "EACCES"; + 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); + }); + + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 1, + run: createCodexRun(1, "/retry-append"), }); - const secondWrite = appendSyncHistoryEntry({ + 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: 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: 201, + run: createCodexRun(201, "/seed-201"), }); - releaseFirstAppend(); - await secondAppendStarted; - await Promise.resolve(); - await Promise.resolve(); - expect(readResolved).toBe(false); - releaseSecondAppend(); - const history = await readPromise; - await firstWrite; - await secondWrite; + 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); + }); - expect(history).toHaveLength(2); - expect(history.at(-1)).toMatchObject({ + it("re-reads seeded history after configureSyncHistoryForTests resets the estimate to null", async () => { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 1, + run: createCodexRun(1, "/seeded"), + }); + 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); }); });