@vacbo/opencode-anthropic-fix 0.1.7 → 0.1.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 +88 -88
- package/dist/opencode-anthropic-auth-cli.mjs +804 -507
- package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
- package/package.json +67 -59
- package/src/__tests__/billing-edge-cases.test.ts +59 -59
- package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
- package/src/__tests__/cc-comparison.test.ts +87 -87
- package/src/__tests__/cc-credentials.test.ts +254 -250
- package/src/__tests__/cch-drift-checker.test.ts +51 -51
- package/src/__tests__/cch-native-style.test.ts +56 -56
- package/src/__tests__/debug-gating.test.ts +42 -42
- package/src/__tests__/decomposition-smoke.test.ts +68 -68
- package/src/__tests__/fingerprint-regression.test.ts +575 -566
- package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
- package/src/__tests__/helpers/conversation-history.ts +119 -119
- package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
- package/src/__tests__/helpers/deferred.ts +69 -69
- package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
- package/src/__tests__/helpers/in-memory-storage.ts +88 -88
- package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
- package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
- package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
- package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
- package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
- package/src/__tests__/helpers/sse.ts +209 -209
- package/src/__tests__/index.parallel.test.ts +605 -595
- package/src/__tests__/sanitization-regex.test.ts +112 -112
- package/src/__tests__/state-bounds.test.ts +90 -90
- package/src/account-identity.test.ts +197 -192
- package/src/account-identity.ts +69 -67
- package/src/account-state.test.ts +86 -86
- package/src/account-state.ts +25 -25
- package/src/accounts/matching.test.ts +335 -0
- package/src/accounts/matching.ts +167 -0
- package/src/accounts/persistence.test.ts +345 -0
- package/src/accounts/persistence.ts +432 -0
- package/src/accounts/repair.test.ts +276 -0
- package/src/accounts/repair.ts +407 -0
- package/src/accounts.dedup.test.ts +621 -621
- package/src/accounts.test.ts +933 -929
- package/src/accounts.ts +633 -989
- package/src/backoff.test.ts +345 -345
- package/src/backoff.ts +219 -219
- package/src/betas.ts +124 -124
- package/src/bun-fetch.test.ts +345 -342
- package/src/bun-fetch.ts +424 -424
- package/src/bun-proxy.test.ts +25 -25
- package/src/bun-proxy.ts +209 -209
- package/src/cc-credentials.ts +111 -111
- package/src/circuit-breaker.test.ts +184 -184
- package/src/circuit-breaker.ts +169 -169
- package/src/cli/commands/auth.ts +963 -0
- package/src/cli/commands/config.ts +547 -0
- package/src/cli/formatting.test.ts +406 -0
- package/src/cli/formatting.ts +219 -0
- package/src/cli.ts +255 -2022
- package/src/commands/handlers/betas.ts +100 -0
- package/src/commands/handlers/config.ts +99 -0
- package/src/commands/handlers/files.ts +375 -0
- package/src/commands/oauth-flow.ts +181 -166
- package/src/commands/prompts.ts +61 -61
- package/src/commands/router.test.ts +421 -0
- package/src/commands/router.ts +143 -635
- package/src/config.test.ts +482 -482
- package/src/config.ts +412 -404
- package/src/constants.ts +48 -48
- package/src/drift/cch-constants.ts +95 -95
- package/src/env.ts +111 -105
- package/src/headers/billing.ts +33 -33
- package/src/headers/builder.ts +130 -130
- package/src/headers/cch.ts +75 -75
- package/src/headers/stainless.ts +25 -25
- package/src/headers/user-agent.ts +23 -23
- package/src/index.ts +436 -828
- package/src/models.ts +27 -27
- package/src/oauth.test.ts +102 -102
- package/src/oauth.ts +178 -178
- package/src/parent-pid-watcher.test.ts +148 -148
- package/src/parent-pid-watcher.ts +69 -69
- package/src/plugin-helpers.ts +82 -82
- package/src/refresh-helpers.ts +145 -139
- package/src/refresh-lock.test.ts +94 -94
- package/src/refresh-lock.ts +93 -93
- package/src/request/body.history.test.ts +579 -571
- package/src/request/body.ts +255 -255
- package/src/request/metadata.ts +65 -65
- package/src/request/retry.test.ts +156 -156
- package/src/request/retry.ts +67 -67
- package/src/request/url.ts +21 -21
- package/src/request-orchestration-helpers.ts +648 -0
- package/src/response/index.ts +5 -5
- package/src/response/mcp.ts +58 -58
- package/src/response/streaming.test.ts +313 -311
- package/src/response/streaming.ts +412 -410
- package/src/rotation.test.ts +304 -301
- package/src/rotation.ts +205 -205
- package/src/storage.test.ts +547 -547
- package/src/storage.ts +315 -291
- package/src/system-prompt/builder.ts +38 -38
- package/src/system-prompt/index.ts +5 -5
- package/src/system-prompt/normalize.ts +60 -60
- package/src/system-prompt/sanitize.ts +30 -30
- package/src/thinking.ts +21 -20
- package/src/token-refresh.test.ts +265 -265
- package/src/token-refresh.ts +219 -214
- package/src/types.ts +30 -30
- package/dist/bun-proxy.mjs +0 -291
package/src/storage.ts
CHANGED
|
@@ -6,43 +6,43 @@ import { findByIdentity } from "./account-identity.js";
|
|
|
6
6
|
import { getConfigDir } from "./config.js";
|
|
7
7
|
|
|
8
8
|
export interface AccountStats {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
requests: number;
|
|
10
|
+
inputTokens: number;
|
|
11
|
+
outputTokens: number;
|
|
12
|
+
cacheReadTokens: number;
|
|
13
|
+
cacheWriteTokens: number;
|
|
14
|
+
lastReset: number;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export interface AccountMetadata {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
18
|
+
id: string;
|
|
19
|
+
email?: string;
|
|
20
|
+
identity?: AccountIdentity;
|
|
21
|
+
label?: string;
|
|
22
|
+
refreshToken: string;
|
|
23
|
+
access?: string;
|
|
24
|
+
expires?: number;
|
|
25
|
+
token_updated_at: number;
|
|
26
|
+
addedAt: number;
|
|
27
|
+
lastUsed: number;
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
rateLimitResetTimes: Record<string, number>;
|
|
30
|
+
consecutiveFailures: number;
|
|
31
|
+
lastFailureTime: number | null;
|
|
32
|
+
lastSwitchReason?: string;
|
|
33
|
+
stats: AccountStats;
|
|
34
|
+
source?: "cc-keychain" | "cc-file" | "oauth";
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export interface AccountStorage {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
version: number;
|
|
39
|
+
accounts: AccountMetadata[];
|
|
40
|
+
activeIndex: number;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export type StoredAccountMatchCandidate = Pick<
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
AccountMetadata,
|
|
45
|
+
"id" | "email" | "identity" | "label" | "refreshToken" | "addedAt" | "source"
|
|
46
46
|
>;
|
|
47
47
|
|
|
48
48
|
const CURRENT_VERSION = 1;
|
|
@@ -51,28 +51,28 @@ const CURRENT_VERSION = 1;
|
|
|
51
51
|
* Create a fresh stats object.
|
|
52
52
|
*/
|
|
53
53
|
export function createDefaultStats(now?: number): AccountStats {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
54
|
+
return {
|
|
55
|
+
requests: 0,
|
|
56
|
+
inputTokens: 0,
|
|
57
|
+
outputTokens: 0,
|
|
58
|
+
cacheReadTokens: 0,
|
|
59
|
+
cacheWriteTokens: 0,
|
|
60
|
+
lastReset: now ?? Date.now(),
|
|
61
|
+
};
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
function validateStats(raw: unknown, now: number): AccountStats {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
65
|
+
if (!raw || typeof raw !== "object") return createDefaultStats(now);
|
|
66
|
+
const s = raw as Record<string, unknown>;
|
|
67
|
+
const safeNum = (v: unknown) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0);
|
|
68
|
+
return {
|
|
69
|
+
requests: safeNum(s.requests),
|
|
70
|
+
inputTokens: safeNum(s.inputTokens),
|
|
71
|
+
outputTokens: safeNum(s.outputTokens),
|
|
72
|
+
cacheReadTokens: safeNum(s.cacheReadTokens),
|
|
73
|
+
cacheWriteTokens: safeNum(s.cacheWriteTokens),
|
|
74
|
+
lastReset: typeof s.lastReset === "number" && Number.isFinite(s.lastReset) ? s.lastReset : now,
|
|
75
|
+
};
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
const GITIGNORE_ENTRIES = [".gitignore", "anthropic-accounts.json", "anthropic-accounts.json.*.tmp"];
|
|
@@ -81,323 +81,347 @@ const GITIGNORE_ENTRIES = [".gitignore", "anthropic-accounts.json", "anthropic-a
|
|
|
81
81
|
* Get the path to the accounts storage file.
|
|
82
82
|
*/
|
|
83
83
|
export function getStoragePath(): string {
|
|
84
|
-
|
|
84
|
+
return join(getConfigDir(), "anthropic-accounts.json");
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
88
|
* Ensure .gitignore in the config directory includes our files.
|
|
89
89
|
*/
|
|
90
90
|
export function ensureGitignore(configDir: string): void {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
91
|
+
const gitignorePath = join(configDir, ".gitignore");
|
|
92
|
+
try {
|
|
93
|
+
let content = "";
|
|
94
|
+
let existingLines: string[] = [];
|
|
95
|
+
|
|
96
|
+
if (existsSync(gitignorePath)) {
|
|
97
|
+
content = readFileSync(gitignorePath, "utf-8");
|
|
98
|
+
existingLines = content.split("\n").map((line) => line.trim());
|
|
99
|
+
}
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
const missingEntries = GITIGNORE_ENTRIES.filter((entry) => !existingLines.includes(entry));
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
if (missingEntries.length === 0) return;
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
105
|
+
if (content === "") {
|
|
106
|
+
writeFileSync(gitignorePath, missingEntries.join("\n") + "\n", "utf-8");
|
|
107
|
+
} else {
|
|
108
|
+
const suffix = content.endsWith("\n") ? "" : "\n";
|
|
109
|
+
appendFileSync(gitignorePath, suffix + missingEntries.join("\n") + "\n", "utf-8");
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Ignore gitignore errors
|
|
110
113
|
}
|
|
111
|
-
} catch {
|
|
112
|
-
// Ignore gitignore errors
|
|
113
|
-
}
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
/**
|
|
117
117
|
* Deduplicate accounts by refresh token, keeping the most recently used.
|
|
118
118
|
*/
|
|
119
119
|
export function deduplicateByRefreshToken(accounts: AccountMetadata[]): AccountMetadata[] {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
120
|
+
const tokenMap = new Map<string, AccountMetadata>();
|
|
121
|
+
|
|
122
|
+
for (const acc of accounts) {
|
|
123
|
+
if (!acc.refreshToken) continue;
|
|
124
|
+
const existing = tokenMap.get(acc.refreshToken);
|
|
125
|
+
if (!existing || (acc.lastUsed || 0) > (existing.lastUsed || 0)) {
|
|
126
|
+
tokenMap.set(acc.refreshToken, acc);
|
|
127
|
+
}
|
|
127
128
|
}
|
|
128
|
-
}
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
return Array.from(tokenMap.values());
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
function validateAccount(raw: unknown, now: number): AccountMetadata | null {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
134
|
+
if (!raw || typeof raw !== "object") return null;
|
|
135
|
+
const acc = raw as Record<string, unknown>;
|
|
136
|
+
|
|
137
|
+
if (typeof acc.refreshToken !== "string" || !acc.refreshToken) return null;
|
|
138
|
+
|
|
139
|
+
const addedAt = typeof acc.addedAt === "number" && Number.isFinite(acc.addedAt) ? acc.addedAt : now;
|
|
140
|
+
|
|
141
|
+
const id =
|
|
142
|
+
typeof acc.id === "string" && acc.id ? acc.id : `${addedAt}:${(acc.refreshToken as string).slice(0, 12)}`;
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
id,
|
|
146
|
+
email: typeof acc.email === "string" ? acc.email : undefined,
|
|
147
|
+
identity: isAccountIdentity(acc.identity) ? acc.identity : undefined,
|
|
148
|
+
label: typeof acc.label === "string" ? acc.label : undefined,
|
|
149
|
+
refreshToken: acc.refreshToken as string,
|
|
150
|
+
access: typeof acc.access === "string" ? acc.access : undefined,
|
|
151
|
+
expires: typeof acc.expires === "number" && Number.isFinite(acc.expires) ? acc.expires : undefined,
|
|
152
|
+
token_updated_at:
|
|
153
|
+
typeof acc.token_updated_at === "number" && Number.isFinite(acc.token_updated_at)
|
|
154
|
+
? acc.token_updated_at
|
|
155
|
+
: typeof acc.tokenUpdatedAt === "number" && Number.isFinite(acc.tokenUpdatedAt)
|
|
156
|
+
? (acc.tokenUpdatedAt as number)
|
|
157
|
+
: addedAt,
|
|
158
|
+
addedAt,
|
|
159
|
+
lastUsed: typeof acc.lastUsed === "number" && Number.isFinite(acc.lastUsed) ? acc.lastUsed : 0,
|
|
160
|
+
enabled: acc.enabled !== false,
|
|
161
|
+
rateLimitResetTimes:
|
|
162
|
+
acc.rateLimitResetTimes &&
|
|
163
|
+
typeof acc.rateLimitResetTimes === "object" &&
|
|
164
|
+
!Array.isArray(acc.rateLimitResetTimes)
|
|
165
|
+
? (acc.rateLimitResetTimes as Record<string, number>)
|
|
166
|
+
: {},
|
|
167
|
+
consecutiveFailures:
|
|
168
|
+
typeof acc.consecutiveFailures === "number" ? Math.max(0, Math.floor(acc.consecutiveFailures)) : 0,
|
|
169
|
+
lastFailureTime: typeof acc.lastFailureTime === "number" ? acc.lastFailureTime : null,
|
|
170
|
+
lastSwitchReason: typeof acc.lastSwitchReason === "string" ? acc.lastSwitchReason : undefined,
|
|
171
|
+
stats: validateStats(acc.stats, now),
|
|
172
|
+
source:
|
|
173
|
+
acc.source === "cc-keychain" || acc.source === "cc-file" || acc.source === "oauth" ? acc.source : undefined,
|
|
174
|
+
};
|
|
171
175
|
}
|
|
172
176
|
|
|
173
177
|
function isAccountIdentity(value: unknown): value is AccountIdentity {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
178
|
+
if (!value || typeof value !== "object") return false;
|
|
179
|
+
|
|
180
|
+
const candidate = value as Record<string, unknown>;
|
|
181
|
+
switch (candidate.kind) {
|
|
182
|
+
case "oauth":
|
|
183
|
+
return typeof candidate.email === "string" && candidate.email.length > 0;
|
|
184
|
+
case "cc":
|
|
185
|
+
return (
|
|
186
|
+
(candidate.source === "cc-keychain" || candidate.source === "cc-file") &&
|
|
187
|
+
typeof candidate.label === "string"
|
|
188
|
+
);
|
|
189
|
+
case "legacy":
|
|
190
|
+
return typeof candidate.refreshToken === "string" && candidate.refreshToken.length > 0;
|
|
191
|
+
default:
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
189
194
|
}
|
|
190
195
|
|
|
191
196
|
function resolveStoredIdentity(candidate: StoredAccountMatchCandidate): AccountIdentity {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
197
|
+
if (isAccountIdentity(candidate.identity)) {
|
|
198
|
+
return candidate.identity;
|
|
199
|
+
}
|
|
195
200
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
201
|
+
if (candidate.source === "oauth" && candidate.email) {
|
|
202
|
+
return { kind: "oauth", email: candidate.email };
|
|
203
|
+
}
|
|
199
204
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
205
|
+
if ((candidate.source === "cc-keychain" || candidate.source === "cc-file") && candidate.label) {
|
|
206
|
+
return {
|
|
207
|
+
kind: "cc",
|
|
208
|
+
source: candidate.source,
|
|
209
|
+
label: candidate.label,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
207
212
|
|
|
208
|
-
|
|
213
|
+
return { kind: "legacy", refreshToken: candidate.refreshToken };
|
|
209
214
|
}
|
|
210
215
|
|
|
211
216
|
function resolveTokenUpdatedAt(account: Pick<AccountMetadata, "token_updated_at" | "addedAt">): number {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
217
|
+
return typeof account.token_updated_at === "number" && Number.isFinite(account.token_updated_at)
|
|
218
|
+
? account.token_updated_at
|
|
219
|
+
: account.addedAt;
|
|
215
220
|
}
|
|
216
221
|
|
|
217
222
|
function clampActiveIndex(accounts: AccountMetadata[], activeIndex: number): number {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
223
|
+
if (accounts.length === 0) {
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
221
226
|
|
|
222
|
-
|
|
227
|
+
return Math.max(0, Math.min(activeIndex, accounts.length - 1));
|
|
223
228
|
}
|
|
224
229
|
|
|
225
230
|
export function findStoredAccountMatch(
|
|
226
|
-
|
|
227
|
-
|
|
231
|
+
accounts: AccountMetadata[],
|
|
232
|
+
candidate: StoredAccountMatchCandidate,
|
|
228
233
|
): AccountMetadata | null {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
234
|
+
const byId = accounts.find((account) => account.id === candidate.id);
|
|
235
|
+
if (byId) {
|
|
236
|
+
return byId;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const byIdentity = findByIdentity(accounts, resolveStoredIdentity(candidate));
|
|
240
|
+
if (byIdentity) {
|
|
241
|
+
return byIdentity;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const byAddedAt = accounts.filter((account) => account.addedAt === candidate.addedAt);
|
|
245
|
+
if (byAddedAt.length === 1) {
|
|
246
|
+
return byAddedAt[0]!;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const byRefreshToken = accounts.find((account) => account.refreshToken === candidate.refreshToken);
|
|
250
|
+
if (byRefreshToken) {
|
|
251
|
+
return byRefreshToken;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return byAddedAt[0] ?? null;
|
|
250
255
|
}
|
|
251
256
|
|
|
252
257
|
export function mergeAccountWithFresherAuth(
|
|
253
|
-
|
|
254
|
-
|
|
258
|
+
account: AccountMetadata,
|
|
259
|
+
diskMatch: AccountMetadata | null,
|
|
255
260
|
): AccountMetadata {
|
|
256
|
-
|
|
257
|
-
|
|
261
|
+
const memoryTokenUpdatedAt = resolveTokenUpdatedAt(account);
|
|
262
|
+
const diskTokenUpdatedAt = diskMatch ? resolveTokenUpdatedAt(diskMatch) : 0;
|
|
263
|
+
|
|
264
|
+
if (!diskMatch || diskTokenUpdatedAt <= memoryTokenUpdatedAt) {
|
|
265
|
+
return {
|
|
266
|
+
...account,
|
|
267
|
+
token_updated_at: memoryTokenUpdatedAt,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
258
270
|
|
|
259
|
-
if (!diskMatch || diskTokenUpdatedAt <= memoryTokenUpdatedAt) {
|
|
260
271
|
return {
|
|
261
|
-
|
|
262
|
-
|
|
272
|
+
...account,
|
|
273
|
+
refreshToken: diskMatch.refreshToken,
|
|
274
|
+
access: diskMatch.access,
|
|
275
|
+
expires: diskMatch.expires,
|
|
276
|
+
token_updated_at: diskTokenUpdatedAt,
|
|
263
277
|
};
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
return {
|
|
267
|
-
...account,
|
|
268
|
-
refreshToken: diskMatch.refreshToken,
|
|
269
|
-
access: diskMatch.access,
|
|
270
|
-
expires: diskMatch.expires,
|
|
271
|
-
token_updated_at: diskTokenUpdatedAt,
|
|
272
|
-
};
|
|
273
278
|
}
|
|
274
279
|
|
|
275
|
-
export function unionAccountsWithDisk(
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const mergedAccounts = storage.accounts.map((account) => {
|
|
286
|
-
const diskMatch = findStoredAccountMatch(disk.accounts, account);
|
|
287
|
-
if (diskMatch) {
|
|
288
|
-
matchedDiskAccounts.add(diskMatch);
|
|
280
|
+
export function unionAccountsWithDisk(
|
|
281
|
+
storage: AccountStorage,
|
|
282
|
+
disk: AccountStorage | null,
|
|
283
|
+
options: { droppedIds?: ReadonlySet<string> } = {},
|
|
284
|
+
): AccountStorage {
|
|
285
|
+
if (!disk || storage.accounts.length === 0) {
|
|
286
|
+
return {
|
|
287
|
+
...storage,
|
|
288
|
+
activeIndex: clampActiveIndex(storage.accounts, storage.activeIndex),
|
|
289
|
+
};
|
|
289
290
|
}
|
|
290
291
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
292
|
+
const activeAccountId = storage.accounts[storage.activeIndex]?.id ?? null;
|
|
293
|
+
const matchedDiskAccounts = new Set<AccountMetadata>();
|
|
294
|
+
const mergedAccounts = storage.accounts.map((account) => {
|
|
295
|
+
const diskMatch = findStoredAccountMatch(disk.accounts, account);
|
|
296
|
+
if (diskMatch) {
|
|
297
|
+
matchedDiskAccounts.add(diskMatch);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return mergeAccountWithFresherAuth(account, diskMatch);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Disk rows that the caller has intentionally dropped (e.g. via a collapse
|
|
304
|
+
// pass) must NOT be restored via the disk-only union. Without this filter
|
|
305
|
+
// the union treats every dropped row as "another writer's account" and
|
|
306
|
+
// re-adds it on every save, defeating any repair that removes rows.
|
|
307
|
+
const droppedIds = options.droppedIds;
|
|
308
|
+
const diskOnlyAccounts = disk.accounts.filter(
|
|
309
|
+
(account) => !matchedDiskAccounts.has(account) && !(droppedIds && droppedIds.has(account.id)),
|
|
310
|
+
);
|
|
311
|
+
const accounts = [...mergedAccounts, ...diskOnlyAccounts];
|
|
312
|
+
const activeIndex = activeAccountId ? accounts.findIndex((account) => account.id === activeAccountId) : -1;
|
|
297
313
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
314
|
+
return {
|
|
315
|
+
...storage,
|
|
316
|
+
accounts,
|
|
317
|
+
activeIndex: activeIndex >= 0 ? activeIndex : clampActiveIndex(accounts, storage.activeIndex),
|
|
318
|
+
};
|
|
303
319
|
}
|
|
304
320
|
|
|
305
321
|
/**
|
|
306
322
|
* Load accounts from disk.
|
|
307
323
|
*/
|
|
308
324
|
export async function loadAccounts(): Promise<AccountStorage | null> {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
try {
|
|
312
|
-
const content = await fs.readFile(storagePath, "utf-8");
|
|
313
|
-
const data = JSON.parse(content);
|
|
314
|
-
|
|
315
|
-
if (!data || typeof data !== "object" || !Array.isArray(data.accounts)) {
|
|
316
|
-
return null;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
if (data.version !== CURRENT_VERSION) {
|
|
320
|
-
// eslint-disable-next-line no-console -- operator diagnostic: storage version mismatch before migration attempt
|
|
321
|
-
console.warn(
|
|
322
|
-
`Storage version mismatch: ${String(data.version)} vs ${CURRENT_VERSION}. Attempting best-effort migration.`,
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const now = Date.now();
|
|
327
|
-
const accounts = data.accounts
|
|
328
|
-
.map((raw: unknown) => validateAccount(raw, now))
|
|
329
|
-
.filter((acc: AccountMetadata | null): acc is AccountMetadata => acc !== null);
|
|
325
|
+
const storagePath = getStoragePath();
|
|
330
326
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
327
|
+
try {
|
|
328
|
+
const content = await fs.readFile(storagePath, "utf-8");
|
|
329
|
+
const data = JSON.parse(content);
|
|
330
|
+
|
|
331
|
+
if (!data || typeof data !== "object" || !Array.isArray(data.accounts)) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (data.version !== CURRENT_VERSION) {
|
|
336
|
+
// eslint-disable-next-line no-console -- operator diagnostic: storage version mismatch before migration attempt
|
|
337
|
+
console.warn(
|
|
338
|
+
`Storage version mismatch: ${String(data.version)} vs ${CURRENT_VERSION}. Attempting best-effort migration.`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const now = Date.now();
|
|
343
|
+
const accounts = data.accounts
|
|
344
|
+
.map((raw: unknown) => validateAccount(raw, now))
|
|
345
|
+
.filter((acc: AccountMetadata | null): acc is AccountMetadata => acc !== null);
|
|
346
|
+
|
|
347
|
+
const deduped = deduplicateByRefreshToken(accounts);
|
|
348
|
+
|
|
349
|
+
let activeIndex =
|
|
350
|
+
typeof data.activeIndex === "number" && Number.isFinite(data.activeIndex) ? data.activeIndex : 0;
|
|
351
|
+
|
|
352
|
+
if (deduped.length > 0) {
|
|
353
|
+
activeIndex = Math.max(0, Math.min(activeIndex, deduped.length - 1));
|
|
354
|
+
} else {
|
|
355
|
+
activeIndex = 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
version: CURRENT_VERSION,
|
|
360
|
+
accounts: deduped,
|
|
361
|
+
activeIndex,
|
|
362
|
+
};
|
|
363
|
+
} catch (error) {
|
|
364
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
365
|
+
if (code === "ENOENT") return null;
|
|
366
|
+
return null;
|
|
339
367
|
}
|
|
340
|
-
|
|
341
|
-
return {
|
|
342
|
-
version: CURRENT_VERSION,
|
|
343
|
-
accounts: deduped,
|
|
344
|
-
activeIndex,
|
|
345
|
-
};
|
|
346
|
-
} catch (error) {
|
|
347
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
348
|
-
if (code === "ENOENT") return null;
|
|
349
|
-
return null;
|
|
350
|
-
}
|
|
351
368
|
}
|
|
352
369
|
|
|
353
370
|
/**
|
|
354
371
|
* Save accounts to disk atomically.
|
|
372
|
+
*
|
|
373
|
+
* `options.droppedIds` lets callers signal that specific disk row ids have
|
|
374
|
+
* been intentionally removed (e.g. by a load-time collapse pass) and must
|
|
375
|
+
* not be restored by the disk-only union.
|
|
355
376
|
*/
|
|
356
|
-
export async function saveAccounts(
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
} catch {
|
|
373
|
-
// If merge read fails, continue with caller-provided storage payload.
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
const tempPath = `${storagePath}.${randomBytes(6).toString("hex")}.tmp`;
|
|
377
|
-
const content = JSON.stringify(storageToWrite, null, 2);
|
|
378
|
-
|
|
379
|
-
try {
|
|
380
|
-
await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
381
|
-
await fs.rename(tempPath, storagePath);
|
|
382
|
-
} catch (error) {
|
|
377
|
+
export async function saveAccounts(
|
|
378
|
+
storage: AccountStorage,
|
|
379
|
+
options: { droppedIds?: ReadonlySet<string> } = {},
|
|
380
|
+
): Promise<void> {
|
|
381
|
+
const storagePath = getStoragePath();
|
|
382
|
+
const configDir = dirname(storagePath);
|
|
383
|
+
|
|
384
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
385
|
+
ensureGitignore(configDir);
|
|
386
|
+
|
|
387
|
+
let storageToWrite = {
|
|
388
|
+
...storage,
|
|
389
|
+
activeIndex: clampActiveIndex(storage.accounts, storage.activeIndex),
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// Merge auth fields against disk by freshness to avoid stale-process clobber.
|
|
383
393
|
try {
|
|
384
|
-
|
|
394
|
+
const disk = await loadAccounts();
|
|
395
|
+
storageToWrite = unionAccountsWithDisk(storageToWrite, disk, { droppedIds: options.droppedIds });
|
|
385
396
|
} catch {
|
|
386
|
-
|
|
397
|
+
// If merge read fails, continue with caller-provided storage payload.
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const tempPath = `${storagePath}.${randomBytes(6).toString("hex")}.tmp`;
|
|
401
|
+
const content = JSON.stringify(storageToWrite, null, 2);
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
405
|
+
await fs.rename(tempPath, storagePath);
|
|
406
|
+
} catch (error) {
|
|
407
|
+
try {
|
|
408
|
+
await fs.unlink(tempPath);
|
|
409
|
+
} catch {
|
|
410
|
+
// Ignore cleanup errors
|
|
411
|
+
}
|
|
412
|
+
throw error;
|
|
387
413
|
}
|
|
388
|
-
throw error;
|
|
389
|
-
}
|
|
390
414
|
}
|
|
391
415
|
|
|
392
416
|
/**
|
|
393
417
|
* Clear all accounts from disk.
|
|
394
418
|
*/
|
|
395
419
|
export async function clearAccounts(): Promise<void> {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
420
|
+
const storagePath = getStoragePath();
|
|
421
|
+
try {
|
|
422
|
+
await fs.unlink(storagePath);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
425
|
+
if (code !== "ENOENT") throw error;
|
|
426
|
+
}
|
|
403
427
|
}
|