@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.
Files changed (107) hide show
  1. package/README.md +88 -88
  2. package/dist/opencode-anthropic-auth-cli.mjs +804 -507
  3. package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
  4. package/package.json +67 -59
  5. package/src/__tests__/billing-edge-cases.test.ts +59 -59
  6. package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
  7. package/src/__tests__/cc-comparison.test.ts +87 -87
  8. package/src/__tests__/cc-credentials.test.ts +254 -250
  9. package/src/__tests__/cch-drift-checker.test.ts +51 -51
  10. package/src/__tests__/cch-native-style.test.ts +56 -56
  11. package/src/__tests__/debug-gating.test.ts +42 -42
  12. package/src/__tests__/decomposition-smoke.test.ts +68 -68
  13. package/src/__tests__/fingerprint-regression.test.ts +575 -566
  14. package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
  15. package/src/__tests__/helpers/conversation-history.ts +119 -119
  16. package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
  17. package/src/__tests__/helpers/deferred.ts +69 -69
  18. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
  19. package/src/__tests__/helpers/in-memory-storage.ts +88 -88
  20. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
  21. package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
  22. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
  23. package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
  24. package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
  25. package/src/__tests__/helpers/sse.ts +209 -209
  26. package/src/__tests__/index.parallel.test.ts +605 -595
  27. package/src/__tests__/sanitization-regex.test.ts +112 -112
  28. package/src/__tests__/state-bounds.test.ts +90 -90
  29. package/src/account-identity.test.ts +197 -192
  30. package/src/account-identity.ts +69 -67
  31. package/src/account-state.test.ts +86 -86
  32. package/src/account-state.ts +25 -25
  33. package/src/accounts/matching.test.ts +335 -0
  34. package/src/accounts/matching.ts +167 -0
  35. package/src/accounts/persistence.test.ts +345 -0
  36. package/src/accounts/persistence.ts +432 -0
  37. package/src/accounts/repair.test.ts +276 -0
  38. package/src/accounts/repair.ts +407 -0
  39. package/src/accounts.dedup.test.ts +621 -621
  40. package/src/accounts.test.ts +933 -929
  41. package/src/accounts.ts +633 -989
  42. package/src/backoff.test.ts +345 -345
  43. package/src/backoff.ts +219 -219
  44. package/src/betas.ts +124 -124
  45. package/src/bun-fetch.test.ts +345 -342
  46. package/src/bun-fetch.ts +424 -424
  47. package/src/bun-proxy.test.ts +25 -25
  48. package/src/bun-proxy.ts +209 -209
  49. package/src/cc-credentials.ts +111 -111
  50. package/src/circuit-breaker.test.ts +184 -184
  51. package/src/circuit-breaker.ts +169 -169
  52. package/src/cli/commands/auth.ts +963 -0
  53. package/src/cli/commands/config.ts +547 -0
  54. package/src/cli/formatting.test.ts +406 -0
  55. package/src/cli/formatting.ts +219 -0
  56. package/src/cli.ts +255 -2022
  57. package/src/commands/handlers/betas.ts +100 -0
  58. package/src/commands/handlers/config.ts +99 -0
  59. package/src/commands/handlers/files.ts +375 -0
  60. package/src/commands/oauth-flow.ts +181 -166
  61. package/src/commands/prompts.ts +61 -61
  62. package/src/commands/router.test.ts +421 -0
  63. package/src/commands/router.ts +143 -635
  64. package/src/config.test.ts +482 -482
  65. package/src/config.ts +412 -404
  66. package/src/constants.ts +48 -48
  67. package/src/drift/cch-constants.ts +95 -95
  68. package/src/env.ts +111 -105
  69. package/src/headers/billing.ts +33 -33
  70. package/src/headers/builder.ts +130 -130
  71. package/src/headers/cch.ts +75 -75
  72. package/src/headers/stainless.ts +25 -25
  73. package/src/headers/user-agent.ts +23 -23
  74. package/src/index.ts +436 -828
  75. package/src/models.ts +27 -27
  76. package/src/oauth.test.ts +102 -102
  77. package/src/oauth.ts +178 -178
  78. package/src/parent-pid-watcher.test.ts +148 -148
  79. package/src/parent-pid-watcher.ts +69 -69
  80. package/src/plugin-helpers.ts +82 -82
  81. package/src/refresh-helpers.ts +145 -139
  82. package/src/refresh-lock.test.ts +94 -94
  83. package/src/refresh-lock.ts +93 -93
  84. package/src/request/body.history.test.ts +579 -571
  85. package/src/request/body.ts +255 -255
  86. package/src/request/metadata.ts +65 -65
  87. package/src/request/retry.test.ts +156 -156
  88. package/src/request/retry.ts +67 -67
  89. package/src/request/url.ts +21 -21
  90. package/src/request-orchestration-helpers.ts +648 -0
  91. package/src/response/index.ts +5 -5
  92. package/src/response/mcp.ts +58 -58
  93. package/src/response/streaming.test.ts +313 -311
  94. package/src/response/streaming.ts +412 -410
  95. package/src/rotation.test.ts +304 -301
  96. package/src/rotation.ts +205 -205
  97. package/src/storage.test.ts +547 -547
  98. package/src/storage.ts +315 -291
  99. package/src/system-prompt/builder.ts +38 -38
  100. package/src/system-prompt/index.ts +5 -5
  101. package/src/system-prompt/normalize.ts +60 -60
  102. package/src/system-prompt/sanitize.ts +30 -30
  103. package/src/thinking.ts +21 -20
  104. package/src/token-refresh.test.ts +265 -265
  105. package/src/token-refresh.ts +219 -214
  106. package/src/types.ts +30 -30
  107. 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
