@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.
- package/LICENSE +21 -0
- package/README.md +368 -0
- package/assets/config.yml +35 -0
- package/assets/default.md +47 -0
- package/assets/instructions/README.md +46 -0
- package/assets/instructions/claude.md +83 -0
- package/assets/instructions/codex.md +19 -0
- package/index.js +106 -0
- package/package.json +90 -0
- package/src/commands/close/index.js +66 -0
- package/src/commands/close/index.test.js +235 -0
- package/src/commands/get-started/index.js +138 -0
- package/src/commands/get-started/index.test.js +246 -0
- package/src/commands/init/index.js +51 -0
- package/src/commands/init/index.test.js +159 -0
- package/src/commands/link/index.js +395 -0
- package/src/commands/link/index.test.js +28 -0
- package/src/commands/lint/index.js +657 -0
- package/src/commands/lint/index.test.js +569 -0
- package/src/commands/list/index.js +131 -0
- package/src/commands/list/index.test.js +153 -0
- package/src/commands/new/index.js +305 -0
- package/src/commands/new/index.test.js +256 -0
- package/src/commands/refine/index.js +741 -0
- package/src/commands/refine/index.test.js +28 -0
- package/src/commands/review/index.js +957 -0
- package/src/commands/review/index.test.js +193 -0
- package/src/commands/start/index.js +180 -0
- package/src/commands/start/index.test.js +88 -0
- package/src/commands/unlink/index.js +123 -0
- package/src/commands/unlink/index.test.js +22 -0
- package/src/utils/arrow-select.js +233 -0
- package/src/utils/cli.js +489 -0
- package/src/utils/cli.test.js +9 -0
- package/src/utils/git.js +146 -0
- package/src/utils/git.test.js +330 -0
- package/src/utils/index.js +193 -0
- package/src/utils/index.test.js +375 -0
- package/src/utils/prompts.js +47 -0
- package/src/utils/prompts.test.js +165 -0
- package/src/utils/test-helpers.js +492 -0
- package/src/utils/ticket.js +423 -0
- package/src/utils/ticket.test.js +190 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import {
|
|
5
|
+
createTempDir,
|
|
6
|
+
cleanupTempDir,
|
|
7
|
+
mockConsole,
|
|
8
|
+
mockProcessCwd,
|
|
9
|
+
mockProcessExit,
|
|
10
|
+
createMockVibeProject
|
|
11
|
+
} from '../../utils/test-helpers.js';
|
|
12
|
+
import reviewCommand from './index.js';
|
|
13
|
+
|
|
14
|
+
/** Creates a minimal valid ticket file in the tickets directory */
|
|
15
|
+
function createMockTicket(ticketsDir, id = 'TKT-001', status = 'in_progress') {
|
|
16
|
+
const content = `---
|
|
17
|
+
id: ${id}
|
|
18
|
+
title: Test Ticket
|
|
19
|
+
slug: ${id.toLowerCase()}-test-ticket
|
|
20
|
+
status: ${status}
|
|
21
|
+
priority: medium
|
|
22
|
+
created_at: 2026-01-01T00:00:00.000Z
|
|
23
|
+
updated_at: 2026-01-01T00:00:00.000Z
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Description
|
|
27
|
+
|
|
28
|
+
Test ticket description.
|
|
29
|
+
|
|
30
|
+
## Acceptance Criteria
|
|
31
|
+
|
|
32
|
+
- [ ] Criterion one
|
|
33
|
+
|
|
34
|
+
## Implementation Notes
|
|
35
|
+
|
|
36
|
+
Some notes.
|
|
37
|
+
|
|
38
|
+
## Testing & Test Cases
|
|
39
|
+
|
|
40
|
+
Some tests.
|
|
41
|
+
`;
|
|
42
|
+
fs.writeFileSync(path.join(ticketsDir, `${id}-test-ticket.md`), content, 'utf-8');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Runs a review command and swallows the process.exit() throw */
|
|
46
|
+
async function runReview(args) {
|
|
47
|
+
try {
|
|
48
|
+
await reviewCommand(args);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (!err.message.startsWith('process.exit(')) throw err;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('review command', () => {
|
|
55
|
+
let tempDir;
|
|
56
|
+
let consoleMock;
|
|
57
|
+
let restoreCwd;
|
|
58
|
+
let exitMock;
|
|
59
|
+
let ticketsDir;
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
tempDir = createTempDir('review-test');
|
|
63
|
+
consoleMock = mockConsole();
|
|
64
|
+
restoreCwd = mockProcessCwd(tempDir);
|
|
65
|
+
exitMock = mockProcessExit();
|
|
66
|
+
|
|
67
|
+
const { ticketsDir: td } = createMockVibeProject(tempDir);
|
|
68
|
+
ticketsDir = td;
|
|
69
|
+
createMockTicket(ticketsDir);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
consoleMock.restore();
|
|
74
|
+
restoreCwd();
|
|
75
|
+
exitMock.restore();
|
|
76
|
+
cleanupTempDir(tempDir);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('help', () => {
|
|
80
|
+
it('should display help with --help flag', async () => {
|
|
81
|
+
await reviewCommand(['--help']);
|
|
82
|
+
|
|
83
|
+
expect(consoleMock.logs.log.some(m => m.includes('vibe review'))).toBe(true);
|
|
84
|
+
expect(consoleMock.logs.log.some(m => m.includes('Usage:'))).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should display help with -h flag', async () => {
|
|
88
|
+
await reviewCommand(['-h']);
|
|
89
|
+
|
|
90
|
+
expect(consoleMock.logs.log.some(m => m.includes('vibe review'))).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('clean subcommand', () => {
|
|
95
|
+
it('should report no cache files when cache is empty', async () => {
|
|
96
|
+
await reviewCommand(['clean']);
|
|
97
|
+
|
|
98
|
+
expect(consoleMock.logs.log.some(m => m.includes('No review cache files to clean'))).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should report no cache files for a specific ticket when base cache dir is missing', async () => {
|
|
102
|
+
await reviewCommand(['clean', 'TKT-001']);
|
|
103
|
+
|
|
104
|
+
expect(consoleMock.logs.log.some(m => m.includes('No review cache files to clean'))).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should report no cache files for specific ticket when base cache exists but ticket dir missing', async () => {
|
|
108
|
+
// Create the base cache dir but not the TKT-001 subdirectory
|
|
109
|
+
fs.mkdirSync(path.join(tempDir, '.vibe/.cache/review/logs'), { recursive: true });
|
|
110
|
+
|
|
111
|
+
await reviewCommand(['clean', 'TKT-001']);
|
|
112
|
+
|
|
113
|
+
expect(consoleMock.logs.log.some(m => m.includes('No cache files found for ticket TKT-001'))).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should clean existing cache files for a specific ticket', async () => {
|
|
117
|
+
const cacheDir = path.join(tempDir, '.vibe/.cache/review/logs/TKT-001');
|
|
118
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
119
|
+
fs.writeFileSync(path.join(cacheDir, 'review-2026-01-01T00-00-00.txt'), 'test content');
|
|
120
|
+
|
|
121
|
+
await reviewCommand(['clean', 'TKT-001']);
|
|
122
|
+
|
|
123
|
+
expect(consoleMock.logs.log.some(m => m.includes('Cleaned'))).toBe(true);
|
|
124
|
+
expect(fs.existsSync(cacheDir)).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should normalise a numeric ticket ID for clean', async () => {
|
|
128
|
+
fs.mkdirSync(path.join(tempDir, '.vibe/.cache/review/logs'), { recursive: true });
|
|
129
|
+
|
|
130
|
+
await reviewCommand(['clean', '1']);
|
|
131
|
+
|
|
132
|
+
expect(consoleMock.logs.log.some(m => m.includes('No cache files found for ticket TKT-001'))).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('ticket ID validation', () => {
|
|
137
|
+
it('should reject an invalid ticket ID format', async () => {
|
|
138
|
+
await runReview(['invalid-id']);
|
|
139
|
+
|
|
140
|
+
expect(consoleMock.logs.error.some(m => m.includes('Invalid ticket ID format'))).toBe(true);
|
|
141
|
+
expect(exitMock.exitCalls).toContain(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should reject a ticket that does not exist', async () => {
|
|
145
|
+
await runReview(['TKT-999']);
|
|
146
|
+
|
|
147
|
+
expect(consoleMock.logs.error.some(m => m.includes('Ticket not found: TKT-999'))).toBe(true);
|
|
148
|
+
expect(exitMock.exitCalls).toContain(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should pass validation for an existing TKT-001', async () => {
|
|
152
|
+
await runReview(['TKT-001']);
|
|
153
|
+
|
|
154
|
+
const errors = consoleMock.logs.error.join(' ');
|
|
155
|
+
expect(errors).not.toContain('Invalid ticket ID format');
|
|
156
|
+
expect(errors).not.toContain('Ticket not found');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should accept a numeric shorthand and normalise it', async () => {
|
|
160
|
+
await runReview(['1']);
|
|
161
|
+
|
|
162
|
+
const errors = consoleMock.logs.error.join(' ');
|
|
163
|
+
expect(errors).not.toContain('Invalid ticket ID format');
|
|
164
|
+
expect(errors).not.toContain('Ticket not found');
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('ticket ID normalisation logic', () => {
|
|
169
|
+
const normalize = (input) => {
|
|
170
|
+
if (!input || typeof input !== 'string') return null;
|
|
171
|
+
const s = input.trim().toUpperCase();
|
|
172
|
+
if (!s || s.length > 20) return null;
|
|
173
|
+
if (/^\d+$/.test(s)) {
|
|
174
|
+
const n = parseInt(s, 10);
|
|
175
|
+
if (n < 1 || n > 999) return null;
|
|
176
|
+
return `TKT-${s.padStart(3, '0')}`;
|
|
177
|
+
}
|
|
178
|
+
if (/^TKT-\d{3}$/.test(s)) {
|
|
179
|
+
const n = parseInt(s.substring(4), 10);
|
|
180
|
+
return n >= 1 && n <= 999 ? s : null;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
it('normalises single digit to TKT-001', () => expect(normalize('1')).toBe('TKT-001'));
|
|
186
|
+
it('normalises two digits to TKT-011', () => expect(normalize('11')).toBe('TKT-011'));
|
|
187
|
+
it('normalises three digits to TKT-111', () => expect(normalize('111')).toBe('TKT-111'));
|
|
188
|
+
it('accepts already-valid TKT-XXX', () => expect(normalize('TKT-001')).toBe('TKT-001'));
|
|
189
|
+
it('rejects invalid strings', () => expect(normalize('invalid')).toBeNull());
|
|
190
|
+
it('rejects null input', () => expect(normalize(null)).toBeNull());
|
|
191
|
+
it('rejects out-of-range zero', () => expect(normalize('0')).toBeNull());
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { getTicketsDir, getConfig, createSlug } from '../../utils/index.js';
|
|
5
|
+
import {
|
|
6
|
+
isGitRepository,
|
|
7
|
+
getCurrentBranch,
|
|
8
|
+
branchExistsLocally,
|
|
9
|
+
branchExistsRemotely,
|
|
10
|
+
createAndCheckoutBranch,
|
|
11
|
+
checkoutBranch,
|
|
12
|
+
getGitStatus
|
|
13
|
+
} from '../../utils/git.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Start working on a ticket by checking out its branch
|
|
17
|
+
* @param {string[]} args Command arguments
|
|
18
|
+
*/
|
|
19
|
+
function startCommand(args) {
|
|
20
|
+
// Check if we're in a git repository
|
|
21
|
+
if (!isGitRepository()) {
|
|
22
|
+
console.error('ā Not in a git repository. Please run this command from within a git repository.');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Parse arguments
|
|
27
|
+
if (args.length === 0) {
|
|
28
|
+
console.error('ā Please provide a ticket ID (e.g., vibe start TKT-006)');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Extract ticket ID and options
|
|
33
|
+
let ticketId = args[0];
|
|
34
|
+
let baseBranch = null;
|
|
35
|
+
let updateStatus = true;
|
|
36
|
+
|
|
37
|
+
// Process additional arguments
|
|
38
|
+
for (let i = 1; i < args.length; i++) {
|
|
39
|
+
if (args[i] === '--base' && i + 1 < args.length) {
|
|
40
|
+
baseBranch = args[i + 1];
|
|
41
|
+
i++; // Skip the next argument as it's the base branch
|
|
42
|
+
} else if (args[i] === '--update-status' || args[i] === '-u') {
|
|
43
|
+
updateStatus = true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Normalize ticket ID format (add TKT- prefix if not present)
|
|
48
|
+
if (!ticketId.startsWith('TKT-')) {
|
|
49
|
+
// Check if it's just a number
|
|
50
|
+
if (/^\d+$/.test(ticketId)) {
|
|
51
|
+
ticketId = `TKT-${ticketId.padStart(3, '0')}`;
|
|
52
|
+
} else {
|
|
53
|
+
console.error('ā Invalid ticket ID format. Expected TKT-XXX or just the number.');
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Get configuration
|
|
59
|
+
const config = getConfig();
|
|
60
|
+
const ticketsDir = getTicketsDir();
|
|
61
|
+
|
|
62
|
+
// Check if the ticket exists
|
|
63
|
+
const ticketFiles = fs.readdirSync(ticketsDir).filter(file => file.startsWith(`${ticketId}-`));
|
|
64
|
+
|
|
65
|
+
if (ticketFiles.length === 0) {
|
|
66
|
+
console.error(`ā Ticket ${ticketId} not found.`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ticketFile = ticketFiles[0];
|
|
71
|
+
const ticketPath = path.join(ticketsDir, ticketFile);
|
|
72
|
+
|
|
73
|
+
// Read the ticket content to get the title and slug
|
|
74
|
+
const ticketContent = fs.readFileSync(ticketPath, 'utf-8');
|
|
75
|
+
const titleMatch = ticketContent.match(/title: (.+)/);
|
|
76
|
+
const title = titleMatch ? titleMatch[1].trim() : '';
|
|
77
|
+
|
|
78
|
+
// Look for the slug in the frontmatter
|
|
79
|
+
const slugMatch = ticketContent.match(/slug: (.+)/);
|
|
80
|
+
let slug;
|
|
81
|
+
|
|
82
|
+
if (slugMatch && slugMatch[1].trim()) {
|
|
83
|
+
// Use the slug from the ticket file
|
|
84
|
+
slug = slugMatch[1].trim();
|
|
85
|
+
} else {
|
|
86
|
+
// Generate a slug from the title as fallback
|
|
87
|
+
slug = `${ticketId}-${createSlug(title)}`;
|
|
88
|
+
console.log(`ā ļø No slug found in ticket. Generated slug: ${slug}`);
|
|
89
|
+
|
|
90
|
+
// Update the ticket with the generated slug
|
|
91
|
+
try {
|
|
92
|
+
let updatedContent = ticketContent;
|
|
93
|
+
if (updatedContent.includes('slug:')) {
|
|
94
|
+
updatedContent = updatedContent.replace(/slug:.*/, `slug: ${slug}`);
|
|
95
|
+
} else {
|
|
96
|
+
updatedContent = updatedContent.replace(/---/, `---\nslug: ${slug}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fs.writeFileSync(ticketPath, updatedContent, 'utf-8');
|
|
100
|
+
console.log(`ā
Updated ticket with slug: ${slug}`);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error(`ā Failed to update ticket with slug: ${error.message}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Get branch prefix from config or use default (empty)
|
|
107
|
+
const branchPrefix = config.git?.branch_prefix || '';
|
|
108
|
+
|
|
109
|
+
// Create branch name - if slug already contains the ticket ID, don't add it again
|
|
110
|
+
const branchName = slug.includes(ticketId)
|
|
111
|
+
? `${branchPrefix}${slug}`
|
|
112
|
+
: `${branchPrefix}${ticketId}-${slug}`;
|
|
113
|
+
|
|
114
|
+
// Check if there are uncommitted changes
|
|
115
|
+
const gitStatus = getGitStatus();
|
|
116
|
+
if (gitStatus) {
|
|
117
|
+
console.warn('ā ļø You have uncommitted changes. Stash or commit them before switching branches.');
|
|
118
|
+
console.log('');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Check if the branch already exists
|
|
122
|
+
const branchExistsLocal = branchExistsLocally(branchName);
|
|
123
|
+
const branchExistsRemote = branchExistsRemotely(branchName);
|
|
124
|
+
|
|
125
|
+
if (branchExistsLocal || branchExistsRemote) {
|
|
126
|
+
console.log(`š Branch ${branchName} already exists.`);
|
|
127
|
+
|
|
128
|
+
// Checkout the existing branch
|
|
129
|
+
if (checkoutBranch(branchName)) {
|
|
130
|
+
console.log(`ā
Switched to branch: ${branchName}`);
|
|
131
|
+
} else {
|
|
132
|
+
console.error(`ā Failed to switch to branch: ${branchName}`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
console.log(`š Creating new branch: ${branchName}`);
|
|
137
|
+
|
|
138
|
+
// Create and checkout the new branch
|
|
139
|
+
if (createAndCheckoutBranch(branchName, baseBranch)) {
|
|
140
|
+
console.log(`ā
Created and switched to branch: ${branchName}`);
|
|
141
|
+
} else {
|
|
142
|
+
console.error(`ā Failed to create branch: ${branchName}`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Update ticket status if requested
|
|
148
|
+
if (updateStatus) {
|
|
149
|
+
try {
|
|
150
|
+
// Read the ticket file
|
|
151
|
+
const ticketContent = fs.readFileSync(ticketPath, 'utf-8');
|
|
152
|
+
|
|
153
|
+
// Get current timestamp in ISO format
|
|
154
|
+
const now = new Date().toISOString();
|
|
155
|
+
|
|
156
|
+
// Update the status to in_progress and update the timestamp
|
|
157
|
+
let updatedContent = ticketContent
|
|
158
|
+
.replace(/^status: (.+)$/m, 'status: in_progress')
|
|
159
|
+
.replace(/^updated_at: (.+)$/m, `updated_at: ${now}`);
|
|
160
|
+
|
|
161
|
+
// Write the updated content back to the file
|
|
162
|
+
fs.writeFileSync(ticketPath, updatedContent, 'utf-8');
|
|
163
|
+
|
|
164
|
+
console.log(`ā
Updated ticket status to: in_progress`);
|
|
165
|
+
console.log(`ā
Updated timestamp to: ${now}`);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.error(`ā Failed to update ticket status: ${error.message}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Summary
|
|
172
|
+
console.log('');
|
|
173
|
+
console.log(`šÆ Now working on: ${ticketId} - ${title}`);
|
|
174
|
+
console.log(`šæ Branch: ${branchName}`);
|
|
175
|
+
console.log('');
|
|
176
|
+
console.log('To push this branch to remote:');
|
|
177
|
+
console.log(` git push -u origin ${branchName}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export default startCommand;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import {
|
|
5
|
+
createTempDir,
|
|
6
|
+
cleanupTempDir,
|
|
7
|
+
mockConsole,
|
|
8
|
+
mockProcessCwd,
|
|
9
|
+
mockProcessExit,
|
|
10
|
+
createMockVibeProject
|
|
11
|
+
} from '../../utils/test-helpers.js';
|
|
12
|
+
import startCommand from './index.js';
|
|
13
|
+
|
|
14
|
+
describe('start command', () => {
|
|
15
|
+
let tempDir;
|
|
16
|
+
let consoleMock;
|
|
17
|
+
let restoreCwd;
|
|
18
|
+
let exitMock;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
tempDir = createTempDir('start-test');
|
|
22
|
+
consoleMock = mockConsole();
|
|
23
|
+
restoreCwd = mockProcessCwd(tempDir);
|
|
24
|
+
exitMock = mockProcessExit();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
consoleMock.restore();
|
|
29
|
+
restoreCwd();
|
|
30
|
+
exitMock.restore();
|
|
31
|
+
cleanupTempDir(tempDir);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('basic validation', () => {
|
|
35
|
+
it('should validate that start command exists and is callable', () => {
|
|
36
|
+
expect(typeof startCommand).toBe('function');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should accept arguments parameter', () => {
|
|
40
|
+
expect(startCommand.length).toBe(1); // Takes one parameter (args array)
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should show error when no ticket ID provided', () => {
|
|
44
|
+
// Arrange
|
|
45
|
+
createMockVibeProject(tempDir);
|
|
46
|
+
|
|
47
|
+
// Act
|
|
48
|
+
expect(() => startCommand([])).toThrow('process.exit(1)');
|
|
49
|
+
|
|
50
|
+
// Assert
|
|
51
|
+
expect(exitMock.exitCalls).toContain(1);
|
|
52
|
+
expect(consoleMock.logs.error).toContain('ā Please provide a ticket ID (e.g., vibe start TKT-006)');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should show error for invalid ticket ID format', () => {
|
|
56
|
+
// Arrange
|
|
57
|
+
createMockVibeProject(tempDir);
|
|
58
|
+
|
|
59
|
+
// Act
|
|
60
|
+
expect(() => startCommand(['invalid-format'])).toThrow('process.exit(1)');
|
|
61
|
+
|
|
62
|
+
// Assert
|
|
63
|
+
expect(exitMock.exitCalls).toContain(1);
|
|
64
|
+
expect(consoleMock.logs.error).toContain('ā Invalid ticket ID format. Expected TKT-XXX or just the number.');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('ticket validation', () => {
|
|
69
|
+
it('should show error when ticket does not exist', () => {
|
|
70
|
+
// Arrange
|
|
71
|
+
createMockVibeProject(tempDir); // No tickets
|
|
72
|
+
|
|
73
|
+
// Act
|
|
74
|
+
expect(() => startCommand(['TKT-001'])).toThrow('process.exit(1)');
|
|
75
|
+
|
|
76
|
+
// Assert
|
|
77
|
+
expect(exitMock.exitCalls).toContain(1);
|
|
78
|
+
expect(consoleMock.logs.error).toContain('ā Ticket TKT-001 not found.');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Note: Full git integration testing would require:
|
|
83
|
+
// - Proper git repository setup
|
|
84
|
+
// - Mocking all git utility functions
|
|
85
|
+
// - Testing branch creation and checkout
|
|
86
|
+
// - Testing ticket status updates
|
|
87
|
+
// This is better handled in integration tests with proper git mocking
|
|
88
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { createInterface } from 'readline';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create readline interface for user input
|
|
8
|
+
*/
|
|
9
|
+
function createReadlineInterface() {
|
|
10
|
+
return createInterface({
|
|
11
|
+
input: process.stdin,
|
|
12
|
+
output: process.stdout
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Prompt user for input with question
|
|
18
|
+
*/
|
|
19
|
+
function askQuestion(rl, question) {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
rl.question(question, (answer) => {
|
|
22
|
+
resolve(answer.trim());
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Load existing config.yml
|
|
29
|
+
*/
|
|
30
|
+
function loadConfig() {
|
|
31
|
+
const configPath = path.join(process.cwd(), '.vibe', 'config.yml');
|
|
32
|
+
|
|
33
|
+
if (!fs.existsSync(configPath)) {
|
|
34
|
+
console.error('ā No .vibe/config.yml found. Run "vibe init" first.');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const configContent = fs.readFileSync(configPath, 'utf8');
|
|
40
|
+
return yaml.load(configContent);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error('ā Error reading config.yml:', error.message);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Save updated config.yml
|
|
49
|
+
*/
|
|
50
|
+
function saveConfig(config) {
|
|
51
|
+
const configPath = path.join(process.cwd(), '.vibe', 'config.yml');
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const yamlContent = yaml.dump(config, {
|
|
55
|
+
indent: 2,
|
|
56
|
+
lineWidth: -1,
|
|
57
|
+
noRefs: true
|
|
58
|
+
});
|
|
59
|
+
fs.writeFileSync(configPath, yamlContent, 'utf8');
|
|
60
|
+
return true;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error('ā Error saving config.yml:', error.message);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Main unlink command implementation
|
|
69
|
+
*/
|
|
70
|
+
async function unlinkCommand() {
|
|
71
|
+
console.log('š VibeKit AI Provider Removal\n');
|
|
72
|
+
|
|
73
|
+
const config = loadConfig();
|
|
74
|
+
const rl = createReadlineInterface();
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
// Check if AI is currently configured
|
|
78
|
+
if (!config.ai || !config.ai.enabled) {
|
|
79
|
+
console.log('ā¹ļø No AI provider is currently configured.');
|
|
80
|
+
rl.close();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Show current configuration
|
|
85
|
+
console.log('š Current AI configuration:');
|
|
86
|
+
console.log(` Provider: ${config.ai.provider === 'claude-code' ? 'Claude Code (Anthropic API)' : config.ai.provider}`);
|
|
87
|
+
console.log(` Model: ${config.ai.model || 'Not specified'}`);
|
|
88
|
+
console.log(` Status: ${config.ai.enabled ? 'Enabled' : 'Disabled'}`);
|
|
89
|
+
console.log();
|
|
90
|
+
|
|
91
|
+
// Confirm removal
|
|
92
|
+
const confirmRemoval = await askQuestion(rl, '? Are you sure you want to disable AI features? (y/n): ');
|
|
93
|
+
|
|
94
|
+
if (confirmRemoval.toLowerCase() !== 'y' && confirmRemoval.toLowerCase() !== 'yes') {
|
|
95
|
+
console.log('š« Cancelled. AI configuration unchanged.');
|
|
96
|
+
rl.close();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Simply disable AI in config (no credential storage to remove)
|
|
101
|
+
config.ai = {
|
|
102
|
+
...config.ai,
|
|
103
|
+
enabled: false,
|
|
104
|
+
provider: 'none'
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if (saveConfig(config)) {
|
|
108
|
+
console.log('ā
AI features disabled successfully!');
|
|
109
|
+
console.log('š AI provider has been unlinked.');
|
|
110
|
+
console.log('\nš” Your API keys in environment variables or .env files remain unchanged.');
|
|
111
|
+
console.log('š” Run "vibe link" anytime to re-enable AI features.');
|
|
112
|
+
} else {
|
|
113
|
+
console.log('ā Failed to save configuration changes.');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
} catch (error) {
|
|
117
|
+
console.error('ā Error during removal:', error.message);
|
|
118
|
+
} finally {
|
|
119
|
+
rl.close();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export default unlinkCommand;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import unlinkCommand from './index.js';
|
|
3
|
+
|
|
4
|
+
describe('unlink command', () => {
|
|
5
|
+
describe('basic validation', () => {
|
|
6
|
+
it('should validate that unlink command exists and is callable', () => {
|
|
7
|
+
// This test validates the command structure without executing interactive parts
|
|
8
|
+
expect(typeof unlinkCommand).toBe('function');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('should be an async function', () => {
|
|
12
|
+
// Validates the command is properly structured as async
|
|
13
|
+
expect(unlinkCommand.constructor.name).toBe('AsyncFunction');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// Note: The unlink command is interactive and async, requiring:
|
|
18
|
+
// - Mocking readline.createInterface()
|
|
19
|
+
// - Mocking user input responses
|
|
20
|
+
// - Testing actual config file modifications
|
|
21
|
+
// Full testing would require complex async/interactive mocking
|
|
22
|
+
});
|