Skip to content

Commit 198b3d6

Browse files
committed
fix(auth): close onboarding restore review gaps
1 parent f8aaa10 commit 198b3d6

5 files changed

Lines changed: 139 additions & 45 deletions

File tree

docs/getting-started.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,17 @@ Expected flow:
4545

4646
Backup restore appears under the `Recover saved accounts` heading in the onboarding menu. This flow does not add any new CLI flags or npm scripts.
4747

48-
1. The dashboard opens in the terminal.
49-
2. Choose `Add New Account`.
50-
3. Choose one of the sign-in options:
48+
1. If no accounts are saved yet, the terminal opens directly to the sign-in menu.
49+
2. Choose one of the sign-in options:
5150
- `Open Browser (Easy)` for the normal OAuth flow
5251
- `Manual / Incognito` when you need to paste the callback yourself
53-
- `Recover saved accounts` when the current pool is empty and a named backup exists under `~/.codex/multi-auth/backups`
54-
4. If you choose the backup restore flow under `Recover saved accounts`, a second menu lets you either load the newest valid backup automatically or pick a backup manually from a newest-first list.
55-
5. If you use browser or manual sign-in, complete the official OAuth flow and return to the terminal.
56-
6. If you load a backup, the selected named backup is restored and its active account is synced back into Codex CLI auth.
57-
7. Confirm the account appears in the saved account list.
52+
- `Recover saved accounts` when the current pool is empty and a named backup exists under `~/.codex/multi-auth/backups` by default, or under `%CODEX_MULTI_AUTH_DIR%\backups` if you override the storage root with `CODEX_MULTI_AUTH_DIR`
53+
3. If you choose `Recover saved accounts`, the next menu lets you either:
54+
- load the newest valid backup automatically
55+
- pick a specific backup from a newest-first list
56+
4. If you use browser or manual sign-in, complete the official OAuth flow and return to the terminal.
57+
5. If you load a backup, the selected backup is restored, its active account is synced back into Codex CLI auth, and the login flow continues with that restored pool.
58+
6. Confirm the restored or newly signed-in account appears in the saved account list.
5859

5960
Verify the new account:
6061

lib/codex-manager.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4381,9 +4381,7 @@ async function runAuthLogin(): Promise<number> {
43814381
}
43824382

43834383
const selectedBackup = restoreMode === "manual"
4384-
? await promptManualBackupSelection(
4385-
await getNamedBackups().catch(() => []),
4386-
)
4384+
? await promptManualBackupSelection(namedBackups)
43874385
: latestNamedBackup;
43884386
if (!selectedBackup) {
43894387
continue;

lib/storage.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -886,7 +886,16 @@ export async function restoreAccountsFromBackup(
886886
}
887887
throw error;
888888
}
889-
const resolvedBackupPath = await fs.realpath(path);
889+
let resolvedBackupPath: string;
890+
try {
891+
resolvedBackupPath = await fs.realpath(path);
892+
} catch (error) {
893+
const code = (error as NodeJS.ErrnoException).code;
894+
if (code === "ENOENT") {
895+
throw new Error(`Backup file no longer exists: ${path}`);
896+
}
897+
throw error;
898+
}
890899
const relativePath = relative(resolvedBackupRoot, resolvedBackupPath);
891900
const isInsideBackupRoot =
892901
relativePath === "" ||

test/codex-manager-cli.test.ts

