berget 2.2.6 → 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 (145) hide show
  1. package/.github/workflows/publish.yml +2 -2
  2. package/.github/workflows/test.yml +10 -4
  3. package/.husky/pre-commit +1 -0
  4. package/.prettierignore +15 -0
  5. package/.prettierrc +7 -3
  6. package/CONTRIBUTING.md +38 -0
  7. package/README.md +2 -148
  8. package/dist/index.js +10 -11
  9. package/dist/package.json +30 -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 +97 -117
  20. package/dist/src/commands/api-keys.js +75 -90
  21. package/dist/src/commands/auth.js +7 -16
  22. package/dist/src/commands/autocomplete.js +1 -1
  23. package/dist/src/commands/billing.js +6 -17
  24. package/dist/src/commands/chat.js +68 -101
  25. package/dist/src/commands/clusters.js +9 -18
  26. package/dist/src/commands/code/__tests__/auth-sync.test.js +351 -0
  27. package/dist/src/commands/code/__tests__/fake-api-key-service.js +13 -0
  28. package/dist/src/commands/code/__tests__/fake-auth-service.js +47 -0
  29. package/dist/src/commands/code/__tests__/fake-command-runner.js +21 -34
  30. package/dist/src/commands/code/__tests__/fake-file-store.js +20 -33
  31. package/dist/src/commands/code/__tests__/fake-prompter.js +83 -57
  32. package/dist/src/commands/code/__tests__/setup-flow.test.js +359 -92
  33. package/dist/src/commands/code/adapters/clack-prompter.js +15 -22
  34. package/dist/src/commands/code/adapters/fs-file-store.js +26 -40
  35. package/dist/src/commands/code/adapters/spawn-command-runner.js +27 -37
  36. package/dist/src/commands/code/auth-sync.js +270 -0
  37. package/dist/src/commands/code/errors.js +12 -9
  38. package/dist/src/commands/code/ports/auth-services.js +2 -0
  39. package/dist/src/commands/code/setup.js +387 -281
  40. package/dist/src/commands/code.js +205 -332
  41. package/dist/src/commands/index.js +5 -5
  42. package/dist/src/commands/models.js +6 -17
  43. package/dist/src/commands/users.js +5 -16
  44. package/dist/src/constants/command-structure.js +104 -104
  45. package/dist/src/services/api-key-service.js +132 -157
  46. package/dist/src/services/auth-service.js +89 -342
  47. package/dist/src/services/browser-auth.js +268 -0
  48. package/dist/src/services/chat-service.js +371 -401
  49. package/dist/src/services/cluster-service.js +47 -62
  50. package/dist/src/services/collaborator-service.js +10 -25
  51. package/dist/src/services/flux-service.js +14 -29
  52. package/dist/src/services/helm-service.js +10 -25
  53. package/dist/src/services/kubectl-service.js +16 -33
  54. package/dist/src/utils/config-checker.js +3 -3
  55. package/dist/src/utils/config-loader.js +95 -95
  56. package/dist/src/utils/default-api-key.js +124 -134
  57. package/dist/src/utils/env-manager.js +55 -66
  58. package/dist/src/utils/error-handler.js +20 -21
  59. package/dist/src/utils/logger.js +72 -65
  60. package/dist/src/utils/markdown-renderer.js +27 -27
  61. package/dist/src/utils/opencode-validator.js +63 -68
  62. package/dist/src/utils/token-manager.js +74 -45
  63. package/dist/tests/commands/chat.test.js +16 -25
  64. package/dist/tests/commands/code.test.js +95 -104
  65. package/dist/tests/utils/config-loader.test.js +48 -48
  66. package/dist/tests/utils/env-manager.test.js +43 -52
  67. package/dist/tests/utils/opencode-validator.test.js +22 -21
  68. package/dist/vitest.config.js +1 -1
  69. package/eslint.config.mjs +67 -0
  70. package/index.ts +35 -42
  71. package/package.json +30 -2
  72. package/src/agents/app.ts +27 -0
  73. package/src/agents/backend.ts +24 -0
  74. package/src/agents/devops.ts +33 -0
  75. package/src/agents/frontend.ts +24 -0
  76. package/src/agents/fullstack.ts +24 -0
  77. package/src/agents/index.ts +73 -0
  78. package/src/agents/quality.ts +69 -0
  79. package/src/agents/security.ts +26 -0
  80. package/src/agents/types.ts +17 -0
  81. package/src/client.ts +118 -152
  82. package/src/commands/api-keys.ts +241 -333
  83. package/src/commands/auth.ts +22 -27
  84. package/src/commands/autocomplete.ts +9 -9
  85. package/src/commands/billing.ts +20 -24
  86. package/src/commands/chat.ts +248 -338
  87. package/src/commands/clusters.ts +27 -26
  88. package/src/commands/code/__tests__/auth-sync.test.ts +482 -0
  89. package/src/commands/code/__tests__/fake-api-key-service.ts +13 -0
  90. package/src/commands/code/__tests__/fake-auth-service.ts +50 -0
  91. package/src/commands/code/__tests__/fake-command-runner.ts +45 -42
  92. package/src/commands/code/__tests__/fake-file-store.ts +32 -23
  93. package/src/commands/code/__tests__/fake-prompter.ts +116 -77
  94. package/src/commands/code/__tests__/setup-flow.test.ts +624 -268
  95. package/src/commands/code/adapters/clack-prompter.ts +53 -39
  96. package/src/commands/code/adapters/fs-file-store.ts +32 -27
  97. package/src/commands/code/adapters/spawn-command-runner.ts +38 -29
  98. package/src/commands/code/auth-sync.ts +329 -0
  99. package/src/commands/code/errors.ts +18 -18
  100. package/src/commands/code/ports/auth-services.ts +14 -0
  101. package/src/commands/code/ports/command-runner.ts +8 -4
  102. package/src/commands/code/ports/file-store.ts +5 -4
  103. package/src/commands/code/ports/prompter.ts +24 -18
  104. package/src/commands/code/setup.ts +570 -340
  105. package/src/commands/code.ts +338 -539
  106. package/src/commands/index.ts +20 -19
  107. package/src/commands/models.ts +28 -32
  108. package/src/commands/users.ts +15 -21
  109. package/src/constants/command-structure.ts +134 -157
  110. package/src/services/api-key-service.ts +105 -122
  111. package/src/services/auth-service.ts +99 -345
  112. package/src/services/browser-auth.ts +296 -0
  113. package/src/services/chat-service.ts +265 -299
  114. package/src/services/cluster-service.ts +42 -45
  115. package/src/services/collaborator-service.ts +14 -19
  116. package/src/services/flux-service.ts +23 -25
  117. package/src/services/helm-service.ts +19 -21
  118. package/src/services/kubectl-service.ts +17 -19
  119. package/src/types/api.d.ts +1905 -1907
  120. package/src/types/json.d.ts +2 -2
  121. package/src/utils/config-checker.ts +10 -10
  122. package/src/utils/config-loader.ts +162 -178
  123. package/src/utils/default-api-key.ts +114 -125
  124. package/src/utils/env-manager.ts +53 -57
  125. package/src/utils/error-handler.ts +61 -56
  126. package/src/utils/logger.ts +79 -73
  127. package/src/utils/markdown-renderer.ts +31 -31
  128. package/src/utils/opencode-validator.ts +85 -89
  129. package/src/utils/token-manager.ts +108 -87
  130. package/templates/agents/app.md +1 -0
  131. package/templates/agents/backend.md +1 -0
  132. package/templates/agents/devops.md +2 -0
  133. package/templates/agents/frontend.md +1 -0
  134. package/templates/agents/fullstack.md +1 -0
  135. package/templates/agents/quality.md +45 -40
  136. package/templates/agents/security.md +1 -0
  137. package/tests/commands/chat.test.ts +53 -62
  138. package/tests/commands/code.test.ts +265 -310
  139. package/tests/utils/config-loader.test.ts +189 -188
  140. package/tests/utils/env-manager.test.ts +110 -113
  141. package/tests/utils/opencode-validator.test.ts +52 -56
  142. package/tsconfig.json +4 -3
  143. package/vitest.config.ts +3 -3
  144. package/AGENTS.md +0 -374
  145. package/TODO.md +0 -19
