oc-chatgpt-multi-auth 5.3.2 → 5.3.4

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 (40) hide show
  1. package/README.md +198 -85
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1623 -50
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/accounts.d.ts +16 -0
  6. package/dist/lib/accounts.d.ts.map +1 -1
  7. package/dist/lib/accounts.js +60 -0
  8. package/dist/lib/accounts.js.map +1 -1
  9. package/dist/lib/config.d.ts +4 -0
  10. package/dist/lib/config.d.ts.map +1 -1
  11. package/dist/lib/config.js +36 -0
  12. package/dist/lib/config.js.map +1 -1
  13. package/dist/lib/refresh-queue.d.ts +16 -0
  14. package/dist/lib/refresh-queue.d.ts.map +1 -1
  15. package/dist/lib/refresh-queue.js +46 -0
  16. package/dist/lib/refresh-queue.js.map +1 -1
  17. package/dist/lib/request/retry-budget.d.ts +19 -0
  18. package/dist/lib/request/retry-budget.d.ts.map +1 -0
  19. package/dist/lib/request/retry-budget.js +99 -0
  20. package/dist/lib/request/retry-budget.js.map +1 -0
  21. package/dist/lib/schemas.d.ts +26 -0
  22. package/dist/lib/schemas.d.ts.map +1 -1
  23. package/dist/lib/schemas.js +28 -0
  24. package/dist/lib/schemas.js.map +1 -1
  25. package/dist/lib/storage/migrations.d.ts +4 -0
  26. package/dist/lib/storage/migrations.d.ts.map +1 -1
  27. package/dist/lib/storage/migrations.js +2 -0
  28. package/dist/lib/storage/migrations.js.map +1 -1
  29. package/dist/lib/storage.d.ts +31 -5
  30. package/dist/lib/storage.d.ts.map +1 -1
  31. package/dist/lib/storage.js +354 -71
  32. package/dist/lib/storage.js.map +1 -1
  33. package/dist/lib/ui/auth-menu.d.ts.map +1 -1
  34. package/dist/lib/ui/auth-menu.js +23 -5
  35. package/dist/lib/ui/auth-menu.js.map +1 -1
  36. package/dist/lib/ui/beginner.d.ts +57 -0
  37. package/dist/lib/ui/beginner.d.ts.map +1 -0
  38. package/dist/lib/ui/beginner.js +230 -0
  39. package/dist/lib/ui/beginner.js.map +1 -0
  40. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { promises as fs, existsSync } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
2
3
  import { dirname, join } from "node:path";
3
4
  import { ACCOUNT_LIMITS } from "./constants.js";
4
5
  import { createLogger } from "./logger.js";
@@ -59,6 +60,73 @@ function withStorageLock(fn) {
59
60
  });
60
61
  return previousMutex.then(fn).finally(() => releaseLock());
61
62
  }
63
+ const WINDOWS_RENAME_RETRY_ATTEMPTS = 5;
64
+ const WINDOWS_RENAME_RETRY_BASE_DELAY_MS = 10;
65
+ const PRE_IMPORT_BACKUP_WRITE_TIMEOUT_MS = 3_000;
66
+ function isWindowsLockError(error) {
67
+ const code = error?.code;
68
+ return code === "EPERM" || code === "EBUSY";
69
+ }
70
+ async function renameWithWindowsRetry(sourcePath, destinationPath) {
71
+ let lastError = null;
72
+ for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
73
+ try {
74
+ await fs.rename(sourcePath, destinationPath);
75
+ return;
76
+ }
77
+ catch (error) {
78
+ if (isWindowsLockError(error)) {
79
+ lastError = error;
80
+ await new Promise((resolve) => setTimeout(resolve, WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt));
81
+ continue;
82
+ }
83
+ throw error;
84
+ }
85
+ }
86
+ if (lastError) {
87
+ throw lastError;
88
+ }
89
+ }
90
+ async function writeFileWithTimeout(filePath, content, timeoutMs) {
91
+ const controller = new AbortController();
92
+ const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
93
+ try {
94
+ await fs.writeFile(filePath, content, {
95
+ encoding: "utf-8",
96
+ mode: 0o600,
97
+ signal: controller.signal,
98
+ });
99
+ }
100
+ catch (error) {
101
+ if (error instanceof Error && error.name === "AbortError") {
102
+ const timeoutError = Object.assign(new Error(`Timed out writing file after ${timeoutMs}ms`), { code: "ETIMEDOUT" });
103
+ throw timeoutError;
104
+ }
105
+ throw error;
106
+ }
107
+ finally {
108
+ clearTimeout(timeoutHandle);
109
+ }
110
+ }
111
+ async function writePreImportBackupFile(backupPath, snapshot) {
112
+ const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
113
+ const tempPath = `${backupPath}.${uniqueSuffix}.tmp`;
114
+ try {
115
+ await fs.mkdir(dirname(backupPath), { recursive: true });
116
+ const backupContent = JSON.stringify(snapshot, null, 2);
117
+ await writeFileWithTimeout(tempPath, backupContent, PRE_IMPORT_BACKUP_WRITE_TIMEOUT_MS);
118
+ await renameWithWindowsRetry(tempPath, backupPath);
119
+ }
120
+ catch (error) {
121
+ try {
122
+ await fs.unlink(tempPath);
123
+ }
124
+ catch {
125
+ // Best effort temp-file cleanup.
126
+ }
127
+ throw error;
128
+ }
129
+ }
62
130
  async function ensureGitignore(storagePath) {
63
131
  if (!currentStoragePath)
64
132
  return;
@@ -219,6 +287,109 @@ function deduplicateAccountsByKey(accounts) {
219
287
  }
220
288
  return result;
221
289
  }
