berget 2.2.7 → 2.2.8

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 (130) hide show
  1. package/.github/workflows/publish.yml +6 -6
  2. package/.github/workflows/test.yml +1 -1
  3. package/.prettierrc +5 -3
  4. package/dist/index.js +24 -25
  5. package/dist/package.json +5 -3
  6. package/dist/src/agents/app.js +8 -8
  7. package/dist/src/agents/backend.js +3 -3
  8. package/dist/src/agents/devops.js +8 -8
  9. package/dist/src/agents/frontend.js +3 -3
  10. package/dist/src/agents/fullstack.js +3 -3
  11. package/dist/src/agents/index.js +18 -18
  12. package/dist/src/agents/quality.js +8 -8
  13. package/dist/src/agents/security.js +8 -8
  14. package/dist/src/client.js +115 -127
  15. package/dist/src/commands/api-keys.js +195 -202
  16. package/dist/src/commands/auth.js +16 -25
  17. package/dist/src/commands/autocomplete.js +8 -8
  18. package/dist/src/commands/billing.js +10 -19
  19. package/dist/src/commands/chat.js +139 -170
  20. package/dist/src/commands/clusters.js +21 -30
  21. package/dist/src/commands/code/__tests__/auth-sync.test.js +189 -186
  22. package/dist/src/commands/code/__tests__/fake-api-key-service.js +3 -13
  23. package/dist/src/commands/code/__tests__/fake-auth-service.js +21 -29
  24. package/dist/src/commands/code/__tests__/fake-command-runner.js +22 -33
  25. package/dist/src/commands/code/__tests__/fake-file-store.js +19 -41
  26. package/dist/src/commands/code/__tests__/fake-prompter.js +81 -97
  27. package/dist/src/commands/code/__tests__/setup-flow.test.js +295 -295
  28. package/dist/src/commands/code/adapters/clack-prompter.js +15 -32
  29. package/dist/src/commands/code/adapters/fs-file-store.js +25 -44
  30. package/dist/src/commands/code/adapters/spawn-command-runner.js +27 -41
  31. package/dist/src/commands/code/auth-sync.js +215 -228
  32. package/dist/src/commands/code/errors.js +15 -12
  33. package/dist/src/commands/code/setup.js +390 -425
  34. package/dist/src/commands/code.js +279 -294
  35. package/dist/src/commands/index.js +5 -5
  36. package/dist/src/commands/models.js +16 -25
  37. package/dist/src/commands/users.js +9 -18
  38. package/dist/src/constants/command-structure.js +138 -138
  39. package/dist/src/services/api-key-service.js +132 -152
  40. package/dist/src/services/auth-service.js +81 -95
  41. package/dist/src/services/browser-auth.js +121 -131
  42. package/dist/src/services/chat-service.js +369 -386
  43. package/dist/src/services/cluster-service.js +47 -62
  44. package/dist/src/services/collaborator-service.js +9 -21
  45. package/dist/src/services/flux-service.js +13 -25
  46. package/dist/src/services/helm-service.js +9 -21
  47. package/dist/src/services/kubectl-service.js +15 -29
  48. package/dist/src/utils/config-checker.js +7 -7
  49. package/dist/src/utils/config-loader.js +109 -109
  50. package/dist/src/utils/default-api-key.js +129 -139
  51. package/dist/src/utils/env-manager.js +55 -66
  52. package/dist/src/utils/error-handler.js +62 -62
  53. package/dist/src/utils/logger.js +74 -67
  54. package/dist/src/utils/markdown-renderer.js +28 -28
  55. package/dist/src/utils/opencode-validator.js +67 -69
  56. package/dist/src/utils/token-manager.js +67 -65
  57. package/dist/tests/commands/chat.test.js +30 -39
  58. package/dist/tests/commands/code.test.js +186 -195
  59. package/dist/tests/utils/config-loader.test.js +107 -107
  60. package/dist/tests/utils/env-manager.test.js +81 -90
  61. package/dist/tests/utils/opencode-validator.test.js +42 -41
  62. package/dist/vitest.config.js +1 -1
  63. package/eslint.config.mjs +50 -30
  64. package/index.ts +30 -31
  65. package/package.json +5 -3
  66. package/src/agents/app.ts +9 -9
  67. package/src/agents/backend.ts +4 -4
  68. package/src/agents/devops.ts +9 -9
  69. package/src/agents/frontend.ts +4 -4
  70. package/src/agents/fullstack.ts +4 -4
  71. package/src/agents/index.ts +27 -25
  72. package/src/agents/quality.ts +9 -9
  73. package/src/agents/security.ts +9 -9
  74. package/src/agents/types.ts +10 -10
  75. package/src/client.ts +85 -77
  76. package/src/commands/api-keys.ts +190 -185
  77. package/src/commands/auth.ts +15 -14
  78. package/src/commands/autocomplete.ts +10 -10
  79. package/src/commands/billing.ts +13 -12
  80. package/src/commands/chat.ts +145 -142
  81. package/src/commands/clusters.ts +20 -19
  82. package/src/commands/code/__tests__/auth-sync.test.ts +176 -175
  83. package/src/commands/code/__tests__/fake-api-key-service.ts +2 -2
  84. package/src/commands/code/__tests__/fake-auth-service.ts +18 -18
  85. package/src/commands/code/__tests__/fake-command-runner.ts +28 -22
  86. package/src/commands/code/__tests__/fake-file-store.ts +15 -15
  87. package/src/commands/code/__tests__/fake-prompter.ts +86 -85
  88. package/src/commands/code/__tests__/setup-flow.test.ts +253 -251
  89. package/src/commands/code/adapters/clack-prompter.ts +32 -30
  90. package/src/commands/code/adapters/fs-file-store.ts +18 -17
  91. package/src/commands/code/adapters/spawn-command-runner.ts +20 -15
  92. package/src/commands/code/auth-sync.ts +210 -210
  93. package/src/commands/code/errors.ts +11 -11
  94. package/src/commands/code/ports/auth-services.ts +7 -7
  95. package/src/commands/code/ports/command-runner.ts +2 -2
  96. package/src/commands/code/ports/file-store.ts +3 -3
  97. package/src/commands/code/ports/prompter.ts +13 -13
  98. package/src/commands/code/setup.ts +408 -406
  99. package/src/commands/code.ts +288 -287
  100. package/src/commands/index.ts +11 -10
  101. package/src/commands/models.ts +19 -18
  102. package/src/commands/users.ts +11 -10
  103. package/src/constants/command-structure.ts +159 -159
  104. package/src/services/api-key-service.ts +85 -85
  105. package/src/services/auth-service.ts +55 -54
  106. package/src/services/browser-auth.ts +62 -62
  107. package/src/services/chat-service.ts +169 -170
  108. package/src/services/cluster-service.ts +28 -28
  109. package/src/services/collaborator-service.ts +6 -6
  110. package/src/services/flux-service.ts +17 -17
  111. package/src/services/helm-service.ts +11 -11
  112. package/src/services/kubectl-service.ts +12 -12
  113. package/src/types/api.d.ts +1933 -1933
  114. package/src/types/json.d.ts +1 -1
  115. package/src/utils/config-checker.ts +6 -6
  116. package/src/utils/config-loader.ts +130 -129
  117. package/src/utils/default-api-key.ts +81 -80
  118. package/src/utils/env-manager.ts +37 -37
  119. package/src/utils/error-handler.ts +64 -64
  120. package/src/utils/logger.ts +72 -66
  121. package/src/utils/markdown-renderer.ts +28 -28
  122. package/src/utils/opencode-validator.ts +72 -71
  123. package/src/utils/token-manager.ts +69 -68
  124. package/tests/commands/chat.test.ts +32 -31
  125. package/tests/commands/code.test.ts +182 -181
  126. package/tests/utils/config-loader.test.ts +111 -110
  127. package/tests/utils/env-manager.test.ts +83 -79
  128. package/tests/utils/opencode-validator.test.ts +43 -42
  129. package/tsconfig.json +2 -1
  130. package/vitest.config.ts +2 -2
