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,158 +1,493 @@
1
1
  import fs from 'fs';
2
- import os from 'os';
3
- import path from 'path';
4
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
-
6
- import { loadConfig } from '../../../src/utils/config-loader.js';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import { loadConfig, setupConfig } from '../../../src/utils/config-loader.js';
7
+
8
+ // Create shared mock interface for readline
9
+ const mockQuestion = vi.fn();
10
+ const mockClose = vi.fn();
11
+ const mockOn = vi.fn();
12
+
13
+ const mockRlInterface = {
14
+ question: mockQuestion,
15
+ close: mockClose,
16
+ on: mockOn,
17
+ write: vi.fn(),
18
+ };
19
+
20
+ // Mock readline at module level
21
+ vi.mock('readline', () => ({
22
+ default: {
23
+ createInterface: vi.fn(() => mockRlInterface),
24
+ },
25
+ }));
7
26
 
8
27
  describe('config-loader', () => {
9
- let testDir: string;
28
+ describe('loadConfig', () => {
29
+ let testConfigDir: string;
30
+ let homedirSpy: vi.SpyInstance;
10
31
 
11
- beforeEach(() => {
12
- // Create a temporary directory for test configs
13
- testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bbk-cli-test-'));
14
- fs.mkdirSync(path.join(testDir, '.claude'));
15
- });
32
+ beforeEach(() => {
33
+ // Create a temporary directory for test configs
34
+ testConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bbk-cli-test-'));
16
35
 
17
- afterEach(() => {
18
- // Clean up test directory
19
- fs.rmSync(testDir, { recursive: true, force: true });
20
- });
36
+ // Spy on os.homedir() to return test directory (works on all platforms)
37
+ homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(testConfigDir);
38
+ });
21
39
 
22
- describe('loadConfig', () => {
23
- it('should load valid Bitbucket configuration file', () => {
24
- const configContent = `---
25
- profiles:
26
- cloud:
27
- email: user@example.com
28
- apiToken: app_token_here
29
- staging:
30
- email: staging@example.com
31
- apiToken: staging_token_here
32
-
33
- defaultProfile: cloud
34
- defaultFormat: json
35
- ---
36
-
37
- # Bitbucket Connection Profiles
38
- `;
40
+ afterEach(() => {
41
+ // Clean up test directory
42
+ if (testConfigDir) {
43
+ fs.rmSync(testConfigDir, { recursive: true, force: true });
44
+ }
39
45
 
40
- const configPath = path.join(testDir, '.claude', 'bitbucket-config.local.md');
41
- fs.writeFileSync(configPath, configContent);
46
+ // Restore original os.homedir()
47
+ homedirSpy.mockRestore();
42
48
 
43
- const config = loadConfig(testDir);
49
+ // Clear mock calls
50
+ vi.clearAllMocks();
51
+ });
44
52
 
45
- expect(config.profiles).toBeDefined();
46
- expect(config.profiles.cloud).toBeDefined();
47
- expect(config.profiles.cloud.email).toBe('user@example.com');
48
- expect(config.profiles.cloud.apiToken).toBe('app_token_here');
53
+ it('should load valid INI configuration file', async () => {
54
+ const configContent = `[auth]
55
+ email=user@example.com
56
+ api_token=app_token_here
49
57
 
50
- expect(config.profiles.staging).toBeDefined();
51
- expect(config.profiles.staging.apiToken).toBe('staging_token_here');
58
+ [defaults]
59
+ workspace=myworkspace
60
+ format=json
61
+ `;
52
62
 
53
- expect(config.defaultProfile).toBe('cloud');
54
- expect(config.defaultFormat).toBe('json');
55
- });
63
+ const configPath = path.join(testConfigDir, '.bbkcli');
64
+ fs.writeFileSync(configPath, configContent);
56
65
 
57
- it('should throw error if config file does not exist', () => {
58
- expect(() => loadConfig(testDir)).toThrow('Configuration file not found');
66
+ const config = await loadConfig();
67
+
68
+ expect(config.email).toBe('user@example.com');
69
+ expect(config.apiToken).toBe('app_token_here');
70
+ expect(config.defaultWorkspace).toBe('myworkspace');
71
+ expect(config.defaultFormat).toBe('json');
59
72
  });
60
73
 
61
- it('should throw error if frontmatter is missing', () => {
62
- const configContent = `# Bitbucket Connection Profiles
74
+ it('should load config without optional default workspace', async () => {
75
+ const configContent = `[auth]
76
+ email=user@example.com
77
+ api_token=app_token_here
63
78
 
64
- This is just markdown content without frontmatter.
79
+ [defaults]
80
+ format=json
65
81
  `;
66
82
 
67
- const configPath = path.join(testDir, '.claude', 'bitbucket-config.local.md');
83
+ const configPath = path.join(testConfigDir, '.bbkcli');
68
84
  fs.writeFileSync(configPath, configContent);
69
85
 
70
- expect(() => loadConfig(testDir)).toThrow('Invalid configuration file format');
86
+ const config = await loadConfig();
87
+
88
+ expect(config.email).toBe('user@example.com');
89
+ expect(config.apiToken).toBe('app_token_here');
90
+ expect(config.defaultWorkspace).toBeUndefined();
91
+ expect(config.defaultFormat).toBe('json');
71
92
  });
72
93
 
73
- it('should throw error if profiles are missing', () => {
74
- const configContent = `---
75
- defaultProfile: cloud
76
- ---
94
+ it('should use json as default format if not specified', async () => {
95
+ const configContent = `[auth]
96
+ email=user@example.com
97
+ api_token=app_token_here
77
98
  `;
78
99
 
79
- const configPath = path.join(testDir, '.claude', 'bitbucket-config.local.md');
100
+ const configPath = path.join(testConfigDir, '.bbkcli');
80
101
  fs.writeFileSync(configPath, configContent);
81
102
 
82
- expect(() => loadConfig(testDir)).toThrow('Configuration must include "profiles" object');
103
+ const config = await loadConfig();
104
+
105
+ expect(config.defaultFormat).toBe('json');
83
106
  });
84
107
 
85
- it('should throw error if profile is missing required auth fields', () => {
86
- const configContent = `---
87
- profiles:
88
- incomplete:
89
- email: test@example.com
90
- # Missing apiToken
91
- ---
108
+ it('should support all output formats: json, toon', async () => {
109
+ const formats: Array<'json' | 'toon'> = ['json', 'toon'];
110
+
111
+ for (const format of formats) {
112
+ const configContent = `[auth]
113
+ email=user@example.com
114
+ api_token=app_token_here
115
+
116
+ [defaults]
117
+ format=${format}
92
118
  `;
93
119
 
94
- const configPath = path.join(testDir, '.claude', 'bitbucket-config.local.md');
95
- fs.writeFileSync(configPath, configContent);
120
+ const configPath = path.join(testConfigDir, '.bbkcli');
121
+ fs.writeFileSync(configPath, configContent);
122
+
123
+ const config = await loadConfig();
124
+ expect(config.defaultFormat).toBe(format);
96
125
 
97
- expect(() => loadConfig(testDir)).toThrow('must have both "email" and "apiToken"');
126
+ // Clean up for next iteration
127
+ fs.rmSync(configPath);
128
+ }
98
129
  });
99
130
 
100
- it('should use first profile as default if defaultProfile not specified', () => {
101
- const configContent = `---
102
- profiles:
103
- first:
104
- email: first@example.com
105
- apiToken: first_token
106
- second:
107
- email: second@example.com
108
- apiToken: second_token
109
- ---
131
+ it('should handle comments and empty lines in config file', async () => {
132
+ const configContent = `# This is a comment
133
+ [auth]
134
+ email=user@example.com
135
+ api_token=app_token_here
136
+
137
+ # Another comment
138
+
139
+ [defaults]
140
+ workspace=myworkspace
141
+
142
+ # format comment
143
+ format=json
110
144
  `;
111
145
 
112
- const configPath = path.join(testDir, '.claude', 'bitbucket-config.local.md');
146
+ const configPath = path.join(testConfigDir, '.bbkcli');
113
147
  fs.writeFileSync(configPath, configContent);
114
148
 
115
- const config = loadConfig(testDir);
149
+ const config = await loadConfig();
150
+
151
+ expect(config.email).toBe('user@example.com');
152
+ expect(config.apiToken).toBe('app_token_here');
153
+ expect(config.defaultWorkspace).toBe('myworkspace');
154
+ expect(config.defaultFormat).toBe('json');
155
+ });
156
+ });
157
+
158
+ describe('setupConfig', () => {
159
+ let testConfigDir: string;
160
+ let homedirSpy: vi.SpyInstance;
161
+
162
+ beforeEach(() => {
163
+ testConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bbk-cli-test-'));
164
+
165
+ // Spy on os.homedir() to return test directory (works on all platforms)
166
+ homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(testConfigDir);
167
+ });
168
+
169
+ afterEach(() => {
170
+ if (testConfigDir) {
171
+ fs.rmSync(testConfigDir, { recursive: true, force: true });
172
+ }
173
+
174
+ // Restore original os.homedir()
175
+ homedirSpy.mockRestore();
176
+
177
+ vi.clearAllMocks();
178
+ });
179
+
180
+ it('should create config file with all fields', async () => {
181
+ // Mock readline to provide all inputs
182
+ mockQuestion
183
+ .mockImplementationOnce((_, callback) => callback('user@example.com'))
184
+ .mockImplementationOnce((_, callback) => callback('test_token'))
185
+ .mockImplementationOnce((_, callback) => callback('myworkspace'))
186
+ .mockImplementationOnce((_, callback) => callback('toon')); // Use non-default format
187
+
188
+ await setupConfig();
189
+
190
+ const configPath = path.join(testConfigDir, '.bbkcli');
191
+ expect(fs.existsSync(configPath)).toBe(true);
192
+
193
+ const content = fs.readFileSync(configPath, 'utf-8');
194
+ expect(content).toContain('[auth]');
195
+ expect(content).toContain('email=user@example.com');
196
+ expect(content).toContain('api_token=test_token');
197
+ expect(content).toContain('[defaults]');
198
+ expect(content).toContain('workspace=myworkspace');
199
+ expect(content).toContain('format=toon');
200
+
201
+ // Check file permissions (0o600 = read/write for owner only)
202
+ // Note: Windows doesn't support Unix-style permissions, so we skip the check
203
+ if (process.platform !== 'win32') {
204
+ const stats = fs.statSync(configPath);
205
+ const mode = stats.mode & 0o777;
206
+ expect(mode).toBe(0o600);
207
+ }
208
+ });
209
+
210
+ it('should create minimal config file with only required fields', async () => {
211
+ mockQuestion
212
+ .mockImplementationOnce((_, callback) => callback('minimal@example.com'))
213
+ .mockImplementationOnce((_, callback) => callback('minimal_token'))
214
+ .mockImplementationOnce((_, callback) => callback('')) // empty workspace
215
+ .mockImplementationOnce((_, callback) => callback('')); // empty format (defaults to json)
216
+
217
+ await setupConfig();
218
+
219
+ const configPath = path.join(testConfigDir, '.bbkcli');
220
+ const content = fs.readFileSync(configPath, 'utf-8');
221
+
222
+ expect(content).toContain('[auth]');
223
+ expect(content).toContain('email=minimal@example.com');
224
+ expect(content).toContain('api_token=minimal_token');
225
+ // Should not include defaults section when both are empty
226
+ expect(content).not.toContain('[defaults]');
227
+ });
228
+
229
+ it('should reject invalid email format and re-prompt', async () => {
230
+ const emailInputs = ['invalid-email', 'still-invalid', 'valid@example.com'];
231
+ let emailInputIndex = 0;
232
+
233
+ mockQuestion.mockImplementation((prompt, callback) => {
234
+ if (prompt.includes('email:')) {
235
+ callback(emailInputs[emailInputIndex++]);
236
+ } else if (prompt.includes('api_token:')) {
237
+ callback('token');
238
+ } else if (prompt.includes('workspace:')) {
239
+ callback('');
240
+ } else if (prompt.includes('format:')) {
241
+ callback('');
242
+ }
243
+ });
244
+
245
+ await setupConfig();
116
246
 
117
- expect(config.defaultProfile).toBe('first');
247
+ const configPath = path.join(testConfigDir, '.bbkcli');
248
+ const content = fs.readFileSync(configPath, 'utf-8');
249
+ expect(content).toContain('email=valid@example.com');
118
250
  });
119
251
 
120
- it('should use json as default format if not specified', () => {
121
- const configContent = `---
122
- profiles:
123
- cloud:
124
- email: user@example.com
125
- apiToken: token_here
126
- ---
252
+ it('should reject empty email and re-prompt', async () => {
253
+ const emailInputs = ['', 'test@example.com'];
254
+ let emailInputIndex = 0;
255
+
256
+ mockQuestion.mockImplementation((prompt, callback) => {
257
+ if (prompt.includes('email:')) {
258
+ callback(emailInputs[emailInputIndex++]);
259
+ } else if (prompt.includes('api_token:')) {
260
+ callback('token');
261
+ } else if (prompt.includes('workspace:')) {
262
+ callback('');
263
+ } else if (prompt.includes('format:')) {
264
+ callback('');
265
+ }
266
+ });
267
+
268
+ await setupConfig();
269
+
270
+ const configPath = path.join(testConfigDir, '.bbkcli');
271
+ const content = fs.readFileSync(configPath, 'utf-8');
272
+ expect(content).toContain('email=test@example.com');
273
+ });
274
+
275
+ it('should reject empty api_token and re-prompt', async () => {
276
+ const tokenInputs = ['', 'real_token'];
277
+ let tokenInputIndex = 0;
278
+
279
+ mockQuestion.mockImplementation((prompt, callback) => {
280
+ if (prompt.includes('email:')) {
281
+ callback('test@example.com');
282
+ } else if (prompt.includes('api_token:')) {
283
+ callback(tokenInputs[tokenInputIndex++]);
284
+ } else if (prompt.includes('workspace:')) {
285
+ callback('');
286
+ } else if (prompt.includes('format:')) {
287
+ callback('');
288
+ }
289
+ });
290
+
291
+ await setupConfig();
292
+
293
+ const configPath = path.join(testConfigDir, '.bbkcli');
294
+ const content = fs.readFileSync(configPath, 'utf-8');
295
+ expect(content).toContain('api_token=real_token');
296
+ });
297
+
298
+ it('should reject invalid format and re-prompt', async () => {
299
+ const formatInputs = ['xml', 'toon']; // Use non-default format
300
+ let formatInputIndex = 0;
301
+
302
+ mockQuestion.mockImplementation((prompt, callback) => {
303
+ if (prompt.includes('email:')) {
304
+ callback('test@example.com');
305
+ } else if (prompt.includes('api_token:')) {
306
+ callback('token');
307
+ } else if (prompt.includes('workspace:')) {
308
+ callback('ws');
309
+ } else if (prompt.includes('format:')) {
310
+ callback(formatInputs[formatInputIndex++]);
311
+ }
312
+ });
313
+
314
+ await setupConfig();
315
+
316
+ const configPath = path.join(testConfigDir, '.bbkcli');
317
+ const content = fs.readFileSync(configPath, 'utf-8');
318
+ expect(content).toContain('format=toon');
319
+ });
320
+
321
+ it('should handle write permission errors gracefully', async () => {
322
+ // Create a directory instead of a file to cause write error
323
+ const existingPath = path.join(testConfigDir, '.bbkcli');
324
+ fs.mkdirSync(existingPath);
325
+
326
+ mockQuestion
327
+ .mockImplementationOnce((_, callback) => callback('test@example.com'))
328
+ .mockImplementationOnce((_, callback) => callback('token'))
329
+ .mockImplementationOnce((_, callback) => callback('ws'))
330
+ .mockImplementationOnce((_, callback) => callback('json'));
331
+
332
+ await expect(setupConfig()).rejects.toThrow('Cannot write config');
333
+ });
334
+
335
+ it('should include defaults section when only format is specified', async () => {
336
+ mockQuestion
337
+ .mockImplementationOnce((_, callback) => callback('test@example.com'))
338
+ .mockImplementationOnce((_, callback) => callback('token'))
339
+ .mockImplementationOnce((_, callback) => callback('')) // empty workspace
340
+ .mockImplementationOnce((_, callback) => callback('toon')); // non-default format
341
+
342
+ await setupConfig();
343
+
344
+ const configPath = path.join(testConfigDir, '.bbkcli');
345
+ const content = fs.readFileSync(configPath, 'utf-8');
346
+
347
+ expect(content).toContain('[defaults]');
348
+ expect(content).toContain('format=toon');
349
+ expect(content).not.toContain('workspace=');
350
+ });
351
+
352
+ it('should include defaults section when only workspace is specified', async () => {
353
+ mockQuestion
354
+ .mockImplementationOnce((_, callback) => callback('test@example.com'))
355
+ .mockImplementationOnce((_, callback) => callback('token'))
356
+ .mockImplementationOnce((_, callback) => callback('myworkspace'))
357
+ .mockImplementationOnce((_, callback) => callback('')); // empty format (defaults to json)
358
+
359
+ await setupConfig();
360
+
361
+ const configPath = path.join(testConfigDir, '.bbkcli');
362
+ const content = fs.readFileSync(configPath, 'utf-8');
363
+
364
+ expect(content).toContain('[defaults]');
365
+ expect(content).toContain('workspace=myworkspace');
366
+ expect(content).not.toContain('format=');
367
+ });
368
+
369
+ it.skipIf(process.platform === 'win32')('should set secure file permissions (0o600)', async () => {
370
+ mockQuestion
371
+ .mockImplementationOnce((_, callback) => callback('secure@example.com'))
372
+ .mockImplementationOnce((_, callback) => callback('secure_token'))
373
+ .mockImplementationOnce((_, callback) => callback('workspace'))
374
+ .mockImplementationOnce((_, callback) => callback('toon'));
375
+
376
+ await setupConfig();
377
+
378
+ const configPath = path.join(testConfigDir, '.bbkcli');
379
+ const stats = fs.statSync(configPath);
380
+ const mode = stats.mode & 0o777;
381
+
382
+ // 0o600 = read/write for owner only (rw-------)
383
+ expect(mode).toBe(0o600);
384
+ });
385
+
386
+ it('should pre-populate existing config values when user presses Enter for all fields', async () => {
387
+ // Create existing config
388
+ const existingConfig = `[auth]
389
+ email=old@example.com
390
+ api_token=old_token_123
391
+
392
+ [defaults]
393
+ workspace=old_workspace
394
+ format=toon
127
395
  `;
396
+ const configPath = path.join(testConfigDir, '.bbkcli');
397
+ fs.writeFileSync(configPath, existingConfig);
398
+
399
+ // User presses Enter for all prompts (accepts existing values)
400
+ mockQuestion
401
+ .mockImplementationOnce((_, callback) => callback('')) // keep email
402
+ .mockImplementationOnce((_, callback) => callback('')) // keep token
403
+ .mockImplementationOnce((_, callback) => callback('')) // keep workspace
404
+ .mockImplementationOnce((_, callback) => callback('')); // keep format
405
+
406
+ await setupConfig();
407
+
408
+ const content = fs.readFileSync(configPath, 'utf-8');
409
+ expect(content).toContain('email=old@example.com');
410
+ expect(content).toContain('api_token=old_token_123');
411
+ expect(content).toContain('workspace=old_workspace');
412
+ expect(content).toContain('format=toon');
413
+ });
128
414
 
129
- const configPath = path.join(testDir, '.claude', 'bitbucket-config.local.md');
130
- fs.writeFileSync(configPath, configContent);
415
+ it('should pre-populate and update individual fields while keeping others', async () => {
416
+ // Create existing config with toon format (non-default)
417
+ const existingConfig = `[auth]
418
+ email=old@example.com
419
+ api_token=old_token_123
131
420
 
132
- const config = loadConfig(testDir);
421
+ [defaults]
422
+ workspace=old_workspace
423
+ format=toon
424
+ `;
425
+ const configPath = path.join(testConfigDir, '.bbkcli');
426
+ fs.writeFileSync(configPath, existingConfig);
427
+
428
+ // User updates email and workspace but keeps token and format
429
+ mockQuestion
430
+ .mockImplementationOnce((_, callback) => callback('new@example.com')) // new email
431
+ .mockImplementationOnce((_, callback) => callback('')) // keep token
432
+ .mockImplementationOnce((_, callback) => callback('new_workspace')) // new workspace
433
+ .mockImplementationOnce((_, callback) => callback('')); // keep format
434
+
435
+ await setupConfig();
436
+
437
+ const content = fs.readFileSync(configPath, 'utf-8');
438
+ expect(content).toContain('email=new@example.com');
439
+ expect(content).toContain('api_token=old_token_123');
440
+ expect(content).toContain('workspace=new_workspace');
441
+ expect(content).toContain('format=toon');
442
+ });
133
443
 
134
- expect(config.defaultFormat).toBe('json');
444
+ it('should handle corrupted existing config file gracefully', async () => {
445
+ // Create a corrupted existing config
446
+ const configPath = path.join(testConfigDir, '.bbkcli');
447
+ fs.writeFileSync(configPath, 'this is not valid INI at all {{{');
448
+
449
+ const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
450
+
451
+ // User provides new values
452
+ mockQuestion
453
+ .mockImplementationOnce((_, callback) => callback('fresh@example.com'))
454
+ .mockImplementationOnce((_, callback) => callback('fresh_token'))
455
+ .mockImplementationOnce((_, callback) => callback('fresh_workspace'))
456
+ .mockImplementationOnce((_, callback) => callback('json'));
457
+
458
+ await setupConfig();
459
+
460
+ const content = fs.readFileSync(configPath, 'utf-8');
461
+ expect(content).toContain('email=fresh@example.com');
462
+ expect(content).toContain('api_token=fresh_token');
463
+
464
+ consoleWarnSpy.mockRestore();
135
465
  });
136
466
 
137
- it('should support all output formats: json, toon', () => {
138
- const formats: Array<'json' | 'toon'> = ['json', 'toon'];
467
+ it('should preserve api_token when user deletes asterisks and presses Enter', async () => {
468
+ // Create existing config
469
+ const existingConfig = `[auth]
470
+ email=test@example.com
471
+ api_token=existing_token
139
472
 
140
- formats.forEach(format => {
141
- const configContent = `---
142
- profiles:
143
- cloud:
144
- email: user@example.com
145
- apiToken: token_here
146
- defaultFormat: ${format}
147
- ---
473
+ [defaults]
474
+ format=json
148
475
  `;
476
+ const configPath = path.join(testConfigDir, '.bbkcli');
477
+ fs.writeFileSync(configPath, existingConfig);
149
478
 
150
- const configPath = path.join(testDir, '.claude', 'bitbucket-config.local.md');
151
- fs.writeFileSync(configPath, configContent);
479
+ // User keeps email, deletes all asterisks (backspaces) and presses Enter to keep token
480
+ mockQuestion
481
+ .mockImplementationOnce((_, callback) => callback('')) // keep email
482
+ .mockImplementationOnce((_, callback) => callback('')) // deleted asterisks, keep token
483
+ .mockImplementationOnce((_, callback) => callback('')) // no workspace
484
+ .mockImplementationOnce((_, callback) => callback('')); // keep format
152
485
 
153
- const config = loadConfig(testDir);
154
- expect(config.defaultFormat).toBe(format);
155
- });
486
+ await setupConfig();
487
+
488
+ const content = fs.readFileSync(configPath, 'utf-8');
489
+ expect(content).toContain('email=test@example.com');
490
+ expect(content).toContain('api_token=existing_token');
156
491
  });
157
492
  });
158
493
  });