berget 2.2.6 → 2.2.7

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