berget 2.2.5 → 2.2.6

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 (44) hide show
  1. package/.github/workflows/publish.yml +2 -2
  2. package/.github/workflows/test.yml +1 -1
  3. package/dist/package.json +3 -1
  4. package/dist/src/commands/code/__tests__/fake-command-runner.js +52 -0
  5. package/dist/src/commands/code/__tests__/fake-file-store.js +46 -0
  6. package/dist/src/commands/code/__tests__/fake-prompter.js +91 -0
  7. package/dist/src/commands/code/__tests__/setup-flow.test.js +238 -0
  8. package/dist/src/commands/code/adapters/clack-prompter.js +71 -0
  9. package/dist/src/commands/code/adapters/fs-file-store.js +75 -0
  10. package/dist/src/commands/code/adapters/spawn-command-runner.js +49 -0
  11. package/dist/src/commands/code/errors.js +27 -0
  12. package/dist/src/commands/code/ports/command-runner.js +2 -0
  13. package/dist/src/commands/code/ports/file-store.js +2 -0
  14. package/dist/src/commands/code/ports/prompter.js +2 -0
  15. package/dist/src/commands/code/setup.js +392 -0
  16. package/dist/src/commands/code.js +187 -631
  17. package/dist/src/constants/command-structure.js +2 -0
  18. package/dist/tests/commands/code.test.js +31 -0
  19. package/dist/tests/utils/opencode-validator.test.js +15 -14
  20. package/package.json +3 -1
  21. package/src/commands/code/__tests__/fake-command-runner.ts +47 -0
  22. package/src/commands/code/__tests__/fake-file-store.ts +35 -0
  23. package/src/commands/code/__tests__/fake-prompter.ts +83 -0
  24. package/src/commands/code/__tests__/setup-flow.test.ts +274 -0
  25. package/src/commands/code/adapters/clack-prompter.ts +43 -0
  26. package/src/commands/code/adapters/fs-file-store.ts +33 -0
  27. package/src/commands/code/adapters/spawn-command-runner.ts +36 -0
  28. package/src/commands/code/errors.ts +23 -0
  29. package/src/commands/code/ports/command-runner.ts +6 -0
  30. package/src/commands/code/ports/file-store.ts +6 -0
  31. package/src/commands/code/ports/prompter.ts +23 -0
  32. package/src/commands/code/setup.ts +402 -0
  33. package/src/commands/code.ts +209 -746
  34. package/src/constants/command-structure.ts +3 -0
  35. package/templates/agents/app.md +22 -0
  36. package/templates/agents/backend.md +22 -0
  37. package/templates/agents/devops.md +28 -0
  38. package/templates/agents/frontend.md +24 -0
  39. package/templates/agents/fullstack.md +22 -0
  40. package/templates/agents/quality.md +64 -0
  41. package/templates/agents/security.md +20 -0
  42. package/tests/commands/code.test.ts +47 -0
  43. package/tests/utils/opencode-validator.test.ts +16 -15
  44. package/opencode.json +0 -146
@@ -39,6 +39,7 @@ exports.SUBCOMMANDS = {
39
39
  RUN: 'run',
40
40
  UPDATE: 'update',
41
41
  SERVE: 'serve',
42
+ SETUP: 'setup',
42
43
  },
43
44
  // API Keys commands
44
45
  API_KEYS: {
@@ -171,6 +172,7 @@ exports.COMMAND_DESCRIPTIONS = {
171
172
  [`${exports.COMMAND_GROUPS.CHAT} ${exports.SUBCOMMANDS.CHAT.LIST}`]: 'List available chat models',
172
173
  // Code group
173
174
  [exports.COMMAND_GROUPS.CODE]: 'AI-powered coding assistant with OpenCode',
175
+ [`${exports.COMMAND_GROUPS.CODE} ${exports.SUBCOMMANDS.CODE.SETUP}`]: 'Interactive setup for Berget AI coding tools',
174
176
  [`${exports.COMMAND_GROUPS.CODE} ${exports.SUBCOMMANDS.CODE.INIT}`]: 'Initialize project for AI coding assistant',
175
177
  [`${exports.COMMAND_GROUPS.CODE} ${exports.SUBCOMMANDS.CODE.RUN}`]: 'Run AI coding assistant',
176
178
  [`${exports.COMMAND_GROUPS.CODE} ${exports.SUBCOMMANDS.CODE.SERVE}`]: 'Start OpenCode web server',
@@ -411,4 +411,35 @@ vitest_1.vi.mock('readline', () => ({
411
411
  })).rejects.toThrow('Env update failed');
412
412
  }));
