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
@@ -1,274 +1,630 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { runSetup } from '../setup'
3
- import { CancelledError, CommandFailedError, PrerequisiteError } from '../errors'
4
- import { FakePrompter, CANCEL, select, confirm } from './fake-prompter'
5
- import { FakeFileStore } from './fake-file-store'
6
- import { FakeCommandRunner } from './fake-command-runner'
7
-
8
- const makeDeps = (overrides: Partial<Parameters<typeof runSetup>[0]> = {}) => ({
9
- prompter: new FakePrompter([]),
10
- files: new FakeFileStore(),
11
- commands: new FakeCommandRunner()
12
- .handle('opencode --version', 'mocked')
13
- .handle('pi --version', 'mocked'),
14
- homeDir: '/home/user',
15
- cwd: '/home/user/project',
16
- ...overrides,
17
- })
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import type { ApiKeyServicePort, AuthServicePort } from '../ports/auth-services';
4
+
5
+ import { CancelledError, CommandFailedError, PrerequisiteError } from '../errors';
6
+ import { runSetup } from '../setup';
7
+ import { FakeApiKeyService } from './fake-api-key-service';
8
+ import { FakeAuthService } from './fake-auth-service';
9
+ import { FakeCommandRunner } from './fake-command-runner';
10
+ import { FakeFileStore } from './fake-file-store';
11
+ import { CANCEL, confirm, FakePrompter, multiselect, select } from './fake-prompter';
12
+
13
+ const makeDeps = (
14
+ overrides: Partial<Parameters<typeof runSetup>[0]> = {},
15
+ ): Parameters<typeof runSetup>[0] => {
16
+ return {
17
+ apiKeyService:
18
+ (overrides.apiKeyService as ApiKeyServicePort) ?? new FakeApiKeyService('sk_ber_test'),
19
+ authService: (overrides.authService as AuthServicePort) ?? new FakeAuthService(false),
20
+ commands:
21
+ overrides.commands ??
22
+ new FakeCommandRunner()
23
+ .handle('opencode --version', 'mocked')
24
+ .handle('pi --version', 'mocked'),
25
+ cwd: '/home/user/project',
26
+ files: overrides.files ?? new FakeFileStore(),
27
+ homeDir: '/home/user',
28
+ prompter: overrides.prompter ?? new FakePrompter([]),
29
+ ...Object.fromEntries(
30
+ Object.entries(overrides).filter(
31
+ ([k]) =>
32
+ k !== 'prompter' &&
33
+ k !== 'files' &&
34
+ k !== 'commands' &&
35
+ k !== 'authService' &&
36
+ k !== 'apiKeyService',
37
+ ),
38
+ ),
39
+ };
40
+ };
41
+
42
+ function base64urlEncode(data: string): string {
43
+ return Buffer.from(data).toString('base64url');
44
+ }
45
+
46
+ function makeJwt(payload: Record<string, unknown>): string {
47
+ const header = base64urlEncode(JSON.stringify({ alg: 'none', typ: 'JWT' }));
48
+ const body = base64urlEncode(JSON.stringify(payload));
49
+ return `${header}.${body}.signature`;
50
+ }
18
51
 