@@ -1,481 +1,482 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it } from 'vitest';
2
+
2
3
  import {
3
- readCliAuth,
4
- isToolAuthenticated,
4
+ type AuthDeps,
5
+ type CliAuth,
6
+ configureAuth,
5
7
  decodeJwtPayload,
6
8
  hasBergetCodeSeat,
7
- syncOAuthToTool,
9
+ isToolAuthenticated,
10
+ readCliAuth,
8
11
  syncApiKeyToTool,
9
- configureAuth,
10
- type CliAuth,
11
- type AuthDeps,
12
- } from "../auth-sync";
13
- import { FakeFileStore } from "./fake-file-store";
14
- import { FakePrompter, select, confirm } from "./fake-prompter";
15
- import { FakeAuthService } from "./fake-auth-service";
16
- import { FakeApiKeyService } from "./fake-api-key-service";
12
+ syncOAuthToTool,
13
+ } from '../auth-sync';
14
+ import { FakeApiKeyService } from './fake-api-key-service';
15
+ import { FakeAuthService } from './fake-auth-service';
16
+ import { FakeFileStore } from './fake-file-store';
17
+ import { confirm, FakePrompter, select } from './fake-prompter';
17
18
 
18
19
  function base64urlEncode(data: string): string {
19
- return Buffer.from(data).toString("base64url");
20
+ return Buffer.from(data).toString('base64url');
20
21
  }
21
22
 
22
23
  function makeJwt(payload: Record<string, unknown>): string {
23
- const header = base64urlEncode(JSON.stringify({ alg: "none", typ: "JWT" }));
24
+ const header = base64urlEncode(JSON.stringify({ alg: 'none', typ: 'JWT' }));
24
25
  const body = base64urlEncode(JSON.stringify(payload));
25
26
  return `${header}.${body}.signature`;
26
27
  }
27
28
 
28
- const HOME = "/home/user";
29
+ const HOME = '/home/user';
29
30
 
