@vibedx/vibekit 0.1.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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +368 -0
  3. package/assets/config.yml +35 -0
  4. package/assets/default.md +47 -0
  5. package/assets/instructions/README.md +46 -0
  6. package/assets/instructions/claude.md +83 -0
  7. package/assets/instructions/codex.md +19 -0
  8. package/index.js +106 -0
  9. package/package.json +90 -0
  10. package/src/commands/close/index.js +66 -0
  11. package/src/commands/close/index.test.js +235 -0
  12. package/src/commands/get-started/index.js +138 -0
  13. package/src/commands/get-started/index.test.js +246 -0
  14. package/src/commands/init/index.js +51 -0
  15. package/src/commands/init/index.test.js +159 -0
  16. package/src/commands/link/index.js +395 -0
  17. package/src/commands/link/index.test.js +28 -0
  18. package/src/commands/lint/index.js +657 -0
  19. package/src/commands/lint/index.test.js +569 -0
  20. package/src/commands/list/index.js +131 -0
  21. package/src/commands/list/index.test.js +153 -0
  22. package/src/commands/new/index.js +305 -0
  23. package/src/commands/new/index.test.js +256 -0
  24. package/src/commands/refine/index.js +741 -0
  25. package/src/commands/refine/index.test.js +28 -0
  26. package/src/commands/review/index.js +957 -0
  27. package/src/commands/review/index.test.js +193 -0
  28. package/src/commands/start/index.js +180 -0
  29. package/src/commands/start/index.test.js +88 -0
  30. package/src/commands/unlink/index.js +123 -0
  31. package/src/commands/unlink/index.test.js +22 -0
  32. package/src/utils/arrow-select.js +233 -0
  33. package/src/utils/cli.js +489 -0
  34. package/src/utils/cli.test.js +9 -0
  35. package/src/utils/git.js +146 -0
  36. package/src/utils/git.test.js +330 -0
  37. package/src/utils/index.js +193 -0
  38. package/src/utils/index.test.js +375 -0
  39. package/src/utils/prompts.js +47 -0
  40. package/src/utils/prompts.test.js +165 -0
  41. package/src/utils/test-helpers.js +492 -0
  42. package/src/utils/ticket.js +423 -0
  43. package/src/utils/ticket.test.js +190 -0