19
52
  describe('runSetup', () => {
20
- describe('happy path', () => {
21
- it('sets up opencode project without existing config', async () => {
22
- const deps = makeDeps({
23
- prompter: new FakePrompter([
24
- select('opencode'),
25
- select('project'),
26
- confirm(true, 'Create'),
27
- ]),
28
- })
29
-
30
- await runSetup(deps)
31
-
32
- const files = deps.files as FakeFileStore
33
- const written = files.getWrittenFiles()
34
- expect(written.has('/home/user/project/opencode.json')).toBe(true)
35
- const config = JSON.parse(written.get('/home/user/project/opencode.json')!)
36
- expect(config.plugin).toContain('@bergetai/opencode-auth@1.0.16')
37
- })
38
-
39
- it('sets up opencode globally without existing config', async () => {
40
- const deps = makeDeps({
41
- prompter: new FakePrompter([
42
- select('opencode'),
43
- select('global'),
44
- confirm(true, 'Create'),
45
- ]),
46
- })
47
-
48
- await runSetup(deps)
49
-
50
- const files = deps.files as FakeFileStore
51
- const written = files.getWrittenFiles()
52
- expect(written.has('/home/user/.config/opencode/opencode.json')).toBe(true)
53
- })
54
-
55
- it('sets up pi project with fresh install', async () => {
56
- const deps = makeDeps({
57
- prompter: new FakePrompter([
58
- select('pi'),
59
- select('project'),
60
- confirm(true, 'Proceed'),
61
- ]),
62
- commands: new FakeCommandRunner()
63
- .handle('pi --version', 'mocked') // For checkInstalled
64
- .handle('pi install', ''), // For actual install
65
- })
66
-
67
- await runSetup(deps)
68
-
69
- const commands = deps.commands as FakeCommandRunner
70
- expect(commands.calls.length).toBeGreaterThan(0)
71
- const installCall = commands.calls.find(c => c.command === 'pi')
72
- expect(installCall?.args).toContain('npm:@bergetai/pi-provider')
73
- })
74
- })
75
-
76
- describe('prerequisites', () => {
77
- it('throws PrerequisiteError when opencode is not installed', async () => {
78
- const deps = makeDeps({
79
- prompter: new FakePrompter([
80
- select('opencode'),
81
- select('project'),
82
- ]),
83
- commands: new FakeCommandRunner(),
84
- })
85
-
86
- // Simulate opencode not being installed
87
- await expect(runSetup(deps)).rejects.toBeInstanceOf(PrerequisiteError)
88
- })
89
- })
90
-
91
- describe('cancellation', () => {
92
- it('throws CancelledError when user cancels at tool selection', async () => {
93
- const deps = makeDeps({
94
- prompter: new FakePrompter([
95
- select(CANCEL),
96
- ]),
97
- })
98
-
99
- await expect(runSetup(deps)).rejects.toBeInstanceOf(CancelledError)
100
- })
101
-
102
- it('throws CancelledError when user cancels at write confirmation', async () => {
103
- const deps = makeDeps({
104
- prompter: new FakePrompter([
105
- select('opencode'),
106
- select('project'),
107
- confirm(false, 'Create'),
108
- ]),
109
- })
110
-
111
- await expect(runSetup(deps)).rejects.toBeInstanceOf(CancelledError)
112
- })
113
- })
114
-
115
- describe('file operations', () => {
116
- it('preserves existing configuration keys when updating', async () => {
117
- const deps = makeDeps({
118
- prompter: new FakePrompter([
119
- select('opencode'),
120
- select('project'),
121
- confirm(true, 'Write'),
122
- ]),
123
- })
124
-
125
- const files = deps.files as FakeFileStore
126
- files.seed('/home/user/project/opencode.json', JSON.stringify({
127
- customField: 'should-preserve',
128
- plugin: ['other-plugin'],
129
- }))
130
-
131
- await runSetup(deps)
132
-
133
- const written = files.getWrittenFiles()
134
- const config = JSON.parse(written.get('/home/user/project/opencode.json')!)
135
- expect(config.customField).toBe('should-preserve')
136
- expect(config.plugin).toContain('other-plugin')
137
- expect(config.plugin).toContain('@bergetai/opencode-auth@1.0.16')
138
- })
139
-
140
- it('preserves jsonc comments when updating', async () => {
141
- const deps = makeDeps({
142
- prompter: new FakePrompter([
143
- select('opencode'),
144
- select('project'),
145
- confirm(true, 'Write'),
146
- ]),
147
- })
148
-
149
- const files = deps.files as FakeFileStore
150
- files.seed('/home/user/project/opencode.jsonc', `{
53
+ describe('happy path', () => {
54
+ it('sets up opencode project without existing config', async () => {
55
+ const deps = makeDeps({
56
+ prompter: new FakePrompter([
57
+ select('opencode'),
58
+ select('project'),
59
+ confirm(true, 'Create'), // Config write
60
+ multiselect([]), // No agents selected
61
+ ]),
62
+ });
63
+
64
+ await runSetup(deps);
65
+
66
+ const files = deps.files as FakeFileStore;
67
+ const written = files.getWrittenFiles();
68
+ expect(written.has('/home/user/project/opencode.json')).toBe(true);
69
+ const config = JSON.parse(written.get('/home/user/project/opencode.json')!);
70
+ expect(config.plugin).toContain('@bergetai/opencode-auth');
71
+ });
72
+
73
+ it('sets up opencode globally without existing config', async () => {
74
+ const deps = makeDeps({
75
+ prompter: new FakePrompter([
76
+ select('opencode'),
77
+ select('global'),
78
+ confirm(true, 'Create'), // Config write
79
+ multiselect([]), // No agents selected
80
+ ]),
81
+ });
82
+
83
+ await runSetup(deps);
84
+
85
+ const files = deps.files as FakeFileStore;
86
+ const written = files.getWrittenFiles();
87
+ expect(written.has('/home/user/.config/opencode/opencode.json')).toBe(true);
88
+ });
89
+
90
+ it('sets up pi project with fresh install', async () => {
91
+ const deps = makeDeps({
92
+ commands: new FakeCommandRunner()
93
+ .handle('pi --version', 'mocked') // For checkInstalled
94
+ .handle('pi install', ''), // For actual install
95
+ prompter: new FakePrompter([
96
+ select('pi'),
97
+ select('project'),
98
+ select('fullstack'), // Agent selection
99
+ confirm(true, 'Create'),
100
+ ]),
101
+ });
102
+
103
+ await runSetup(deps);
104
+
105
+ const commands = deps.commands as FakeCommandRunner;
106
+ expect(commands.calls.length).toBeGreaterThan(0);
107
+ const installCall = commands.calls.find((c) => c.command === 'pi');
108
+ expect(installCall?.args).toContain('npm:@bergetai/pi-provider');
109
+ });
110
+
111
+ it('skips agent selection for pi project', async () => {
112
+ const deps = makeDeps({
113
+ commands: new FakeCommandRunner()
114
+ .handle('pi --version', 'mocked') // For checkInstalled
115
+ .handle('pi install', ''), // For actual install
116
+ prompter: new FakePrompter([
117
+ select('pi'),
118
+ select('project'),
119
+ select('__skip__'), // Skip agent selection
120
+ ]),
121
+ });
122
+
123
+ await runSetup(deps);
124
+
125
+ const files = deps.files as FakeFileStore;
126
+ const written = files.getWrittenFiles();
127
+ // Should not create any agent files
128
+ for (const path of written.keys()) {
129
+ expect(path).not.toContain('SYSTEM.md');
130
+ }
131
+ });
132
+ });
133
+
134
+ describe('prerequisites', () => {
135
+ it('throws PrerequisiteError when opencode is not installed', async () => {
136
+ const deps = makeDeps({
137
+ commands: new FakeCommandRunner(),
138
+ prompter: new FakePrompter([select('opencode'), select('project')]),
139
+ });
140
+
141
+ // Simulate opencode not being installed
142
+ await expect(runSetup(deps)).rejects.toBeInstanceOf(PrerequisiteError);
143
+ });
144
+ });
145
+
146
+ describe('cancellation', () => {
147
+ it('throws CancelledError when user cancels at tool selection', async () => {
148
+ const deps = makeDeps({
149
+ prompter: new FakePrompter([select(CANCEL)]),
150
+ });
151
+
152
+ await expect(runSetup(deps)).rejects.toBeInstanceOf(CancelledError);
153
+ });
154
+
155
+ it('throws CancelledError when user cancels at write confirmation', async () => {
156
+ const deps = makeDeps({
157
+ prompter: new FakePrompter([
158
+ select('opencode'),
159
+ select('project'),
160
+ confirm(false, 'Create'),
161
+ ]),
162
+ });
163
+
164
+ await expect(runSetup(deps)).rejects.toBeInstanceOf(CancelledError);
165
+ });
166
+
167
+ it('throws CancelledError when user cancels at agent write confirmation (opencode)', async () => {
168
+ const deps = makeDeps({
169
+ prompter: new FakePrompter([
170
+ select('opencode'),
171
+ select('project'),
172
+ confirm(true, 'Create'),
173
+ multiselect(['backend', 'frontend']),
174
+ confirm(false, 'agent'),
175
+ ]),
176
+ });
177
+
178
+ await expect(runSetup(deps)).rejects.toBeInstanceOf(CancelledError);
179
+ });
180
+
181
+ it('throws CancelledError when user cancels at agent write confirmation (pi)', async () => {
182
+ const deps = makeDeps({
183
+ commands: new FakeCommandRunner().handle('pi --version', 'mocked').handle('pi install', ''),
184
+ prompter: new FakePrompter([
185
+ select('pi'),
186
+ select('project'),
187
+ select('fullstack'),
188
+ confirm(false, /Create|Overwrite/),
189
+ ]),
190
+ });
191
+
192
+ await expect(runSetup(deps)).rejects.toBeInstanceOf(CancelledError);
193
+ });
194
+ });
195
+
196
+ describe('file operations', () => {
197
+ it('preserves existing configuration keys when updating', async () => {
198
+ const deps = makeDeps({
199
+ prompter: new FakePrompter([
200
+ select('opencode'),
201
+ select('project'),
202
+ confirm(true, 'Write'),
203
+ multiselect([]),
204
+ ]),
205
+ });
206
+
207
+ const files = deps.files as FakeFileStore;
208
+ files.seed(
209
+ '/home/user/project/opencode.json',
210
+ JSON.stringify({
211
+ customField: 'should-preserve',
212
+ plugin: ['other-plugin'],
213
+ }),
214
+ );
215
+
216
+ await runSetup(deps);
217
+
218
+ const written = files.getWrittenFiles();
219
+ const config = JSON.parse(written.get('/home/user/project/opencode.json')!);
220
+ expect(config.customField).toBe('should-preserve');
221
+ expect(config.plugin).toContain('other-plugin');
222
+ expect(config.plugin).toContain('@bergetai/opencode-auth');
223
+ });
224
+
225
+ it('preserves jsonc comments when updating', async () => {
226
+ const deps = makeDeps({
227
+ prompter: new FakePrompter([
228
+ select('opencode'),
229
+ select('project'),
230
+ confirm(true, 'Write'),
231
+ multiselect([]),
232
+ ]),
233
+ });
234
+
235
+ const files = deps.files as FakeFileStore;
236
+ files.seed(
237
+ '/home/user/project/opencode.jsonc',
238
+ `{
151
239
  // This is my custom config
152
240
  "customField": "should-preserve",
153
241
  /* block comment explaining plugin */
154
242
  "plugin": ["other-plugin"]
155
- }`)
156
-
157
- await runSetup(deps)
158
-
159
- const written = files.getWrittenFiles()
160
- const content = written.get('/home/user/project/opencode.jsonc')!
161
- expect(content).toContain('// This is my custom config')
162
- expect(content).toContain('/* block comment explaining plugin */')
163
- expect(content).toContain('"customField": "should-preserve"')
164
- expect(content).toContain('@bergetai/opencode-auth@1.0.16')
165
- })
166
-
167
- it('shows no changes needed when config is already up to date', async () => {
168
- const deps = makeDeps({
169
- prompter: new FakePrompter([
170
- select('opencode'),
171
- select('project'),
172
- ]),
173
- })
174
-
175
- const files = deps.files as FakeFileStore
176
- // Already has the exact same plugin version
177
- files.seed('/home/user/project/opencode.json', JSON.stringify({
178
- $schema: 'https://opencode.ai/config.json',
179
- plugin: ['@bergetai/opencode-auth@1.0.16'],
180
- }, null, 2) + '\n')
181
-
182
- await runSetup(deps)
183
-
184
- // Check that no write happened — content should be unchanged
185
- const written = files.getWrittenFiles()
186
- const content = written.get('/home/user/project/opencode.json')!
187
- const config = JSON.parse(content)
188
- expect(config.plugin).toEqual(['@bergetai/opencode-auth@1.0.16'])
189
- expect(content).toContain('$schema')
190
- })
191
-
192
- it('preserves existing Pi settings when setting defaultProvider', async () => {
193
- const deps = makeDeps({
194
- prompter: new FakePrompter([
195
- select('pi'),
196
- select('project'),
197
- confirm(true, 'Proceed'),
198
- ]),
199
- commands: new FakeCommandRunner()
200
- .handle('pi --version', 'mocked')
201
- .handle('pi install', ''),
202
- })
203
-
204
- const files = deps.files as FakeFileStore
205
- files.seed('/home/user/project/.pi/settings.json', JSON.stringify({
206
- existingKey: 'should-preserve',
207
- anotherSetting: true,
208
- }))
209
-
210
- await runSetup(deps)
211
-
212
- const written = files.getWrittenFiles()
213
- const settings = JSON.parse(written.get('/home/user/project/.pi/settings.json')!)
214
- expect(settings.existingKey).toBe('should-preserve')
215
- expect(settings.anotherSetting).toBe(true)
216
- expect(settings.defaultProvider).toBe('berget')
217
- })
218
-
219
- it('creates parent directories when writing files', async () => {
220
- const deps = makeDeps({
221
- prompter: new FakePrompter([
222
- select('opencode'),
223
- select('global'),
224
- confirm(true, 'Create'),
225
- ]),
226
- })
227
-
228
- await runSetup(deps)
229
-
230
- const files = deps.files as FakeFileStore
231
- const written = files.getWrittenFiles()
232
- expect(written.has('/home/user/.config/opencode/opencode.json')).toBe(true)
233
- })
234
- })
235
-
236
- describe('command execution', () => {
237
- it('passes arguments as array (no shell injection)', async () => {
238
- const deps = makeDeps({
239
- prompter: new FakePrompter([
240
- select('pi'),
241
- select('project'),
242
- confirm(true, 'Proceed'),
243
- ]),
244
- commands: new FakeCommandRunner()
245
- .handle('pi --version', 'mocked')
246
- .handle('pi install', ''),
247
- })
248
-
249
- await runSetup(deps)
250
-
251
- const commands = deps.commands as FakeCommandRunner
252
- const installCall = commands.calls.find(c => c.command === 'pi')
253
- expect(installCall?.args).toContain('npm:@bergetai/pi-provider')
254
- expect(installCall?.args).toContain('-l')
255
- })
256
- })
257
-
258
- describe('error handling', () => {
259
- it('throws CommandFailedError when pi install fails', async () => {
260
- const deps = makeDeps({
261
- prompter: new FakePrompter([
262
- select('pi'),
263
- select('project'),
264
- confirm(true, 'Proceed'),
265
- ]),
266
- commands: new FakeCommandRunner()
267
- .handle('pi --version', 'mocked')
268
- .handle('pi install', new Error('npm error')),
269
- })
270
-
271
- await expect(runSetup(deps)).rejects.toBeInstanceOf(CommandFailedError)
272
- })
273
- })
274
- })
243
+ }`,
244
+ );
245
+
246
+ await runSetup(deps);
247
+
248
+ const written = files.getWrittenFiles();
249
+ const content = written.get('/home/user/project/opencode.jsonc')!;
250
+ expect(content).toContain('// This is my custom config');
251
+ expect(content).toContain('/* block comment explaining plugin */');
252
+ expect(content).toContain('"customField": "should-preserve"');
253
+ expect(content).toContain('@bergetai/opencode-auth');
254
+ });
255
+
256
+ it('shows no changes needed when config is already up to date', async () => {
257
+ const deps = makeDeps({
258
+ prompter: new FakePrompter([select('opencode'), select('project'), multiselect([])]),
259
+ });
260
+
261
+ const files = deps.files as FakeFileStore;
262
+ // Already has the exact same plugin version
263
+ files.seed(
264
+ '/home/user/project/opencode.json',
265
+ JSON.stringify(
266
+ {
267
+ $schema: 'https://opencode.ai/config.json',
268
+ plugin: ['@bergetai/opencode-auth'],
269
+ },
270
+ null,
271
+ 2,
272
+ ) + '\n',
273
+ );
274
+
275
+ await runSetup(deps);
276
+
277
+ // Check that no write happened — content should be unchanged
278
+ const written = files.getWrittenFiles();
279
+ const content = written.get('/home/user/project/opencode.json')!;
280
+ const config = JSON.parse(content);
281
+ expect(config.plugin).toEqual(['@bergetai/opencode-auth']);
282
+ expect(content).toContain('$schema');
283
+ });
284
+
285
+ it('preserves existing Pi settings when setting defaultProvider', async () => {
286
+ const deps = makeDeps({
287
+ commands: new FakeCommandRunner().handle('pi --version', 'mocked').handle('pi install', ''),
288
+ prompter: new FakePrompter([
289
+ select('pi'),
290
+ select('project'),
291
+ select('fullstack'),
292
+ confirm(true, 'Create'),
293
+ ]),
294
+ });
295
+
296
+ const files = deps.files as FakeFileStore;
297
+ files.seed(
298
+ '/home/user/project/.pi/settings.json',
299
+ JSON.stringify({
300
+ anotherSetting: true,
301
+ existingKey: 'should-preserve',
302
+ }),
303
+ );
304
+
305
+ await runSetup(deps);
306
+
307
+ const written = files.getWrittenFiles();
308
+ const settings = JSON.parse(written.get('/home/user/project/.pi/settings.json')!);
309
+ expect(settings.existingKey).toBe('should-preserve');
310
+ expect(settings.anotherSetting).toBe(true);
311
+ expect(settings.defaultProvider).toBe('berget');
312
+ });
313
+
314
+ it('creates parent directories when writing files', async () => {
315
+ const deps = makeDeps({
316
+ prompter: new FakePrompter([
317
+ select('opencode'),
318
+ select('global'),
319
+ confirm(true, 'Create'),
320
+ multiselect([]),
321
+ ]),
322
+ });
323
+
324
+ await runSetup(deps);
325
+
326
+ const files = deps.files as FakeFileStore;
327
+ const written = files.getWrittenFiles();
328
+ expect(written.has('/home/user/.config/opencode/opencode.json')).toBe(true);
329
+ });
330
+ });
331
+
332
+ describe('command execution', () => {
333
+ it('passes arguments as array (no shell injection)', async () => {
334
+ const deps = makeDeps({
335
+ commands: new FakeCommandRunner().handle('pi --version', 'mocked').handle('pi install', ''),
336
+ prompter: new FakePrompter([
337
+ select('pi'),
338
+ select('project'),
339
+ select('fullstack'),
340
+ confirm(true, 'Create'),
341
+ ]),
342
+ });
343
+
344
+ await runSetup(deps);
345
+
346
+ const commands = deps.commands as FakeCommandRunner;
347
+ const installCall = commands.calls.find((c) => c.command === 'pi');
348
+ expect(installCall?.args).toContain('npm:@bergetai/pi-provider');
349
+ expect(installCall?.args).toContain('-l');
350
+ });
351
+ });
352
+
353
+ describe('error handling', () => {
354
+ it('throws CommandFailedError when pi install fails', async () => {
355
+ const deps = makeDeps({
356
+ commands: new FakeCommandRunner()
357
+ .handle('pi --version', 'mocked')
358
+ .handle('pi install', new Error('npm error')),
359
+ prompter: new FakePrompter([select('pi'), select('project')]),
360
+ });
361
+
362
+ await expect(runSetup(deps)).rejects.toBeInstanceOf(CommandFailedError);
363
+ });
364
+ });
365
+
366
+ describe('auth integration', () => {
367
+ it('already authenticated shows simplified message', async () => {
368
+ const files = new FakeFileStore();
369
+ files.seed(
370
+ '/home/user/.local/share/opencode/auth.json',
371
+ JSON.stringify({ berget: { type: 'oauth' } }),
372
+ );
373
+
374
+ const deps = makeDeps({
375
+ files,
376
+ prompter: new FakePrompter([
377
+ select('opencode'),
378
+ select('project'),
379
+ select('keep'), // New: keep existing auth
380
+ confirm(true, 'Create'), // Config write
381
+ multiselect([]),
382
+ ]),
383
+ });
384
+
385
+ await runSetup(deps);
386
+
387
+ const prompter = deps.prompter as FakePrompter;
388
+ const notes = prompter.calls.filter((c) => c.method === 'note');
389
+ const lastNote = notes.at(-1);
390
+ expect(JSON.stringify(lastNote)).toContain('Run: opencode');
391
+ expect(JSON.stringify(lastNote)).not.toContain('/connect');
392
+ });
393
+
394
+ it('login failure shows manual auth instructions', async () => {
395
+ const deps = makeDeps({
396
+ authService: new FakeAuthService(false),
397
+ commands: new FakeCommandRunner().handle('pi --version', 'mocked').handle('pi install', ''),
398
+ files: new FakeFileStore(), // No pre-seeded auth → auth flow runs
399
+ prompter: new FakePrompter([
400
+ select('pi'),
401
+ select('project'),
402
+ select('fullstack'),
403
+ confirm(true, 'Create'),
404
+ ]),
405
+ });
406
+
407
+ await runSetup(deps);
408
+
409
+ const prompter = deps.prompter as FakePrompter;
410
+ const notes = prompter.calls.filter((c) => c.method === 'note');
411
+ const lastNote = notes.at(-1);
412
+ expect(JSON.stringify(lastNote)).toContain('/login');
413
+ });
414
+
415
+ it('creates api key for pi when no seat', async () => {
416
+ const files = new FakeFileStore();
417
+
418
+ const deps = makeDeps({
419
+ authService: new FakeAuthService(true, false), // succeed, no seat
420
+ commands: new FakeCommandRunner().handle('pi --version', 'mocked').handle('pi install', ''),
421
+ files,
422
+ prompter: new FakePrompter([
423
+ select('pi'),
424
+ select('project'),
425
+ confirm(true), // API key creation prompt
426
+ select('fullstack'),
427
+ confirm(true, 'Create'),
428
+ ]),
429
+ });
430
+
431
+ await runSetup(deps);
432
+
433
+ const written = files.getWrittenFiles();
434
+ expect(written.has('/home/user/.pi/agent/auth.json')).toBe(true);
435
+ const parsed = JSON.parse(written.get('/home/user/.pi/agent/auth.json')!);
436
+ expect(parsed.berget.type).toBe('api_key');
437
+ });
438
+
439
+ it('uses subscription when berget_code_seat present', async () => {
440
+ const files = new FakeFileStore();
441
+ const farFuture = Math.floor(Date.now() / 1000) + 3600 * 24 * 365; // 1 year from now in seconds
442
+ files.seed(
443
+ '/home/user/.berget/auth.json',
444
+ JSON.stringify({
445
+ access_token: makeJwt({ exp: farFuture, realm_access: { roles: ['berget_code_seat'] } }),
446
+ expires_at: farFuture * 1000,
447
+ refresh_token: 'ref',
448
+ }),
449
+ );
450
+
451
+ const deps = makeDeps({
452
+ files,
453
+ prompter: new FakePrompter([
454
+ select('opencode'),
455
+ select('project'),
456
+ select('subscription'),
457
+ confirm(true, 'Create'),
458
+ multiselect([]),
459
+ ]),
460
+ });
461
+
462
+ await runSetup(deps);
463
+
464
+ const written = files.getWrittenFiles();
465
+ const parsed = JSON.parse(written.get('/home/user/.local/share/opencode/auth.json')!);
466
+ expect(parsed.berget.type).toBe('oauth');
467
+ });
468
+ });
469
+
470
+ describe('agent configuration', () => {
471
+ it('sets up multiple agents for opencode project', async () => {
472
+ const deps = makeDeps({
473
+ prompter: new FakePrompter([
474
+ select('opencode'),
475
+ select('project'),
476
+ confirm(true, 'Create'),
477
+ multiselect(['backend', 'frontend']),
478
+ confirm(true, 'agent'),
479
+ ]),
480
+ });
481
+
482
+ await runSetup(deps);
483
+
484
+ const files = deps.files as FakeFileStore;
485
+ const written = files.getWrittenFiles();
486
+ expect(written.has('/home/user/project/.opencode/agents/backend.md')).toBe(true);
487
+ expect(written.has('/home/user/project/.opencode/agents/frontend.md')).toBe(true);
488
+ });
489
+
490
+ it('sets up no agents for opencode when none selected', async () => {
491
+ const deps = makeDeps({
492
+ prompter: new FakePrompter([
493
+ select('opencode'),
494
+ select('project'),
495
+ confirm(true, 'Create'),
496
+ multiselect([]),
497
+ ]),
498
+ });
499
+
500
+ await runSetup(deps);
501
+
502
+ const files = deps.files as FakeFileStore;
503
+ const written = files.getWrittenFiles();
504
+ for (const path of written.keys()) {
505
+ expect(path).not.toMatch(/agents\/\w+\.md$/);
506
+ }
507
+ });
508
+
509
+ it('sets up agent globally for opencode', async () => {
510
+ const deps = makeDeps({
511
+ prompter: new FakePrompter([
512
+ select('opencode'),
513
+ select('global'),
514
+ confirm(true, 'Create'),
515
+ multiselect(['fullstack']),
516
+ confirm(true, 'agent'),
517
+ ]),
518
+ });
519
+
520
+ await runSetup(deps);
521
+
522
+ const files = deps.files as FakeFileStore;
523
+ const written = files.getWrittenFiles();
524
+ expect(written.has('/home/user/.config/opencode/agents/fullstack.md')).toBe(true);
525
+ });
526
+
527
+ it('sets up agent for pi project', async () => {
528
+ const deps = makeDeps({
529
+ commands: new FakeCommandRunner().handle('pi --version', 'mocked').handle('pi install', ''),
530
+ prompter: new FakePrompter([
531
+ select('pi'),
532
+ select('project'),
533
+ select('fullstack'),
534
+ confirm(true, 'Create'),
535
+ ]),
536
+ });
537
+
538
+ await runSetup(deps);
539
+
540
+ const files = deps.files as FakeFileStore;
541
+ const written = files.getWrittenFiles();
542
+ expect(written.has('/home/user/project/.pi/SYSTEM.md')).toBe(true);
543
+ });
544
+
545
+ it('sets up agent for pi globally', async () => {
546
+ const deps = makeDeps({
547
+ commands: new FakeCommandRunner().handle('pi --version', 'mocked').handle('pi install', ''),
548
+ prompter: new FakePrompter([
549
+ select('pi'),
550
+ select('global'),
551
+ select('backend'),
552
+ confirm(true, 'Create'),
553
+ ]),
554
+ });
555
+
556
+ await runSetup(deps);
557
+
558
+ const files = deps.files as FakeFileStore;
559
+ const written = files.getWrittenFiles();
560
+ expect(written.has('/home/user/.pi/agent/SYSTEM.md')).toBe(true);
561
+ });
562
+
563
+ it('skips writing identical opencode agent files', async () => {
564
+ const deps = makeDeps({
565
+ prompter: new FakePrompter([
566
+ select('opencode'),
567
+ select('project'),
568
+ confirm(true, 'Create'),
569
+ multiselect(['backend', 'frontend']),
570
+ confirm(true, 'agent'),
571
+ ]),
572
+ });
573
+
574
+ // First run writes the files
575
+ await runSetup(deps);
576
+
577
+ const files = deps.files as FakeFileStore;
578
+ const firstBackend = files
579
+ .getWrittenFiles()
580
+ .get('/home/user/project/.opencode/agents/backend.md');
581
+ const firstFrontend = files
582
+ .getWrittenFiles()
583
+ .get('/home/user/project/.opencode/agents/frontend.md');
584
+
585
+ // Second run with exact same content should not prompt for overwrite
586
+ const deps2 = makeDeps({
587
+ files,
588
+ prompter: new FakePrompter([
589
+ select('opencode'),
590
+ select('project'),
591
+ multiselect(['backend', 'frontend']),
592
+ ]),
593
+ });
594
+
595
+ await runSetup(deps2);
596
+
597
+ // Content should be unchanged
598
+ expect(files.getWrittenFiles().get('/home/user/project/.opencode/agents/backend.md')).toBe(
599
+ firstBackend,
600
+ );
601
+ expect(files.getWrittenFiles().get('/home/user/project/.opencode/agents/frontend.md')).toBe(
602
+ firstFrontend,
603
+ );
604
+ });
605
+
606
+ it('overwrites pi SYSTEM.md when content differs', async () => {
607
+ const files = new FakeFileStore();
608
+ files.seed('/home/user/project/.pi/SYSTEM.md', 'old agent content');
609
+
610
+ const deps = makeDeps({
611
+ commands: new FakeCommandRunner().handle('pi --version', 'mocked').handle('pi install', ''),
612
+ files,
613
+ prompter: new FakePrompter([
614
+ select('pi'),
615
+ select('project'),
616
+ select('fullstack'),
617
+ confirm(true, 'Overwrite'),
618
+ ]),
619
+ });
620
+
621
+ await runSetup(deps);
622
+
623
+ const written = files.getWrittenFiles();
624
+ const content = written.get('/home/user/project/.pi/SYSTEM.md');
625
+ expect(content).not.toBe('old agent content');
626
+ // Pi doesn't use front matter, so check for system prompt content
627
+ expect(content).toContain('Fullstack Agent');
628
+ });
629
+ });
630
+ });