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.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +281 -41
- package/dist/index.js.map +1 -1
- package/dist/lib/accounts.d.ts +8 -0
- package/dist/lib/accounts.d.ts.map +1 -1
- package/dist/lib/accounts.js +179 -20
- 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 +16 -0
- package/dist/lib/auth/auth.js.map +1 -1
- package/dist/lib/cli.d.ts +4 -0
- package/dist/lib/cli.d.ts.map +1 -1
- package/dist/lib/cli.js +20 -0
- package/dist/lib/cli.js.map +1 -1
- package/dist/lib/codex-status.d.ts +34 -0
- package/dist/lib/codex-status.d.ts.map +1 -0
- package/dist/lib/codex-status.js +125 -0
- package/dist/lib/codex-status.js.map +1 -0
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +4 -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/storage-scope.d.ts +5 -0
- package/dist/lib/storage-scope.d.ts.map +1 -0
- package/dist/lib/storage-scope.js +11 -0
- package/dist/lib/storage-scope.js.map +1 -0
- package/dist/lib/storage.d.ts +39 -1
- package/dist/lib/storage.d.ts.map +1 -1
- package/dist/lib/storage.js +495 -116
- package/dist/lib/storage.js.map +1 -1
- package/dist/lib/types.d.ts +7 -0
- package/dist/lib/types.d.ts.map +1 -1
- package/package.json +8 -4
package/dist/lib/storage.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
:
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
365
|
-
...(
|
|
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
|
-
|
|
378
|
-
const legacyExists = existsSync(legacyPath);
|
|
379
|
-
if (!legacyExists)
|
|
551
|
+
if (!existsSync(legacyPath))
|
|
380
552
|
return;
|
|
381
|
-
|
|
382
|
-
await
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
489
|
-
const
|
|
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
|
-
|
|
494
|
-
|
|
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));
|