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.
- package/README.md +198 -85
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1623 -50
- package/dist/index.js.map +1 -1
- package/dist/lib/accounts.d.ts +16 -0
- package/dist/lib/accounts.d.ts.map +1 -1
- package/dist/lib/accounts.js +60 -0
- package/dist/lib/accounts.js.map +1 -1
- package/dist/lib/config.d.ts +4 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +36 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/refresh-queue.d.ts +16 -0
- package/dist/lib/refresh-queue.d.ts.map +1 -1
- package/dist/lib/refresh-queue.js +46 -0
- package/dist/lib/refresh-queue.js.map +1 -1
- package/dist/lib/request/retry-budget.d.ts +19 -0
- package/dist/lib/request/retry-budget.d.ts.map +1 -0
- package/dist/lib/request/retry-budget.js +99 -0
- package/dist/lib/request/retry-budget.js.map +1 -0
- package/dist/lib/schemas.d.ts +26 -0
- package/dist/lib/schemas.d.ts.map +1 -1
- package/dist/lib/schemas.js +28 -0
- package/dist/lib/schemas.js.map +1 -1
- package/dist/lib/storage/migrations.d.ts +4 -0
- package/dist/lib/storage/migrations.d.ts.map +1 -1
- package/dist/lib/storage/migrations.js +2 -0
- package/dist/lib/storage/migrations.js.map +1 -1
- package/dist/lib/storage.d.ts +31 -5
- package/dist/lib/storage.d.ts.map +1 -1
- package/dist/lib/storage.js +354 -71
- package/dist/lib/storage.js.map +1 -1
- package/dist/lib/ui/auth-menu.d.ts.map +1 -1
- package/dist/lib/ui/auth-menu.js +23 -5
- package/dist/lib/ui/auth-menu.js.map +1 -1
- package/dist/lib/ui/beginner.d.ts +57 -0
- package/dist/lib/ui/beginner.d.ts.map +1 -0
- package/dist/lib/ui/beginner.js +230 -0
- package/dist/lib/ui/beginner.js.map +1 -0
- package/package.json +1 -1
package/dist/lib/storage.js
CHANGED
|
@@ -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
|
|
311
|
-
const
|
|
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
|
-
|
|
485
|
+
keys.push(`organizationId:${organizationId}`);
|
|
318
486
|
}
|
|
319
487
|
const accountId = typeof account.accountId === "string" ? account.accountId.trim() : "";
|
|
320
488
|
if (accountId) {
|
|
321
|
-
|
|
489
|
+
keys.push(`accountId:${accountId}`);
|
|
322
490
|
}
|
|
323
491
|
const refreshToken = typeof account.refreshToken === "string" ? account.refreshToken.trim() : "";
|
|
324
492
|
if (refreshToken) {
|
|
325
|
-
|
|
493
|
+
keys.push(`refreshToken:${refreshToken}`);
|
|
326
494
|
}
|
|
327
|
-
return
|
|
495
|
+
return keys;
|
|
496
|
+
}
|
|
497
|
+
function toAccountIdentityKey(account) {
|
|
498
|
+
return toAccountIdentityKeys(account)[0];
|
|
328
499
|
}
|
|
329
|
-
function
|
|
500
|
+
function extractActiveKeys(accounts, activeIndex) {
|
|
330
501
|
const candidate = accounts[activeIndex];
|
|
331
502
|
if (!isRecord(candidate))
|
|
332
|
-
return
|
|
333
|
-
return
|
|
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
|
|
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 (
|
|
376
|
-
const mappedIndex = deduplicatedAccounts
|
|
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
|
|
574
|
+
const familyKeys = extractActiveKeys(rawAccounts, clampedRawIndex);
|
|
393
575
|
let mappedIndex = clampIndex(rawIndex, deduplicatedAccounts.length);
|
|
394
|
-
if (
|
|
395
|
-
const idx = deduplicatedAccounts
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
|
739
|
-
const existingActiveIndexByFamily =
|
|
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 (
|
|
752
|
-
const idx = deduplicatedAccounts
|
|
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
|
|
765
|
-
if (
|
|
766
|
-
const idx = deduplicatedAccounts
|
|
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 {
|
|
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
|
-
|
|
784
|
-
|
|
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
|