opencode-openai-codex-multi-auth 4.5.5 → 4.5.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 (47) hide show
  1. package/README.md +10 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +356 -48
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/account-matching.d.ts.map +1 -1
  6. package/dist/lib/account-matching.js +3 -37
  7. package/dist/lib/account-matching.js.map +1 -1
  8. package/dist/lib/accounts.d.ts +22 -1
  9. package/dist/lib/accounts.d.ts.map +1 -1
  10. package/dist/lib/accounts.js +291 -56
  11. package/dist/lib/accounts.js.map +1 -1
  12. package/dist/lib/auth/auth.d.ts +5 -0
  13. package/dist/lib/auth/auth.d.ts.map +1 -1
  14. package/dist/lib/auth/auth.js +19 -1
  15. package/dist/lib/auth/auth.js.map +1 -1
  16. package/dist/lib/cli.d.ts +7 -1
  17. package/dist/lib/cli.d.ts.map +1 -1
  18. package/dist/lib/cli.js +57 -3
  19. package/dist/lib/cli.js.map +1 -1
  20. package/dist/lib/config.d.ts +8 -0
  21. package/dist/lib/config.d.ts.map +1 -1
  22. package/dist/lib/config.js +35 -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/rate-limit.d.ts +36 -0
  30. package/dist/lib/rate-limit.d.ts.map +1 -0
  31. package/dist/lib/rate-limit.js +78 -0
  32. package/dist/lib/rate-limit.js.map +1 -0
  33. package/dist/lib/refresh-queue.d.ts +37 -0
  34. package/dist/lib/refresh-queue.d.ts.map +1 -0
  35. package/dist/lib/refresh-queue.js +83 -0
  36. package/dist/lib/refresh-queue.js.map +1 -0
  37. package/dist/lib/rotation.d.ts +1 -1
  38. package/dist/lib/rotation.d.ts.map +1 -1
  39. package/dist/lib/rotation.js +15 -3
  40. package/dist/lib/rotation.js.map +1 -1
  41. package/dist/lib/storage.d.ts +20 -0
  42. package/dist/lib/storage.d.ts.map +1 -1
  43. package/dist/lib/storage.js +502 -108
  44. package/dist/lib/storage.js.map +1 -1
  45. package/dist/lib/types.d.ts +45 -0
  46. package/dist/lib/types.d.ts.map +1 -1
  47. package/package.json +11 -5
@@ -1,15 +1,157 @@
1
- import { existsSync } from "node:fs";
2
- import { promises as fs } from "node:fs";
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, promises as fs } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
+ import lockfile from "proper-lockfile";
5
6
  import { findAccountMatchIndex } from "./account-matching.js";
6
7
  const STORAGE_FILE = "openai-codex-accounts.json";
7
8
  const AUTH_DEBUG_ENABLED = process.env.OPENCODE_OPENAI_AUTH_DEBUG === "1";
9
+ const MAX_QUARANTINE_FILES = 20;
8
10
  function debug(...args) {
9
11
  if (!AUTH_DEBUG_ENABLED)
10
12
  return;
11
13
  console.debug(...args);
12
14
  }