30
31
  const fakeCliAuth = (overrides: Partial<CliAuth> = {}): CliAuth => ({
31
32
  access_token: makeJwt({
32
- realm_access: { roles: ["default-roles-berget"] },
33
- exp: 9999999999999, // JWT exp in seconds (different from expires_at in ms)
33
+ exp: 9_999_999_999_999, // JWT exp in seconds (different from expires_at in ms)
34
+ realm_access: { roles: ['default-roles-berget'] },
34
35
  }),
35
- refresh_token: "refreshtoken",
36
- expires_at: 9999999999999,
36
+ expires_at: 9_999_999_999_999,
37
+ refresh_token: 'refreshtoken',
37
38
  ...overrides,
38
39
  });
39
40
 
40
- describe("readCliAuth", () => {
41
- it("returns null when auth file does not exist", async () => {
41
+ describe('readCliAuth', () => {
42
+ it('returns null when auth file does not exist', async () => {
42
43
  const files = new FakeFileStore();
43
44
  const result = await readCliAuth(files, HOME);
44
45
  expect(result).toBeNull();
45
46
  });
46
47
 
47
- it("parses valid auth file", async () => {
48
+ it('parses valid auth file', async () => {
48
49
  const files = new FakeFileStore();
49
50
  const auth: CliAuth = fakeCliAuth();
50
- files.seed(HOME + "/.berget/auth.json", JSON.stringify(auth));
51
+ files.seed(HOME + '/.berget/auth.json', JSON.stringify(auth));
51
52
 
52
53
  const result = await readCliAuth(files, HOME);
53
54
  // The JWT's exp claim should be extracted and converted to milliseconds
54
55
  const jwtPayload = JSON.parse(
55
- Buffer.from(auth.access_token.split(".")[1], "base64url").toString()
56
+ Buffer.from(auth.access_token.split('.')[1], 'base64url').toString(),
56
57
  );
57
58
  const expectedAuth = {
58
59
  access_token: auth.access_token,
59
- refresh_token: auth.refresh_token,
60
60
  expires_at: (jwtPayload.exp as number) * 1000,
61
+ refresh_token: auth.refresh_token,
61
62
  };
62
63
  expect(result).toEqual(expectedAuth);
63
64
  });
64
65
 
65
- it("returns null for malformed JSON", async () => {
66
+ it('returns null for malformed JSON', async () => {
66
67
  const files = new FakeFileStore();
67
- files.seed(HOME + "/.berget/auth.json", "not json");
68
+ files.seed(HOME + '/.berget/auth.json', 'not json');
68
69
  const result = await readCliAuth(files, HOME);
69
70
  expect(result).toBeNull();
70
71
  });
71
72
 
72
- it("returns null when fields are missing", async () => {
73
+ it('returns null when fields are missing', async () => {
73
74
  const files = new FakeFileStore();
74
- files.seed(HOME + "/.berget/auth.json", JSON.stringify({ access_token: "only" }));
75
+ files.seed(HOME + '/.berget/auth.json', JSON.stringify({ access_token: 'only' }));
75
76
  const result = await readCliAuth(files, HOME);
76
77
  expect(result).toBeNull();
77
78
  });
78
79
  });
79
80
 
80
- describe("isToolAuthenticated", () => {
81
- it("returns false when auth file does not exist", async () => {
81
+ describe('isToolAuthenticated', () => {
82
+ it('returns false when auth file does not exist', async () => {
82
83
  const files = new FakeFileStore();
83
- const result = await isToolAuthenticated(files, HOME, "opencode");
84
+ const result = await isToolAuthenticated(files, HOME, 'opencode');
84
85
  expect(result).toBe(false);
85
86
  });
86
87
 
87
- it("returns true when berget entry exists", async () => {
88
+ it('returns true when berget entry exists', async () => {
88
89
  const files = new FakeFileStore();
89
90
  files.seed(
90
- HOME + "/.local/share/opencode/auth.json",
91
- JSON.stringify({ berget: { type: "oauth", access: "tok" } })
91
+ HOME + '/.local/share/opencode/auth.json',
92
+ JSON.stringify({ berget: { access: 'tok', type: 'oauth' } }),
92
93
  );
93
- const result = await isToolAuthenticated(files, HOME, "opencode");
94
+ const result = await isToolAuthenticated(files, HOME, 'opencode');
94
95
  expect(result).toBe(true);
95
96
  });
96
97
 
97
- it("returns false when berget entry is missing", async () => {
98
+ it('returns false when berget entry is missing', async () => {
98
99
  const files = new FakeFileStore();
99
100
  files.seed(
100
- HOME + "/.local/share/opencode/auth.json",
101
- JSON.stringify({ openai: { type: "api" } })
101
+ HOME + '/.local/share/opencode/auth.json',
102
+ JSON.stringify({ openai: { type: 'api' } }),
102
103
  );
103
- const result = await isToolAuthenticated(files, HOME, "opencode");
104
+ const result = await isToolAuthenticated(files, HOME, 'opencode');
104
105
  expect(result).toBe(false);
105
106
  });
106
107
 
107
- it("checks correct path for pi", async () => {
108
+ it('checks correct path for pi', async () => {
108
109
  const files = new FakeFileStore();
109
- files.seed(HOME + "/.pi/agent/auth.json", JSON.stringify({ berget: { type: "oauth" } }));
110
- const result = await isToolAuthenticated(files, HOME, "pi");
110
+ files.seed(HOME + '/.pi/agent/auth.json', JSON.stringify({ berget: { type: 'oauth' } }));
111
+ const result = await isToolAuthenticated(files, HOME, 'pi');
111
112
  expect(result).toBe(true);
112
113
  });
113
114
  });
114
115
 
115
- describe("decodeJwtPayload", () => {
116
- it("decodes a valid JWT payload", () => {
117
- const payload = { sub: "123", realm_access: { roles: ["admin"] } };
116
+ describe('decodeJwtPayload', () => {
117
+ it('decodes a valid JWT payload', () => {
118
+ const payload = { realm_access: { roles: ['admin'] }, sub: '123' };
118
119
  const jwt = makeJwt(payload);
119
120
  expect(decodeJwtPayload(jwt)).toEqual(payload);
120
121
  });
121
122
 
122
- it("returns null for invalid format", () => {
123
- expect(decodeJwtPayload("not.a")).toBeNull();
124
- expect(decodeJwtPayload("onlyOnePart")).toBeNull();
123
+ it('returns null for invalid format', () => {
124
+ expect(decodeJwtPayload('not.a')).toBeNull();
125
+ expect(decodeJwtPayload('onlyOnePart')).toBeNull();
125
126
  });
126
127
 
127
- it("returns null for invalid base64", () => {
128
- expect(decodeJwtPayload("header.bad\.base64.signature")).toBeNull();
128
+ it('returns null for invalid base64', () => {
129
+ expect(decodeJwtPayload('header.bad\.base64.signature')).toBeNull();
129
130
  });
130
131
  });
131
132
 
132
- describe("hasBergetCodeSeat", () => {
133
- it("returns true when berget_code_seat is present", () => {
133
+ describe('hasBergetCodeSeat', () => {
134
+ it('returns true when berget_code_seat is present', () => {
134
135
  const token = makeJwt({
135
- realm_access: { roles: ["berget_code_seat", "default-roles-berget"] },
136
+ realm_access: { roles: ['berget_code_seat', 'default-roles-berget'] },
136
137
  });
137
138
  expect(hasBergetCodeSeat(token)).toBe(true);
138
139
  });
139
140
 
140
- it("returns false when role is missing", () => {
141
+ it('returns false when role is missing', () => {
141
142
  const token = makeJwt({
142
- realm_access: { roles: ["default-roles-berget"] },
143
+ realm_access: { roles: ['default-roles-berget'] },
143
144
  });
144
145
  expect(hasBergetCodeSeat(token)).toBe(false);
145
146
  });
146
147
 
147
- it("returns false when realm_access is missing", () => {
148
- const token = makeJwt({ sub: "123" });
148
+ it('returns false when realm_access is missing', () => {
149
+ const token = makeJwt({ sub: '123' });
149
150
  expect(hasBergetCodeSeat(token)).toBe(false);
150
151
  });
151
152
 
152
- it("returns false for invalid JWT", () => {
153
- expect(hasBergetCodeSeat("invalid")).toBe(false);
153
+ it('returns false for invalid JWT', () => {
154
+ expect(hasBergetCodeSeat('invalid')).toBe(false);
154
155
  });
155
156
  });
156
157
 
157
- describe("syncOAuthToTool", () => {
158
- it("writes oauth tokens to opencode auth file", async () => {
158
+ describe('syncOAuthToTool', () => {
159
+ it('writes oauth tokens to opencode auth file', async () => {
159
160
  const files = new FakeFileStore();
160
161
  const auth = fakeCliAuth();
161
162
 
162
- await syncOAuthToTool(files, HOME, "opencode", auth);
163
+ await syncOAuthToTool(files, HOME, 'opencode', auth);
163
164
 
164
165
  const written = files.getWrittenFiles();
165
- const content = written.get(HOME + "/.local/share/opencode/auth.json")!;
166
+ const content = written.get(HOME + '/.local/share/opencode/auth.json')!;
166
167
  const parsed = JSON.parse(content);
167
168
  // The expires field should now use the JWT's exp claim (converted to milliseconds)
168
169
  const jwtPayload = JSON.parse(
169
- Buffer.from(auth.access_token.split(".")[1], "base64url").toString()
170
+ Buffer.from(auth.access_token.split('.')[1], 'base64url').toString(),
170
171
  );
171
172
  expect(parsed.berget).toEqual({
172
- type: "oauth",
173
173
  access: auth.access_token,
174
- refresh: auth.refresh_token,
175
174
  expires: (jwtPayload.exp as number) * 1000,
175
+ refresh: auth.refresh_token,
176
+ type: 'oauth',
176
177
  });
177
178
  });
178
179
 
179
- it("writes oauth tokens to pi auth file", async () => {
180
+ it('writes oauth tokens to pi auth file', async () => {
180
181
  const files = new FakeFileStore();
181
182
  const auth = fakeCliAuth();
182
183
 
183
- await syncOAuthToTool(files, HOME, "pi", auth);
184
+ await syncOAuthToTool(files, HOME, 'pi', auth);
184
185
 
185
186
  const written = files.getWrittenFiles();
186
- const content = written.get(HOME + "/.pi/agent/auth.json")!;
187
+ const content = written.get(HOME + '/.pi/agent/auth.json')!;
187
188
  const parsed = JSON.parse(content);
188
- expect(parsed.berget.type).toBe("oauth");
189
+ expect(parsed.berget.type).toBe('oauth');
189
190
  });
190
191
 
191
- it("merges with existing providers", async () => {
192
+ it('merges with existing providers', async () => {
192
193
  const files = new FakeFileStore();
193
194
  files.seed(
194
- HOME + "/.local/share/opencode/auth.json",
195
- JSON.stringify({ openai: { type: "api", key: "sk-openai" } })
195
+ HOME + '/.local/share/opencode/auth.json',
196
+ JSON.stringify({ openai: { key: 'sk-openai', type: 'api' } }),
196
197
  );
197
198
 
198
199
  const auth = fakeCliAuth();
199
- await syncOAuthToTool(files, HOME, "opencode", auth);
200
+ await syncOAuthToTool(files, HOME, 'opencode', auth);
200
201
 
201
202
  const written = files.getWrittenFiles();
202
- const parsed = JSON.parse(written.get(HOME + "/.local/share/opencode/auth.json")!);
203
- expect(parsed.openai).toEqual({ type: "api", key: "sk-openai" });
204
- expect(parsed.berget.type).toBe("oauth");
203
+ const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
204
+ expect(parsed.openai).toEqual({ key: 'sk-openai', type: 'api' });
205
+ expect(parsed.berget.type).toBe('oauth');
205
206
  });
206
207
 
207
- it("sets 0o600 permissions on the auth file", async () => {
208
+ it('sets 0o600 permissions on the auth file', async () => {
208
209
  const files = new FakeFileStore();
209
210
  const auth = fakeCliAuth();
210
211
 
211
- await syncOAuthToTool(files, HOME, "opencode", auth);
212
+ await syncOAuthToTool(files, HOME, 'opencode', auth);
212
213
 
213
214
  const chmodCalls = files.getChmodCalls();
214
215
  expect(chmodCalls).toHaveLength(1);
215
216
  expect(chmodCalls[0]).toEqual({
216
- path: HOME + "/.local/share/opencode/auth.json",
217
217
  mode: 0o600,
218
+ path: HOME + '/.local/share/opencode/auth.json',
218
219
  });
219
220
  });
220
221
  });
221
222
 
222
- describe("syncApiKeyToTool", () => {
223
+ describe('syncApiKeyToTool', () => {
223
224
  it('writes api key to opencode auth file with type "api"', async () => {
224
225
  const files = new FakeFileStore();
225
226
 
226
- await syncApiKeyToTool(files, HOME, "opencode", "sk_ber_test");
227
+ await syncApiKeyToTool(files, HOME, 'opencode', 'sk_ber_test');
227
228
 
228
229
  const written = files.getWrittenFiles();
229
- const content = written.get(HOME + "/.local/share/opencode/auth.json")!;
230
+ const content = written.get(HOME + '/.local/share/opencode/auth.json')!;
230
231
  const parsed = JSON.parse(content);
231
232
  expect(parsed.berget).toEqual({
232
- type: "api",
233
- key: "sk_ber_test",
233
+ key: 'sk_ber_test',
234
+ type: 'api',
234
235
  });
235
236
  });
236
237
 
237
238
  it('writes api key to pi auth file with type "api_key"', async () => {
238
239
  const files = new FakeFileStore();
239
240
 
240
- await syncApiKeyToTool(files, HOME, "pi", "sk_ber_pi");
241
+ await syncApiKeyToTool(files, HOME, 'pi', 'sk_ber_pi');
241
242
 
242
243
  const written = files.getWrittenFiles();
243
- const content = written.get(HOME + "/.pi/agent/auth.json")!;
244
+ const content = written.get(HOME + '/.pi/agent/auth.json')!;
244
245
  const parsed = JSON.parse(content);
245
246
  expect(parsed.berget).toEqual({
246
- type: "api_key",
247
- key: "sk_ber_pi",
247
+ key: 'sk_ber_pi',
248
+ type: 'api_key',
248
249
  });
249
250
  });
250
251
 
251
- it("merges with existing providers", async () => {
252
+ it('merges with existing providers', async () => {
252
253
  const files = new FakeFileStore();
253
254
  files.seed(
254
- HOME + "/.local/share/opencode/auth.json",
255
- JSON.stringify({ anthropic: { type: "api", key: "sk-ant" } })
255
+ HOME + '/.local/share/opencode/auth.json',
256
+ JSON.stringify({ anthropic: { key: 'sk-ant', type: 'api' } }),
256
257
  );
257
258
 
258
- await syncApiKeyToTool(files, HOME, "opencode", "sk_ber_test");
259
+ await syncApiKeyToTool(files, HOME, 'opencode', 'sk_ber_test');
259
260
 
260
261
  const written = files.getWrittenFiles();
261
- const parsed = JSON.parse(written.get(HOME + "/.local/share/opencode/auth.json")!);
262
- expect(parsed.anthropic).toEqual({ type: "api", key: "sk-ant" });
262
+ const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
263
+ expect(parsed.anthropic).toEqual({ key: 'sk-ant', type: 'api' });
263
264
  });
264
265
 
265
- it("sets 0o600 permissions on the auth file", async () => {
266
+ it('sets 0o600 permissions on the auth file', async () => {
266
267
  const files = new FakeFileStore();
267
268
 
268
- await syncApiKeyToTool(files, HOME, "opencode", "sk_ber_test");
269
+ await syncApiKeyToTool(files, HOME, 'opencode', 'sk_ber_test');
269
270
 
270
271
  const chmodCalls = files.getChmodCalls();
271
272
  expect(chmodCalls).toHaveLength(1);
272
273
  expect(chmodCalls[0]).toEqual({
273
- path: HOME + "/.local/share/opencode/auth.json",
274
274
  mode: 0o600,
275
+ path: HOME + '/.local/share/opencode/auth.json',
275
276
  });
276
277
  });
277
278
  });
278
279
 
279
- describe("configureAuth", () => {
280
+ describe('configureAuth', () => {
280
281
  const makeAuthDeps = (overrides: Partial<AuthDeps> = {}): AuthDeps =>
281
282
  ({
282
- prompter: new FakePrompter([]),
283
- files: new FakeFileStore(),
283
+ apiKeyService: new FakeApiKeyService('sk_ber_test'),
284
284
  authService: new FakeAuthService(true),
285
- apiKeyService: new FakeApiKeyService("sk_ber_test"),
285
+ files: new FakeFileStore(),
286
286
  homeDir: HOME,
287
+ prompter: new FakePrompter([]),
287
288
  ...overrides,
288
289
  }) as AuthDeps;
289
290
 
290
- it("Case A: already authenticated — chooses keep → skips flow", async () => {
291
+ it('Case A: already authenticated — chooses keep → skips flow', async () => {
291
292
  const files = new FakeFileStore();
292
293
  files.seed(
293
- HOME + "/.local/share/opencode/auth.json",
294
- JSON.stringify({ berget: { type: "oauth" } })
294
+ HOME + '/.local/share/opencode/auth.json',
295
+ JSON.stringify({ berget: { type: 'oauth' } }),
295
296
  );
296
297
 
297
- const prompter = new FakePrompter([select("keep")]);
298
+ const prompter = new FakePrompter([select('keep')]);
298
299
 
299
300
  const deps = makeAuthDeps({ files, prompter });
300
- const result = await configureAuth(deps, "opencode");
301
+ const result = await configureAuth(deps, 'opencode');
301
302
 
302
303
  expect(result.authenticated).toBe(true);
303
304
  expect((deps.prompter as FakePrompter).calls.length).toBe(1); // Only the select prompt
304
305
  });
305
306
 
306
- it("Case A reconfigure: already authenticated — reconfigure with fresh browser login", async () => {
307
+ it('Case A reconfigure: already authenticated — reconfigure with fresh browser login', async () => {
307
308
  const files = new FakeFileStore();
308
309
  files.seed(
309
- HOME + "/.local/share/opencode/auth.json",
310
- JSON.stringify({ berget: { type: "oauth" } })
310
+ HOME + '/.local/share/opencode/auth.json',
311
+ JSON.stringify({ berget: { type: 'oauth' } }),
311
312
  );
312
313
 
313
- const prompter = new FakePrompter([select("reconfigure"), select("subscription")]);
314
+ const prompter = new FakePrompter([select('reconfigure'), select('subscription')]);
314
315
 
315
316
  const deps = makeAuthDeps({ files, prompter });
316
317
  const authService = deps.authService as FakeAuthService;
317
- const result = await configureAuth(deps, "opencode");
318
+ const result = await configureAuth(deps, 'opencode');
318
319
 
319
320
  expect(result.authenticated).toBe(true);
320
321
  // Should have called loginInteractive (no token reuse since no ~/.berget/auth.json seeded)
321
322
  expect(authService.loginInteractiveCallCount).toBeGreaterThanOrEqual(1);
322
323
  });
323
324
 
324
- it("Case A reconfigure: already authenticated — reconfigure with valid CLI token → skips browser", async () => {
325
+ it('Case A reconfigure: already authenticated — reconfigure with valid CLI token → skips browser', async () => {
325
326
  const farFuture = Date.now() + 365 * 24 * 60 * 60 * 1000; // 1 year from now
326
327
  const files = new FakeFileStore();
327
328
  files.seed(
328
- HOME + "/.local/share/opencode/auth.json",
329
- JSON.stringify({ berget: { type: "oauth" } })
329
+ HOME + '/.local/share/opencode/auth.json',
330
+ JSON.stringify({ berget: { type: 'oauth' } }),
330
331
  );
331
332
  files.seed(
332
- HOME + "/.berget/auth.json",
333
+ HOME + '/.berget/auth.json',
333
334
  JSON.stringify({
334
- access_token: makeJwt({ realm_access: { roles: ["berget_code_seat"] }, exp: farFuture }),
335
- refresh_token: "ref",
335
+ access_token: makeJwt({ exp: farFuture, realm_access: { roles: ['berget_code_seat'] } }),
336
336
  expires_at: farFuture,
337
- })
337
+ refresh_token: 'ref',
338
+ }),
338
339
  );
339
340
 
340
- const prompter = new FakePrompter([select("reconfigure"), select("subscription")]);
341
+ const prompter = new FakePrompter([select('reconfigure'), select('subscription')]);
341
342
 
342
343
  const authService = new FakeAuthService(true);
343
- const deps = makeAuthDeps({ files, prompter, authService });
344
- const result = await configureAuth(deps, "opencode");
344
+ const deps = makeAuthDeps({ authService, files, prompter });
345
+ const result = await configureAuth(deps, 'opencode');
345
346
 
346
347
  expect(result.authenticated).toBe(true);
347
348
  // Should NOT have called loginInteractive since token was reused
348
349
  expect(authService.loginInteractiveCallCount).toBe(0);
349
350
  });
350
351
 
351
- it("Case B: login success + berget_code_seat → chooses subscription", async () => {
352
+ it('Case B: login success + berget_code_seat → chooses subscription', async () => {
352
353
  const files = new FakeFileStore();
353
354
  const jwt = makeJwt({
354
- realm_access: { roles: ["berget_code_seat"] },
355
- exp: 9999999999999,
355
+ exp: 9_999_999_999_999,
356
+ realm_access: { roles: ['berget_code_seat'] },
356
357
  });
357
358
  files.seed(
358
- HOME + "/.berget/auth.json",
359
+ HOME + '/.berget/auth.json',
359
360
  JSON.stringify({
360
361
  access_token: jwt,
361
- refresh_token: "ref",
362
- expires_at: 9999999999999,
363
- })
362
+ expires_at: 9_999_999_999_999,
363
+ refresh_token: 'ref',
364
+ }),
364
365
  );
365
366
 
366
- const prompter = new FakePrompter([select("subscription")]);
367
+ const prompter = new FakePrompter([select('subscription')]);
367
368
 
368
369
  const deps = makeAuthDeps({ files, prompter });
369
- const result = await configureAuth(deps, "opencode");
370
+ const result = await configureAuth(deps, 'opencode');
370
371
 
371
372
  expect(result.authenticated).toBe(true);
372
373
  const written = files.getWrittenFiles();
373
- expect(written.has(HOME + "/.local/share/opencode/auth.json")).toBe(true);
374
- const parsed = JSON.parse(written.get(HOME + "/.local/share/opencode/auth.json")!);
375
- expect(parsed.berget.type).toBe("oauth");
374
+ expect(written.has(HOME + '/.local/share/opencode/auth.json')).toBe(true);
375
+ const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
376
+ expect(parsed.berget.type).toBe('oauth');
376
377
  });
377
378
 
378
- it("Case B variant: login success + seat → chooses api_key", async () => {
379
+ it('Case B variant: login success + seat → chooses api_key', async () => {
379
380
  const files = new FakeFileStore();
380
381
  const jwt = makeJwt({
381
- realm_access: { roles: ["berget_code_seat"] },
382
- exp: 9999999999999,
382
+ exp: 9_999_999_999_999,
383
+ realm_access: { roles: ['berget_code_seat'] },
383
384
  });
384
385
  files.seed(
385
- HOME + "/.berget/auth.json",
386
+ HOME + '/.berget/auth.json',
386
387
  JSON.stringify({
387
388
  access_token: jwt,
388
- refresh_token: "ref",
389
- expires_at: 9999999999999,
390
- })
389
+ expires_at: 9_999_999_999_999,
390
+ refresh_token: 'ref',
391
+ }),
391
392
  );
392
393
 
393
- const prompter = new FakePrompter([select("api_key")]);
394
+ const prompter = new FakePrompter([select('api_key')]);
394
395
 
395
396
  const deps = makeAuthDeps({ files, prompter });
396
- const result = await configureAuth(deps, "opencode");
397
+ const result = await configureAuth(deps, 'opencode');
397
398
 
398
399
  expect(result.authenticated).toBe(true);
399
400
  const written = files.getWrittenFiles();
400
- const parsed = JSON.parse(written.get(HOME + "/.local/share/opencode/auth.json")!);
401
- expect(parsed.berget.type).toBe("api");
402
- expect(parsed.berget.key).toBe("sk_ber_test");
401
+ const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
402
+ expect(parsed.berget.type).toBe('api');
403
+ expect(parsed.berget.key).toBe('sk_ber_test');
403
404
  });
404
405
 
405
- it("Case C: login success + no seat → creates api key", async () => {
406
+ it('Case C: login success + no seat → creates api key', async () => {
406
407
  const files = new FakeFileStore();
407
408
  const prompter = new FakePrompter([confirm(true)]);
408
409
 
409
- const deps = makeAuthDeps({ files, prompter, authService: new FakeAuthService(true, false) });
410
- const result = await configureAuth(deps, "opencode");
410
+ const deps = makeAuthDeps({ authService: new FakeAuthService(true, false), files, prompter });
411
+ const result = await configureAuth(deps, 'opencode');
411
412
 
412
413
  expect(result.authenticated).toBe(true);
413
414
  const written = files.getWrittenFiles();
414
- const parsed = JSON.parse(written.get(HOME + "/.local/share/opencode/auth.json")!);
415
- expect(parsed.berget.type).toBe("api");
416
- expect(parsed.berget.key).toBe("sk_ber_test");
415
+ const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
416
+ expect(parsed.berget.type).toBe('api');
417
+ expect(parsed.berget.key).toBe('sk_ber_test');
417
418
  });
418
419
 
419
- it("Case D: login success + no seat → declines api key", async () => {
420
+ it('Case D: login success + no seat → declines api key', async () => {
420
421
  const files = new FakeFileStore();
421
422
  const prompter = new FakePrompter([confirm(false)]);
422
423
 
423
- const deps = makeAuthDeps({ files, prompter, authService: new FakeAuthService(true, false) });
424
- const result = await configureAuth(deps, "opencode");
424
+ const deps = makeAuthDeps({ authService: new FakeAuthService(true, false), files, prompter });
425
+ const result = await configureAuth(deps, 'opencode');
425
426
 
426
427
  expect(result.authenticated).toBe(false);
427
- expect(files.getWrittenFiles().has(HOME + "/.local/share/opencode/auth.json")).toBe(false);
428
+ expect(files.getWrittenFiles().has(HOME + '/.local/share/opencode/auth.json')).toBe(false);
428
429
  });
429
430
 
430
- it("Case E: login fails", async () => {
431
+ it('Case E: login fails', async () => {
431
432
  const files = new FakeFileStore();
432
433
  const authService = new FakeAuthService(false);
433
434
 
434
- const deps = makeAuthDeps({ files, authService });
435
- const result = await configureAuth(deps, "opencode");
435
+ const deps = makeAuthDeps({ authService, files });
436
+ const result = await configureAuth(deps, 'opencode');
436
437
 
437
438
  expect(result.authenticated).toBe(false);
438
439
  });
439
440
 
440
- it("fails authentication when jwt decode fails", async () => {
441
+ it('fails authentication when jwt decode fails', async () => {
441
442
  const prompter = new FakePrompter([]);
442
443
 
443
444
  const deps = makeAuthDeps({
444
- prompter,
445
445
  authService: new FakeAuthService(true, true, false), // valid login, has seat, but invalid token
446
+ prompter,
446
447
  });
447
- const result = await configureAuth(deps, "opencode");
448
+ const result = await configureAuth(deps, 'opencode');
448
449
 
449
450
  expect(result.authenticated).toBe(false); // Should fail due to invalid JWT
450
451
  const written = (deps.files as FakeFileStore).getWrittenFiles();
451
452
  expect(written.size).toBe(0); // No files should be written
452
453
  });
453
454
 
454
- it("preserves existing providers during sync", async () => {
455
+ it('preserves existing providers during sync', async () => {
455
456
  const files = new FakeFileStore();
456
457
  files.seed(
457
- HOME + "/.local/share/opencode/auth.json",
458
- JSON.stringify({ openai: { type: "api", key: "sk-openai" } })
458
+ HOME + '/.local/share/opencode/auth.json',
459
+ JSON.stringify({ openai: { key: 'sk-openai', type: 'api' } }),
459
460
  );
460
461
  files.seed(
461
- HOME + "/.berget/auth.json",
462
+ HOME + '/.berget/auth.json',
462
463
  JSON.stringify({
463
464
  access_token: makeJwt({
464
- realm_access: { roles: ["berget_code_seat"], exp: 9999999999999 },
465
+ realm_access: { exp: 9_999_999_999_999, roles: ['berget_code_seat'] },
465
466
  }),
466
- refresh_token: "ref",
467
- expires_at: 9999999999999,
468
- })
467
+ expires_at: 9_999_999_999_999,
468
+ refresh_token: 'ref',
469
+ }),
469
470
  );
470
471
 
471
- const prompter = new FakePrompter([select("subscription")]);
472
+ const prompter = new FakePrompter([select('subscription')]);
472
473
 
473
474
  const deps = makeAuthDeps({ files, prompter });
474
- await configureAuth(deps, "opencode");
475
+ await configureAuth(deps, 'opencode');
475
476
 
476
477
  const written = files.getWrittenFiles();
477
- const parsed = JSON.parse(written.get(HOME + "/.local/share/opencode/auth.json")!);
478
- expect(parsed.openai).toEqual({ type: "api", key: "sk-openai" });
478
+ const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
479
+ expect(parsed.openai).toEqual({ key: 'sk-openai', type: 'api' });
479
480
  expect(parsed.berget).toBeDefined();
480
481
  });
481
482
  });