bbk-cli 1.1.2 → 1.2.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 (39) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +113 -132
  4. package/dist/cli/wrapper.d.ts +0 -1
  5. package/dist/cli/wrapper.d.ts.map +1 -1
  6. package/dist/cli/wrapper.js +19 -54
  7. package/dist/cli/wrapper.js.map +1 -1
  8. package/dist/commands/helpers.js +1 -1
  9. package/dist/commands/runner.d.ts.map +1 -1
  10. package/dist/commands/runner.js +22 -18
  11. package/dist/commands/runner.js.map +1 -1
  12. package/dist/config/constants.d.ts.map +1 -1
  13. package/dist/config/constants.js +37 -52
  14. package/dist/config/constants.js.map +1 -1
  15. package/dist/utils/arg-parser.d.ts.map +1 -1
  16. package/dist/utils/arg-parser.js +23 -8
  17. package/dist/utils/arg-parser.js.map +1 -1
  18. package/dist/utils/bitbucket-client.d.ts +24 -37
  19. package/dist/utils/bitbucket-client.d.ts.map +1 -1
  20. package/dist/utils/bitbucket-client.js +38 -52
  21. package/dist/utils/bitbucket-client.js.map +1 -1
  22. package/dist/utils/bitbucket-utils.d.ts +48 -68
  23. package/dist/utils/bitbucket-utils.d.ts.map +1 -1
  24. package/dist/utils/bitbucket-utils.js +100 -125
  25. package/dist/utils/bitbucket-utils.js.map +1 -1
  26. package/dist/utils/config-loader.d.ts +10 -29
  27. package/dist/utils/config-loader.d.ts.map +1 -1
  28. package/dist/utils/config-loader.js +277 -51
  29. package/dist/utils/config-loader.js.map +1 -1
  30. package/dist/utils/index.d.ts +1 -1
  31. package/dist/utils/index.d.ts.map +1 -1
  32. package/dist/utils/index.js +1 -1
  33. package/dist/utils/index.js.map +1 -1
  34. package/package.json +1 -3
  35. package/tests/integration/cli-integration.test.ts +96 -217
  36. package/tests/unit/cli/wrapper.test.ts +28 -137
  37. package/tests/unit/commands/runner.test.ts +69 -197
  38. package/tests/unit/utils/arg-parser.test.ts +53 -4
  39. package/tests/unit/utils/config-loader.test.ts +441 -106
@@ -1,9 +1,9 @@
1
1
  import fs from 'fs';
2
- import os from 'os';
3
- import path from 'path';
4
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
 