@@ -0,0 +1,375 @@
1
+ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import {
5
+ createTempDir,
6
+ cleanupTempDir,
7
+ mockProcessCwd,
8
+ createMockVibeProject
9
+ } from './test-helpers.js';
10
+ import {
11
+ getTicketsDir,
12
+ getConfig,
13
+ getNextTicketId,
14
+ createSlug,
15
+ createFullSlug
16
+ } from './index.js';
17
+
18
+ describe('utils/index.js', () => {
19
+ let tempDir;
20
+ let restoreCwd;
21
+
22
+ beforeEach(() => {
23
+ tempDir = createTempDir('utils-test');
24
+ restoreCwd = mockProcessCwd(tempDir);
25
+ });
26
+
27
+ afterEach(() => {
28
+ restoreCwd();
29
+ cleanupTempDir(tempDir);
30
+ });
31
+
32
+ describe('getTicketsDir', () => {
33
+ it('should return default tickets directory when no config exists', () => {
34
+ // Act
35
+ const result = getTicketsDir();
36
+
37
+ // Assert
38
+ expect(result).toBe(path.join(tempDir, '.vibe', 'tickets'));
39
+ });
40
+
41
+ it('should return custom tickets directory from config', () => {
42
+ // Arrange
43
+ createMockVibeProject(tempDir, {
44
+ configData: `project:
45
+ name: Test Project
46
+
47
+ tickets:
48
+ path: custom/tickets
49
+ `
50
+ });
51
+
52
+ // Act
53
+ const result = getTicketsDir();
54
+
55
+ // Assert
56
+ expect(result).toBe(path.join(tempDir, 'custom', 'tickets'));
57
+ });
58
+
59
+ it('should handle relative paths in config', () => {
60
+ // Arrange
61
+ createMockVibeProject(tempDir, {
62
+ configData: `tickets:
63
+ path: ./my-tickets
64
+ `
65
+ });
66
+
67
+ // Act
68
+ const result = getTicketsDir();
69
+
70
+ // Assert
71
+ expect(result).toBe(path.join(tempDir, 'my-tickets'));
72
+ });
73
+
74
+ it('should fallback to default when config path is invalid', () => {
75
+ // Arrange
76
+ createMockVibeProject(tempDir, {
77
+ configData: `tickets:
78
+ path: null
79
+ `
80
+ });
81
+
82
+ // Act
83
+ const result = getTicketsDir();
84
+
85
+ // Assert
86
+ expect(result).toBe(path.join(tempDir, '.vibe', 'tickets'));
87
+ });
88
+ });
89
+
90
+ describe('getConfig', () => {
91
+ it('should return empty object when no config exists', () => {
92
+ // Act
93
+ const result = getConfig();
94
+
95
+ // Assert
96
+ expect(result).toEqual({});
97
+ });
98
+
99
+ it('should return parsed config when file exists', () => {
100
+ // Arrange
101
+ createMockVibeProject(tempDir, {
102
+ configData: `project:
103
+ name: Test Project
104
+ version: 1.0.0
105
+
106
+ tickets:
107
+ priority_options:
108
+ - low
109
+ - medium
110
+ - high
111
+ `
112
+ });
113
+
114
+ // Act
115
+ const result = getConfig();
116
+
117
+ // Assert
118
+ expect(result.project.name).toBe('Test Project');
119
+ expect(result.project.version).toBe('1.0.0');
120
+ expect(result.tickets.priority_options).toContain('medium');
121
+ });
122
+
123
+ it('should handle malformed YAML gracefully', () => {
124
+ // Arrange
125
+ const vibeDir = path.join(tempDir, '.vibe');
126
+ fs.mkdirSync(vibeDir, { recursive: true });
127
+ fs.writeFileSync(path.join(vibeDir, 'config.yml'), 'invalid: yaml: content: [', 'utf-8');
128
+
129
+ // Mock console.error to suppress expected error output
130
+ const originalError = console.error;
131
+ console.error = jest.fn();
132
+
133
+ // Act
134
+ const result = getConfig();
135
+
136
+ // Assert
137
+ expect(result).toEqual({});
138
+ expect(console.error).toHaveBeenCalledWith(
139
+ expect.stringContaining('❌ Error reading config:')
140
+ );
141
+
142
+ // Restore console.error
143
+ console.error = originalError;
144
+ });
145
+
146
+ it('should handle non-object YAML content', () => {
147
+ // Arrange
148
+ const vibeDir = path.join(tempDir, '.vibe');
149
+ fs.mkdirSync(vibeDir, { recursive: true });
150
+ fs.writeFileSync(path.join(vibeDir, 'config.yml'), 'just a string', 'utf-8');
151
+
152
+ // Act
153
+ const result = getConfig();
154
+
155
+ // Assert
156
+ expect(result).toEqual({});
157
+ });
158
+ });
159
+
160
+ describe('getNextTicketId', () => {
161
+ it('should return TKT-001 when no tickets exist', () => {
162
+ // Arrange
163
+ createMockVibeProject(tempDir);
164
+
165
+ // Act
166
+ const result = getNextTicketId();
167
+
168
+ // Assert
169
+ expect(result).toBe('TKT-001');
170
+ });
171
+
172
+ it('should return TKT-001 when tickets directory does not exist', () => {
173
+ // Act
174
+ const result = getNextTicketId();
175
+
176
+ // Assert
177
+ expect(result).toBe('TKT-001');
178
+ });
179
+
180
+ it('should increment from existing tickets', () => {
181
+ // Arrange
182
+ createMockVibeProject(tempDir, {
183
+ withTickets: [
184
+ { id: 'TKT-001', title: 'First ticket' },
185
+ { id: 'TKT-002', title: 'Second ticket' },
186
+ { id: 'TKT-005', title: 'Fifth ticket' }
187
+ ]
188
+ });
189
+
190
+ // Act
191
+ const result = getNextTicketId();
192
+
193
+ // Assert
194
+ expect(result).toBe('TKT-006');
195
+ });
196
+
197
+ it('should handle non-ticket files in directory', () => {
198
+ // Arrange
199
+ const vibeProject = createMockVibeProject(tempDir, {
200
+ withTickets: [{ id: 'TKT-003', title: 'Third ticket' }]
201
+ });
202
+
203
+ // Add non-ticket files
204
+ fs.writeFileSync(path.join(vibeProject.ticketsDir, 'README.md'), 'Some readme', 'utf-8');
205
+ fs.writeFileSync(path.join(vibeProject.ticketsDir, 'notes.txt'), 'Some notes', 'utf-8');
206
+
207
+ // Act
208
+ const result = getNextTicketId();
209
+
210
+ // Assert
211
+ expect(result).toBe('TKT-004');
212
+ });
213
+
214
+ it('should handle malformed ticket filenames', () => {
215
+ // Arrange
216
+ const vibeProject = createMockVibeProject(tempDir, {
217
+ withTickets: [{ id: 'TKT-002', title: 'Valid ticket' }]
218
+ });
219
+
220
+ // Add invalid ticket files
221
+ fs.writeFileSync(path.join(vibeProject.ticketsDir, 'TKT-invalid.md'), 'Invalid', 'utf-8');
222
+ fs.writeFileSync(path.join(vibeProject.ticketsDir, 'TKT-.md'), 'Empty number', 'utf-8');
223
+
224
+ // Act
225
+ const result = getNextTicketId();
226
+
227
+ // Assert
228
+ expect(result).toBe('TKT-003');
229
+ });
230
+ });
231
+
232
+ describe('createSlug', () => {
233
+ it('should create basic slug from title', () => {
234
+ // Act
235
+ const result = createSlug('Fix User Authentication Bug');
236
+
237
+ // Assert
238
+ expect(result).toBe('fix-user-authentication-bug');
239
+ });
240
+
241
+ it('should handle empty or null title', () => {
242
+ // Act & Assert
243
+ expect(createSlug('')).toBe('');
244
+ expect(createSlug(null)).toBe('');
245
+ expect(createSlug(undefined)).toBe('');
246
+ });
247
+
248
+ it('should handle non-string input', () => {
249
+ // Act & Assert
250
+ expect(createSlug(123)).toBe('');
251
+ expect(createSlug({})).toBe('');
252
+ });
253
+
254
+ it('should remove special characters', () => {
255
+ // Act
256
+ const result = createSlug('Add @user #login with $pecial ch@rs!');
257
+
258
+ // Assert
259
+ expect(result).toBe('add-user-login-with-pecial');
260
+ });
261
+
262
+ it('should limit words based on config', () => {
263
+ // Arrange
264
+ const config = {
265
+ tickets: {
266
+ slug: {
267
+ word_limit: 3
268
+ }
269
+ }
270
+ };
271
+
272
+ // Act
273
+ const result = createSlug('This is a very long title with many words', config);
274
+
275
+ // Assert
276
+ expect(result).toBe('this-is-a');
277
+ });
278
+
279
+ it('should limit length based on config', () => {
280
+ // Arrange
281
+ const config = {
282
+ tickets: {
283
+ slug: {
284
+ max_length: 10
285
+ }
286
+ }
287
+ };
288
+
289
+ // Act
290
+ const result = createSlug('Very long title that should be truncated', config);
291
+
292
+ // Assert
293
+ expect(result).toBe('very-long');
294
+ expect(result.length).toBeLessThanOrEqual(10);
295
+ });
296
+
297
+ it('should use default values for invalid config', () => {
298
+ // Arrange
299
+ const config = {
300
+ tickets: {
301
+ slug: {
302
+ max_length: -5,
303
+ word_limit: 0
304
+ }
305
+ }
306
+ };
307
+
308
+ // Act
309
+ const result = createSlug('Test title', config);
310
+
311
+ // Assert
312
+ expect(result).toBe('t'); // Limited by clamped max_length
313
+ });
314
+
315
+ it('should return fallback for empty result', () => {
316
+ // Act
317
+ const result = createSlug('!@#$%^&*()');
318
+
319
+ // Assert
320
+ expect(result).toBe('untitled');
321
+ });
322
+
323
+ it('should handle multiple spaces and trim properly', () => {
324
+ // Act
325
+ const result = createSlug(' Multiple Spaces Between Words ');
326
+
327
+ // Assert
328
+ expect(result).toBe('multiple-spaces-between-words');
329
+ });
330
+ });
331
+
332
+ describe('createFullSlug', () => {
333
+ it('should combine ticket ID and slug', () => {
334
+ // Act
335
+ const result = createFullSlug('TKT-001', 'fix-auth-bug');
336
+
337
+ // Assert
338
+ expect(result).toBe('TKT-001-fix-auth-bug');
339
+ });
340
+
341
+ it('should handle empty ticket ID', () => {
342
+ // Act & Assert
343
+ expect(createFullSlug('', 'test-slug')).toBe('');
344
+ expect(createFullSlug(null, 'test-slug')).toBe('');
345
+ expect(createFullSlug(undefined, 'test-slug')).toBe('');
346
+ });
347
+
348
+ it('should handle empty slug text', () => {
349
+ // Act & Assert
350
+ expect(createFullSlug('TKT-001', '')).toBe('TKT-001');
351
+ expect(createFullSlug('TKT-001', null)).toBe('TKT-001');
352
+ expect(createFullSlug('TKT-001', undefined)).toBe('TKT-001');
353
+ });
354
+
355
+ it('should handle both empty inputs', () => {
356
+ // Act & Assert
357
+ expect(createFullSlug('', '')).toBe('');
358
+ expect(createFullSlug(null, null)).toBe('');
359
+ });
360
+
361
+ it('should trim whitespace from inputs', () => {
362
+ // Act
363
+ const result = createFullSlug(' TKT-001 ', ' test-slug ');
364
+
365
+ // Assert
366
+ expect(result).toBe('TKT-001-test-slug');
367
+ });
368
+
369
+ it('should handle non-string inputs', () => {
370
+ // Act & Assert
371
+ expect(createFullSlug(123, 'slug')).toBe('');
372
+ expect(createFullSlug('TKT-001', 123)).toBe('TKT-001');
373
+ });
374
+ });
375
+ });
@@ -0,0 +1,47 @@
1
+ import { input, select, confirm, spinner } from './cli.js';
2
+
3
+ /**
4
+ * Enhanced prompt with emoji and consistent formatting
5
+ * @param {string} emoji - Emoji to display with the prompt
6
+ * @param {string} message - The prompt message
7
+ * @param {Object} options - Configuration options
8
+ * @param {string|null} options.defaultValue - Default value if user provides no input
9
+ * @param {boolean} options.required - Whether input is required
10
+ * @param {string} options.type - Type of prompt ('input' or 'confirm')
11
+ * @returns {Promise<string|boolean>} User's response
12
+ */
13
+ export async function emojiPrompt(emoji, message, options = {}) {
14
+ const { defaultValue = null, required = false, type = 'input' } = options;
15
+ const enhancedMessage = `${emoji} ${message}`;
16
+
17
+ if (type === 'confirm') {
18
+ return confirm(enhancedMessage, defaultValue);
19
+ }
20
+
21
+ return input(enhancedMessage, { defaultValue, required });
22
+ }
23
+
24
+ /**
25
+ * Confirmation prompt with emoji
26
+ * @param {string} emoji - Emoji to display
27
+ * @param {string} message - Confirmation message
28
+ * @param {boolean} defaultValue - Default confirmation value
29
+ * @returns {Promise<boolean>} User's confirmation
30
+ */
31
+ export async function confirmPrompt(emoji, message, defaultValue = true) {
32
+ return emojiPrompt(emoji, message, { type: 'confirm', defaultValue });
33
+ }
34
+
35
+ /**
36
+ * Input prompt with emoji
37
+ * @param {string} emoji - Emoji to display
38
+ * @param {string} message - Input message
39
+ * @param {Object} options - Input options
40
+ * @returns {Promise<string>} User's input
41
+ */
42
+ export async function inputPrompt(emoji, message, options = {}) {
43
+ return emojiPrompt(emoji, message, options);
44
+ }
45
+
46
+ // Re-export core CLI functions for convenience
47
+ export { input, select, confirm, spinner };
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect, jest } from '@jest/globals';
2
+
3
+ // Mock the CLI module
4
+ jest.unstable_mockModule('./cli.js', () => ({
5
+ input: jest.fn(),
6
+ select: jest.fn(),
7
+ confirm: jest.fn(),
8
+ spinner: jest.fn()
9
+ }));
10
+
11
+ describe('prompts utilities', () => {
12
+ let mockCli;
13
+
14
+ beforeEach(async () => {
15
+ // Import the mocked CLI module
16
+ mockCli = await import('./cli.js');
17
+
18
+ // Reset all mocks
19
+ Object.values(mockCli).forEach(mock => mock.mockClear());
20
+ });
21
+
22
+ describe('emojiPrompt', () => {
23
+ it('should call input with emoji-enhanced message', async () => {
24
+ // Arrange
25
+ mockCli.input.mockResolvedValue('test response');
26
+ const { emojiPrompt } = await import('./prompts.js');
27
+
28
+ // Act
29
+ const result = await emojiPrompt('🎯', 'What is your goal?', { defaultValue: 'none' });
30
+
31
+ // Assert
32
+ expect(mockCli.input).toHaveBeenCalledWith('🎯 What is your goal?', { defaultValue: 'none', required: false });
33
+ expect(result).toBe('test response');
34
+ });
35
+
36
+ it('should call confirm when type is confirm', async () => {
37
+ // Arrange
38
+ mockCli.confirm.mockResolvedValue(true);
39
+ const { emojiPrompt } = await import('./prompts.js');
40
+
41
+ // Act
42
+ const result = await emojiPrompt('❓', 'Are you sure?', { type: 'confirm', defaultValue: false });
43
+
44
+ // Assert
45
+ expect(mockCli.confirm).toHaveBeenCalledWith('❓ Are you sure?', false);
46
+ expect(result).toBe(true);
47
+ });
48
+
49
+ it('should handle required input option', async () => {
50
+ // Arrange
51
+ mockCli.input.mockResolvedValue('required response');
52
+ const { emojiPrompt } = await import('./prompts.js');
53
+
54
+ // Act
55
+ const result = await emojiPrompt('⚠️', 'Required field:', { required: true });
56
+
57
+ // Assert
58
+ expect(mockCli.input).toHaveBeenCalledWith('⚠️ Required field:', { defaultValue: null, required: true });
59
+ expect(result).toBe('required response');
60
+ });
61
+
62
+ it('should use default options when none provided', async () => {
63
+ // Arrange
64
+ mockCli.input.mockResolvedValue('default response');
65
+ const { emojiPrompt } = await import('./prompts.js');
66
+
67
+ // Act
68
+ const result = await emojiPrompt('📝', 'Enter text:');
69
+
70
+ // Assert
71
+ expect(mockCli.input).toHaveBeenCalledWith('📝 Enter text:', { defaultValue: null, required: false });
72
+ expect(result).toBe('default response');
73
+ });
74
+ });
75
+
76
+ describe('confirmPrompt', () => {
77
+ it('should call emojiPrompt with confirm type', async () => {
78
+ // Arrange
79
+ mockCli.confirm.mockResolvedValue(true);
80
+ const { confirmPrompt } = await import('./prompts.js');
81
+
82
+ // Act
83
+ const result = await confirmPrompt('🤔', 'Do you want to continue?');
84
+
85
+ // Assert
86
+ expect(mockCli.confirm).toHaveBeenCalledWith('🤔 Do you want to continue?', true);
87
+ expect(result).toBe(true);
88
+ });
89
+
90
+ it('should use custom default value', async () => {
91
+ // Arrange
92
+ mockCli.confirm.mockResolvedValue(false);
93
+ const { confirmPrompt } = await import('./prompts.js');
94
+
95
+ // Act
96
+ const result = await confirmPrompt('🛑', 'Cancel operation?', false);
97
+
98
+ // Assert
99
+ expect(mockCli.confirm).toHaveBeenCalledWith('🛑 Cancel operation?', false);
100
+ expect(result).toBe(false);
101
+ });
102
+ });
103
+
104
+ describe('inputPrompt', () => {
105
+ it('should call emojiPrompt with input type', async () => {
106
+ // Arrange
107
+ mockCli.input.mockResolvedValue('user input');
108
+ const { inputPrompt } = await import('./prompts.js');
109
+
110
+ // Act
111
+ const result = await inputPrompt('💬', 'Enter your message:', { defaultValue: 'hello' });
112
+
113
+ // Assert
114
+ expect(mockCli.input).toHaveBeenCalledWith('💬 Enter your message:', { defaultValue: 'hello', required: false });
115
+ expect(result).toBe('user input');
116
+ });
117
+
118
+ it('should handle empty options', async () => {
119
+ // Arrange
120
+ mockCli.input.mockResolvedValue('empty options response');
121
+ const { inputPrompt } = await import('./prompts.js');
122
+
123
+ // Act
124
+ const result = await inputPrompt('📋', 'Enter data:');
125
+
126
+ // Assert
127
+ expect(mockCli.input).toHaveBeenCalledWith('📋 Enter data:', { defaultValue: null, required: false });
128
+ expect(result).toBe('empty options response');
129
+ });
130
+ });
131
+
132
+ describe('re-exported functions', () => {
133
+ it('should re-export input function', async () => {
134
+ // Act
135
+ const { input } = await import('./prompts.js');
136
+
137
+ // Assert
138
+ expect(input).toBe(mockCli.input);
139
+ });
140
+
141
+ it('should re-export select function', async () => {
142
+ // Act
143
+ const { select } = await import('./prompts.js');
144
+
145
+ // Assert
146
+ expect(select).toBe(mockCli.select);
147
+ });
148
+
149
+ it('should re-export confirm function', async () => {
150
+ // Act
151
+ const { confirm } = await import('./prompts.js');
152
+
153
+ // Assert
154
+ expect(confirm).toBe(mockCli.confirm);
155
+ });
156
+
157
+ it('should re-export spinner function', async () => {
158
+ // Act
159
+ const { spinner } = await import('./prompts.js');
160
+
161
+ // Assert
162
+ expect(spinner).toBe(mockCli.spinner);
163
+ });
164
+ });
165
+ });