berget 1.3.1 → 2.0.0

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 (67) hide show
  1. package/.env.example +5 -0
  2. package/.github/workflows/publish.yml +56 -0
  3. package/.github/workflows/test.yml +38 -0
  4. package/AGENTS.md +184 -0
  5. package/README.md +177 -38
  6. package/TODO.md +2 -0
  7. package/blog-post.md +176 -0
  8. package/dist/index.js +11 -8
  9. package/dist/package.json +14 -3
  10. package/dist/src/commands/api-keys.js +4 -2
  11. package/dist/src/commands/chat.js +182 -23
  12. package/dist/src/commands/code.js +1424 -0
  13. package/dist/src/commands/index.js +2 -0
  14. package/dist/src/constants/command-structure.js +12 -0
  15. package/dist/src/schemas/opencode-schema.json +1121 -0
  16. package/dist/src/services/chat-service.js +10 -10
  17. package/dist/src/services/cluster-service.js +1 -1
  18. package/dist/src/utils/default-api-key.js +2 -2
  19. package/dist/src/utils/env-manager.js +86 -0
  20. package/dist/src/utils/error-handler.js +10 -3
  21. package/dist/src/utils/markdown-renderer.js +4 -4
  22. package/dist/src/utils/opencode-validator.js +122 -0
  23. package/dist/src/utils/token-manager.js +2 -2
  24. package/dist/tests/commands/chat.test.js +109 -0
  25. package/dist/tests/commands/code.test.js +414 -0
  26. package/dist/tests/utils/env-manager.test.js +148 -0
  27. package/dist/tests/utils/opencode-validator.test.js +103 -0
  28. package/dist/vitest.config.js +9 -0
  29. package/index.ts +67 -32
  30. package/opencode.json +182 -0
  31. package/package.json +14 -3
  32. package/src/client.ts +20 -20
  33. package/src/commands/api-keys.ts +93 -60
  34. package/src/commands/auth.ts +4 -2
  35. package/src/commands/billing.ts +6 -3
  36. package/src/commands/chat.ts +291 -97
  37. package/src/commands/clusters.ts +2 -2
  38. package/src/commands/code.ts +1696 -0
  39. package/src/commands/index.ts +2 -0
  40. package/src/commands/models.ts +3 -3
  41. package/src/commands/users.ts +2 -2
  42. package/src/constants/command-structure.ts +112 -58
  43. package/src/schemas/opencode-schema.json +991 -0
  44. package/src/services/api-key-service.ts +1 -1
  45. package/src/services/auth-service.ts +27 -25
  46. package/src/services/chat-service.ts +37 -44
  47. package/src/services/cluster-service.ts +5 -5
  48. package/src/services/collaborator-service.ts +3 -3
  49. package/src/services/flux-service.ts +2 -2
  50. package/src/services/helm-service.ts +2 -2
  51. package/src/services/kubectl-service.ts +3 -6
  52. package/src/types/api.d.ts +1032 -1010
  53. package/src/types/json.d.ts +3 -3
  54. package/src/utils/default-api-key.ts +54 -42
  55. package/src/utils/env-manager.ts +98 -0
  56. package/src/utils/error-handler.ts +24 -15
  57. package/src/utils/logger.ts +12 -12
  58. package/src/utils/markdown-renderer.ts +18 -18
  59. package/src/utils/opencode-validator.ts +134 -0
  60. package/src/utils/token-manager.ts +35 -23
  61. package/tests/commands/chat.test.ts +129 -0
  62. package/tests/commands/code.test.ts +505 -0
  63. package/tests/utils/env-manager.test.ts +199 -0
  64. package/tests/utils/opencode-validator.test.ts +118 -0
  65. package/tsconfig.json +8 -8
  66. package/vitest.config.ts +8 -0
  67. package/-27b-it +0 -0
