From 1742fd55d15e8709bc4702e3a3ddefa08fafb6a8 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 00:58:31 +0800 Subject: [PATCH 1/3] feat(auth): add first-run setup wizard --- docs/getting-started.md | 2 + lib/codex-manager.ts | 88 +++++++++++++++++++++++++++++++++- lib/ui/auth-menu.ts | 60 +++++++++++++++++++++++ lib/ui/copy.ts | 20 ++++++++ test/codex-manager-cli.test.ts | 17 +++++++ 5 files changed, 186 insertions(+), 1 deletion(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index e1bcb74d..cb327c0c 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, run doctor/check paths, open settings and Codex CLI sync, or skip straight to login. All actions reuse the existing backup browser, doctor, and settings flows. + If you have named backups in `~/.codex/multi-auth/backups/` and no active accounts, the login flow can prompt you to restore before opening OAuth. Confirm to open `Restore From Backup`, review the recoverable backup list, and restore the entries you want. Skip the prompt to continue with a fresh login. Verify the new account: diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index a438ebe7..441a2945 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -95,6 +95,10 @@ import { } from "./storage.js"; import type { AccountIdSource, TokenFailure, TokenResult } from "./types.js"; import { ANSI } from "./ui/ansi.js"; +import { + type FirstRunWizardOptions, + showFirstRunWizard, +} from "./ui/auth-menu.js"; import { confirm } from "./ui/confirm.js"; import { UI_COPY } from "./ui/copy.js"; import { paintUiText, quotaToneFromLeftPercent } from "./ui/format.js"; @@ -4763,14 +4767,96 @@ async function runBackupBrowserManager( } } +function shouldShowFirstRunWizard(storage: AccountStorageV3 | null): boolean { + return isInteractiveLoginMenuAvailable() && storage === null; +} + +async function buildFirstRunWizardOptions(): Promise { + let namedBackupCount = 0; + let rotatingBackupCount = 0; + try { + const namedBackups = await listNamedBackups(); + namedBackupCount = namedBackups.length; + } catch (error) { + console.warn("Failed to list named backups", error); + } + try { + const rotatingBackups = await listRotatingBackups(); + rotatingBackupCount = rotatingBackups.length; + } catch (error) { + console.warn("Failed to list rotating backups", error); + } + + return { + storagePath: getStoragePath(), + namedBackupCount, + rotatingBackupCount, + }; +} + +async function runFirstRunWizard(): Promise<"continue" | "cancelled"> { + const displaySettings = await loadDashboardDisplaySettings(); + applyUiThemeFromDashboardSettings(displaySettings); + while (true) { + const wizardOptions = await buildFirstRunWizardOptions(); + const action = await showFirstRunWizard(wizardOptions); + switch (action.type) { + case "cancel": + return "cancelled"; + case "login": + case "skip": + return "continue"; + case "restore": + await runBackupBrowserManager(displaySettings); + break; + case "settings": + await configureUnifiedSettings(displaySettings); + break; + case "doctor": + await runActionPanel( + "Doctor", + "Checking storage and sync paths", + async () => { + await runDoctor([]); + }, + displaySettings, + ); + break; + } + + const latestStorage = await loadAccounts(); + if (latestStorage && latestStorage.accounts.length > 0) { + return "continue"; + } + } +} + async function runAuthLogin(): Promise { setStoragePath(null); let suppressRecoveryPrompt = false; let recoveryPromptAttempted = false; let pendingMenuQuotaRefresh: Promise | null = null; let menuQuotaRefreshStatus: string | undefined; + const initialStorage = await loadAccounts(); + let cachedInitialStorage: AccountStorageV3 | null | undefined = + initialStorage; + + if (shouldShowFirstRunWizard(initialStorage)) { + const wizardOutcome = await runFirstRunWizard(); + if (wizardOutcome === "cancelled") { + console.log("Cancelled."); + return 0; + } + cachedInitialStorage = null; + } loginFlow: while (true) { - let existingStorage = await loadAccounts(); + let existingStorage: AccountStorageV3 | null; + if (cachedInitialStorage !== undefined) { + existingStorage = cachedInitialStorage; + cachedInitialStorage = undefined; + } else { + existingStorage = await loadAccounts(); + } if (existingStorage && existingStorage.accounts.length > 0) { while (true) { existingStorage = await loadAccounts(); diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index dbc1326c..0c3a14a7 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -81,6 +81,20 @@ export type AuthMenuAction = | { type: "delete-all" } | { type: "cancel" }; +export interface FirstRunWizardOptions { + storagePath: string; + namedBackupCount: number; + rotatingBackupCount: number; +} + +export type FirstRunWizardAction = + | { type: "login" } + | { type: "restore" } + | { type: "settings" } + | { type: "doctor" } + | { type: "skip" } + | { type: "cancel" }; + export type AccountAction = | "back" | "delete" @@ -505,6 +519,52 @@ async function promptSearchQuery(current: string): Promise { } } +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", + }, + { + label: UI_COPY.firstRun.login, + value: { type: "login" }, + color: "green", + }, + { + label: UI_COPY.firstRun.settings, + value: { type: "settings" }, + color: "green", + }, + { + label: UI_COPY.firstRun.doctor, + value: { type: "doctor" }, + color: "yellow", + }, + { 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: "row-invert", + theme: ui.theme, + }); + + return result ?? { type: "cancel" }; +} + function authMenuFocusKey(action: AuthMenuAction): string { switch (action.type) { case "select-account": diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 637e1d13..e8f32792 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -145,6 +145,26 @@ export const UI_COPY = { "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (f) fresh, (r) reset, (q) back [a/c/b/x/s/d/g/f/r/q]: ", invalidModePrompt: "Use one of: a, c, b, x, s, d, g, 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", + login: "Add or Log In", + settings: "Open Settings & Sync", + doctor: "Run Doctor / Check Paths", + skip: "Skip setup and continue", + cancel: "Exit", + help: "↑↓ Move | Enter Select | Q Exit", + }, } as const; /** diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 58089871..bacb770f 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -520,6 +520,22 @@ describe("codex manager cli commands", () => { expect(logSpy.mock.calls[0]?.[0]).toContain("Codex Multi-Auth CLI"); }); + it("shows first-run wizard before OAuth when storage file is missing", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + selectMock.mockResolvedValueOnce({ type: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectMock).toHaveBeenCalledTimes(1); + const [, options] = selectMock.mock.calls[0] ?? []; + expect(options?.message).toBe("First-Run Setup"); + expect(String(options?.subtitle)).toContain("No saved accounts detected"); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + }); + it("offers startup backup browser before OAuth when interactive login starts empty", async () => { setInteractiveTTY(true); const emptyStorage = { @@ -1412,6 +1428,7 @@ describe("codex manager cli commands", () => { assessNamedBackupRestoreMock.mockResolvedValue(assessment); confirmMock.mockResolvedValueOnce(true); selectMock + .mockResolvedValueOnce({ type: "login" }) .mockResolvedValueOnce({ type: "back" }) .mockResolvedValueOnce("cancel"); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); From a750669d812e8084bbbeef08d1668c81b7ab7c88 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 02:24:46 +0800 Subject: [PATCH 2/3] test(auth): cover first-run wizard branches --- docs/getting-started.md | 2 +- lib/codex-manager.ts | 40 ++++++++ lib/ui/auth-menu.ts | 11 +++ lib/ui/copy.ts | 1 + test/codex-manager-cli.test.ts | 175 +++++++++++++++++++++++++++++++++ 5 files changed, 228 insertions(+), 1 deletion(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index cb327c0c..7d530b7a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -49,7 +49,7 @@ 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, run doctor/check paths, open settings and Codex CLI sync, or skip straight to login. All actions reuse the existing backup browser, doctor, and settings flows. +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 an OpenCode 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. If you have named backups in `~/.codex/multi-auth/backups/` and no active accounts, the login flow can prompt you to restore before opening OAuth. Confirm to open `Restore From Backup`, review the recoverable backup list, and restore the entries you want. Skip the prompt to continue with a fresh login. diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 441a2945..a2ee432f 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -79,11 +79,13 @@ import { type AccountMetadataV3, type AccountStorageV3, assessNamedBackupRestore, + assessOpencodeAccountPool, type FlaggedAccountMetadataV1, type FlaggedAccountStorageV1, getActionableNamedBackupRestores, getNamedBackupsDirectoryPath, getStoragePath, + importAccounts, listNamedBackups, listRotatingBackups, loadAccounts, @@ -4774,6 +4776,7 @@ function shouldShowFirstRunWizard(storage: AccountStorageV3 | null): boolean { async function buildFirstRunWizardOptions(): Promise { let namedBackupCount = 0; let rotatingBackupCount = 0; + let hasOpencodeSource = false; try { const namedBackups = await listNamedBackups(); namedBackupCount = namedBackups.length; @@ -4786,11 +4789,17 @@ async function buildFirstRunWizardOptions(): Promise { } catch (error) { console.warn("Failed to list rotating backups", error); } + try { + hasOpencodeSource = (await assessOpencodeAccountPool()) !== null; + } catch (error) { + console.warn("Failed to detect OpenCode import source", error); + } return { storagePath: getStoragePath(), namedBackupCount, rotatingBackupCount, + hasOpencodeSource, }; } @@ -4809,6 +4818,37 @@ async function runFirstRunWizard(): Promise<"continue" | "cancelled"> { case "restore": await runBackupBrowserManager(displaySettings); break; + case "import-opencode": { + const assessment = await assessOpencodeAccountPool(); + if (!assessment) { + console.log("No OpenCode account pool was detected."); + break; + } + if (!assessment.valid || assessment.wouldExceedLimit) { + console.log( + assessment.error ?? "OpenCode account pool is not importable.", + ); + break; + } + const confirmed = await confirm( + `Import OpenCode accounts from ${assessment.backup.path}?`, + ); + if (!confirmed) { + break; + } + await runActionPanel( + "Import OpenCode Accounts", + `Importing from ${assessment.backup.path}`, + 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, + ); + break; + } case "settings": await configureUnifiedSettings(displaySettings); break; diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index 0c3a14a7..d8cce3cb 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -85,11 +85,13 @@ 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" } @@ -533,6 +535,15 @@ export async function showFirstRunWizard( 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" }, diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index e8f32792..fe2436e3 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -158,6 +158,7 @@ export const UI_COPY = { 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", diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index bacb770f..0ea97714 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -13,8 +13,10 @@ 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 restoreNamedBackupMock = vi.fn(); +const importAccountsMock = vi.fn(); const queuedRefreshMock = vi.fn(); const setCodexCliActiveSelectionMock = vi.fn(); const promptAddAnotherAccountMock = vi.fn(); @@ -114,8 +116,10 @@ vi.mock("../lib/storage.js", () => ({ listNamedBackups: listNamedBackupsMock, listRotatingBackups: listRotatingBackupsMock, assessNamedBackupRestore: assessNamedBackupRestoreMock, + assessOpencodeAccountPool: assessOpencodeAccountPoolMock, getNamedBackupsDirectoryPath: getNamedBackupsDirectoryPathMock, restoreNamedBackup: restoreNamedBackupMock, + importAccounts: importAccountsMock, })); vi.mock("../lib/refresh-queue.js", () => ({ @@ -305,8 +309,10 @@ describe("codex manager cli commands", () => { listNamedBackupsMock.mockReset(); listRotatingBackupsMock.mockReset(); assessNamedBackupRestoreMock.mockReset(); + assessOpencodeAccountPoolMock.mockReset(); getNamedBackupsDirectoryPathMock.mockReset(); restoreNamedBackupMock.mockReset(); + importAccountsMock.mockReset(); confirmMock.mockReset(); getActionableNamedBackupRestoresMock.mockResolvedValue({ assessments: [], @@ -345,6 +351,8 @@ describe("codex manager cli commands", () => { skipped: 0, total: 0, }); + assessOpencodeAccountPoolMock.mockResolvedValue(null); + importAccountsMock.mockResolvedValue({ imported: 0, skipped: 0, total: 0 }); confirmMock.mockResolvedValue(false); fetchCodexQuotaSnapshotMock.mockResolvedValue({ status: 200, @@ -536,6 +544,173 @@ describe("codex manager cli commands", () => { expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); }); + it("continues into OAuth when first-run wizard chooses login", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + listNamedBackupsMock.mockResolvedValue([]); + listRotatingBackupsMock.mockResolvedValue([]); + assessOpencodeAccountPoolMock.mockResolvedValue(null); + selectMock.mockResolvedValueOnce({ type: "login" }); + createAuthorizationFlowMock.mockResolvedValue({ + codeVerifier: "verifier", + authorizationUrl: "https://example.test/auth", + state: "state", + }); + startLocalOAuthServerMock.mockResolvedValue({ + waitForCallback: vi + .fn() + .mockResolvedValue( + new URL( + "http://localhost:1455/auth/callback?code=test-code&state=state", + ), + ), + close: vi.fn().mockResolvedValue(undefined), + }); + exchangeAuthorizationCodeMock.mockResolvedValue({ + accessToken: "access", + refreshToken: "refresh", + expiresAt: Date.now() + 60_000, + }); + promptAddAnotherAccountMock.mockResolvedValue(false); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + }); + + it("loops back to first-run wizard after opening settings without creating accounts", async () => { + setInteractiveTTY(true); + const settingsHub = await import("../lib/codex-manager/settings-hub.js"); + const 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); + }); + listNamedBackupsMock.mockResolvedValue([]); + listRotatingBackupsMock.mockResolvedValue([]); + assessOpencodeAccountPoolMock.mockResolvedValue(null); + selectMock + .mockResolvedValueOnce({ type: "settings" }) + .mockResolvedValueOnce({ type: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectMock).toHaveBeenCalledTimes(2); + expect(configureUnifiedSettingsSpy).toHaveBeenCalledTimes(1); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + }); + + it("loops back to first-run wizard after running doctor without creating accounts", async () => { + setInteractiveTTY(true); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + let loadCount = 0; + loadAccountsMock.mockImplementation(async () => { + loadCount += 1; + return loadCount === 1 ? null : structuredClone(emptyStorage); + }); + listNamedBackupsMock.mockResolvedValue([]); + listRotatingBackupsMock.mockResolvedValue([]); + assessOpencodeAccountPoolMock.mockResolvedValue(null); + selectMock + .mockResolvedValueOnce({ type: "doctor" }) + .mockResolvedValueOnce({ type: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectMock).toHaveBeenCalledTimes(2); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + }); + + it("imports OpenCode accounts from the first-run wizard", async () => { + setInteractiveTTY(true); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + const restoredStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "imported@example.com", + refreshToken: "refresh-imported", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + }; + let loadCount = 0; + loadAccountsMock.mockImplementation(async () => { + loadCount += 1; + if (loadCount === 1) return null; + return loadCount === 2 + ? structuredClone(emptyStorage) + : structuredClone(restoredStorage); + }); + listNamedBackupsMock.mockResolvedValue([]); + listRotatingBackupsMock.mockResolvedValue([]); + assessOpencodeAccountPoolMock.mockResolvedValue({ + backup: { + name: "openai-codex-accounts.json", + path: "/mock/.opencode/openai-codex-accounts.json", + createdAt: null, + updatedAt: Date.now(), + sizeBytes: 256, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + valid: true, + nextActiveIndex: 0, + nextActiveEmail: "imported@example.com", + nextActiveAccountId: undefined, + activeAccountChanged: true, + error: "", + }); + importAccountsMock.mockResolvedValue({ imported: 1, skipped: 0, total: 1 }); + confirmMock.mockResolvedValueOnce(true); + selectMock.mockResolvedValueOnce({ type: "import-opencode" }); + + 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(createAuthorizationFlowMock).not.toHaveBeenCalled(); + }); + it("offers startup backup browser before OAuth when interactive login starts empty", async () => { setInteractiveTTY(true); const emptyStorage = { From 1dff20a98b2caceefc2f38161e4f58d023ba340f Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 04:04:15 +0800 Subject: [PATCH 3/3] feat(auth): finalize first-run wizard flow --- lib/codex-manager.ts | 129 ++++++++++++++++++++++++--------- lib/storage.ts | 7 ++ lib/ui/auth-menu.ts | 98 +++++++++++-------------- test/codex-manager-cli.test.ts | 75 +++++++++++-------- 4 files changed, 191 insertions(+), 118 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index dc59d4b6..c090a32e 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -87,6 +87,7 @@ import { getNamedBackupsDirectoryPath, getStoragePath, importAccounts, + listAccountSnapshots, listNamedBackups, listRotatingBackups, loadAccounts, @@ -3598,6 +3599,7 @@ async function runDoctor(args: string[]): Promise { setStoragePath(null); const storagePath = getStoragePath(); + const walPath = `${storagePath}.wal`; const checks: DoctorCheck[] = []; const addCheck = (check: DoctorCheck): void => { checks.push(check); @@ -3632,6 +3634,79 @@ async function runDoctor(args: string[]): Promise { } } + addCheck({ + key: "storage-journal", + severity: existsSync(walPath) ? "ok" : "warn", + message: existsSync(walPath) + ? "Write-ahead journal found" + : "Write-ahead journal missing; recovery will rely on backups", + details: walPath, + }); + + const rotatingBackups = await listRotatingBackups(); + const validRotatingBackups = rotatingBackups.filter((backup) => backup.valid); + const invalidRotatingBackups = rotatingBackups.filter( + (backup) => !backup.valid, + ); + addCheck({ + key: "rotating-backups", + severity: + validRotatingBackups.length > 0 + ? "ok" + : rotatingBackups.length > 0 + ? "error" + : "warn", + message: + validRotatingBackups.length > 0 + ? `${validRotatingBackups.length} rotating backup(s) available` + : rotatingBackups.length > 0 + ? "Rotating backups are unreadable" + : "No rotating backups found yet", + details: + invalidRotatingBackups.length > 0 + ? `${invalidRotatingBackups.length} invalid backup(s); recreate by saving accounts` + : dirname(storagePath), + }); + + const snapshotBackups = await listAccountSnapshots(); + const validSnapshots = snapshotBackups.filter((snapshot) => snapshot.valid); + const invalidSnapshots = snapshotBackups.filter( + (snapshot) => !snapshot.valid, + ); + addCheck({ + key: "snapshot-backups", + severity: + validSnapshots.length > 0 + ? "ok" + : snapshotBackups.length > 0 + ? "error" + : "warn", + message: + validSnapshots.length > 0 + ? `${validSnapshots.length} recovery snapshot(s) available` + : snapshotBackups.length > 0 + ? "Snapshot backups are unreadable" + : "No recovery snapshots found", + details: + invalidSnapshots.length > 0 + ? `${invalidSnapshots.length} invalid snapshot(s); create a fresh snapshot before destructive actions` + : getNamedBackupsDirectoryPath(), + }); + + const hasAnyRecoveryArtifact = + existsSync(storagePath) || + existsSync(walPath) || + validRotatingBackups.length > 0 || + validSnapshots.length > 0; + addCheck({ + key: "recovery-chain", + severity: hasAnyRecoveryArtifact ? "ok" : "warn", + message: hasAnyRecoveryArtifact + ? "Recovery artifacts present" + : "No recovery artifacts found; create a snapshot or backup before destructive actions", + details: `storage=${existsSync(storagePath)}, wal=${existsSync(walPath)}, rotating=${validRotatingBackups.length}, snapshots=${validSnapshots.length}`, + }); + const codexAuthPath = getCodexCliAuthPath(); const codexConfigPath = getCodexCliConfigPath(); let codexAuthEmail: string | undefined; @@ -4768,14 +4843,11 @@ async function runBackupBrowserManager( } } -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; @@ -4802,14 +4874,14 @@ async function buildFirstRunWizardOptions(): Promise { }; } -async function runFirstRunWizard(): Promise<"continue" | "cancelled"> { - const displaySettings = await loadDashboardDisplaySettings(); - applyUiThemeFromDashboardSettings(displaySettings); +async function runFirstRunWizard( + displaySettings: DashboardDisplaySettings, +): Promise<"continue" | "cancelled"> { while (true) { - const wizardOptions = await buildFirstRunWizardOptions(); - const action = await showFirstRunWizard(wizardOptions); + const action = await showFirstRunWizard(await buildFirstRunWizardOptions()); switch (action.type) { case "cancel": + console.log("Cancelled."); return "cancelled"; case "login": case "skip": @@ -4856,17 +4928,12 @@ async function runFirstRunWizard(): Promise<"continue" | "cancelled"> { "Doctor", "Checking storage and sync paths", async () => { - await runDoctor([]); + await runDoctor(["--json"]); }, displaySettings, ); break; } - - const latestStorage = await loadAccounts(); - if (latestStorage && latestStorage.accounts.length > 0) { - return "continue"; - } } } @@ -4876,26 +4943,8 @@ async function runAuthLogin(): Promise { let recoveryPromptAttempted = false; let pendingMenuQuotaRefresh: Promise | null = null; let menuQuotaRefreshStatus: string | undefined; - const initialStorage = await loadAccounts(); - let cachedInitialStorage: AccountStorageV3 | null | undefined = - initialStorage; - - if (shouldShowFirstRunWizard(initialStorage)) { - const wizardOutcome = await runFirstRunWizard(); - if (wizardOutcome === "cancelled") { - console.log("Cancelled."); - return 0; - } - cachedInitialStorage = null; - } loginFlow: while (true) { - let existingStorage: AccountStorageV3 | null; - if (cachedInitialStorage !== undefined) { - existingStorage = cachedInitialStorage; - cachedInitialStorage = undefined; - } else { - existingStorage = await loadAccounts(); - } + let existingStorage = await loadAccounts(); if (existingStorage && existingStorage.accounts.length > 0) { while (true) { existingStorage = await loadAccounts(); @@ -5144,6 +5193,18 @@ async function runAuthLogin(): Promise { } } } + if (existingCount === 0 && isInteractiveLoginMenuAvailable()) { + const displaySettings = await loadDashboardDisplaySettings(); + applyUiThemeFromDashboardSettings(displaySettings); + const firstRunResult = await runFirstRunWizard(displaySettings); + if (firstRunResult === "cancelled") { + return 0; + } + const refreshedAfterWizard = await loadAccounts(); + if ((refreshedAfterWizard?.accounts.length ?? 0) > 0) { + continue; + } + } let forceNewLogin = existingCount > 0; while (true) { const tokenResult = await runOAuthFlow(forceNewLogin); diff --git a/lib/storage.ts b/lib/storage.ts index 2b1a1ea6..0083ec5a 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1474,6 +1474,13 @@ export async function listNamedBackups(): Promise { } } +export async function listAccountSnapshots(): Promise { + const backups = await listNamedBackups(); + return backups.filter((backup) => + AUTO_SNAPSHOT_NAME_PATTERN.test(backup.name), + ); +} + export async function listRotatingBackups(): Promise { const storagePath = getStoragePath(); const candidates = getAccountsBackupRecoveryCandidates(storagePath); diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index 5923d9a3..cf618ed5 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -522,61 +522,6 @@ async function promptSearchQuery(current: string): Promise { } } -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" }, - color: "green", - }, - { - label: UI_COPY.firstRun.doctor, - value: { type: "doctor" }, - color: "yellow", - }, - { 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: "row-invert", - theme: ui.theme, - }); - - return result ?? { type: "cancel" }; -} - function authMenuFocusKey(action: AuthMenuAction): string { switch (action.type) { case "select-account": @@ -1006,4 +951,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/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 0edf99f1..0e5e752f 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -14,6 +14,7 @@ const listNamedBackupsMock = vi.fn(); const listRotatingBackupsMock = vi.fn(); const assessNamedBackupRestoreMock = vi.fn(); const assessOpencodeAccountPoolMock = vi.fn(); +const listAccountSnapshotsMock = vi.fn(); const getNamedBackupsDirectoryPathMock = vi.fn(); const restoreNamedBackupMock = vi.fn(); const importAccountsMock = vi.fn(); @@ -117,6 +118,7 @@ vi.mock("../lib/storage.js", () => ({ listRotatingBackups: listRotatingBackupsMock, assessNamedBackupRestore: assessNamedBackupRestoreMock, assessOpencodeAccountPool: assessOpencodeAccountPoolMock, + listAccountSnapshots: listAccountSnapshotsMock, getNamedBackupsDirectoryPath: getNamedBackupsDirectoryPathMock, restoreNamedBackup: restoreNamedBackupMock, importAccounts: importAccountsMock, @@ -310,6 +312,7 @@ describe("codex manager cli commands", () => { listRotatingBackupsMock.mockReset(); assessNamedBackupRestoreMock.mockReset(); assessOpencodeAccountPoolMock.mockReset(); + listAccountSnapshotsMock.mockReset(); getNamedBackupsDirectoryPathMock.mockReset(); restoreNamedBackupMock.mockReset(); importAccountsMock.mockReset(); @@ -352,6 +355,7 @@ describe("codex manager cli commands", () => { total: 0, }); assessOpencodeAccountPoolMock.mockResolvedValue(null); + listAccountSnapshotsMock.mockResolvedValue([]); importAccountsMock.mockResolvedValue({ imported: 0, skipped: 0, total: 0 }); confirmMock.mockResolvedValue(false); fetchCodexQuotaSnapshotMock.mockResolvedValue({ @@ -531,26 +535,31 @@ describe("codex manager cli commands", () => { it("shows first-run wizard before OAuth when storage file is missing", async () => { setInteractiveTTY(true); loadAccountsMock.mockResolvedValue(null); - selectMock.mockResolvedValueOnce({ type: "cancel" }); + const authMenu = await import("../lib/ui/auth-menu.js"); + const wizardSpy = vi + .spyOn(authMenu, "showFirstRunWizard") + .mockResolvedValue({ type: "cancel" }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(selectMock).toHaveBeenCalledTimes(1); - const [, options] = selectMock.mock.calls[0] ?? []; - expect(options?.message).toBe("First-Run Setup"); - expect(String(options?.subtitle)).toContain("No saved accounts detected"); + expect(wizardSpy).toHaveBeenCalledTimes(1); + expect(wizardSpy).toHaveBeenCalledWith( + expect.objectContaining({ + storagePath: "/mock/openai-codex-accounts.json", + }), + ); expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); }); it("continues into OAuth when first-run wizard chooses login", async () => { setInteractiveTTY(true); loadAccountsMock.mockResolvedValue(null); - listNamedBackupsMock.mockResolvedValue([]); - listRotatingBackupsMock.mockResolvedValue([]); - assessOpencodeAccountPoolMock.mockResolvedValue(null); - selectMock.mockResolvedValueOnce({ type: "login" }); + const authMenu = await import("../lib/ui/auth-menu.js"); + vi.spyOn(authMenu, "showFirstRunWizard").mockResolvedValue({ + type: "login", + }); createAuthorizationFlowMock.mockResolvedValue({ codeVerifier: "verifier", authorizationUrl: "https://example.test/auth", @@ -583,6 +592,10 @@ describe("codex manager cli commands", () => { 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); @@ -597,24 +610,20 @@ describe("codex manager cli commands", () => { loadCount += 1; return loadCount === 1 ? null : structuredClone(emptyStorage); }); - listNamedBackupsMock.mockResolvedValue([]); - listRotatingBackupsMock.mockResolvedValue([]); - assessOpencodeAccountPoolMock.mockResolvedValue(null); - selectMock - .mockResolvedValueOnce({ type: "settings" }) - .mockResolvedValueOnce({ type: "cancel" }); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(selectMock).toHaveBeenCalledTimes(2); expect(configureUnifiedSettingsSpy).toHaveBeenCalledTimes(1); expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); }); it("loops back to first-run wizard after running doctor without creating accounts", async () => { setInteractiveTTY(true); + const authMenu = await import("../lib/ui/auth-menu.js"); + vi.spyOn(authMenu, "showFirstRunWizard") + .mockResolvedValueOnce({ type: "doctor" }) + .mockResolvedValueOnce({ type: "cancel" }); const emptyStorage = { version: 3, activeIndex: 0, @@ -626,23 +635,20 @@ describe("codex manager cli commands", () => { loadCount += 1; return loadCount === 1 ? null : structuredClone(emptyStorage); }); - listNamedBackupsMock.mockResolvedValue([]); - listRotatingBackupsMock.mockResolvedValue([]); - assessOpencodeAccountPoolMock.mockResolvedValue(null); - selectMock - .mockResolvedValueOnce({ type: "doctor" }) - .mockResolvedValueOnce({ type: "cancel" }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(selectMock).toHaveBeenCalledTimes(2); expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); }); it("imports OpenCode accounts from the first-run wizard", async () => { setInteractiveTTY(true); + const authMenu = await import("../lib/ui/auth-menu.js"); + vi.spyOn(authMenu, "showFirstRunWizard").mockResolvedValue({ + type: "import-opencode", + }); const emptyStorage = { version: 3, activeIndex: 0, @@ -699,8 +705,6 @@ describe("codex manager cli commands", () => { }); importAccountsMock.mockResolvedValue({ imported: 1, skipped: 0, total: 1 }); confirmMock.mockResolvedValueOnce(true); - selectMock.mockResolvedValueOnce({ type: "import-opencode" }); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); @@ -1643,7 +1647,12 @@ describe("codex manager cli commands", () => { it("offers backup recovery before OAuth when actionable backups exist", 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", @@ -1685,8 +1694,16 @@ describe("codex manager cli commands", () => { assessNamedBackupRestoreMock.mockResolvedValue(assessment); confirmMock.mockResolvedValueOnce(true); selectMock - .mockResolvedValueOnce({ type: "login" }) - .mockResolvedValueOnce({ type: "back" }) + .mockResolvedValueOnce({ + type: "inspect", + entry: { + kind: "named", + label: assessment.backup.name, + backup: assessment.backup, + assessment, + }, + }) + .mockResolvedValueOnce("back") .mockResolvedValueOnce("cancel"); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); const authModule = await import("../lib/auth/auth.js");