15
+ function hasCompleteIdentity(record) {
16
+ return Boolean(record.accountId && record.email && record.plan);
17
+ }
18
+ function normalizeAccountRecord(candidate, now) {
19
+ if (!candidate || typeof candidate !== "object")
20
+ return null;
21
+ const record = candidate;
22
+ const refreshTokenRaw = typeof record.refreshToken === "string"
23
+ ? record.refreshToken
24
+ : typeof record.refresh_token === "string"
25
+ ? record.refresh_token
26
+ : typeof record.refresh === "string"
27
+ ? record.refresh
28
+ : undefined;
29
+ const refreshToken = typeof refreshTokenRaw === "string" ? refreshTokenRaw.trim() : "";
30
+ if (!refreshToken)
31
+ return null;
32
+ const accountIdRaw = typeof record.accountId === "string"
33
+ ? record.accountId
34
+ : typeof record.account_id === "string"
35
+ ? record.account_id
36
+ : undefined;
37
+ const accountId = typeof accountIdRaw === "string" && accountIdRaw.trim() ? accountIdRaw : undefined;
38
+ const emailRaw = typeof record.email === "string" ? record.email : undefined;
39
+ const email = typeof emailRaw === "string" && emailRaw.trim() ? emailRaw : undefined;
40
+ const planRaw = typeof record.plan === "string"
41
+ ? record.plan
42
+ : typeof record.chatgpt_plan_type === "string"
43
+ ? record.chatgpt_plan_type
44
+ : undefined;
45
+ const plan = typeof planRaw === "string" && planRaw.trim() ? planRaw : undefined;
46
+ const enabled = typeof record.enabled === "boolean" ? record.enabled : undefined;
47
+ const addedAt = typeof record.addedAt === "number" && Number.isFinite(record.addedAt)
48
+ ? record.addedAt
49
+ : now;
50
+ const lastUsed = typeof record.lastUsed === "number" && Number.isFinite(record.lastUsed)
51
+ ? record.lastUsed
52
+ : now;
53
+ const lastSwitchReason = typeof record.lastSwitchReason === "string" ? record.lastSwitchReason : undefined;
54
+ const rateLimitResetTimes = record.rateLimitResetTimes && typeof record.rateLimitResetTimes === "object"
55
+ ? record.rateLimitResetTimes
56
+ : undefined;
57
+ const coolingDownUntil = typeof record.coolingDownUntil === "number" && Number.isFinite(record.coolingDownUntil)
58
+ ? record.coolingDownUntil
59
+ : undefined;
60
+ const cooldownReasonRaw = typeof record.cooldownReason === "string" ? record.cooldownReason : undefined;
61
+ const cooldownReason = cooldownReasonRaw === "auth-failure" ? "auth-failure" : undefined;
62
+ return {
63
+ refreshToken,
64
+ accountId,
65
+ email,
66
+ plan,
67
+ enabled,
68
+ addedAt: Math.max(0, Math.floor(addedAt)),
69
+ lastUsed: Math.max(0, Math.floor(lastUsed)),
70
+ lastSwitchReason: lastSwitchReason === "rate-limit" ||
71
+ lastSwitchReason === "initial" ||
72
+ lastSwitchReason === "rotation"
73
+ ? lastSwitchReason
74
+ : undefined,
75
+ rateLimitResetTimes,
76
+ coolingDownUntil,
77
+ cooldownReason,
78
+ };
79
+ }
80
+ const LOCK_OPTIONS = {
81
+ stale: 10_000,
82
+ retries: {
83
+ retries: 5,
84
+ minTimeout: 100,
85
+ maxTimeout: 1000,
86
+ factor: 2,
87
+ },
88
+ realpath: false,
89
+ };
90
+ async function ensureFileExists(path) {
91
+ if (existsSync(path))
92
+ return;
93
+ await fs.mkdir(dirname(path), { recursive: true });
94
+ await fs.writeFile(path, JSON.stringify({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }, null, 2), "utf-8");
95
+ }
96
+ async function withFileLock(path, fn) {
97
+ let release = null;
98
+ try {
99
+ await ensureFileExists(path).catch(() => undefined);
100
+ release = await lockfile.lock(path, LOCK_OPTIONS);
101
+ return await fn();
102
+ }
103
+ finally {
104
+ if (release) {
105
+ try {
106
+ await release();
107
+ }
108
+ catch {
109
+ // ignore lock release errors
110
+ }
111
+ }
112
+ }
113
+ }
114
+ async function ensurePrivateFileMode(path) {
115
+ try {
116
+ // Best-effort: make quarantine files readable only by the current user.
117
+ await fs.chmod(path, 0o600);
118
+ }
119
+ catch {
120
+ // ignore (unsupported on some platforms/filesystems)
121
+ }
122
+ }
123
+ async function cleanupQuarantineFiles(storagePath) {
124
+ try {
125
+ const dir = dirname(storagePath);
126
+ const entries = await fs.readdir(dir);
127
+ const prefix = `${STORAGE_FILE}.quarantine-`;
128
+ const matches = entries
129
+ .filter((name) => name.startsWith(prefix) && name.endsWith(".json"))
130
+ .map((name) => {
131
+ const stampRaw = name.slice(prefix.length, name.length - ".json".length);
132
+ const stamp = Number.parseInt(stampRaw, 10);
133
+ return {
134
+ name,
135
+ stamp: Number.isFinite(stamp) ? stamp : 0,
136
+ };
137
+ })
138
+ .sort((a, b) => a.stamp - b.stamp);
139
+ if (matches.length <= MAX_QUARANTINE_FILES)
140
+ return;
141
+ const toDelete = matches.slice(0, matches.length - MAX_QUARANTINE_FILES);
142
+ await Promise.all(toDelete.map(async (entry) => {
143
+ try {
144
+ await fs.unlink(join(dir, entry.name));
145
+ }
146
+ catch {
147
+ // ignore per-file deletion failures
148
+ }
149
+ }));
150
+ }
151
+ catch {
152
+ // ignore cleanup failures
153
+ }
154
+ }
13
155
  function getOpencodeConfigDir() {
14
156
  const xdgConfigHome = process.env.XDG_CONFIG_HOME;
15
157
  if (xdgConfigHome && xdgConfigHome.trim()) {
@@ -23,71 +165,23 @@ function getLegacyOpencodeDir() {
23
165
  export function getStoragePath() {
24
166
  return join(getOpencodeConfigDir(), STORAGE_FILE);
25
167
  }
168
+ export async function backupAccountsFile() {
169
+ const filePath = getStoragePath();
170
+ if (!existsSync(filePath))
171
+ return null;
172
+ const backupPath = `${filePath}.bak-${Date.now()}`;
173
+ await withFileLock(filePath, async () => {
174
+ if (!existsSync(filePath))
175
+ return;
176
+ await fs.copyFile(filePath, backupPath);
177
+ });
178
+ return backupPath;
179
+ }
26
180
  function getLegacyStoragePath() {
27
181
  return join(getLegacyOpencodeDir(), STORAGE_FILE);
28
182
  }
29
183
  function normalizeStorage(parsed) {
30
184
  const now = Date.now();
31
- const normalizeAccountRecord = (candidate) => {
32
- if (!candidate || typeof candidate !== "object")
33
- return null;
34
- const record = candidate;
35
- const refreshTokenRaw = typeof record.refreshToken === "string"
36
- ? record.refreshToken
37
- : typeof record.refresh_token === "string"
38
- ? record.refresh_token
39
- : typeof record.refresh === "string"
40
- ? record.refresh
41
- : undefined;
42
- const refreshToken = typeof refreshTokenRaw === "string" ? refreshTokenRaw.trim() : "";
43
- if (!refreshToken)
44
- return null;
45
- const accountIdRaw = typeof record.accountId === "string"
46
- ? record.accountId
47
- : typeof record.account_id === "string"
48
- ? record.account_id
49
- : undefined;
50
- const accountId = typeof accountIdRaw === "string" && accountIdRaw.trim() ? accountIdRaw : undefined;
51
- const emailRaw = typeof record.email === "string" ? record.email : undefined;
52
- const email = typeof emailRaw === "string" && emailRaw.trim() ? emailRaw : undefined;
53
- const planRaw = typeof record.plan === "string"
54
- ? record.plan
55
- : typeof record.chatgpt_plan_type === "string"
56
- ? record.chatgpt_plan_type
57
- : undefined;
58
- const plan = typeof planRaw === "string" && planRaw.trim() ? planRaw : undefined;
59
- const addedAt = typeof record.addedAt === "number" && Number.isFinite(record.addedAt)
60
- ? record.addedAt
61
- : now;
62
- const lastUsed = typeof record.lastUsed === "number" && Number.isFinite(record.lastUsed)
63
- ? record.lastUsed
64
- : now;
65
- const lastSwitchReason = typeof record.lastSwitchReason === "string" ? record.lastSwitchReason : undefined;
66
- const rateLimitResetTimes = record.rateLimitResetTimes && typeof record.rateLimitResetTimes === "object"
67
- ? record.rateLimitResetTimes
68
- : undefined;
69
- const coolingDownUntil = typeof record.coolingDownUntil === "number" && Number.isFinite(record.coolingDownUntil)
70
- ? record.coolingDownUntil
71
- : undefined;
72
- const cooldownReasonRaw = typeof record.cooldownReason === "string" ? record.cooldownReason : undefined;
73
- const cooldownReason = cooldownReasonRaw === "auth-failure" ? "auth-failure" : undefined;
74
- return {
75
- refreshToken,
76
- accountId,
77
- email,
78
- plan,
79
- addedAt: Math.max(0, Math.floor(addedAt)),
80
- lastUsed: Math.max(0, Math.floor(lastUsed)),
81
- lastSwitchReason: lastSwitchReason === "rate-limit" ||
82
- lastSwitchReason === "initial" ||
83
- lastSwitchReason === "rotation"
84
- ? lastSwitchReason
85
- : undefined,
86
- rateLimitResetTimes,
87
- coolingDownUntil,
88
- cooldownReason,
89
- };
90
- };
91
185
  let accountsSource;
92
186
  let activeIndexSource = 0;
93
187
  let activeIndexByFamilySource = undefined;
@@ -105,18 +199,51 @@ function normalizeStorage(parsed) {
105
199
  }
106
200
  if (!Array.isArray(accountsSource))
107
201
  return null;
108
- const accounts = accountsSource
109
- .map(normalizeAccountRecord)
202
+ const normalizedAccounts = accountsSource
203
+ .map((entry) => normalizeAccountRecord(entry, now))
110
204
  .filter((a) => a !== null);
111
- if (accounts.length === 0)
112
- return null;
113
- const activeIndex = typeof activeIndexSource === "number" && Number.isFinite(activeIndexSource)
205
+ const activeIndexRaw = typeof activeIndexSource === "number" && Number.isFinite(activeIndexSource)
114
206
  ? Math.max(0, Math.floor(activeIndexSource))
115
207
  : 0;
116
- const clampedActiveIndex = accounts.length > 0 ? Math.min(activeIndex, accounts.length - 1) : 0;
117
- const activeIndexByFamily = activeIndexByFamilySource && typeof activeIndexByFamilySource === "object"
208
+ const activeIndexClamped = normalizedAccounts.length > 0
209
+ ? Math.min(activeIndexRaw, normalizedAccounts.length - 1)
210
+ : 0;
211
+ const activeCandidate = normalizedAccounts[activeIndexClamped] ?? null;
212
+ const activeIndexByFamilyRaw = (activeIndexByFamilySource && typeof activeIndexByFamilySource === "object"
118
213
  ? activeIndexByFamilySource
119
- : {};
214
+ : {}) ?? {};
215
+ const activeCandidatesByFamily = {};
216
+ for (const [family, index] of Object.entries(activeIndexByFamilyRaw)) {
217
+ if (typeof index !== "number" || !Number.isFinite(index))
218
+ continue;
219
+ const clamped = normalizedAccounts.length > 0
220
+ ? Math.min(Math.max(0, Math.floor(index)), normalizedAccounts.length - 1)
221
+ : 0;
222
+ const candidate = normalizedAccounts[clamped];
223
+ if (candidate)
224
+ activeCandidatesByFamily[family] = candidate;
225
+ }
226
+ const { accounts } = dedupeRefreshTokens(normalizedAccounts);
227
+ if (accounts.length === 0)
228
+ return null;
229
+ const mappedActiveIndex = activeCandidate
230
+ ? findAccountMatchIndex(accounts, {
231
+ accountId: activeCandidate.accountId,
232
+ plan: activeCandidate.plan,
233
+ email: activeCandidate.email,
234
+ })
235
+ : -1;
236
+ const fallbackActiveIndex = accounts.length > 0 ? Math.min(activeIndexRaw, accounts.length - 1) : 0;
237
+ const clampedActiveIndex = mappedActiveIndex >= 0 ? mappedActiveIndex : fallbackActiveIndex;
238
+ const activeIndexByFamily = {};
239
+ for (const [family, candidate] of Object.entries(activeCandidatesByFamily)) {
240
+ const mappedIndex = findAccountMatchIndex(accounts, {
241
+ accountId: candidate.accountId,
242
+ plan: candidate.plan,
243
+ email: candidate.email,
244
+ });
245
+ activeIndexByFamily[family] = mappedIndex >= 0 ? mappedIndex : clampedActiveIndex;
246
+ }
120
247
  return {
121
248
  version: 3,
122
249
  accounts,
@@ -135,6 +262,33 @@ function areRateLimitStatesEqual(left, right) {
135
262
  }
136
263
  return true;
137
264
  }
265
+ function shouldPreferDuplicate(candidate, existing) {
266
+ if (candidate.lastUsed !== existing.lastUsed) {
267
+ return candidate.lastUsed > existing.lastUsed;
268
+ }
269
+ return candidate.addedAt > existing.addedAt;
270
+ }
271
+ function dedupeRefreshTokens(accounts) {
272
+ const deduped = [];
273
+ const indexByToken = new Map();
274
+ let changed = false;
275
+ for (const account of accounts) {
276
+ const existingIndex = indexByToken.get(account.refreshToken);
277
+ if (existingIndex === undefined) {
278
+ indexByToken.set(account.refreshToken, deduped.length);
279
+ deduped.push(account);
280
+ continue;
281
+ }
282
+ const existing = deduped[existingIndex];
283
+ if (!existing)
284
+ continue;
285
+ if (shouldPreferDuplicate(account, existing)) {
286
+ deduped[existingIndex] = account;
287
+ }
288
+ changed = true;
289
+ }
290
+ return { accounts: deduped, changed };
291
+ }
138
292
  function mergeAccounts(existing, incoming) {
139
293
  const merged = existing.map((account) => ({ ...account }));
140
294
  let changed = false;
@@ -152,7 +306,7 @@ function mergeAccounts(existing, incoming) {
152
306
  const current = merged[matchIndex];
153
307
  const updated = { ...current };
154
308
  let didUpdate = false;
155
- if (!updated.refreshToken && candidate.refreshToken) {
309
+ if (candidate.refreshToken && candidate.refreshToken !== updated.refreshToken) {
156
310
  updated.refreshToken = candidate.refreshToken;
157
311
  didUpdate = true;
158
312
  }
@@ -168,6 +322,10 @@ function mergeAccounts(existing, incoming) {
168
322
  updated.plan = candidate.plan;
169
323
  didUpdate = true;
170
324
  }
325
+ if (typeof candidate.enabled === "boolean" && candidate.enabled !== updated.enabled) {
326
+ updated.enabled = candidate.enabled;
327
+ didUpdate = true;
328
+ }
171
329
  const candidateAddedAt = typeof candidate.addedAt === "number" && Number.isFinite(candidate.addedAt)
172
330
  ? candidate.addedAt
173
331
  : undefined;
@@ -195,10 +353,14 @@ function mergeAccounts(existing, incoming) {
195
353
  didUpdate = true;
196
354
  }
197
355
  if (candidate.rateLimitResetTimes) {
198
- const mergedRateLimits = {
199
- ...candidate.rateLimitResetTimes,
200
- ...updated.rateLimitResetTimes,
201
- };
356
+ const mergedRateLimits = { ...(updated.rateLimitResetTimes ?? {}) };
357
+ for (const [key, value] of Object.entries(candidate.rateLimitResetTimes)) {
358
+ if (typeof value !== "number")
359
+ continue;
360
+ const currentValue = mergedRateLimits[key];
361
+ mergedRateLimits[key] =
362
+ typeof currentValue === "number" ? Math.max(currentValue, value) : value;
363
+ }
202
364
  const currentRateLimits = updated.rateLimitResetTimes ?? {};
203
365
  if (!areRateLimitStatesEqual(currentRateLimits, mergedRateLimits)) {
204
366
  updated.rateLimitResetTimes = mergedRateLimits;
@@ -225,31 +387,54 @@ function mergeAccounts(existing, incoming) {
225
387
  changed = true;
226
388
  }
227
389
  }
228
- return { accounts: merged, changed };
390
+ const deduped = dedupeRefreshTokens(merged);
391
+ if (deduped.changed)
392
+ changed = true;
393
+ return { accounts: deduped.accounts, changed };
394
+ }
395
+ function mergeAccountStorage(existing, incoming) {
396
+ const { accounts: mergedAccounts } = mergeAccounts(existing.accounts, incoming.accounts);
397
+ const incomingActive = incoming.activeIndex >= 0 && incoming.activeIndex < incoming.accounts.length
398
+ ? incoming.accounts[incoming.activeIndex]
399
+ : null;
400
+ const mappedIndex = incomingActive
401
+ ? findAccountMatchIndex(mergedAccounts, {
402
+ accountId: incomingActive.accountId,
403
+ plan: incomingActive.plan,
404
+ email: incomingActive.email,
405
+ })
406
+ : -1;
407
+ const baseActiveIndex = mappedIndex >= 0
408
+ ? mappedIndex
409
+ : incoming.accounts.length > 0
410
+ ? incoming.activeIndex
411
+ : existing.activeIndex;
412
+ const activeIndex = mergedAccounts.length > 0
413
+ ? Math.min(Math.max(0, baseActiveIndex), mergedAccounts.length - 1)
414
+ : 0;
415
+ const activeIndexByFamily = {
416
+ ...(existing.activeIndexByFamily ?? {}),
417
+ ...(incoming.activeIndexByFamily ?? {}),
418
+ };
419
+ return {
420
+ version: 3,
421
+ accounts: mergedAccounts,
422
+ activeIndex,
423
+ activeIndexByFamily,
424
+ };
229
425
  }
230
426
  async function migrateLegacyAccountsFileIfNeeded() {
231
427
  const newPath = getStoragePath();
232
428
  const legacyPath = getLegacyStoragePath();
233
- const newExists = existsSync(newPath);
234
- const legacyExists = existsSync(legacyPath);
235
- if (!legacyExists)
429
+ if (!existsSync(legacyPath))
236
430
  return;
237
- if (!newExists) {
238
- await fs.mkdir(dirname(newPath), { recursive: true });
239
- try {
240
- await fs.rename(legacyPath, newPath);
241
- }
242
- catch {
243
- try {
244
- await fs.copyFile(legacyPath, newPath);
245
- await fs.unlink(legacyPath);
246
- }
247
- catch {
248
- // Best-effort; ignore.
249
- }
250
- }
431
+ await withFileLock(newPath, async () => {
432
+ await migrateLegacyAccountsFileIfNeededLocked(newPath, legacyPath);
433
+ });
434
+ }
435
+ async function migrateLegacyAccountsFileIfNeededLocked(newPath, legacyPath) {
436
+ if (!existsSync(legacyPath))
251
437
  return;
252
- }
253
438
  try {
254
439
  const [newRaw, legacyRaw] = await Promise.all([
255
440
  fs.readFile(newPath, "utf-8"),
@@ -262,6 +447,12 @@ async function migrateLegacyAccountsFileIfNeeded() {
262
447
  if (!newStorage) {
263
448
  debug("[StorageMigration] New storage invalid, adopting legacy accounts");
264
449
  await fs.writeFile(newPath, JSON.stringify(legacyStorage, null, 2), "utf-8");
450
+ try {
451
+ await fs.unlink(legacyPath);
452
+ }
453
+ catch {
454
+ // Best-effort; ignore.
455
+ }
265
456
  return;
266
457
  }
267
458
  const { accounts: mergedAccounts, changed } = mergeAccounts(newStorage.accounts, legacyStorage.accounts);
@@ -294,6 +485,178 @@ async function migrateLegacyAccountsFileIfNeeded() {
294
485
  // Best-effort; ignore.
295
486
  }
296
487
  }
488
+ async function loadAccountsUnsafe(filePath) {
489
+ try {
490
+ const raw = await fs.readFile(filePath, "utf-8");
491
+ const parsed = JSON.parse(raw);
492
+ return normalizeStorage(parsed);
493
+ }
494
+ catch {
495
+ return null;
496
+ }
497
+ }
498
+ function extractAccountsSource(parsed) {
499
+ if (Array.isArray(parsed)) {
500
+ return { accountsSource: parsed, activeIndexSource: 0, activeIndexByFamilySource: undefined };
501
+ }
502
+ if (parsed && typeof parsed === "object") {
503
+ const storage = parsed;
504
+ if (!Array.isArray(storage.accounts))
505
+ return null;
506
+ return {
507
+ accountsSource: storage.accounts,
508
+ activeIndexSource: storage.activeIndex,
509
+ activeIndexByFamilySource: storage.activeIndexByFamily,
510
+ };
511
+ }
512
+ return null;
513
+ }
514
+ export async function inspectAccountsFile() {
515
+ const filePath = getStoragePath();
516
+ if (!existsSync(filePath)) {
517
+ return { status: "missing", corruptEntries: [], legacyEntries: [], validEntries: [] };
518
+ }
519
+ try {
520
+ const raw = await fs.readFile(filePath, "utf-8");
521
+ const parsed = JSON.parse(raw);
522
+ const source = extractAccountsSource(parsed);
523
+ if (!source) {
524
+ return {
525
+ status: "corrupt-file",
526
+ reason: "invalid-shape",
527
+ corruptEntries: [],
528
+ legacyEntries: [],
529
+ validEntries: [],
530
+ };
531
+ }
532
+ const now = Date.now();
533
+ const corruptEntries = [];
534
+ const legacyEntries = [];
535
+ const validEntries = [];
536
+ for (const entry of source.accountsSource) {
537
+ const normalized = normalizeAccountRecord(entry, now);
538
+ if (!normalized) {
539
+ corruptEntries.push(entry);
540
+ continue;
541
+ }
542
+ if (!hasCompleteIdentity(normalized)) {
543
+ legacyEntries.push(normalized);
544
+ continue;
545
+ }
546
+ validEntries.push(normalized);
547
+ }
548
+ if (corruptEntries.length > 0 || legacyEntries.length > 0) {
549
+ return {
550
+ status: "needs-repair",
551
+ corruptEntries,
552
+ legacyEntries,
553
+ validEntries,
554
+ };
555
+ }
556
+ return { status: "ok", corruptEntries: [], legacyEntries: [], validEntries };
557
+ }
558
+ catch {
559
+ return {
560
+ status: "corrupt-file",
561
+ reason: "parse-error",
562
+ corruptEntries: [],
563
+ legacyEntries: [],
564
+ validEntries: [],
565
+ };
566
+ }
567
+ }
568
+ async function writeAccountsFile(storage) {
569
+ const filePath = getStoragePath();
570
+ await withFileLock(filePath, async () => {
571
+ const jsonContent = JSON.stringify(storage, null, 2);
572
+ const tmpPath = `${filePath}.${randomBytes(6).toString("hex")}.tmp`;
573
+ try {
574
+ await fs.writeFile(tmpPath, jsonContent, "utf-8");
575
+ await fs.rename(tmpPath, filePath);
576
+ }
577
+ catch (error) {
578
+ try {
579
+ await fs.unlink(tmpPath);
580
+ }
581
+ catch {
582
+ // ignore cleanup errors
583
+ }
584
+ throw error;
585
+ }
586
+ });
587
+ }
588
+ export async function writeQuarantineFile(records, reason) {
589
+ const filePath = getStoragePath();
590
+ const quarantinePath = `${filePath}.quarantine-${Date.now()}.json`;
591
+ await fs.mkdir(dirname(filePath), { recursive: true });
592
+ const payload = {
593
+ reason,
594
+ quarantinedAt: new Date().toISOString(),
595
+ records,
596
+ };
597
+ if (existsSync(filePath)) {
598
+ await withFileLock(filePath, async () => {
599
+ await fs.writeFile(quarantinePath, JSON.stringify(payload, null, 2), "utf-8");
600
+ await ensurePrivateFileMode(quarantinePath);
601
+ await cleanupQuarantineFiles(filePath);
602
+ });
603
+ return quarantinePath;
604
+ }
605
+ await fs.writeFile(quarantinePath, JSON.stringify(payload, null, 2), "utf-8");
606
+ await ensurePrivateFileMode(quarantinePath);
607
+ await cleanupQuarantineFiles(filePath);
608
+ return quarantinePath;
609
+ }
610
+ export async function replaceAccountsFile(storage) {
611
+ await writeAccountsFile(storage);
612
+ }
613
+ export async function quarantineCorruptFile() {
614
+ const filePath = getStoragePath();
615
+ if (!existsSync(filePath))
616
+ return null;
617
+ const quarantinePath = `${filePath}.quarantine-${Date.now()}.json`;
618
+ await withFileLock(filePath, async () => {
619
+ if (!existsSync(filePath))
620
+ return;
621
+ await fs.copyFile(filePath, quarantinePath);
622
+ await ensurePrivateFileMode(quarantinePath);
623
+ await cleanupQuarantineFiles(filePath);
624
+ });
625
+ await writeAccountsFile({
626
+ version: 3,
627
+ accounts: [],
628
+ activeIndex: 0,
629
+ activeIndexByFamily: {},
630
+ });
631
+ return quarantinePath;
632
+ }
633
+ export async function autoQuarantineCorruptAccountsFile() {
634
+ const inspection = await inspectAccountsFile();
635
+ if (inspection.status !== "corrupt-file")
636
+ return null;
637
+ return await quarantineCorruptFile();
638
+ }
639
+ export async function quarantineAccounts(storage, entries, reason) {
640
+ if (!entries.length) {
641
+ return { storage, quarantinePath: await writeQuarantineFile([], reason) };
642
+ }
643
+ const tokens = new Set(entries.map((entry) => entry.refreshToken));
644
+ const remaining = storage.accounts.filter((account) => !tokens.has(account.refreshToken));
645
+ const normalized = normalizeStorage({
646
+ accounts: remaining,
647
+ activeIndex: storage.activeIndex,
648
+ activeIndexByFamily: storage.activeIndexByFamily,
649
+ });
650
+ const updated = normalized ?? {
651
+ version: 3,
652
+ accounts: [],
653
+ activeIndex: 0,
654
+ activeIndexByFamily: {},
655
+ };
656
+ const quarantinePath = await writeQuarantineFile(entries, reason);
657
+ await writeAccountsFile(updated);
658
+ return { storage: updated, quarantinePath };
659
+ }
297
660
  export async function loadAccounts() {
298
661
  await migrateLegacyAccountsFileIfNeeded();
299
662
  const filePath = getStoragePath();
@@ -311,26 +674,57 @@ export async function loadAccounts() {
311
674
  return null;
312
675
  }
313
676
  }
677
+ export function toggleAccountEnabled(storage, index) {
678
+ if (!storage?.accounts)
679
+ return null;
680
+ if (!Number.isFinite(index))
681
+ return null;
682
+ const targetIndex = Math.floor(index);
683
+ if (targetIndex < 0 || targetIndex >= storage.accounts.length)
684
+ return null;
685
+ const accounts = storage.accounts.map((account, idx) => {
686
+ if (idx !== targetIndex)
687
+ return account;
688
+ const enabled = account.enabled === false ? true : false;
689
+ return { ...account, enabled };
690
+ });
691
+ return { ...storage, accounts };
692
+ }
314
693
  export async function saveAccounts(storage) {
315
694
  const filePath = getStoragePath();
316
695
  debug(`[SaveAccounts] Saving to ${filePath} with ${storage.accounts.length} accounts`);
317
696
  try {
318
- await fs.mkdir(dirname(filePath), { recursive: true });
319
- const jsonContent = JSON.stringify(storage, null, 2);
320
- debug(`[SaveAccounts] Writing ${jsonContent.length} bytes`);
321
- const tmpPath = `${filePath}.tmp`;
322
- await fs.writeFile(tmpPath, jsonContent, "utf-8");
323
- await fs.rename(tmpPath, filePath);
324
- if (AUTH_DEBUG_ENABLED) {
325
- const verifyContent = await fs.readFile(filePath, "utf-8");
326
- const verifyStorage = normalizeStorage(JSON.parse(verifyContent));
327
- if (verifyStorage) {
328
- debug(`[SaveAccounts] Verification successful - ${verifyStorage.accounts.length} accounts in file`);
697
+ await withFileLock(filePath, async () => {
698
+ await migrateLegacyAccountsFileIfNeededLocked(filePath, getLegacyStoragePath());
699
+ const existing = await loadAccountsUnsafe(filePath);
700
+ const mergedStorage = existing ? mergeAccountStorage(existing, storage) : storage;
701
+ const jsonContent = JSON.stringify(mergedStorage, null, 2);
702
+ debug(`[SaveAccounts] Writing ${jsonContent.length} bytes`);
703
+ const tmpPath = `${filePath}.${randomBytes(6).toString("hex")}.tmp`;
704
+ try {
705
+ await fs.writeFile(tmpPath, jsonContent, "utf-8");
706
+ await fs.rename(tmpPath, filePath);
329
707
  }
330
- else {
331
- debug("[SaveAccounts] Verification failed - invalid storage format");
708
+ catch (error) {
709
+ try {
710
+ await fs.unlink(tmpPath);
711
+ }
712
+ catch {
713
+ // ignore cleanup errors
714
+ }
715
+ throw error;
332
716
  }
333
- }
717
+ if (AUTH_DEBUG_ENABLED) {
718
+ const verifyContent = await fs.readFile(filePath, "utf-8");
719
+ const verifyStorage = normalizeStorage(JSON.parse(verifyContent));
720
+ if (verifyStorage) {
721
+ debug(`[SaveAccounts] Verification successful - ${verifyStorage.accounts.length} accounts in file`);
722
+ }
723
+ else {
724
+ debug("[SaveAccounts] Verification failed - invalid storage format");
725
+ }
726
+ }
727
+ });
334
728
  }
335
729
  catch (error) {
336
730
  console.error("[SaveAccounts] Error saving accounts:", error);