413
413
  });
414
+ (0, vitest_1.describe)('experimental features', () => {
415
+ let originalEnv;
416
+ (0, vitest_1.beforeEach)(() => {
417
+ originalEnv = process.env.BERGET_EXPERIMENTAL;
418
+ });
419
+ (0, vitest_1.afterEach)(() => {
420
+ if (originalEnv === undefined) {
421
+ delete process.env.BERGET_EXPERIMENTAL;
422
+ }
423
+ else {
424
+ process.env.BERGET_EXPERIMENTAL = originalEnv;
425
+ }
426
+ });
427
+ (0, vitest_1.it)('should NOT show setup command when BERGET_EXPERIMENTAL is not set', () => {
428
+ delete process.env.BERGET_EXPERIMENTAL;
429
+ const freshProgram = new commander_1.Command();
430
+ (0, code_1.registerCodeCommands)(freshProgram);
431
+ const codeCommand = freshProgram.commands.find((cmd) => cmd.name() === 'code');
432
+ const setupCommand = codeCommand === null || codeCommand === void 0 ? void 0 : codeCommand.commands.find((cmd) => cmd.name() === 'setup');
433
+ (0, vitest_1.expect)(setupCommand).toBeUndefined();
434
+ });
435
+ (0, vitest_1.it)('should show setup command when BERGET_EXPERIMENTAL is set', () => {
436
+ process.env.BERGET_EXPERIMENTAL = '1';
437
+ const freshProgram = new commander_1.Command();
438
+ (0, code_1.registerCodeCommands)(freshProgram);
439
+ const codeCommand = freshProgram.commands.find((cmd) => cmd.name() === 'code');
440
+ const setupCommand = codeCommand === null || codeCommand === void 0 ? void 0 : codeCommand.commands.find((cmd) => cmd.name() === 'setup');
441
+ (0, vitest_1.expect)(setupCommand).toBeDefined();
442
+ (0, vitest_1.expect)(setupCommand === null || setupCommand === void 0 ? void 0 : setupCommand.description()).toBe('Interactive setup for Berget AI coding tools');
443
+ });
444
+ });
414
445
  });
@@ -81,23 +81,24 @@ const fs_1 = require("fs");
81
81
  });
82
82
  (0, vitest_1.it)('should validate the current opencode.json file', () => {
83
83
  var _a;
84
+ let currentConfig;
84
85
  try {
85
- const currentConfig = JSON.parse((0, fs_1.readFileSync)('opencode.json', 'utf8'));
86
- // Apply fixes to handle common issues
87
- const fixedConfig = (0, opencode_validator_1.fixOpenCodeConfig)(currentConfig);
88
- // Validate the fixed config
89
- const result = (0, opencode_validator_1.validateOpenCodeConfig)(fixedConfig);
90
- // The fixed config should be valid according to the JSON Schema
91
- (0, vitest_1.expect)(result.valid).toBe(true);
92
- if (!result.valid) {
93
- console.log('Fixed opencode.json validation errors:');
94
- (_a = result.errors) === null || _a === void 0 ? void 0 : _a.forEach((err) => console.log(` - ${err}`));
95
- }
86
+ currentConfig = JSON.parse((0, fs_1.readFileSync)('opencode.json', 'utf8'));
96
87
  }
97
88
  catch (error) {
98
- // If we can't read the file, that's ok for this test
99
- console.log('Could not read opencode.json for testing:', error);
100
- vitest_1.expect.fail('Should be able to read opencode.json');
89
+ // Skip when opencode.json is not present (e.g. in CI or clean checkouts)
90
+ console.log('Skipping: opencode.json not found:', error);
91
+ return;
92
+ }
93
+ // Apply fixes to handle common issues
94
+ const fixedConfig = (0, opencode_validator_1.fixOpenCodeConfig)(currentConfig);
95
+ // Validate the fixed config
96
+ const result = (0, opencode_validator_1.validateOpenCodeConfig)(fixedConfig);
97
+ // The fixed config should be valid according to the JSON Schema
98
+ (0, vitest_1.expect)(result.valid).toBe(true);
99
+ if (!result.valid) {
100
+ console.log('Fixed opencode.json validation errors:');
101
+ (_a = result.errors) === null || _a === void 0 ? void 0 : _a.forEach((err) => console.log(` - ${err}`));
101
102
  }
102
103
  });