- requests: number;
10
- inputTokens: number;
11
- outputTokens: number;
12
- cacheReadTokens: number;
13
- cacheWriteTokens: number;
14
- lastReset: number;
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
- 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";
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
- version: number;
39
- accounts: AccountMetadata[];
40
- activeIndex: number;
38
+ version: number;
39
+ accounts: AccountMetadata[];
40
+ activeIndex: number;
41
41
  }
42
42
 
43
43
  export type StoredAccountMatchCandidate = Pick<
44
- AccountMetadata,
45
- "id" | "email" | "identity" | "label" | "refreshToken" | "addedAt" | "source"
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
- return {
55
- requests: 0,
56
- inputTokens: 0,
57
- outputTokens: 0,
58
- cacheReadTokens: 0,
59
- cacheWriteTokens: 0,
60
- lastReset: now ?? Date.now(),
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
- 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
- };
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
- return join(getConfigDir(), "anthropic-accounts.json");
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
- 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
- }
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
- const missingEntries = GITIGNORE_ENTRIES.filter((entry) => !existingLines.includes(entry));
101
+ const missingEntries = GITIGNORE_ENTRIES.filter((entry) => !existingLines.includes(entry));
102
102
 
103
- if (missingEntries.length === 0) return;
103
+ if (missingEntries.length === 0) return;
104
104
 
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");
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
- 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);
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
- return Array.from(tokenMap.values());
130
+ return Array.from(tokenMap.values());
131
131
  }
132
132
 
