codex-multi-auth 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +223 -98
  3. package/assets/readme-hero.svg +31 -31
  4. package/config/README.md +30 -87
  5. package/config/{opencode-legacy.json → codex-legacy.json} +571 -571
  6. package/config/{opencode-modern.json → codex-modern.json} +241 -239
  7. package/config/{minimal-opencode.json → minimal-codex.json} +15 -13
  8. package/config/schema/config.schema.json +21 -0
  9. package/dist/index.d.ts +5 -5
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +23 -20
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/accounts.d.ts +1 -1
  14. package/dist/lib/accounts.d.ts.map +1 -1
  15. package/dist/lib/auth/auth.d.ts +1 -1
  16. package/dist/lib/auth/auth.js +1 -1
  17. package/dist/lib/cli.d.ts +1 -1
  18. package/dist/lib/cli.js +4 -4
  19. package/dist/lib/cli.js.map +1 -1
  20. package/dist/lib/codex-manager/settings-hub.d.ts +6 -0
  21. package/dist/lib/codex-manager/settings-hub.d.ts.map +1 -0
  22. package/dist/lib/codex-manager/settings-hub.js +1786 -0
  23. package/dist/lib/codex-manager/settings-hub.js.map +1 -0
  24. package/dist/lib/codex-manager.d.ts.map +1 -1
  25. package/dist/lib/codex-manager.js +5 -1637
  26. package/dist/lib/codex-manager.js.map +1 -1
  27. package/dist/lib/config.d.ts.map +1 -1
  28. package/dist/lib/config.js +20 -11
  29. package/dist/lib/config.js.map +1 -1
  30. package/dist/lib/context-overflow.d.ts +1 -1
  31. package/dist/lib/context-overflow.js +11 -11
  32. package/dist/lib/oauth-success.html +1 -1
  33. package/dist/lib/prompts/codex-host-bridge.d.ts +19 -0
  34. package/dist/lib/prompts/codex-host-bridge.d.ts.map +1 -0
  35. package/dist/lib/prompts/{codex-opencode-bridge.js → codex-host-bridge.js} +143 -143
  36. package/dist/lib/prompts/codex-host-bridge.js.map +1 -0
  37. package/dist/lib/prompts/codex.d.ts +2 -2
  38. package/dist/lib/prompts/codex.d.ts.map +1 -1
  39. package/dist/lib/prompts/codex.js +3 -3
  40. package/dist/lib/prompts/host-codex-prompt.d.ts +25 -0
  41. package/dist/lib/prompts/host-codex-prompt.d.ts.map +1 -0
  42. package/dist/lib/prompts/{opencode-codex.js → host-codex-prompt.js} +136 -47
  43. package/dist/lib/prompts/host-codex-prompt.js.map +1 -0
  44. package/dist/lib/recovery/storage.d.ts +2 -2
  45. package/dist/lib/recovery/storage.js +2 -2
  46. package/dist/lib/recovery/types.d.ts +1 -1
  47. package/dist/lib/recovery/types.js +1 -1
  48. package/dist/lib/recovery.d.ts +1 -1
  49. package/dist/lib/recovery.d.ts.map +1 -1
  50. package/dist/lib/recovery.js +1 -4
  51. package/dist/lib/recovery.js.map +1 -1
  52. package/dist/lib/request/fetch-helpers.d.ts +3 -3
  53. package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
  54. package/dist/lib/request/fetch-helpers.js +6 -2
  55. package/dist/lib/request/fetch-helpers.js.map +1 -1
  56. package/dist/lib/request/helpers/input-utils.d.ts +2 -2
  57. package/dist/lib/request/helpers/input-utils.d.ts.map +1 -1
  58. package/dist/lib/request/helpers/input-utils.js +14 -14
  59. package/dist/lib/request/helpers/input-utils.js.map +1 -1
  60. package/dist/lib/request/helpers/model-map.d.ts +2 -2
  61. package/dist/lib/request/helpers/model-map.js +2 -2
  62. package/dist/lib/request/request-transformer.d.ts +12 -12
  63. package/dist/lib/request/request-transformer.d.ts.map +1 -1
  64. package/dist/lib/request/request-transformer.js +23 -24
  65. package/dist/lib/request/request-transformer.js.map +1 -1
  66. package/dist/lib/runtime-paths.d.ts +4 -4
  67. package/dist/lib/runtime-paths.d.ts.map +1 -1
  68. package/dist/lib/runtime-paths.js +57 -13
  69. package/dist/lib/runtime-paths.js.map +1 -1
  70. package/dist/lib/storage/paths.d.ts +11 -0
  71. package/dist/lib/storage/paths.d.ts.map +1 -1
  72. package/dist/lib/storage/paths.js +167 -5
  73. package/dist/lib/storage/paths.js.map +1 -1
  74. package/dist/lib/storage.d.ts +1 -0
  75. package/dist/lib/storage.d.ts.map +1 -1
  76. package/dist/lib/storage.js +290 -81
  77. package/dist/lib/storage.js.map +1 -1
  78. package/dist/lib/tools/hashline-tools.d.ts +1 -1
  79. package/dist/lib/tools/hashline-tools.d.ts.map +1 -1
  80. package/dist/lib/tools/hashline-tools.js +1 -1
  81. package/dist/lib/tools/hashline-tools.js.map +1 -1
  82. package/dist/lib/types.d.ts +1 -1
  83. package/dist/lib/types.d.ts.map +1 -1
  84. package/dist/lib/ui/copy.d.ts +6 -6
  85. package/dist/lib/ui/copy.js +6 -6
  86. package/dist/lib/ui/copy.js.map +1 -1
  87. package/node_modules/@codex-ai/plugin/dist/index.d.ts +2 -0
  88. package/node_modules/@codex-ai/plugin/dist/index.js +2 -0
  89. package/node_modules/@codex-ai/plugin/dist/tool.d.ts +42 -0
  90. package/node_modules/@codex-ai/plugin/dist/tool.js +29 -0
  91. package/node_modules/@codex-ai/plugin/package.json +9 -0
  92. package/package.json +30 -16
  93. package/scripts/audit-dev-allowlist.js +128 -128
  94. package/scripts/bench-format/{opencode.mjs → codex-host.mjs} +206 -205
  95. package/scripts/bench-format/hashline-v2.mjs +642 -642
  96. package/scripts/bench-format/models.mjs +111 -105
  97. package/scripts/bench-format/render.mjs +496 -496
  98. package/scripts/bench-format/stats.mjs +54 -54
  99. package/scripts/bench-format/tasks.mjs +151 -151
  100. package/scripts/benchmark-edit-formats.mjs +1162 -1161
  101. package/scripts/benchmark-render-dashboard.mjs +49 -49
  102. package/scripts/codex.js +40 -2
  103. package/scripts/copy-oauth-success.js +37 -37
  104. package/scripts/install-codex-auth-utils.js +49 -0
  105. package/scripts/{install-opencode-codex-auth.js → install-codex-auth.js} +220 -193
  106. package/scripts/repo-hygiene.js +320 -0
  107. package/scripts/test-all-models.sh +7 -7
  108. package/scripts/test-model-matrix.js +475 -423
  109. package/scripts/validate-model-map.sh +7 -7
  110. package/vendor/codex-ai-plugin/dist/index.d.ts +2 -0
  111. package/vendor/codex-ai-plugin/dist/index.js +2 -0
  112. package/vendor/codex-ai-plugin/dist/tool.d.ts +42 -0
  113. package/vendor/codex-ai-plugin/dist/tool.js +29 -0
  114. package/vendor/codex-ai-plugin/package.json +9 -0
  115. package/vendor/codex-ai-sdk/dist/index.d.ts +4 -0
  116. package/vendor/codex-ai-sdk/dist/index.js +2 -0
  117. package/vendor/codex-ai-sdk/package.json +8 -0
  118. package/assets/opencode-logo-ornate-dark.svg +0 -18
  119. package/dist/lib/prompts/codex-opencode-bridge.d.ts +0 -19
  120. package/dist/lib/prompts/codex-opencode-bridge.d.ts.map +0 -1
  121. package/dist/lib/prompts/codex-opencode-bridge.js.map +0 -1
  122. package/dist/lib/prompts/opencode-codex.d.ts +0 -25
  123. package/dist/lib/prompts/opencode-codex.d.ts.map +0 -1
  124. package/dist/lib/prompts/opencode-codex.js.map +0 -1
