berget 2.2.4 → 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.
- package/.github/workflows/publish.yml +2 -2
- package/.github/workflows/test.yml +1 -1
- package/dist/package.json +3 -1
- package/dist/src/commands/code/__tests__/fake-command-runner.js +52 -0
- package/dist/src/commands/code/__tests__/fake-file-store.js +46 -0
- package/dist/src/commands/code/__tests__/fake-prompter.js +91 -0
- package/dist/src/commands/code/__tests__/setup-flow.test.js +238 -0
- package/dist/src/commands/code/adapters/clack-prompter.js +71 -0
- package/dist/src/commands/code/adapters/fs-file-store.js +75 -0
- package/dist/src/commands/code/adapters/spawn-command-runner.js +49 -0
- package/dist/src/commands/code/errors.js +27 -0
- package/dist/src/commands/code/ports/command-runner.js +2 -0
- package/dist/src/commands/code/ports/file-store.js +2 -0
- package/dist/src/commands/code/ports/prompter.js +2 -0
- package/dist/src/commands/code/setup.js +392 -0
- package/dist/src/commands/code.js +189 -633
- package/dist/src/constants/command-structure.js +2 -0
- package/dist/tests/commands/code.test.js +31 -0
- package/dist/tests/utils/opencode-validator.test.js +15 -14
- package/package.json +3 -1
- package/src/commands/code/__tests__/fake-command-runner.ts +47 -0
- package/src/commands/code/__tests__/fake-file-store.ts +35 -0
- package/src/commands/code/__tests__/fake-prompter.ts +83 -0
- package/src/commands/code/__tests__/setup-flow.test.ts +274 -0
- package/src/commands/code/adapters/clack-prompter.ts +43 -0
- package/src/commands/code/adapters/fs-file-store.ts +33 -0
- package/src/commands/code/adapters/spawn-command-runner.ts +36 -0
- package/src/commands/code/errors.ts +23 -0
- package/src/commands/code/ports/command-runner.ts +6 -0
- package/src/commands/code/ports/file-store.ts +6 -0
- package/src/commands/code/ports/prompter.ts +23 -0
- package/src/commands/code/setup.ts +402 -0
- package/src/commands/code.ts +211 -748
- package/src/constants/command-structure.ts +3 -0
- package/templates/agents/app.md +22 -0
- package/templates/agents/backend.md +22 -0
- package/templates/agents/devops.md +28 -0
- package/templates/agents/frontend.md +24 -0
- package/templates/agents/fullstack.md +22 -0
- package/templates/agents/quality.md +64 -0
- package/templates/agents/security.md +20 -0
- package/tests/commands/code.test.ts +47 -0
- package/tests/utils/opencode-validator.test.ts +16 -15
- 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
|
-
|
|
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
|
-
//
|
|
99
|
-
console.log('
|
|
100
|
-
|
|
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.
|
|
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
|
+
}
|