diff --git a/docs/getting-started.md b/docs/getting-started.md index 3ea0f42c..6a3112f4 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 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. Verify the new account: diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index e937fe69..c090a32e 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -101,6 +101,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"; @@ -4839,6 +4843,100 @@ async function runBackupBrowserManager( } } +async function buildFirstRunWizardOptions(): Promise { + let namedBackupCount = 0; + let rotatingBackupCount = 0; + let hasOpencodeSource = false; + + 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); + } + try { + hasOpencodeSource = (await assessOpencodeAccountPool()) !== null; + } catch (error) { + console.warn("Failed to detect OpenCode import source", error); + } + + return { + storagePath: getStoragePath(), + namedBackupCount, + rotatingBackupCount, + hasOpencodeSource, + }; +} + +async function runFirstRunWizard( + displaySettings: DashboardDisplaySettings, +): Promise<"continue" | "cancelled"> { + while (true) { + const action = await showFirstRunWizard(await buildFirstRunWizardOptions()); + switch (action.type) { + case "cancel": + console.log("Cancelled."); + return "cancelled"; + case "login": + case "skip": + return "continue"; + 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; + case "doctor": + await runActionPanel( + "Doctor", + "Checking storage and sync paths", + async () => { + await runDoctor(["--json"]); + }, + displaySettings, + ); + break; + } + } +} + async function runAuthLogin(): Promise { setStoragePath(null); let suppressRecoveryPrompt = false; @@ -5095,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/ui/auth-menu.ts b/lib/ui/auth-menu.ts index 5f10a4b2..cf618ed5 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -82,6 +82,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" @@ -935,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/lib/ui/copy.ts b/lib/ui/copy.ts index 183336ec..a6c4b996 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -146,6 +146,27 @@ 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", + importOpencode: "Import OpenCode Accounts", + login: "Add or Log In", + settings: "Open Settings & Sync", + doctor: "Run Doctor / Check Paths", + skip: "Skip setup and continue", + cancel: "Exit", + help: "↑↓ Move | Enter Select | Q Exit", + }, } as const; /** diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 521907db..0e5e752f 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -13,8 +13,11 @@ const getActionableNamedBackupRestoresMock = vi.fn(); 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(); const queuedRefreshMock = vi.fn(); const setCodexCliActiveSelectionMock = vi.fn(); const promptAddAnotherAccountMock = vi.fn(); @@ -114,8 +117,11 @@ vi.mock("../lib/storage.js", () => ({ listNamedBackups: listNamedBackupsMock, listRotatingBackups: listRotatingBackupsMock, assessNamedBackupRestore: assessNamedBackupRestoreMock, + assessOpencodeAccountPool: assessOpencodeAccountPoolMock, + listAccountSnapshots: listAccountSnapshotsMock, getNamedBackupsDirectoryPath: getNamedBackupsDirectoryPathMock, restoreNamedBackup: restoreNamedBackupMock, + importAccounts: importAccountsMock, })); vi.mock("../lib/refresh-queue.js", () => ({ @@ -305,8 +311,11 @@ describe("codex manager cli commands", () => { listNamedBackupsMock.mockReset(); listRotatingBackupsMock.mockReset(); assessNamedBackupRestoreMock.mockReset(); + assessOpencodeAccountPoolMock.mockReset(); + listAccountSnapshotsMock.mockReset(); getNamedBackupsDirectoryPathMock.mockReset(); restoreNamedBackupMock.mockReset(); + importAccountsMock.mockReset(); confirmMock.mockReset(); getActionableNamedBackupRestoresMock.mockResolvedValue({ assessments: [], @@ -345,6 +354,9 @@ describe("codex manager cli commands", () => { skipped: 0, total: 0, }); + assessOpencodeAccountPoolMock.mockResolvedValue(null); + listAccountSnapshotsMock.mockResolvedValue([]); + importAccountsMock.mockResolvedValue({ imported: 0, skipped: 0, total: 0 }); confirmMock.mockResolvedValue(false); fetchCodexQuotaSnapshotMock.mockResolvedValue({ status: 200, @@ -520,6 +532,189 @@ 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); + const authMenu = await import("../lib/ui/auth-menu.js"); + const wizardSpy = vi + .spyOn(authMenu, "showFirstRunWizard") + .mockResolvedValue({ type: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(wizardSpy).toHaveBeenCalledTimes(1); + expect(wizardSpy).toHaveBeenCalledWith( + expect.objectContaining({ + storagePath: "/mock/openai-codex-accounts.json", + }), + ); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + }); + + it("continues into OAuth when first-run wizard chooses login", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + 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", + 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 authMenu = await import("../lib/ui/auth-menu.js"); + vi.spyOn(authMenu, "showFirstRunWizard") + .mockResolvedValueOnce({ type: "settings" }) + .mockResolvedValueOnce({ type: "cancel" }); + const configureUnifiedSettingsSpy = vi + .spyOn(settingsHub, "configureUnifiedSettings") + .mockResolvedValue(undefined); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + let loadCount = 0; + loadAccountsMock.mockImplementation(async () => { + loadCount += 1; + return loadCount === 1 ? null : structuredClone(emptyStorage); + }); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(configureUnifiedSettingsSpy).toHaveBeenCalledTimes(1); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + }); + + it("loops back to first-run wizard after running doctor without creating accounts", async () => { + setInteractiveTTY(true); + const authMenu = await import("../lib/ui/auth-menu.js"); + vi.spyOn(authMenu, "showFirstRunWizard") + .mockResolvedValueOnce({ type: "doctor" }) + .mockResolvedValueOnce({ type: "cancel" }); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + let loadCount = 0; + loadAccountsMock.mockImplementation(async () => { + loadCount += 1; + return loadCount === 1 ? null : structuredClone(emptyStorage); + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(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, + 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); + 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 = { @@ -1452,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", @@ -1494,7 +1694,16 @@ describe("codex manager cli commands", () => { assessNamedBackupRestoreMock.mockResolvedValue(assessment); confirmMock.mockResolvedValueOnce(true); selectMock - .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");