codex-multi-auth 0.1.7 → 0.1.9

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 (64) hide show
  1. package/LICENSE +21 -1
  2. package/README.md +16 -3
  3. package/assets/readme-hero.svg +31 -31
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +38 -65
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/accounts.d.ts.map +1 -1
  9. package/dist/lib/accounts.js +29 -10
  10. package/dist/lib/accounts.js.map +1 -1
  11. package/dist/lib/codex-cli/sync.d.ts +9 -16
  12. package/dist/lib/codex-cli/sync.d.ts.map +1 -1
  13. package/dist/lib/codex-cli/sync.js +51 -284
  14. package/dist/lib/codex-cli/sync.js.map +1 -1
  15. package/dist/lib/codex-manager/settings-hub.d.ts +44 -2
  16. package/dist/lib/codex-manager/settings-hub.d.ts.map +1 -1
  17. package/dist/lib/codex-manager/settings-hub.js +746 -117
  18. package/dist/lib/codex-manager/settings-hub.js.map +1 -1
  19. package/dist/lib/codex-manager.d.ts.map +1 -1
  20. package/dist/lib/codex-manager.js +240 -216
  21. package/dist/lib/codex-manager.js.map +1 -1
  22. package/dist/lib/context-overflow.js +10 -10
  23. package/dist/lib/entitlement-cache.d.ts +8 -5
  24. package/dist/lib/entitlement-cache.d.ts.map +1 -1
  25. package/dist/lib/entitlement-cache.js +24 -9
  26. package/dist/lib/entitlement-cache.js.map +1 -1
  27. package/dist/lib/named-backup-export.d.ts +69 -0
  28. package/dist/lib/named-backup-export.d.ts.map +1 -0
  29. package/dist/lib/named-backup-export.js +207 -0
  30. package/dist/lib/named-backup-export.js.map +1 -0
  31. package/dist/lib/oc-chatgpt-import-adapter.d.ts +64 -0
  32. package/dist/lib/oc-chatgpt-import-adapter.d.ts.map +1 -0
  33. package/dist/lib/oc-chatgpt-import-adapter.js +522 -0
  34. package/dist/lib/oc-chatgpt-import-adapter.js.map +1 -0
  35. package/dist/lib/oc-chatgpt-orchestrator.d.ts +80 -0
  36. package/dist/lib/oc-chatgpt-orchestrator.d.ts.map +1 -0
  37. package/dist/lib/oc-chatgpt-orchestrator.js +130 -0
  38. package/dist/lib/oc-chatgpt-orchestrator.js.map +1 -0
  39. package/dist/lib/oc-chatgpt-target-detection.d.ts +59 -0
  40. package/dist/lib/oc-chatgpt-target-detection.d.ts.map +1 -0
  41. package/dist/lib/oc-chatgpt-target-detection.js +327 -0
  42. package/dist/lib/oc-chatgpt-target-detection.js.map +1 -0
  43. package/dist/lib/prompts/codex.js +2 -2
  44. package/dist/lib/runtime-paths.d.ts.map +1 -1
  45. package/dist/lib/runtime-paths.js +21 -1
  46. package/dist/lib/runtime-paths.js.map +1 -1
  47. package/dist/lib/storage.d.ts +67 -16
  48. package/dist/lib/storage.d.ts.map +1 -1
  49. package/dist/lib/storage.js +797 -169
  50. package/dist/lib/storage.js.map +1 -1
  51. package/dist/lib/ui/copy.d.ts +15 -1
  52. package/dist/lib/ui/copy.d.ts.map +1 -1
  53. package/dist/lib/ui/copy.js +15 -1
  54. package/dist/lib/ui/copy.js.map +1 -1
  55. package/package.json +130 -131
  56. package/scripts/bench-format/hashline-v2.mjs +642 -642
  57. package/scripts/bench-format/render.mjs +496 -496
  58. package/scripts/bench-format/stats.mjs +54 -54
  59. package/scripts/bench-format/tasks.mjs +151 -151
  60. package/scripts/benchmark-render-dashboard.mjs +49 -49
  61. package/scripts/install-codex-auth.js +9 -9
  62. package/scripts/test-all-models.sh +7 -7
  63. package/scripts/test-model-matrix.js +138 -46
  64. package/scripts/validate-model-map.sh +7 -7
@@ -1,12 +1,14 @@
1
- import { promises as fs, existsSync } from "node:fs";
2
- import { basename, dirname, join } from "node:path";
1
+ import { AsyncLocalStorage } from "node:async_hooks";
3
2
  import { createHash } from "node:crypto";
3
+ import { existsSync, promises as fs } from "node:fs";
4
+ import { basename, dirname, join } from "node:path";
4
5
  import { ACCOUNT_LIMITS } from "./constants.js";
5
6
  import { createLogger } from "./logger.js";
7
+ import { exportNamedBackupFile, resolveNamedBackupPath, } from "./named-backup-export.js";
6
8
  import { MODEL_FAMILIES } from "./prompts/codex.js";
7
9
  import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js";
8
- import { getConfigDir, getProjectConfigDir, getProjectGlobalConfigDir, findProjectRoot, resolvePath, resolveProjectStorageIdentityRoot, } from "./storage/paths.js";
9
10
  import { migrateV1ToV3, } from "./storage/migrations.js";
11
+ import { findProjectRoot, getConfigDir, getProjectConfigDir, getProjectGlobalConfigDir, resolvePath, resolveProjectStorageIdentityRoot, } from "./storage/paths.js";
10
12
  const log = createLogger("storage");
11
13
  const ACCOUNTS_FILE_NAME = "openai-codex-accounts.json";
12
14
  const FLAGGED_ACCOUNTS_FILE_NAME = "openai-codex-flagged-accounts.json";
@@ -16,6 +18,7 @@ const ACCOUNTS_WAL_SUFFIX = ".wal";
16
18
  const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3;
17
19
  const BACKUP_COPY_MAX_ATTEMPTS = 5;
18
20
  const BACKUP_COPY_BASE_DELAY_MS = 10;
21
+ const RESET_MARKER_SUFFIX = ".reset-intent";
19
22
  let storageBackupEnabled = true;
20
23
  let lastAccountsSaveTimestamp = 0;
