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.
- package/README.md +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +356 -48
- package/dist/index.js.map +1 -1
- package/dist/lib/account-matching.d.ts.map +1 -1
- package/dist/lib/account-matching.js +3 -37
- package/dist/lib/account-matching.js.map +1 -1
- package/dist/lib/accounts.d.ts +22 -1
- package/dist/lib/accounts.d.ts.map +1 -1
- package/dist/lib/accounts.js +291 -56
- package/dist/lib/accounts.js.map +1 -1
- package/dist/lib/auth/auth.d.ts +5 -0
- package/dist/lib/auth/auth.d.ts.map +1 -1
- package/dist/lib/auth/auth.js +19 -1
- package/dist/lib/auth/auth.js.map +1 -1
- package/dist/lib/cli.d.ts +7 -1
- package/dist/lib/cli.d.ts.map +1 -1
- package/dist/lib/cli.js +57 -3
- package/dist/lib/cli.js.map +1 -1
- package/dist/lib/config.d.ts +8 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +35 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/formatting.d.ts +9 -0
- package/dist/lib/formatting.d.ts.map +1 -0
- package/dist/lib/formatting.js +71 -0
- package/dist/lib/formatting.js.map +1 -0
- package/dist/lib/oauth-success.html +1 -1
- package/dist/lib/rate-limit.d.ts +36 -0
- package/dist/lib/rate-limit.d.ts.map +1 -0
- package/dist/lib/rate-limit.js +78 -0
- package/dist/lib/rate-limit.js.map +1 -0
- package/dist/lib/refresh-queue.d.ts +37 -0
- package/dist/lib/refresh-queue.d.ts.map +1 -0
- package/dist/lib/refresh-queue.js +83 -0
- package/dist/lib/refresh-queue.js.map +1 -0
- package/dist/lib/rotation.d.ts +1 -1
- package/dist/lib/rotation.d.ts.map +1 -1
- package/dist/lib/rotation.js +15 -3
- package/dist/lib/rotation.js.map +1 -1
- package/dist/lib/storage.d.ts +20 -0
- package/dist/lib/storage.d.ts.map +1 -1
- package/dist/lib/storage.js +502 -108
- package/dist/lib/storage.js.map +1 -1
- package/dist/lib/types.d.ts +45 -0
- package/dist/lib/types.d.ts.map +1 -1
- package/package.json +11 -5
package/dist/lib/storage.js
CHANGED
|
@@ -1,15 +1,157 @@
|
|
|
1
|
-
import {
|
|
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
|
|
109
|
-
.map(normalizeAccountRecord)
|
|
202
|
+
const normalizedAccounts = accountsSource
|
|
203
|
+
.map((entry) => normalizeAccountRecord(entry, now))
|
|
110
204
|
.filter((a) => a !== null);
|
|
111
|
-
|
|
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
|
|
117
|
-
|
|
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 (
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
const legacyExists = existsSync(legacyPath);
|
|
235
|
-
if (!legacyExists)
|
|
429
|
+
if (!existsSync(legacyPath))
|
|
236
430
|
return;
|
|
237
|
-
|
|
238
|
-
await
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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);
|