Skip to content

Commit 35eb932

Browse files
committed
fix(storage): share named-backup restore path
1 parent 8d61d29 commit 35eb932

4 files changed

Lines changed: 129 additions & 10 deletions

File tree

lib/codex-manager.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,11 @@ import {
5757
} from "./quota-cache.js";
5858
import {
5959
assessNamedBackupRestore,
60-
importAccounts,
6160
getNamedBackupsDirectoryPath,
6261
isNamedBackupContainmentError,
6362
listNamedBackups,
6463
NAMED_BACKUP_ASSESS_CONCURRENCY,
65-
resolveNamedBackupRestorePath,
64+
restoreAssessedNamedBackup,
6665
findMatchingAccountIndex,
6766
getStoragePath,
6867
loadFlaggedAccounts,
@@ -329,6 +328,7 @@ function printUsage(): void {
329328
" codex auth report [--live] [--json] [--model <model>] [--out <path>]",
330329
" codex auth fix [--dry-run] [--json] [--live] [--model <model>]",
331330
" codex auth doctor [--json] [--fix] [--dry-run]",
331+
" codex auth restore-backup",
332332
"",
333333
"Notes:",
334334
" - Uses ~/.codex/multi-auth/openai-codex-accounts.json",
@@ -4359,10 +4359,7 @@ async function runBackupRestoreManager(
43594359
if (!confirmed) return;
43604360

43614361
try {
4362-
const validatedBackupPath = await resolveNamedBackupRestorePath(
4363-
latestAssessment.backup.name,
4364-
);
4365-
const result = await importAccounts(validatedBackupPath);
4362+
const result = await restoreAssessedNamedBackup(latestAssessment);
43664363
if (!result.changed) {
43674364
console.log("All accounts in this backup already exist");
43684365
return;
@@ -4466,6 +4463,18 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise<number> {
44664463
if (command === "doctor") {
44674464
return runDoctor(rest);
44684465
}
4466+
if (command === "restore-backup") {
4467+
try {
4468+
await runBackupRestoreManager(startupDisplaySettings);
4469+
return 0;
4470+
} catch (error) {
4471+
const message = error instanceof Error ? error.message : String(error);
4472+
console.error(
4473+
`Restore failed: ${collapseWhitespace(message) || "unknown error"}`,
4474+
);
4475+
return 1;
4476+
}
4477+
}
44694478

44704479
console.error(`Unknown command: ${command}`);
44714480
printUsage();

lib/storage.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1856,16 +1856,21 @@ export async function restoreNamedBackup(
18561856
name: string,
18571857
): Promise<ImportAccountsResult> {
18581858
const assessment = await assessNamedBackupRestore(name);
1859+
return restoreAssessedNamedBackup(assessment);
1860+
}
1861+
1862+
export async function restoreAssessedNamedBackup(
1863+
assessment: Pick<BackupRestoreAssessment, "backup" | "eligibleForRestore" | "error">,
1864+
): Promise<ImportAccountsResult> {
18591865
if (!assessment.eligibleForRestore) {
18601866
throw new Error(
18611867
assessment.error ?? "Backup is not eligible for restore.",
18621868
);
18631869
}
1864-
const validatedPath = assertNamedBackupRestorePath(
1865-
assessment.backup.path,
1866-
getNamedBackupRoot(getStoragePath()),
1870+
const resolvedPath = await resolveNamedBackupRestorePath(
1871+
assessment.backup.name,
18671872
);
1868-
return importAccounts(validatedPath);
1873+
return importAccounts(resolvedPath);
18691874
}
18701875

18711876
function parseAndNormalizeStorage(data: unknown): {

test/codex-manager-cli.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const assessNamedBackupRestoreMock = vi.fn();
1919
const getNamedBackupsDirectoryPathMock = vi.fn();
2020
const resolveNamedBackupRestorePathMock = vi.fn();
2121
const importAccountsMock = vi.fn();
22+
const restoreAssessedNamedBackupMock = vi.fn();
2223
const queuedRefreshMock = vi.fn();
2324
const setCodexCliActiveSelectionMock = vi.fn();
2425
const promptAddAnotherAccountMock = vi.fn();
@@ -121,6 +122,7 @@ vi.mock("../lib/storage.js", async () => {
121122
getNamedBackupsDirectoryPath: getNamedBackupsDirectoryPathMock,
122123
resolveNamedBackupRestorePath: resolveNamedBackupRestorePathMock,
123124
importAccounts: importAccountsMock,
125+
restoreAssessedNamedBackup: restoreAssessedNamedBackupMock,
124126
exportNamedBackup: exportNamedBackupMock,
125127
normalizeAccountStorage: normalizeAccountStorageMock,
126128
};
@@ -519,6 +521,7 @@ describe("codex manager cli commands", () => {
519521
getNamedBackupsDirectoryPathMock.mockReset();
520522
resolveNamedBackupRestorePathMock.mockReset();
521523
importAccountsMock.mockReset();
524+
restoreAssessedNamedBackupMock.mockReset();
522525
confirmMock.mockReset();
523526
listNamedBackupsMock.mockResolvedValue([]);
524527
assessNamedBackupRestoreMock.mockResolvedValue({
@@ -552,6 +555,11 @@ describe("codex manager cli commands", () => {
552555
total: 1,
553556
changed: true,
554557
});
558+
restoreAssessedNamedBackupMock.mockImplementation(async (assessment) =>
559+
importAccountsMock(
560+
await resolveNamedBackupRestorePathMock(assessment.backup.name),
561+
),
562+
);
555563
confirmMock.mockResolvedValue(true);
556564
withAccountStorageTransactionMock.mockImplementation(
557565
async (handler) => {
@@ -2446,6 +2454,72 @@ describe("codex manager cli commands", () => {
24462454
expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce();
24472455
});
24482456

2457+
it("restores a named backup from the direct restore-backup command", async () => {
2458+
setInteractiveTTY(true);
2459+
const now = Date.now();
2460+
loadAccountsMock.mockResolvedValue({
2461+
version: 3,
2462+
activeIndex: 0,
2463+
activeIndexByFamily: { codex: 0 },
2464+
accounts: [
2465+
{
2466+
email: "settings@example.com",
2467+
accountId: "acc_settings",
2468+
refreshToken: "refresh-settings",
2469+
accessToken: "access-settings",
2470+
expiresAt: now + 3_600_000,
2471+
addedAt: now - 1_000,
2472+
lastUsed: now - 1_000,
2473+
enabled: true,
2474+
},
2475+
],
2476+
});
2477+
const assessment = {
2478+
backup: {
2479+
name: "named-backup",
2480+
path: mockBackupPath("named-backup"),
2481+
createdAt: null,
2482+
updatedAt: now,
2483+
sizeBytes: 128,
2484+
version: 3,
2485+
accountCount: 1,
2486+
schemaErrors: [],
2487+
valid: true,
2488+
loadError: undefined,
2489+
},
2490+
currentAccountCount: 1,
2491+
mergedAccountCount: 2,
2492+
imported: 1,
2493+
skipped: 0,
2494+
wouldExceedLimit: false,
2495+
eligibleForRestore: true,
2496+
error: undefined,
2497+
};
2498+
listNamedBackupsMock.mockResolvedValue([assessment.backup]);
2499+
assessNamedBackupRestoreMock.mockResolvedValue(assessment);
2500+
selectMock.mockResolvedValueOnce({ type: "restore", assessment });
2501+
2502+
const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");
2503+
const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]);
2504+
2505+
expect(exitCode).toBe(0);
2506+
expect(promptLoginModeMock).not.toHaveBeenCalled();
2507+
expect(listNamedBackupsMock).toHaveBeenCalledTimes(1);
2508+
expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith(
2509+
"named-backup",
2510+
expect.objectContaining({
2511+
currentStorage: expect.objectContaining({
2512+
accounts: expect.any(Array),
2513+
}),
2514+
}),
2515+
);
2516+
expect(confirmMock).toHaveBeenCalledOnce();
2517+
expect(importAccountsMock).toHaveBeenCalledWith(
2518+
mockBackupPath("named-backup"),
2519+
);
2520+
expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce();
2521+
});
2522+
24492523
it("rejects a restore when the backup root changes before the final import path check", async () => {
24502524
setInteractiveTTY(true);
24512525
const now = Date.now();

test/storage.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
loadFlaggedAccounts,
3030
normalizeAccountStorage,
3131
resolveNamedBackupRestorePath,
32+
restoreAssessedNamedBackup,
3233
restoreNamedBackup,
3334
resolveAccountSelectionIndex,
3435
saveFlaggedAccounts,
@@ -2010,6 +2011,36 @@ describe("storage", () => {
20102011
expect((await loadAccounts())?.accounts ?? []).toHaveLength(0);
20112012
});
20122013

2014+
it("re-resolves an assessed named backup before the final import", async () => {
2015+
await saveAccounts({
2016+
version: 3,
2017+
activeIndex: 0,
2018+
accounts: [
2019+
{
2020+
accountId: "deleted-helper",
2021+
refreshToken: "ref-deleted-helper",
2022+
addedAt: 1,
2023+
lastUsed: 1,
2024+
},
2025+
],
2026+
});
2027+
2028+
const backup = await createNamedBackup("deleted-helper-assessment");
2029+
await clearAccounts();
2030+
2031+
const assessment = await assessNamedBackupRestore(
2032+
"deleted-helper-assessment",
2033+
);
2034+
expect(assessment.eligibleForRestore).toBe(true);
2035+
2036+
await removeWithRetry(backup.path, { force: true });
2037+
2038+
await expect(restoreAssessedNamedBackup(assessment)).rejects.toThrow(
2039+
/Import file not found/,
2040+
);
2041+
expect((await loadAccounts())?.accounts ?? []).toHaveLength(0);
2042+
});
2043+
20132044
it("throws when a named backup becomes invalid JSON after assessment", async () => {
20142045
await saveAccounts({
20152046
version: 3,

0 commit comments

Comments
 (0)