opencode-openai-codex-multi-auth 4.5.6 → 4.5.10

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 (39) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +281 -41
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/accounts.d.ts +8 -0
  5. package/dist/lib/accounts.d.ts.map +1 -1
  6. package/dist/lib/accounts.js +179 -20
  7. package/dist/lib/accounts.js.map +1 -1
  8. package/dist/lib/auth/auth.d.ts +5 -0
  9. package/dist/lib/auth/auth.d.ts.map +1 -1
  10. package/dist/lib/auth/auth.js +16 -0
  11. package/dist/lib/auth/auth.js.map +1 -1
  12. package/dist/lib/cli.d.ts +4 -0
  13. package/dist/lib/cli.d.ts.map +1 -1
  14. package/dist/lib/cli.js +20 -0
  15. package/dist/lib/cli.js.map +1 -1
  16. package/dist/lib/codex-status.d.ts +34 -0
  17. package/dist/lib/codex-status.d.ts.map +1 -0
  18. package/dist/lib/codex-status.js +125 -0
  19. package/dist/lib/codex-status.js.map +1 -0
  20. package/dist/lib/config.d.ts +1 -0
  21. package/dist/lib/config.d.ts.map +1 -1
  22. package/dist/lib/config.js +4 -0
  23. package/dist/lib/config.js.map +1 -1
  24. package/dist/lib/formatting.d.ts +9 -0
  25. package/dist/lib/formatting.d.ts.map +1 -0
  26. package/dist/lib/formatting.js +71 -0
  27. package/dist/lib/formatting.js.map +1 -0
  28. package/dist/lib/oauth-success.html +1 -1
  29. package/dist/lib/storage-scope.d.ts +5 -0
  30. package/dist/lib/storage-scope.d.ts.map +1 -0
  31. package/dist/lib/storage-scope.js +11 -0
  32. package/dist/lib/storage-scope.js.map +1 -0
  33. package/dist/lib/storage.d.ts +39 -1
  34. package/dist/lib/storage.d.ts.map +1 -1
  35. package/dist/lib/storage.js +495 -116
  36. package/dist/lib/storage.js.map +1 -1
  37. package/dist/lib/types.d.ts +7 -0
  38. package/dist/lib/types.d.ts.map +1 -1
  39. package/package.json +8 -4
@@ -1,16 +1,135 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { existsSync, promises as fs } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { dirname, join } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import lockfile from "proper-lockfile";
6
6
  import { findAccountMatchIndex } from "./account-matching.js";
7
+ const PLAN_TYPE_LABELS = {
8
+ free: "Free",
9
+ plus: "Plus",
10
+ pro: "Pro",
11
+ team: "Team",
12
+ business: "Business",
13
+ enterprise: "Enterprise",
14
+ edu: "Edu",
15
+ };
16
+ function normalizePlanType(planType) {
17
+ if (typeof planType !== "string")
18
+ return undefined;
19
+ const trimmed = planType.trim();
20
+ if (!trimmed)
21
+ return undefined;
22
+ const mapped = PLAN_TYPE_LABELS[trimmed.toLowerCase()];
23
+ return mapped ?? trimmed;
24
+ }
7
25
  const STORAGE_FILE = "openai-codex-accounts.json";
8
26
  const AUTH_DEBUG_ENABLED = process.env.OPENCODE_OPENAI_AUTH_DEBUG === "1";
27
+ const MAX_QUARANTINE_FILES = 20;
28
+ const MAX_BACKUP_FILES = 20;
29
+ let storagePathOverride = null;
30
+ let storageScopeOverride = "global";
31
+ function findClosestProjectAccountsFile(startDir) {
32
+ let current = resolve(startDir);
33
+ // Walk up to filesystem root.
34
+ while (true) {
35
+ const candidate = join(current, ".opencode", STORAGE_FILE);
36
+ if (existsSync(candidate))
37
+ return candidate;
38
+ const parent = dirname(current);
39
+ if (parent === current)
40
+ return null;
41
+ current = parent;
42
+ }
43
+ }
44
+ export function configureStorageForCwd(options) {
45
+ if (!options.perProjectAccounts) {
46
+ storagePathOverride = null;
47
+ storageScopeOverride = "global";
48
+ return { scope: "global", storagePath: getStoragePath() };
49
+ }
50
+ const projectPath = findClosestProjectAccountsFile(options.cwd);
51
+ if (projectPath) {
52
+ storagePathOverride = projectPath;
53
+ storageScopeOverride = "project";
54
+ return { scope: "project", storagePath: projectPath };
55
+ }
56
+ storagePathOverride = null;
57
+ storageScopeOverride = "global";
58
+ return { scope: "global", storagePath: getStoragePath() };
59
+ }
60
+ export function getStorageScope() {
61
+ return { scope: storageScopeOverride, storagePath: getStoragePath() };
62
+ }
9
63
  function debug(...args) {
10
64
  if (!AUTH_DEBUG_ENABLED)
11
65
  return;
12
66
  console.debug(...args);
13
67
  }
