commic 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/.husky/pre-commit +2 -0
- package/README.md +306 -0
- package/biome.json +50 -0
- package/dist/ai/AIService.d.ts +51 -0
- package/dist/ai/AIService.d.ts.map +1 -0
- package/dist/ai/AIService.js +351 -0
- package/dist/ai/AIService.js.map +1 -0
- package/dist/config/ConfigManager.d.ts +49 -0
- package/dist/config/ConfigManager.d.ts.map +1 -0
- package/dist/config/ConfigManager.js +124 -0
- package/dist/config/ConfigManager.js.map +1 -0
- package/dist/errors/CustomErrors.d.ts +54 -0
- package/dist/errors/CustomErrors.d.ts.map +1 -0
- package/dist/errors/CustomErrors.js +99 -0
- package/dist/errors/CustomErrors.js.map +1 -0
- package/dist/git/GitService.d.ts +77 -0
- package/dist/git/GitService.d.ts.map +1 -0
- package/dist/git/GitService.js +219 -0
- package/dist/git/GitService.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/orchestrator/MainOrchestrator.d.ts +63 -0
- package/dist/orchestrator/MainOrchestrator.d.ts.map +1 -0
- package/dist/orchestrator/MainOrchestrator.js +225 -0
- package/dist/orchestrator/MainOrchestrator.js.map +1 -0
- package/dist/types/index.d.ts +55 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/ui/UIManager.d.ts +118 -0
- package/dist/ui/UIManager.d.ts.map +1 -0
- package/dist/ui/UIManager.js +369 -0
- package/dist/ui/UIManager.js.map +1 -0
- package/dist/validation/ConventionalCommitsValidator.d.ts +33 -0
- package/dist/validation/ConventionalCommitsValidator.d.ts.map +1 -0
- package/dist/validation/ConventionalCommitsValidator.js +114 -0
- package/dist/validation/ConventionalCommitsValidator.js.map +1 -0
- package/package.json +49 -0
- package/src/ai/AIService.ts +413 -0
- package/src/config/ConfigManager.ts +141 -0
- package/src/errors/CustomErrors.ts +176 -0
- package/src/git/GitService.ts +246 -0
- package/src/index.ts +55 -0
- package/src/orchestrator/MainOrchestrator.ts +263 -0
- package/src/types/index.ts +60 -0
- package/src/ui/UIManager.ts +420 -0
- package/src/validation/ConventionalCommitsValidator.ts +139 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { type GenerativeModel, GoogleGenerativeAI } from '@google/generative-ai';
|
|
2
|
+
import { APIError, ValidationError } from '../errors/CustomErrors.js';
|
|
3
|
+
import type { CommitSuggestion, GitDiff } from '../types/index.js';
|
|
4
|
+
import { ConventionalCommitsValidator } from '../validation/ConventionalCommitsValidator.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Handles AI-powered commit message generation using Google's Gemini API
|
|
8
|
+
*/
|
|
9
|
+
export class AIService {
|
|
10
|
+
private readonly genAI: GoogleGenerativeAI;
|
|
11
|
+
private readonly model: GenerativeModel;
|
|
12
|
+
|
|
13
|
+
constructor(apiKey: string, modelName: string) {
|
|
14
|
+
this.genAI = new GoogleGenerativeAI(apiKey);
|
|
15
|
+
this.model = this.genAI.getGenerativeModel({ model: modelName });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build prompt for Gemini API to generate commit messages
|
|
20
|
+
* @param diff Git diff information
|
|
21
|
+
* @param count Number of suggestions to generate (3-5)
|
|
22
|
+
* @returns Formatted prompt string
|
|
23
|
+
*/
|
|
24
|
+
private buildPrompt(diff: GitDiff, count: number): string {
|
|
25
|
+
// Combine staged and unstaged diffs
|
|
26
|
+
const combinedDiff = [diff.staged, diff.unstaged].filter((d) => d.length > 0).join('\n\n');
|
|
27
|
+
|
|
28
|
+
// Truncate diff if too large (Gemini input token limit: 1,048,576 tokens)
|
|
29
|
+
// Using ~800K characters (approx 200K-266K tokens) leaves plenty of room for prompt
|
|
30
|
+
// 1 token ≈ 3-4 characters on average, so 800K chars ≈ 200K-266K tokens
|
|
31
|
+
const maxDiffLength = 800000;
|
|
32
|
+
const truncatedDiff =
|
|
33
|
+
combinedDiff.length > maxDiffLength
|
|
34
|
+
? combinedDiff.substring(0, maxDiffLength) +
|
|
35
|
+
'\n\n[... diff truncated due to size limit ...]'
|
|
36
|
+
: combinedDiff;
|
|
37
|
+
|
|
38
|
+
return `You are an expert Git commit message writer. Analyze the ENTIRE Git diff below and generate ${count} commit messages that summarize ALL changes together. Each commit message should cover the complete set of changes, not individual features.
|
|
39
|
+
|
|
40
|
+
IMPORTANT:
|
|
41
|
+
- Analyze ALL changes in the diff as a single commit
|
|
42
|
+
- Each suggested message should describe the complete set of changes
|
|
43
|
+
- Do NOT create separate messages for different parts of the diff
|
|
44
|
+
- Consider all file changes, additions, deletions, and modifications together
|
|
45
|
+
- Provide different perspectives/styles for the SAME set of changes
|
|
46
|
+
|
|
47
|
+
CRITICAL RULES:
|
|
48
|
+
1. Format: type(scope)?: description
|
|
49
|
+
2. Valid types: feat, fix, docs, style, refactor, test, chore, perf, ci, build
|
|
50
|
+
3. Use imperative mood (add, fix, update - NOT added, fixed, updated)
|
|
51
|
+
4. Description starts with lowercase
|
|
52
|
+
5. Single-line messages: max 72 chars, no body
|
|
53
|
+
6. Multi-line messages: blank line between subject and body
|
|
54
|
+
7. At least 2 messages should be single-line
|
|
55
|
+
8. Each message must summarize ALL changes in the diff
|
|
56
|
+
|
|
57
|
+
OUTPUT FORMAT:
|
|
58
|
+
Separate each commit message with exactly "---" on its own line.
|
|
59
|
+
Return ONLY the messages, no explanations or numbering.
|
|
60
|
+
|
|
61
|
+
EXAMPLES (each covers all changes):
|
|
62
|
+
feat(auth): add JWT validation and user login flow
|
|
63
|
+
---
|
|
64
|
+
fix: resolve authentication issues and update error handling
|
|
65
|
+
---
|
|
66
|
+
refactor(auth): improve JWT implementation and error messages
|
|
67
|
+
|
|
68
|
+
Update token validation logic and add comprehensive error handling
|
|
69
|
+
---
|
|
70
|
+
feat: implement user authentication system
|
|
71
|
+
|
|
72
|
+
Add JWT token validation, login endpoints, and error handling
|
|
73
|
+
|
|
74
|
+
GIT DIFF:
|
|
75
|
+
${truncatedDiff}
|
|
76
|
+
|
|
77
|
+
Generate ${count} commit messages that each describe ALL the changes above:`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parse API response into CommitSuggestion array
|
|
82
|
+
* Tries multiple parsing strategies for robustness
|
|
83
|
+
* @param response Raw response text from API
|
|
84
|
+
* @returns Array of commit suggestions
|
|
85
|
+
*/
|
|
86
|
+
private parseResponse(response: string): CommitSuggestion[] {
|
|
87
|
+
if (!response || response.trim().length === 0) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let messages: string[] = [];
|
|
92
|
+
|
|
93
|
+
// Strategy 1: Split by "---" on its own line
|
|
94
|
+
const tripleDashPattern = /\n---\n/g;
|
|
95
|
+
if (tripleDashPattern.test(response)) {
|
|
96
|
+
messages = response
|
|
97
|
+
.split(tripleDashPattern)
|
|
98
|
+
.map((msg) => msg.trim())
|
|
99
|
+
.filter((msg) => msg.length > 0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Strategy 2: Split by "---" anywhere
|
|
103
|
+
if (messages.length === 0 || messages.length === 1) {
|
|
104
|
+
messages = response
|
|
105
|
+
.split('---')
|
|
106
|
+
.map((msg) => msg.trim())
|
|
107
|
+
.filter((msg) => msg.length > 0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Strategy 3: Split by numbered items (1., 2., etc.)
|
|
111
|
+
if (messages.length === 0 || messages.length === 1) {
|
|
112
|
+
const numberedPattern = /^\d+\.\s+/gm;
|
|
113
|
+
if (numberedPattern.test(response)) {
|
|
114
|
+
messages = response
|
|
115
|
+
.split(numberedPattern)
|
|
116
|
+
.map((msg) => msg.trim())
|
|
117
|
+
.filter((msg) => msg.length > 0 && !/^\d+\./.test(msg));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Strategy 4: Split by double newlines (common for multi-line messages)
|
|
122
|
+
if (messages.length === 0 || messages.length === 1) {
|
|
123
|
+
const doubleNewlinePattern = /\n\n+/;
|
|
124
|
+
if (doubleNewlinePattern.test(response)) {
|
|
125
|
+
const parts = response.split(doubleNewlinePattern);
|
|
126
|
+
// Filter out parts that look like commit messages (start with type:)
|
|
127
|
+
messages = parts
|
|
128
|
+
.map((msg) => msg.trim())
|
|
129
|
+
.filter((msg) => {
|
|
130
|
+
const trimmed = msg.trim();
|
|
131
|
+
return (
|
|
132
|
+
trimmed.length > 0 &&
|
|
133
|
+
/^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\([^)]+\))?(!)?:\s/.test(
|
|
134
|
+
trimmed
|
|
135
|
+
)
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Strategy 5: Try to extract commit messages by pattern matching
|
|
142
|
+
if (messages.length === 0) {
|
|
143
|
+
const commitPattern =
|
|
144
|
+
/(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\([^)]+\))?(!)?:\s[^\n]+(?:\n(?!---|\d+\.)[^\n]+)*/g;
|
|
145
|
+
const matches = response.match(commitPattern);
|
|
146
|
+
if (matches) {
|
|
147
|
+
messages = matches.map((msg) => msg.trim());
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// If still no messages, try to extract any line starting with a valid type
|
|
152
|
+
if (messages.length === 0) {
|
|
153
|
+
const lines = response.split('\n');
|
|
154
|
+
const validTypeLines: string[] = [];
|
|
155
|
+
let currentMessage = '';
|
|
156
|
+
|
|
157
|
+
for (const line of lines) {
|
|
158
|
+
const trimmed = line.trim();
|
|
159
|
+
if (
|
|
160
|
+
/^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\([^)]+\))?(!)?:\s/.test(
|
|
161
|
+
trimmed
|
|
162
|
+
)
|
|
163
|
+
) {
|
|
164
|
+
if (currentMessage) {
|
|
165
|
+
validTypeLines.push(currentMessage.trim());
|
|
166
|
+
}
|
|
167
|
+
currentMessage = trimmed;
|
|
168
|
+
} else if (currentMessage && trimmed.length > 0) {
|
|
169
|
+
currentMessage += `\n${trimmed}`;
|
|
170
|
+
} else if (currentMessage && trimmed.length === 0) {
|
|
171
|
+
// Blank line - continue building message
|
|
172
|
+
currentMessage += '\n';
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (currentMessage) {
|
|
176
|
+
validTypeLines.push(currentMessage.trim());
|
|
177
|
+
}
|
|
178
|
+
messages = validTypeLines;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Clean up messages - remove any that are clearly not commit messages
|
|
182
|
+
messages = messages
|
|
183
|
+
.map((msg) => {
|
|
184
|
+
// Remove common prefixes AI might add
|
|
185
|
+
return msg
|
|
186
|
+
.replace(/^(Here are|Here's|Generated|Commit messages?):?\s*/i, '')
|
|
187
|
+
.replace(/^[-*•]\s*/, '')
|
|
188
|
+
.trim();
|
|
189
|
+
})
|
|
190
|
+
.filter((msg) => {
|
|
191
|
+
// Must start with a valid commit type
|
|
192
|
+
return (
|
|
193
|
+
msg.length > 0 &&
|
|
194
|
+
/^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\([^)]+\))?(!)?:\s/.test(msg)
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return messages.map((message) => ({
|
|
199
|
+
message: message.trim(),
|
|
200
|
+
type: ConventionalCommitsValidator.isSingleLine(message) ? 'single-line' : 'multi-line',
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Filter and validate suggestions, returning only valid ones
|
|
206
|
+
* @param suggestions Array of suggestions to validate
|
|
207
|
+
* @returns Array of valid suggestions
|
|
208
|
+
*/
|
|
209
|
+
private filterValidSuggestions(suggestions: CommitSuggestion[]): CommitSuggestion[] {
|
|
210
|
+
const validSuggestions: CommitSuggestion[] = [];
|
|
211
|
+
|
|
212
|
+
for (const suggestion of suggestions) {
|
|
213
|
+
const validation = ConventionalCommitsValidator.validate(suggestion.message);
|
|
214
|
+
if (validation.valid) {
|
|
215
|
+
validSuggestions.push(suggestion);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return validSuggestions;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Check if suggestions meet minimum requirements
|
|
224
|
+
* @param suggestions Array of suggestions to check
|
|
225
|
+
* @returns true if meets minimum requirements
|
|
226
|
+
*/
|
|
227
|
+
private meetsMinimumRequirements(suggestions: CommitSuggestion[]): boolean {
|
|
228
|
+
// Need at least 2 valid suggestions
|
|
229
|
+
if (suggestions.length < 2) {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// At least 1 should be single-line (relaxed from 2)
|
|
234
|
+
const singleLineCount = suggestions.filter((s) => s.type === 'single-line').length;
|
|
235
|
+
if (singleLineCount < 1) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Generate commit message suggestions using Gemini API
|
|
244
|
+
* @param diff Git diff information
|
|
245
|
+
* @param count Number of suggestions to generate (3-5)
|
|
246
|
+
* @returns Array of validated commit suggestions
|
|
247
|
+
* @throws APIError if API request fails
|
|
248
|
+
* @throws ValidationError if no valid suggestions generated
|
|
249
|
+
*/
|
|
250
|
+
async generateCommitMessages(diff: GitDiff, count: number = 4): Promise<CommitSuggestion[]> {
|
|
251
|
+
// Ensure count is within bounds
|
|
252
|
+
const requestCount = Math.max(3, Math.min(5, count));
|
|
253
|
+
|
|
254
|
+
let attempts = 0;
|
|
255
|
+
const maxAttempts = 3;
|
|
256
|
+
let bestSuggestions: CommitSuggestion[] = [];
|
|
257
|
+
|
|
258
|
+
while (attempts < maxAttempts) {
|
|
259
|
+
attempts++;
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
// Build prompt
|
|
263
|
+
const prompt = this.buildPrompt(diff, requestCount);
|
|
264
|
+
|
|
265
|
+
// Call Gemini API with timeout
|
|
266
|
+
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
267
|
+
setTimeout(() => reject(new Error('Request timeout')), 30000)
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const result = await Promise.race([this.model.generateContent(prompt), timeoutPromise]);
|
|
271
|
+
|
|
272
|
+
const response = result.response;
|
|
273
|
+
const text = response.text();
|
|
274
|
+
|
|
275
|
+
if (!text || text.trim().length === 0) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Parse response
|
|
280
|
+
const parsedSuggestions = this.parseResponse(text);
|
|
281
|
+
|
|
282
|
+
if (parsedSuggestions.length === 0) {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Filter valid suggestions
|
|
287
|
+
const validSuggestions = this.filterValidSuggestions(parsedSuggestions);
|
|
288
|
+
|
|
289
|
+
// If we have valid suggestions, check if they meet requirements
|
|
290
|
+
if (validSuggestions.length > 0) {
|
|
291
|
+
// If we meet minimum requirements, return them
|
|
292
|
+
if (this.meetsMinimumRequirements(validSuggestions)) {
|
|
293
|
+
// Limit to requested count, prioritizing single-line messages
|
|
294
|
+
const sorted = validSuggestions.sort((a, b) => {
|
|
295
|
+
if (a.type === 'single-line' && b.type !== 'single-line') return -1;
|
|
296
|
+
if (a.type !== 'single-line' && b.type === 'single-line') return 1;
|
|
297
|
+
return 0;
|
|
298
|
+
});
|
|
299
|
+
return sorted.slice(0, requestCount);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Store best suggestions so far
|
|
303
|
+
if (validSuggestions.length > bestSuggestions.length) {
|
|
304
|
+
bestSuggestions = validSuggestions;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// If we have some valid suggestions but not enough, try to generate more
|
|
309
|
+
if (validSuggestions.length > 0 && validSuggestions.length < 2 && attempts < maxAttempts) {
|
|
310
|
+
// Request more messages in next attempt
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// If we have at least 2 valid suggestions, return them even if not perfect
|
|
315
|
+
if (validSuggestions.length >= 2) {
|
|
316
|
+
return validSuggestions.slice(0, requestCount);
|
|
317
|
+
}
|
|
318
|
+
} catch (error) {
|
|
319
|
+
const errorMessage = (error as Error).message.toLowerCase();
|
|
320
|
+
|
|
321
|
+
// Handle specific API errors that shouldn't be retried
|
|
322
|
+
if (errorMessage.includes('rate limit') || errorMessage.includes('quota')) {
|
|
323
|
+
throw APIError.rateLimitExceeded();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (
|
|
327
|
+
errorMessage.includes('api key') ||
|
|
328
|
+
errorMessage.includes('auth') ||
|
|
329
|
+
errorMessage.includes('permission')
|
|
330
|
+
) {
|
|
331
|
+
throw APIError.authenticationFailed();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (errorMessage.includes('timeout') || errorMessage.includes('request timeout')) {
|
|
335
|
+
if (attempts >= maxAttempts) {
|
|
336
|
+
throw APIError.timeout();
|
|
337
|
+
}
|
|
338
|
+
// Continue to retry on timeout
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// For other errors, continue retrying
|
|
343
|
+
if (attempts < maxAttempts) {
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// If we have some valid suggestions, return them as fallback
|
|
349
|
+
if (bestSuggestions.length >= 1) {
|
|
350
|
+
return bestSuggestions.slice(0, requestCount);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Last resort: try to generate a simple fallback message
|
|
354
|
+
if (bestSuggestions.length === 0) {
|
|
355
|
+
const fallbackMessage = this.generateFallbackMessage(diff);
|
|
356
|
+
if (fallbackMessage) {
|
|
357
|
+
return [
|
|
358
|
+
{
|
|
359
|
+
message: fallbackMessage,
|
|
360
|
+
type: 'single-line',
|
|
361
|
+
},
|
|
362
|
+
];
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// If all else fails, throw error with helpful message
|
|
367
|
+
throw ValidationError.noValidSuggestions();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Generate a simple fallback commit message when AI fails
|
|
372
|
+
* @param diff Git diff information
|
|
373
|
+
* @returns Simple commit message or null
|
|
374
|
+
*/
|
|
375
|
+
private generateFallbackMessage(diff: GitDiff): string | null {
|
|
376
|
+
const combinedDiff = `${diff.staged}\n${diff.unstaged}`.toLowerCase();
|
|
377
|
+
|
|
378
|
+
// Simple heuristics to determine commit type
|
|
379
|
+
let type = 'chore';
|
|
380
|
+
if (
|
|
381
|
+
combinedDiff.includes('fix') ||
|
|
382
|
+
combinedDiff.includes('bug') ||
|
|
383
|
+
combinedDiff.includes('error')
|
|
384
|
+
) {
|
|
385
|
+
type = 'fix';
|
|
386
|
+
} else if (
|
|
387
|
+
combinedDiff.includes('feat') ||
|
|
388
|
+
combinedDiff.includes('add') ||
|
|
389
|
+
combinedDiff.includes('new')
|
|
390
|
+
) {
|
|
391
|
+
type = 'feat';
|
|
392
|
+
} else if (combinedDiff.includes('doc') || combinedDiff.includes('readme')) {
|
|
393
|
+
type = 'docs';
|
|
394
|
+
} else if (combinedDiff.includes('refactor')) {
|
|
395
|
+
type = 'refactor';
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Try to extract a simple description
|
|
399
|
+
const lines = combinedDiff.split('\n').slice(0, 5);
|
|
400
|
+
const fileChanges = lines.filter((l) => l.startsWith('+++') || l.startsWith('---'));
|
|
401
|
+
|
|
402
|
+
if (fileChanges.length > 0) {
|
|
403
|
+
const firstFile =
|
|
404
|
+
fileChanges[0]
|
|
405
|
+
.replace(/^[+-]{3}\s+/, '')
|
|
406
|
+
.split('/')
|
|
407
|
+
.pop() || 'files';
|
|
408
|
+
return `${type}: update ${firstFile}`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return `${type}: update code`;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { ConfigurationError } from '../errors/CustomErrors.js';
|
|
5
|
+
import type { Config } from '../types/index.js';
|
|
6
|
+
import type { UIManager } from '../ui/UIManager.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Manages persistent configuration for the Commit CLI
|
|
10
|
+
* Handles loading, saving, and validation of API key and model preferences
|
|
11
|
+
*/
|
|
12
|
+
export class ConfigManager {
|
|
13
|
+
private readonly configPath: string;
|
|
14
|
+
private readonly configDir: string;
|
|
15
|
+
private static readonly CONFIG_VERSION = '1.0.0';
|
|
16
|
+
|
|
17
|
+
constructor(configPath?: string) {
|
|
18
|
+
this.configPath = configPath || join(homedir(), '.commic', 'config.json');
|
|
19
|
+
this.configDir = dirname(this.configPath);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Load configuration from disk
|
|
24
|
+
* @returns Config object or null if not found
|
|
25
|
+
* @throws ConfigurationError if config file is corrupted
|
|
26
|
+
*/
|
|
27
|
+
async load(): Promise<Config | null> {
|
|
28
|
+
try {
|
|
29
|
+
const configData = await fs.readFile(this.configPath, 'utf-8');
|
|
30
|
+
const config = JSON.parse(configData) as Config;
|
|
31
|
+
|
|
32
|
+
// Validate config structure
|
|
33
|
+
if (!config.apiKey || !config.model) {
|
|
34
|
+
throw ConfigurationError.configFileCorrupted();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return config;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
40
|
+
// Config file doesn't exist yet - this is fine on first run
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (error instanceof SyntaxError) {
|
|
45
|
+
throw ConfigurationError.configFileCorrupted();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (error instanceof ConfigurationError) {
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
throw ConfigurationError.configSaveFailed(error as Error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Save configuration to disk
|
|
58
|
+
* Creates config directory if it doesn't exist
|
|
59
|
+
* @param config Configuration to save
|
|
60
|
+
* @throws ConfigurationError if save fails
|
|
61
|
+
*/
|
|
62
|
+
async save(config: Config): Promise<void> {
|
|
63
|
+
try {
|
|
64
|
+
// Ensure config directory exists
|
|
65
|
+
await fs.mkdir(this.configDir, { recursive: true });
|
|
66
|
+
|
|
67
|
+
// Add version to config
|
|
68
|
+
const configWithVersion: Config = {
|
|
69
|
+
...config,
|
|
70
|
+
version: ConfigManager.CONFIG_VERSION,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Write config file with pretty formatting
|
|
74
|
+
await fs.writeFile(this.configPath, JSON.stringify(configWithVersion, null, 2), 'utf-8');
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw ConfigurationError.configSaveFailed(error as Error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if configuration file exists
|
|
82
|
+
* @returns true if config exists, false otherwise
|
|
83
|
+
*/
|
|
84
|
+
async exists(): Promise<boolean> {
|
|
85
|
+
try {
|
|
86
|
+
await fs.access(this.configPath);
|
|
87
|
+
return true;
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get the configuration file path
|
|
95
|
+
* @returns Absolute path to config file
|
|
96
|
+
*/
|
|
97
|
+
getConfigPath(): string {
|
|
98
|
+
return this.configPath;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Validate API key format
|
|
103
|
+
* Basic validation - checks if it looks like a Gemini API key
|
|
104
|
+
* @param apiKey API key to validate
|
|
105
|
+
* @returns true if valid format
|
|
106
|
+
*/
|
|
107
|
+
static validateApiKeyFormat(apiKey: string): boolean {
|
|
108
|
+
// Gemini API keys typically start with "AIza" and are around 39 characters
|
|
109
|
+
// This is a basic check - actual validation happens when making API calls
|
|
110
|
+
return apiKey.length > 20 && apiKey.trim() === apiKey;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Prompt user for configuration (API key and model)
|
|
115
|
+
* @param ui UIManager instance for prompts
|
|
116
|
+
* @returns New configuration object
|
|
117
|
+
*/
|
|
118
|
+
async promptForConfig(ui: UIManager): Promise<Config> {
|
|
119
|
+
ui.showSectionHeader('🔧 Configuration Setup');
|
|
120
|
+
ui.showInfo('Get your free API key at: https://aistudio.google.com/app/api-keys');
|
|
121
|
+
ui.newLine();
|
|
122
|
+
|
|
123
|
+
// Prompt for API key
|
|
124
|
+
const apiKey = await ui.promptForApiKey();
|
|
125
|
+
|
|
126
|
+
// Validate API key format
|
|
127
|
+
if (!ConfigManager.validateApiKeyFormat(apiKey)) {
|
|
128
|
+
throw ConfigurationError.invalidApiKey();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Prompt for model selection
|
|
132
|
+
const availableModels = ['gemini-2.5-flash', 'gemini-flash-latest'];
|
|
133
|
+
const model = await ui.promptForModel(availableModels);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
apiKey,
|
|
137
|
+
model,
|
|
138
|
+
version: ConfigManager.CONFIG_VERSION,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|