brain-dev 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 +152 -0
- package/agents/brain-checker.md +33 -0
- package/agents/brain-debugger.md +35 -0
- package/agents/brain-executor.md +37 -0
- package/agents/brain-mapper.md +44 -0
- package/agents/brain-planner.md +49 -0
- package/agents/brain-researcher.md +47 -0
- package/agents/brain-synthesizer.md +43 -0
- package/agents/brain-verifier.md +41 -0
- package/bin/brain-tools.cjs +185 -0
- package/bin/lib/adr.cjs +283 -0
- package/bin/lib/agents.cjs +152 -0
- package/bin/lib/anti-patterns.cjs +183 -0
- package/bin/lib/audit.cjs +268 -0
- package/bin/lib/commands/adr.cjs +126 -0
- package/bin/lib/commands/complete.cjs +270 -0
- package/bin/lib/commands/config.cjs +306 -0
- package/bin/lib/commands/discuss.cjs +237 -0
- package/bin/lib/commands/execute.cjs +415 -0
- package/bin/lib/commands/health.cjs +103 -0
- package/bin/lib/commands/map.cjs +101 -0
- package/bin/lib/commands/new-project.cjs +885 -0
- package/bin/lib/commands/pause.cjs +142 -0
- package/bin/lib/commands/phase-manage.cjs +357 -0
- package/bin/lib/commands/plan.cjs +451 -0
- package/bin/lib/commands/progress.cjs +167 -0
- package/bin/lib/commands/quick.cjs +447 -0
- package/bin/lib/commands/resume.cjs +196 -0
- package/bin/lib/commands/storm.cjs +590 -0
- package/bin/lib/commands/verify.cjs +504 -0
- package/bin/lib/commands.cjs +263 -0
- package/bin/lib/complexity.cjs +138 -0
- package/bin/lib/complexity.test.cjs +108 -0
- package/bin/lib/config.cjs +452 -0
- package/bin/lib/core.cjs +62 -0
- package/bin/lib/detect.cjs +603 -0
- package/bin/lib/git.cjs +112 -0
- package/bin/lib/health.cjs +356 -0
- package/bin/lib/init.cjs +310 -0
- package/bin/lib/logger.cjs +100 -0
- package/bin/lib/platform.cjs +58 -0
- package/bin/lib/requirements.cjs +158 -0
- package/bin/lib/roadmap.cjs +228 -0
- package/bin/lib/security.cjs +237 -0
- package/bin/lib/state.cjs +353 -0
- package/bin/lib/templates.cjs +48 -0
- package/bin/templates/advocate.md +182 -0
- package/bin/templates/checkpoint.md +55 -0
- package/bin/templates/debugger.md +148 -0
- package/bin/templates/discuss.md +60 -0
- package/bin/templates/executor.md +201 -0
- package/bin/templates/mapper.md +129 -0
- package/bin/templates/plan-checker.md +134 -0
- package/bin/templates/planner.md +165 -0
- package/bin/templates/researcher.md +78 -0
- package/bin/templates/storm.html +376 -0
- package/bin/templates/synthesis.md +30 -0
- package/bin/templates/verifier.md +181 -0
- package/commands/brain/adr.md +34 -0
- package/commands/brain/complete.md +37 -0
- package/commands/brain/config.md +37 -0
- package/commands/brain/discuss.md +35 -0
- package/commands/brain/execute.md +38 -0
- package/commands/brain/health.md +33 -0
- package/commands/brain/map.md +35 -0
- package/commands/brain/new-project.md +38 -0
- package/commands/brain/pause.md +26 -0
- package/commands/brain/plan.md +38 -0
- package/commands/brain/progress.md +28 -0
- package/commands/brain/quick.md +51 -0
- package/commands/brain/resume.md +28 -0
- package/commands/brain/storm.md +30 -0
- package/commands/brain/verify.md +39 -0
- package/hooks/bootstrap.sh +54 -0
- package/hooks/post-tool-use.sh +45 -0
- package/hooks/statusline.sh +130 -0
- package/package.json +36 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { execSync } = require('node:child_process');
|
|
6
|
+
const { isGitRepo } = require('./git.cjs');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Manifest files with tier-based priority.
|
|
10
|
+
* Backend manifests take priority over frontend tooling (package.json).
|
|
11
|
+
* When both composer.json and package.json exist, composer.json is primary.
|
|
12
|
+
*/
|
|
13
|
+
const MANIFEST_FILES = [
|
|
14
|
+
// Tier: backend — language-specific manifests (primary stack)
|
|
15
|
+
{ file: 'composer.json', language: 'php', tier: 'backend' },
|
|
16
|
+
{ file: 'Gemfile', language: 'ruby', tier: 'backend' },
|
|
17
|
+
{ file: 'go.mod', language: 'go', tier: 'backend' },
|
|
18
|
+
{ file: 'Cargo.toml', language: 'rust', tier: 'backend' },
|
|
19
|
+
{ file: 'pom.xml', language: 'java', tier: 'backend' },
|
|
20
|
+
{ file: 'build.gradle', language: 'java', tier: 'backend' },
|
|
21
|
+
{ file: 'mix.exs', language: 'elixir', tier: 'backend' },
|
|
22
|
+
{ file: 'pyproject.toml', language: 'python', tier: 'backend' },
|
|
23
|
+
{ file: 'requirements.txt', language: 'python', tier: 'backend' },
|
|
24
|
+
{ file: 'setup.py', language: 'python', tier: 'backend' },
|
|
25
|
+
{ file: 'pubspec.yaml', language: 'dart', tier: 'backend' },
|
|
26
|
+
// Tier: frontend — often tooling alongside a backend manifest
|
|
27
|
+
{ file: 'package.json', language: 'javascript', tier: 'frontend' }
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Framework detection from package.json dependency names.
|
|
32
|
+
*/
|
|
33
|
+
const FRAMEWORK_MAP = {
|
|
34
|
+
'next': 'Next.js',
|
|
35
|
+
'@next/': 'Next.js',
|
|
36
|
+
'nuxt': 'Nuxt',
|
|
37
|
+
'react-native': 'React Native',
|
|
38
|
+
'expo': 'Expo',
|
|
39
|
+
'react': 'React',
|
|
40
|
+
'react-dom': 'React',
|
|
41
|
+
'vue': 'Vue.js',
|
|
42
|
+
'@angular/core': 'Angular',
|
|
43
|
+
'svelte': 'Svelte',
|
|
44
|
+
'@sveltejs/kit': 'SvelteKit',
|
|
45
|
+
'express': 'Express',
|
|
46
|
+
'fastify': 'Fastify',
|
|
47
|
+
'@nestjs/core': 'NestJS',
|
|
48
|
+
'hono': 'Hono',
|
|
49
|
+
'koa': 'Koa',
|
|
50
|
+
'remix': 'Remix',
|
|
51
|
+
'@remix-run/': 'Remix',
|
|
52
|
+
'gatsby': 'Gatsby',
|
|
53
|
+
'astro': 'Astro',
|
|
54
|
+
'laravel-mix': 'Laravel',
|
|
55
|
+
'laravel-vite-plugin': 'Laravel',
|
|
56
|
+
'@inertiajs/vue3': 'Inertia.js',
|
|
57
|
+
'@inertiajs/react': 'Inertia.js',
|
|
58
|
+
'electron': 'Electron'
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Feature detection from npm dependency names.
|
|
63
|
+
*/
|
|
64
|
+
const FEATURE_MAP = {
|
|
65
|
+
'prisma': 'Database/ORM',
|
|
66
|
+
'@prisma/client': 'Database/ORM',
|
|
67
|
+
'typeorm': 'Database/ORM',
|
|
68
|
+
'sequelize': 'Database/ORM',
|
|
69
|
+
'mongoose': 'Database/ORM',
|
|
70
|
+
'drizzle-orm': 'Database/ORM',
|
|
71
|
+
'knex': 'Database/ORM',
|
|
72
|
+
'passport': 'Authentication',
|
|
73
|
+
'next-auth': 'Authentication',
|
|
74
|
+
'@auth/': 'Authentication',
|
|
75
|
+
'jsonwebtoken': 'Authentication',
|
|
76
|
+
'bcrypt': 'Authentication',
|
|
77
|
+
'stripe': 'Payments',
|
|
78
|
+
'@stripe/': 'Payments',
|
|
79
|
+
'socket.io': 'Real-time',
|
|
80
|
+
'ws': 'Real-time',
|
|
81
|
+
'pusher': 'Real-time',
|
|
82
|
+
'bull': 'Job Queues',
|
|
83
|
+
'bullmq': 'Job Queues',
|
|
84
|
+
'redis': 'Caching',
|
|
85
|
+
'ioredis': 'Caching',
|
|
86
|
+
'jest': 'Testing',
|
|
87
|
+
'vitest': 'Testing',
|
|
88
|
+
'mocha': 'Testing',
|
|
89
|
+
'@testing-library/': 'Testing',
|
|
90
|
+
'tailwindcss': 'Tailwind CSS',
|
|
91
|
+
'styled-components': 'CSS-in-JS',
|
|
92
|
+
'@emotion/': 'CSS-in-JS',
|
|
93
|
+
'graphql': 'GraphQL',
|
|
94
|
+
'@apollo/': 'GraphQL',
|
|
95
|
+
'trpc': 'tRPC',
|
|
96
|
+
'@trpc/': 'tRPC'
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Feature detection from composer.json dependency names.
|
|
101
|
+
*/
|
|
102
|
+
const COMPOSER_FEATURE_MAP = {
|
|
103
|
+
'laravel/sanctum': 'Authentication',
|
|
104
|
+
'laravel/passport': 'Authentication',
|
|
105
|
+
'laravel/fortify': 'Authentication',
|
|
106
|
+
'laravel/socialite': 'OAuth',
|
|
107
|
+
'laravel/horizon': 'Job Queues',
|
|
108
|
+
'laravel/cashier': 'Payments',
|
|
109
|
+
'laravel/scout': 'Search',
|
|
110
|
+
'laravel/telescope': 'Debugging',
|
|
111
|
+
'predis/predis': 'Caching',
|
|
112
|
+
'doctrine/orm': 'Database/ORM',
|
|
113
|
+
'doctrine/dbal': 'Database/ORM',
|
|
114
|
+
'symfony/mailer': 'Email',
|
|
115
|
+
'phpunit/phpunit': 'Testing',
|
|
116
|
+
'pestphp/pest': 'Testing',
|
|
117
|
+
'spatie/laravel-permission': 'Authorization',
|
|
118
|
+
'spatie/laravel-medialibrary': 'File Storage'
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Frontend framework detection (when package.json is secondary manifest).
|
|
123
|
+
*/
|
|
124
|
+
const FE_FRAMEWORK_MAP = {
|
|
125
|
+
'vue': 'Vue.js',
|
|
126
|
+
'react': 'React',
|
|
127
|
+
'react-dom': 'React',
|
|
128
|
+
'@angular/core': 'Angular',
|
|
129
|
+
'svelte': 'Svelte',
|
|
130
|
+
'alpinejs': 'Alpine.js',
|
|
131
|
+
'livewire': 'Livewire'
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const BUNDLER_MAP = { 'vite': 'Vite', 'webpack': 'Webpack', 'esbuild': 'esbuild', 'turbo': 'Turborepo', 'parcel': 'Parcel' };
|
|
135
|
+
const CSS_MAP = { 'tailwindcss': 'Tailwind CSS', 'styled-components': 'CSS-in-JS', '@emotion/react': 'CSS-in-JS', 'sass': 'Sass', 'less': 'Less' };
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Common source code directories to check.
|
|
139
|
+
*/
|
|
140
|
+
const CODE_DIRS = ['src', 'lib', 'app', 'pages', 'components', 'api', 'cmd', 'pkg', 'internal', 'server', 'client', 'modules', 'routes', 'resources', 'database'];
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Source file extensions to count.
|
|
144
|
+
*/
|
|
145
|
+
const CODE_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.rs', '.java', '.rb', '.php', '.ex', '.exs', '.dart', '.swift', '.kt', '.cs', '.vue', '.svelte', '.blade.php']);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Detect project type, stack, and workspace from a directory.
|
|
149
|
+
* Supports multi-manifest detection (e.g., composer.json + package.json for Laravel).
|
|
150
|
+
* Lightweight, synchronous — no agent spawning.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} rootDir - Project root directory
|
|
153
|
+
* @returns {{ type: string, signals: object, stack: object, features: string[], workspace: object|null, summary: string }}
|
|
154
|
+
*/
|
|
155
|
+
function detectProject(rootDir) {
|
|
156
|
+
// 1. Collect ALL present manifests (no break on first match)
|
|
157
|
+
const foundManifests = [];
|
|
158
|
+
for (const entry of MANIFEST_FILES) {
|
|
159
|
+
if (fs.existsSync(path.join(rootDir, entry.file))) {
|
|
160
|
+
foundManifests.push(entry);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Determine primary (backend) and frontend manifests
|
|
165
|
+
const primaryManifest = foundManifests.find(m => m.tier === 'backend') || foundManifests[0] || null;
|
|
166
|
+
const frontendManifest = foundManifests.find(m => m.tier === 'frontend' && m !== primaryManifest) || null;
|
|
167
|
+
|
|
168
|
+
const signals = {
|
|
169
|
+
manifest: primaryManifest ? primaryManifest.file : null,
|
|
170
|
+
manifests: foundManifests.map(m => m.file),
|
|
171
|
+
git: false,
|
|
172
|
+
commitCount: 0,
|
|
173
|
+
codeFiles: 0,
|
|
174
|
+
codeDirs: []
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const stack = {
|
|
178
|
+
primary: { language: null, framework: null, runtime: null, dependencies: [] },
|
|
179
|
+
frontend: null
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const features = [];
|
|
183
|
+
|
|
184
|
+
// 2. Parse primary manifest
|
|
185
|
+
if (primaryManifest) {
|
|
186
|
+
stack.primary.language = primaryManifest.language;
|
|
187
|
+
|
|
188
|
+
if (primaryManifest.file === 'package.json') {
|
|
189
|
+
const pkgInfo = parsePackageJson(rootDir);
|
|
190
|
+
if (pkgInfo) {
|
|
191
|
+
stack.primary.framework = pkgInfo.framework;
|
|
192
|
+
stack.primary.runtime = pkgInfo.runtime;
|
|
193
|
+
stack.primary.dependencies = pkgInfo.topDeps;
|
|
194
|
+
if (pkgInfo.hasTypeScript) stack.primary.language = 'typescript';
|
|
195
|
+
// Feature detection from npm deps
|
|
196
|
+
addFeaturesFromDeps(pkgInfo.allDeps, FEATURE_MAP, features);
|
|
197
|
+
}
|
|
198
|
+
} else if (primaryManifest.file === 'composer.json') {
|
|
199
|
+
const composerInfo = parseComposerJson(rootDir);
|
|
200
|
+
if (composerInfo) {
|
|
201
|
+
stack.primary.framework = composerInfo.framework;
|
|
202
|
+
stack.primary.dependencies = composerInfo.topDeps;
|
|
203
|
+
// Feature detection from composer deps
|
|
204
|
+
addFeaturesFromDeps(composerInfo.allDeps, COMPOSER_FEATURE_MAP, features);
|
|
205
|
+
}
|
|
206
|
+
} else if (primaryManifest.file === 'go.mod') {
|
|
207
|
+
const goInfo = parseGoMod(rootDir);
|
|
208
|
+
if (goInfo) {
|
|
209
|
+
stack.primary.dependencies = goInfo.deps;
|
|
210
|
+
stack.primary.runtime = 'go';
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 3. Parse frontend manifest (when separate from primary)
|
|
216
|
+
if (frontendManifest && frontendManifest !== primaryManifest) {
|
|
217
|
+
const pkgInfo = parsePackageJson(rootDir);
|
|
218
|
+
if (pkgInfo) {
|
|
219
|
+
stack.frontend = extractFrontendInfo(pkgInfo);
|
|
220
|
+
// Also detect features from npm deps
|
|
221
|
+
addFeaturesFromDeps(pkgInfo.allDeps, FEATURE_MAP, features);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 4. Backward compat: expose flat stack properties from primary
|
|
226
|
+
stack.language = stack.primary.language;
|
|
227
|
+
stack.framework = stack.primary.framework;
|
|
228
|
+
stack.runtime = stack.primary.runtime;
|
|
229
|
+
stack.dependencies = stack.primary.dependencies;
|
|
230
|
+
|
|
231
|
+
// 5. Git detection
|
|
232
|
+
signals.git = isGitRepo(rootDir);
|
|
233
|
+
if (signals.git) {
|
|
234
|
+
signals.commitCount = getCommitCount(rootDir);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 6. Code directory detection
|
|
238
|
+
for (const dir of CODE_DIRS) {
|
|
239
|
+
const dirPath = path.join(rootDir, dir);
|
|
240
|
+
if (fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) {
|
|
241
|
+
signals.codeDirs.push(dir + '/');
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 7. Code file counting
|
|
246
|
+
signals.codeFiles = countCodeFiles(rootDir, signals.codeDirs);
|
|
247
|
+
|
|
248
|
+
// 8. Classification
|
|
249
|
+
const hasDeps = signals.manifest && stack.primary.dependencies.length > 0;
|
|
250
|
+
const hasCode = signals.codeDirs.length > 0 && signals.codeFiles > 0;
|
|
251
|
+
const hasHistory = signals.git && signals.commitCount >= 5;
|
|
252
|
+
|
|
253
|
+
const type = (hasDeps || hasCode || hasHistory) ? 'brownfield' : 'greenfield';
|
|
254
|
+
|
|
255
|
+
// 9. Workspace detection (sibling projects)
|
|
256
|
+
const workspace = detectWorkspace(rootDir);
|
|
257
|
+
|
|
258
|
+
// 10. Summary
|
|
259
|
+
const summary = buildSummary(type, stack, signals, features);
|
|
260
|
+
|
|
261
|
+
return { type, signals, stack, features, workspace, summary };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Add features from dependency list using a feature map.
|
|
266
|
+
* @param {string[]} deps
|
|
267
|
+
* @param {object} featureMap
|
|
268
|
+
* @param {string[]} features - mutated
|
|
269
|
+
*/
|
|
270
|
+
function addFeaturesFromDeps(deps, featureMap, features) {
|
|
271
|
+
for (const dep of deps) {
|
|
272
|
+
for (const [pattern, feature] of Object.entries(featureMap)) {
|
|
273
|
+
if (dep === pattern || dep.startsWith(pattern)) {
|
|
274
|
+
if (!features.includes(feature)) {
|
|
275
|
+
features.push(feature);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Extract frontend-specific info from package.json when it's the secondary manifest.
|
|
284
|
+
* @param {object} pkgInfo - Result from parsePackageJson()
|
|
285
|
+
* @returns {{ framework: string|null, bundler: string|null, css: string|null, dependencies: string[] }}
|
|
286
|
+
*/
|
|
287
|
+
function extractFrontendInfo(pkgInfo) {
|
|
288
|
+
let framework = null, bundler = null, css = null;
|
|
289
|
+
|
|
290
|
+
for (const dep of pkgInfo.allDeps) {
|
|
291
|
+
if (!framework) {
|
|
292
|
+
for (const [p, n] of Object.entries(FE_FRAMEWORK_MAP)) {
|
|
293
|
+
if (dep === p || dep.startsWith(p)) { framework = n; break; }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (!bundler) {
|
|
297
|
+
for (const [p, n] of Object.entries(BUNDLER_MAP)) {
|
|
298
|
+
if (dep === p) { bundler = n; break; }
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (!css) {
|
|
302
|
+
for (const [p, n] of Object.entries(CSS_MAP)) {
|
|
303
|
+
if (dep === p || dep.startsWith(p)) { css = n; break; }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return { framework, bundler, css, dependencies: pkgInfo.topDeps };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Detect sibling projects sharing a common name prefix.
|
|
313
|
+
* Lightweight scan of parent directory for related repos.
|
|
314
|
+
*
|
|
315
|
+
* @param {string} rootDir - Current project root
|
|
316
|
+
* @returns {{ root: string, siblings: Array<{ name: string, path: string, stack: object }> }|null}
|
|
317
|
+
*/
|
|
318
|
+
function detectWorkspace(rootDir) {
|
|
319
|
+
const projectName = path.basename(rootDir);
|
|
320
|
+
const parentDir = path.dirname(rootDir);
|
|
321
|
+
|
|
322
|
+
// Extract prefix: "resumeshift" from "resumeshift", "resumeshift-rn", "resumeshift-landing"
|
|
323
|
+
const prefix = projectName.split(/[-_]/)[0];
|
|
324
|
+
if (prefix.length < 3) return null;
|
|
325
|
+
|
|
326
|
+
let entries;
|
|
327
|
+
try {
|
|
328
|
+
entries = fs.readdirSync(parentDir, { withFileTypes: true });
|
|
329
|
+
} catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const siblings = [];
|
|
334
|
+
for (const entry of entries) {
|
|
335
|
+
if (!entry.isDirectory()) continue;
|
|
336
|
+
if (entry.name === projectName) continue;
|
|
337
|
+
if (!entry.name.startsWith(prefix)) continue;
|
|
338
|
+
if (siblings.length >= 5) break; // cap at 5
|
|
339
|
+
|
|
340
|
+
const sibPath = path.join(parentDir, entry.name);
|
|
341
|
+
const lightResult = detectProjectLight(sibPath);
|
|
342
|
+
if (lightResult) {
|
|
343
|
+
siblings.push({
|
|
344
|
+
name: entry.name,
|
|
345
|
+
path: sibPath,
|
|
346
|
+
stack: lightResult
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return siblings.length > 0 ? { root: parentDir, siblings } : null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Lightweight project detection — only checks first manifest and framework.
|
|
356
|
+
* No git, file counting, or feature detection. Should complete in < 50ms.
|
|
357
|
+
*
|
|
358
|
+
* @param {string} dir
|
|
359
|
+
* @returns {{ language: string, framework: string|null }|null}
|
|
360
|
+
*/
|
|
361
|
+
function detectProjectLight(dir) {
|
|
362
|
+
for (const { file, language } of MANIFEST_FILES) {
|
|
363
|
+
if (fs.existsSync(path.join(dir, file))) {
|
|
364
|
+
let framework = null;
|
|
365
|
+
if (file === 'package.json') {
|
|
366
|
+
const info = parsePackageJson(dir);
|
|
367
|
+
if (info) framework = info.framework;
|
|
368
|
+
} else if (file === 'composer.json') {
|
|
369
|
+
const info = parseComposerJson(dir);
|
|
370
|
+
if (info) framework = info.framework;
|
|
371
|
+
}
|
|
372
|
+
return { language, framework };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Parse package.json to extract framework, runtime, and dependencies.
|
|
380
|
+
* @param {string} rootDir
|
|
381
|
+
* @returns {object|null}
|
|
382
|
+
*/
|
|
383
|
+
function parsePackageJson(rootDir) {
|
|
384
|
+
try {
|
|
385
|
+
const content = fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8');
|
|
386
|
+
const pkg = JSON.parse(content);
|
|
387
|
+
|
|
388
|
+
const deps = Object.keys(pkg.dependencies || {});
|
|
389
|
+
const devDeps = Object.keys(pkg.devDependencies || {});
|
|
390
|
+
const allDeps = [...deps, ...devDeps];
|
|
391
|
+
|
|
392
|
+
// Framework detection
|
|
393
|
+
let framework = null;
|
|
394
|
+
for (const dep of allDeps) {
|
|
395
|
+
for (const [pattern, name] of Object.entries(FRAMEWORK_MAP)) {
|
|
396
|
+
if (dep === pattern || dep.startsWith(pattern)) {
|
|
397
|
+
framework = name;
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (framework) break;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Runtime detection
|
|
405
|
+
let runtime = 'node';
|
|
406
|
+
if (allDeps.includes('bun') || (pkg.scripts && JSON.stringify(pkg.scripts).includes('bun '))) {
|
|
407
|
+
runtime = 'bun';
|
|
408
|
+
}
|
|
409
|
+
if (allDeps.includes('deno')) {
|
|
410
|
+
runtime = 'deno';
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// TypeScript detection
|
|
414
|
+
const hasTypeScript = allDeps.includes('typescript') || allDeps.some(d => d.startsWith('@types/'));
|
|
415
|
+
|
|
416
|
+
// Top 10 most significant deps (skip type definitions)
|
|
417
|
+
const topDeps = deps.filter(d => !d.startsWith('@types/')).slice(0, 10);
|
|
418
|
+
|
|
419
|
+
return { framework, runtime, hasTypeScript, topDeps, allDeps };
|
|
420
|
+
} catch {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Parse go.mod to extract module dependencies.
|
|
427
|
+
* @param {string} rootDir
|
|
428
|
+
* @returns {object|null}
|
|
429
|
+
*/
|
|
430
|
+
function parseGoMod(rootDir) {
|
|
431
|
+
try {
|
|
432
|
+
const content = fs.readFileSync(path.join(rootDir, 'go.mod'), 'utf8');
|
|
433
|
+
const deps = [];
|
|
434
|
+
const lines = content.split('\n');
|
|
435
|
+
let inRequire = false;
|
|
436
|
+
|
|
437
|
+
for (const line of lines) {
|
|
438
|
+
if (line.trim() === 'require (') { inRequire = true; continue; }
|
|
439
|
+
if (line.trim() === ')') { inRequire = false; continue; }
|
|
440
|
+
if (inRequire) {
|
|
441
|
+
const match = line.trim().match(/^(\S+)/);
|
|
442
|
+
if (match) {
|
|
443
|
+
const parts = match[1].split('/');
|
|
444
|
+
deps.push(parts[parts.length - 1]);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return { deps: deps.slice(0, 10) };
|
|
450
|
+
} catch {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Parse composer.json for PHP/Laravel/Symfony projects.
|
|
457
|
+
* Includes feature detection from require and require-dev.
|
|
458
|
+
* @param {string} rootDir
|
|
459
|
+
* @returns {object|null}
|
|
460
|
+
*/
|
|
461
|
+
function parseComposerJson(rootDir) {
|
|
462
|
+
try {
|
|
463
|
+
const content = fs.readFileSync(path.join(rootDir, 'composer.json'), 'utf8');
|
|
464
|
+
const pkg = JSON.parse(content);
|
|
465
|
+
const requireDeps = Object.keys(pkg.require || {}).filter(d => d !== 'php');
|
|
466
|
+
const requireDevDeps = Object.keys(pkg['require-dev'] || {});
|
|
467
|
+
const allDeps = [...requireDeps, ...requireDevDeps];
|
|
468
|
+
const topDeps = requireDeps.slice(0, 10);
|
|
469
|
+
|
|
470
|
+
let framework = null;
|
|
471
|
+
if (allDeps.some(d => d.startsWith('laravel/'))) framework = 'Laravel';
|
|
472
|
+
if (allDeps.includes('symfony/framework-bundle')) framework = 'Symfony';
|
|
473
|
+
|
|
474
|
+
return { framework, topDeps, allDeps };
|
|
475
|
+
} catch {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Get git commit count for a repository.
|
|
482
|
+
* @param {string} dir
|
|
483
|
+
* @returns {number}
|
|
484
|
+
*/
|
|
485
|
+
function getCommitCount(dir) {
|
|
486
|
+
try {
|
|
487
|
+
const output = execSync('git rev-list --count HEAD', {
|
|
488
|
+
cwd: dir,
|
|
489
|
+
encoding: 'utf8',
|
|
490
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
491
|
+
});
|
|
492
|
+
return parseInt(output.trim(), 10) || 0;
|
|
493
|
+
} catch {
|
|
494
|
+
return 0;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Count source code files in discovered directories (1-level deep).
|
|
500
|
+
* @param {string} rootDir
|
|
501
|
+
* @param {string[]} codeDirs - Directory names with trailing slash
|
|
502
|
+
* @returns {number}
|
|
503
|
+
*/
|
|
504
|
+
function countCodeFiles(rootDir, codeDirs) {
|
|
505
|
+
let count = 0;
|
|
506
|
+
|
|
507
|
+
// Count files in root dir (shallow)
|
|
508
|
+
try {
|
|
509
|
+
const rootFiles = fs.readdirSync(rootDir);
|
|
510
|
+
for (const file of rootFiles) {
|
|
511
|
+
const ext = path.extname(file);
|
|
512
|
+
if (CODE_EXTENSIONS.has(ext)) count++;
|
|
513
|
+
}
|
|
514
|
+
} catch { /* ignore */ }
|
|
515
|
+
|
|
516
|
+
// Count files in code dirs (1-level deep)
|
|
517
|
+
for (const dir of codeDirs) {
|
|
518
|
+
const dirName = dir.replace(/\/$/, '');
|
|
519
|
+
const dirPath = path.join(rootDir, dirName);
|
|
520
|
+
try {
|
|
521
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
522
|
+
for (const entry of entries) {
|
|
523
|
+
if (entry.isFile()) {
|
|
524
|
+
const ext = path.extname(entry.name);
|
|
525
|
+
if (CODE_EXTENSIONS.has(ext)) count++;
|
|
526
|
+
} else if (entry.isDirectory()) {
|
|
527
|
+
try {
|
|
528
|
+
const subEntries = fs.readdirSync(path.join(dirPath, entry.name));
|
|
529
|
+
for (const sub of subEntries) {
|
|
530
|
+
const ext = path.extname(sub);
|
|
531
|
+
if (CODE_EXTENSIONS.has(ext)) count++;
|
|
532
|
+
}
|
|
533
|
+
} catch { /* ignore */ }
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
} catch { /* ignore */ }
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return count;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Build a human-readable project summary string.
|
|
544
|
+
* Supports multi-stack (primary + frontend) format.
|
|
545
|
+
* @param {string} type
|
|
546
|
+
* @param {object} stack
|
|
547
|
+
* @param {object} signals
|
|
548
|
+
* @param {string[]} features
|
|
549
|
+
* @returns {string}
|
|
550
|
+
*/
|
|
551
|
+
function buildSummary(type, stack, signals, features) {
|
|
552
|
+
if (type === 'greenfield') {
|
|
553
|
+
return 'New project (no existing code detected)';
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const parts = [];
|
|
557
|
+
|
|
558
|
+
// Primary stack
|
|
559
|
+
const primary = stack.primary || stack;
|
|
560
|
+
if (primary.framework) {
|
|
561
|
+
if (primary.language) {
|
|
562
|
+
parts.push(`${primary.framework} (${primary.language})`);
|
|
563
|
+
} else {
|
|
564
|
+
parts.push(primary.framework);
|
|
565
|
+
}
|
|
566
|
+
} else if (primary.language) {
|
|
567
|
+
parts.push(primary.language.charAt(0).toUpperCase() + primary.language.slice(1));
|
|
568
|
+
}
|
|
569
|
+
// Backward compat: handle old flat stack shape
|
|
570
|
+
else if (stack.framework) {
|
|
571
|
+
parts.push(stack.framework);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Frontend stack
|
|
575
|
+
if (stack.frontend) {
|
|
576
|
+
const feParts = [stack.frontend.framework, stack.frontend.bundler, stack.frontend.css].filter(Boolean);
|
|
577
|
+
if (feParts.length > 0) {
|
|
578
|
+
parts.push(`+ ${feParts.join('/')} frontend`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Features mention
|
|
583
|
+
if (features.length > 0) {
|
|
584
|
+
const featureStr = features.slice(0, 3).join(', ');
|
|
585
|
+
parts.push(`with ${featureStr}`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Size
|
|
589
|
+
const sizeStr = signals.codeFiles > 0 ? `${signals.codeFiles} source files` : null;
|
|
590
|
+
const commitStr = signals.commitCount > 0 ? `${signals.commitCount} commits` : null;
|
|
591
|
+
|
|
592
|
+
if (sizeStr && commitStr) {
|
|
593
|
+
parts.push(`(${sizeStr}, ${commitStr})`);
|
|
594
|
+
} else if (sizeStr) {
|
|
595
|
+
parts.push(`(${sizeStr})`);
|
|
596
|
+
} else if (commitStr) {
|
|
597
|
+
parts.push(`(${commitStr})`);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return parts.join(' ') || 'Existing project';
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
module.exports = { detectProject, detectProjectLight, detectWorkspace, extractFrontendInfo, parsePackageJson, parseComposerJson, getCommitCount, countCodeFiles, buildSummary };
|
package/bin/lib/git.cjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execSync, execFileSync } = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check if a directory is inside a git working tree.
|
|
7
|
+
* @param {string} dir - Directory to check
|
|
8
|
+
* @returns {boolean}
|
|
9
|
+
*/
|
|
10
|
+
function isGitRepo(dir) {
|
|
11
|
+
try {
|
|
12
|
+
execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
13
|
+
cwd: dir,
|
|
14
|
+
stdio: 'pipe'
|
|
15
|
+
});
|
|
16
|
+
return true;
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Initialize a git repository in the given directory.
|
|
24
|
+
* Idempotent: safe to call if .git/ already exists.
|
|
25
|
+
* @param {string} dir - Directory to initialize
|
|
26
|
+
* @returns {boolean} true on success
|
|
27
|
+
*/
|
|
28
|
+
function gitInit(dir) {
|
|
29
|
+
try {
|
|
30
|
+
execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' });
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Stage specific files and create a commit.
|
|
39
|
+
* Uses execFileSync to prevent shell injection via file paths or messages.
|
|
40
|
+
* @param {string} message - Commit message (e.g. 'chore: initialize brain')
|
|
41
|
+
* @param {string[]} files - Array of file paths to stage (relative to dir)
|
|
42
|
+
* @param {string} dir - Working directory
|
|
43
|
+
* @returns {boolean} true on success, false on failure (empty commit, no changes)
|
|
44
|
+
*/
|
|
45
|
+
function gitCommit(message, files, dir) {
|
|
46
|
+
try {
|
|
47
|
+
if (!files || files.length === 0) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Stage specific files - safe from shell injection
|
|
52
|
+
execFileSync('git', ['add', ...files], { cwd: dir, stdio: 'pipe' });
|
|
53
|
+
|
|
54
|
+
// Commit - safe from shell injection
|
|
55
|
+
execFileSync('git', ['commit', '-m', message], {
|
|
56
|
+
cwd: dir,
|
|
57
|
+
stdio: 'pipe'
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return true;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get git status as structured data.
|
|
68
|
+
* @param {string} dir - Working directory
|
|
69
|
+
* @returns {Array<{status: string, file: string}>} Array of status entries
|
|
70
|
+
*/
|
|
71
|
+
function gitStatus(dir) {
|
|
72
|
+
try {
|
|
73
|
+
const output = execFileSync('git', ['status', '--porcelain'], {
|
|
74
|
+
cwd: dir,
|
|
75
|
+
encoding: 'utf8',
|
|
76
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (!output.trim()) {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return output.trim().split('\n').map(line => ({
|
|
84
|
+
status: line.slice(0, 2).trim(),
|
|
85
|
+
file: line.slice(3)
|
|
86
|
+
}));
|
|
87
|
+
} catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Create an annotated git tag.
|
|
94
|
+
* Uses execFileSync to prevent shell injection via tag names or messages.
|
|
95
|
+
* @param {string} tag - Tag name (e.g., 'v1.0')
|
|
96
|
+
* @param {string} message - Tag annotation message
|
|
97
|
+
* @param {string} dir - Working directory
|
|
98
|
+
* @returns {boolean} true on success
|
|
99
|
+
*/
|
|
100
|
+
function gitTag(tag, message, dir) {
|
|
101
|
+
try {
|
|
102
|
+
execFileSync('git', ['tag', '-a', tag, '-m', message], {
|
|
103
|
+
cwd: dir,
|
|
104
|
+
stdio: 'pipe'
|
|
105
|
+
});
|
|
106
|
+
return true;
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { isGitRepo, gitInit, gitCommit, gitStatus, gitTag };
|