6
- // Mock the Bitbucket API functions
6
+ // Mock the Bitbucket API functions - must be at top level but without external references
7
7
  vi.mock('../../src/utils/bitbucket-client.js', () => ({
8
8
  listRepositories: vi.fn(),
9
9
  getRepository: vi.fn(),
@@ -21,52 +21,37 @@ vi.mock('../../src/utils/bitbucket-client.js', () => ({
21
21
  clearClients: vi.fn(),
22
22
  }));
23
23
 
24
- // Mock helper functions - only where we need to spy on them
25
- vi.mock('../../src/commands/helpers.js', async () => {
26
- const actual = await vi.importActual('../../src/commands/helpers.js');
27
- return {
28
- ...actual,
29
- printAvailableCommands: vi.fn(actual.printAvailableCommands),
30
- printCommandDetail: vi.fn(actual.printCommandDetail),
31
- getCurrentVersion: vi.fn(() => '0.0.0'),
32
- };
33
- });
34
-
35
- // Mock config-loader to spy on loadConfig
36
- vi.mock('../../src/utils/config-loader.js', async () => {
37
- const actual = await vi.importActual('../../src/utils/config-loader.js');
38
- return {
39
- ...actual,
40
- loadConfig: vi.fn(actual.loadConfig),
41
- };
42
- });
24
+ // Mock helper functions
25
+ vi.mock('../../src/commands/helpers.js', () => ({
26
+ printAvailableCommands: vi.fn(),
27
+ printCommandDetail: vi.fn(),
28
+ getCurrentVersion: vi.fn(() => '0.0.0'),
29
+ }));
43
30
 
44
31
  // Integration tests that test the entire flow through multiple modules
45
32
 
46
33
  describe('CLI Integration', () => {
47
- let testDir: string;
34
+ let testConfigDir: string;
35
+ let homedirSpy: vi.SpyInstance;
48
36
  let configPath: string;
49
37
 
50
38
  beforeEach(() => {
51
- testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bbk-cli-integration-'));
52
- fs.mkdirSync(path.join(testDir, '.claude'));
53
- configPath = path.join(testDir, '.claude', 'bitbucket-config.local.md');
54
-
55
- // Write valid config
56
- const configContent = `---
57
- profiles:
58
- cloud:
59
- email: test@test.com
60
- apiToken: test_token_123
61
- staging:
62
- email: staging@test.com
63
- apiToken: staging_token_456
64
-
65
- defaultProfile: cloud
66
- defaultFormat: json
67
- ---
68
-
69
- # Test Config`;
39
+ testConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bbk-cli-integration-'));
40
+
41
+ // Spy on os.homedir() to return test directory (works on all platforms)
42
+ homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(testConfigDir);
43
+
44
+ configPath = path.join(testConfigDir, '.bbkcli');
45
+
46
+ // Write valid INI config
47
+ const configContent = `[auth]
48
+ email=test@test.com
49
+ api_token=test_token_123
50
+
51
+ [defaults]
52
+ workspace=testworkspace
53
+ format=json
54
+ `;
70
55
  fs.writeFileSync(configPath, configContent);
71
56
 
72
57
  // Clear all mocks before each test
@@ -74,59 +59,49 @@ defaultFormat: json
74
59
  });
75
60
 
76
61
  afterEach(() => {
77
- fs.rmSync(testDir, { recursive: true, force: true });
62
+ fs.rmSync(testConfigDir, { recursive: true, force: true });
63
+
64
+ // Restore original os.homedir()
65
+ homedirSpy.mockRestore();
78
66
  });
79
67
 
80
68
  describe('Config Loading Integration', () => {
81
69
  it('should load and parse configuration file', async () => {
82
- process.env.CLAUDE_PROJECT_ROOT = testDir;
83
-
84
70
  const { loadConfig } = await import('../../src/utils/config-loader.js');
85
- const config = await loadConfig(testDir);
71
+ const config = await loadConfig();
86
72
 
87
73
  expect(config).toBeDefined();
88
- expect(config.profiles).toBeDefined();
89
- expect(config.profiles.cloud).toBeDefined();
90
- expect(config.profiles.cloud.email).toBe('test@test.com');
91
- expect(config.profiles.cloud.apiToken).toBe('test_token_123');
92
- expect(config.defaultProfile).toBe('cloud');
74
+ expect(config.email).toBe('test@test.com');
75
+ expect(config.apiToken).toBe('test_token_123');
76
+ expect(config.defaultWorkspace).toBe('testworkspace');
93
77
  expect(config.defaultFormat).toBe('json');
94
78
  });
95
79
 
96
- it('should support multiple profiles', async () => {
97
- const { loadConfig } = await import('../../src/utils/config-loader.js');
98
- const config = await loadConfig(testDir);
99
-
100
- expect(Object.keys(config.profiles)).toHaveLength(2);
101
- expect(config.profiles.cloud).toBeDefined();
102
- expect(config.profiles.staging).toBeDefined();
103
- });
104
-
105
- it('should validate required profile fields', async () => {
106
- const invalidConfig = `---
107
- profiles:
108
- incomplete:
109
- email: test@test.com
110
- # Missing apiToken
111
- ---
80
+ it('should load config without optional fields', async () => {
81
+ // Write minimal config
82
+ const minimalConfig = `[auth]
83
+ email=minimal@test.com
84
+ api_token=minimal_token
112
85
  `;
113
- fs.writeFileSync(configPath, invalidConfig);
86
+ fs.writeFileSync(configPath, minimalConfig);
114
87
 
115
88
  const { loadConfig } = await import('../../src/utils/config-loader.js');
89
+ const config = await loadConfig();
116
90
 
117
- expect(() => loadConfig(testDir)).toThrow('must have both "email" and "apiToken"');
91
+ expect(config.email).toBe('minimal@test.com');
92
+ expect(config.apiToken).toBe('minimal_token');
93
+ expect(config.defaultWorkspace).toBeUndefined();
94
+ expect(config.defaultFormat).toBe('json'); // Default format
118
95
  });
119
96
  });
120
97
 
121
98
  describe('Command Runner Integration', () => {
122
99
  it('should parse command line arguments and execute', async () => {
123
- process.env.CLAUDE_PROJECT_ROOT = testDir;
124
-
125
100
  const { parseArguments } = await import('../../src/utils/arg-parser.js');
126
101
  const { listRepositories } = await import('../../src/utils/bitbucket-client.js');
127
102
 
128
103
  // Mock the Bitbucket API call
129
- listRepositories.mockResolvedValue({
104
+ vi.mocked(listRepositories).mockResolvedValue({
130
105
  success: true,
131
106
  result: JSON.stringify({ repositories: [{ slug: 'my-repo', name: 'My Repository' }] }),
132
107
  });
@@ -212,105 +187,22 @@ profiles:
212
187
  });
213
188
  });
214
189
 
215
- describe('Bitbucket API Integration', () => {
216
- it('should initialize Bitbucket client with profile', async () => {
217
- process.env.CLAUDE_PROJECT_ROOT = testDir;
218
-
219
- const { getBitbucketClientOptions } = await import('../../src/utils/config-loader.js');
220
- const { loadConfig } = await import('../../src/utils/config-loader.js');
221
-
222
- const config = await loadConfig(testDir);
223
- const options = getBitbucketClientOptions(config, 'cloud');
224
-
225
- expect(options.auth).toBeDefined();
226
- expect(options.auth?.email).toBe('test@test.com');
227
- expect(options.auth?.apiToken).toBe('test_token_123');
228
- });
229
-
230
- it('should handle different profiles', async () => {
231
- const { getBitbucketClientOptions } = await import('../../src/utils/config-loader.js');
232
- const { loadConfig } = await import('../../src/utils/config-loader.js');
233
-
234
- const config = await loadConfig(testDir);
235
- const cloudOptions = getBitbucketClientOptions(config, 'cloud');
236
- const stagingOptions = getBitbucketClientOptions(config, 'staging');
237
-
238
- expect(cloudOptions.auth?.email).toBe('test@test.com');
239
- expect(stagingOptions.auth?.email).toBe('staging@test.com');
240
- expect(cloudOptions).not.toEqual(stagingOptions);
241
- });
242
- });
243
-
244
- describe('Command Help Integration', () => {
245
- it('should display all available commands', async () => {
246
- const { printAvailableCommands } = await import('../../src/commands/helpers.js');
247
- const { COMMANDS } = await import('../../src/config/constants.js');
248
-
249
- const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
250
-
251
- printAvailableCommands();
252
-
253
- expect(consoleLogSpy).toHaveBeenCalledWith('\nAvailable commands:');
254
- expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining(`1. ${COMMANDS[0]}`));
255
- expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining(`10. ${COMMANDS[9]}`));
256
-
257
- consoleLogSpy.mockRestore();
258
- });
259
-
260
- it('should display detailed help for each command', async () => {
261
- const { printCommandDetail } = await import('../../src/commands/helpers.js');
262
- const { COMMANDS, COMMANDS_INFO } = await import('../../src/config/constants.js');
263
-
264
- const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
265
-
266
- COMMANDS.forEach((command, index) => {
267
- consoleLogSpy.mockClear();
268
- printCommandDetail(command);
269
-
270
- expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining(command));
271
- expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining(COMMANDS_INFO[index]));
272
- });
273
-
274
- consoleLogSpy.mockRestore();
275
- });
276
- });
277
-
278
190
  describe('CLI Wrapper Integration', () => {
279
191
  it('should initialize CLI with config', async () => {
280
- process.env.CLAUDE_PROJECT_ROOT = testDir;
281
-
282
192
  const { wrapper } = await import('../../src/cli/wrapper.js');
283
- const { loadConfig } = await import('../../src/utils/config-loader.js');
284
-
285
- const cli = new wrapper();
286
-
287
- await cli.connect();
288
-
289
- expect(loadConfig).toHaveBeenCalledWith(testDir);
290
- });
291
-
292
- it('should handle profile switching', async () => {
293
- process.env.CLAUDE_PROJECT_ROOT = testDir;
294
193
 
295
- const { wrapper } = await import('../../src/cli/wrapper.js');
296
194
  const cli = new wrapper();
297
195
 
298
196
  await cli.connect();
299
197
 
300
- // Simulate profile switch
301
- const handleCommand = cli['handleCommand'].bind(cli);
302
- const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
303
-
304
- await handleCommand('profile staging');
305
-
306
- expect(consoleLogSpy).toHaveBeenCalledWith('Switched to profile: staging');
307
-
308
- consoleLogSpy.mockRestore();
198
+ // Config should be loaded
199
+ // @ts-expect-error - accessing private property for testing
200
+ expect(cli.config).toBeDefined();
201
+ // @ts-expect-error - accessing private property for testing
202
+ expect(cli.config?.email).toBe('test@test.com');
309
203
  });
310
204
 
311
205
  it('should handle format switching', async () => {
312
- process.env.CLAUDE_PROJECT_ROOT = testDir;
313
-
314
206
  const { wrapper } = await import('../../src/cli/wrapper.js');
315
207
  const cli = new wrapper();
316
208
 
@@ -328,61 +220,26 @@ profiles:
328
220
  });
329
221
 
330
222
  describe('Error Handling Integration', () => {
331
- it('should handle missing config file', async () => {
332
- fs.rmSync(configPath);
333
-
334
- process.env.CLAUDE_PROJECT_ROOT = testDir;
335
-
336
- const { loadConfig } = await import('../../src/utils/config-loader.js');
337
-
338
- expect(() => loadConfig(testDir)).toThrow('Configuration file not found');
339
- });
340
-
341
- it('should handle invalid config format', async () => {
342
- const invalidConfig = `# Invalid Config
343
-
344
- This is just markdown without frontmatter
345
- `;
346
- fs.writeFileSync(configPath, invalidConfig);
347
-
348
- const { loadConfig } = await import('../../src/utils/config-loader.js');
349
-
350
- expect(() => loadConfig(testDir)).toThrow('Invalid configuration file format');
351
- });
352
-
353
- it('should handle missing profile', async () => {
354
- const { getBitbucketClientOptions } = await import('../../src/utils/config-loader.js');
355
- const { loadConfig } = await import('../../src/utils/config-loader.js');
356
-
357
- const config = await loadConfig(testDir);
358
-
359
- expect(() => getBitbucketClientOptions(config, 'nonexistent')).toThrow('Profile "nonexistent" not found');
360
- });
361
-
362
223
  it('should handle invalid email format', async () => {
363
- const invalidConfig = `---
364
- profiles:
365
- invalid:
366
- email: invalid-email
367
- apiToken: token
368
- ---
224
+ // Write config with invalid email
225
+ const invalidConfig = `[auth]
226
+ email=invalid-email
227
+ api_token=test_token
369
228
  `;
370
229
  fs.writeFileSync(configPath, invalidConfig);
371
230
 
372
- const { loadConfig } = await import('../../src/utils/config-loader.js');
231
+ const { loadConfig } = await import('../../src/utils/index.js');
373
232
 
374
- expect(() => loadConfig(testDir)).toThrow('Profile "invalid" has invalid email format: "invalid-email"');
233
+ expect(() => loadConfig()).toThrow(/Invalid email/);
375
234
  });
376
235
  });
377
236
 
378
237
  describe('End-to-End Workflows', () => {
379
238
  it('should execute list-repositories workflow', async () => {
380
- process.env.CLAUDE_PROJECT_ROOT = testDir;
381
-
382
239
  const { runCommand } = await import('../../src/commands/runner.js');
383
240
  const { listRepositories } = await import('../../src/utils/bitbucket-client.js');
384
241
 
385
- listRepositories.mockResolvedValue({
242
+ vi.mocked(listRepositories).mockResolvedValue({
386
243
  success: true,
387
244
  result: JSON.stringify({
388
245
  repositories: [
@@ -403,7 +260,7 @@ profiles:
403
260
  // Expected
404
261
  }
405
262
 
406
- expect(listRepositories).toHaveBeenCalledWith('cloud', 'test-workspace', 'json');
263
+ expect(listRepositories).toHaveBeenCalledWith('test-workspace', 'json');
407
264
  expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('docs-repo'));
408
265
  expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Engineering'));
409
266
 
@@ -412,12 +269,10 @@ profiles:
412
269
  });
413
270
 
414
271
  it('should execute create-issue workflow', async () => {
415
- process.env.CLAUDE_PROJECT_ROOT = testDir;
416
-
417
272
  const { runCommand } = await import('../../src/commands/runner.js');
418
273
  const { createIssue } = await import('../../src/utils/bitbucket-client.js');
419
274
 
420
- createIssue.mockResolvedValue({
275
+ vi.mocked(createIssue).mockResolvedValue({
421
276
  success: true,
422
277
  result: JSON.stringify({
423
278
  id: '12345',
@@ -447,7 +302,6 @@ profiles:
447
302
  }
448
303
 
449
304
  expect(createIssue).toHaveBeenCalledWith(
450
- 'cloud',
451
305
  'test-workspace',
452
306
  'my-repo',
453
307
  'New Issue',
@@ -463,12 +317,10 @@ profiles:
463
317
  });
464
318
 
465
319
  it('should execute get-user workflow', async () => {
466
- process.env.CLAUDE_PROJECT_ROOT = testDir;
467
-
468
320
  const { runCommand } = await import('../../src/commands/runner.js');
469
321
  const { getUser } = await import('../../src/utils/bitbucket-client.js');
470
322
 
471
- getUser.mockResolvedValue({
323
+ vi.mocked(getUser).mockResolvedValue({
472
324
  success: true,
473
325
  result: JSON.stringify({
474
326
  uuid: '{5b10a2844c20165700ede21g}',
@@ -488,7 +340,7 @@ profiles:
488
340
  // Expected
489
341
  }
490
342
 
491
- expect(getUser).toHaveBeenCalledWith('cloud', '5b10a2844c20165700ede21g', 'json');
343
+ expect(getUser).toHaveBeenCalledWith('5b10a2844c20165700ede21g', 'json');
492
344
  expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('John Doe'));
493
345
 
494
346
  exitSpy.mockRestore();
@@ -496,14 +348,12 @@ profiles:
496
348
  });
497
349
 
498
350
  it('should execute test-connection workflow', async () => {
499
- process.env.CLAUDE_PROJECT_ROOT = testDir;
500
-
501
351
  const { runCommand } = await import('../../src/commands/runner.js');
502
352
  const { testConnection } = await import('../../src/utils/bitbucket-client.js');
503
353
 
504
- testConnection.mockResolvedValue({
354
+ vi.mocked(testConnection).mockResolvedValue({
505
355
  success: true,
506
- result: 'Connection successful!\n\nProfile: cloud\nLogged in as: John Doe (johndoe)',
356
+ result: 'Connection successful!\n\nLogged in as: John Doe (johndoe)',
507
357
  });
508
358
 
509
359
  const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
@@ -517,7 +367,7 @@ profiles:
517
367
  // Expected
518
368
  }
519
369
 
520
- expect(testConnection).toHaveBeenCalledWith('cloud');
370
+ expect(testConnection).toHaveBeenCalled();
521
371
  expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Connection successful'));
522
372
  expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('John Doe'));
523
373
 
@@ -525,4 +375,33 @@ profiles:
525
375
  consoleLogSpy.mockRestore();
526
376
  });
527
377
  });
378
+
379
+ describe('Default Workspace Resolution', () => {
380
+ it('should override default workspace when specified', async () => {
381
+ const { runCommand } = await import('../../src/commands/runner.js');
382
+ const { listRepositories } = await import('../../src/utils/bitbucket-client.js');
383
+
384
+ vi.mocked(listRepositories).mockResolvedValue({
385
+ success: true,
386
+ result: JSON.stringify({ repositories: [] }),
387
+ });
388
+
389
+ const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
390
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
391
+ throw new Error('process.exit called');
392
+ });
393
+
394
+ try {
395
+ await runCommand('list-repositories', '{"workspace":"custom-workspace"}', null);
396
+ } catch {
397
+ // Expected
398
+ }
399
+
400
+ // Should use the specified workspace, not the default
401
+ expect(listRepositories).toHaveBeenCalledWith('custom-workspace', 'json');
402
+
403
+ exitSpy.mockRestore();
404
+ consoleLogSpy.mockRestore();
405
+ });
406
+ });
528
407
  });