68
+ function hasCompleteIdentity(record) {
69
+ return Boolean(record.accountId && record.email && record.plan);
70
+ }
71
+ function normalizeAccountRecord(candidate, now) {
72
+ if (!candidate || typeof candidate !== "object")
73
+ return null;
74
+ const record = candidate;
75
+ const refreshTokenRaw = typeof record.refreshToken === "string"
76
+ ? record.refreshToken
77
+ : typeof record.refresh_token === "string"
78
+ ? record.refresh_token
79
+ : typeof record.refresh === "string"
80
+ ? record.refresh
81
+ : undefined;
82
+ const refreshToken = typeof refreshTokenRaw === "string" ? refreshTokenRaw.trim() : "";
83
+ if (!refreshToken)
84
+ return null;
85
+ const accountIdRaw = typeof record.accountId === "string"
86
+ ? record.accountId
87
+ : typeof record.account_id === "string"
88
+ ? record.account_id
89
+ : undefined;
90
+ const accountId = typeof accountIdRaw === "string" && accountIdRaw.trim() ? accountIdRaw : undefined;
91
+ const emailRaw = typeof record.email === "string" ? record.email : undefined;
92
+ const email = typeof emailRaw === "string" && emailRaw.trim() ? emailRaw : undefined;
93
+ const planRaw = typeof record.plan === "string"
94
+ ? record.plan
95
+ : typeof record.chatgpt_plan_type === "string"
96
+ ? record.chatgpt_plan_type
97
+ : undefined;
98
+ const plan = normalizePlanType(planRaw);
99
+ const enabled = typeof record.enabled === "boolean" ? record.enabled : undefined;
100
+ const addedAt = typeof record.addedAt === "number" && Number.isFinite(record.addedAt)
101
+ ? record.addedAt
102
+ : now;
103
+ const lastUsed = typeof record.lastUsed === "number" && Number.isFinite(record.lastUsed)
104
+ ? record.lastUsed
105
+ : now;
106
+ const lastSwitchReason = typeof record.lastSwitchReason === "string" ? record.lastSwitchReason : undefined;
107
+ const rateLimitResetTimes = record.rateLimitResetTimes && typeof record.rateLimitResetTimes === "object"
108
+ ? record.rateLimitResetTimes
109
+ : undefined;
110
+ const coolingDownUntil = typeof record.coolingDownUntil === "number" && Number.isFinite(record.coolingDownUntil)
111
+ ? record.coolingDownUntil
112
+ : undefined;
113
+ const cooldownReasonRaw = typeof record.cooldownReason === "string" ? record.cooldownReason : undefined;
114
+ const cooldownReason = cooldownReasonRaw === "auth-failure" ? "auth-failure" : undefined;
115
+ return {
116
+ refreshToken,
117
+ accountId,
118
+ email,
119
+ plan,
120
+ enabled,
121
+ addedAt: Math.max(0, Math.floor(addedAt)),
122
+ lastUsed: Math.max(0, Math.floor(lastUsed)),
123
+ lastSwitchReason: lastSwitchReason === "rate-limit" ||
124
+ lastSwitchReason === "initial" ||
125
+ lastSwitchReason === "rotation"
126
+ ? lastSwitchReason
127
+ : undefined,
128
+ rateLimitResetTimes,
129
+ coolingDownUntil,
130
+ cooldownReason,
131
+ };
132
+ }
14
133
  const LOCK_OPTIONS = {
15
134
  stale: 10_000,
16
135
  retries: {
@@ -21,9 +140,16 @@ const LOCK_OPTIONS = {
21
140
  },
22
141
  realpath: false,
23
142
  };
143
+ async function ensureFileExists(path) {
144
+ if (existsSync(path))
145
+ return;
146
+ await fs.mkdir(dirname(path), { recursive: true });
147
+ await fs.writeFile(path, JSON.stringify({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }, null, 2), "utf-8");
148
+ }
24
149
  async function withFileLock(path, fn) {
25
150
  let release = null;
26
151
  try {
152
+ await ensureFileExists(path).catch(() => undefined);
27
153
  release = await lockfile.lock(path, LOCK_OPTIONS);
28
154
  return await fn();
29
155
  }
@@ -38,6 +164,79 @@ async function withFileLock(path, fn) {
38
164
  }
39
165
  }
40
166
  }