290
+ function pickNewestAccountIndex(accounts, existingIndex, candidateIndex) {
291
+ const existing = accounts[existingIndex];
292
+ const candidate = accounts[candidateIndex];
293
+ if (!existing)
294
+ return candidateIndex;
295
+ if (!candidate)
296
+ return existingIndex;
297
+ const newest = selectNewestAccount(existing, candidate);
298
+ return newest === candidate ? candidateIndex : existingIndex;
299
+ }
300
+ function mergeAccountRecords(target, source) {
301
+ const newest = selectNewestAccount(target, source);
302
+ const older = newest === target ? source : target;
303
+ return {
304
+ ...older,
305
+ ...newest,
306
+ organizationId: target.organizationId ?? source.organizationId,
307
+ accountId: target.accountId ?? source.accountId,
308
+ accountIdSource: target.accountIdSource ?? source.accountIdSource,
309
+ accountLabel: target.accountLabel ?? source.accountLabel,
310
+ email: target.email ?? source.email,
311
+ };
312
+ }
313
+ function deduplicateAccountsByRefreshToken(accounts) {
314
+ const working = [...accounts];
315
+ const indicesToRemove = new Set();
316
+ const refreshMap = new Map();
317
+ for (let i = 0; i < working.length; i += 1) {
318
+ const account = working[i];
319
+ if (!account)
320
+ continue;
321
+ const refreshToken = account.refreshToken?.trim();
322
+ if (!refreshToken)
323
+ continue;
324
+ const orgKey = account.organizationId?.trim() ?? "";
325
+ let entry = refreshMap.get(refreshToken);
326
+ if (!entry) {
327
+ entry = { byOrg: new Map(), fallbackIndex: undefined };
328
+ refreshMap.set(refreshToken, entry);
329
+ }
330
+ if (orgKey) {
331
+ const existingIndex = entry.byOrg.get(orgKey);
332
+ if (existingIndex !== undefined) {
333
+ const newestIndex = pickNewestAccountIndex(working, existingIndex, i);
334
+ const obsoleteIndex = newestIndex === existingIndex ? i : existingIndex;
335
+ const target = working[newestIndex];
336
+ const source = working[obsoleteIndex];
337
+ if (target && source) {
338
+ working[newestIndex] = mergeAccountRecords(target, source);
339
+ }
340
+ indicesToRemove.add(obsoleteIndex);
341
+ entry.byOrg.set(orgKey, newestIndex);
342
+ continue;
343
+ }
344
+ entry.byOrg.set(orgKey, i);
345
+ continue;
346
+ }
347
+ const existingFallback = entry.fallbackIndex;
348
+ if (typeof existingFallback === "number") {
349
+ const newestIndex = pickNewestAccountIndex(working, existingFallback, i);
350
+ const obsoleteIndex = newestIndex === existingFallback ? i : existingFallback;
351
+ const target = working[newestIndex];
352
+ const source = working[obsoleteIndex];
353
+ if (target && source) {
354
+ working[newestIndex] = mergeAccountRecords(target, source);
355
+ }
356
+ indicesToRemove.add(obsoleteIndex);
357
+ entry.fallbackIndex = newestIndex;
358
+ continue;
359
+ }
360
+ entry.fallbackIndex = i;
361
+ }
362
+ for (const entry of refreshMap.values()) {
363
+ const fallbackIndex = entry.fallbackIndex;
364
+ if (typeof fallbackIndex !== "number")
365
+ continue;
366
+ const orgIndices = Array.from(entry.byOrg.values());
367
+ if (orgIndices.length === 0)
368
+ continue;
369
+ const [firstOrgIndex, ...otherOrgIndices] = orgIndices;
370
+ if (typeof firstOrgIndex !== "number")
371
+ continue;
372
+ let preferredOrgIndex = firstOrgIndex;
373
+ for (const orgIndex of otherOrgIndices) {
374
+ preferredOrgIndex = pickNewestAccountIndex(working, preferredOrgIndex, orgIndex);
375
+ }
376
+ const preferredOrg = working[preferredOrgIndex];
377
+ const fallback = working[fallbackIndex];
378
+ if (preferredOrg && fallback) {
379
+ working[preferredOrgIndex] = mergeAccountRecords(preferredOrg, fallback);
380
+ }
381
+ indicesToRemove.add(fallbackIndex);
382
+ }
383
+ const result = [];
384
+ for (let i = 0; i < working.length; i += 1) {
385
+ if (indicesToRemove.has(i))
386
+ continue;
387
+ const account = working[i];
388
+ if (account)
389
+ result.push(account);
390
+ }
391
+ return result;
392
+ }
222
393
  /**
223
394
  * Removes duplicate accounts, keeping the most recently used entry for each unique key.
224
395
  * Deduplication identity hierarchy: organizationId -> accountId -> refreshToken.
@@ -234,7 +405,7 @@ export function deduplicateAccounts(accounts) {
234
405
  * 2) Then apply legacy email dedupe only for entries that still do not have organizationId/accountId.
235
406
  */
