devsplain 1.2.0 → 1.5.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 +115 -41
- package/bin/cli.js +591 -195
- package/bin/post-commit.js +96 -0
- package/bin/setup-hook.js +79 -0
- package/lib/config.js +124 -80
- package/lib/llm.js +221 -139
- package/package.json +43 -43
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { spliceComments } = require('./cli');
|
|
5
|
+
|
|
6
|
+
// Wrap logic in try-catch to prevent blocking the git commit process on failure
|
|
7
|
+
try {
|
|
8
|
+
const lastCommitMsg = execSync('git log -1 --format=%s', { encoding: 'utf8' }).trim();
|
|
9
|
+
// Avoid infinite loops if this hook triggered the current commit
|
|
10
|
+
if (lastCommitMsg === 'docs: auto-generated comments by devsplain') {
|
|
11
|
+
process.exit(0);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const changedFilesStr = execSync('git diff-tree --no-commit-id --name-only -r HEAD', { encoding: 'utf8' }).trim();
|
|
15
|
+
if (!changedFilesStr) {
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
const changedFiles = changedFilesStr.split(/\r?\n/);
|
|
19
|
+
|
|
20
|
+
// Define supported file extensions for auto-documentation
|
|
21
|
+
const validExtensions = [
|
|
22
|
+
'.js', '.jsx', '.ts', '.tsx', '.html', '.css', '.scss', '.vue', '.svelte',
|
|
23
|
+
'.py', '.java', '.c', '.cpp', '.cs', '.go', '.rb', '.php', '.rs',
|
|
24
|
+
'.swift', '.kt', '.dart', '.sh'
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// Filter changed files to include only supported extensions that currently exist
|
|
28
|
+
const filesToComment = changedFiles.filter(file => {
|
|
29
|
+
const ext = path.extname(file).toLowerCase();
|
|
30
|
+
return validExtensions.includes(ext) && fs.existsSync(file);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (filesToComment.length === 0) {
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log(`[devsplain] Found ${filesToComment.length} file(s) in the last commit to auto-comment.`);
|
|
38
|
+
|
|
39
|
+
const args = process.argv.slice(2);
|
|
40
|
+
let modeFlag = '';
|
|
41
|
+
if (args.includes('--light')) modeFlag = ' --light';
|
|
42
|
+
if (args.includes('--full')) modeFlag = ' --full';
|
|
43
|
+
|
|
44
|
+
let commentedAny = false;
|
|
45
|
+
|
|
46
|
+
// Iterate through each changed file to check if content actually changed
|
|
47
|
+
for (const file of filesToComment) {
|
|
48
|
+
try {
|
|
49
|
+
const ext = path.extname(file).toLowerCase();
|
|
50
|
+
// Retrieve current file content from filesystem
|
|
51
|
+
const contentHead = fs.readFileSync(file, 'utf8');
|
|
52
|
+
let contentPrev = '';
|
|
53
|
+
// Attempt to fetch the version of the file from the previous commit for comparison
|
|
54
|
+
try {
|
|
55
|
+
contentPrev = execSync(`git show HEAD~1:"${file}"`, {
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
58
|
+
});
|
|
59
|
+
} catch (prevErr) {
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (contentPrev) {
|
|
63
|
+
// Strip existing comments to determine if the logic changed or just documentation
|
|
64
|
+
const cleanHead = spliceComments(contentHead, [], 'clean', ext);
|
|
65
|
+
const cleanPrev = spliceComments(contentPrev, [], 'clean', ext);
|
|
66
|
+
if (cleanHead === cleanPrev) {
|
|
67
|
+
console.log(`[devsplain] Skipping ${file}: commit contains only comment changes.`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch (cleanErr) {
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log(`[devsplain] Automatically commenting file: ${file}`);
|
|
75
|
+
try {
|
|
76
|
+
// Invoke the CLI tool to generate comments for the changed file
|
|
77
|
+
execSync(`node bin/cli.js "${file}" --force${modeFlag}`, { stdio: 'inherit' });
|
|
78
|
+
commentedAny = true;
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.warn(`[devsplain] Warning: Failed to comment ${file}: ${err.message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// If changes were made, stage and commit them automatically to the current branch
|
|
85
|
+
if (commentedAny) {
|
|
86
|
+
const status = execSync('git diff --name-only', { encoding: 'utf8' }).trim();
|
|
87
|
+
if (status.length > 0) {
|
|
88
|
+
console.log('[devsplain] Staging and committing auto-generated comments...');
|
|
89
|
+
// Use --no-verify to prevent triggering this hook recursively during the commit
|
|
90
|
+
execSync('git commit -am "docs: auto-generated comments by devsplain" --no-verify', { stdio: 'inherit' });
|
|
91
|
+
console.log('[devsplain] Comments committed successfully! Rollback via: git reset --hard HEAD~1');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.warn(`[devsplain] Warning: post-commit hook script failed: ${e.message}`);
|
|
96
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const readline = require('readline');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Configures and installs git pre-commit and post-commit hooks.
|
|
8
|
+
* Detects the local git repository and prompts the user for comment mode preferences.
|
|
9
|
+
*/
|
|
10
|
+
async function installHooks() {
|
|
11
|
+
try {
|
|
12
|
+
// Resolve the actual .git directory path
|
|
13
|
+
const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim();
|
|
14
|
+
const hooksDir = path.join(gitDir, 'hooks');
|
|
15
|
+
if (!fs.existsSync(hooksDir)) {
|
|
16
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let modeChoice = '1';
|
|
20
|
+
// Check if running in an interactive terminal to prompt for preferences
|
|
21
|
+
if (process.stdout.isTTY) {
|
|
22
|
+
const rl = readline.createInterface({
|
|
23
|
+
input: process.stdin,
|
|
24
|
+
output: process.stdout
|
|
25
|
+
});
|
|
26
|
+
// Helper to wrap readline as a Promise for async/await control flow
|
|
27
|
+
const askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
|
|
28
|
+
|
|
29
|
+
console.log('\nSelect default commenting mode for Git commits:');
|
|
30
|
+
console.log('1. Balanced (mix of JSDoc and sparse inline comments)');
|
|
31
|
+
console.log('2. Light (JSDoc block comments above functions only)');
|
|
32
|
+
console.log('3. Full (aggressive inline commenting)');
|
|
33
|
+
const answer = await askQuestion('Select (1-3, default: 1): ');
|
|
34
|
+
modeChoice = answer.trim() || '1';
|
|
35
|
+
rl.close();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Map user selection to CLI argument flags for the post-commit handler
|
|
39
|
+
let modeArgs = '';
|
|
40
|
+
if (modeChoice === '2') {
|
|
41
|
+
modeArgs = ' --light';
|
|
42
|
+
} else if (modeChoice === '3') {
|
|
43
|
+
modeArgs = ' --full';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Define and write the pre-commit shell script
|
|
47
|
+
const preCommitHookPath = path.join(hooksDir, 'pre-commit');
|
|
48
|
+
const preCommitContent = `#!/bin/sh
|
|
49
|
+
# devsplain native pre-commit hook
|
|
50
|
+
echo "Running pre-commit tests..."
|
|
51
|
+
npm test || exit 1
|
|
52
|
+
`;
|
|
53
|
+
fs.writeFileSync(preCommitHookPath, preCommitContent);
|
|
54
|
+
// Ensure the shell script is executable by the system
|
|
55
|
+
try {
|
|
56
|
+
fs.chmodSync(preCommitHookPath, 0o755);
|
|
57
|
+
} catch (err) {}
|
|
58
|
+
|
|
59
|
+
// Define and write the post-commit shell script
|
|
60
|
+
const postCommitHookPath = path.join(hooksDir, 'post-commit');
|
|
61
|
+
const postCommitContent = `#!/bin/sh
|
|
62
|
+
# devsplain native post-commit hook
|
|
63
|
+
echo "Auto-generating comments for files in the last commit..."
|
|
64
|
+
node bin/post-commit.js${modeArgs} || exit 1
|
|
65
|
+
`;
|
|
66
|
+
fs.writeFileSync(postCommitHookPath, postCommitContent);
|
|
67
|
+
// Ensure the post-commit shell script is executable
|
|
68
|
+
try {
|
|
69
|
+
fs.chmodSync(postCommitHookPath, 0o755);
|
|
70
|
+
} catch (err) {}
|
|
71
|
+
|
|
72
|
+
console.log('Success: Native Git pre-commit and post-commit hooks installed successfully!');
|
|
73
|
+
} catch (e) {
|
|
74
|
+
console.warn('Warning: Could not set up Git hooks (not inside a git repository or git command missing).');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Execute the hook installation sequence
|
|
79
|
+
installHooks();
|
package/lib/config.js
CHANGED
|
@@ -1,81 +1,125 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const os = require('os');
|
|
4
|
-
const readline = require('readline');
|
|
5
|
-
const configPath = path.join(os.homedir(), '.devsplainrc');
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
provider =
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
console.log("
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
provider
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const readline = require('readline');
|
|
5
|
+
const configPath = path.join(os.homedir(), '.devsplainrc');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Retrieves configuration from environment variables or a local config file.
|
|
9
|
+
* Runs a setup wizard if configuration is missing or forced.
|
|
10
|
+
* @param {boolean} forceWizard - Whether to force the interactive setup.
|
|
11
|
+
* @returns {Promise<Object>} The configuration object.
|
|
12
|
+
*/
|
|
13
|
+
async function getConfig(forceWizard = false) {
|
|
14
|
+
// Priority 1: Check environment variables for configuration
|
|
15
|
+
if (process.env.DEVSPLAIN_API_KEY || process.env.DEVSPLAIN_PROVIDER) {
|
|
16
|
+
const provider = process.env.DEVSPLAIN_PROVIDER || 'gemini';
|
|
17
|
+
const model = process.env.DEVSPLAIN_MODEL || (provider === 'gemini' ? 'gemini-2.0-flash' : 'llama-3.3-70b-versatile');
|
|
18
|
+
const baseUrl = process.env.DEVSPLAIN_BASE_URL || (provider === 'gemini' ? null : 'https://api.groq.com/openai');
|
|
19
|
+
return {
|
|
20
|
+
provider,
|
|
21
|
+
apiKey: process.env.DEVSPLAIN_API_KEY || '',
|
|
22
|
+
model,
|
|
23
|
+
baseUrl
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Priority 2: Check for existing config file or run setup wizard
|
|
28
|
+
if (!fs.existsSync(configPath) || forceWizard) {
|
|
29
|
+
const rl = readline.createInterface({
|
|
30
|
+
input: process.stdin,
|
|
31
|
+
output: process.stdout
|
|
32
|
+
});
|
|
33
|
+
// Promisify readline to allow async flow
|
|
34
|
+
const askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
|
|
35
|
+
|
|
36
|
+
let config = null;
|
|
37
|
+
let confirmed = false;
|
|
38
|
+
|
|
39
|
+
// Loop until user confirms configuration choices
|
|
40
|
+
while (!confirmed) {
|
|
41
|
+
let baseUrl = "";
|
|
42
|
+
let model = "";
|
|
43
|
+
let provider = "";
|
|
44
|
+
|
|
45
|
+
console.log("\nWhich AI Provider Do You want to use?");
|
|
46
|
+
console.log("1. Groq (Free, Fast, Llama-3)");
|
|
47
|
+
console.log("2. Gemini (Free Tier)");
|
|
48
|
+
console.log("3. OpenAI (Paid)");
|
|
49
|
+
console.log("4. Custom (Ollama, local, etc)");
|
|
50
|
+
|
|
51
|
+
const choice = await askQuestion("Select (1-4): ");
|
|
52
|
+
|
|
53
|
+
// Handle specific provider setup logic
|
|
54
|
+
if (choice === '1') {
|
|
55
|
+
provider = 'groq';
|
|
56
|
+
baseUrl = 'https://api.groq.com/openai';
|
|
57
|
+
console.log("\nGet your free Groq key here: https://console.groq.com/keys");
|
|
58
|
+
const customModel = await askQuestion("Model name (press Enter for default 'llama-3.3-70b-versatile'): ");
|
|
59
|
+
model = customModel.trim() || 'llama-3.3-70b-versatile';
|
|
60
|
+
} else if (choice === '2') {
|
|
61
|
+
provider = 'gemini';
|
|
62
|
+
baseUrl = null;
|
|
63
|
+
console.log("\nGet your free Gemini key here: https://aistudio.google.com/apikey");
|
|
64
|
+
const customModel = await askQuestion("Model name (press Enter for default 'gemini-2.0-flash'): ");
|
|
65
|
+
model = customModel.trim() || 'gemini-2.0-flash';
|
|
66
|
+
} else if (choice === '3') {
|
|
67
|
+
provider = 'openai';
|
|
68
|
+
baseUrl = 'https://api.openai.com';
|
|
69
|
+
console.log("\nGet your OpenAI key here: https://platform.openai.com/api-keys");
|
|
70
|
+
const customModel = await askQuestion("Model name (press Enter for default 'gpt-4o'): ");
|
|
71
|
+
model = customModel.trim() || 'gpt-4o';
|
|
72
|
+
} else if (choice === '4') {
|
|
73
|
+
provider = 'custom';
|
|
74
|
+
model = await askQuestion("Model name (e.g., llama3): ");
|
|
75
|
+
baseUrl = await askQuestion("Base URL (e.g., http://localhost:11434): ");
|
|
76
|
+
} else {
|
|
77
|
+
console.log("Invalid choice. Please select 1, 2, 3, or 4.");
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const apiKey = await askQuestion("Paste your API key (leave blank for local models): ");
|
|
82
|
+
|
|
83
|
+
console.log("\n--- Configuration Summary ---");
|
|
84
|
+
console.log(`Provider: ${provider}`);
|
|
85
|
+
console.log(`Model: ${model}`);
|
|
86
|
+
console.log(`Base URL: ${baseUrl || 'N/A'}`);
|
|
87
|
+
// Mask API key for security in display
|
|
88
|
+
console.log(`API Key: ${apiKey ? apiKey.substring(0, 4) + '*'.repeat(Math.max(0, apiKey.length - 4)) : 'None'}`);
|
|
89
|
+
console.log("-----------------------------\n");
|
|
90
|
+
|
|
91
|
+
const confirm = await askQuestion("Does this look correct? (y/n, default: y): ");
|
|
92
|
+
if (confirm.toLowerCase() === 'y' || confirm.trim() === '') {
|
|
93
|
+
config = {
|
|
94
|
+
provider,
|
|
95
|
+
apiKey,
|
|
96
|
+
model,
|
|
97
|
+
baseUrl
|
|
98
|
+
};
|
|
99
|
+
confirmed = true;
|
|
100
|
+
} else {
|
|
101
|
+
console.log("Let's restart the configuration setup.");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
rl.close();
|
|
106
|
+
|
|
107
|
+
// Save finalized config to home directory
|
|
108
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
109
|
+
try {
|
|
110
|
+
// Ensure config file is not readable by other users (POSIX only)
|
|
111
|
+
if (process.platform !== 'win32') {
|
|
112
|
+
fs.chmodSync(configPath, 0o600);
|
|
113
|
+
}
|
|
114
|
+
} catch (chmodErr) {
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return config;
|
|
118
|
+
} else {
|
|
119
|
+
// Priority 3: Load from existing file
|
|
120
|
+
const rawData = fs.readFileSync(configPath, 'utf8');
|
|
121
|
+
return JSON.parse(rawData);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
81
125
|
module.exports = { getConfig };
|