@@ -0,0 +1,505 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { Command } from 'commander'
3
+ import { registerCodeCommands } from '../../src/commands/code'
4
+ import { ApiKeyService } from '../../src/services/api-key-service'
5
+ import * as fs from 'fs'
6
+ import { readFile, writeFile } from 'fs/promises'
7
+ import { updateEnvFile } from '../../src/utils/env-manager'
8
+
9
+ // Mock dependencies
10
+ vi.mock('../../src/services/api-key-service')
11
+ vi.mock('fs', () => ({
12
+ default: {
13
+ existsSync: vi.fn(),
14
+ readFileSync: vi.fn(),
15
+ },
16
+ }))
17
+ vi.mock('fs/promises', () => ({
18
+ readFile: vi.fn(),
19
+ writeFile: vi.fn(),
20
+ }))
21
+ vi.mock('../../src/utils/env-manager')
22
+ vi.mock('child_process', () => ({
23
+ spawn: vi.fn(),
24
+ }))
25
+ vi.mock('readline', () => ({
26
+ createInterface: vi.fn(() => ({
27
+ question: vi.fn(),
28
+ close: vi.fn(),
29
+ })),
30
+ }))
31
+
32
+ describe('Code Commands', () => {
33
+ let program: Command
34
+ let mockApiKeyService: any
35
+ let mockFs: any
36
+ let mockFsPromises: any
37
+ let mockSpawn: any
38
+
39
+ beforeEach(() => {
40
+ program = new Command()
41
+
42
+ // Mock ApiKeyService
43
+ mockApiKeyService = {
44
+ create: vi.fn(),
45
+ list: vi.fn(),
46
+ rotate: vi.fn(),
47
+ }
48
+ vi.mocked(ApiKeyService.getInstance).mockReturnValue(mockApiKeyService)
49
+
50
+ // Mock fs
51
+ mockFs = vi.mocked(fs)
52
+ mockFs.existsSync = vi.fn()
53
+ mockFs.readFileSync = vi.fn()
54
+
55
+ // Mock fs/promises
56
+ mockFsPromises = vi.mocked({ readFile, writeFile })
57
+ mockFsPromises.readFile = vi.fn()
58
+ mockFsPromises.writeFile = vi.fn()
59
+
60
+ // Mock spawn
61
+ mockSpawn = vi.fn()
62
+ vi.doMock('child_process', () => ({ spawn: mockSpawn }))
63
+
64
+ registerCodeCommands(program)
65
+ })
66
+
67
+ afterEach(() => {
68
+ vi.clearAllMocks()
69
+ })
70
+
71
+ describe('code init command', () => {
72
+ it('should register init command with correct description', () => {
73
+ const codeCommand = program.commands.find((cmd) => cmd.name() === 'code')
74
+ const initCommand = codeCommand?.commands.find(
75
+ (cmd) => cmd.name() === 'init',
76
+ )
77
+
78
+ expect(initCommand).toBeDefined()
79
+ expect(initCommand?.description()).toBe(
80
+ 'Initialize project for AI coding assistant',
81
+ )
82
+ })
83
+
84
+ it('should have name, force, and yes options', () => {
85
+ const codeCommand = program.commands.find((cmd) => cmd.name() === 'code')
86
+ const initCommand = codeCommand?.commands.find(
87
+ (cmd) => cmd.name() === 'init',
88
+ )
89
+
90
+ expect(initCommand).toBeDefined()
91
+
92
+ const nameOption = initCommand?.options.find(
93
+ (opt) => opt.long === '--name',
94
+ )
95
+ const forceOption = initCommand?.options.find(
96
+ (opt) => opt.long === '--force',
97
+ )
98
+ const yesOption = initCommand?.options.find((opt) => opt.long === '--yes')
99
+
100
+ expect(nameOption).toBeDefined()
101
+ expect(nameOption?.description).toContain('Project name')
102
+ expect(forceOption).toBeDefined()
103
+ expect(forceOption?.description).toContain(
104
+ 'Overwrite existing configuration',
105
+ )
106
+ expect(yesOption).toBeDefined()
107
+ expect(yesOption?.description).toContain('Automatically answer yes')
108
+ })
109
+
110
+ it('should check if opencode is installed', () => {
111
+ const codeCommand = program.commands.find((cmd) => cmd.name() === 'code')
112
+ const initCommand = codeCommand?.commands.find(
113
+ (cmd) => cmd.name() === 'init',
114
+ )
115
+
116
+ expect(initCommand).toBeDefined()
117
+
118
+ // The command should attempt to spawn opencode --version
119
+ // This is tested implicitly through the spawn mock
120
+ })
121
+
122
+ it('should list existing API keys and allow selection', async () => {
123
+ // Mock successful opencode installation check
124
+ mockSpawn.mockImplementation((command: string, args: string[]) => {
125
+ if (command === 'opencode' && args[0] === '--version') {
126
+ return {
127
+ on: vi.fn().mockImplementation((event, callback) => {
128
+ if (event === 'close') callback(0)
129
+ }),
130
+ }
131
+ }
132
+ return { on: vi.fn() }
133
+ })
134
+
135
+ // Mock existing API keys
136
+ const mockExistingKeys = [
137
+ {
138
+ id: 1,
139
+ name: 'existing-key-1',
140
+ prefix: 'sk_ber',
141
+ created: '2023-01-01T00:00:00.000Z',
142
+ lastUsed: null,
143
+ },
144
+ {
145
+ id: 2,
146
+ name: 'existing-key-2',
147
+ prefix: 'sk_ber',
148
+ created: '2023-01-02T00:00:00.000Z',
149
+ lastUsed: '2023-01-03T00:00:00.000Z',
150
+ },
151
+ ]
152
+ mockApiKeyService.list.mockResolvedValue(mockExistingKeys)
153
+
154
+ // Mock file operations
155
+ mockFs.existsSync.mockReturnValue(false)
156
+ mockFsPromises.writeFile.mockResolvedValue(undefined)
157
+
158
+ // Verify that the list method is called
159
+ expect(mockApiKeyService.list).toBeDefined()
160
+ })
161
+
162
+ it('should create new API key with project-based naming', async () => {
163
+ // Mock successful opencode installation check
164
+ mockSpawn.mockImplementation((command: string, args: string[]) => {
165
+ if (command === 'opencode' && args[0] === '--version') {
166
+ return {
167
+ on: vi.fn().mockImplementation((event, callback) => {
168
+ if (event === 'close') callback(0)
169
+ }),
170
+ }
171
+ }
172
+ return { on: vi.fn() }
173
+ })
174
+
175
+ // Mock no existing keys
176
+ mockApiKeyService.list.mockResolvedValue([])
177
+
178
+ // Mock successful API key creation
179
+ const mockApiKeyData = {
180
+ id: 123,
181
+ name: 'opencode-testproject-1234567890',
182
+ key: 'test-api-key-12345',
183
+ }
184
+ mockApiKeyService.create.mockResolvedValue(mockApiKeyData)
185
+
186
+ // Mock file operations
187
+ mockFs.existsSync.mockReturnValue(false)
188
+ mockFsPromises.writeFile.mockResolvedValue(undefined)
189
+
190
+ // Verify that the create method is available
191
+ expect(mockApiKeyService.create).toBeDefined()
192
+ })
193
+
194
+ it('should create opencode.json with correct structure', async () => {
195
+ // This tests the expected config structure
196
+ const expectedConfig = {
197
+ model: 'berget/deepseek-r1',
198
+ apiKey: 'test-api-key',
199
+ projectName: 'testproject',
200
+ provider: 'berget',
201
+ created: expect.any(String),
202
+ version: '1.0.0',
203
+ }
204
+
205
+ expect(expectedConfig.model).toBe('berget/deepseek-r1')
206
+ expect(expectedConfig.provider).toBe('berget')
207
+ expect(expectedConfig.version).toBe('1.0.0')
208
+ })
209
+
210
+ it('should handle existing config file', () => {
211
+ const codeCommand = program.commands.find((cmd) => cmd.name() === 'code')
212
+ const initCommand = codeCommand?.commands.find(
213
+ (cmd) => cmd.name() === 'init',
214
+ )
215
+
216
+ expect(initCommand).toBeDefined()
217
+
218
+ // Should check if opencode.json exists before proceeding
219
+ expect(mockFs.existsSync).toBeDefined()
220
+ })
221
+ })
222
+
223
+ describe('code run command', () => {
224
+ it('should register run command with correct description', () => {
225
+ const codeCommand = program.commands.find((cmd) => cmd.name() === 'code')
226
+ const runCommand = codeCommand?.commands.find(
227
+ (cmd) => cmd.name() === 'run',
228
+ )
229
+
230
+ expect(runCommand).toBeDefined()
231
+ expect(runCommand?.description()).toBe('Run AI coding assistant')
232
+ })
233
+
234
+ it('should accept prompt argument and model, no-config, and yes options', () => {
235
+ const codeCommand = program.commands.find((cmd) => cmd.name() === 'code')
236
+ const runCommand = codeCommand?.commands.find(
237
+ (cmd) => cmd.name() === 'run',
238
+ )
239
+
240
+ expect(runCommand).toBeDefined()
241
+
242
+ const modelOption = runCommand?.options.find(
243
+ (opt) => opt.long === '--model',
244
+ )
245
+ const noConfigOption = runCommand?.options.find(
246
+ (opt) => opt.long === '--no-config',
247
+ )
248
+ const yesOption = runCommand?.options.find((opt) => opt.long === '--yes')
249
+
250
+ expect(modelOption).toBeDefined()
251
+ expect(modelOption?.description).toContain('Model to use')
252
+ expect(noConfigOption).toBeDefined()
253
+ expect(noConfigOption?.description).toContain(
254
+ 'Run without loading project config',
255
+ )
256
+ expect(yesOption).toBeDefined()
257
+ expect(yesOption?.description).toContain('Automatically answer yes')
258
+ })
259
+
260
+ it('should load configuration from opencode.json', async () => {
261
+ const mockConfig = {
262
+ model: 'berget/deepseek-r1',
263
+ apiKey: 'test-api-key',
264
+ projectName: 'testproject',
265
+ provider: 'berget',
266
+ created: '2023-01-01T00:00:00.000Z',
267
+ version: '1.0.0',
268
+ }
269
+
270
+ // Mock file exists and contains config
271
+ mockFs.existsSync.mockReturnValue(true)
272
+ mockFsPromises.readFile.mockResolvedValue(JSON.stringify(mockConfig))
273
+
274
+ // Mock successful opencode check
275
+ mockSpawn.mockImplementation((command: string, args: string[]) => {
276
+ if (command === 'opencode' && args[0] === '--version') {
277
+ return {
278
+ on: vi.fn().mockImplementation((event, callback) => {
279
+ if (event === 'close') callback(0)
280
+ }),
281
+ }
282
+ }
283
+ return { on: vi.fn() }
284
+ })
285
+
286
+ // Verify config structure expectations
287
+ expect(mockConfig.model).toBe('berget/deepseek-r1')
288
+ expect(mockConfig.apiKey).toBe('test-api-key')
289
+ expect(mockConfig.projectName).toBe('testproject')
290
+ })
291
+
292
+ it('should spawn opencode with correct arguments', () => {
293
+ const codeCommand = program.commands.find((cmd) => cmd.name() === 'code')
294
+ const runCommand = codeCommand?.commands.find(
295
+ (cmd) => cmd.name() === 'run',
296
+ )
297
+
298
+ expect(runCommand).toBeDefined()
299
+
300
+ // Should spawn opencode with appropriate arguments
301
+ expect(mockSpawn).toBeDefined()
302
+ })
303
+
304
+ it('should handle missing configuration file', () => {
305
+ const codeCommand = program.commands.find((cmd) => cmd.name() === 'code')
306
+ const runCommand = codeCommand?.commands.find(
307
+ (cmd) => cmd.name() === 'run',
308
+ )
309
+
310
+ expect(runCommand).toBeDefined()
311
+
312
+ // Should check if opencode.json exists
313
+ expect(mockFs.existsSync).toBeDefined()
314
+ })
315
+ })
316
+
317
+ describe('opencode installation', () => {
318
+ it('should check if opencode is installed', () => {
319
+ // The spawn function should be called with opencode --version
320
+ expect(mockSpawn).toBeDefined()
321
+ })
322
+
323
+ it('should offer to install opencode if not found', () => {
324
+ // Mock opencode not installed
325
+ mockSpawn.mockImplementation((command: string, args: string[]) => {
326
+ if (command === 'opencode' && args[0] === '--version') {
327
+ return {
328
+ on: vi.fn().mockImplementation((event, callback) => {
329
+ if (event === 'close') callback(1) // Non-zero exit code
330
+ }),
331
+ }
332
+ }
333
+ return { on: vi.fn() }
334
+ })
335
+
336
+ // Should handle the case where opencode is not installed
337
+ expect(mockSpawn).toBeDefined()
338
+ })
339
+
340
+ it('should install opencode via npm if user agrees', () => {
341
+ // Should spawn npm install -g opencode-ai
342
+ expect(mockSpawn).toBeDefined()
343
+ })
344
+ })
345
+
346
+ describe('automation support', () => {
347
+ it('should support -y flag for automated initialization', () => {
348
+ const codeCommand = program.commands.find((cmd) => cmd.name() === 'code')
349
+ const initCommand = codeCommand?.commands.find(
350
+ (cmd) => cmd.name() === 'init',
351
+ )
352
+
353
+ expect(initCommand).toBeDefined()
354
+
355
+ const yesOption = initCommand?.options.find((opt) => opt.long === '--yes')
356
+ expect(yesOption).toBeDefined()
357
+ expect(yesOption?.description).toContain('automation')
358
+ })
359
+
360
+ it('should support -y flag for automated run', () => {
361
+ const codeCommand = program.commands.find((cmd) => cmd.name() === 'code')
362
+ const runCommand = codeCommand?.commands.find(
363
+ (cmd) => cmd.name() === 'run',
364
+ )
365
+
366
+ expect(runCommand).toBeDefined()
367
+
368
+ const yesOption = runCommand?.options.find((opt) => opt.long === '--yes')
369
+ expect(yesOption).toBeDefined()
370
+ expect(yesOption?.description).toContain('automation')
371
+ })
372
+
373
+ it('should use BERGET_API_KEY environment variable in automation mode', () => {
374
+ // Test that environment variable is used when -y flag is set
375
+ process.env.BERGET_API_KEY = 'test-env-key'
376
+
377
+ expect(process.env.BERGET_API_KEY).toBe('test-env-key')
378
+
379
+ // Clean up
380
+ delete process.env.BERGET_API_KEY
381
+ })
382
+ })
383
+
384
+ describe('.env file handling', () => {
385
+ let mockUpdateEnvFile: any
386
+
387
+ beforeEach(() => {
388
+ mockUpdateEnvFile = vi.mocked(updateEnvFile)
389
+ })
390
+
391
+ it('should call updateEnvFile when creating new project', async () => {
392
+ mockUpdateEnvFile.mockResolvedValue(true)
393
+ mockFs.existsSync.mockReturnValue(false) // .env doesn't exist
394
+ mockFsPromises.writeFile.mockResolvedValue(undefined)
395
+
396
+ // This would be tested by actually calling the init command
397
+ // For now we verify the mock is properly set up
398
+ expect(mockUpdateEnvFile).toBeDefined()
399
+ })
400
+
401
+ it('should not overwrite existing BERGET_API_KEY in .env', async () => {
402
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
403
+
404
+ // Mock existing .env with BERGET_API_KEY
405
+ mockFs.existsSync.mockReturnValue(true)
406
+ mockFs.readFileSync.mockReturnValue(
407
+ 'BERGET_API_KEY=existing_key\nOTHER_KEY=value\n',
408
+ )
409
+
410
+ // Mock updateEnvFile to simulate the check
411
+ mockUpdateEnvFile.mockImplementation(async (options: any) => {
412
+ if (options.key === 'BERGET_API_KEY' && !options.force) {
413
+ console.log(
414
+ `⚠ ${options.key} already exists in .env - leaving unchanged`,
415
+ )
416
+ return false
417
+ }
418
+ return true
419
+ })
420
+
421
+ await updateEnvFile({
422
+ key: 'BERGET_API_KEY',
423
+ value: 'new_key',
424
+ })
425
+
426
+ expect(consoleSpy).toHaveBeenCalledWith(
427
+ expect.stringContaining(
428
+ 'BERGET_API_KEY already exists in .env - leaving unchanged',
429
+ ),
430
+ )
431
+
432
+ consoleSpy.mockRestore()
433
+ })
434
+
435
+ it('should add new key to existing .env file', async () => {
436
+ mockFs.existsSync.mockReturnValue(true)
437
+ mockFs.readFileSync.mockReturnValue('EXISTING_KEY=value\n')
438
+ mockUpdateEnvFile.mockResolvedValue(true)
439
+
440
+ await updateEnvFile({
441
+ key: 'BERGET_API_KEY',
442
+ value: 'new_api_key',
443
+ comment: 'Berget AI Configuration',
444
+ })
445
+
446
+ expect(mockUpdateEnvFile).toHaveBeenCalledWith({
447
+ key: 'BERGET_API_KEY',
448
+ value: 'new_api_key',
449
+ comment: 'Berget AI Configuration',
450
+ })
451
+ })
452
+
453
+ it('should create new .env file when none exists', async () => {
454
+ mockFs.existsSync.mockReturnValue(false)
455
+ mockUpdateEnvFile.mockResolvedValue(true)
456
+
457
+ await updateEnvFile({
458
+ key: 'BERGET_API_KEY',
459
+ value: 'new_api_key',
460
+ })
461
+
462
+ expect(mockUpdateEnvFile).toHaveBeenCalledWith({
463
+ key: 'BERGET_API_KEY',
464
+ value: 'new_api_key',
465
+ })
466
+ })
467
+ })
468
+
469
+ describe('error handling', () => {
470
+ it('should handle API key creation failures', () => {
471
+ // Mock API key service to throw error
472
+ mockApiKeyService.create.mockRejectedValue(new Error('API Error'))
473
+
474
+ expect(mockApiKeyService.create).toBeDefined()
475
+ })
476
+
477
+ it('should handle file system errors', () => {
478
+ // Mock file operations to throw errors
479
+ mockFsPromises.writeFile.mockRejectedValue(new Error('File write error'))
480
+
481
+ expect(mockFsPromises.writeFile).toBeDefined()
482
+ })
483
+
484
+ it('should handle spawn errors', () => {
485
+ // Mock spawn to throw error
486
+ mockSpawn.mockImplementation(() => {
487
+ throw new Error('Command not found')
488
+ })
489
+
490
+ expect(mockSpawn).toBeDefined()
491
+ })
492
+
493
+ it('should handle .env update failures', async () => {
494
+ const mockUpdateEnvFile = vi.mocked(updateEnvFile)
495
+ mockUpdateEnvFile.mockRejectedValue(new Error('Env update failed'))
496
+
497
+ await expect(
498
+ updateEnvFile({
499
+ key: 'TEST_KEY',
500
+ value: 'test_value',
501
+ }),
502
+ ).rejects.toThrow('Env update failed')
503
+ })
504
+ })
505
+ })
@@ -0,0 +1,199 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import fs from 'fs'
3
+ import { writeFile } from 'fs/promises'
4
+ import path from 'path'
5
+ import { updateEnvFile, hasEnvKey } from '../../src/utils/env-manager'
6
+
7
+ vi.mock('fs')
8
+ vi.mock('fs/promises')
9
+ vi.mock('path')
10
+
11
+ const mockFs = vi.mocked(fs)
12
+ const mockWriteFile = vi.mocked(writeFile)
13
+ const mockPath = vi.mocked(path)
14
+
15
+ describe('env-manager', () => {
16
+ const testEnvPath = '/test/.env'
17
+ const testCwd = '/test'
18
+
19
+ beforeEach(() => {
20
+ vi.clearAllMocks()
21
+ mockPath.join.mockReturnValue(testEnvPath)
22
+ vi.spyOn(process, 'cwd').mockReturnValue(testCwd)
23
+ })
24
+
25
+ afterEach(() => {
26
+ vi.restoreAllMocks()
27
+ })
28
+
29
+ describe('updateEnvFile', () => {
30
+ it('should create a new .env file with the key when file does not exist', async () => {
31
+ mockFs.existsSync.mockReturnValue(false)
32
+
33
+ await updateEnvFile({
34
+ key: 'TEST_KEY',
35
+ value: 'test_value',
36
+ comment: 'Test comment',
37
+ })
38
+
39
+ expect(mockFs.existsSync).toHaveBeenCalledWith(testEnvPath)
40
+ expect(mockWriteFile).toHaveBeenCalledWith(
41
+ testEnvPath,
42
+ '# Test comment\nTEST_KEY=test_value\n',
43
+ )
44
+ })
45
+
46
+ it('should append to existing .env file when key does not exist', async () => {
47
+ const existingContent = 'EXISTING_KEY=existing_value\n'
48
+ mockFs.existsSync.mockReturnValue(true)
49
+ mockFs.readFileSync.mockReturnValue(existingContent)
50
+
51
+ await updateEnvFile({
52
+ key: 'NEW_KEY',
53
+ value: 'new_value',
54
+ comment: 'Test comment',
55
+ })
56
+
57
+ expect(mockWriteFile).toHaveBeenCalledWith(
58
+ testEnvPath,
59
+ 'EXISTING_KEY=existing_value\nNEW_KEY=new_value\n',
60
+ )
61
+ })
62
+
63
+ it('should not update when key already exists and force is false', async () => {
64
+ const existingContent =
65
+ 'EXISTING_KEY=existing_value\nTEST_KEY=old_value\n'
66
+ mockFs.existsSync.mockReturnValue(true)
67
+ mockFs.readFileSync.mockReturnValue(existingContent)
68
+
69
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
70
+
71
+ await updateEnvFile({
72
+ key: 'TEST_KEY',
73
+ value: 'new_value',
74
+ })
75
+
76
+ expect(consoleSpy).toHaveBeenCalledWith(
77
+ expect.stringContaining(
78
+ 'TEST_KEY already exists in .env - leaving unchanged',
79
+ ),
80
+ )
81
+ expect(mockWriteFile).not.toHaveBeenCalled()
82
+
83
+ consoleSpy.mockRestore()
84
+ })
85
+
86
+ it('should update existing key when force is true', async () => {
87
+ const existingContent =
88
+ 'EXISTING_KEY=existing_value\nTEST_KEY=old_value\n'
89
+ mockFs.existsSync.mockReturnValue(true)
90
+ mockFs.readFileSync.mockReturnValue(existingContent)
91
+
92
+ await updateEnvFile({
93
+ key: 'TEST_KEY',
94
+ value: 'new_value',
95
+ force: true,
96
+ })
97
+
98
+ expect(mockWriteFile).toHaveBeenCalledWith(
99
+ testEnvPath,
100
+ 'EXISTING_KEY=existing_value\nTEST_KEY=new_value\n',
101
+ )
102
+ })
103
+
104
+ it('should handle complex values with quotes and special characters', async () => {
105
+ mockFs.existsSync.mockReturnValue(false)
106
+
107
+ await updateEnvFile({
108
+ key: 'COMPLEX_KEY',
109
+ value: 'value with "quotes" and $special',
110
+ comment: 'Complex test',
111
+ })
112
+
113
+ expect(mockWriteFile).toHaveBeenCalledWith(
114
+ testEnvPath,
115
+ '# Complex test\nCOMPLEX_KEY=value with "quotes" and $special\n',
116
+ )
117
+ })
118
+
119
+ it('should use custom env path when provided', async () => {
120
+ const customPath = '/custom/.env'
121
+ mockFs.existsSync.mockReturnValue(false)
122
+
123
+ await updateEnvFile({
124
+ envPath: customPath,
125
+ key: 'TEST_KEY',
126
+ value: 'test_value',
127
+ })
128
+
129
+ expect(mockFs.existsSync).toHaveBeenCalledWith(customPath)
130
+ expect(mockWriteFile).toHaveBeenCalledWith(
131
+ customPath,
132
+ 'TEST_KEY=test_value\n',
133
+ )
134
+ })
135
+
136
+ it('should throw error when write fails', async () => {
137
+ mockFs.existsSync.mockReturnValue(false)
138
+ mockWriteFile.mockRejectedValue(new Error('Write error'))
139
+
140
+ await expect(
141
+ updateEnvFile({
142
+ key: 'TEST_KEY',
143
+ value: 'test_value',
144
+ }),
145
+ ).rejects.toThrow('Write error')
146
+ })
147
+ })
148
+
149
+ describe('hasEnvKey', () => {
150
+ it('should return false when .env file does not exist', () => {
151
+ mockFs.existsSync.mockReturnValue(false)
152
+
153
+ const result = hasEnvKey(testEnvPath, 'TEST_KEY')
154
+
155
+ expect(result).toBe(false)
156
+ expect(mockFs.existsSync).toHaveBeenCalledWith(testEnvPath)
157
+ expect(mockFs.readFileSync).not.toHaveBeenCalled()
158
+ })
159
+
160
+ it('should return true when key exists in .env file', () => {
161
+ const existingContent = 'KEY1=value1\nTEST_KEY=test_value\nKEY2=value2\n'
162
+ mockFs.existsSync.mockReturnValue(true)
163
+ mockFs.readFileSync.mockReturnValue(existingContent)
164
+
165
+ const result = hasEnvKey(testEnvPath, 'TEST_KEY')
166
+
167
+ expect(result).toBe(true)
168
+ })
169
+
170
+ it('should return false when key does not exist in .env file', () => {
171
+ const existingContent = 'KEY1=value1\nKEY2=value2\n'
172
+ mockFs.existsSync.mockReturnValue(true)
173
+ mockFs.readFileSync.mockReturnValue(existingContent)
174
+
175
+ const result = hasEnvKey(testEnvPath, 'TEST_KEY')
176
+
177
+ expect(result).toBe(false)
178
+ })
179
+
180
+ it('should return false when .env file is malformed', () => {
181
+ mockFs.existsSync.mockReturnValue(true)
182
+ mockFs.readFileSync.mockImplementation(() => {
183
+ throw new Error('Read error')
184
+ })
185
+
186
+ const result = hasEnvKey(testEnvPath, 'TEST_KEY')
187
+
188
+ expect(result).toBe(false)
189
+ })
190
+
191
+ it('should use default path when not provided', () => {
192
+ mockFs.existsSync.mockReturnValue(false)
193
+
194
+ hasEnvKey(undefined, 'TEST_KEY')
195
+
196
+ expect(mockFs.existsSync).toHaveBeenCalledWith(testEnvPath)
197
+ })
198
+ })
199
+ })