bit-cli-ai 1.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.
- package/.env.example +15 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/package.json +73 -0
- package/src/commands/analyze.js +230 -0
- package/src/commands/branch.js +202 -0
- package/src/commands/commit.js +211 -0
- package/src/commands/hotzone.js +235 -0
- package/src/commands/init.js +233 -0
- package/src/commands/merge.js +191 -0
- package/src/index.js +104 -0
- package/src/utils/ai.js +238 -0
- package/src/utils/config.js +178 -0
- package/src/utils/errors.js +170 -0
- package/src/utils/git.js +241 -0
- package/src/utils/logger.js +94 -0
- package/src/utils/validation.js +108 -0
package/src/utils/git.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git utilities and helpers
|
|
3
|
+
* Wrapper around git commands with error handling and logging
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execSync, exec } from 'child_process';
|
|
7
|
+
import { promisify } from 'util';
|
|
8
|
+
import { log } from './logger.js';
|
|
9
|
+
import { NotARepositoryError, GitError, MergeConflictError } from './errors.js';
|
|
10
|
+
|
|
11
|
+
const execAsync = promisify(exec);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Execute a git command synchronously
|
|
15
|
+
*/
|
|
16
|
+
export function gitSync(command, options = {}) {
|
|
17
|
+
const { silent = false, throwOnError = true } = options;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
log.git('sync', { command });
|
|
21
|
+
const result = execSync(`git ${command}`, {
|
|
22
|
+
encoding: 'utf-8',
|
|
23
|
+
stdio: silent ? 'pipe' : undefined,
|
|
24
|
+
});
|
|
25
|
+
return result?.trim() || '';
|
|
26
|
+
} catch (error) {
|
|
27
|
+
log.exception(error, { command: `git ${command}` });
|
|
28
|
+
|
|
29
|
+
if (error.message?.includes('not a git repository')) {
|
|
30
|
+
throw new NotARepositoryError();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (throwOnError) {
|
|
34
|
+
throw new GitError(`Git command failed: ${command}`, {
|
|
35
|
+
stderr: error.stderr?.toString(),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Execute a git command asynchronously
|
|
45
|
+
*/
|
|
46
|
+
export async function gitAsync(command, options = {}) {
|
|
47
|
+
const { silent = false, throwOnError = true } = options;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
log.git('async', { command });
|
|
51
|
+
const { stdout, stderr } = await execAsync(`git ${command}`);
|
|
52
|
+
return stdout?.trim() || '';
|
|
53
|
+
} catch (error) {
|
|
54
|
+
log.exception(error, { command: `git ${command}` });
|
|
55
|
+
|
|
56
|
+
if (error.message?.includes('not a git repository')) {
|
|
57
|
+
throw new NotARepositoryError();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (throwOnError) {
|
|
61
|
+
throw new GitError(`Git command failed: ${command}`, {
|
|
62
|
+
stderr: error.stderr?.toString(),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check if current directory is a git repository
|
|
72
|
+
*/
|
|
73
|
+
export function isGitRepository() {
|
|
74
|
+
try {
|
|
75
|
+
execSync('git rev-parse --is-inside-work-tree', { stdio: 'pipe' });
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get current branch name
|
|
84
|
+
*/
|
|
85
|
+
export function getCurrentBranch() {
|
|
86
|
+
return gitSync('rev-parse --abbrev-ref HEAD', { silent: true });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get list of all branches
|
|
91
|
+
*/
|
|
92
|
+
export function getBranches(options = {}) {
|
|
93
|
+
const { all = false, remote = false } = options;
|
|
94
|
+
|
|
95
|
+
let command = 'branch';
|
|
96
|
+
if (all) command += ' -a';
|
|
97
|
+
else if (remote) command += ' -r';
|
|
98
|
+
|
|
99
|
+
const output = gitSync(command, { silent: true });
|
|
100
|
+
return output
|
|
101
|
+
.split('\n')
|
|
102
|
+
.map((b) => b.trim().replace(/^\* /, ''))
|
|
103
|
+
.filter(Boolean);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get staged files
|
|
108
|
+
*/
|
|
109
|
+
export function getStagedFiles() {
|
|
110
|
+
const output = gitSync('diff --cached --name-only', { silent: true });
|
|
111
|
+
return output ? output.split('\n').filter(Boolean) : [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get modified files (not staged)
|
|
116
|
+
*/
|
|
117
|
+
export function getModifiedFiles() {
|
|
118
|
+
const output = gitSync('diff --name-only', { silent: true });
|
|
119
|
+
return output ? output.split('\n').filter(Boolean) : [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get untracked files
|
|
124
|
+
*/
|
|
125
|
+
export function getUntrackedFiles() {
|
|
126
|
+
const output = gitSync('ls-files --others --exclude-standard', { silent: true });
|
|
127
|
+
return output ? output.split('\n').filter(Boolean) : [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get diff of staged changes
|
|
132
|
+
*/
|
|
133
|
+
export function getStagedDiff() {
|
|
134
|
+
return gitSync('diff --cached', { silent: true });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get commit history
|
|
139
|
+
*/
|
|
140
|
+
export function getCommitHistory(options = {}) {
|
|
141
|
+
const { limit = 10, format = '%H|%s|%an|%ar' } = options;
|
|
142
|
+
|
|
143
|
+
const output = gitSync(`log -${limit} --format="${format}"`, { silent: true });
|
|
144
|
+
|
|
145
|
+
return output.split('\n').filter(Boolean).map((line) => {
|
|
146
|
+
const [hash, message, author, date] = line.split('|');
|
|
147
|
+
return { hash, message, author, date };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get repository root directory
|
|
153
|
+
*/
|
|
154
|
+
export function getRepoRoot() {
|
|
155
|
+
return gitSync('rev-parse --show-toplevel', { silent: true });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Check for merge conflicts
|
|
160
|
+
*/
|
|
161
|
+
export function hasConflicts() {
|
|
162
|
+
try {
|
|
163
|
+
const output = gitSync('diff --name-only --diff-filter=U', { silent: true });
|
|
164
|
+
return output.length > 0;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Get conflicting files
|
|
172
|
+
*/
|
|
173
|
+
export function getConflictingFiles() {
|
|
174
|
+
const output = gitSync('diff --name-only --diff-filter=U', { silent: true });
|
|
175
|
+
return output ? output.split('\n').filter(Boolean) : [];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Preview merge using merge-tree
|
|
180
|
+
*/
|
|
181
|
+
export function previewMerge(branch) {
|
|
182
|
+
try {
|
|
183
|
+
const currentBranch = getCurrentBranch();
|
|
184
|
+
const baseCommit = gitSync(`merge-base ${currentBranch} ${branch}`, { silent: true });
|
|
185
|
+
const output = gitSync(`merge-tree ${baseCommit} ${currentBranch} ${branch}`, { silent: true });
|
|
186
|
+
|
|
187
|
+
// Check for conflict markers
|
|
188
|
+
const hasConflicts = output.includes('<<<<<<<') || output.includes('>>>>>>>');
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
hasConflicts,
|
|
192
|
+
output,
|
|
193
|
+
baseCommit,
|
|
194
|
+
};
|
|
195
|
+
} catch (error) {
|
|
196
|
+
throw new GitError(`Failed to preview merge with ${branch}`, {
|
|
197
|
+
originalError: error.message,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Get file content at specific commit
|
|
204
|
+
*/
|
|
205
|
+
export function getFileAtCommit(file, commit = 'HEAD') {
|
|
206
|
+
try {
|
|
207
|
+
return gitSync(`show ${commit}:${file}`, { silent: true });
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get remote URL
|
|
215
|
+
*/
|
|
216
|
+
export function getRemoteUrl(remote = 'origin') {
|
|
217
|
+
try {
|
|
218
|
+
return gitSync(`remote get-url ${remote}`, { silent: true });
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export default {
|
|
225
|
+
gitSync,
|
|
226
|
+
gitAsync,
|
|
227
|
+
isGitRepository,
|
|
228
|
+
getCurrentBranch,
|
|
229
|
+
getBranches,
|
|
230
|
+
getStagedFiles,
|
|
231
|
+
getModifiedFiles,
|
|
232
|
+
getUntrackedFiles,
|
|
233
|
+
getStagedDiff,
|
|
234
|
+
getCommitHistory,
|
|
235
|
+
getRepoRoot,
|
|
236
|
+
hasConflicts,
|
|
237
|
+
getConflictingFiles,
|
|
238
|
+
previewMerge,
|
|
239
|
+
getFileAtCommit,
|
|
240
|
+
getRemoteUrl,
|
|
241
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized logging utility for Bit CLI
|
|
3
|
+
* Supports multiple log levels, file output, and structured logging
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import winston from 'winston';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
|
|
11
|
+
// Ensure log directory exists
|
|
12
|
+
const LOG_DIR = path.join(os.homedir(), '.bit', 'logs');
|
|
13
|
+
if (!fs.existsSync(LOG_DIR)) {
|
|
14
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Custom format for console output
|
|
18
|
+
const consoleFormat = winston.format.combine(
|
|
19
|
+
winston.format.timestamp({ format: 'HH:mm:ss' }),
|
|
20
|
+
winston.format.printf(({ level, message, timestamp, ...meta }) => {
|
|
21
|
+
const metaStr = Object.keys(meta).length ? JSON.stringify(meta) : '';
|
|
22
|
+
return `[${timestamp}] ${level.toUpperCase()}: ${message} ${metaStr}`;
|
|
23
|
+
})
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// Custom format for file output
|
|
27
|
+
const fileFormat = winston.format.combine(
|
|
28
|
+
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
|
29
|
+
winston.format.json()
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// Create logger instance
|
|
33
|
+
const logger = winston.createLogger({
|
|
34
|
+
level: process.env.BIT_LOG_LEVEL || 'info',
|
|
35
|
+
transports: [
|
|
36
|
+
// File transport for all logs
|
|
37
|
+
new winston.transports.File({
|
|
38
|
+
filename: path.join(LOG_DIR, 'bit-error.log'),
|
|
39
|
+
level: 'error',
|
|
40
|
+
format: fileFormat,
|
|
41
|
+
maxsize: 5242880, // 5MB
|
|
42
|
+
maxFiles: 5,
|
|
43
|
+
}),
|
|
44
|
+
new winston.transports.File({
|
|
45
|
+
filename: path.join(LOG_DIR, 'bit-combined.log'),
|
|
46
|
+
format: fileFormat,
|
|
47
|
+
maxsize: 5242880, // 5MB
|
|
48
|
+
maxFiles: 5,
|
|
49
|
+
}),
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Add console transport in development
|
|
54
|
+
if (process.env.BIT_DEBUG === 'true') {
|
|
55
|
+
logger.add(
|
|
56
|
+
new winston.transports.Console({
|
|
57
|
+
format: consoleFormat,
|
|
58
|
+
})
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Helper methods for common logging patterns
|
|
63
|
+
export const log = {
|
|
64
|
+
info: (message, meta = {}) => logger.info(message, meta),
|
|
65
|
+
error: (message, meta = {}) => logger.error(message, meta),
|
|
66
|
+
warn: (message, meta = {}) => logger.warn(message, meta),
|
|
67
|
+
debug: (message, meta = {}) => logger.debug(message, meta),
|
|
68
|
+
|
|
69
|
+
// Log command execution
|
|
70
|
+
command: (cmd, args = []) => {
|
|
71
|
+
logger.info('Command executed', { command: cmd, arguments: args });
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
// Log git operations
|
|
75
|
+
git: (operation, details = {}) => {
|
|
76
|
+
logger.info('Git operation', { operation, ...details });
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
// Log AI operations
|
|
80
|
+
ai: (operation, details = {}) => {
|
|
81
|
+
logger.info('AI operation', { operation, ...details });
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
// Log errors with stack trace
|
|
85
|
+
exception: (error, context = {}) => {
|
|
86
|
+
logger.error('Exception occurred', {
|
|
87
|
+
message: error.message,
|
|
88
|
+
stack: error.stack,
|
|
89
|
+
...context,
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export default logger;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input validation using Zod schemas
|
|
3
|
+
* Ensures all user inputs and data are validated before processing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
|
|
8
|
+
// Branch name validation
|
|
9
|
+
export const branchNameSchema = z
|
|
10
|
+
.string()
|
|
11
|
+
.min(1, 'Branch name cannot be empty')
|
|
12
|
+
.max(255, 'Branch name too long')
|
|
13
|
+
.regex(
|
|
14
|
+
/^(?!\/|.*(?:\/\.|\/\/|@\{|\\))[^\x00-\x1f\x7f ~^:?*\[]+(?<!\.lock|\/|\.|\/)$/,
|
|
15
|
+
'Invalid branch name. Cannot contain special characters or patterns forbidden by Git'
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
// Commit message validation
|
|
19
|
+
export const commitMessageSchema = z
|
|
20
|
+
.string()
|
|
21
|
+
.min(1, 'Commit message cannot be empty')
|
|
22
|
+
.max(500, 'Commit message too long (max 500 characters)')
|
|
23
|
+
.refine(
|
|
24
|
+
(msg) => !msg.startsWith(' '),
|
|
25
|
+
'Commit message cannot start with a space'
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// File path validation
|
|
29
|
+
export const filePathSchema = z
|
|
30
|
+
.string()
|
|
31
|
+
.min(1, 'File path cannot be empty')
|
|
32
|
+
.refine(
|
|
33
|
+
(path) => !path.includes('\0'),
|
|
34
|
+
'File path cannot contain null bytes'
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Configuration validation
|
|
38
|
+
export const configSchema = z.object({
|
|
39
|
+
ai: z.object({
|
|
40
|
+
provider: z.enum(['openai', 'anthropic', 'local']).default('openai'),
|
|
41
|
+
model: z.string().default('gpt-4o-mini'),
|
|
42
|
+
maxTokens: z.number().min(100).max(4000).default(500),
|
|
43
|
+
temperature: z.number().min(0).max(2).default(0.7),
|
|
44
|
+
}).optional(),
|
|
45
|
+
git: z.object({
|
|
46
|
+
ghostPrefix: z.string().default('ghost/'),
|
|
47
|
+
autoStage: z.boolean().default(false),
|
|
48
|
+
signCommits: z.boolean().default(false),
|
|
49
|
+
}).optional(),
|
|
50
|
+
ui: z.object({
|
|
51
|
+
colors: z.boolean().default(true),
|
|
52
|
+
spinners: z.boolean().default(true),
|
|
53
|
+
verbose: z.boolean().default(false),
|
|
54
|
+
}).optional(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Remote URL validation
|
|
58
|
+
export const remoteUrlSchema = z
|
|
59
|
+
.string()
|
|
60
|
+
.refine(
|
|
61
|
+
(url) => {
|
|
62
|
+
// Accept SSH or HTTPS Git URLs
|
|
63
|
+
const sshPattern = /^git@[\w.-]+:[\w./-]+\.git$/;
|
|
64
|
+
const httpsPattern = /^https?:\/\/[\w.-]+\/[\w./-]+\.git$/;
|
|
65
|
+
return sshPattern.test(url) || httpsPattern.test(url);
|
|
66
|
+
},
|
|
67
|
+
'Invalid Git remote URL'
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Validate input against a schema
|
|
72
|
+
* @param {z.ZodSchema} schema - Zod schema to validate against
|
|
73
|
+
* @param {any} data - Data to validate
|
|
74
|
+
* @returns {{ success: boolean, data?: any, error?: string }}
|
|
75
|
+
*/
|
|
76
|
+
export function validate(schema, data) {
|
|
77
|
+
try {
|
|
78
|
+
const result = schema.parse(data);
|
|
79
|
+
return { success: true, data: result };
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error instanceof z.ZodError) {
|
|
82
|
+
const messages = error.errors.map((e) => e.message).join(', ');
|
|
83
|
+
return { success: false, error: messages };
|
|
84
|
+
}
|
|
85
|
+
return { success: false, error: 'Validation failed' };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Validate and throw on error
|
|
91
|
+
*/
|
|
92
|
+
export function validateOrThrow(schema, data) {
|
|
93
|
+
const result = validate(schema, data);
|
|
94
|
+
if (!result.success) {
|
|
95
|
+
throw new Error(result.error);
|
|
96
|
+
}
|
|
97
|
+
return result.data;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default {
|
|
101
|
+
branchNameSchema,
|
|
102
|
+
commitMessageSchema,
|
|
103
|
+
filePathSchema,
|
|
104
|
+
configSchema,
|
|
105
|
+
remoteUrlSchema,
|
|
106
|
+
validate,
|
|
107
|
+
validateOrThrow,
|
|
108
|
+
};
|