@@ -0,0 +1,351 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ const auth_sync_1 = require("../auth-sync");
5
+ const fake_api_key_service_1 = require("./fake-api-key-service");
6
+ const fake_auth_service_1 = require("./fake-auth-service");
7
+ const fake_file_store_1 = require("./fake-file-store");
8
+ const fake_prompter_1 = require("./fake-prompter");
9
+ function base64urlEncode(data) {
10
+ return Buffer.from(data).toString('base64url');
11
+ }
12
+ function makeJwt(payload) {
13
+ const header = base64urlEncode(JSON.stringify({ alg: 'none', typ: 'JWT' }));
14
+ const body = base64urlEncode(JSON.stringify(payload));
15
+ return `${header}.${body}.signature`;
16
+ }
17
+ const HOME = '/home/user';
18
+ const fakeCliAuth = (overrides = {}) => ({
19
+ access_token: makeJwt({
20
+ exp: 9999999999999, // JWT exp in seconds (different from expires_at in ms)
21
+ realm_access: { roles: ['default-roles-berget'] },
22
+ }),
23
+ expires_at: 9999999999999,
24
+ refresh_token: 'refreshtoken',
25
+ ...overrides,
26
+ });
27
+ (0, vitest_1.describe)('readCliAuth', () => {
28
+ (0, vitest_1.it)('returns null when auth file does not exist', async () => {
29
+ const files = new fake_file_store_1.FakeFileStore();
30
+ const result = await (0, auth_sync_1.readCliAuth)(files, HOME);
31
+ (0, vitest_1.expect)(result).toBeNull();
32
+ });
33
+ (0, vitest_1.it)('parses valid auth file', async () => {
34
+ const files = new fake_file_store_1.FakeFileStore();
35
+ const auth = fakeCliAuth();
36
+ files.seed(HOME + '/.berget/auth.json', JSON.stringify(auth));
37
+ const result = await (0, auth_sync_1.readCliAuth)(files, HOME);
38
+ // The JWT's exp claim should be extracted and converted to milliseconds
39
+ const jwtPayload = JSON.parse(Buffer.from(auth.access_token.split('.')[1], 'base64url').toString());
40
+ const expectedAuth = {
41
+ access_token: auth.access_token,
42
+ expires_at: jwtPayload.exp * 1000,
43
+ refresh_token: auth.refresh_token,
44
+ };
45
+ (0, vitest_1.expect)(result).toEqual(expectedAuth);
46
+ });
47
+ (0, vitest_1.it)('returns null for malformed JSON', async () => {
48
+ const files = new fake_file_store_1.FakeFileStore();
49
+ files.seed(HOME + '/.berget/auth.json', 'not json');
50
+ const result = await (0, auth_sync_1.readCliAuth)(files, HOME);
51
+ (0, vitest_1.expect)(result).toBeNull();
52
+ });
53
+ (0, vitest_1.it)('returns null when fields are missing', async () => {
54
+ const files = new fake_file_store_1.FakeFileStore();
55
+ files.seed(HOME + '/.berget/auth.json', JSON.stringify({ access_token: 'only' }));
56
+ const result = await (0, auth_sync_1.readCliAuth)(files, HOME);
57
+ (0, vitest_1.expect)(result).toBeNull();
58
+ });
59
+ });
60
+ (0, vitest_1.describe)('isToolAuthenticated', () => {
61
+ (0, vitest_1.it)('returns false when auth file does not exist', async () => {
62
+ const files = new fake_file_store_1.FakeFileStore();
63
+ const result = await (0, auth_sync_1.isToolAuthenticated)(files, HOME, 'opencode');
64
+ (0, vitest_1.expect)(result).toBe(false);
65
+ });
66
+ (0, vitest_1.it)('returns true when berget entry exists', async () => {
67
+ const files = new fake_file_store_1.FakeFileStore();
68
+ files.seed(HOME + '/.local/share/opencode/auth.json', JSON.stringify({ berget: { access: 'tok', type: 'oauth' } }));
69
+ const result = await (0, auth_sync_1.isToolAuthenticated)(files, HOME, 'opencode');
70
+ (0, vitest_1.expect)(result).toBe(true);
71
+ });
72
+ (0, vitest_1.it)('returns false when berget entry is missing', async () => {
73
+ const files = new fake_file_store_1.FakeFileStore();
74
+ files.seed(HOME + '/.local/share/opencode/auth.json', JSON.stringify({ openai: { type: 'api' } }));
75
+ const result = await (0, auth_sync_1.isToolAuthenticated)(files, HOME, 'opencode');
76
+ (0, vitest_1.expect)(result).toBe(false);
77
+ });
78
+ (0, vitest_1.it)('checks correct path for pi', async () => {
79
+ const files = new fake_file_store_1.FakeFileStore();
80
+ files.seed(HOME + '/.pi/agent/auth.json', JSON.stringify({ berget: { type: 'oauth' } }));
81
+ const result = await (0, auth_sync_1.isToolAuthenticated)(files, HOME, 'pi');
82
+ (0, vitest_1.expect)(result).toBe(true);
83
+ });
84
+ });
85
+ (0, vitest_1.describe)('decodeJwtPayload', () => {
86
+ (0, vitest_1.it)('decodes a valid JWT payload', () => {
87
+ const payload = { realm_access: { roles: ['admin'] }, sub: '123' };
88
+ const jwt = makeJwt(payload);
89
+ (0, vitest_1.expect)((0, auth_sync_1.decodeJwtPayload)(jwt)).toEqual(payload);
90
+ });
91
+ (0, vitest_1.it)('returns null for invalid format', () => {
92
+ (0, vitest_1.expect)((0, auth_sync_1.decodeJwtPayload)('not.a')).toBeNull();
93
+ (0, vitest_1.expect)((0, auth_sync_1.decodeJwtPayload)('onlyOnePart')).toBeNull();
94
+ });
95
+ (0, vitest_1.it)('returns null for invalid base64', () => {
96
+ (0, vitest_1.expect)((0, auth_sync_1.decodeJwtPayload)('header.bad\.base64.signature')).toBeNull();
97
+ });
98
+ });
99
+ (0, vitest_1.describe)('hasBergetCodeSeat', () => {
100
+ (0, vitest_1.it)('returns true when berget_code_seat is present', () => {
101
+ const token = makeJwt({
102
+ realm_access: { roles: ['berget_code_seat', 'default-roles-berget'] },
103
+ });
104
+ (0, vitest_1.expect)((0, auth_sync_1.hasBergetCodeSeat)(token)).toBe(true);
105
+ });
106
+ (0, vitest_1.it)('returns false when role is missing', () => {
107
+ const token = makeJwt({
108
+ realm_access: { roles: ['default-roles-berget'] },
109
+ });
110
+ (0, vitest_1.expect)((0, auth_sync_1.hasBergetCodeSeat)(token)).toBe(false);
111
+ });
112
+ (0, vitest_1.it)('returns false when realm_access is missing', () => {
113
+ const token = makeJwt({ sub: '123' });
114
+ (0, vitest_1.expect)((0, auth_sync_1.hasBergetCodeSeat)(token)).toBe(false);
115
+ });
116
+ (0, vitest_1.it)('returns false for invalid JWT', () => {
117
+ (0, vitest_1.expect)((0, auth_sync_1.hasBergetCodeSeat)('invalid')).toBe(false);
118
+ });
119
+ });
120
+ (0, vitest_1.describe)('syncOAuthToTool', () => {
121
+ (0, vitest_1.it)('writes oauth tokens to opencode auth file', async () => {
122
+ const files = new fake_file_store_1.FakeFileStore();
123
+ const auth = fakeCliAuth();
124
+ await (0, auth_sync_1.syncOAuthToTool)(files, HOME, 'opencode', auth);
125
+ const written = files.getWrittenFiles();
126
+ const content = written.get(HOME + '/.local/share/opencode/auth.json');
127
+ const parsed = JSON.parse(content);
128
+ // The expires field should now use the JWT's exp claim (converted to milliseconds)
129
+ const jwtPayload = JSON.parse(Buffer.from(auth.access_token.split('.')[1], 'base64url').toString());
130
+ (0, vitest_1.expect)(parsed.berget).toEqual({
131
+ access: auth.access_token,
132
+ expires: jwtPayload.exp * 1000,
133
+ refresh: auth.refresh_token,
134
+ type: 'oauth',
135
+ });
136
+ });
137
+ (0, vitest_1.it)('writes oauth tokens to pi auth file', async () => {
138
+ const files = new fake_file_store_1.FakeFileStore();
139
+ const auth = fakeCliAuth();
140
+ await (0, auth_sync_1.syncOAuthToTool)(files, HOME, 'pi', auth);
141
+ const written = files.getWrittenFiles();
142
+ const content = written.get(HOME + '/.pi/agent/auth.json');
143
+ const parsed = JSON.parse(content);
144
+ (0, vitest_1.expect)(parsed.berget.type).toBe('oauth');
145
+ });
146
+ (0, vitest_1.it)('merges with existing providers', async () => {
147
+ const files = new fake_file_store_1.FakeFileStore();
148
+ files.seed(HOME + '/.local/share/opencode/auth.json', JSON.stringify({ openai: { key: 'sk-openai', type: 'api' } }));
149
+ const auth = fakeCliAuth();
150
+ await (0, auth_sync_1.syncOAuthToTool)(files, HOME, 'opencode', auth);
151
+ const written = files.getWrittenFiles();
152
+ const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json'));
153
+ (0, vitest_1.expect)(parsed.openai).toEqual({ key: 'sk-openai', type: 'api' });
154
+ (0, vitest_1.expect)(parsed.berget.type).toBe('oauth');
155
+ });
156
+ (0, vitest_1.it)('sets 0o600 permissions on the auth file', async () => {
157
+ const files = new fake_file_store_1.FakeFileStore();
158
+ const auth = fakeCliAuth();
159
+ await (0, auth_sync_1.syncOAuthToTool)(files, HOME, 'opencode', auth);
160
+ const chmodCalls = files.getChmodCalls();
161
+ (0, vitest_1.expect)(chmodCalls).toHaveLength(1);
162
+ (0, vitest_1.expect)(chmodCalls[0]).toEqual({
163
+ mode: 0o600,
164
+ path: HOME + '/.local/share/opencode/auth.json',
165
+ });
166
+ });
167
+ });
168
+ (0, vitest_1.describe)('syncApiKeyToTool', () => {
169
+ (0, vitest_1.it)('writes api key to opencode auth file with type "api"', async () => {
170
+ const files = new fake_file_store_1.FakeFileStore();
171
+ await (0, auth_sync_1.syncApiKeyToTool)(files, HOME, 'opencode', 'sk_ber_test');
172
+ const written = files.getWrittenFiles();
173
+ const content = written.get(HOME + '/.local/share/opencode/auth.json');
174
+ const parsed = JSON.parse(content);
175
+ (0, vitest_1.expect)(parsed.berget).toEqual({
176
+ key: 'sk_ber_test',
177
+ type: 'api',
178
+ });
179
+ });
180
+ (0, vitest_1.it)('writes api key to pi auth file with type "api_key"', async () => {
181
+ const files = new fake_file_store_1.FakeFileStore();
182
+ await (0, auth_sync_1.syncApiKeyToTool)(files, HOME, 'pi', 'sk_ber_pi');
183
+ const written = files.getWrittenFiles();
184
+ const content = written.get(HOME + '/.pi/agent/auth.json');
185
+ const parsed = JSON.parse(content);
186
+ (0, vitest_1.expect)(parsed.berget).toEqual({
187
+ key: 'sk_ber_pi',
188
+ type: 'api_key',
189
+ });
190
+ });
191
+ (0, vitest_1.it)('merges with existing providers', async () => {
192
+ const files = new fake_file_store_1.FakeFileStore();
193
+ files.seed(HOME + '/.local/share/opencode/auth.json', JSON.stringify({ anthropic: { key: 'sk-ant', type: 'api' } }));
194
+ await (0, auth_sync_1.syncApiKeyToTool)(files, HOME, 'opencode', 'sk_ber_test');
195
+ const written = files.getWrittenFiles();
196
+ const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json'));
197
+ (0, vitest_1.expect)(parsed.anthropic).toEqual({ key: 'sk-ant', type: 'api' });
198
+ });
199
+ (0, vitest_1.it)('sets 0o600 permissions on the auth file', async () => {
200
+ const files = new fake_file_store_1.FakeFileStore();
201
+ await (0, auth_sync_1.syncApiKeyToTool)(files, HOME, 'opencode', 'sk_ber_test');
202
+ const chmodCalls = files.getChmodCalls();
203
+ (0, vitest_1.expect)(chmodCalls).toHaveLength(1);
204
+ (0, vitest_1.expect)(chmodCalls[0]).toEqual({
205
+ mode: 0o600,
206
+ path: HOME + '/.local/share/opencode/auth.json',
207
+ });
208
+ });
209
+ });
210
+ (0, vitest_1.describe)('configureAuth', () => {
211
+ const makeAuthDeps = (overrides = {}) => ({
212
+ apiKeyService: new fake_api_key_service_1.FakeApiKeyService('sk_ber_test'),
213
+ authService: new fake_auth_service_1.FakeAuthService(true),
214
+ files: new fake_file_store_1.FakeFileStore(),
215
+ homeDir: HOME,
216
+ prompter: new fake_prompter_1.FakePrompter([]),
217
+ ...overrides,
218
+ });
219
+ (0, vitest_1.it)('Case A: already authenticated — chooses keep → skips flow', async () => {
220
+ const files = new fake_file_store_1.FakeFileStore();
221
+ files.seed(HOME + '/.local/share/opencode/auth.json', JSON.stringify({ berget: { type: 'oauth' } }));
222
+ const prompter = new fake_prompter_1.FakePrompter([(0, fake_prompter_1.select)('keep')]);
223
+ const deps = makeAuthDeps({ files, prompter });
224
+ const result = await (0, auth_sync_1.configureAuth)(deps, 'opencode');
225
+ (0, vitest_1.expect)(result.authenticated).toBe(true);
226
+ (0, vitest_1.expect)(deps.prompter.calls.length).toBe(1); // Only the select prompt
227
+ });
228
+ (0, vitest_1.it)('Case A reconfigure: already authenticated — reconfigure with fresh browser login', async () => {
229
+ const files = new fake_file_store_1.FakeFileStore();
230
+ files.seed(HOME + '/.local/share/opencode/auth.json', JSON.stringify({ berget: { type: 'oauth' } }));
231
+ const prompter = new fake_prompter_1.FakePrompter([(0, fake_prompter_1.select)('reconfigure'), (0, fake_prompter_1.select)('subscription')]);
232
+ const deps = makeAuthDeps({ files, prompter });
233
+ const authService = deps.authService;
234
+ const result = await (0, auth_sync_1.configureAuth)(deps, 'opencode');
235
+ (0, vitest_1.expect)(result.authenticated).toBe(true);
236
+ // Should have called loginInteractive (no token reuse since no ~/.berget/auth.json seeded)
237
+ (0, vitest_1.expect)(authService.loginInteractiveCallCount).toBeGreaterThanOrEqual(1);
238
+ });
239
+ (0, vitest_1.it)('Case A reconfigure: already authenticated — reconfigure with valid CLI token → skips browser', async () => {
240
+ const farFuture = Date.now() + 365 * 24 * 60 * 60 * 1000; // 1 year from now
241
+ const files = new fake_file_store_1.FakeFileStore();
242
+ files.seed(HOME + '/.local/share/opencode/auth.json', JSON.stringify({ berget: { type: 'oauth' } }));
243
+ files.seed(HOME + '/.berget/auth.json', JSON.stringify({
244
+ access_token: makeJwt({ exp: farFuture, realm_access: { roles: ['berget_code_seat'] } }),
245
+ expires_at: farFuture,
246
+ refresh_token: 'ref',
247
+ }));
248
+ const prompter = new fake_prompter_1.FakePrompter([(0, fake_prompter_1.select)('reconfigure'), (0, fake_prompter_1.select)('subscription')]);
249
+ const authService = new fake_auth_service_1.FakeAuthService(true);
250
+ const deps = makeAuthDeps({ authService, files, prompter });
251
+ const result = await (0, auth_sync_1.configureAuth)(deps, 'opencode');
252
+ (0, vitest_1.expect)(result.authenticated).toBe(true);
253
+ // Should NOT have called loginInteractive since token was reused
254
+ (0, vitest_1.expect)(authService.loginInteractiveCallCount).toBe(0);
255
+ });
256
+ (0, vitest_1.it)('Case B: login success + berget_code_seat → chooses subscription', async () => {
257
+ const files = new fake_file_store_1.FakeFileStore();
258
+ const jwt = makeJwt({
259
+ exp: 9999999999999,
260
+ realm_access: { roles: ['berget_code_seat'] },
261
+ });
262
+ files.seed(HOME + '/.berget/auth.json', JSON.stringify({
263
+ access_token: jwt,
264
+ expires_at: 9999999999999,
265
+ refresh_token: 'ref',
266
+ }));
267
+ const prompter = new fake_prompter_1.FakePrompter([(0, fake_prompter_1.select)('subscription')]);
268
+ const deps = makeAuthDeps({ files, prompter });
269
+ const result = await (0, auth_sync_1.configureAuth)(deps, 'opencode');
270
+ (0, vitest_1.expect)(result.authenticated).toBe(true);
271
+ const written = files.getWrittenFiles();
272
+ (0, vitest_1.expect)(written.has(HOME + '/.local/share/opencode/auth.json')).toBe(true);
273
+ const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json'));
274
+ (0, vitest_1.expect)(parsed.berget.type).toBe('oauth');
275
+ });
276
+ (0, vitest_1.it)('Case B variant: login success + seat → chooses api_key', async () => {
277
+ const files = new fake_file_store_1.FakeFileStore();
278
+ const jwt = makeJwt({
279
+ exp: 9999999999999,
280
+ realm_access: { roles: ['berget_code_seat'] },
281
+ });
282
+ files.seed(HOME + '/.berget/auth.json', JSON.stringify({
283
+ access_token: jwt,
284
+ expires_at: 9999999999999,
285
+ refresh_token: 'ref',
286
+ }));
287
+ const prompter = new fake_prompter_1.FakePrompter([(0, fake_prompter_1.select)('api_key')]);
288
+ const deps = makeAuthDeps({ files, prompter });
289
+ const result = await (0, auth_sync_1.configureAuth)(deps, 'opencode');
290
+ (0, vitest_1.expect)(result.authenticated).toBe(true);
291
+ const written = files.getWrittenFiles();
292
+ const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json'));
293
+ (0, vitest_1.expect)(parsed.berget.type).toBe('api');
294
+ (0, vitest_1.expect)(parsed.berget.key).toBe('sk_ber_test');
295
+ });
296
+ (0, vitest_1.it)('Case C: login success + no seat → creates api key', async () => {
297
+ const files = new fake_file_store_1.FakeFileStore();
298
+ const prompter = new fake_prompter_1.FakePrompter([(0, fake_prompter_1.confirm)(true)]);
299
+ const deps = makeAuthDeps({ authService: new fake_auth_service_1.FakeAuthService(true, false), files, prompter });
300
+ const result = await (0, auth_sync_1.configureAuth)(deps, 'opencode');
301
+ (0, vitest_1.expect)(result.authenticated).toBe(true);
302
+ const written = files.getWrittenFiles();
303
+ const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json'));
304
+ (0, vitest_1.expect)(parsed.berget.type).toBe('api');
305
+ (0, vitest_1.expect)(parsed.berget.key).toBe('sk_ber_test');
306
+ });
307
+ (0, vitest_1.it)('Case D: login success + no seat → declines api key', async () => {
308
+ const files = new fake_file_store_1.FakeFileStore();
309
+ const prompter = new fake_prompter_1.FakePrompter([(0, fake_prompter_1.confirm)(false)]);
310
+ const deps = makeAuthDeps({ authService: new fake_auth_service_1.FakeAuthService(true, false), files, prompter });
311
+ const result = await (0, auth_sync_1.configureAuth)(deps, 'opencode');
312
+ (0, vitest_1.expect)(result.authenticated).toBe(false);
313
+ (0, vitest_1.expect)(files.getWrittenFiles().has(HOME + '/.local/share/opencode/auth.json')).toBe(false);
314
+ });
315
+ (0, vitest_1.it)('Case E: login fails', async () => {
316
+ const files = new fake_file_store_1.FakeFileStore();
317
+ const authService = new fake_auth_service_1.FakeAuthService(false);
318
+ const deps = makeAuthDeps({ authService, files });
319
+ const result = await (0, auth_sync_1.configureAuth)(deps, 'opencode');
320
+ (0, vitest_1.expect)(result.authenticated).toBe(false);
321
+ });
322
+ (0, vitest_1.it)('fails authentication when jwt decode fails', async () => {
323
+ const prompter = new fake_prompter_1.FakePrompter([]);
324
+ const deps = makeAuthDeps({
325
+ authService: new fake_auth_service_1.FakeAuthService(true, true, false), // valid login, has seat, but invalid token
326
+ prompter,
327
+ });
328
+ const result = await (0, auth_sync_1.configureAuth)(deps, 'opencode');
329
+ (0, vitest_1.expect)(result.authenticated).toBe(false); // Should fail due to invalid JWT
330
+ const written = deps.files.getWrittenFiles();
331
+ (0, vitest_1.expect)(written.size).toBe(0); // No files should be written
332
+ });
333
+ (0, vitest_1.it)('preserves existing providers during sync', async () => {
334
+ const files = new fake_file_store_1.FakeFileStore();
335
+ files.seed(HOME + '/.local/share/opencode/auth.json', JSON.stringify({ openai: { key: 'sk-openai', type: 'api' } }));
336
+ files.seed(HOME + '/.berget/auth.json', JSON.stringify({
337
+ access_token: makeJwt({
338
+ realm_access: { exp: 9999999999999, roles: ['berget_code_seat'] },
339
+ }),
340
+ expires_at: 9999999999999,
341
+ refresh_token: 'ref',
342
+ }));
343
+ const prompter = new fake_prompter_1.FakePrompter([(0, fake_prompter_1.select)('subscription')]);
344
+ const deps = makeAuthDeps({ files, prompter });
345
+ await (0, auth_sync_1.configureAuth)(deps, 'opencode');
346
+ const written = files.getWrittenFiles();
347
+ const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json'));
348
+ (0, vitest_1.expect)(parsed.openai).toEqual({ key: 'sk-openai', type: 'api' });
349
+ (0, vitest_1.expect)(parsed.berget).toBeDefined();
350
+ });
351
+ });
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FakeApiKeyService = void 0;
4
+ class FakeApiKeyService {
5
+ _key;
6
+ constructor(key) {
7
+ this._key = key;
8
+ }
9
+ async create(_options) {
10
+ return { key: this._key };
11
+ }
12
+ }
13
+ exports.FakeApiKeyService = FakeApiKeyService;
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FakeAuthService = void 0;
4
+ class FakeAuthService {
5
+ _shouldSucceed;
6
+ _hasSeat;
7
+ _validToken;
8
+ loginCallCount = 0;
9
+ loginInteractiveCallCount = 0;
10
+ constructor(_shouldSucceed, _hasSeat = true, _validToken = true) {
11
+ this._shouldSucceed = _shouldSucceed;
12
+ this._hasSeat = _hasSeat;
13
+ this._validToken = _validToken;
14
+ }
15
+ async login() {
16
+ this.loginCallCount++;
17
+ return this._shouldSucceed;
18
+ }
19
+ loginInteractive() {
20
+ this.loginInteractiveCallCount++;
21
+ if (!this._shouldSucceed) {
22
+ return Promise.resolve({ error: 'Login failed', success: false });
23
+ }
24
+ const farFuture = Math.floor(Date.now() / 1000) + 3600 * 24 * 365; // 1 year from now in seconds
25
+ const accessToken = this._validToken
26
+ ? makeJwt({
27
+ exp: farFuture,
28
+ realm_access: { roles: this._hasSeat ? ['berget_code_seat'] : ['default-roles-berget'] },
29
+ })
30
+ : 'invalid.token.here';
31
+ return Promise.resolve({
32
+ accessToken,
33
+ expiresIn: 3600,
34
+ refreshToken: 'refresh',
35
+ success: true,
36
+ });
37
+ }
38
+ }
39
+ exports.FakeAuthService = FakeAuthService;
40
+ function base64urlEncode(data) {
41
+ return Buffer.from(data).toString('base64url');
42
+ }
43
+ function makeJwt(payload) {
44
+ const header = base64urlEncode(JSON.stringify({ alg: 'none', typ: 'JWT' }));
45
+ const body = base64urlEncode(JSON.stringify(payload));
46
+ return `${header}.${body}.signature`;
47
+ }
@@ -1,24 +1,20 @@
1
1
  "use strict";