Lines changed: 80 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3035,44 +3035,89 @@ describe("codex manager cli commands", () => {
30353035
.mockResolvedValueOnce("latest");
30363036
promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" });
30373037
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3038+
const localeSpy = vi
3039+
.spyOn(Date.prototype, "toLocaleString")
3040+
.mockReturnValue("Localized Saved Time");
3041+
const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");
3042+
3043+
try {
3044+
const exitCode = await runCodexMultiAuthCli(["auth", "login"]);
3045+
3046+
expect(exitCode).toBe(0);
3047+
expect(getNamedBackupsMock).toHaveBeenCalled();
3048+
const signInItems = selectMock.mock.calls[0]?.[0] as Array<{
3049+
label: string;
3050+
kind?: string;
3051+
hint?: string;
3052+
}>;
3053+
expect(signInItems.map((item) => item.label)).toContain(
3054+
"Recover saved accounts",
3055+
);
3056+
expect(signInItems.find((item) => item.label === "Recover saved accounts")?.kind).toBe(
3057+
"heading",
3058+
);
3059+
expect(
3060+
signInItems.find((item) => item.label === "Restore Saved Backup")?.hint,
3061+
).toBe("last-good.json | 2 accounts | saved Localized Saved Time");
3062+
expect(localeSpy).toHaveBeenCalledWith(undefined, {
3063+
month: "short",
3064+
day: "numeric",
3065+
year: "numeric",
3066+
hour: "numeric",
3067+
minute: "2-digit",
3068+
});
3069+
expect(restoreAccountsFromBackupMock).toHaveBeenCalledWith(
3070+
"/mock/backups/last-good.json",
3071+
{ persist: false },
3072+
);
3073+
expect(confirmMock).toHaveBeenCalledWith("Load last-good.json (2 accounts)?");
3074+
expect(saveAccountsMock).toHaveBeenCalledTimes(1);
3075+
expect(saveAccountsMock.mock.calls[0]?.[0]).toEqual(
3076+
expect.objectContaining({
3077+
activeIndex: 1,
3078+
activeIndexByFamily: { codex: 1 },
3079+
}),
3080+
);
3081+
expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith(
3082+
expect.objectContaining({
3083+
accountId: "acc_restored",
3084+
email: "restored@example.com",
3085+
}),
3086+
);
3087+
expect(logSpy).toHaveBeenCalledWith(
3088+
"Loaded last-good.json (2 accounts).",
3089+
);
3090+
expect(promptLoginModeMock).toHaveBeenCalledTimes(1);
3091+
} finally {
3092+
localeSpy.mockRestore();
3093+
}
3094+
});
3095+
3096+
it("returns to onboarding when restore mode is back", async () => {
3097+
const now = Date.now();
3098+
setInteractiveTTY(true);
3099+
loadAccountsMock.mockResolvedValue(null);
3100+
getNamedBackupsMock.mockResolvedValue([
3101+
{
3102+
path: "/mock/backups/latest.json",
3103+
fileName: "latest.json",
3104+
accountCount: 1,
3105+
mtimeMs: now,
3106+
},
3107+
]);
3108+
selectMock
3109+
.mockResolvedValueOnce("restore-backup")
3110+
.mockResolvedValueOnce("back")
3111+
.mockResolvedValueOnce("cancel");
30383112
const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");
30393113

30403114
const exitCode = await runCodexMultiAuthCli(["auth", "login"]);
30413115

30423116
expect(exitCode).toBe(0);
3043-
expect(getNamedBackupsMock).toHaveBeenCalled();
3044-
const signInItems = selectMock.mock.calls[0]?.[0] as Array<{
3045-
label: string;
3046-
kind?: string;
3047-
}>;
3048-
expect(signInItems.map((item) => item.label)).toContain(
3049-
"Recover saved accounts",
3050-
);
3051-
expect(signInItems.find((item) => item.label === "Recover saved accounts")?.kind).toBe(
3052-
"heading",
3053-
);
3054-
expect(restoreAccountsFromBackupMock).toHaveBeenCalledWith(
3055-
"/mock/backups/last-good.json",
3056-
{ persist: false },
3057-
);
3058-
expect(confirmMock).toHaveBeenCalledWith("Load last-good.json (2 accounts)?");
3059-
expect(saveAccountsMock).toHaveBeenCalledTimes(1);
3060-
expect(saveAccountsMock.mock.calls[0]?.[0]).toEqual(
3061-
expect.objectContaining({
3062-
activeIndex: 1,
3063-
activeIndexByFamily: { codex: 1 },
3064-
}),
3065-
);
3066-
expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith(
3067-
expect.objectContaining({
3068-
accountId: "acc_restored",
3069-
email: "restored@example.com",
3070-
}),
3071-
);
3072-
expect(logSpy).toHaveBeenCalledWith(
3073-
"Loaded last-good.json (2 accounts).",
3074-
);
3075-
expect(promptLoginModeMock).toHaveBeenCalledTimes(1);
3117+
expect(restoreAccountsFromBackupMock).not.toHaveBeenCalled();
3118+
expect(confirmMock).not.toHaveBeenCalled();
3119+
expect(saveAccountsMock).not.toHaveBeenCalled();
3120+
expect(promptLoginModeMock).not.toHaveBeenCalled();
30763121
});
30773122

30783123
it("returns to the onboarding menu when the manual backup picker is backed out", async () => {
@@ -3111,6 +3156,7 @@ describe("codex manager cli commands", () => {
31113156

31123157
it("does not offer backup restore on onboarding when accounts already exist", async () => {
31133158
const now = Date.now();
3159+
setInteractiveTTY(true);
31143160
loadAccountsMock
31153161
.mockResolvedValueOnce({
31163162
version: 3,
@@ -3152,6 +3198,7 @@ describe("codex manager cli commands", () => {
31523198
const exitCode = await runCodexMultiAuthCli(["auth", "login"]);
31533199

31543200
expect(exitCode).toBe(0);
3201+
expect(promptLoginModeMock).toHaveBeenCalledTimes(1);
31553202
expect(getNamedBackupsMock).not.toHaveBeenCalled();
31563203
});
31573204

test/storage-last-backup.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,45 @@ describe("storage last backup restore", () => {
255255
);
256256
});
257257

258+
it("throws a clear error when a backup disappears before restore realpath", async () => {
259+
const backupPath = buildNamedBackupPath("backup-disappeared-before-restore");
260+
await fs.mkdir(dirname(backupPath), { recursive: true });
261+
await fs.writeFile(
262+
backupPath,
263+
JSON.stringify({
264+
version: 3,
265+
activeIndex: 0,
266+
activeIndexByFamily: { codex: 0 },
267+
accounts: [{ refreshToken: "gone-refresh", addedAt: 1, lastUsed: 1 }],
268+
}),
269+
"utf-8",
270+
);
271+
272+
const originalRealpath = fs.realpath.bind(fs);
273+
const realpathSpy = vi.spyOn(fs, "realpath").mockImplementation(async (path, options) => {
274+
if (String(path) === backupPath) {
275+
await fs.rm(backupPath, { force: true });
276+
const error = new Error(
277+
`ENOENT: no such file or directory, realpath '${backupPath}'`,
278+
) as NodeJS.ErrnoException;
279+
error.code = "ENOENT";
280+
throw error;
281+
}
282+
return originalRealpath(
283+
path as Parameters<typeof originalRealpath>[0],
284+
options as Parameters<typeof originalRealpath>[1],
285+
);
286+
});
287+
288+
try {
289+
await expect(restoreAccountsFromBackup(backupPath)).rejects.toThrow(
290+
`Backup file no longer exists: ${backupPath}`,
291+
);
292+
} finally {
293+
realpathSpy.mockRestore();
294+
}
295+
});
296+
258297
it("rejects restore paths outside the managed named-backup root", async () => {
259298
const backupPath = buildNamedBackupPath("backup-inside-root");
260299
const escapedBackupPath = join(testRoot, "backup-outside-root.json");

0 commit comments

Comments
 (0)