103
104
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "berget",
3
- "version": "2.2.5",
3
+ "version": "2.2.6",
4
4
  "main": "dist/index.js",
5
5
  "bin": {
6
6
  "berget": "dist/index.js"
@@ -33,12 +33,14 @@
33
33
  "vitest": "^1.0.0"
34
34
  },
35
35
  "dependencies": {
36
+ "@clack/prompts": "^0.10.0",
36
37
  "ajv": "^8.17.1",
37
38
  "ajv-formats": "^3.0.1",
38
39
  "chalk": "^4.1.2",
39
40
  "commander": "^12.0.0",
40
41
  "dotenv": "^17.2.3",
41
42
  "fs-extra": "^11.3.0",
43
+ "jsonc-parser": "^3.3.1",
42
44
  "marked": "^9.1.6",
43
45
  "marked-terminal": "^6.2.0",
44
46
  "open": "^9.1.0",
@@ -0,0 +1,47 @@
1
+ import type { CommandRunner } from '../ports/command-runner'
2
+
3
+ type Handler = {
4
+ match: (command: string, args: readonly string[]) => boolean
5
+ response: string | Error | ((command: string, args: readonly string[]) => string | Error)
6
+ }
7
+
8
+ export class FakeCommandRunner implements CommandRunner {
9
+ private handlers: Handler[] = []
10
+ private _calls: Array<{ command: string; args: string[]; options?: { cwd?: string } }> = []
11
+
12
+ handle(match: string | RegExp, response: string | Error): this {
13
+ this.handlers.push({
14
+ match: (cmd, args) => {
15
+ const full = `${cmd} ${args.join(' ')}`
16
+ if (typeof match === 'string') return full.startsWith(match)
17
+ return match.test(full)
18
+ },
19
+ response,
20
+ })
21
+ return this
22
+ }
23
+
24
+ checkInstalled(binary: string): Promise<boolean> {
25
+ this._calls.push({ command: `check:${binary}`, args: [] })
26
+ return Promise.resolve(
27
+ this.handlers.some(h => h.match(binary, ['--version'])) || false
28
+ )
29
+ }
30
+
31
+ async run(command: string, args: readonly string[], options?: { cwd?: string }): Promise<string> {
32
+ this._calls.push({ command, args: [...args], options })
33
+ const handler = this.handlers.find(h => h.match(command, args))
34
+ if (!handler) throw new Error(`Unexpected command: ${command} ${args.join(' ')}`)
35
+
36
+ const result = typeof handler.response === 'function'
37
+ ? handler.response(command, args)
38
+ : handler.response
39
+
40
+ if (result instanceof Error) throw result
41
+ return result
42
+ }
43
+
44
+ get calls() {
45
+ return this._calls
46
+ }
47
+ }
@@ -0,0 +1,35 @@
1
+ import type { FileStore } from '../ports/file-store'
2
+
3
+ export interface FileEntry {
4
+ content: string
5
+ isDirectory?: boolean
6
+ }
7
+
8
+ export class FakeFileStore implements FileStore {
9
+ private files: Map<string, string> = new Map()
10
+ private dirs: Set<string> = new Set()
11
+
12
+ seed(path: string, content: string): void {
13
+ this.files.set(path, content)
14
+ }
15
+
16
+ async exists(path: string): Promise<boolean> {
17
+ return this.files.has(path) || this.dirs.has(path)
18
+ }
19
+
20
+ async readFile(path: string): Promise<string | null> {
21
+ return this.files.get(path) ?? null
22
+ }
23
+
24
+ async writeFile(path: string, content: string): Promise<void> {
25
+ this.files.set(path, content)
26
+ }
27
+
28
+ async mkdir(path: string): Promise<void> {
29
+ this.dirs.add(path)
30
+ }
31
+
32
+ getWrittenFiles(): Map<string, string> {
33
+ return new Map(this.files)
34
+ }
35
+ }
@@ -0,0 +1,83 @@
1
+ import { CancelledError } from '../errors'
2
+ import type { Prompter, Spinner } from '../ports/prompter'
3
+
4
+ export const CANCEL = Symbol('cancel')
5
+
6
+ type PromptEntry =
7
+ | { kind: 'select'; match?: RegExp; response: string | symbol }
8
+ | { kind: 'confirm'; match?: RegExp; response: boolean | symbol }
9
+
10
+ export const select = <T>(
11
+ value: T | symbol,
12
+ match?: string | RegExp
13
+ ): PromptEntry => ({
14
+ kind: 'select',
15
+ match: typeof match === 'string' ? new RegExp(match) : match,
16
+ response: typeof value === 'symbol' ? value : String(value),
17
+ })
18
+
19
+ export const confirm = (
20
+ value: boolean | symbol,
21
+ match?: string | RegExp
22
+ ): PromptEntry => ({
23
+ kind: 'confirm',
24
+ match: typeof match === 'string' ? new RegExp(match) : match,
25
+ response: value,
26
+ })
27
+
28
+ export class FakePrompter implements Prompter {
29
+ private _calls: Array<{ method: string; args: unknown }> = []
30
+ private _cursor = 0
31
+
32
+ constructor(private readonly _script: PromptEntry[]) {}
33
+
34
+ intro(message: string): void {
35
+ this._calls.push({ method: 'intro', args: { message } })
36
+ }
37
+ outro(message: string): void {
38
+ this._calls.push({ method: 'outro', args: { message } })
39
+ }
40
+ note(message: string, title?: string): void {
41
+ this._calls.push({ method: 'note', args: { message, title } })
42
+ }
43
+ spinner(): Spinner {
44
+ return {
45
+ start: (msg: string) => {
46
+ this._calls.push({ method: 'spinner.start', args: { message: msg } })
47
+ },
48
+ stop: (msg: string) => {
49
+ this._calls.push({ method: 'spinner.stop', args: { message: msg } })
50
+ },
51
+ }
52
+ }
53
+
54
+ async select<T>(opts: { message: string }): Promise<T> {
55
+ this._calls.push({ method: 'select', args: opts })
56
+ const entry = this._script[this._cursor++]
57
+ if (!entry) throw new Error(`No script entry for select #${this._cursor} (${opts.message})`)
58
+ if (entry.kind !== 'select') throw new Error(`Expected confirm, got select for ${opts.message}`)
59
+ if (entry.match && !entry.match.test(opts.message)) throw new Error(`Message mismatch: got "${opts.message}"`)
60
+ if (entry.response === CANCEL) throw new CancelledError()
61
+ return entry.response as T
62
+ }
63
+
64
+ async confirm(opts: { message: string }): Promise<boolean> {
65
+ this._calls.push({ method: 'confirm', args: opts })
66
+ const entry = this._script[this._cursor++]
67
+ if (!entry) throw new Error(`No script entry for confirm #${this._cursor} (${opts.message})`)
68
+ if (entry.kind !== 'confirm') throw new Error(`Expected select, got confirm for ${opts.message}`)
69
+ if (entry.match && !entry.match.test(opts.message)) throw new Error(`Message mismatch: got "${opts.message}"`)
70
+ if (entry.response === CANCEL) throw new CancelledError()
71
+ return entry.response as boolean
72
+ }
73
+
74
+ get calls() {
75
+ return this._calls
76
+ }
77
+
78
+ assertExhausted() {
79
+ if (this._cursor !== this._script.length) {
80
+ throw new Error(`Script not exhausted: ${this._script.length - this._cursor} entries left`)
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,274 @@
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
+ })
18
+
19
+ 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', `{
151
+ // This is my custom config
152
+ "customField": "should-preserve",
153
+ /* block comment explaining plugin */
154
+ "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
+ })
@@ -0,0 +1,43 @@
1
+ import * as p from '@clack/prompts'
2
+ import { CancelledError } from '../errors'
3
+ import type { Prompter, Spinner } from '../ports/prompter'
4
+
5
+ const unwrap = <T>(v: T | symbol): T => {
6
+ if (p.isCancel(v)) throw new CancelledError()
7
+ return v as T
8
+ }
9
+
10
+ export class ClackPrompter implements Prompter {
11
+ intro(message: string): void {
12
+ p.intro(message)
13
+ }
14
+ outro(message: string): void {
15
+ p.outro(message)
16
+ }
17
+ note(message: string, title?: string): void {
18
+ p.note(message, title)
19
+ }
20
+ spinner(): Spinner {
21
+ const s = p.spinner()
22
+ return {
23
+ start: (msg: string) => s.start(msg),
24
+ stop: (msg: string) => s.stop(msg),
25
+ }
26
+ }
27
+ async select<T>(opts: {
28
+ message: string
29
+ options: ReadonlyArray<{
30
+ value: T
31
+ label: string
32
+ hint?: string
33
+ }>
34
+ }): Promise<T> {
35
+ return unwrap(await p.select(opts as any))
36
+ }
37
+ async confirm(opts: {
38
+ message: string
39
+ initialValue?: boolean
40
+ }): Promise<boolean> {
41
+ return unwrap(await p.confirm(opts))
42
+ }
43
+ }
@@ -0,0 +1,33 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import * as path from 'node:path'
3
+ import type { FileStore } from '../ports/file-store'
4
+
5
+ export class FsFileStore implements FileStore {
6
+ async exists(filePath: string): Promise<boolean> {
7
+ try {
8
+ await fs.access(filePath)
9
+ return true
10
+ } catch {
11
+ return false
12
+ }
13
+ }
14
+
15
+ async readFile(filePath: string): Promise<string | null> {
16
+ try {
17
+ return await fs.readFile(filePath, 'utf8')
18
+ } catch (err: any) {
19
+ if (err.code === 'ENOENT') return null
20
+ throw err
21
+ }
22
+ }
23
+
24
+ async writeFile(filePath: string, content: string): Promise<void> {
25
+ const dir = path.dirname(filePath)
26
+ await fs.mkdir(dir, { recursive: true })
27
+ await fs.writeFile(filePath, content, 'utf8')
28
+ }
29
+
30
+ async mkdir(dir: string): Promise<void> {
31
+ await fs.mkdir(dir, { recursive: true })
32
+ }
33
+ }
@@ -0,0 +1,36 @@
1
+ import { spawn } from 'node:child_process'
2
+ import type { CommandRunner } from '../ports/command-runner'
3
+
4
+ export class SpawnCommandRunner implements CommandRunner {
5
+ async checkInstalled(binary: string): Promise<boolean> {
6
+ return new Promise((resolve) => {
7
+ const child = spawn('which', [binary], { stdio: 'pipe' })
8
+ child.on('close', (code) => resolve(code === 0))
9
+ child.on('error', () => resolve(false))
10
+ })
11
+ }
12
+
13
+ async run(command: string, args: readonly string[], options?: { cwd?: string }): Promise<string> {
14
+ return new Promise<string>((resolve, reject) => {
15
+ const child = spawn(command, args as string[], {
16
+ stdio: 'pipe',
17
+ cwd: options?.cwd || process.cwd(),
18
+ })
19
+
20
+ let stdout = ''
21
+ let stderr = ''
22
+
23
+ child.stdout?.on('data', (d) => { stdout += d.toString() })
24
+ child.stderr?.on('data', (d) => { stderr += d.toString() })
25
+
26
+ child.on('close', (code) => {
27
+ if (code === 0) {
28
+ resolve(stdout)
29
+ } else {
30
+ reject(new Error(stderr.trim() || `Command failed with exit code ${code}`))
31
+ }
32
+ })
33
+ child.on('error', (err) => reject(err))
34
+ })
35
+ }
36
+ }