@@ -5,7 +5,7 @@ import { ACCOUNT_LIMITS } from "./constants.js";
5
5
  import { createLogger } from "./logger.js";
6
6
  import { MODEL_FAMILIES } from "./prompts/codex.js";
7
7
  import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js";
8
- import { getConfigDir, getProjectConfigDir, getProjectGlobalConfigDir, findProjectRoot, resolvePath } from "./storage/paths.js";
8
+ import { getConfigDir, getProjectConfigDir, getProjectGlobalConfigDir, findProjectRoot, resolvePath, resolveProjectStorageIdentityRoot, } from "./storage/paths.js";
9
9
  import { migrateV1ToV3, } from "./storage/migrations.js";
10
10
  const log = createLogger("storage");
11
11
  const ACCOUNTS_FILE_NAME = "openai-codex-accounts.json";
@@ -13,6 +13,7 @@ const FLAGGED_ACCOUNTS_FILE_NAME = "openai-codex-flagged-accounts.json";
13
13
  const LEGACY_FLAGGED_ACCOUNTS_FILE_NAME = "openai-codex-blocked-accounts.json";
14
14
  const ACCOUNTS_BACKUP_SUFFIX = ".bak";
15
15
  const ACCOUNTS_WAL_SUFFIX = ".wal";
16
+ const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3;
16
17
  const BACKUP_COPY_MAX_ATTEMPTS = 5;
