opencode-anthropic-fix 0.0.14

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.
@@ -0,0 +1,346 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { HealthScoreTracker, TokenBucketTracker, selectAccount } from "./rotation.mjs";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // HealthScoreTracker
6
+ // ---------------------------------------------------------------------------
7
+
8
+ describe("HealthScoreTracker", () => {
9
+ /** @type {HealthScoreTracker} */
10
+ let tracker;
11
+
12
+ beforeEach(() => {
13
+ tracker = new HealthScoreTracker();
14
+ });
15
+
16
+ it("returns initial score for unknown account", () => {
17
+ expect(tracker.getScore(0)).toBe(70);
18
+ });
19
+
20
+ it("increases score on success", () => {
21
+ tracker.recordSuccess(0);
22
+ expect(tracker.getScore(0)).toBe(71);
23
+ });
24
+
25
+ it("decreases score on rate limit", () => {
26
+ tracker.recordRateLimit(0);
27
+ expect(tracker.getScore(0)).toBe(60);
28
+ });
29
+
30
+ it("decreases score on failure", () => {
31
+ tracker.recordFailure(0);
32
+ expect(tracker.getScore(0)).toBe(50);
33
+ });
34
+
35
+ it("does not go below 0", () => {
36
+ for (let i = 0; i < 10; i++) {
37
+ tracker.recordFailure(0);
38
+ }
39
+ expect(tracker.getScore(0)).toBe(0);
40
+ });
41
+
42
+ it("caps at max_score", () => {
43
+ for (let i = 0; i < 50; i++) {
44
+ tracker.recordSuccess(0);
45
+ }
46
+ expect(tracker.getScore(0)).toBe(100);
47
+ });
48
+
49
+ it("isUsable returns true when score >= min_usable", () => {
50
+ expect(tracker.isUsable(0)).toBe(true); // 70 >= 50
51
+ });
52
+
53
+ it("isUsable returns false when score < min_usable", () => {
54
+ // 70 - 20 = 50 (still usable), need one more
55
+ tracker.recordFailure(0); // 50
56
+ expect(tracker.isUsable(0)).toBe(true);
57
+ tracker.recordRateLimit(0); // 40
58
+ expect(tracker.isUsable(0)).toBe(false);
59
+ });
60
+
61
+ it("reset removes tracking for an account", () => {
62
+ tracker.recordFailure(0);
63
+ tracker.reset(0);
64
+ expect(tracker.getScore(0)).toBe(70); // Back to initial
65
+ });
66
+
67
+ it("tracks accounts independently", () => {
68
+ tracker.recordFailure(0);
69
+ tracker.recordSuccess(1);
70
+ expect(tracker.getScore(0)).toBe(50);
71
+ expect(tracker.getScore(1)).toBe(71);
72
+ });
73
+
74
+ it("applies passive recovery over time", () => {
75
+ tracker.recordFailure(0); // Score = 50
76
+ // Simulate 1 hour passing
77
+ const state = tracker.getScore(0);
78
+ expect(state).toBe(50);
79
+
80
+ // We can't easily test time-based recovery without mocking Date.now
81
+ // but we can verify the formula works by checking the code path
82
+ });
83
+
84
+ it("uses custom config", () => {
85
+ const custom = new HealthScoreTracker({
86
+ initial: 100,
87
+ success_reward: 5,
88
+ rate_limit_penalty: -5,
89
+ failure_penalty: -10,
90
+ min_usable: 80,
91
+ max_score: 100,
92
+ });
93
+ expect(custom.getScore(0)).toBe(100);
94
+ custom.recordRateLimit(0);
95
+ expect(custom.getScore(0)).toBe(95);
96
+ expect(custom.isUsable(0)).toBe(true);
97
+ });
98
+ });
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // TokenBucketTracker
102
+ // ---------------------------------------------------------------------------
103
+
104
+ describe("TokenBucketTracker", () => {
105
+ /** @type {TokenBucketTracker} */
106
+ let tracker;
107
+
108
+ beforeEach(() => {
109
+ tracker = new TokenBucketTracker();
110
+ });
111
+
112
+ it("returns initial tokens for unknown account", () => {
113
+ expect(tracker.getTokens(0)).toBe(50);
114
+ });
115
+
116
+ it("hasTokens returns true when tokens available", () => {
117
+ expect(tracker.hasTokens(0)).toBe(true);
118
+ });
119
+
120
+ it("consume reduces tokens", () => {
121
+ tracker.consume(0);
122
+ expect(tracker.getTokens(0)).toBe(49);
123
+ });
124
+
125
+ it("consume returns false when insufficient tokens", () => {
126
+ // Drain all tokens
127
+ for (let i = 0; i < 50; i++) {
128
+ tracker.consume(0);
129
+ }
130
+ expect(tracker.consume(0)).toBe(false);
131
+ expect(tracker.hasTokens(0)).toBe(false);
132
+ });
133
+
134
+ it("refund adds tokens back", () => {
135
+ tracker.consume(0);
136
+ tracker.consume(0);
137
+ tracker.refund(0);
138
+ expect(tracker.getTokens(0)).toBe(49);
139
+ });
140
+
141
+ it("refund does not exceed max", () => {
142
+ tracker.refund(0, 100);
143
+ expect(tracker.getTokens(0)).toBe(50);
144
+ });
145
+
146
+ it("getMaxTokens returns configured max", () => {
147
+ expect(tracker.getMaxTokens()).toBe(50);
148
+ });
149
+
150
+ it("uses custom config", () => {
151
+ const custom = new TokenBucketTracker({
152
+ max_tokens: 10,
153
+ initial_tokens: 5,
154
+ regeneration_rate_per_minute: 1,
155
+ });
156
+ expect(custom.getTokens(0)).toBe(5);
157
+ expect(custom.getMaxTokens()).toBe(10);
158
+ });
159
+
160
+ it("tracks accounts independently", () => {
161
+ tracker.consume(0);
162
+ expect(tracker.getTokens(0)).toBe(49);
163
+ expect(tracker.getTokens(1)).toBe(50);
164
+ });
165
+ });
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // selectAccount
169
+ // ---------------------------------------------------------------------------
170
+
171
+ describe("selectAccount", () => {
172
+ /** @type {HealthScoreTracker} */
173
+ let healthTracker;
174
+ /** @type {TokenBucketTracker} */
175
+ let tokenTracker;
176
+
177
+ beforeEach(() => {
178
+ healthTracker = new HealthScoreTracker();
179
+ tokenTracker = new TokenBucketTracker();
180
+ });
181
+
182
+ /**
183
+ * @param {Partial<import('./rotation.mjs').AccountCandidate>} overrides
184
+ * @returns {import('./rotation.mjs').AccountCandidate}
185
+ */
186
+ function makeCandidate(overrides = {}) {
187
+ return {
188
+ index: 0,
189
+ lastUsed: 0,
190
+ healthScore: 70,
191
+ isRateLimited: false,
192
+ enabled: true,
193
+ ...overrides,
194
+ };
195
+ }
196
+
197
+ it("returns null when no candidates", () => {
198
+ expect(selectAccount([], "sticky", null, healthTracker, tokenTracker, 0)).toBeNull();
199
+ });
200
+
201
+ it("returns null when all candidates are rate-limited", () => {
202
+ const candidates = [
203
+ makeCandidate({ index: 0, isRateLimited: true }),
204
+ makeCandidate({ index: 1, isRateLimited: true }),
205
+ ];
206
+ expect(selectAccount(candidates, "sticky", null, healthTracker, tokenTracker, 0)).toBeNull();
207
+ });
208
+
209
+ it("returns null when all candidates are disabled", () => {
210
+ const candidates = [makeCandidate({ index: 0, enabled: false })];
211
+ expect(selectAccount(candidates, "sticky", null, healthTracker, tokenTracker, 0)).toBeNull();
212
+ });
213
+
214
+ // Sticky strategy
215
+ describe("sticky strategy", () => {
216
+ it("returns current account if available", () => {
217
+ const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
218
+ const result = selectAccount(candidates, "sticky", 0, healthTracker, tokenTracker, 0);
219
+ expect(result?.index).toBe(0);
220
+ });
221
+
222
+ it("switches when current is rate-limited", () => {
223
+ const candidates = [makeCandidate({ index: 0, isRateLimited: true }), makeCandidate({ index: 1 })];
224
+ const result = selectAccount(candidates, "sticky", 0, healthTracker, tokenTracker, 0);
225
+ expect(result?.index).toBe(1);
226
+ });
227
+
228
+ it("does not switch when current is unhealthy", () => {
229
+ // Make account 0 unhealthy
230
+ for (let i = 0; i < 5; i++) {
231
+ healthTracker.recordFailure(0);
232
+ }
233
+ const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
234
+ const result = selectAccount(candidates, "sticky", 0, healthTracker, tokenTracker, 0);
235
+ expect(result?.index).toBe(0);
236
+ expect(result?.cursor).toBe(0);
237
+ });
238
+
239
+ it("keeps current even when cursor points elsewhere", () => {
240
+ // Make account 0 unhealthy
241
+ for (let i = 0; i < 5; i++) {
242
+ healthTracker.recordFailure(0);
243
+ }
244
+ const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
245
+ // Even with cursor=1, sticky should keep the current account
246
+ const result = selectAccount(candidates, "sticky", 0, healthTracker, tokenTracker, 1);
247
+ expect(result?.index).toBe(0);
248
+ expect(result?.cursor).toBe(1);
249
+ });
250
+
251
+ it("selects first available when no current", () => {
252
+ const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
253
+ const result = selectAccount(candidates, "sticky", null, healthTracker, tokenTracker, 0);
254
+ expect(result?.index).toBe(0);
255
+ });
256
+ });
257
+
258
+ // Round-robin strategy
259
+ describe("round-robin strategy", () => {
260
+ it("rotates through accounts", () => {
261
+ const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 }), makeCandidate({ index: 2 })];
262
+
263
+ const r1 = selectAccount(candidates, "round-robin", null, healthTracker, tokenTracker, 0);
264
+ expect(r1?.index).toBe(0);
265
+
266
+ const r2 = selectAccount(candidates, "round-robin", 0, healthTracker, tokenTracker, r1.cursor);
267
+ expect(r2?.index).toBe(1);
268
+
269
+ const r3 = selectAccount(candidates, "round-robin", 1, healthTracker, tokenTracker, r2.cursor);
270
+ expect(r3?.index).toBe(2);
271
+
272
+ // Wraps around
273
+ const r4 = selectAccount(candidates, "round-robin", 2, healthTracker, tokenTracker, r3.cursor);
274
+ expect(r4?.index).toBe(0);
275
+ });
276
+
277
+ it("skips rate-limited accounts", () => {
278
+ const candidates = [
279
+ makeCandidate({ index: 0, isRateLimited: true }),
280
+ makeCandidate({ index: 1 }),
281
+ makeCandidate({ index: 2 }),
282
+ ];
283
+ const result = selectAccount(candidates, "round-robin", null, healthTracker, tokenTracker, 0);
284
+ expect(result?.index).toBe(1);
285
+ });
286
+ });
287
+
288
+ // Hybrid strategy
289
+ describe("hybrid strategy", () => {
290
+ it("selects the account with highest composite score", () => {
291
+ const candidates = [
292
+ makeCandidate({ index: 0, lastUsed: Date.now() }),
293
+ makeCandidate({ index: 1, lastUsed: 0 }), // Older = more fresh
294
+ ];
295
+ // Account 1 should have higher freshness score
296
+ const result = selectAccount(candidates, "hybrid", null, healthTracker, tokenTracker, 0);
297
+ // Both have same health and tokens, but account 1 has more freshness
298
+ expect(result?.index).toBe(1);
299
+ });
300
+
301
+ it("applies stickiness bonus to current account", () => {
302
+ const candidates = [
303
+ makeCandidate({ index: 0, lastUsed: Date.now() }),
304
+ makeCandidate({ index: 1, lastUsed: Date.now() - 1000 }),
305
+ ];
306
+ // Account 0 is current — stickiness bonus should keep it
307
+ const result = selectAccount(candidates, "hybrid", 0, healthTracker, tokenTracker, 0);
308
+ expect(result?.index).toBe(0);
309
+ });
310
+
311
+ it("falls back to any available when all are unhealthy", () => {
312
+ // Make all accounts unhealthy
313
+ for (let i = 0; i < 10; i++) {
314
+ healthTracker.recordFailure(0);
315
+ healthTracker.recordFailure(1);
316
+ }
317
+ const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
318
+ const result = selectAccount(candidates, "hybrid", null, healthTracker, tokenTracker, 0);
319
+ // Should fall back to first available
320
+ expect(result).not.toBeNull();
321
+ });
322
+
323
+ it("switches when advantage exceeds threshold", () => {
324
+ // Make account 0 very unhealthy
325
+ for (let i = 0; i < 4; i++) {
326
+ healthTracker.recordFailure(0);
327
+ }
328
+ // Drain account 0's tokens
329
+ for (let i = 0; i < 50; i++) {
330
+ tokenTracker.consume(0);
331
+ }
332
+
333
+ const candidates = [makeCandidate({ index: 0, lastUsed: Date.now() }), makeCandidate({ index: 1, lastUsed: 0 })];
334
+ const result = selectAccount(candidates, "hybrid", 0, healthTracker, tokenTracker, 0);
335
+ // Account 1 should win despite stickiness bonus because account 0 is very degraded
336
+ expect(result?.index).toBe(1);
337
+ });
338
+ });
339
+
340
+ // Default/unknown strategy
341
+ it("falls back to first available for unknown strategy", () => {
342
+ const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
343
+ const result = selectAccount(candidates, "unknown", null, healthTracker, tokenTracker, 0);
344
+ expect(result?.index).toBe(0);
345
+ });
346
+ });
@@ -0,0 +1,265 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { existsSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { randomBytes } from "node:crypto";
5
+ import { getConfigDir } from "./config.mjs";
6
+
7
+ /**
8
+ * @typedef {object} AccountStats
9
+ * @property {number} requests
10
+ * @property {number} inputTokens
11
+ * @property {number} outputTokens
12
+ * @property {number} cacheReadTokens
13
+ * @property {number} cacheWriteTokens
14
+ * @property {number} lastReset
15
+ */
16
+
17
+ /**
18
+ * @typedef {object} AccountMetadata
19
+ * @property {string} id
20
+ * @property {string} [email]
21
+ * @property {string} refreshToken
22
+ * @property {string} [access]
23
+ * @property {number} [expires]
24
+ * @property {number} addedAt
25
+ * @property {number} lastUsed
26
+ * @property {boolean} enabled
27
+ * @property {Record<string, number>} rateLimitResetTimes
28
+ * @property {number} consecutiveFailures
29
+ * @property {number | null} lastFailureTime
30
+ * @property {string} [lastSwitchReason]
31
+ * @property {AccountStats} stats
32
+ */
33
+
34
+ /**
35
+ * @typedef {object} AccountStorage
36
+ * @property {number} version
37
+ * @property {AccountMetadata[]} accounts
38
+ * @property {number} activeIndex
39
+ */
40
+
41
+ const CURRENT_VERSION = 1;
42
+
43
+ /**
44
+ * Create a fresh stats object.
45
+ * @param {number} [now]
46
+ * @returns {AccountStats}
47
+ */
48
+ export function createDefaultStats(now) {
49
+ return {
50
+ requests: 0,
51
+ inputTokens: 0,
52
+ outputTokens: 0,
53
+ cacheReadTokens: 0,
54
+ cacheWriteTokens: 0,
55
+ lastReset: now ?? Date.now(),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Validate and normalise a stats object, filling in missing fields.
61
+ * @param {unknown} raw
62
+ * @param {number} now
63
+ * @returns {AccountStats}
64
+ */
65
+ function validateStats(raw, now) {
66
+ if (!raw || typeof raw !== "object") return createDefaultStats(now);
67
+ const s = /** @type {Record<string, unknown>} */ (raw);
68
+ const safeNum = (/** @type {unknown} */ v) =>
69
+ typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0;
70
+ return {
71
+ requests: safeNum(s.requests),
72
+ inputTokens: safeNum(s.inputTokens),
73
+ outputTokens: safeNum(s.outputTokens),
74
+ cacheReadTokens: safeNum(s.cacheReadTokens),
75
+ cacheWriteTokens: safeNum(s.cacheWriteTokens),
76
+ lastReset: typeof s.lastReset === "number" && Number.isFinite(s.lastReset) ? s.lastReset : now,
77
+ };
78
+ }
79
+
80
+ const GITIGNORE_ENTRIES = [".gitignore", "anthropic-accounts.json", "anthropic-accounts.json.*.tmp"];
81
+
82
+ /**
83
+ * Get the path to the accounts storage file.
84
+ * @returns {string}
85
+ */
86
+ export function getStoragePath() {
87
+ return join(getConfigDir(), "anthropic-accounts.json");
88
+ }
89
+
90
+ /**
91
+ * Ensure .gitignore in the config directory includes our files.
92
+ * @param {string} configDir
93
+ */
94
+ export function ensureGitignore(configDir) {
95
+ const gitignorePath = join(configDir, ".gitignore");
96
+ try {
97
+ let content = "";
98
+ /** @type {string[]} */
99
+ let existingLines = [];
100
+
101
+ if (existsSync(gitignorePath)) {
102
+ content = readFileSync(gitignorePath, "utf-8");
103
+ existingLines = content.split("\n").map((line) => line.trim());
104
+ }
105
+
106
+ const missingEntries = GITIGNORE_ENTRIES.filter((entry) => !existingLines.includes(entry));
107
+
108
+ if (missingEntries.length === 0) return;
109
+
110
+ if (content === "") {
111
+ writeFileSync(gitignorePath, missingEntries.join("\n") + "\n", "utf-8");
112
+ } else {
113
+ const suffix = content.endsWith("\n") ? "" : "\n";
114
+ appendFileSync(gitignorePath, suffix + missingEntries.join("\n") + "\n", "utf-8");
115
+ }
116
+ } catch {
117
+ // Ignore gitignore errors
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Deduplicate accounts by refresh token, keeping the most recently used.
123
+ * @param {AccountMetadata[]} accounts
124
+ * @returns {AccountMetadata[]}
125
+ */
126
+ export function deduplicateByRefreshToken(accounts) {
127
+ /** @type {Map<string, AccountMetadata>} */
128
+ const tokenMap = new Map();
129
+
130
+ for (const acc of accounts) {
131
+ if (!acc.refreshToken) continue;
132
+ const existing = tokenMap.get(acc.refreshToken);
133
+ if (!existing || (acc.lastUsed || 0) > (existing.lastUsed || 0)) {
134
+ tokenMap.set(acc.refreshToken, acc);
135
+ }
136
+ }
137
+
138
+ return Array.from(tokenMap.values());
139
+ }
140
+
141
+ /**
142
+ * Validate a single account entry.
143
+ * @param {unknown} raw
144
+ * @param {number} now
145
+ * @returns {AccountMetadata | null}
146
+ */
147
+ function validateAccount(raw, now) {
148
+ if (!raw || typeof raw !== "object") return null;
149
+ const acc = /** @type {Record<string, unknown>} */ (raw);
150
+
151
+ if (typeof acc.refreshToken !== "string" || !acc.refreshToken) return null;
152
+
153
+ const addedAt = typeof acc.addedAt === "number" && Number.isFinite(acc.addedAt) ? acc.addedAt : now;
154
+
155
+ const id = typeof acc.id === "string" && acc.id ? acc.id : `${addedAt}:${acc.refreshToken.slice(0, 12)}`;
156
+
157
+ return {
158
+ id,
159
+ email: typeof acc.email === "string" ? acc.email : undefined,
160
+ refreshToken: acc.refreshToken,
161
+ access: typeof acc.access === "string" ? acc.access : undefined,
162
+ expires: typeof acc.expires === "number" && Number.isFinite(acc.expires) ? acc.expires : undefined,
163
+ addedAt,
164
+ lastUsed: typeof acc.lastUsed === "number" && Number.isFinite(acc.lastUsed) ? acc.lastUsed : 0,
165
+ enabled: acc.enabled !== false,
166
+ rateLimitResetTimes:
167
+ acc.rateLimitResetTimes && typeof acc.rateLimitResetTimes === "object" && !Array.isArray(acc.rateLimitResetTimes)
168
+ ? /** @type {Record<string, number>} */ (acc.rateLimitResetTimes)
169
+ : {},
170
+ consecutiveFailures:
171
+ typeof acc.consecutiveFailures === "number" ? Math.max(0, Math.floor(acc.consecutiveFailures)) : 0,
172
+ lastFailureTime: typeof acc.lastFailureTime === "number" ? acc.lastFailureTime : null,
173
+ lastSwitchReason: typeof acc.lastSwitchReason === "string" ? acc.lastSwitchReason : undefined,
174
+ stats: validateStats(acc.stats, now),
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Load accounts from disk.
180
+ * @returns {Promise<AccountStorage | null>}
181
+ */
182
+ export async function loadAccounts() {
183
+ const storagePath = getStoragePath();
184
+
185
+ try {
186
+ const content = await fs.readFile(storagePath, "utf-8");
187
+ const data = JSON.parse(content);
188
+
189
+ if (!data || typeof data !== "object" || !Array.isArray(data.accounts)) {
190
+ return null;
191
+ }
192
+
193
+ if (data.version !== CURRENT_VERSION) {
194
+ // Future: handle migrations here
195
+ return null;
196
+ }
197
+
198
+ const now = Date.now();
199
+ const accounts = data.accounts
200
+ .map((raw) => validateAccount(raw, now))
201
+ .filter(/** @returns {acc is AccountMetadata} */ (acc) => acc !== null);
202
+
203
+ const deduped = deduplicateByRefreshToken(accounts);
204
+
205
+ let activeIndex = typeof data.activeIndex === "number" && Number.isFinite(data.activeIndex) ? data.activeIndex : 0;
206
+
207
+ if (deduped.length > 0) {
208
+ activeIndex = Math.max(0, Math.min(activeIndex, deduped.length - 1));
209
+ } else {
210
+ activeIndex = 0;
211
+ }
212
+
213
+ return {
214
+ version: CURRENT_VERSION,
215
+ accounts: deduped,
216
+ activeIndex,
217
+ };
218
+ } catch (error) {
219
+ const code = /** @type {NodeJS.ErrnoException} */ (error).code;
220
+ if (code === "ENOENT") return null;
221
+ return null;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Save accounts to disk atomically.
227
+ * @param {AccountStorage} storage
228
+ * @returns {Promise<void>}
229
+ */
230
+ export async function saveAccounts(storage) {
231
+ const storagePath = getStoragePath();
232
+ const configDir = dirname(storagePath);
233
+
234
+ await fs.mkdir(configDir, { recursive: true });
235
+ ensureGitignore(configDir);
236
+
237
+ const tempPath = `${storagePath}.${randomBytes(6).toString("hex")}.tmp`;
238
+ const content = JSON.stringify(storage, null, 2);
239
+
240
+ try {
241
+ await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
242
+ await fs.rename(tempPath, storagePath);
243
+ } catch (error) {
244
+ try {
245
+ await fs.unlink(tempPath);
246
+ } catch {
247
+ // Ignore cleanup errors
248
+ }
249
+ throw error;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Clear all accounts from disk.
255
+ * @returns {Promise<void>}
256
+ */
257
+ export async function clearAccounts() {
258
+ const storagePath = getStoragePath();
259
+ try {
260
+ await fs.unlink(storagePath);
261
+ } catch (error) {
262
+ const code = /** @type {NodeJS.ErrnoException} */ (error).code;
263
+ if (code !== "ENOENT") throw error;
264
+ }
265
+ }