133
133
  function validateAccount(raw: unknown, now: number): AccountMetadata | null {
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 = typeof acc.id === "string" && acc.id ? acc.id : `${addedAt}:${(acc.refreshToken as string).slice(0, 12)}`;
142
-
143
- return {
144
- id,
145
- email: typeof acc.email === "string" ? acc.email : undefined,
146
- identity: isAccountIdentity(acc.identity) ? acc.identity : undefined,
147
- label: typeof acc.label === "string" ? acc.label : undefined,
148
- refreshToken: acc.refreshToken as string,
149
- access: typeof acc.access === "string" ? acc.access : undefined,
150
- expires: typeof acc.expires === "number" && Number.isFinite(acc.expires) ? acc.expires : undefined,
151
- token_updated_at:
152
- typeof acc.token_updated_at === "number" && Number.isFinite(acc.token_updated_at)
153
- ? acc.token_updated_at
154
- : typeof acc.tokenUpdatedAt === "number" && Number.isFinite(acc.tokenUpdatedAt)
155
- ? (acc.tokenUpdatedAt as number)
156
- : addedAt,
157
- addedAt,
158
- lastUsed: typeof acc.lastUsed === "number" && Number.isFinite(acc.lastUsed) ? acc.lastUsed : 0,
159
- enabled: acc.enabled !== false,
160
- rateLimitResetTimes:
161
- acc.rateLimitResetTimes && typeof acc.rateLimitResetTimes === "object" && !Array.isArray(acc.rateLimitResetTimes)
162
- ? (acc.rateLimitResetTimes as Record<string, number>)
163
- : {},
164
- consecutiveFailures:
165
- typeof acc.consecutiveFailures === "number" ? Math.max(0, Math.floor(acc.consecutiveFailures)) : 0,
166
- lastFailureTime: typeof acc.lastFailureTime === "number" ? acc.lastFailureTime : null,
167
- lastSwitchReason: typeof acc.lastSwitchReason === "string" ? acc.lastSwitchReason : undefined,
168
- stats: validateStats(acc.stats, now),
169
- source: acc.source === "cc-keychain" || acc.source === "cc-file" || acc.source === "oauth" ? acc.source : undefined,
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
- if (!value || typeof value !== "object") return false;
175
-
176
- const candidate = value as Record<string, unknown>;
177
- switch (candidate.kind) {
178
- case "oauth":
179
- return typeof candidate.email === "string" && candidate.email.length > 0;
180
- case "cc":
181
- return (
182
- (candidate.source === "cc-keychain" || candidate.source === "cc-file") && typeof candidate.label === "string"
183
- );
184
- case "legacy":
185
- return typeof candidate.refreshToken === "string" && candidate.refreshToken.length > 0;
186
- default:
187
- return false;
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
- if (isAccountIdentity(candidate.identity)) {
193
- return candidate.identity;
194
- }
197
+ if (isAccountIdentity(candidate.identity)) {
198
+ return candidate.identity;
199
+ }
195
200
 
196
- if (candidate.source === "oauth" && candidate.email) {
197
- return { kind: "oauth", email: candidate.email };
198
- }
201
+ if (candidate.source === "oauth" && candidate.email) {
202
+ return { kind: "oauth", email: candidate.email };
203
+ }
199
204
 
200
- if ((candidate.source === "cc-keychain" || candidate.source === "cc-file") && candidate.label) {
201
- return {
202
- kind: "cc",
203
- source: candidate.source,
204
- label: candidate.label,
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
- return { kind: "legacy", refreshToken: candidate.refreshToken };
213
+ return { kind: "legacy", refreshToken: candidate.refreshToken };
209
214
  }
210
215
 
211
216
  function resolveTokenUpdatedAt(account: Pick<AccountMetadata, "token_updated_at" | "addedAt">): number {
212
- return typeof account.token_updated_at === "number" && Number.isFinite(account.token_updated_at)
213
- ? account.token_updated_at
214
- : account.addedAt;
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
- if (accounts.length === 0) {
219
- return 0;
220
- }
223
+ if (accounts.length === 0) {
224
+ return 0;
225
+ }
221
226
 
222
- return Math.max(0, Math.min(activeIndex, accounts.length - 1));
227
+ return Math.max(0, Math.min(activeIndex, accounts.length - 1));
223
228
  }
224
229
 
225
230
  export function findStoredAccountMatch(
226
- accounts: AccountMetadata[],
227
- candidate: StoredAccountMatchCandidate,
231
+ accounts: AccountMetadata[],
232
+ candidate: StoredAccountMatchCandidate,
228
233
  ): AccountMetadata | null {
229
- const byId = accounts.find((account) => account.id === candidate.id);
230
- if (byId) {
231
- return byId;
232
- }
233
-
234
- const byIdentity = findByIdentity(accounts, resolveStoredIdentity(candidate));
235
- if (byIdentity) {
236
- return byIdentity;
237
- }
238
-
239
- const byAddedAt = accounts.filter((account) => account.addedAt === candidate.addedAt);
240
- if (byAddedAt.length === 1) {
241
- return byAddedAt[0]!;
242
- }
243
-
244
- const byRefreshToken = accounts.find((account) => account.refreshToken === candidate.refreshToken);
245
- if (byRefreshToken) {
246
- return byRefreshToken;
247
- }
248
-
249
- return byAddedAt[0] ?? null;
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
- account: AccountMetadata,
254
- diskMatch: AccountMetadata | null,
258
+ account: AccountMetadata,
259
+ diskMatch: AccountMetadata | null,
255
260
  ): AccountMetadata {
256
- const memoryTokenUpdatedAt = resolveTokenUpdatedAt(account);
257
- const diskTokenUpdatedAt = diskMatch ? resolveTokenUpdatedAt(diskMatch) : 0;
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
- ...account,
262
- token_updated_at: memoryTokenUpdatedAt,
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(storage: AccountStorage, disk: AccountStorage | null): AccountStorage {
276
- if (!disk || storage.accounts.length === 0) {
277
- return {
278
- ...storage,
279
- activeIndex: clampActiveIndex(storage.accounts, storage.activeIndex),
280
- };
281
- }
282
-
283
- const activeAccountId = storage.accounts[storage.activeIndex]?.id ?? null;
284
- const matchedDiskAccounts = new Set<AccountMetadata>();
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
- return mergeAccountWithFresherAuth(account, diskMatch);
292
- });
293
-
294
- const diskOnlyAccounts = disk.accounts.filter((account) => !matchedDiskAccounts.has(account));
295
- const accounts = [...mergedAccounts, ...diskOnlyAccounts];
296
- const activeIndex = activeAccountId ? accounts.findIndex((account) => account.id === activeAccountId) : -1;
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
- return {
299
- ...storage,
300
- accounts,
301
- activeIndex: activeIndex >= 0 ? activeIndex : clampActiveIndex(accounts, storage.activeIndex),
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
- const storagePath = getStoragePath();
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
- const deduped = deduplicateByRefreshToken(accounts);
332
-
333
- let activeIndex = typeof data.activeIndex === "number" && Number.isFinite(data.activeIndex) ? data.activeIndex : 0;
334
-
335
- if (deduped.length > 0) {
336
- activeIndex = Math.max(0, Math.min(activeIndex, deduped.length - 1));
337
- } else {
338
- activeIndex = 0;
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(storage: AccountStorage): Promise<void> {
357
- const storagePath = getStoragePath();
358
- const configDir = dirname(storagePath);
359
-
360
- await fs.mkdir(configDir, { recursive: true });
361
- ensureGitignore(configDir);
362
-
363
- let storageToWrite = {
364
- ...storage,
365
- activeIndex: clampActiveIndex(storage.accounts, storage.activeIndex),
366
- };
367
-
368
- // Merge auth fields against disk by freshness to avoid stale-process clobber.
369
- try {
370
- const disk = await loadAccounts();
371
- storageToWrite = unionAccountsWithDisk(storageToWrite, disk);
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
- await fs.unlink(tempPath);
394
+ const disk = await loadAccounts();
395
+ storageToWrite = unionAccountsWithDisk(storageToWrite, disk, { droppedIds: options.droppedIds });
385
396
  } catch {
386
- // Ignore cleanup errors
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
- const storagePath = getStoragePath();
397
- try {
398
- await fs.unlink(storagePath);
399
- } catch (error) {
400
- const code = (error as NodeJS.ErrnoException).code;
401
- if (code !== "ENOENT") throw error;
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
  }