17
18
  const BACKUP_COPY_BASE_DELAY_MS = 10;
18
19
  let storageBackupEnabled = true;
@@ -95,6 +96,7 @@ async function ensureGitignore(storagePath) {
95
96
  }
96
97
  let currentStoragePath = null;
97
98
  let currentLegacyProjectStoragePath = null;
99
+ let currentLegacyWorktreeStoragePath = null;
98
100
  let currentProjectRoot = null;
99
101
  export function setStorageBackupEnabled(enabled) {
100
102
  storageBackupEnabled = enabled;
@@ -102,9 +104,156 @@ export function setStorageBackupEnabled(enabled) {
102
104
  function getAccountsBackupPath(path) {
103
105
  return `${path}${ACCOUNTS_BACKUP_SUFFIX}`;
104
106
  }
107
+ function getAccountsBackupPathAtIndex(path, index) {
108
+ if (index <= 0) {
109
+ return getAccountsBackupPath(path);
110
+ }
111
+ return `${path}${ACCOUNTS_BACKUP_SUFFIX}.${index}`;
112
+ }
113
+ function getAccountsBackupRecoveryCandidates(path) {
114
+ const candidates = [];
115
+ for (let i = 0; i < ACCOUNTS_BACKUP_HISTORY_DEPTH; i += 1) {
116
+ candidates.push(getAccountsBackupPathAtIndex(path, i));
117
+ }
118
+ return candidates;
119
+ }
105
120
  function getAccountsWalPath(path) {
106
121
  return `${path}${ACCOUNTS_WAL_SUFFIX}`;
107
122
  }
123
+ async function copyFileWithRetry(sourcePath, destinationPath, options) {
124
+ const allowMissingSource = options?.allowMissingSource ?? false;
125
+ for (let attempt = 0; attempt < BACKUP_COPY_MAX_ATTEMPTS; attempt += 1) {
126
+ try {
127
+ await fs.copyFile(sourcePath, destinationPath);
128
+ return;
129
+ }
130
+ catch (error) {
131
+ const code = error.code;
132
+ if (allowMissingSource && code === "ENOENT") {
133
+ return;
134
+ }
135
+ const canRetry = (code === "EPERM" || code === "EBUSY") &&
136
+ attempt + 1 < BACKUP_COPY_MAX_ATTEMPTS;
137
+ if (canRetry) {
138
+ await new Promise((resolve) => setTimeout(resolve, BACKUP_COPY_BASE_DELAY_MS * 2 ** attempt));
139
+ continue;
140
+ }
141
+ throw error;
142
+ }
143
+ }
144
+ }
145
+ async function renameFileWithRetry(sourcePath, destinationPath) {
146
+ for (let attempt = 0; attempt < BACKUP_COPY_MAX_ATTEMPTS; attempt += 1) {
147
+ try {
148
+ await fs.rename(sourcePath, destinationPath);
149
+ return;
150
+ }
151
+ catch (error) {
152
+ const code = error.code;
153
+ const canRetry = (code === "EPERM" || code === "EBUSY" || code === "EAGAIN") &&
154
+ attempt + 1 < BACKUP_COPY_MAX_ATTEMPTS;
155
+ if (!canRetry) {
156
+ throw error;
157
+ }
158
+ const jitterMs = Math.floor(Math.random() * BACKUP_COPY_BASE_DELAY_MS);
159
+ await new Promise((resolve) => setTimeout(resolve, BACKUP_COPY_BASE_DELAY_MS * 2 ** attempt + jitterMs));
160
+ }
161
+ }
162
+ }
163
+ async function createRotatingAccountsBackup(path) {
164
+ const candidates = getAccountsBackupRecoveryCandidates(path);
165
+ const rotationNonce = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
166
+ const stagedWrites = [];
167
+ const buildStagedPath = (targetPath, label) => `${targetPath}.rotate.${rotationNonce}.${label}.tmp`;
168
+ try {
169
+ for (let i = candidates.length - 1; i > 0; i -= 1) {
170
+ const previousPath = candidates[i - 1];
171
+ const currentPath = candidates[i];
172
+ if (!previousPath || !currentPath || !existsSync(previousPath)) {
173
+ continue;
174
+ }
175
+ const stagedPath = buildStagedPath(currentPath, `slot-${i}`);
176
+ await copyFileWithRetry(previousPath, stagedPath, { allowMissingSource: true });
177
+ if (existsSync(stagedPath)) {
178
+ stagedWrites.push({ targetPath: currentPath, stagedPath });
179
+ }
180
+ }
181
+ const latestBackupPath = candidates[0];
182
+ if (!latestBackupPath) {
183
+ return;
184
+ }
185
+ const latestStagedPath = buildStagedPath(latestBackupPath, "latest");
186
+ await copyFileWithRetry(path, latestStagedPath);
187
+ if (existsSync(latestStagedPath)) {
188
+ stagedWrites.push({ targetPath: latestBackupPath, stagedPath: latestStagedPath });
189
+ }
190
+ for (const stagedWrite of stagedWrites) {
191
+ await renameFileWithRetry(stagedWrite.stagedPath, stagedWrite.targetPath);
192
+ }
193
+ }
194
+ finally {
195
+ for (const stagedWrite of stagedWrites) {
196
+ if (!existsSync(stagedWrite.stagedPath)) {
197
+ continue;
198
+ }
199
+ try {
200
+ await fs.unlink(stagedWrite.stagedPath);
201
+ }
202
+ catch {
203
+ // Best effort cleanup for staged rotation artifacts.
204
+ }
205
+ }
206
+ }
207
+ }
208
+ function isRotatingBackupTempArtifact(storagePath, candidatePath) {
209
+ const backupPrefix = `${storagePath}${ACCOUNTS_BACKUP_SUFFIX}`;
210
+ if (!candidatePath.startsWith(backupPrefix) || !candidatePath.endsWith(".tmp")) {
211
+ return false;
212
+ }
213
+ const suffix = candidatePath.slice(backupPrefix.length);
214
+ const rotateSeparatorIndex = suffix.indexOf(".rotate.");
215
+ if (rotateSeparatorIndex === -1) {
216
+ return false;
217
+ }
218
+ const backupIndexSuffix = suffix.slice(0, rotateSeparatorIndex);
219
+ if (backupIndexSuffix.length > 0 && !/^\.\d+$/.test(backupIndexSuffix)) {
220
+ return false;
221
+ }
222
+ return true;
223
+ }
224
+ async function cleanupStaleRotatingBackupArtifacts(path) {
225
+ const directoryPath = dirname(path);
226
+ try {
227
+ const directoryEntries = await fs.readdir(directoryPath, { withFileTypes: true });
228
+ const staleArtifacts = directoryEntries
229
+ .filter((entry) => entry.isFile())
230
+ .map((entry) => join(directoryPath, entry.name))
231
+ .filter((entryPath) => isRotatingBackupTempArtifact(path, entryPath));
232
+ for (const staleArtifactPath of staleArtifacts) {
233
+ try {
234
+ await fs.unlink(staleArtifactPath);
235
+ }
236
+ catch (error) {
237
+ const code = error.code;
238
+ if (code !== "ENOENT") {
239
+ log.warn("Failed to remove stale rotating backup artifact", {
240
+ path: staleArtifactPath,
241
+ error: String(error),
242
+ });
243
+ }
244
+ }
245
+ }
246
+ }
247
+ catch (error) {
248
+ const code = error.code;
249
+ if (code !== "ENOENT") {
250
+ log.warn("Failed to scan for stale rotating backup artifacts", {
251
+ path,
252
+ error: String(error),
253
+ });
254
+ }
255
+ }
256
+ }
108
257
  function computeSha256(value) {
109
258
  return createHash("sha256").update(value).digest("hex");
110
259
  }
@@ -115,24 +264,31 @@ export function setStoragePath(projectPath) {
115
264
  if (!projectPath) {
116
265
  currentStoragePath = null;
117
266
  currentLegacyProjectStoragePath = null;
267
+ currentLegacyWorktreeStoragePath = null;
118
268
  currentProjectRoot = null;
119
269
  return;
120
270
  }
121
271
  const projectRoot = findProjectRoot(projectPath);
122
272
  if (projectRoot) {
123
273
  currentProjectRoot = projectRoot;
124
- currentStoragePath = join(getProjectGlobalConfigDir(projectRoot), ACCOUNTS_FILE_NAME);
274
+ const identityRoot = resolveProjectStorageIdentityRoot(projectRoot);
275
+ currentStoragePath = join(getProjectGlobalConfigDir(identityRoot), ACCOUNTS_FILE_NAME);
125
276
  currentLegacyProjectStoragePath = join(getProjectConfigDir(projectRoot), ACCOUNTS_FILE_NAME);
277
+ const previousWorktreeScopedPath = join(getProjectGlobalConfigDir(projectRoot), ACCOUNTS_FILE_NAME);
278
+ currentLegacyWorktreeStoragePath =
279
+ previousWorktreeScopedPath !== currentStoragePath ? previousWorktreeScopedPath : null;
126
280
  }
127
281
  else {
128
282
  currentStoragePath = null;
129
283
  currentLegacyProjectStoragePath = null;
284
+ currentLegacyWorktreeStoragePath = null;
130
285
  currentProjectRoot = null;
131
286
  }
132
287
  }
133
288
  export function setStoragePathDirect(path) {
134
289
  currentStoragePath = path;
135
290
  currentLegacyProjectStoragePath = null;
291
+ currentLegacyWorktreeStoragePath = null;
136
292
  currentProjectRoot = null;
137
293
  }
138
294
  /**
@@ -152,50 +308,108 @@ function getLegacyFlaggedAccountsPath() {
152
308
  return join(dirname(getStoragePath()), LEGACY_FLAGGED_ACCOUNTS_FILE_NAME);
153
309
  }
154
310
  async function migrateLegacyProjectStorageIfNeeded(persist = saveAccounts) {
155
- if (!currentStoragePath ||
156
- !currentLegacyProjectStoragePath ||
157
- currentLegacyProjectStoragePath === currentStoragePath ||
158
- !existsSync(currentLegacyProjectStoragePath)) {
311
+ if (!currentStoragePath) {
159
312
  return null;
160
313
  }
161
- try {
162
- const legacyContent = await fs.readFile(currentLegacyProjectStoragePath, "utf-8");
163
- const legacyData = JSON.parse(legacyContent);
164
- const normalized = normalizeAccountStorage(legacyData);
165
- if (!normalized)
166
- return null;
167
- await persist(normalized);
314
+ const candidatePaths = [currentLegacyWorktreeStoragePath, currentLegacyProjectStoragePath]
315
+ .filter((path) => typeof path === "string" && path.length > 0 && path !== currentStoragePath)
316
+ .filter((path, index, all) => all.indexOf(path) === index);
317
+ if (candidatePaths.length === 0) {
318
+ return null;
319
+ }
320
+ const existingCandidatePaths = candidatePaths.filter((legacyPath) => existsSync(legacyPath));
321
+ if (existingCandidatePaths.length === 0) {
322
+ return null;
323
+ }
324
+ let targetStorage = await loadNormalizedStorageFromPath(currentStoragePath, "current account storage");
325
+ let migrated = false;
326
+ for (const legacyPath of existingCandidatePaths) {
327
+ const legacyStorage = await loadNormalizedStorageFromPath(legacyPath, "legacy account storage");
328
+ if (!legacyStorage) {
329
+ continue;
330
+ }
331
+ const mergedStorage = mergeStorageForMigration(targetStorage, legacyStorage);
332
+ const fallbackStorage = targetStorage ?? legacyStorage;
333
+ try {
334
+ await persist(mergedStorage);
335
+ targetStorage = mergedStorage;
336
+ migrated = true;
337
+ }
338
+ catch (error) {
339
+ targetStorage = fallbackStorage;
340
+ log.warn("Failed to persist migrated account storage", {
341
+ from: legacyPath,
342
+ to: currentStoragePath,
343
+ error: String(error),
344
+ });
345
+ continue;
346
+ }
168
347
  try {
169
- await fs.unlink(currentLegacyProjectStoragePath);
170
- log.info("Removed legacy project account storage file after migration", {
171
- path: currentLegacyProjectStoragePath,
348
+ await fs.unlink(legacyPath);
349
+ log.info("Removed legacy account storage file after migration", {
350
+ path: legacyPath,
172
351
  });
173
352
  }
174
353
  catch (unlinkError) {
175
354
  const code = unlinkError.code;
176
355
  if (code !== "ENOENT") {
177
- log.warn("Failed to remove legacy project account storage file after migration", {
178
- path: currentLegacyProjectStoragePath,
356
+ log.warn("Failed to remove legacy account storage file after migration", {
357
+ path: legacyPath,
179
358
  error: String(unlinkError),
180
359
  });
181
360
  }
182
361
  }
183
362
  log.info("Migrated legacy project account storage", {
184
- from: currentLegacyProjectStoragePath,
363
+ from: legacyPath,
185
364
  to: currentStoragePath,
186
- accounts: normalized.accounts.length,
365
+ accounts: mergedStorage.accounts.length,
187
366
  });
367
+ }
368
+ if (migrated) {
369
+ return targetStorage;
370
+ }
371
+ if (targetStorage && !existsSync(currentStoragePath)) {
372
+ return targetStorage;
373
+ }
374
+ return null;
375
+ }
376
+ async function loadNormalizedStorageFromPath(path, label) {
377
+ try {
378
+ const { normalized, schemaErrors } = await loadAccountsFromPath(path);
379
+ if (schemaErrors.length > 0) {
380
+ log.warn(`${label} schema validation warnings`, {
381
+ path,
382
+ errors: schemaErrors.slice(0, 5),
383
+ });
384
+ }
188
385
  return normalized;
189
386
  }
190
387
  catch (error) {
191
- log.warn("Failed to migrate legacy project account storage", {
192
- from: currentLegacyProjectStoragePath,
193
- to: currentStoragePath,
194
- error: String(error),
195
- });
388
+ const code = error.code;
389
+ if (code !== "ENOENT") {
390
+ log.warn(`Failed to load ${label}`, {
391
+ path,
392
+ error: String(error),
393
+ });
394
+ }
196
395
  return null;
197
396
  }
198
397
  }
398
+ function mergeStorageForMigration(current, incoming) {
399
+ if (!current) {
400
+ return incoming;
401
+ }
402
+ const merged = normalizeAccountStorage({
403
+ version: 3,
404
+ activeIndex: current.activeIndex,
405
+ activeIndexByFamily: current.activeIndexByFamily,
406
+ accounts: [...current.accounts, ...incoming.accounts],
407
+ });
408
+ if (!merged) {
409
+ return current;
410
+ }
411
+ return merged;
412
+ }
199
413
  function selectNewestAccount(current, candidate) {
200
414
  if (!current)
201
415
  return candidate;
@@ -256,6 +470,14 @@ export function deduplicateAccounts(accounts) {
256
470
  * @param accounts - Array of accounts to deduplicate
257
471
  * @returns New array with email duplicates removed
258
472
  */
473
+ export function normalizeEmailKey(email) {
474
+ if (!email)
475
+ return undefined;
476
+ const trimmed = email.trim();
477
+ if (!trimmed)
478
+ return undefined;
479
+ return trimmed.toLowerCase();
480
+ }
259
481
  export function deduplicateAccountsByEmail(accounts) {
260
482
  const emailToNewestIndex = new Map();
261
483
  const indicesToKeep = new Set();
@@ -263,7 +485,7 @@ export function deduplicateAccountsByEmail(accounts) {
263
485
  const account = accounts[i];
264
486
  if (!account)
265
487
  continue;
266
- const email = account.email?.trim();
488
+ const email = normalizeEmailKey(account.email);
267
489
  if (!email) {
268
490
  indicesToKeep.add(i);
269
491
  continue;
@@ -447,8 +669,12 @@ async function loadAccountsFromJournal(path) {
447
669
  }
448
670
  }
449
671
  async function loadAccountsInternal(persistMigration) {
672
+ const path = getStoragePath();
673
+ await cleanupStaleRotatingBackupArtifacts(path);
674
+ const migratedLegacyStorage = persistMigration
675
+ ? await migrateLegacyProjectStorageIfNeeded(persistMigration)
676
+ : null;
450
677
  try {
451
- const path = getStoragePath();
452
678
  const { normalized, storedVersion, schemaErrors } = await loadAccountsFromPath(path);
453
679
  if (schemaErrors.length > 0) {
454
680
  log.warn("Account storage schema validation warnings", { errors: schemaErrors.slice(0, 5) });
@@ -468,11 +694,8 @@ async function loadAccountsInternal(persistMigration) {
468
694
  }
469
695
  catch (error) {
470
696
  const code = error.code;
471
- const path = getStoragePath();
472
- if (code === "ENOENT" && persistMigration) {
473
- const migrated = await migrateLegacyProjectStorageIfNeeded(persistMigration);
474
- if (migrated)
475
- return migrated;
697
+ if (code === "ENOENT" && migratedLegacyStorage) {
698
+ return migratedLegacyStorage;
476
699
  }
477
700
  const recoveredFromWal = await loadAccountsFromJournal(path);
478
701
  if (recoveredFromWal) {
@@ -490,38 +713,40 @@ async function loadAccountsInternal(persistMigration) {
490
713
  return recoveredFromWal;
491
714
  }
492
715
  if (storageBackupEnabled) {
493
- const backupPath = getAccountsBackupPath(path);
494
- try {
495
- const backup = await loadAccountsFromPath(backupPath);
496
- if (backup.schemaErrors.length > 0) {
497
- log.warn("Backup account storage schema validation warnings", {
498
- path: backupPath,
499
- errors: backup.schemaErrors.slice(0, 5),
500
- });
501
- }
502
- if (backup.normalized) {
503
- log.warn("Recovered account storage from backup file", { path, backupPath });
504
- if (persistMigration) {
505
- try {
506
- await persistMigration(backup.normalized);
507
- }
508
- catch (persistError) {
509
- log.warn("Failed to persist recovered backup storage", {
510
- path,
511
- error: String(persistError),
512
- });
716
+ const backupCandidates = getAccountsBackupRecoveryCandidates(path);
717
+ for (const backupPath of backupCandidates) {
718
+ try {
719
+ const backup = await loadAccountsFromPath(backupPath);
720
+ if (backup.schemaErrors.length > 0) {
721
+ log.warn("Backup account storage schema validation warnings", {
722
+ path: backupPath,
723
+ errors: backup.schemaErrors.slice(0, 5),
724
+ });
725
+ }
726
+ if (backup.normalized) {
727
+ log.warn("Recovered account storage from backup file", { path, backupPath });
728
+ if (persistMigration) {
729
+ try {
730
+ await persistMigration(backup.normalized);
731
+ }
732
+ catch (persistError) {
733
+ log.warn("Failed to persist recovered backup storage", {
734
+ path,
735
+ error: String(persistError),
736
+ });
737
+ }
513
738
  }
739
+ return backup.normalized;
514
740
  }
515
- return backup.normalized;
516
741
  }
517
- }
518
- catch (backupError) {
519
- const backupCode = backupError.code;
520
- if (backupCode !== "ENOENT") {
521
- log.warn("Failed to load backup account storage", {
522
- path: backupPath,
523
- error: String(backupError),
524
- });
742
+ catch (backupError) {
743
+ const backupCode = backupError.code;
744
+ if (backupCode !== "ENOENT") {
745
+ log.warn("Failed to load backup account storage", {
746
+ path: backupPath,
747
+ error: String(backupError),
748
+ });
749
+ }
525
750
  }
526
751
  }
527
752
  }
@@ -540,29 +765,13 @@ async function saveAccountsUnlocked(storage) {
540
765
  await fs.mkdir(dirname(path), { recursive: true });
541
766
  await ensureGitignore(path);
542
767
  if (storageBackupEnabled && existsSync(path)) {
543
- const backupPath = getAccountsBackupPath(path);
544
768
  try {
545
- for (let attempt = 0; attempt < BACKUP_COPY_MAX_ATTEMPTS; attempt += 1) {
546
- try {
547
- await fs.copyFile(path, backupPath);
548
- break;
549
- }
550
- catch (backupError) {
551
- const code = backupError.code;
552
- const canRetry = (code === "EPERM" || code === "EBUSY") &&
553
- attempt + 1 < BACKUP_COPY_MAX_ATTEMPTS;
554
- if (canRetry) {
555
- await new Promise((resolve) => setTimeout(resolve, BACKUP_COPY_BASE_DELAY_MS * 2 ** attempt));
556
- continue;
557
- }
558
- throw backupError;
559
- }
560
- }
769
+ await createRotatingAccountsBackup(path);
561
770
  }
562
771
  catch (backupError) {
563
772
  log.warn("Failed to create account storage backup", {
564
773
  path,
565
- backupPath,
774
+ backupPath: getAccountsBackupPath(path),
566
775
  error: String(backupError),
567
776
  });
568
777
  }
@@ -657,7 +866,7 @@ export async function clearAccounts() {
657
866
  return withStorageLock(async () => {
658
867
  const path = getStoragePath();
659
868
  const walPath = getAccountsWalPath(path);
660
- const backupPath = getAccountsBackupPath(path);
869
+ const backupPaths = getAccountsBackupRecoveryCandidates(path);
661
870
  const clearPath = async (targetPath) => {
662
871
  try {
663
872
  await fs.unlink(targetPath);
@@ -673,7 +882,7 @@ export async function clearAccounts() {
673
882
  }
674
883
  };
675
884
  try {
676
- await Promise.all([clearPath(path), clearPath(walPath), clearPath(backupPath)]);
885
+ await Promise.all([clearPath(path), clearPath(walPath), ...backupPaths.map(clearPath)]);
677
886
  }
678
887
  catch {
679
888
  // Individual path cleanup is already best-effort with per-artifact logging.