236
407
  function deduplicateAccountsForStorage(accounts) {
237
- return deduplicateAccountsByEmail(deduplicateAccountsByKey(accounts));
408
+ return deduplicateAccountsByRefreshToken(deduplicateAccountsByEmail(deduplicateAccountsByKey(accounts)));
238
409
  }
239
410
  /**
240
411
  * Removes duplicate legacy accounts by email, keeping the most recently used entry.
@@ -307,35 +478,46 @@ function clampIndex(index, length) {
307
478
  return 0;
308
479
  return Math.max(0, Math.min(index, length - 1));
309
480
  }
310
- function toAccountKey(account) {
311
- const key = toAccountIdentityKey(account);
312
- return key || "";
313
- }
314
- function toAccountIdentityKey(account) {
481
+ function toAccountIdentityKeys(account) {
482
+ const keys = [];
315
483
  const organizationId = typeof account.organizationId === "string" ? account.organizationId.trim() : "";
316
484
  if (organizationId) {
317
- return `organizationId:${organizationId}`;
485
+ keys.push(`organizationId:${organizationId}`);
318
486
  }
319
487
  const accountId = typeof account.accountId === "string" ? account.accountId.trim() : "";
320
488
  if (accountId) {
321
- return `accountId:${accountId}`;
489
+ keys.push(`accountId:${accountId}`);
322
490
  }
323
491
  const refreshToken = typeof account.refreshToken === "string" ? account.refreshToken.trim() : "";
324
492
  if (refreshToken) {
325
- return `refreshToken:${refreshToken}`;
493
+ keys.push(`refreshToken:${refreshToken}`);
326
494
  }
327
- return undefined;
495
+ return keys;
496
+ }
497
+ function toAccountIdentityKey(account) {
498
+ return toAccountIdentityKeys(account)[0];
328
499
  }
329
- function extractActiveKey(accounts, activeIndex) {
500
+ function extractActiveKeys(accounts, activeIndex) {
330
501
  const candidate = accounts[activeIndex];
331
502
  if (!isRecord(candidate))
332
- return undefined;
333
- return toAccountIdentityKey({
503
+ return [];
504
+ return toAccountIdentityKeys({
334
505
  organizationId: typeof candidate.organizationId === "string" ? candidate.organizationId : undefined,
335
506
  accountId: typeof candidate.accountId === "string" ? candidate.accountId : undefined,
336
507
  refreshToken: typeof candidate.refreshToken === "string" ? candidate.refreshToken : "",
337
508
  });
338
509
  }
510
+ function findAccountIndexByIdentityKeys(accounts, identityKeys) {
511
+ if (identityKeys.length === 0)
512
+ return -1;
513
+ for (const identityKey of identityKeys) {
514
+ const idx = accounts.findIndex((account) => toAccountIdentityKeys(account).includes(identityKey));
515
+ if (idx >= 0) {
516
+ return idx;
517
+ }
518
+ }
519
+ return -1;
520
+ }
339
521
  /**
340
522
  * Normalizes and validates account storage data, migrating from v1 to v3 if needed.
341
523
  * Handles deduplication, index clamping, and per-family active index mapping.
@@ -362,7 +544,7 @@ export function normalizeAccountStorage(data) {
362
544
  ? data.activeIndex
363
545
  : 0;
364
546
  const rawActiveIndex = clampIndex(activeIndexValue, rawAccounts.length);
365
- const activeKey = extractActiveKey(rawAccounts, rawActiveIndex);
547
+ const activeKeys = extractActiveKeys(rawAccounts, rawActiveIndex);
366
548
  const fromVersion = data.version;
367
549
  const baseStorage = fromVersion === 1
368
550
  ? migrateV1ToV3(data)
@@ -372,8 +554,8 @@ export function normalizeAccountStorage(data) {
372
554
  const activeIndex = (() => {
373
555
  if (deduplicatedAccounts.length === 0)
374
556
  return 0;
375
- if (activeKey) {
376
- const mappedIndex = deduplicatedAccounts.findIndex((account) => toAccountKey(account) === activeKey);
557
+ if (activeKeys.length > 0) {
558
+ const mappedIndex = findAccountIndexByIdentityKeys(deduplicatedAccounts, activeKeys);
377
559
  if (mappedIndex >= 0)
378
560
  return mappedIndex;
379
561
  }
@@ -389,10 +571,10 @@ export function normalizeAccountStorage(data) {
389
571
  ? rawIndexValue
390
572
  : rawActiveIndex;
391
573
  const clampedRawIndex = clampIndex(rawIndex, rawAccounts.length);
392
- const familyKey = extractActiveKey(rawAccounts, clampedRawIndex);
574
+ const familyKeys = extractActiveKeys(rawAccounts, clampedRawIndex);
393
575
  let mappedIndex = clampIndex(rawIndex, deduplicatedAccounts.length);
394
- if (familyKey && deduplicatedAccounts.length > 0) {
395
- const idx = deduplicatedAccounts.findIndex((account) => toAccountKey(account) === familyKey);
576
+ if (familyKeys.length > 0 && deduplicatedAccounts.length > 0) {
577
+ const idx = findAccountIndexByIdentityKeys(deduplicatedAccounts, familyKeys);
396
578
  if (idx >= 0) {
397
579
  mappedIndex = idx;
398
580
  }
@@ -459,32 +641,17 @@ async function saveAccountsUnlocked(storage) {
459
641
  try {
460
642
  await fs.mkdir(dirname(path), { recursive: true });
461
643
  await ensureGitignore(path);
462
- const content = JSON.stringify(storage, null, 2);
644
+ // Normalize before persisting so every write path enforces dedup semantics
645
+ // (organizationId/accountId identity plus refresh-token collision collapse).
646
+ const normalizedStorage = normalizeAccountStorage(storage) ?? storage;
647
+ const content = JSON.stringify(normalizedStorage, null, 2);
463
648
  await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
464
649
  const stats = await fs.stat(tempPath);
465
650
  if (stats.size === 0) {
466
651
  const emptyError = Object.assign(new Error("File written but size is 0"), { code: "EEMPTY" });
467
652
  throw emptyError;
468
653
  }
469
- // Retry rename with exponential backoff for Windows EPERM/EBUSY
470
- let lastError = null;
471
- for (let attempt = 0; attempt < 5; attempt++) {
472
- try {
473
- await fs.rename(tempPath, path);
474
- return;
475
- }
476
- catch (renameError) {
477
- const code = renameError.code;
478
- if (code === "EPERM" || code === "EBUSY") {
479
- lastError = renameError;
480
- await new Promise(r => setTimeout(r, 10 * Math.pow(2, attempt)));
481
- continue;
482
- }
483
- throw renameError;
484
- }
485
- }
486
- if (lastError)
487
- throw lastError;
654
+ await renameWithWindowsRetry(tempPath, path);
488
655
  }
489
656
  catch (error) {
490
657
  try {
@@ -556,6 +723,15 @@ function normalizeFlaggedStorage(data) {
556
723
  const isAccountIdSource = (value) => value === "token" || value === "id_token" || value === "org" || value === "manual";
557
724
  const isSwitchReason = (value) => value === "rate-limit" || value === "initial" || value === "rotation";
558
725
  const isCooldownReason = (value) => value === "auth-failure" || value === "network-error";
726
+ const normalizeTags = (value) => {
727
+ if (!Array.isArray(value))
728
+ return undefined;
729
+ const normalized = value
730
+ .filter((entry) => typeof entry === "string")
731
+ .map((entry) => entry.trim().toLowerCase())
732
+ .filter((entry) => entry.length > 0);
733
+ return normalized.length > 0 ? Array.from(new Set(normalized)) : undefined;
734
+ };
559
735
  let rateLimitResetTimes;
560
736
  if (isRecord(rawAccount.rateLimitResetTimes)) {
561
737
  const normalizedRateLimits = {};
@@ -577,6 +753,10 @@ function normalizeFlaggedStorage(data) {
577
753
  const cooldownReason = isCooldownReason(rawAccount.cooldownReason)
578
754
  ? rawAccount.cooldownReason
579
755
  : undefined;
756
+ const accountTags = normalizeTags(rawAccount.accountTags);
757
+ const accountNote = typeof rawAccount.accountNote === "string" && rawAccount.accountNote.trim()
758
+ ? rawAccount.accountNote.trim()
759
+ : undefined;
580
760
  const normalized = {
581
761
  refreshToken,
582
762
  addedAt: typeof rawAccount.addedAt === "number" ? rawAccount.addedAt : flaggedAt,
@@ -585,6 +765,8 @@ function normalizeFlaggedStorage(data) {
585
765
  accountId: typeof rawAccount.accountId === "string" ? rawAccount.accountId : undefined,
586
766
  accountIdSource,
587
767
  accountLabel: typeof rawAccount.accountLabel === "string" ? rawAccount.accountLabel : undefined,
768
+ accountTags,
769
+ accountNote,
588
770
  email: typeof rawAccount.email === "string" ? rawAccount.email : undefined,
589
771
  enabled: typeof rawAccount.enabled === "boolean" ? rawAccount.enabled : undefined,
590
772
  lastSwitchReason,
@@ -659,7 +841,7 @@ export async function saveFlaggedAccounts(storage) {
659
841
  await fs.mkdir(dirname(path), { recursive: true });
660
842
  const content = JSON.stringify(normalizeFlaggedStorage(storage), null, 2);
661
843
  await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
662
- await fs.rename(tempPath, path);
844
+ await renameWithWindowsRetry(tempPath, path);
663
845
  }
664
846
  catch (error) {
665
847
  try {
@@ -686,6 +868,71 @@ export async function clearFlaggedAccounts() {
686
868
  }
687
869
  });
688
870
  }
871
+ function formatBackupTimestamp(date = new Date()) {
872
+ const yyyy = String(date.getFullYear());
873
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
874
+ const dd = String(date.getDate()).padStart(2, "0");
875
+ const hh = String(date.getHours()).padStart(2, "0");
876
+ const min = String(date.getMinutes()).padStart(2, "0");
877
+ const ss = String(date.getSeconds()).padStart(2, "0");
878
+ const mmm = String(date.getMilliseconds()).padStart(3, "0");
879
+ return `${yyyy}${mm}${dd}-${hh}${min}${ss}${mmm}`;
880
+ }
881
+ function sanitizeBackupPrefix(prefix) {
882
+ const trimmed = prefix.trim();
883
+ const safe = trimmed
884
+ .replace(/[^a-zA-Z0-9_-]+/g, "-")
885
+ .replace(/-+/g, "-")
886
+ .replace(/^-+|-+$/g, "");
887
+ return safe.length > 0 ? safe : "codex-backup";
888
+ }
889
+ export function createTimestampedBackupPath(prefix = "codex-backup") {
890
+ const storagePath = getStoragePath();
891
+ const backupDir = join(dirname(storagePath), "backups");
892
+ const safePrefix = sanitizeBackupPrefix(prefix);
893
+ const nonce = randomBytes(3).toString("hex");
894
+ return join(backupDir, `${safePrefix}-${formatBackupTimestamp()}-${nonce}.json`);
895
+ }
896
+ async function readAndNormalizeImportFile(filePath) {
897
+ const resolvedPath = resolvePath(filePath);
898
+ if (!existsSync(resolvedPath)) {
899
+ throw new Error(`Import file not found: ${resolvedPath}`);
900
+ }
901
+ const content = await fs.readFile(resolvedPath, "utf-8");
902
+ let imported;
903
+ try {
904
+ imported = JSON.parse(content);
905
+ }
906
+ catch {
907
+ throw new Error(`Invalid JSON in import file: ${resolvedPath}`);
908
+ }
909
+ const normalized = normalizeAccountStorage(imported);
910
+ if (!normalized) {
911
+ throw new Error("Invalid account storage format");
912
+ }
913
+ return { resolvedPath, normalized };
914
+ }
915
+ export async function previewImportAccounts(filePath) {
916
+ const { normalized } = await readAndNormalizeImportFile(filePath);
917
+ return withAccountStorageTransaction((existing) => {
918
+ const existingAccounts = existing?.accounts ?? [];
919
+ const merged = [...existingAccounts, ...normalized.accounts];
920
+ if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) {
921
+ const deduped = deduplicateAccountsForStorage(merged);
922
+ if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) {
923
+ throw new Error(`Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduped.length})`);
924
+ }
925
+ }
926
+ const deduplicatedAccounts = deduplicateAccountsForStorage(merged);
927
+ const imported = deduplicatedAccounts.length - existingAccounts.length;
928
+ const skipped = normalized.accounts.length - imported;
929
+ return Promise.resolve({
930
+ imported,
931
+ total: deduplicatedAccounts.length,
932
+ skipped,
933
+ });
934
+ });
935
+ }
689
936
  /**
690
937
  * Exports current accounts to a JSON file for backup/migration.
691
938
  * @param filePath - Destination file path
@@ -713,30 +960,44 @@ export async function exportAccounts(filePath, force = true) {
713
960
  * @param filePath - Source file path
714
961
  * @throws Error if file is invalid or would exceed MAX_ACCOUNTS
715
962
  */