167
+ async function ensurePrivateFileMode(path) {
168
+ try {
169
+ // Best-effort: make quarantine files readable only by the current user.
170
+ await fs.chmod(path, 0o600);
171
+ }
172
+ catch {
173
+ // ignore (unsupported on some platforms/filesystems)
174
+ }
175
+ }
176
+ async function cleanupQuarantineFiles(storagePath) {
177
+ try {
178
+ const dir = dirname(storagePath);
179
+ const entries = await fs.readdir(dir);
180
+ const prefix = `${STORAGE_FILE}.quarantine-`;
181
+ const matches = entries
182
+ .filter((name) => name.startsWith(prefix) && name.endsWith(".json"))
183
+ .map((name) => {
184
+ const stampRaw = name.slice(prefix.length, name.length - ".json".length);
185
+ const stamp = Number.parseInt(stampRaw, 10);
186
+ return {
187
+ name,
188
+ stamp: Number.isFinite(stamp) ? stamp : 0,
189
+ };
190
+ })
191
+ .sort((a, b) => a.stamp - b.stamp);
192
+ if (matches.length <= MAX_QUARANTINE_FILES)
193
+ return;
194
+ const toDelete = matches.slice(0, matches.length - MAX_QUARANTINE_FILES);
195
+ await Promise.all(toDelete.map(async (entry) => {
196
+ try {
197
+ await fs.unlink(join(dir, entry.name));
198
+ }
199
+ catch {
200
+ // ignore per-file deletion failures
201
+ }
202
+ }));
203
+ }
204
+ catch {
205
+ // ignore cleanup failures
206
+ }
207
+ }
208
+ async function cleanupBackupFiles(storagePath) {
209
+ try {
210
+ const dir = dirname(storagePath);
211
+ const entries = await fs.readdir(dir);
212
+ const prefix = `${STORAGE_FILE}.bak-`;
213
+ const matches = entries
214
+ .filter((name) => name.startsWith(prefix))
215
+ .map((name) => {
216
+ const stampRaw = name.slice(prefix.length);
217
+ const stamp = Number.parseInt(stampRaw, 10);
218
+ return {
219
+ name,
220
+ stamp: Number.isFinite(stamp) ? stamp : 0,
221
+ };
222
+ })
223
+ .sort((a, b) => a.stamp - b.stamp);
224
+ if (matches.length <= MAX_BACKUP_FILES)
225
+ return;
226
+ const toDelete = matches.slice(0, matches.length - MAX_BACKUP_FILES);
227
+ await Promise.all(toDelete.map(async (entry) => {
228
+ try {
229
+ await fs.unlink(join(dir, entry.name));
230
+ }
231
+ catch {
232
+ // ignore per-file deletion failures
233
+ }
234
+ }));
235
+ }
236
+ catch {
237
+ // ignore cleanup failures
238
+ }
239
+ }
41
240
  function getOpencodeConfigDir() {
42
241
  const xdgConfigHome = process.env.XDG_CONFIG_HOME;
43
242
  if (xdgConfigHome && xdgConfigHome.trim()) {
@@ -49,6 +248,8 @@ function getLegacyOpencodeDir() {
49
248
  return join(homedir(), ".opencode");
50
249
  }
51
250
  export function getStoragePath() {
251
+ if (storagePathOverride)
252
+ return storagePathOverride;
52
253
  return join(getOpencodeConfigDir(), STORAGE_FILE);
53
254
  }
54
255
  export async function backupAccountsFile() {
@@ -60,6 +261,8 @@ export async function backupAccountsFile() {
60
261
  if (!existsSync(filePath))
61
262
  return;
62
263
  await fs.copyFile(filePath, backupPath);
264
+ await ensurePrivateFileMode(backupPath);
265
+ await cleanupBackupFiles(filePath);
63
266
  });
64
267
  return backupPath;
65
268
  }
@@ -68,68 +271,6 @@ function getLegacyStoragePath() {
68
271
  }
69
272
  function normalizeStorage(parsed) {
70
273
  const now = Date.now();
71
- const normalizeAccountRecord = (candidate) => {
72
- if (!candidate || typeof candidate !== "object")
73
- return null;
74
- const record = candidate;
75
- const refreshTokenRaw = typeof record.refreshToken === "string"
76
- ? record.refreshToken
77
- : typeof record.refresh_token === "string"
78
- ? record.refresh_token
79
- : typeof record.refresh === "string"
80
- ? record.refresh
81
- : undefined;
82
- const refreshToken = typeof refreshTokenRaw === "string" ? refreshTokenRaw.trim() : "";
83
- if (!refreshToken)
84
- return null;
85
- const accountIdRaw = typeof record.accountId === "string"
86
- ? record.accountId
87
- : typeof record.account_id === "string"
88
- ? record.account_id
89
- : undefined;
90
- const accountId = typeof accountIdRaw === "string" && accountIdRaw.trim() ? accountIdRaw : undefined;
91
- const emailRaw = typeof record.email === "string" ? record.email : undefined;
92
- const email = typeof emailRaw === "string" && emailRaw.trim() ? emailRaw : undefined;
93
- const planRaw = typeof record.plan === "string"
94
- ? record.plan
95
- : typeof record.chatgpt_plan_type === "string"
96
- ? record.chatgpt_plan_type
97
- : undefined;
98
- const plan = typeof planRaw === "string" && planRaw.trim() ? planRaw : undefined;
99
- const enabled = typeof record.enabled === "boolean" ? record.enabled : undefined;
100
- const addedAt = typeof record.addedAt === "number" && Number.isFinite(record.addedAt)
101
- ? record.addedAt
102
- : now;
103
- const lastUsed = typeof record.lastUsed === "number" && Number.isFinite(record.lastUsed)
104
- ? record.lastUsed
105
- : now;
106
- const lastSwitchReason = typeof record.lastSwitchReason === "string" ? record.lastSwitchReason : undefined;
107
- const rateLimitResetTimes = record.rateLimitResetTimes && typeof record.rateLimitResetTimes === "object"
108
- ? record.rateLimitResetTimes
109
- : undefined;
110
- const coolingDownUntil = typeof record.coolingDownUntil === "number" && Number.isFinite(record.coolingDownUntil)
111
- ? record.coolingDownUntil
112
- : undefined;
113
- const cooldownReasonRaw = typeof record.cooldownReason === "string" ? record.cooldownReason : undefined;
114
- const cooldownReason = cooldownReasonRaw === "auth-failure" ? "auth-failure" : undefined;
115
- return {
116
- refreshToken,
117
- accountId,
118
- email,
119
- plan,
120
- enabled,
121
- addedAt: Math.max(0, Math.floor(addedAt)),
122
- lastUsed: Math.max(0, Math.floor(lastUsed)),
123
- lastSwitchReason: lastSwitchReason === "rate-limit" ||
124
- lastSwitchReason === "initial" ||
125
- lastSwitchReason === "rotation"
126
- ? lastSwitchReason
127
- : undefined,
128
- rateLimitResetTimes,
129
- coolingDownUntil,
130
- cooldownReason,
131
- };
132
- };
133
274
  let accountsSource;
134
275
  let activeIndexSource = 0;
135
276
  let activeIndexByFamilySource = undefined;
@@ -148,7 +289,7 @@ function normalizeStorage(parsed) {
148
289
  if (!Array.isArray(accountsSource))
149
290
  return null;
150
291
  const normalizedAccounts = accountsSource
151
- .map(normalizeAccountRecord)
292
+ .map((entry) => normalizeAccountRecord(entry, now))
152
293
  .filter((a) => a !== null);
153
294
  const activeIndexRaw = typeof activeIndexSource === "number" && Number.isFinite(activeIndexSource)
154
295
  ? Math.max(0, Math.floor(activeIndexSource))
@@ -237,15 +378,18 @@ function dedupeRefreshTokens(accounts) {
237
378
  }
238
379
  return { accounts: deduped, changed };
239
380
  }
240
- function mergeAccounts(existing, incoming) {
381
+ function mergeAccounts(existing, incoming, options) {
241
382
  const merged = existing.map((account) => ({ ...account }));
242
383
  let changed = false;
243
384
  for (const candidate of incoming) {
244
- const matchIndex = findAccountMatchIndex(merged, {
385
+ let matchIndex = findAccountMatchIndex(merged, {
245
386
  accountId: candidate.accountId,
246
387
  plan: candidate.plan,
247
388
  email: candidate.email,
248
389
  });
390
+ if (matchIndex < 0 && (!candidate.accountId || !candidate.email || !candidate.plan)) {
391
+ matchIndex = merged.findIndex((account) => account.refreshToken === candidate.refreshToken);
392
+ }
249
393
  if (matchIndex < 0) {
250
394
  merged.push({ ...candidate });
251
395
  changed = true;
@@ -255,8 +399,11 @@ function mergeAccounts(existing, incoming) {
255
399
  const updated = { ...current };
256
400
  let didUpdate = false;
257
401
  if (candidate.refreshToken && candidate.refreshToken !== updated.refreshToken) {
258
- updated.refreshToken = candidate.refreshToken;
259
- didUpdate = true;
402
+ const shouldPreserve = options?.preserveRefreshTokens === true;
403
+ if (!shouldPreserve || !updated.refreshToken) {
404
+ updated.refreshToken = candidate.refreshToken;
405
+ didUpdate = true;
406
+ }
260
407
  }
261
408
  if (!updated.accountId && candidate.accountId) {
262
409
  updated.accountId = candidate.accountId;
@@ -340,18 +487,33 @@ function mergeAccounts(existing, incoming) {
340
487
  changed = true;
341
488
  return { accounts: deduped.accounts, changed };
342
489
  }
343
- function mergeAccountStorage(existing, incoming) {
344
- const { accounts: mergedAccounts } = mergeAccounts(existing.accounts, incoming.accounts);
345
- const incomingActive = incoming.activeIndex >= 0 && incoming.activeIndex < incoming.accounts.length
346
- ? incoming.accounts[incoming.activeIndex]
347
- : null;
348
- const mappedIndex = incomingActive
349
- ? findAccountMatchIndex(mergedAccounts, {
350
- accountId: incomingActive.accountId,
351
- plan: incomingActive.plan,
352
- email: incomingActive.email,
353
- })
354
- : -1;
490
+ function findAccountIndexByIdentityOrToken(accounts, candidate) {
491
+ if (!candidate)
492
+ return -1;
493
+ const matchIndex = findAccountMatchIndex(accounts, {
494
+ accountId: candidate.accountId,
495
+ plan: candidate.plan,
496
+ email: candidate.email,
497
+ });
498
+ if (matchIndex >= 0)
499
+ return matchIndex;
500
+ if (!candidate.accountId || !candidate.email || !candidate.plan) {
501
+ return accounts.findIndex((account) => account.refreshToken === candidate.refreshToken);
502
+ }
503
+ return -1;
504
+ }
505
+ function mergeAccountStorage(existing, incoming, options) {
506
+ const { accounts: mergedAccounts } = mergeAccounts(existing.accounts, incoming.accounts, options);
507
+ const getAccountAtIndex = (source, index) => {
508
+ if (typeof index !== "number" || !Number.isFinite(index))
509
+ return null;
510
+ if (source.accounts.length === 0)
511
+ return null;
512
+ const clamped = Math.min(Math.max(0, Math.floor(index)), source.accounts.length - 1);
513
+ return source.accounts[clamped] ?? null;
514
+ };
515
+ const incomingActive = getAccountAtIndex(incoming, incoming.activeIndex);
516
+ const mappedIndex = findAccountIndexByIdentityOrToken(mergedAccounts, incomingActive);
355
517
  const baseActiveIndex = mappedIndex >= 0
356
518
  ? mappedIndex
357
519
  : incoming.accounts.length > 0
@@ -360,10 +522,22 @@ function mergeAccountStorage(existing, incoming) {
360
522
  const activeIndex = mergedAccounts.length > 0
361
523
  ? Math.min(Math.max(0, baseActiveIndex), mergedAccounts.length - 1)
362
524
  : 0;
363
- const activeIndexByFamily = {
364
- ...(existing.activeIndexByFamily ?? {}),
365
- ...(incoming.activeIndexByFamily ?? {}),
366
- };
525
+ const activeIndexByFamily = {};
526
+ const families = new Set([
527
+ ...Object.keys(existing.activeIndexByFamily ?? {}),
528
+ ...Object.keys(incoming.activeIndexByFamily ?? {}),
529
+ ]);
530
+ for (const family of families) {
531
+ const incomingIndex = incoming.activeIndexByFamily?.[family];
532
+ const incomingAccount = getAccountAtIndex(incoming, incomingIndex);
533
+ let mappedFamilyIndex = findAccountIndexByIdentityOrToken(mergedAccounts, incomingAccount);
534
+ if (mappedFamilyIndex < 0) {
535
+ const existingIndex = existing.activeIndexByFamily?.[family];
536
+ const existingAccount = getAccountAtIndex(existing, existingIndex);
537
+ mappedFamilyIndex = findAccountIndexByIdentityOrToken(mergedAccounts, existingAccount);
538
+ }
539
+ activeIndexByFamily[family] = mappedFamilyIndex >= 0 ? mappedFamilyIndex : activeIndex;
540
+ }
367
541
  return {
368
542
  version: 3,
369
543
  accounts: mergedAccounts,
@@ -374,26 +548,17 @@ function mergeAccountStorage(existing, incoming) {
374
548
  async function migrateLegacyAccountsFileIfNeeded() {
375
549
  const newPath = getStoragePath();
376
550
  const legacyPath = getLegacyStoragePath();
377
- const newExists = existsSync(newPath);
378
- const legacyExists = existsSync(legacyPath);
379
- if (!legacyExists)
551
+ if (!existsSync(legacyPath))
380
552
  return;
381
- if (!newExists) {
382
- await fs.mkdir(dirname(newPath), { recursive: true });
383
- try {
384
- await fs.rename(legacyPath, newPath);
385
- }
386
- catch {
387
- try {
388
- await fs.copyFile(legacyPath, newPath);
389
- await fs.unlink(legacyPath);
390
- }
391
- catch {
392
- // Best-effort; ignore.
393
- }
394
- }
553
+ await withFileLock(newPath, async () => {
554
+ await migrateLegacyAccountsFileIfNeededLocked(newPath, legacyPath);
555
+ });
556
+ }
557
+ async function migrateLegacyAccountsFileIfNeededLocked(newPath, legacyPath) {
558
+ if (getStorageScope().scope === "project")
559
+ return;
560
+ if (!existsSync(legacyPath))
395
561
  return;
396
- }
397
562
  try {
398
563
  const [newRaw, legacyRaw] = await Promise.all([
399
564
  fs.readFile(newPath, "utf-8"),
@@ -406,6 +571,12 @@ async function migrateLegacyAccountsFileIfNeeded() {
406
571
  if (!newStorage) {
407
572
  debug("[StorageMigration] New storage invalid, adopting legacy accounts");
408
573
  await fs.writeFile(newPath, JSON.stringify(legacyStorage, null, 2), "utf-8");
574
+ try {
575
+ await fs.unlink(legacyPath);
576
+ }
577
+ catch {
578
+ // Best-effort; ignore.
579
+ }
409
580
  return;
410
581
  }
411
582
  const { accounts: mergedAccounts, changed } = mergeAccounts(newStorage.accounts, legacyStorage.accounts);
@@ -438,6 +609,209 @@ async function migrateLegacyAccountsFileIfNeeded() {
438
609
  // Best-effort; ignore.
439
610
  }
440
611
  }
612
+ export async function loadAccountsUnsafe(filePath) {
613
+ try {
614
+ const raw = await fs.readFile(filePath, "utf-8");
615
+ const parsed = JSON.parse(raw);
616
+ return normalizeStorage(parsed);
617
+ }
618
+ catch {
619
+ return null;
620
+ }
621
+ }
622
+ export async function saveAccountsWithLock(mergeFn) {
623
+ const filePath = getStoragePath();
624
+ debug(`[SaveAccountsWithLock] Saving to ${filePath}`);
625
+ try {
626
+ await withFileLock(filePath, async () => {
627
+ await migrateLegacyAccountsFileIfNeededLocked(filePath, getLegacyStoragePath());
628
+ const existing = await loadAccountsUnsafe(filePath);
629
+ const mergedStorage = mergeFn(existing);
630
+ const jsonContent = JSON.stringify(mergedStorage, null, 2);
631
+ debug(`[SaveAccountsWithLock] Writing ${jsonContent.length} bytes`);
632
+ const tmpPath = `${filePath}.${randomBytes(6).toString("hex")}.tmp`;
633
+ try {
634
+ await fs.writeFile(tmpPath, jsonContent, "utf-8");
635
+ await fs.rename(tmpPath, filePath);
636
+ }
637
+ catch (error) {
638
+ try {
639
+ await fs.unlink(tmpPath);
640
+ }
641
+ catch {
642
+ // ignore cleanup errors
643
+ }
644
+ throw error;
645
+ }
646
+ });
647
+ }
648
+ catch (error) {
649
+ console.error("[SaveAccountsWithLock] Error saving accounts:", error);
650
+ throw error;
651
+ }
652
+ }
653
+ function extractAccountsSource(parsed) {
654
+ if (Array.isArray(parsed)) {
655
+ return { accountsSource: parsed, activeIndexSource: 0, activeIndexByFamilySource: undefined };
656
+ }
657
+ if (parsed && typeof parsed === "object") {
658
+ const storage = parsed;
659
+ if (!Array.isArray(storage.accounts))
660
+ return null;
661
+ return {
662
+ accountsSource: storage.accounts,
663
+ activeIndexSource: storage.activeIndex,
664
+ activeIndexByFamilySource: storage.activeIndexByFamily,
665
+ };
666
+ }
667
+ return null;
668
+ }
669
+ export async function inspectAccountsFile() {
670
+ const filePath = getStoragePath();
671
+ if (!existsSync(filePath)) {
672
+ return { status: "missing", corruptEntries: [], legacyEntries: [], validEntries: [] };
673
+ }
674
+ try {
675
+ const raw = await fs.readFile(filePath, "utf-8");
676
+ const parsed = JSON.parse(raw);
677
+ const source = extractAccountsSource(parsed);
678
+ if (!source) {
679
+ return {
680
+ status: "corrupt-file",
681
+ reason: "invalid-shape",
682
+ corruptEntries: [],
683
+ legacyEntries: [],
684
+ validEntries: [],
685
+ };
686
+ }
687
+ const now = Date.now();
688
+ const corruptEntries = [];
689
+ const legacyEntries = [];
690
+ const validEntries = [];
691
+ for (const entry of source.accountsSource) {
692
+ const normalized = normalizeAccountRecord(entry, now);
693
+ if (!normalized) {
694
+ corruptEntries.push(entry);
695
+ continue;
696
+ }
697
+ if (!hasCompleteIdentity(normalized)) {
698
+ legacyEntries.push(normalized);
699
+ continue;
700
+ }
701
+ validEntries.push(normalized);
702
+ }
703
+ if (corruptEntries.length > 0 || legacyEntries.length > 0) {
704
+ return {
705
+ status: "needs-repair",
706
+ corruptEntries,
707
+ legacyEntries,
708
+ validEntries,
709
+ };
710
+ }
711
+ return { status: "ok", corruptEntries: [], legacyEntries: [], validEntries };
712
+ }
713
+ catch {
714
+ return {
715
+ status: "corrupt-file",
716
+ reason: "parse-error",
717
+ corruptEntries: [],
718
+ legacyEntries: [],
719
+ validEntries: [],
720
+ };
721
+ }
722
+ }
723
+ async function writeAccountsFile(storage) {
724
+ const filePath = getStoragePath();
725
+ await withFileLock(filePath, async () => {
726
+ const jsonContent = JSON.stringify(storage, null, 2);
727
+ const tmpPath = `${filePath}.${randomBytes(6).toString("hex")}.tmp`;
728
+ try {
729
+ await fs.writeFile(tmpPath, jsonContent, "utf-8");
730
+ await fs.rename(tmpPath, filePath);
731
+ }
732
+ catch (error) {
733
+ try {
734
+ await fs.unlink(tmpPath);
735
+ }
736
+ catch {
737
+ // ignore cleanup errors
738
+ }
739
+ throw error;
740
+ }
741
+ });
742
+ }
743
+ export async function writeQuarantineFile(records, reason) {
744
+ const filePath = getStoragePath();
745
+ const quarantinePath = `${filePath}.quarantine-${Date.now()}.json`;
746
+ await fs.mkdir(dirname(filePath), { recursive: true });
747
+ const payload = {
748
+ reason,
749
+ quarantinedAt: new Date().toISOString(),
750
+ records,
751
+ };
752
+ if (existsSync(filePath)) {
753
+ await withFileLock(filePath, async () => {
754
+ await fs.writeFile(quarantinePath, JSON.stringify(payload, null, 2), "utf-8");
755
+ await ensurePrivateFileMode(quarantinePath);
756
+ await cleanupQuarantineFiles(filePath);
757
+ });
758
+ return quarantinePath;
759
+ }
760
+ await fs.writeFile(quarantinePath, JSON.stringify(payload, null, 2), "utf-8");
761
+ await ensurePrivateFileMode(quarantinePath);
762
+ await cleanupQuarantineFiles(filePath);
763
+ return quarantinePath;
764
+ }
765
+ export async function replaceAccountsFile(storage) {
766
+ await writeAccountsFile(storage);
767
+ }
768
+ export async function quarantineCorruptFile() {
769
+ const filePath = getStoragePath();
770
+ if (!existsSync(filePath))
771
+ return null;
772
+ const quarantinePath = `${filePath}.quarantine-${Date.now()}.json`;
773
+ await withFileLock(filePath, async () => {
774
+ if (!existsSync(filePath))
775
+ return;
776
+ await fs.copyFile(filePath, quarantinePath);
777
+ await ensurePrivateFileMode(quarantinePath);
778
+ await cleanupQuarantineFiles(filePath);
779
+ });
780
+ await writeAccountsFile({
781
+ version: 3,
782
+ accounts: [],
783
+ activeIndex: 0,
784
+ activeIndexByFamily: {},
785
+ });
786
+ return quarantinePath;
787
+ }
788
+ export async function autoQuarantineCorruptAccountsFile() {
789
+ const inspection = await inspectAccountsFile();
790
+ if (inspection.status !== "corrupt-file")
791
+ return null;
792
+ return await quarantineCorruptFile();
793
+ }
794
+ export async function quarantineAccounts(storage, entries, reason) {
795
+ if (!entries.length) {
796
+ return { storage, quarantinePath: await writeQuarantineFile([], reason) };
797
+ }
798
+ const tokens = new Set(entries.map((entry) => entry.refreshToken));
799
+ const remaining = storage.accounts.filter((account) => !tokens.has(account.refreshToken));
800
+ const normalized = normalizeStorage({
801
+ accounts: remaining,
802
+ activeIndex: storage.activeIndex,
803
+ activeIndexByFamily: storage.activeIndexByFamily,
804
+ });
805
+ const updated = normalized ?? {
806
+ version: 3,
807
+ accounts: [],
808
+ activeIndex: 0,
809
+ activeIndexByFamily: {},
810
+ };
811
+ const quarantinePath = await writeQuarantineFile(entries, reason);
812
+ await writeAccountsFile(updated);
813
+ return { storage: updated, quarantinePath };
814
+ }
441
815
  export async function loadAccounts() {
442
816
  await migrateLegacyAccountsFileIfNeeded();
443
817
  const filePath = getStoragePath();
@@ -471,27 +845,32 @@ export function toggleAccountEnabled(storage, index) {
471
845
  });
472
846
  return { ...storage, accounts };
473
847
  }
474
- export async function saveAccounts(storage) {
848
+ export async function saveAccounts(storage, options) {
475
849
  const filePath = getStoragePath();
476
850
  debug(`[SaveAccounts] Saving to ${filePath} with ${storage.accounts.length} accounts`);
477
851
  try {
478
- await fs.mkdir(dirname(filePath), { recursive: true });
479
- if (!existsSync(filePath)) {
480
- await fs.writeFile(filePath, JSON.stringify({
481
- version: 3,
482
- accounts: [],
483
- activeIndex: 0,
484
- activeIndexByFamily: {},
485
- }, null, 2), "utf-8");
486
- }
487
852
  await withFileLock(filePath, async () => {
488
- const existing = await loadAccounts().catch(() => null);
489
- const mergedStorage = existing ? mergeAccountStorage(existing, storage) : storage;
853
+ await migrateLegacyAccountsFileIfNeededLocked(filePath, getLegacyStoragePath());
854
+ const existing = await loadAccountsUnsafe(filePath);
855
+ const mergedStorage = existing && !options?.replace
856
+ ? mergeAccountStorage(existing, storage, options)
857
+ : storage;
490
858
  const jsonContent = JSON.stringify(mergedStorage, null, 2);
491
859
  debug(`[SaveAccounts] Writing ${jsonContent.length} bytes`);
492
860
  const tmpPath = `${filePath}.${randomBytes(6).toString("hex")}.tmp`;
493
- await fs.writeFile(tmpPath, jsonContent, "utf-8");
494
- await fs.rename(tmpPath, filePath);
861
+ try {
862
+ await fs.writeFile(tmpPath, jsonContent, "utf-8");
863
+ await fs.rename(tmpPath, filePath);
864
+ }
865
+ catch (error) {
866
+ try {
867
+ await fs.unlink(tmpPath);
868
+ }
869
+ catch {
870
+ // ignore cleanup errors
871
+ }
872
+ throw error;
873
+ }
495
874
  if (AUTH_DEBUG_ENABLED) {
496
875
  const verifyContent = await fs.readFile(filePath, "utf-8");
497
876
  const verifyStorage = normalizeStorage(JSON.parse(verifyContent));