21
24
  /**
@@ -59,6 +62,7 @@ export function formatStorageErrorHint(error, path) {
59
62
  }
60
63
  }
61
64
  let storageMutex = Promise.resolve();
65
+ const transactionSnapshotContext = new AsyncLocalStorage();
62
66
  function withStorageLock(fn) {
63
67
  const previousMutex = storageMutex;
64
68
  let releaseLock;
@@ -69,8 +73,12 @@ function withStorageLock(fn) {
69
73
  }
70
74
  function looksLikeSyntheticFixtureAccount(account) {
71
75
  const email = typeof account.email === "string" ? account.email.trim().toLowerCase() : "";
72
- const refreshToken = typeof account.refreshToken === "string" ? account.refreshToken.trim().toLowerCase() : "";
73
- const accountId = typeof account.accountId === "string" ? account.accountId.trim().toLowerCase() : "";
76
+ const refreshToken = typeof account.refreshToken === "string"
77
+ ? account.refreshToken.trim().toLowerCase()
78
+ : "";
79
+ const accountId = typeof account.accountId === "string"
80
+ ? account.accountId.trim().toLowerCase()
81
+ : "";
74
82
  if (!/^account\d+@example\.com$/.test(email)) {
75
83
  return false;
76
84
  }
@@ -104,7 +112,10 @@ async function ensureGitignore(storagePath) {
104
112
  if (existsSync(gitignorePath)) {
105
113
  content = await fs.readFile(gitignorePath, "utf-8");
106
114
  const lines = content.split("\n").map((l) => l.trim());
107
- if (lines.includes(".codex") || lines.includes(".codex/") || lines.includes("/.codex") || lines.includes("/.codex/")) {
115
+ if (lines.includes(".codex") ||
116
+ lines.includes(".codex/") ||
117
+ lines.includes("/.codex") ||
118
+ lines.includes("/.codex/")) {
108
119
  return;
109
120
  }
110
121
  }
@@ -152,6 +163,10 @@ async function getAccountsBackupRecoveryCandidatesWithDiscovery(path) {
152
163
  continue;
153
164
  if (!entry.name.startsWith(candidatePrefix))
154
165
  continue;
166
+ if (isCacheLikeBackupArtifactName(entry.name))
167
+ continue;
168
+ if (entry.name.endsWith(RESET_MARKER_SUFFIX))
169
+ continue;
155
170
  if (entry.name.endsWith(".tmp"))
156
171
  continue;
157
172
  if (entry.name.includes(".rotate."))
@@ -232,7 +247,9 @@ async function createRotatingAccountsBackup(path) {
232
247
  continue;
233
248
  }
234
249
  const stagedPath = buildStagedPath(currentPath, `slot-${i}`);
235
- await copyFileWithRetry(previousPath, stagedPath, { allowMissingSource: true });
250
+ await copyFileWithRetry(previousPath, stagedPath, {
251
+ allowMissingSource: true,
252
+ });
236
253
  if (existsSync(stagedPath)) {
237
254
  stagedWrites.push({ targetPath: currentPath, stagedPath });
238
255
  }
@@ -244,7 +261,10 @@ async function createRotatingAccountsBackup(path) {
244
261
  const latestStagedPath = buildStagedPath(latestBackupPath, "latest");
245
262
  await copyFileWithRetry(path, latestStagedPath);
246
263
  if (existsSync(latestStagedPath)) {
247
- stagedWrites.push({ targetPath: latestBackupPath, stagedPath: latestStagedPath });
264
+ stagedWrites.push({
265
+ targetPath: latestBackupPath,
266
+ stagedPath: latestStagedPath,
267
+ });
248
268
  }
249
269
  for (const stagedWrite of stagedWrites) {
250
270
  await renameFileWithRetry(stagedWrite.stagedPath, stagedWrite.targetPath);
@@ -266,7 +286,8 @@ async function createRotatingAccountsBackup(path) {
266
286
  }
267
287
  function isRotatingBackupTempArtifact(storagePath, candidatePath) {
268
288
  const backupPrefix = `${storagePath}${ACCOUNTS_BACKUP_SUFFIX}`;
269
- if (!candidatePath.startsWith(backupPrefix) || !candidatePath.endsWith(".tmp")) {
289
+ if (!candidatePath.startsWith(backupPrefix) ||
290
+ !candidatePath.endsWith(".tmp")) {
270
291
  return false;
271
292
  }
272
293
  const suffix = candidatePath.slice(backupPrefix.length);
@@ -283,7 +304,9 @@ function isRotatingBackupTempArtifact(storagePath, candidatePath) {
283
304
  async function cleanupStaleRotatingBackupArtifacts(path) {
284
305
  const directoryPath = dirname(path);
285
306
  try {
286
- const directoryEntries = await fs.readdir(directoryPath, { withFileTypes: true });
307
+ const directoryEntries = await fs.readdir(directoryPath, {
308
+ withFileTypes: true,
309
+ });
287
310
  const staleArtifacts = directoryEntries
288
311
  .filter((entry) => entry.isFile())
289
312
  .map((entry) => join(directoryPath, entry.name))
@@ -316,6 +339,198 @@ async function cleanupStaleRotatingBackupArtifacts(path) {
316
339
  function computeSha256(value) {
317
340
  return createHash("sha256").update(value).digest("hex");
318
341
  }
342
+ function getIntentionalResetMarkerPath(path) {
343
+ return `${path}${RESET_MARKER_SUFFIX}`;
344
+ }
345
+ function createEmptyStorageWithMetadata(restoreEligible, restoreReason) {
346
+ return {
347
+ version: 3,
348
+ accounts: [],
349
+ activeIndex: 0,
350
+ activeIndexByFamily: {},
351
+ restoreEligible,
352
+ restoreReason,
353
+ };
354
+ }
355
+ function withRestoreMetadata(storage, restoreEligible, restoreReason) {
356
+ return {
357
+ ...storage,
358
+ restoreEligible,
359
+ restoreReason,
360
+ };
361
+ }
362
+ function isCacheLikeBackupArtifactName(entryName) {
363
+ return entryName.toLowerCase().includes(".cache");
364
+ }
365
+ async function statSnapshot(path) {
366
+ try {
367
+ const stats = await fs.stat(path);
368
+ return { exists: true, bytes: stats.size, mtimeMs: stats.mtimeMs };
369
+ }
370
+ catch (error) {
371
+ const code = error.code;
372
+ if (code !== "ENOENT") {
373
+ log.warn("Failed to stat backup candidate", {
374
+ path,
375
+ error: String(error),
376
+ });
377
+ }
378
+ return { exists: false };
379
+ }
380
+ }
381
+ async function describeAccountSnapshot(path, kind, index) {
382
+ const stats = await statSnapshot(path);
383
+ if (!stats.exists) {
384
+ return { kind, path, index, exists: false, valid: false };
385
+ }
386
+ try {
387
+ const { normalized, schemaErrors, storedVersion } = await loadAccountsFromPath(path);
388
+ return {
389
+ kind,
390
+ path,
391
+ index,
392
+ exists: true,
393
+ valid: !!normalized,
394
+ bytes: stats.bytes,
395
+ mtimeMs: stats.mtimeMs,
396
+ version: typeof storedVersion === "number" ? storedVersion : undefined,
397
+ accountCount: normalized?.accounts.length,
398
+ schemaErrors: schemaErrors.length > 0 ? schemaErrors : undefined,
399
+ };
400
+ }
401
+ catch (error) {
402
+ const code = error.code;
403
+ if (code !== "ENOENT") {
404
+ log.warn("Failed to inspect account snapshot", {
405
+ path,
406
+ error: String(error),
407
+ });
408
+ }
409
+ return {
410
+ kind,
411
+ path,
412
+ index,
413
+ exists: true,
414
+ valid: false,
415
+ bytes: stats.bytes,
416
+ mtimeMs: stats.mtimeMs,
417
+ };
418
+ }
419
+ }
420
+ async function describeAccountsWalSnapshot(path) {
421
+ const stats = await statSnapshot(path);
422
+ if (!stats.exists) {
423
+ return { kind: "accounts-wal", path, exists: false, valid: false };
424
+ }
425
+ try {
426
+ const raw = await fs.readFile(path, "utf-8");
427
+ const parsed = JSON.parse(raw);
428
+ if (!isRecord(parsed)) {
429
+ return {
430
+ kind: "accounts-wal",
431
+ path,
432
+ exists: true,
433
+ valid: false,
434
+ bytes: stats.bytes,
435
+ mtimeMs: stats.mtimeMs,
436
+ };
437
+ }
438
+ const entry = parsed;
439
+ if (entry.version !== 1 ||
440
+ typeof entry.content !== "string" ||
441
+ typeof entry.checksum !== "string" ||
442
+ computeSha256(entry.content) !== entry.checksum) {
443
+ return {
444
+ kind: "accounts-wal",
445
+ path,
446
+ exists: true,
447
+ valid: false,
448
+ bytes: stats.bytes,
449
+ mtimeMs: stats.mtimeMs,
450
+ };
451
+ }
452
+ const { normalized, storedVersion, schemaErrors } = parseAndNormalizeStorage(JSON.parse(entry.content));
453
+ return {
454
+ kind: "accounts-wal",
455
+ path,
456
+ exists: true,
457
+ valid: !!normalized,
458
+ bytes: stats.bytes,
459
+ mtimeMs: stats.mtimeMs,
460
+ version: typeof storedVersion === "number" ? storedVersion : undefined,
461
+ accountCount: normalized?.accounts.length,
462
+ schemaErrors: schemaErrors.length > 0 ? schemaErrors : undefined,
463
+ };
464
+ }
465
+ catch {
466
+ return {
467
+ kind: "accounts-wal",
468
+ path,
469
+ exists: true,
470
+ valid: false,
471
+ bytes: stats.bytes,
472
+ mtimeMs: stats.mtimeMs,
473
+ };
474
+ }
475
+ }
476
+ async function loadFlaggedAccountsFromPath(path) {
477
+ const content = await fs.readFile(path, "utf-8");
478
+ const data = JSON.parse(content);
479
+ return normalizeFlaggedStorage(data);
480
+ }
481
+ async function describeFlaggedSnapshot(path, kind, index) {
482
+ const stats = await statSnapshot(path);
483
+ if (!stats.exists) {
484
+ return { kind, path, index, exists: false, valid: false };
485
+ }
486
+ try {
487
+ const storage = await loadFlaggedAccountsFromPath(path);
488
+ return {
489
+ kind,
490
+ path,
491
+ index,
492
+ exists: true,
493
+ valid: true,
494
+ bytes: stats.bytes,
495
+ mtimeMs: stats.mtimeMs,
496
+ version: storage.version,
497
+ flaggedCount: storage.accounts.length,
498
+ };
499
+ }
500
+ catch (error) {
501
+ const code = error.code;
502
+ if (code !== "ENOENT") {
503
+ log.warn("Failed to inspect flagged snapshot", {
504
+ path,
505
+ error: String(error),
506
+ });
507
+ }
508
+ return {
509
+ kind,
510
+ path,
511
+ index,
512
+ exists: true,
513
+ valid: false,
514
+ bytes: stats.bytes,
515
+ mtimeMs: stats.mtimeMs,
516
+ };
517
+ }
518
+ }
519
+ function latestValidSnapshot(snapshots) {
520
+ return snapshots
521
+ .filter((snapshot) => snapshot.valid)
522
+ .sort((left, right) => (right.mtimeMs ?? 0) - (left.mtimeMs ?? 0))[0];
523
+ }
524
+ function buildMetadataSection(storagePath, snapshots) {
525
+ const latestValid = latestValidSnapshot(snapshots);
526
+ return {
527
+ storagePath,
528
+ latestValidPath: latestValid?.path,
529
+ snapshotCount: snapshots.length,
530
+ validSnapshotCount: snapshots.filter((snapshot) => snapshot.valid).length,
531
+ snapshots,
532
+ };
533
+ }
319
534
  export function getLastAccountsSaveTimestamp() {
320
535
  return lastAccountsSaveTimestamp;
321
536
  }
@@ -335,7 +550,9 @@ export function setStoragePath(projectPath) {
335
550
  currentLegacyProjectStoragePath = join(getProjectConfigDir(projectRoot), ACCOUNTS_FILE_NAME);
336
551
  const previousWorktreeScopedPath = join(getProjectGlobalConfigDir(projectRoot), ACCOUNTS_FILE_NAME);
337
552
  currentLegacyWorktreeStoragePath =
338
- previousWorktreeScopedPath !== currentStoragePath ? previousWorktreeScopedPath : null;
553
+ previousWorktreeScopedPath !== currentStoragePath
554
+ ? previousWorktreeScopedPath
555
+ : null;
339
556
  }
340
557
  else {
341
558
  currentStoragePath = null;
@@ -360,6 +577,15 @@ export function getStoragePath() {
360
577
  }
361
578
  return join(getConfigDir(), ACCOUNTS_FILE_NAME);
362
579
  }
580
+ export function buildNamedBackupPath(name) {
581
+ return resolveNamedBackupPath(name, getStoragePath());
582
+ }
583
+ export async function exportNamedBackup(name, options) {
584
+ return exportNamedBackupFile(name, {
585
+ getStoragePath,
586
+ exportAccounts,
587
+ }, options);
588
+ }
363
589
  export function getFlaggedAccountsPath() {
364
590
  return join(dirname(getStoragePath()), FLAGGED_ACCOUNTS_FILE_NAME);
365
591
  }
@@ -370,8 +596,13 @@ async function migrateLegacyProjectStorageIfNeeded(persist = saveAccounts) {
370
596
  if (!currentStoragePath) {
371
597
  return null;
372
598
  }
373
- const candidatePaths = [currentLegacyWorktreeStoragePath, currentLegacyProjectStoragePath]
374
- .filter((path) => typeof path === "string" && path.length > 0 && path !== currentStoragePath)
599
+ const candidatePaths = [
600
+ currentLegacyWorktreeStoragePath,
601
+ currentLegacyProjectStoragePath,
602
+ ]
603
+ .filter((path) => typeof path === "string" &&
604
+ path.length > 0 &&
605
+ path !== currentStoragePath)
375
606
  .filter((path, index, all) => all.indexOf(path) === index);
376
607
  if (candidatePaths.length === 0) {
377
608
  return null;
@@ -482,106 +713,224 @@ function selectNewestAccount(current, candidate) {
482
713
  const candidateAddedAt = candidate.addedAt || 0;
483
714
  return candidateAddedAt >= currentAddedAt ? candidate : current;
484
715
  }
485
- function deduplicateAccountsByKey(accounts) {
486
- const keyToIndex = new Map();
487
- const indicesToKeep = new Set();
716
+ function normalizeAccountIdKey(accountId) {
717
+ if (!accountId)
718
+ return undefined;
719
+ const trimmed = accountId.trim();
720
+ return trimmed || undefined;
721
+ }
722
+ /**
723
+ * Normalize email keys for case-insensitive account identity matching.
724
+ */
725
+ export function normalizeEmailKey(email) {
726
+ if (!email)
727
+ return undefined;
728
+ const trimmed = email.trim();
729
+ if (!trimmed)
730
+ return undefined;
731
+ return trimmed.toLowerCase();
732
+ }
733
+ function normalizeRefreshTokenKey(refreshToken) {
734
+ if (!refreshToken)
735
+ return undefined;
736
+ const trimmed = refreshToken.trim();
737
+ return trimmed || undefined;
738
+ }
739
+ function toAccountIdentityRef(account) {
740
+ return {
741
+ accountId: normalizeAccountIdKey(account?.accountId),
742
+ emailKey: normalizeEmailKey(account?.email),
743
+ refreshToken: normalizeRefreshTokenKey(account?.refreshToken),
744
+ };
745
+ }
746
+ function collectDistinctIdentityValues(values) {
747
+ const distinct = new Set();
748
+ for (const value of values) {
749
+ if (value)
750
+ distinct.add(value);
751
+ }
752
+ return distinct;
753
+ }
754
+ export function getAccountIdentityKey(account) {
755
+ const ref = toAccountIdentityRef(account);
756
+ if (ref.accountId && ref.emailKey) {
757
+ return `account:${ref.accountId}::email:${ref.emailKey}`;
758
+ }
759
+ if (ref.accountId)
760
+ return `account:${ref.accountId}`;
761
+ if (ref.emailKey)
762
+ return `email:${ref.emailKey}`;
763
+ if (ref.refreshToken)
764
+ return `refresh:${ref.refreshToken}`;
765
+ return undefined;
766
+ }
767
+ function findNewestMatchingIndex(accounts, predicate) {
768
+ let matchIndex;
769
+ let match;
488
770
  for (let i = 0; i < accounts.length; i += 1) {
489
771
  const account = accounts[i];
490
772
  if (!account)
491
773
  continue;
492
- const key = account.accountId || account.refreshToken;
493
- if (!key)
774
+ const ref = toAccountIdentityRef(account);
775
+ if (!predicate(ref))
494
776
  continue;
495
- const existingIndex = keyToIndex.get(key);
496
- if (existingIndex === undefined) {
497
- keyToIndex.set(key, i);
777
+ if (matchIndex === undefined) {
778
+ matchIndex = i;
779
+ match = account;
498
780
  continue;
499
781
  }
500
- const existing = accounts[existingIndex];
501
- const newest = selectNewestAccount(existing, account);
502
- keyToIndex.set(key, newest === account ? i : existingIndex);
503
- }
504
- for (const idx of keyToIndex.values()) {
505
- indicesToKeep.add(idx);
506
- }
507
- const result = [];
508
- for (let i = 0; i < accounts.length; i += 1) {
509
- if (indicesToKeep.has(i)) {
510
- const account = accounts[i];
511
- if (account)
512
- result.push(account);
782
+ const newest = selectNewestAccount(match, account);
783
+ if (newest === account) {
784
+ matchIndex = i;
785
+ match = account;
513
786
  }
514
787
  }
515
- return result;
788
+ return matchIndex;
516
789
  }
517
- /**
518
- * Removes duplicate accounts, keeping the most recently used entry for each unique key.
519
- * Deduplication is based on accountId or refreshToken.
520
- * @param accounts - Array of accounts to deduplicate
521
- * @returns New array with duplicates removed
522
- */
523
- export function deduplicateAccounts(accounts) {
524
- return deduplicateAccountsByKey(accounts);
790
+ function findCompositeAccountMatchIndex(accounts, candidateRef) {
791
+ if (!candidateRef.accountId || !candidateRef.emailKey)
792
+ return undefined;
793
+ return findNewestMatchingIndex(accounts, (ref) => ref.accountId === candidateRef.accountId &&
794
+ ref.emailKey === candidateRef.emailKey);
525
795
  }
526
- /**
527
- * Removes duplicate accounts by email, keeping the most recently used entry.
528
- * Accounts without email are always preserved.
529
- * @param accounts - Array of accounts to deduplicate
530
- * @returns New array with email duplicates removed
531
- */
532
- export function normalizeEmailKey(email) {
533
- if (!email)
796
+ function findSafeEmailMatchIndex(accounts, candidateRef) {
797
+ if (!candidateRef.emailKey)
534
798
  return undefined;
535
- const trimmed = email.trim();
536
- if (!trimmed)
799
+ const emailAccountIds = [candidateRef.accountId];
800
+ let foundAny = false;
801
+ for (let i = 0; i < accounts.length; i += 1) {
802
+ const account = accounts[i];
803
+ if (!account)
804
+ continue;
805
+ const ref = toAccountIdentityRef(account);
806
+ if (ref.emailKey !== candidateRef.emailKey)
807
+ continue;
808
+ foundAny = true;
809
+ emailAccountIds.push(ref.accountId);
810
+ }
811
+ if (!foundAny)
537
812
  return undefined;
538
- return trimmed.toLowerCase();
813
+ if (collectDistinctIdentityValues(emailAccountIds).size > 1) {
814
+ return undefined;
815
+ }
816
+ return findNewestMatchingIndex(accounts, (ref) => ref.emailKey === candidateRef.emailKey);
539
817
  }
540
- export function deduplicateAccountsByEmail(accounts) {
541
- const emailToNewestIndex = new Map();
542
- const indicesToKeep = new Set();
818
+ function findCompatibleRefreshTokenMatchIndex(accounts, candidateRef) {
819
+ if (!candidateRef.refreshToken)
820
+ return undefined;
821
+ let matchingIndex;
822
+ let matchingAccount = null;
543
823
  for (let i = 0; i < accounts.length; i += 1) {
544
824
  const account = accounts[i];
545
825
  if (!account)
546
826
  continue;
547
- const email = normalizeEmailKey(account.email);
548
- if (!email) {
549
- indicesToKeep.add(i);
827
+ const ref = toAccountIdentityRef(account);
828
+ if (ref.refreshToken !== candidateRef.refreshToken)
550
829
  continue;
830
+ if ((candidateRef.accountId &&
831
+ ref.accountId &&
832
+ ref.accountId !== candidateRef.accountId) ||
833
+ (candidateRef.emailKey &&
834
+ ref.emailKey &&
835
+ ref.emailKey !== candidateRef.emailKey)) {
836
+ return undefined;
551
837
  }
552
- const existingIndex = emailToNewestIndex.get(email);
553
- if (existingIndex === undefined) {
554
- emailToNewestIndex.set(email, i);
555
- continue;
838
+ if (matchingIndex !== undefined &&
839
+ !candidateRef.accountId &&
840
+ !candidateRef.emailKey) {
841
+ return undefined;
556
842
  }
557
- const existing = accounts[existingIndex];
558
- // istanbul ignore next -- defensive code: existingIndex always refers to valid account
559
- if (!existing) {
560
- emailToNewestIndex.set(email, i);
843
+ if (matchingIndex === undefined || matchingAccount === null) {
844
+ matchingIndex = i;
845
+ matchingAccount = account;
561
846
  continue;
562
847
  }
563
- const existingLastUsed = existing.lastUsed || 0;
564
- const candidateLastUsed = account.lastUsed || 0;
565
- const existingAddedAt = existing.addedAt || 0;
566
- const candidateAddedAt = account.addedAt || 0;
567
- const isNewer = candidateLastUsed > existingLastUsed ||
568
- (candidateLastUsed === existingLastUsed && candidateAddedAt > existingAddedAt);
569
- if (isNewer) {
570
- emailToNewestIndex.set(email, i);
848
+ const newest = selectNewestAccount(matchingAccount, account);
849
+ if (newest === account) {
850
+ matchingIndex = i;
851
+ matchingAccount = account;
571
852
  }
572
853
  }
573
- for (const idx of emailToNewestIndex.values()) {
574
- indicesToKeep.add(idx);
854
+ return matchingIndex;
855
+ }
856
+ function findUniqueAccountIdMatchIndex(accounts, candidateRef, options) {
857
+ if (!candidateRef.accountId)
858
+ return undefined;
859
+ if (!candidateRef.emailKey &&
860
+ !options.allowUniqueAccountIdFallbackWithoutEmail) {
861
+ return undefined;
575
862
  }
576
- const result = [];
863
+ let matchingIndex;
864
+ let matchingEmailKey;
577
865
  for (let i = 0; i < accounts.length; i += 1) {
578
- if (indicesToKeep.has(i)) {
579
- const account = accounts[i];
580
- if (account)
581
- result.push(account);
866
+ const account = accounts[i];
867
+ if (!account)
868
+ continue;
869
+ const ref = toAccountIdentityRef(account);
870
+ if (ref.accountId !== candidateRef.accountId)
871
+ continue;
872
+ if (matchingIndex !== undefined) {
873
+ return undefined;
874
+ }
875
+ matchingIndex = i;
876
+ matchingEmailKey = ref.emailKey;
877
+ }
878
+ if (matchingIndex !== undefined &&
879
+ matchingEmailKey &&
880
+ candidateRef.emailKey &&
881
+ matchingEmailKey !== candidateRef.emailKey) {
882
+ return undefined;
883
+ }
884
+ return matchingIndex;
885
+ }
886
+ export function findMatchingAccountIndex(accounts, candidate, options = {}) {
887
+ const candidateRef = toAccountIdentityRef(candidate);
888
+ const byComposite = findCompositeAccountMatchIndex(accounts, candidateRef);
889
+ if (byComposite !== undefined)
890
+ return byComposite;
891
+ const byEmail = findSafeEmailMatchIndex(accounts, candidateRef);
892
+ if (byEmail !== undefined)
893
+ return byEmail;
894
+ if (candidateRef.refreshToken) {
895
+ const byRefresh = findCompatibleRefreshTokenMatchIndex(accounts, candidateRef);
896
+ if (byRefresh !== undefined)
897
+ return byRefresh;
898
+ }
899
+ return findUniqueAccountIdMatchIndex(accounts, candidateRef, options);
900
+ }
901
+ export function resolveAccountSelectionIndex(accounts, candidate, fallbackIndex = 0) {
902
+ if (accounts.length === 0)
903
+ return 0;
904
+ const matchedIndex = findMatchingAccountIndex(accounts, candidate, {
905
+ allowUniqueAccountIdFallbackWithoutEmail: true,
906
+ });
907
+ if (matchedIndex !== undefined)
908
+ return matchedIndex;
909
+ return clampIndex(fallbackIndex, accounts.length);
910
+ }
911
+ function deduplicateAccountsByIdentity(accounts) {
912
+ const deduplicated = [];
913
+ for (const account of accounts) {
914
+ if (!account)
915
+ continue;
916
+ const existingIndex = findMatchingAccountIndex(deduplicated, account);
917
+ if (existingIndex === undefined) {
918
+ deduplicated.push(account);
919
+ continue;
582
920
  }
921
+ deduplicated[existingIndex] = selectNewestAccount(deduplicated[existingIndex], account);
583
922
  }
584
- return result;
923
+ return deduplicated;
924
+ }
925
+ /**
926
+ * Removes duplicate accounts, keeping the most recently used entry for each
927
+ * safely matched identity.
928
+ */
929
+ export function deduplicateAccounts(accounts) {
930
+ return deduplicateAccountsByIdentity(accounts);
931
+ }
932
+ export function deduplicateAccountsByEmail(accounts) {
933
+ return deduplicateAccountsByIdentity(accounts);
585
934
  }
586
935
  function isRecord(value) {
587
936
  return !!value && typeof value === "object" && !Array.isArray(value);
@@ -591,20 +940,17 @@ function clampIndex(index, length) {
591
940
  return 0;
592
941
  return Math.max(0, Math.min(index, length - 1));
593
942
  }
594
- function toAccountKey(account) {
595
- return account.accountId || account.refreshToken;
596
- }
597
- function extractActiveKey(accounts, activeIndex) {
943
+ function extractActiveAccountRef(accounts, activeIndex) {
598
944
  const candidate = accounts[activeIndex];
599
945
  if (!isRecord(candidate))
600
- return undefined;
601
- const accountId = typeof candidate.accountId === "string" && candidate.accountId.trim()
602
- ? candidate.accountId
603
- : undefined;
604
- const refreshToken = typeof candidate.refreshToken === "string" && candidate.refreshToken.trim()
605
- ? candidate.refreshToken
606
- : undefined;
607
- return accountId || refreshToken;
946
+ return {};
947
+ return toAccountIdentityRef({
948
+ accountId: typeof candidate.accountId === "string" ? candidate.accountId : undefined,
949
+ email: typeof candidate.email === "string" ? candidate.email : undefined,
950
+ refreshToken: typeof candidate.refreshToken === "string"
951
+ ? candidate.refreshToken
952
+ : undefined,
953
+ });
608
954
  }
609
955
  /**
610
956
  * Normalizes and validates account storage data, migrating from v1 to v3 if needed.
@@ -632,22 +978,23 @@ export function normalizeAccountStorage(data) {
632
978
  ? data.activeIndex
633
979
  : 0;
634
980
  const rawActiveIndex = clampIndex(activeIndexValue, rawAccounts.length);
635
- const activeKey = extractActiveKey(rawAccounts, rawActiveIndex);
981
+ const activeRef = extractActiveAccountRef(rawAccounts, rawActiveIndex);
636
982
  const fromVersion = data.version;
637
983
  const baseStorage = fromVersion === 1
638
984
  ? migrateV1ToV3(data)
639
985
  : data;
640
- const validAccounts = rawAccounts.filter((account) => isRecord(account) && typeof account.refreshToken === "string" && !!account.refreshToken.trim());
641
- const deduplicatedAccounts = deduplicateAccountsByEmail(deduplicateAccountsByKey(validAccounts));
986
+ const validAccounts = rawAccounts.filter((account) => isRecord(account) &&
987
+ typeof account.refreshToken === "string" &&
988
+ !!account.refreshToken.trim());
989
+ const deduplicatedAccounts = deduplicateAccounts(validAccounts);
642
990
  const activeIndex = (() => {
643
991
  if (deduplicatedAccounts.length === 0)
644
992
  return 0;
645
- if (activeKey) {
646
- const mappedIndex = deduplicatedAccounts.findIndex((account) => toAccountKey(account) === activeKey);
647
- if (mappedIndex >= 0)
648
- return mappedIndex;
649
- }
650
- return clampIndex(rawActiveIndex, deduplicatedAccounts.length);
993
+ return resolveAccountSelectionIndex(deduplicatedAccounts, {
994
+ accountId: activeRef.accountId,
995
+ email: activeRef.emailKey,
996
+ refreshToken: activeRef.refreshToken,
997
+ }, rawActiveIndex);
651
998
  })();
652
999
  const activeIndexByFamily = {};
653
1000
  const rawFamilyIndices = isRecord(baseStorage.activeIndexByFamily)
@@ -659,15 +1006,12 @@ export function normalizeAccountStorage(data) {
659
1006
  ? rawIndexValue
660
1007
  : rawActiveIndex;
661
1008
  const clampedRawIndex = clampIndex(rawIndex, rawAccounts.length);
662
- const familyKey = extractActiveKey(rawAccounts, clampedRawIndex);
663
- let mappedIndex = clampIndex(rawIndex, deduplicatedAccounts.length);
664
- if (familyKey && deduplicatedAccounts.length > 0) {
665
- const idx = deduplicatedAccounts.findIndex((account) => toAccountKey(account) === familyKey);
666
- if (idx >= 0) {
667
- mappedIndex = idx;
668
- }
669
- }
670
- activeIndexByFamily[family] = mappedIndex;
1009
+ const familyRef = extractActiveAccountRef(rawAccounts, clampedRawIndex);
1010
+ activeIndexByFamily[family] = resolveAccountSelectionIndex(deduplicatedAccounts, {
1011
+ accountId: familyRef.accountId,
1012
+ email: familyRef.emailKey,
1013
+ refreshToken: familyRef.refreshToken,
1014
+ }, rawIndex);
671
1015
  }
672
1016
  return {
673
1017
  version: 3,
@@ -684,10 +1028,88 @@ export function normalizeAccountStorage(data) {
684
1028
  export async function loadAccounts() {
685
1029
  return loadAccountsInternal(saveAccounts);
686
1030
  }
1031
+ export async function getBackupMetadata() {
1032
+ const storagePath = getStoragePath();
1033
+ const walPath = getAccountsWalPath(storagePath);
1034
+ const accountCandidates = await getAccountsBackupRecoveryCandidatesWithDiscovery(storagePath);
1035
+ const accountSnapshots = [
1036
+ await describeAccountSnapshot(storagePath, "accounts-primary"),
1037
+ await describeAccountsWalSnapshot(walPath),
1038
+ ];
1039
+ for (const [index, candidate] of accountCandidates.entries()) {
1040
+ const kind = candidate === `${storagePath}.bak`
1041
+ ? "accounts-backup"
1042
+ : candidate.startsWith(`${storagePath}.bak.`)
1043
+ ? "accounts-backup-history"
1044
+ : "accounts-discovered-backup";
1045
+ accountSnapshots.push(await describeAccountSnapshot(candidate, kind, index));
1046
+ }
1047
+ const flaggedPath = getFlaggedAccountsPath();
1048
+ const flaggedCandidates = await getAccountsBackupRecoveryCandidatesWithDiscovery(flaggedPath);
1049
+ const flaggedSnapshots = [
1050
+ await describeFlaggedSnapshot(flaggedPath, "flagged-primary"),
1051
+ ];
1052
+ for (const [index, candidate] of flaggedCandidates.entries()) {
1053
+ const kind = candidate === `${flaggedPath}.bak`
1054
+ ? "flagged-backup"
1055
+ : candidate.startsWith(`${flaggedPath}.bak.`)
1056
+ ? "flagged-backup-history"
1057
+ : "flagged-discovered-backup";
1058
+ flaggedSnapshots.push(await describeFlaggedSnapshot(candidate, kind, index));
1059
+ }
1060
+ return {
1061
+ accounts: buildMetadataSection(storagePath, accountSnapshots),
1062
+ flaggedAccounts: buildMetadataSection(flaggedPath, flaggedSnapshots),
1063
+ };
1064
+ }
1065
+ export async function getRestoreAssessment() {
1066
+ const storagePath = getStoragePath();
1067
+ const resetMarkerPath = getIntentionalResetMarkerPath(storagePath);
1068
+ const backupMetadata = await getBackupMetadata();
1069
+ if (existsSync(resetMarkerPath)) {
1070
+ return {
1071
+ storagePath,
1072
+ restoreEligible: false,
1073
+ restoreReason: "intentional-reset",
1074
+ backupMetadata,
1075
+ };
1076
+ }
1077
+ const primarySnapshot = backupMetadata.accounts.snapshots.find((snapshot) => snapshot.kind === "accounts-primary");
1078
+ if (!primarySnapshot?.exists) {
1079
+ return {
1080
+ storagePath,
1081
+ restoreEligible: true,
1082
+ restoreReason: "missing-storage",
1083
+ latestSnapshot: backupMetadata.accounts.latestValidPath
1084
+ ? backupMetadata.accounts.snapshots.find((snapshot) => snapshot.path === backupMetadata.accounts.latestValidPath)
1085
+ : undefined,
1086
+ backupMetadata,
1087
+ };
1088
+ }
1089
+ if (primarySnapshot.valid && primarySnapshot.accountCount === 0) {
1090
+ return {
1091
+ storagePath,
1092
+ restoreEligible: true,
1093
+ restoreReason: "empty-storage",
1094
+ latestSnapshot: primarySnapshot,
1095
+ backupMetadata,
1096
+ };
1097
+ }
1098
+ return {
1099
+ storagePath,
1100
+ restoreEligible: false,
1101
+ latestSnapshot: backupMetadata.accounts.latestValidPath
1102
+ ? backupMetadata.accounts.snapshots.find((snapshot) => snapshot.path === backupMetadata.accounts.latestValidPath)
1103
+ : undefined,
1104
+ backupMetadata,
1105
+ };
1106
+ }
687
1107
  function parseAndNormalizeStorage(data) {
688
1108
  const schemaErrors = getValidationErrors(AnyAccountStorageSchema, data);
689
1109
  const normalized = normalizeAccountStorage(data);
690
- const storedVersion = isRecord(data) ? data.version : undefined;
1110
+ const storedVersion = isRecord(data)
1111
+ ? data.version
1112
+ : undefined;
691
1113
  return { normalized, storedVersion, schemaErrors };
692
1114
  }
693
1115
  async function loadAccountsFromPath(path) {
@@ -697,8 +1119,15 @@ async function loadAccountsFromPath(path) {
697
1119
  }
698
1120
  async function loadAccountsFromJournal(path) {
699
1121
  const walPath = getAccountsWalPath(path);
1122
+ const resetMarkerPath = getIntentionalResetMarkerPath(path);
1123
+ if (existsSync(resetMarkerPath)) {
1124
+ return null;
1125
+ }
700
1126
  try {
701
1127
  const raw = await fs.readFile(walPath, "utf-8");
1128
+ if (existsSync(resetMarkerPath)) {
1129
+ return null;
1130
+ }
702
1131
  const parsed = JSON.parse(raw);
703
1132
  if (!isRecord(parsed))
704
1133
  return null;
@@ -722,13 +1151,17 @@ async function loadAccountsFromJournal(path) {
722
1151
  catch (error) {
723
1152
  const code = error.code;
724
1153
  if (code !== "ENOENT") {
725
- log.warn("Failed to load account WAL journal", { path: walPath, error: String(error) });
1154
+ log.warn("Failed to load account WAL journal", {
1155
+ path: walPath,
1156
+ error: String(error),
1157
+ });
726
1158
  }
727
1159
  return null;
728
1160
  }
729
1161
  }
730
1162
  async function loadAccountsInternal(persistMigration) {
731
1163
  const path = getStoragePath();
1164
+ const resetMarkerPath = getIntentionalResetMarkerPath(path);
732
1165
  await cleanupStaleRotatingBackupArtifacts(path);
733
1166
  const migratedLegacyStorage = persistMigration
734
1167
  ? await migrateLegacyProjectStorageIfNeeded(persistMigration)
@@ -736,19 +1169,32 @@ async function loadAccountsInternal(persistMigration) {
736
1169
  try {
737
1170
  const { normalized, storedVersion, schemaErrors } = await loadAccountsFromPath(path);
738
1171
  if (schemaErrors.length > 0) {
739
- log.warn("Account storage schema validation warnings", { errors: schemaErrors.slice(0, 5) });
1172
+ log.warn("Account storage schema validation warnings", {
1173
+ errors: schemaErrors.slice(0, 5),
1174
+ });
740
1175
  }
741
1176
  if (normalized && storedVersion !== normalized.version) {
742
- log.info("Migrating account storage to v3", { from: storedVersion, to: normalized.version });
1177
+ log.info("Migrating account storage to v3", {
1178
+ from: storedVersion,
1179
+ to: normalized.version,
1180
+ });
743
1181
  if (persistMigration) {
744
1182
  try {
745
1183
  await persistMigration(normalized);
746
1184
  }
747
1185
  catch (saveError) {
748
- log.warn("Failed to persist migrated storage", { error: String(saveError) });
1186
+ log.warn("Failed to persist migrated storage", {
1187
+ error: String(saveError),
1188
+ });
749
1189
  }
750
1190
  }
751
1191
  }
1192
+ if (existsSync(resetMarkerPath)) {
1193
+ return createEmptyStorageWithMetadata(false, "intentional-reset");
1194
+ }
1195
+ if (normalized && normalized.accounts.length === 0) {
1196
+ return withRestoreMetadata(normalized, true, "empty-storage");
1197
+ }
752
1198
  const primaryLooksSynthetic = looksLikeSyntheticFixtureStorage(normalized);
753
1199
  if (storageBackupEnabled && normalized && primaryLooksSynthetic) {
754
1200
  const backupCandidates = await getAccountsBackupRecoveryCandidatesWithDiscovery(path);
@@ -797,6 +1243,9 @@ async function loadAccountsInternal(persistMigration) {
797
1243
  }
798
1244
  catch (error) {
799
1245
  const code = error.code;
1246
+ if (existsSync(resetMarkerPath)) {
1247
+ return createEmptyStorageWithMetadata(false, "intentional-reset");
1248
+ }
800
1249
  if (code === "ENOENT" && migratedLegacyStorage) {
801
1250
  return migratedLegacyStorage;
802
1251
  }
@@ -815,6 +1264,9 @@ async function loadAccountsInternal(persistMigration) {
815
1264
  }
816
1265
  return recoveredFromWal;
817
1266
  }
1267
+ if (existsSync(resetMarkerPath)) {
1268
+ return createEmptyStorageWithMetadata(false, "intentional-reset");
1269
+ }
818
1270
  if (storageBackupEnabled) {
819
1271
  const backupCandidates = await getAccountsBackupRecoveryCandidatesWithDiscovery(path);
820
1272
  for (const backupPath of backupCandidates) {
@@ -827,7 +1279,10 @@ async function loadAccountsInternal(persistMigration) {
827
1279
  });
828
1280
  }
829
1281
  if (backup.normalized) {
830
- log.warn("Recovered account storage from backup file", { path, backupPath });
1282
+ log.warn("Recovered account storage from backup file", {
1283
+ path,
1284
+ backupPath,
1285
+ });
831
1286
  if (persistMigration) {
832
1287
  try {
833
1288
  await persistMigration(backup.normalized);
@@ -856,11 +1311,15 @@ async function loadAccountsInternal(persistMigration) {
856
1311
  if (code !== "ENOENT") {
857
1312
  log.error("Failed to load account storage", { error: String(error) });
858
1313
  }
1314
+ if (code === "ENOENT") {
1315
+ return createEmptyStorageWithMetadata(true, "missing-storage");
1316
+ }
859
1317
  return null;
860
1318
  }
861
1319
  }
862
1320
  async function saveAccountsUnlocked(storage) {
863
1321
  const path = getStoragePath();
1322
+ const resetMarkerPath = getIntentionalResetMarkerPath(path);
864
1323
  const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
865
1324
  const tempPath = `${path}.${uniqueSuffix}.tmp`;
866
1325
  const walPath = getAccountsWalPath(path);
@@ -870,7 +1329,9 @@ async function saveAccountsUnlocked(storage) {
870
1329
  if (looksLikeSyntheticFixtureStorage(storage)) {
871
1330
  try {
872
1331
  const existing = await loadNormalizedStorageFromPath(path, "existing account storage");
873
- if (existing && existing.accounts.length > 0 && !looksLikeSyntheticFixtureStorage(existing)) {
1332
+ if (existing &&
1333
+ existing.accounts.length > 0 &&
1334
+ !looksLikeSyntheticFixtureStorage(existing)) {
874
1335
  throw new StorageError("Refusing to overwrite non-synthetic account storage with synthetic fixture payload", "EINVALID", path, "Detected synthetic fixture-like account payload. Use explicit account import/login commands instead.");
875
1336
  }
876
1337
  }
@@ -916,6 +1377,12 @@ async function saveAccountsUnlocked(storage) {
916
1377
  for (let attempt = 0; attempt < 5; attempt++) {
917
1378
  try {
918
1379
  await fs.rename(tempPath, path);
1380
+ try {
1381
+ await fs.unlink(resetMarkerPath);
1382
+ }
1383
+ catch {
1384
+ // Best effort cleanup.
1385
+ }
919
1386
  lastAccountsSaveTimestamp = Date.now();
920
1387
  try {
921
1388
  await fs.unlink(walPath);
@@ -929,7 +1396,7 @@ async function saveAccountsUnlocked(storage) {
929
1396
  const code = renameError.code;
930
1397
  if (code === "EPERM" || code === "EBUSY") {
931
1398
  lastError = renameError;
932
- await new Promise(r => setTimeout(r, 10 * Math.pow(2, attempt)));
1399
+ await new Promise((r) => setTimeout(r, 10 * 2 ** attempt));
933
1400
  continue;
934
1401
  }
935
1402
  throw renameError;
@@ -957,10 +1424,63 @@ async function saveAccountsUnlocked(storage) {
957
1424
  throw new StorageError(`Failed to save accounts: ${err?.message || "Unknown error"}`, code, path, hint, err instanceof Error ? err : undefined);
958
1425
  }
959
1426
  }
1427
+ function cloneAccountStorageForPersistence(storage) {
1428
+ return {
1429
+ version: 3,
1430
+ accounts: structuredClone(storage?.accounts ?? []),
1431
+ activeIndex: typeof storage?.activeIndex === "number" &&
1432
+ Number.isFinite(storage.activeIndex)
1433
+ ? storage.activeIndex
1434
+ : 0,
1435
+ activeIndexByFamily: structuredClone(storage?.activeIndexByFamily ?? {}),
1436
+ };
1437
+ }
960
1438
  export async function withAccountStorageTransaction(handler) {
961
1439
  return withStorageLock(async () => {
962
- const current = await loadAccountsInternal(saveAccountsUnlocked);
963
- return handler(current, saveAccountsUnlocked);
1440
+ const state = {
1441
+ snapshot: await loadAccountsInternal(saveAccountsUnlocked),
1442
+ active: true,
1443
+ };
1444
+ const current = state.snapshot;
1445
+ const persist = async (storage) => {
1446
+ await saveAccountsUnlocked(storage);
1447
+ state.snapshot = storage;
1448
+ };
1449
+ return transactionSnapshotContext.run(state, () => handler(current, persist));
1450
+ });
1451
+ }
1452
+ export async function withAccountAndFlaggedStorageTransaction(handler) {
1453
+ return withStorageLock(async () => {
1454
+ const state = {
1455
+ snapshot: await loadAccountsInternal(saveAccountsUnlocked),
1456
+ active: true,
1457
+ };
1458
+ const current = state.snapshot;
1459
+ const persist = async (accountStorage, flaggedStorage) => {
1460
+ const previousAccounts = cloneAccountStorageForPersistence(state.snapshot);
1461
+ const nextAccounts = cloneAccountStorageForPersistence(accountStorage);
1462
+ await saveAccountsUnlocked(nextAccounts);
1463
+ try {
1464
+ await saveFlaggedAccountsUnlocked(flaggedStorage);
1465
+ state.snapshot = nextAccounts;
1466
+ }
1467
+ catch (error) {
1468
+ try {
1469
+ await saveAccountsUnlocked(previousAccounts);
1470
+ state.snapshot = previousAccounts;
1471
+ }
1472
+ catch (rollbackError) {
1473
+ const combinedError = new AggregateError([error, rollbackError], "Flagged save failed and account storage rollback also failed");
1474
+ log.error("Failed to rollback account storage after flagged save failure", {
1475
+ error: String(error),
1476
+ rollbackError: String(rollbackError),
1477
+ });
1478
+ throw combinedError;
1479
+ }
1480
+ throw error;
1481
+ }
1482
+ };
1483
+ return transactionSnapshotContext.run(state, () => handler(current, persist));
964
1484
  });
965
1485
  }
966
1486
  /**
@@ -982,8 +1502,10 @@ export async function saveAccounts(storage) {
982
1502
  export async function clearAccounts() {
983
1503
  return withStorageLock(async () => {
984
1504
  const path = getStoragePath();
1505
+ const resetMarkerPath = getIntentionalResetMarkerPath(path);
985
1506
  const walPath = getAccountsWalPath(path);
986
- const backupPaths = getAccountsBackupRecoveryCandidates(path);
1507
+ const backupPaths = await getAccountsBackupRecoveryCandidatesWithDiscovery(path);
1508
+ await fs.writeFile(resetMarkerPath, JSON.stringify({ version: 1, createdAt: Date.now() }), { encoding: "utf-8", mode: 0o600 });
987
1509
  const clearPath = async (targetPath) => {
988
1510
  try {
989
1511
  await fs.unlink(targetPath);
@@ -999,7 +1521,11 @@ export async function clearAccounts() {
999
1521
  }
1000
1522
  };
1001
1523
  try {
1002
- await Promise.all([clearPath(path), clearPath(walPath), ...backupPaths.map(clearPath)]);
1524
+ await Promise.all([
1525
+ clearPath(path),
1526
+ clearPath(walPath),
1527
+ ...backupPaths.map(clearPath),
1528
+ ]);
1003
1529
  }
1004
1530
  catch {
1005
1531
  // Individual path cleanup is already best-effort with per-artifact logging.
@@ -1014,13 +1540,22 @@ function normalizeFlaggedStorage(data) {
1014
1540
  for (const rawAccount of data.accounts) {
1015
1541
  if (!isRecord(rawAccount))
1016
1542
  continue;
1017
- const refreshToken = typeof rawAccount.refreshToken === "string" ? rawAccount.refreshToken.trim() : "";
1543
+ const refreshToken = typeof rawAccount.refreshToken === "string"
1544
+ ? rawAccount.refreshToken.trim()
1545
+ : "";
1018
1546
  if (!refreshToken)
1019
1547
  continue;
1020
- const flaggedAt = typeof rawAccount.flaggedAt === "number" ? rawAccount.flaggedAt : Date.now();
1021
- const isAccountIdSource = (value) => value === "token" || value === "id_token" || value === "org" || value === "manual";
1548
+ const flaggedAt = typeof rawAccount.flaggedAt === "number"
1549
+ ? rawAccount.flaggedAt
1550
+ : Date.now();
1551
+ const isAccountIdSource = (value) => value === "token" ||
1552
+ value === "id_token" ||
1553
+ value === "org" ||
1554
+ value === "manual";
1022
1555
  const isSwitchReason = (value) => value === "rate-limit" || value === "initial" || value === "rotation";
1023
- const isCooldownReason = (value) => value === "auth-failure" || value === "network-error" || value === "rate-limit";
1556
+ const isCooldownReason = (value) => value === "auth-failure" ||
1557
+ value === "network-error" ||
1558
+ value === "rate-limit";
1024
1559
  let rateLimitResetTimes;
1025
1560
  if (isRecord(rawAccount.rateLimitResetTimes)) {
1026
1561
  const normalizedRateLimits = {};
@@ -1045,19 +1580,33 @@ function normalizeFlaggedStorage(data) {
1045
1580
  const normalized = {
1046
1581
  refreshToken,
1047
1582
  addedAt: typeof rawAccount.addedAt === "number" ? rawAccount.addedAt : flaggedAt,
1048
- lastUsed: typeof rawAccount.lastUsed === "number" ? rawAccount.lastUsed : flaggedAt,
1049
- accountId: typeof rawAccount.accountId === "string" ? rawAccount.accountId : undefined,
1583
+ lastUsed: typeof rawAccount.lastUsed === "number"
1584
+ ? rawAccount.lastUsed
1585
+ : flaggedAt,
1586
+ accountId: typeof rawAccount.accountId === "string"
1587
+ ? rawAccount.accountId
1588
+ : undefined,
1050
1589
  accountIdSource,
1051
- accountLabel: typeof rawAccount.accountLabel === "string" ? rawAccount.accountLabel : undefined,
1590
+ accountLabel: typeof rawAccount.accountLabel === "string"
1591
+ ? rawAccount.accountLabel
1592
+ : undefined,
1052
1593
  email: typeof rawAccount.email === "string" ? rawAccount.email : undefined,
1053
- enabled: typeof rawAccount.enabled === "boolean" ? rawAccount.enabled : undefined,
1594
+ enabled: typeof rawAccount.enabled === "boolean"
1595
+ ? rawAccount.enabled
1596
+ : undefined,
1054
1597
  lastSwitchReason,
1055
1598
  rateLimitResetTimes,
1056
- coolingDownUntil: typeof rawAccount.coolingDownUntil === "number" ? rawAccount.coolingDownUntil : undefined,
1599
+ coolingDownUntil: typeof rawAccount.coolingDownUntil === "number"
1600
+ ? rawAccount.coolingDownUntil
1601
+ : undefined,
1057
1602
  cooldownReason,
1058
1603
  flaggedAt,
1059
- flaggedReason: typeof rawAccount.flaggedReason === "string" ? rawAccount.flaggedReason : undefined,
1060
- lastError: typeof rawAccount.lastError === "string" ? rawAccount.lastError : undefined,
1604
+ flaggedReason: typeof rawAccount.flaggedReason === "string"
1605
+ ? rawAccount.flaggedReason
1606
+ : undefined,
1607
+ lastError: typeof rawAccount.lastError === "string"
1608
+ ? rawAccount.lastError
1609
+ : undefined,
1061
1610
  };
1062
1611
  byRefreshToken.set(refreshToken, normalized);
1063
1612
  }
@@ -1068,16 +1617,24 @@ function normalizeFlaggedStorage(data) {
1068
1617
  }
1069
1618
  export async function loadFlaggedAccounts() {
1070
1619
  const path = getFlaggedAccountsPath();
1620
+ const resetMarkerPath = getIntentionalResetMarkerPath(path);
1071
1621
  const empty = { version: 1, accounts: [] };
1072
1622
  try {
1073
1623
  const content = await fs.readFile(path, "utf-8");
1074
1624
  const data = JSON.parse(content);
1075
- return normalizeFlaggedStorage(data);
1625
+ const loaded = normalizeFlaggedStorage(data);
1626
+ if (existsSync(resetMarkerPath)) {
1627
+ return empty;
1628
+ }
1629
+ return loaded;
1076
1630
  }
1077
1631
  catch (error) {
1078
1632
  const code = error.code;
1079
1633
  if (code !== "ENOENT") {
1080
- log.error("Failed to load flagged account storage", { path, error: String(error) });
1634
+ log.error("Failed to load flagged account storage", {
1635
+ path,
1636
+ error: String(error),
1637
+ });
1081
1638
  return empty;
1082
1639
  }
1083
1640
  }
@@ -1114,38 +1671,89 @@ export async function loadFlaggedAccounts() {
1114
1671
  return empty;
1115
1672
  }
1116
1673
  }
1117
- export async function saveFlaggedAccounts(storage) {
1118
- return withStorageLock(async () => {
1119
- const path = getFlaggedAccountsPath();
1120
- const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
1121
- const tempPath = `${path}.${uniqueSuffix}.tmp`;
1122
- try {
1123
- await fs.mkdir(dirname(path), { recursive: true });
1124
- const content = JSON.stringify(normalizeFlaggedStorage(storage), null, 2);
1125
- await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
1126
- await fs.rename(tempPath, path);
1127
- }
1128
- catch (error) {
1674
+ async function saveFlaggedAccountsUnlocked(storage) {
1675
+ const path = getFlaggedAccountsPath();
1676
+ const markerPath = getIntentionalResetMarkerPath(path);
1677
+ const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
1678
+ const tempPath = `${path}.${uniqueSuffix}.tmp`;
1679
+ try {
1680
+ await fs.mkdir(dirname(path), { recursive: true });
1681
+ if (existsSync(path)) {
1129
1682
  try {
1130
- await fs.unlink(tempPath);
1683
+ await copyFileWithRetry(path, `${path}.bak`, {
1684
+ allowMissingSource: true,
1685
+ });
1131
1686
  }
1132
- catch {
1133
- // Ignore cleanup failures.
1687
+ catch (backupError) {
1688
+ log.warn("Failed to create flagged backup snapshot", {
1689
+ path,
1690
+ error: String(backupError),
1691
+ });
1134
1692
  }
1135
- log.error("Failed to save flagged account storage", { path, error: String(error) });
1136
- throw error;
1137
1693
  }
1694
+ const content = JSON.stringify(normalizeFlaggedStorage(storage), null, 2);
1695
+ await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
1696
+ await renameFileWithRetry(tempPath, path);
1697
+ try {
1698
+ await fs.unlink(markerPath);
1699
+ }
1700
+ catch {
1701
+ // Best effort cleanup.
1702
+ }
1703
+ }
1704
+ catch (error) {
1705
+ try {
1706
+ await fs.unlink(tempPath);
1707
+ }
1708
+ catch {
1709
+ // Ignore cleanup failures.
1710
+ }
1711
+ log.error("Failed to save flagged account storage", {
1712
+ path,
1713
+ error: String(error),
1714
+ });
1715
+ throw error;
1716
+ }
1717
+ }
1718
+ export async function saveFlaggedAccounts(storage) {
1719
+ return withStorageLock(async () => {
1720
+ await saveFlaggedAccountsUnlocked(storage);
1138
1721
  });
1139
1722
  }
1140
1723
  export async function clearFlaggedAccounts() {
1141
1724
  return withStorageLock(async () => {
1725
+ const path = getFlaggedAccountsPath();
1726
+ const markerPath = getIntentionalResetMarkerPath(path);
1142
1727
  try {
1143
- await fs.unlink(getFlaggedAccountsPath());
1728
+ await fs.writeFile(markerPath, "reset", {
1729
+ encoding: "utf-8",
1730
+ mode: 0o600,
1731
+ });
1144
1732
  }
1145
1733
  catch (error) {
1146
- const code = error.code;
1147
- if (code !== "ENOENT") {
1148
- log.error("Failed to clear flagged account storage", { error: String(error) });
1734
+ log.error("Failed to write flagged reset marker", {
1735
+ path,
1736
+ markerPath,
1737
+ error: String(error),
1738
+ });
1739
+ throw error;
1740
+ }
1741
+ const backupPaths = await getAccountsBackupRecoveryCandidatesWithDiscovery(path);
1742
+ for (const candidate of [path, ...backupPaths, markerPath]) {
1743
+ try {
1744
+ await fs.unlink(candidate);
1745
+ }
1746
+ catch (error) {
1747
+ const code = error.code;
1748
+ if (code !== "ENOENT") {
1749
+ log.error("Failed to clear flagged account storage", {
1750
+ path: candidate,
1751
+ error: String(error),
1752
+ });
1753
+ if (candidate === path) {
1754
+ throw error;
1755
+ }
1756
+ }
1149
1757
  }
1150
1758
  }
1151
1759
  });
@@ -1156,23 +1764,38 @@ export async function clearFlaggedAccounts() {
1156
1764
  * @param force - If true, overwrite existing file (default: true)
1157
1765
  * @throws Error if file exists and force is false, or if no accounts to export
1158
1766
  */
1159
- export async function exportAccounts(filePath, force = true) {
1767
+ export async function exportAccounts(filePath, force = true, beforeCommit) {
1160
1768
  const resolvedPath = resolvePath(filePath);
1161
1769
  if (!force && existsSync(resolvedPath)) {
1162
1770
  throw new Error(`File already exists: ${resolvedPath}`);
1163
1771
  }
1164
- const storage = await withAccountStorageTransaction((current) => Promise.resolve(current));
1772
+ const transactionState = transactionSnapshotContext.getStore();
1773
+ const storage = transactionState?.active
1774
+ ? transactionState.snapshot
1775
+ : await withAccountStorageTransaction((current) => Promise.resolve(current));
1165
1776
  if (!storage || storage.accounts.length === 0) {
1166
1777
  throw new Error("No accounts to export");
1167
1778
  }
1168
1779
  await fs.mkdir(dirname(resolvedPath), { recursive: true });
1169
- const content = JSON.stringify(storage, null, 2);
1780
+ await beforeCommit?.(resolvedPath);
1781
+ if (!force && existsSync(resolvedPath)) {
1782
+ throw new Error(`File already exists: ${resolvedPath}`);
1783
+ }
1784
+ const content = JSON.stringify({
1785
+ version: storage.version,
1786
+ accounts: storage.accounts,
1787
+ activeIndex: storage.activeIndex,
1788
+ activeIndexByFamily: storage.activeIndexByFamily,
1789
+ }, null, 2);
1170
1790
  await fs.writeFile(resolvedPath, content, { encoding: "utf-8", mode: 0o600 });
1171
- log.info("Exported accounts", { path: resolvedPath, count: storage.accounts.length });
1791
+ log.info("Exported accounts", {
1792
+ path: resolvedPath,
1793
+ count: storage.accounts.length,
1794
+ });
1172
1795
  }
1173
1796
  /**
1174
1797
  * Imports accounts from a JSON file, merging with existing accounts.
1175
- * Deduplicates by accountId/email, preserving most recently used entries.
1798
+ * Deduplicates by safe account identity, preserving most recently used entries.
1176
1799
  * @param filePath - Source file path
1177
1800
  * @throws Error if file is invalid or would exceed MAX_ACCOUNTS
1178
1801
  */
@@ -1194,17 +1817,17 @@ export async function importAccounts(filePath) {
1194
1817
  if (!normalized) {
1195
1818
  throw new Error("Invalid account storage format");
1196
1819
  }
1197
- const { imported: importedCount, total, skipped: skippedCount } = await withAccountStorageTransaction(async (existing, persist) => {
1820
+ const { imported: importedCount, total, skipped: skippedCount, } = await withAccountStorageTransaction(async (existing, persist) => {
1198
1821
  const existingAccounts = existing?.accounts ?? [];
1199
1822
  const existingActiveIndex = existing?.activeIndex ?? 0;
1200
1823
  const merged = [...existingAccounts, ...normalized.accounts];
1201
1824
  if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) {
1202
- const deduped = deduplicateAccountsByEmail(deduplicateAccounts(merged));
1825
+ const deduped = deduplicateAccounts(merged);
1203
1826
  if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) {
1204
1827
  throw new Error(`Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduped.length})`);
1205
1828
  }
1206
1829
  }
1207
- const deduplicatedAccounts = deduplicateAccountsByEmail(deduplicateAccounts(merged));
1830
+ const deduplicatedAccounts = deduplicateAccounts(merged);
1208
1831
  const newStorage = {
1209
1832
  version: 3,
1210
1833
  accounts: deduplicatedAccounts,
@@ -1216,7 +1839,12 @@ export async function importAccounts(filePath) {
1216
1839
  const skipped = normalized.accounts.length - imported;
1217
1840
  return { imported, total: deduplicatedAccounts.length, skipped };
1218
1841
  });
1219
- log.info("Imported accounts", { path: resolvedPath, imported: importedCount, skipped: skippedCount, total });
1842
+ log.info("Imported accounts", {
1843
+ path: resolvedPath,
1844
+ imported: importedCount,
1845
+ skipped: skippedCount,
1846
+ total,
1847
+ });
1220
1848
  return { imported: importedCount, total, skipped: skippedCount };
1221
1849
  }
1222
1850
  //# sourceMappingURL=storage.js.map