@mattgraba/dev-toolkit 2.0.1

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.
@@ -0,0 +1,107 @@
1
+ // utils/fileScanner.js
2
+
3
+ import fg from "fast-glob";
4
+ import fs from "fs";
5
+ import path from "path";
6
+ import ignore from "ignore";
7
+
8
+ // Files that must never be sent to an AI API, gitignored or not.
9
+ // .gitignore handles the common cases; this is the safety net for
10
+ // credential-shaped files that a project forgot to ignore.
11
+ const SECRET_FILE_PATTERNS = [
12
+ /\.pem$/i,
13
+ /\.key$/i,
14
+ /\.p12$/i,
15
+ /\.pfx$/i,
16
+ /id_rsa/i,
17
+ /id_ed25519/i,
18
+ /credential/i,
19
+ /secret/i,
20
+ /\.env(\.|$)/i,
21
+ ];
22
+
23
+ function looksLikeSecretFile(relativePath) {
24
+ return SECRET_FILE_PATTERNS.some((pattern) => pattern.test(relativePath));
25
+ }
26
+
27
+ /**
28
+ * Reads and parses .gitignore from the provided directory
29
+ */
30
+ function loadGitignore(directory) {
31
+ const ig = ignore();
32
+ const gitignorePath = path.join(directory, ".gitignore");
33
+
34
+ if (fs.existsSync(gitignorePath)) {
35
+ const gitignoreContent = fs.readFileSync(gitignorePath, "utf-8");
36
+ ig.add(gitignoreContent.split(/\r?\n/));
37
+ }
38
+
39
+ return ig;
40
+ }
41
+
42
+ /**
43
+ * Scan project directory for relevant files
44
+ * @param {Object} options
45
+ * @param {string} options.directory - root directory to scan
46
+ * @param {string[]} options.extensions - file extensions to include
47
+ * @param {number} options.maxFileSizeKB - max file size to include
48
+ * @param {number} options.maxFiles - max number of files to include
49
+ * @param {string[]} options.exclude - folders to exclude
50
+ * @returns {Promise<Array<{ name: string, content: string }>>}
51
+ */
52
+ async function scanFiles({
53
+ directory = ".",
54
+ extensions = ["js", "ts", "json"],
55
+ maxFileSizeKB = 20,
56
+ maxFiles = 50,
57
+ exclude = ["node_modules", ".git", "dist", "build"]
58
+ }) {
59
+ // Match all files with specified extensions
60
+ const patterns = extensions.map((ext) => `**/*.${ext}`);
61
+ const excludePatterns = exclude.map((dir) => `${dir}/**`);
62
+
63
+ // Build ignore patterns like "node_modules/**"
64
+ const gitignore = loadGitignore(directory);
65
+
66
+ const entries = await fg(patterns, {
67
+ cwd: directory,
68
+ ignore: excludePatterns,
69
+ dot: false, // Ignore hidden files like .env, .vscode
70
+ onlyFiles: true,
71
+ absolute: true, // Easier for fs.readFileSync
72
+ });
73
+
74
+ const results = [];
75
+
76
+ for (const absolutePath of entries) {
77
+ if (results.length >= maxFiles) {
78
+ console.warn(`⚠️ Context capped at ${maxFiles} files; remaining files skipped.`);
79
+ break;
80
+ }
81
+
82
+ try {
83
+ const relativePath = path.relative(directory, absolutePath);
84
+
85
+ if (gitignore.ignores(relativePath)) continue;
86
+
87
+ if (looksLikeSecretFile(relativePath)) {
88
+ console.warn(`⚠️ Skipping ${relativePath}: filename looks credential-related`);
89
+ continue;
90
+ }
91
+
92
+ const stats = fs.statSync(absolutePath);
93
+ const sizeKB = stats.size / 1024;
94
+
95
+ if (sizeKB <= maxFileSizeKB) {
96
+ const content = fs.readFileSync(absolutePath, "utf-8");
97
+ results.push({ name: relativePath, content });
98
+ }
99
+ } catch (err) {
100
+ console.warn(`Skipping file ${absolutePath}: ${err.message}`);
101
+ }
102
+ }
103
+
104
+ return results;
105
+ }
106
+
107
+ export { scanFiles };
@@ -0,0 +1,146 @@
1
+ // cli/utils/formatBox.js
2
+ // Boxed rendering for analyze results, ported from the stranded pre-v2
3
+ // "structured boxed output" work (e20e5fe) onto the v2 CLI structure.
4
+
5
+ import chalk from 'chalk';
6
+
7
+ /**
8
+ * Word-wraps text to fit within a given width, preserving existing line breaks.
9
+ */
10
+ function wordWrap(text, maxWidth) {
11
+ const lines = [];
12
+ for (const rawLine of text.split('\n')) {
13
+ if (rawLine.length <= maxWidth) {
14
+ lines.push(rawLine);
15
+ continue;
16
+ }
17
+ const words = rawLine.split(' ');
18
+ let current = '';
19
+ for (const word of words) {
20
+ if (current.length + word.length + 1 > maxWidth) {
21
+ lines.push(current);
22
+ current = word;
23
+ } else {
24
+ current = current ? `${current} ${word}` : word;
25
+ }
26
+ }
27
+ if (current) lines.push(current);
28
+ }
29
+ return lines;
30
+ }
31
+
32
+ /**
33
+ * Renders a boxed analysis result:
34
+ *
35
+ * ┌─ Analysis Result ──────────────────────────────┐
36
+ * │ │
37
+ * │ File: ./brokenFunction.ts │
38
+ * │ Status: 2 issues found │
39
+ * │ │
40
+ * │ [1] Line 14 — Uncaught promise rejection │
41
+ * │ description text... │
42
+ * │ │
43
+ * │ Suggestion: │
44
+ * │ ... │
45
+ * │ │
46
+ * └────────────────────────────────────────────────┘
47
+ */
48
+ export function renderAnalysisBox({ filePath, issues = [], suggestion = '' }) {
49
+ const termWidth = Math.min(process.stdout.columns || 80, 80);
50
+ const boxWidth = Math.max(termWidth - 4, 40); // leave margin
51
+ const innerWidth = boxWidth - 4; // 2 for "│ " and 2 for " │"
52
+ const pad = 2; // left padding inside box
53
+
54
+ const border = chalk.cyan;
55
+ const padStr = ' '.repeat(pad);
56
+
57
+ // Build content lines (plain strings, padded at render time)
58
+ const contentLines = [''];
59
+
60
+ contentLines.push(`File: ${filePath}`);
61
+ const issueCount = issues.length;
62
+ const statusText = issueCount === 0
63
+ ? 'No issues found'
64
+ : `${issueCount} issue${issueCount !== 1 ? 's' : ''} found`;
65
+ contentLines.push(`Status: ${statusText}`);
66
+ contentLines.push('');
67
+
68
+ for (let i = 0; i < issues.length; i++) {
69
+ const issue = issues[i];
70
+ const lineRef = issue.line != null ? `Line ${issue.line}` : 'General';
71
+ const header = `[${i + 1}] ${lineRef} — ${issue.title}`;
72
+
73
+ for (const wl of wordWrap(header, innerWidth)) {
74
+ contentLines.push(wl);
75
+ }
76
+
77
+ if (issue.detail) {
78
+ for (const dl of wordWrap(issue.detail, innerWidth - 4)) {
79
+ contentLines.push(` ${dl}`);
80
+ }
81
+ }
82
+
83
+ contentLines.push('');
84
+ }
85
+
86
+ if (suggestion) {
87
+ contentLines.push(chalk.green('Suggestion:'));
88
+ for (const sl of wordWrap(suggestion, innerWidth - 4)) {
89
+ contentLines.push(` ${sl}`);
90
+ }
91
+ contentLines.push('');
92
+ }
93
+
94
+ // Row layout: " │" + pad + innerWidth + pad + "│" — so the horizontal
95
+ // span between the corners is pad + innerWidth + pad = boxWidth.
96
+ const titleText = ' Analysis Result ';
97
+ const top = border(` ┌─${titleText}${'─'.repeat(Math.max(boxWidth - 1 - titleText.length, 0))}┐`);
98
+ const bottom = border(` └${'─'.repeat(boxWidth)}┘`);
99
+
100
+ const output = [top];
101
+
102
+ for (const line of contentLines) {
103
+ // Strip ANSI for length calculation
104
+ const plainLine = line.replace(new RegExp(String.fromCharCode(27) + '\[[0-9;]*m', 'g'), '');
105
+ const rightPad = Math.max(innerWidth - plainLine.length, 0);
106
+ output.push(` ${border('│')}${padStr}${line}${' '.repeat(rightPad)}${padStr}${border('│')}`);
107
+ }
108
+
109
+ output.push(bottom);
110
+
111
+ console.log(output.join('\n'));
112
+ }
113
+
114
+ /**
115
+ * Parses a structured JSON analysis response from the AI.
116
+ * Handles markdown code fences and falls back gracefully to a
117
+ * single-issue shape when the model didn't return valid JSON.
118
+ */
119
+ export function parseAnalysisJSON(rawText) {
120
+ let text = rawText.trim();
121
+ const fenceMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)```/i);
122
+ if (fenceMatch) {
123
+ text = fenceMatch[1].trim();
124
+ }
125
+
126
+ try {
127
+ const parsed = JSON.parse(text);
128
+ if (parsed.issues && Array.isArray(parsed.issues)) {
129
+ return {
130
+ issues: parsed.issues.map(i => ({
131
+ line: i.line ?? null,
132
+ title: i.title || 'Issue',
133
+ detail: i.detail || '',
134
+ })),
135
+ suggestion: parsed.suggestion || '',
136
+ };
137
+ }
138
+ } catch {
139
+ // Fall through to fallback
140
+ }
141
+
142
+ return {
143
+ issues: [{ line: null, title: 'Analysis', detail: rawText.trim() }],
144
+ suggestion: '',
145
+ };
146
+ }
@@ -0,0 +1,13 @@
1
+ // utils/fsUtils.js
2
+ import fs from 'fs';
3
+ import chalk from 'chalk';
4
+
5
+ function checkFileExists(filePath) {
6
+ if (!fs.existsSync(filePath)) {
7
+ console.error(chalk.red(`❌ File not found: ${filePath}`));
8
+ return false;
9
+ }
10
+ return true;
11
+ }
12
+
13
+ export { checkFileExists };
@@ -0,0 +1,24 @@
1
+ import { getToken } from './configManager.js';
2
+ import { appendLocalHistory } from './localHistory.js';
3
+
4
+ /**
5
+ * Saves a command result to history.
6
+ *
7
+ * Hosted mode: the server already records history inside each AI route,
8
+ * so the CLI does nothing here (posting to /history as well would create
9
+ * duplicate entries — which is exactly what earlier versions did).
10
+ *
11
+ * BYOK mode (no login token): history is saved locally instead.
12
+ */
13
+ async function saveToHistory({ command, input, output }) {
14
+ if (getToken()) return; // hosted mode — server-side history already saved
15
+
16
+ try {
17
+ appendLocalHistory({ command, input, output });
18
+ } catch (err) {
19
+ // History must never break the main command, but don't hide the failure
20
+ console.warn(`⚠️ Could not save history: ${err.message}`);
21
+ }
22
+ }
23
+
24
+ export default saveToHistory;
@@ -0,0 +1,44 @@
1
+ // cli/utils/localHistory.js
2
+ // File-based history for BYOK mode — no account or server required.
3
+ // Hosted-mode history lives server-side; this is the local equivalent.
4
+
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { CONFIG_DIR, SECRET_FILE_MODE } from './configManager.js';
8
+
9
+ const HISTORY_FILE = path.join(CONFIG_DIR, 'history.json');
10
+ const MAX_ENTRIES = 100;
11
+
12
+ function readLocalHistory() {
13
+ if (!fs.existsSync(HISTORY_FILE)) {
14
+ return [];
15
+ }
16
+ try {
17
+ const entries = JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8'));
18
+ return Array.isArray(entries) ? entries : [];
19
+ } catch {
20
+ return [];
21
+ }
22
+ }
23
+
24
+ function appendLocalHistory({ command, input, output }) {
25
+ if (!fs.existsSync(CONFIG_DIR)) {
26
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
27
+ }
28
+
29
+ const entries = readLocalHistory();
30
+ entries.unshift({
31
+ command,
32
+ input,
33
+ output,
34
+ createdAt: new Date().toISOString(),
35
+ });
36
+
37
+ // History can contain source code — same owner-only mode as the config file.
38
+ fs.writeFileSync(HISTORY_FILE, JSON.stringify(entries.slice(0, MAX_ENTRIES), null, 2), {
39
+ encoding: 'utf-8',
40
+ mode: SECRET_FILE_MODE,
41
+ });
42
+ }
43
+
44
+ export { HISTORY_FILE, MAX_ENTRIES, readLocalHistory, appendLocalHistory };
@@ -0,0 +1,74 @@
1
+ // cli/utils/promptPassword.js
2
+ // Masked password input using raw-mode stdin — no dependency needed.
3
+ // Echoes '*' per keystroke, supports backspace and Ctrl+C.
4
+
5
+ const CTRL_C = '';
6
+ const BACKSPACE = '';
7
+
8
+ function promptPassword(promptText) {
9
+ return new Promise((resolve, reject) => {
10
+ const { stdin, stdout } = process;
11
+
12
+ stdout.write(promptText);
13
+
14
+ if (!stdin.isTTY) {
15
+ // Piped input (tests, scripts): read one line without raw mode
16
+ let piped = '';
17
+ stdin.setEncoding('utf-8');
18
+ stdin.on('data', function onData(chunk) {
19
+ piped += chunk;
20
+ const newline = piped.indexOf('\n');
21
+ if (newline !== -1) {
22
+ stdin.removeListener('data', onData);
23
+ stdin.pause();
24
+ resolve(piped.slice(0, newline).replace(/\r$/, ''));
25
+ }
26
+ });
27
+ return;
28
+ }
29
+
30
+ stdin.setRawMode(true);
31
+ stdin.resume();
32
+ stdin.setEncoding('utf-8');
33
+
34
+ let password = '';
35
+
36
+ function done(err, value) {
37
+ stdin.setRawMode(false);
38
+ stdin.pause();
39
+ stdin.removeListener('data', onKeypress);
40
+ stdout.write('\n');
41
+ if (err) reject(err);
42
+ else resolve(value);
43
+ }
44
+
45
+ function onKeypress(char) {
46
+ switch (char) {
47
+ case '\r':
48
+ case '\n':
49
+ done(null, password);
50
+ break;
51
+ case CTRL_C:
52
+ done(new Error('Cancelled'));
53
+ break;
54
+ case BACKSPACE:
55
+ case '\b':
56
+ if (password.length > 0) {
57
+ password = password.slice(0, -1);
58
+ stdout.write('\b \b');
59
+ }
60
+ break;
61
+ default:
62
+ // Ignore other control characters (arrows etc.)
63
+ if (char >= ' ') {
64
+ password += char;
65
+ stdout.write('*');
66
+ }
67
+ }
68
+ }
69
+
70
+ stdin.on('data', onKeypress);
71
+ });
72
+ }
73
+
74
+ export default promptPassword;
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@mattgraba/dev-toolkit",
3
+ "version": "2.0.1",
4
+ "description": "Dev Toolkit — an AI-assisted developer CLI for analyzing errors, debugging issues, and scaffolding projects. BYOK-first; optional hosted mode.",
5
+ "type": "module",
6
+ "bin": {
7
+ "devtk": "bin/devtk.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "cli",
12
+ "!cli/tests",
13
+ "README.md",
14
+ "CHANGELOG.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "dev:cli": "node cli/cli.js",
19
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --detectOpenHandles --forceExit"
20
+ },
21
+ "keywords": [
22
+ "cli",
23
+ "developer-tools",
24
+ "ai",
25
+ "debugging",
26
+ "productivity",
27
+ "openai",
28
+ "byok",
29
+ "open-source"
30
+ ],
31
+ "author": "Matt Graba",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/mattgraba/dev-toolkit.git"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/mattgraba/dev-toolkit/issues"
39
+ },
40
+ "homepage": "https://github.com/mattgraba/dev-toolkit#readme",
41
+ "engines": {
42
+ "node": ">=18"
43
+ },
44
+ "preferGlobal": true,
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "jest": {
49
+ "testEnvironment": "node",
50
+ "transform": {},
51
+ "testPathIgnorePatterns": [
52
+ "/node_modules/",
53
+ "/server/",
54
+ "/client/"
55
+ ]
56
+ },
57
+ "dependencies": {
58
+ "axios": "^1.10.0",
59
+ "chalk": "^4.1.2",
60
+ "commander": "^14.0.0",
61
+ "dotenv": "^17.2.0",
62
+ "fast-glob": "^3.3.3",
63
+ "ignore": "^7.0.5",
64
+ "openai": "^5.20.3",
65
+ "ora": "^5.4.1"
66
+ },
67
+ "devDependencies": {
68
+ "jest": "^30.0.4"
69
+ }
70
+ }