2
- var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
- function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
- return new (P || (P = Promise))(function (resolve, reject) {
5
- function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
- function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
- function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
- step((generator = generator.apply(thisArg, _arguments || [])).next());
9
- });
10
- };
11
2
  Object.defineProperty(exports, "__esModule", { value: true });
12
3
  exports.FakeCommandRunner = void 0;
13
4
  class FakeCommandRunner {
14
- constructor() {
15
- this.handlers = [];
16
- this._calls = [];
5
+ get calls() {
6
+ return this._calls;
7
+ }
8
+ _calls = [];
9
+ handlers = [];
10
+ checkInstalled(binary) {
11
+ this._calls.push({ args: [], command: `check:${binary}` });
12
+ return Promise.resolve(this.handlers.some((h) => h.match(binary, ['--version'])) || false);
17
13
  }
18
14
  handle(match, response) {
19
15
  this.handlers.push({
20
- match: (cmd, args) => {
21
- const full = `${cmd} ${args.join(' ')}`;
16
+ match: (cmd, arguments_) => {
17
+ const full = `${cmd} ${arguments_.join(' ')}`;
22
18
  if (typeof match === 'string')
23
19
  return full.startsWith(match);
24
20
  return match.test(full);
@@ -27,26 +23,17 @@ class FakeCommandRunner {
27
23
  });
28
24
  return this;
29
25
  }
30
- checkInstalled(binary) {
31
- this._calls.push({ command: `check:${binary}`, args: [] });
32
- return Promise.resolve(this.handlers.some(h => h.match(binary, ['--version'])) || false);
33
- }
34
- run(command, args, options) {
35
- return __awaiter(this, void 0, void 0, function* () {
36
- this._calls.push({ command, args: [...args], options });
37
- const handler = this.handlers.find(h => h.match(command, args));
38
- if (!handler)
39
- throw new Error(`Unexpected command: ${command} ${args.join(' ')}`);
40
- const result = typeof handler.response === 'function'
41
- ? handler.response(command, args)
42
- : handler.response;
43
- if (result instanceof Error)
44
- throw result;
45
- return result;
46
- });
47
- }
48
- get calls() {
49
- return this._calls;
26
+ async run(command, arguments_, options) {
27
+ this._calls.push({ args: [...arguments_], command, options });
28
+ const handler = this.handlers.find((h) => h.match(command, arguments_));
29
+ if (!handler)
30
+ throw new Error(`Unexpected command: ${command} ${arguments_.join(' ')}`);
31
+ const result = typeof handler.response === 'function'
32
+ ? handler.response(command, arguments_)
33
+ : handler.response;
34
+ if (result instanceof Error)
35
+ throw result;
36
+ return result;
50
37
  }
51
38
  }
52
39
  exports.FakeCommandRunner = FakeCommandRunner;
@@ -1,46 +1,33 @@
1
1
  "use strict";
2
- var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
- function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
- return new (P || (P = Promise))(function (resolve, reject) {
5
- function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
- function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
- function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
- step((generator = generator.apply(thisArg, _arguments || [])).next());
9
- });
10
- };
11
2
  Object.defineProperty(exports, "__esModule", { value: true });
12
3
  exports.FakeFileStore = void 0;
13
4
  class FakeFileStore {
14
- constructor() {
15
- this.files = new Map();
16
- this.dirs = new Set();
5
+ _chmodCalls = [];
6
+ dirs = new Set();
7
+ files = new Map();
8
+ async chmod(path, mode) {
9
+ this._chmodCalls.push({ mode, path });
17
10
  }
18
- seed(path, content) {
19
- this.files.set(path, content);
11
+ async exists(path) {
12
+ return this.files.has(path) || this.dirs.has(path);
20
13
  }
21
- exists(path) {
22
- return __awaiter(this, void 0, void 0, function* () {
23
- return this.files.has(path) || this.dirs.has(path);
24
- });
14
+ getChmodCalls() {
15
+ return this._chmodCalls;
25
16
  }
26
- readFile(path) {
27
- var _a;
28
- return __awaiter(this, void 0, void 0, function* () {
29
- return (_a = this.files.get(path)) !== null && _a !== void 0 ? _a : null;
30
- });
17
+ getWrittenFiles() {
18
+ return new Map(this.files);
31
19
  }
32
- writeFile(path, content) {
33
- return __awaiter(this, void 0, void 0, function* () {
34
- this.files.set(path, content);
35
- });
20
+ async mkdir(path) {
21
+ this.dirs.add(path);
36
22
  }
37
- mkdir(path) {
38
- return __awaiter(this, void 0, void 0, function* () {
39
- this.dirs.add(path);
40
- });
23
+ async readFile(path) {
24
+ return this.files.get(path) ?? null;
41
25
  }
42
- getWrittenFiles() {
43
- return new Map(this.files);
26
+ seed(path, content) {
27
+ this.files.set(path, content);
28
+ }
29
+ async writeFile(path, content) {
30
+ this.files.set(path, content);
44
31
  }
45
32
  }
46
33
  exports.FakeFileStore = FakeFileStore;