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,470 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import {
3
+ deduplicateByRefreshToken,
4
+ ensureGitignore,
5
+ getStoragePath,
6
+ loadAccounts,
7
+ saveAccounts,
8
+ clearAccounts,
9
+ createDefaultStats,
10
+ } from "./storage.mjs";
11
+ import { promises as fs } from "node:fs";
12
+ import { existsSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
13
+
14
+ // Mock fs modules
15
+ vi.mock("node:fs", () => ({
16
+ existsSync: vi.fn(),
17
+ readFileSync: vi.fn(),
18
+ appendFileSync: vi.fn(),
19
+ writeFileSync: vi.fn(),
20
+ promises: {
21
+ readFile: vi.fn(),
22
+ writeFile: vi.fn(),
23
+ rename: vi.fn(),
24
+ chmod: vi.fn(),
25
+ unlink: vi.fn(),
26
+ mkdir: vi.fn(),
27
+ },
28
+ }));
29
+
30
+ vi.mock("node:crypto", () => ({
31
+ randomBytes: vi.fn(() => ({
32
+ toString: () => "abcdef123456",
33
+ })),
34
+ }));
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // deduplicateByRefreshToken
38
+ // ---------------------------------------------------------------------------
39
+
40
+ describe("deduplicateByRefreshToken", () => {
41
+ it("returns empty array for empty input", () => {
42
+ expect(deduplicateByRefreshToken([])).toEqual([]);
43
+ });
44
+
45
+ it("returns single account unchanged", () => {
46
+ const accounts = [
47
+ {
48
+ refreshToken: "token1",
49
+ addedAt: 1000,
50
+ lastUsed: 2000,
51
+ enabled: true,
52
+ rateLimitResetTimes: {},
53
+ consecutiveFailures: 0,
54
+ lastFailureTime: null,
55
+ },
56
+ ];
57
+ const result = deduplicateByRefreshToken(accounts);
58
+ expect(result).toHaveLength(1);
59
+ expect(result[0].refreshToken).toBe("token1");
60
+ });
61
+
62
+ it("keeps most recently used when duplicates exist", () => {
63
+ const accounts = [
64
+ {
65
+ refreshToken: "token1",
66
+ addedAt: 1000,
67
+ lastUsed: 1000,
68
+ enabled: true,
69
+ rateLimitResetTimes: {},
70
+ consecutiveFailures: 0,
71
+ lastFailureTime: null,
72
+ },
73
+ {
74
+ refreshToken: "token1",
75
+ addedAt: 2000,
76
+ lastUsed: 5000,
77
+ enabled: true,
78
+ rateLimitResetTimes: {},
79
+ consecutiveFailures: 0,
80
+ lastFailureTime: null,
81
+ },
82
+ ];
83
+ const result = deduplicateByRefreshToken(accounts);
84
+ expect(result).toHaveLength(1);
85
+ expect(result[0].lastUsed).toBe(5000);
86
+ });
87
+
88
+ it("keeps different tokens as separate accounts", () => {
89
+ const accounts = [
90
+ {
91
+ refreshToken: "token1",
92
+ addedAt: 1000,
93
+ lastUsed: 1000,
94
+ enabled: true,
95
+ rateLimitResetTimes: {},
96
+ consecutiveFailures: 0,
97
+ lastFailureTime: null,
98
+ },
99
+ {
100
+ refreshToken: "token2",
101
+ addedAt: 2000,
102
+ lastUsed: 2000,
103
+ enabled: true,
104
+ rateLimitResetTimes: {},
105
+ consecutiveFailures: 0,
106
+ lastFailureTime: null,
107
+ },
108
+ ];
109
+ const result = deduplicateByRefreshToken(accounts);
110
+ expect(result).toHaveLength(2);
111
+ });
112
+
113
+ it("skips accounts without refreshToken", () => {
114
+ const accounts = [
115
+ {
116
+ refreshToken: "",
117
+ addedAt: 1000,
118
+ lastUsed: 1000,
119
+ enabled: true,
120
+ rateLimitResetTimes: {},
121
+ consecutiveFailures: 0,
122
+ lastFailureTime: null,
123
+ },
124
+ ];
125
+ const result = deduplicateByRefreshToken(accounts);
126
+ expect(result).toHaveLength(0);
127
+ });
128
+ });
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // ensureGitignore
132
+ // ---------------------------------------------------------------------------
133
+
134
+ describe("ensureGitignore", () => {
135
+ beforeEach(() => {
136
+ vi.resetAllMocks();
137
+ });
138
+
139
+ it("creates new .gitignore when none exists", () => {
140
+ existsSync.mockReturnValue(false);
141
+ ensureGitignore("/config/dir");
142
+ expect(writeFileSync).toHaveBeenCalledWith(
143
+ expect.stringMatching(/[\\/]config[\\/]dir[\\/]\.gitignore$/),
144
+ expect.stringContaining("anthropic-accounts.json"),
145
+ "utf-8",
146
+ );
147
+ });
148
+
149
+ it("appends missing entries to existing .gitignore", () => {
150
+ existsSync.mockReturnValue(true);
151
+ readFileSync.mockReturnValue("some-other-file\n");
152
+ ensureGitignore("/config/dir");
153
+ expect(appendFileSync).toHaveBeenCalledWith(
154
+ expect.stringMatching(/[\\/]config[\\/]dir[\\/]\.gitignore$/),
155
+ expect.stringContaining("anthropic-accounts.json"),
156
+ "utf-8",
157
+ );
158
+ });
159
+
160
+ it("does nothing when all entries already present", () => {
161
+ existsSync.mockReturnValue(true);
162
+ readFileSync.mockReturnValue(".gitignore\nanthropic-accounts.json\nanthropic-accounts.json.*.tmp\n");
163
+ ensureGitignore("/config/dir");
164
+ expect(appendFileSync).not.toHaveBeenCalled();
165
+ expect(writeFileSync).not.toHaveBeenCalled();
166
+ });
167
+
168
+ it("handles errors gracefully", () => {
169
+ existsSync.mockImplementation(() => {
170
+ throw new Error("permission denied");
171
+ });
172
+ // Should not throw
173
+ expect(() => ensureGitignore("/config/dir")).not.toThrow();
174
+ });
175
+ });
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // getStoragePath
179
+ // ---------------------------------------------------------------------------
180
+
181
+ describe("getStoragePath", () => {
182
+ it("returns path ending with anthropic-accounts.json", () => {
183
+ const path = getStoragePath();
184
+ expect(path.endsWith("anthropic-accounts.json")).toBe(true);
185
+ });
186
+ });
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // loadAccounts
190
+ // ---------------------------------------------------------------------------
191
+
192
+ describe("loadAccounts", () => {
193
+ beforeEach(() => {
194
+ vi.resetAllMocks();
195
+ });
196
+
197
+ it("returns null when file does not exist", async () => {
198
+ fs.readFile.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
199
+ const result = await loadAccounts();
200
+ expect(result).toBeNull();
201
+ });
202
+
203
+ it("returns null for invalid JSON", async () => {
204
+ fs.readFile.mockResolvedValue("not json");
205
+ const result = await loadAccounts();
206
+ expect(result).toBeNull();
207
+ });
208
+
209
+ it("returns null for wrong version", async () => {
210
+ fs.readFile.mockResolvedValue(JSON.stringify({ version: 99, accounts: [] }));
211
+ const result = await loadAccounts();
212
+ expect(result).toBeNull();
213
+ });
214
+
215
+ it("returns null when accounts is not an array", async () => {
216
+ fs.readFile.mockResolvedValue(JSON.stringify({ version: 1, accounts: "not-array" }));
217
+ const result = await loadAccounts();
218
+ expect(result).toBeNull();
219
+ });
220
+
221
+ it("loads valid accounts", async () => {
222
+ fs.readFile.mockResolvedValue(
223
+ JSON.stringify({
224
+ version: 1,
225
+ accounts: [
226
+ {
227
+ refreshToken: "token1",
228
+ access: "access1",
229
+ expires: 9999999999,
230
+ addedAt: 1000,
231
+ lastUsed: 2000,
232
+ enabled: true,
233
+ rateLimitResetTimes: {},
234
+ consecutiveFailures: 0,
235
+ lastFailureTime: null,
236
+ },
237
+ ],
238
+ activeIndex: 0,
239
+ }),
240
+ );
241
+ const result = await loadAccounts();
242
+ expect(result).not.toBeNull();
243
+ expect(result.accounts).toHaveLength(1);
244
+ expect(result.accounts[0].refreshToken).toBe("token1");
245
+ expect(result.accounts[0].access).toBe("access1");
246
+ expect(result.accounts[0].expires).toBe(9999999999);
247
+ expect(result.activeIndex).toBe(0);
248
+ });
249
+
250
+ it("filters out invalid accounts (missing refreshToken)", async () => {
251
+ fs.readFile.mockResolvedValue(
252
+ JSON.stringify({
253
+ version: 1,
254
+ accounts: [{ refreshToken: "valid", addedAt: 1000 }, { email: "no-token" }, null],
255
+ activeIndex: 0,
256
+ }),
257
+ );
258
+ const result = await loadAccounts();
259
+ expect(result.accounts).toHaveLength(1);
260
+ expect(result.accounts[0].refreshToken).toBe("valid");
261
+ });
262
+
263
+ it("clamps activeIndex to valid range", async () => {
264
+ fs.readFile.mockResolvedValue(
265
+ JSON.stringify({
266
+ version: 1,
267
+ accounts: [{ refreshToken: "token1" }],
268
+ activeIndex: 99,
269
+ }),
270
+ );
271
+ const result = await loadAccounts();
272
+ expect(result.activeIndex).toBe(0);
273
+ });
274
+
275
+ it("deduplicates accounts by refresh token", async () => {
276
+ fs.readFile.mockResolvedValue(
277
+ JSON.stringify({
278
+ version: 1,
279
+ accounts: [
280
+ { refreshToken: "token1", lastUsed: 1000 },
281
+ { refreshToken: "token1", lastUsed: 5000 },
282
+ ],
283
+ activeIndex: 0,
284
+ }),
285
+ );
286
+ const result = await loadAccounts();
287
+ expect(result.accounts).toHaveLength(1);
288
+ expect(result.accounts[0].lastUsed).toBe(5000);
289
+ });
290
+
291
+ it("applies defaults for missing fields", async () => {
292
+ fs.readFile.mockResolvedValue(
293
+ JSON.stringify({
294
+ version: 1,
295
+ accounts: [{ refreshToken: "token1" }],
296
+ activeIndex: 0,
297
+ }),
298
+ );
299
+ const result = await loadAccounts();
300
+ const acc = result.accounts[0];
301
+ expect(acc.enabled).toBe(true);
302
+ expect(acc.consecutiveFailures).toBe(0);
303
+ expect(acc.lastFailureTime).toBeNull();
304
+ expect(acc.rateLimitResetTimes).toEqual({});
305
+ expect(acc.lastUsed).toBe(0);
306
+ });
307
+ });
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // saveAccounts
311
+ // ---------------------------------------------------------------------------
312
+
313
+ describe("saveAccounts", () => {
314
+ beforeEach(() => {
315
+ vi.resetAllMocks();
316
+ existsSync.mockReturnValue(true);
317
+ readFileSync.mockReturnValue(".gitignore\nanthropic-accounts.json\nanthropic-accounts.json.*.tmp\n");
318
+ fs.mkdir.mockResolvedValue(undefined);
319
+ fs.writeFile.mockResolvedValue(undefined);
320
+ fs.rename.mockResolvedValue(undefined);
321
+ fs.chmod.mockResolvedValue(undefined);
322
+ });
323
+
324
+ it("writes atomically via temp file + rename", async () => {
325
+ const storage = {
326
+ version: 1,
327
+ accounts: [{ refreshToken: "token1" }],
328
+ activeIndex: 0,
329
+ };
330
+ await saveAccounts(storage);
331
+
332
+ expect(fs.writeFile).toHaveBeenCalledWith(expect.stringContaining(".tmp"), expect.any(String), {
333
+ encoding: "utf-8",
334
+ mode: 0o600,
335
+ });
336
+ expect(fs.rename).toHaveBeenCalled();
337
+ });
338
+
339
+ it("creates config directory if needed", async () => {
340
+ const storage = { version: 1, accounts: [], activeIndex: 0 };
341
+ await saveAccounts(storage);
342
+ expect(fs.mkdir).toHaveBeenCalledWith(expect.any(String), {
343
+ recursive: true,
344
+ });
345
+ });
346
+
347
+ it("cleans up temp file on write error", async () => {
348
+ fs.writeFile.mockRejectedValue(new Error("disk full"));
349
+ fs.unlink.mockResolvedValue(undefined);
350
+
351
+ const storage = { version: 1, accounts: [], activeIndex: 0 };
352
+ await expect(saveAccounts(storage)).rejects.toThrow("disk full");
353
+ expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".tmp"));
354
+ });
355
+ });
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // clearAccounts
359
+ // ---------------------------------------------------------------------------
360
+
361
+ describe("clearAccounts", () => {
362
+ beforeEach(() => {
363
+ vi.resetAllMocks();
364
+ });
365
+
366
+ it("deletes the storage file", async () => {
367
+ fs.unlink.mockResolvedValue(undefined);
368
+ await clearAccounts();
369
+ expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("anthropic-accounts.json"));
370
+ });
371
+
372
+ it("ignores ENOENT errors", async () => {
373
+ fs.unlink.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
374
+ await expect(clearAccounts()).resolves.toBeUndefined();
375
+ });
376
+
377
+ it("rethrows non-ENOENT errors", async () => {
378
+ fs.unlink.mockRejectedValue(Object.assign(new Error("permission denied"), { code: "EACCES" }));
379
+ await expect(clearAccounts()).rejects.toThrow("permission denied");
380
+ });
381
+ });
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // Stats validation
385
+ // ---------------------------------------------------------------------------
386
+
387
+ describe("createDefaultStats", () => {
388
+ it("creates zeroed stats with given timestamp", () => {
389
+ const stats = createDefaultStats(1000);
390
+ expect(stats).toEqual({
391
+ requests: 0,
392
+ inputTokens: 0,
393
+ outputTokens: 0,
394
+ cacheReadTokens: 0,
395
+ cacheWriteTokens: 0,
396
+ lastReset: 1000,
397
+ });
398
+ });
399
+
400
+ it("uses Date.now() when no timestamp given", () => {
401
+ const before = Date.now();
402
+ const stats = createDefaultStats();
403
+ expect(stats.lastReset).toBeGreaterThanOrEqual(before);
404
+ expect(stats.lastReset).toBeLessThanOrEqual(Date.now());
405
+ });
406
+ });
407
+
408
+ describe("loadAccounts with stats", () => {
409
+ it("loads accounts with stats fields preserved", async () => {
410
+ const stored = {
411
+ version: 1,
412
+ accounts: [
413
+ {
414
+ refreshToken: "tok1",
415
+ stats: {
416
+ requests: 42,
417
+ inputTokens: 10000,
418
+ outputTokens: 5000,
419
+ cacheReadTokens: 2000,
420
+ cacheWriteTokens: 100,
421
+ lastReset: 1700000000000,
422
+ },
423
+ },
424
+ ],
425
+ activeIndex: 0,
426
+ };
427
+ fs.readFile.mockResolvedValue(JSON.stringify(stored));
428
+
429
+ const result = await loadAccounts();
430
+ expect(result.accounts[0].stats.requests).toBe(42);
431
+ expect(result.accounts[0].stats.inputTokens).toBe(10000);
432
+ expect(result.accounts[0].stats.outputTokens).toBe(5000);
433
+ expect(result.accounts[0].stats.cacheReadTokens).toBe(2000);
434
+ expect(result.accounts[0].stats.cacheWriteTokens).toBe(100);
435
+ expect(result.accounts[0].stats.lastReset).toBe(1700000000000);
436
+ });
437
+
438
+ it("provides default stats when missing from stored data", async () => {
439
+ const stored = {
440
+ version: 1,
441
+ accounts: [{ refreshToken: "tok1" }],
442
+ activeIndex: 0,
443
+ };
444
+ fs.readFile.mockResolvedValue(JSON.stringify(stored));
445
+
446
+ const result = await loadAccounts();
447
+ expect(result.accounts[0].stats.requests).toBe(0);
448
+ expect(result.accounts[0].stats.inputTokens).toBe(0);
449
+ expect(result.accounts[0].stats.outputTokens).toBe(0);
450
+ });
451
+
452
+ it("clamps negative stats values to 0", async () => {
453
+ const stored = {
454
+ version: 1,
455
+ accounts: [
456
+ {
457
+ refreshToken: "tok1",
458
+ stats: { requests: -5, inputTokens: -100, outputTokens: NaN },
459
+ },
460
+ ],
461
+ activeIndex: 0,
462
+ };
463
+ fs.readFile.mockResolvedValue(JSON.stringify(stored));
464
+
465
+ const result = await loadAccounts();
466
+ expect(result.accounts[0].stats.requests).toBe(0);
467
+ expect(result.accounts[0].stats.inputTokens).toBe(0);
468
+ expect(result.accounts[0].stats.outputTokens).toBe(0);
469
+ });
470
+ });
package/nul ADDED
@@ -0,0 +1 @@
1
+ dir: cannot access '/b': No such file or directory
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "opencode-anthropic-fix",
3
+ "version": "0.0.14",
4
+ "main": "./index.mjs",
5
+ "scripts": {
6
+ "build": "node scripts/build.mjs",
7
+ "test": "vitest run",
8
+ "test:watch": "vitest",
9
+ "lint": "eslint .",
10
+ "lint:fix": "eslint --fix .",
11
+ "format": "prettier --write .",
12
+ "format:check": "prettier --check .",
13
+ "install:link": "node scripts/install.mjs link",
14
+ "install:copy": "npm run build && node scripts/install.mjs copy",
15
+ "uninstall": "node scripts/install.mjs uninstall",
16
+ "prepare": "husky"
17
+ },
18
+ "lint-staged": {
19
+ "*.mjs": [
20
+ "prettier --write",
21
+ "eslint --fix"
22
+ ],
23
+ "*.{json,md}": [
24
+ "prettier --write"
25
+ ]
26
+ },
27
+ "devDependencies": {
28
+ "@eslint/js": "^10.0.1",
29
+ "@opencode-ai/plugin": "^0.4.45",
30
+ "esbuild": "^0.27.3",
31
+ "eslint": "^10.0.0",
32
+ "husky": "^9.1.7",
33
+ "lint-staged": "^16.2.7",
34
+ "prettier": "^3.8.1",
35
+ "vitest": "^4.0.18"
36
+ },
37
+ "dependencies": {
38
+ "@openauthjs/openauth": "^0.4.3"
39
+ }
40
+ }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bun
2
+ import { $ } from "bun";
3
+
4
+ const dir = new URL("..", import.meta.url).pathname;
5
+ process.chdir(dir);
6
+
7
+ const bumpType = process.argv[2] || "patch";
8
+
9
+ console.log(`Bumping ${bumpType} version...`);
10
+
11
+ // Bump version in package.json
12
+ await $`npm version ${bumpType} --no-git-tag-version`;
13
+
14
+ // Read the new version
15
+ const pkg = await Bun.file("./package.json").json();
16
+ const version = pkg.version;
17
+
18
+ console.log(`New version: ${version}`);
19
+
20
+ // Commit the version bump
21
+ await $`git add package.json`;
22
+ await $`git commit -m "Bump version to ${version}"`;
23
+ await $`git push`;
24
+
25
+ // Trigger GitHub workflow
26
+ console.log(`Triggering publish workflow...`);
27
+ await $`gh workflow run publish.yml -f bump="${bumpType}"`;
28
+
29
+ console.log(`✓ Version bumped to ${version} and publish workflow triggered`);
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Bundle plugin and CLI into self-contained single files.
5
+ *
6
+ * dist/opencode-anthropic-auth-plugin.js — plugin (no external deps)
7
+ * dist/opencode-anthropic-auth-cli.mjs — CLI (no external deps)
8
+ */
9
+
10
+ import { build } from "esbuild";
11
+
12
+ const shared = {
13
+ bundle: true,
14
+ format: "esm",
15
+ platform: "node",
16
+ target: "node20",
17
+ // Node builtins stay as imports
18
+ external: ["node:*"],
19
+ };
20
+
21
+ await Promise.all([
22
+ build({
23
+ ...shared,
24
+ entryPoints: ["index.mjs"],
25
+ outfile: "dist/opencode-anthropic-auth-plugin.js",
26
+ }),
27
+ build({
28
+ ...shared,
29
+ entryPoints: ["cli.mjs"],
30
+ outfile: "dist/opencode-anthropic-auth-cli.mjs",
31
+ }),
32
+ ]);
33
+
34
+ console.log("Built dist/opencode-anthropic-auth-plugin.js and dist/opencode-anthropic-auth-cli.mjs");