716
- export async function importAccounts(filePath) {
717
- const resolvedPath = resolvePath(filePath);
718
- // Check file exists with friendly error
719
- if (!existsSync(resolvedPath)) {
720
- throw new Error(`Import file not found: ${resolvedPath}`);
721
- }
722
- const content = await fs.readFile(resolvedPath, "utf-8");
723
- let imported;
724
- try {
725
- imported = JSON.parse(content);
726
- }
727
- catch {
728
- throw new Error(`Invalid JSON in import file: ${resolvedPath}`);
729
- }
730
- const normalized = normalizeAccountStorage(imported);
731
- if (!normalized) {
732
- throw new Error("Invalid account storage format");
733
- }
734
- const { imported: importedCount, total, skipped: skippedCount } = await withAccountStorageTransaction(async (existing, persist) => {
735
- const existingAccounts = existing?.accounts ?? [];
736
- const existingActiveIndex = existing?.activeIndex ?? 0;
963
+ export async function importAccounts(filePath, options = {}) {
964
+ const { resolvedPath, normalized } = await readAndNormalizeImportFile(filePath);
965
+ const backupMode = options.backupMode ?? "none";
966
+ const backupPrefix = options.preImportBackupPrefix ?? "codex-pre-import-backup";
967
+ const { imported: importedCount, total, skipped: skippedCount, backupStatus, backupPath, backupError, } = await withAccountStorageTransaction(async (existing, persist) => {
968
+ const existingStorage = existing ??
969
+ {
970
+ version: 3,
971
+ accounts: [],
972
+ activeIndex: 0,
973
+ activeIndexByFamily: {},
974
+ };
975
+ const existingAccounts = existingStorage.accounts;
976
+ const existingActiveIndex = existingStorage.activeIndex;
737
977
  const clampedExistingActiveIndex = clampIndex(existingActiveIndex, existingAccounts.length);
738
- const existingActiveKey = extractActiveKey(existingAccounts, clampedExistingActiveIndex);
739
- const existingActiveIndexByFamily = existing?.activeIndexByFamily ?? {};
978
+ const existingActiveKeys = extractActiveKeys(existingAccounts, clampedExistingActiveIndex);
979
+ const existingActiveIndexByFamily = existingStorage.activeIndexByFamily ?? {};
980
+ let backupStatus = "skipped";
981
+ let backupPath;
982
+ let backupError;
983
+ if (backupMode !== "none" && existingAccounts.length > 0) {
984
+ backupPath = createTimestampedBackupPath(backupPrefix);
985
+ try {
986
+ await writePreImportBackupFile(backupPath, existingStorage);
987
+ backupStatus = "created";
988
+ }
989
+ catch (error) {
990
+ backupStatus = "failed";
991
+ backupError = error instanceof Error ? error.message : String(error);
992
+ if (backupMode === "required") {
993
+ throw new Error(`Pre-import backup failed: ${backupError}`);
994
+ }
995
+ log.warn("Pre-import backup failed; continuing import apply", {
996
+ path: backupPath,
997
+ error: backupError,
998
+ });
999
+ }
1000
+ }
740
1001
  const merged = [...existingAccounts, ...normalized.accounts];
741
1002
  if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) {
742
1003
  const deduped = deduplicateAccountsForStorage(merged);
@@ -748,8 +1009,8 @@ export async function importAccounts(filePath) {
748
1009
  const mappedActiveIndex = (() => {
749
1010
  if (deduplicatedAccounts.length === 0)
750
1011
  return 0;
751
- if (existingActiveKey) {
752
- const idx = deduplicatedAccounts.findIndex((account) => toAccountKey(account) === existingActiveKey);
1012
+ if (existingActiveKeys.length > 0) {
1013
+ const idx = findAccountIndexByIdentityKeys(deduplicatedAccounts, existingActiveKeys);
753
1014
  if (idx >= 0)
754
1015
  return idx;
755
1016
  }
@@ -761,9 +1022,9 @@ export async function importAccounts(filePath) {
761
1022
  const familyIndex = typeof rawFamilyIndex === "number" && Number.isFinite(rawFamilyIndex)
762
1023
  ? rawFamilyIndex
763
1024
  : clampedExistingActiveIndex;
764
- const familyKey = extractActiveKey(existingAccounts, clampIndex(familyIndex, existingAccounts.length));
765
- if (familyKey) {
766
- const idx = deduplicatedAccounts.findIndex((account) => toAccountKey(account) === familyKey);
1025
+ const familyKeys = extractActiveKeys(existingAccounts, clampIndex(familyIndex, existingAccounts.length));
1026
+ if (familyKeys.length > 0) {
1027
+ const idx = findAccountIndexByIdentityKeys(deduplicatedAccounts, familyKeys);
767
1028
  activeIndexByFamily[family] = idx >= 0 ? idx : mappedActiveIndex;
768
1029
  continue;
769
1030
  }
@@ -778,9 +1039,31 @@ export async function importAccounts(filePath) {
778
1039
  await persist(newStorage);
779
1040
  const imported = deduplicatedAccounts.length - existingAccounts.length;
780
1041
  const skipped = normalized.accounts.length - imported;
781
- return { imported, total: deduplicatedAccounts.length, skipped };
1042
+ return {
1043
+ imported,
1044
+ total: deduplicatedAccounts.length,
1045
+ skipped,
1046
+ backupStatus,
1047
+ backupPath,
1048
+ backupError,
1049
+ };
1050
+ });
1051
+ log.info("Imported accounts", {
1052
+ path: resolvedPath,
1053
+ imported: importedCount,
1054
+ skipped: skippedCount,
1055
+ total,
1056
+ backupStatus,
1057
+ backupPath,
1058
+ backupError,
782
1059
  });
783
- log.info("Imported accounts", { path: resolvedPath, imported: importedCount, skipped: skippedCount, total });
784
- return { imported: importedCount, total, skipped: skippedCount };
1060
+ return {
1061
+ imported: importedCount,
1062
+ total,
1063
+ skipped: skippedCount,
1064
+ backupStatus,
1065
+ backupPath,
1066
+ backupError,
1067
+ };
785
1068
  }
786
1069
  //# sourceMappingURL=storage.js.map