forge-workflow 0.0.6 → 0.0.7
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/.cursorrules +149 -0
- package/bin/forge.js +36 -3
- package/lib/agents/README.md +46 -1
- package/lib/agents/cline.plugin.json +11 -4
- package/lib/agents/codex.plugin.json +2 -2
- package/lib/agents/copilot.plugin.json +5 -5
- package/lib/agents/cursor.plugin.json +1 -1
- package/lib/agents/kilocode.plugin.json +1 -1
- package/lib/agents/opencode.plugin.json +7 -4
- package/lib/agents/roo.plugin.json +10 -3
- package/lib/agents-config.js +127 -79
- package/lib/codex-skills.js +50 -0
- package/lib/commands/_registry.js +40 -1
- package/lib/commands/commands-reset.js +147 -0
- package/lib/commands/dev.js +26 -0
- package/lib/commands/plan.js +18 -0
- package/lib/commands/setup.js +4295 -0
- package/lib/commands/ship.js +20 -0
- package/lib/commands/status.js +210 -44
- package/lib/commands/sync.js +17 -1
- package/lib/commands/validate.js +13 -0
- package/lib/detect-agent.js +38 -8
- package/lib/detection-utils.js +405 -0
- package/lib/file-utils.js +260 -0
- package/lib/forge-context.js +42 -0
- package/lib/frontmatter.js +79 -0
- package/lib/husky-migration.js +113 -12
- package/lib/lefthook-check.js +27 -6
- package/lib/plugin-manager.js +225 -72
- package/lib/project-discovery.js +39 -5
- package/lib/runtime-health.js +305 -0
- package/lib/shell-utils.js +50 -0
- package/lib/ui-utils.js +43 -0
- package/lib/validation-utils.js +163 -0
- package/lib/workflow/enforce-stage.js +179 -0
- package/lib/workflow/stages.js +201 -0
- package/lib/workflow/state.js +332 -0
- package/opencode.json +67 -0
- package/package.json +15 -5
- package/scripts/beads-context.sh +12 -4
- package/scripts/check-agents.js +103 -0
- package/scripts/pr-coordinator.sh +71 -21
- package/scripts/smart-status.sh +21 -11
- package/scripts/sync-commands.js +49 -20
- package/scripts/test.js +16 -1
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* detection-utils.js — Project detection utilities extracted from bin/forge.js
|
|
3
|
+
*
|
|
4
|
+
* Functions that previously relied on the module-level `projectRoot` variable
|
|
5
|
+
* now accept it as an explicit parameter. Functions that mutated the module-level
|
|
6
|
+
* `PKG_MANAGER` variable now return a result object instead.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const { execSync } = require('node:child_process');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Safely execute a shell command, returning trimmed stdout or null.
|
|
15
|
+
* NOTE: This uses execSync intentionally for non-user-input detection commands
|
|
16
|
+
* like `bun --version`, `npm --version`, etc. The command strings are hardcoded
|
|
17
|
+
* constants, not user input, so shell injection is not a concern here.
|
|
18
|
+
* @param {string} cmd - Command to run (must be a hardcoded constant).
|
|
19
|
+
* @returns {string|null} Trimmed output or null on failure.
|
|
20
|
+
*/
|
|
21
|
+
function safeExec(cmd) {
|
|
22
|
+
try {
|
|
23
|
+
return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); // NOSONAR — hardcoded detection commands only, no user input
|
|
24
|
+
} catch (_e) { // NOSONAR — intentional: safeExec returns null on any failure (command not found, etc.)
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Detect a package manager from lock file presence.
|
|
31
|
+
* @param {string} name - Package manager name (e.g. 'bun').
|
|
32
|
+
* @param {string[]} lockFiles - Lock file names to check.
|
|
33
|
+
* @param {string} versionPrefix - Display prefix for version string.
|
|
34
|
+
* @param {string} projectRoot - Absolute path to project root.
|
|
35
|
+
* @returns {{ found: boolean, name: string, version: string|null }} Detection result.
|
|
36
|
+
*/
|
|
37
|
+
function detectFromLockFile(name, lockFiles, versionPrefix, projectRoot) {
|
|
38
|
+
const found = lockFiles.some(f => fs.existsSync(path.join(projectRoot, f)));
|
|
39
|
+
if (!found) return { found: false, name, version: null };
|
|
40
|
+
|
|
41
|
+
const version = safeExec(`${name} --version`);
|
|
42
|
+
if (version) console.log(` ✓ ${versionPrefix}${version} (detected from lock file)`);
|
|
43
|
+
return { found: true, name, version };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Detect a package manager from command availability.
|
|
48
|
+
* @param {string} name - Package manager name.
|
|
49
|
+
* @param {string} versionPrefix - Display prefix for version string.
|
|
50
|
+
* @returns {{ found: boolean, name: string, version: string|null }} Detection result.
|
|
51
|
+
*/
|
|
52
|
+
function detectFromCommand(name, versionPrefix) {
|
|
53
|
+
const version = safeExec(`${name} --version`);
|
|
54
|
+
if (!version) return { found: false, name, version: null };
|
|
55
|
+
|
|
56
|
+
console.log(` ✓ ${versionPrefix}${version} (detected as package manager)`);
|
|
57
|
+
return { found: true, name, version };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Detect package manager from lock files and command availability.
|
|
62
|
+
* @param {string[]} errors - Array to push error messages into.
|
|
63
|
+
* @param {string} projectRoot - Absolute path to project root.
|
|
64
|
+
* @returns {{ name: string, version: string|null }|null} Detected manager, or null.
|
|
65
|
+
*/
|
|
66
|
+
function detectPackageManager(errors, projectRoot) {
|
|
67
|
+
// Check lock files first (most authoritative)
|
|
68
|
+
const lockFileChecks = [
|
|
69
|
+
{ name: 'bun', files: ['bun.lockb', 'bun.lock'], prefix: 'bun v' },
|
|
70
|
+
{ name: 'pnpm', files: ['pnpm-lock.yaml'], prefix: 'pnpm ' },
|
|
71
|
+
{ name: 'yarn', files: ['yarn.lock'], prefix: 'yarn ' },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
for (const check of lockFileChecks) {
|
|
75
|
+
const result = detectFromLockFile(check.name, check.files, check.prefix, projectRoot);
|
|
76
|
+
if (result.found) return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Fallback: detect from installed commands
|
|
80
|
+
const commandChecks = [
|
|
81
|
+
{ name: 'bun', prefix: 'bun v' },
|
|
82
|
+
{ name: 'pnpm', prefix: 'pnpm ' },
|
|
83
|
+
{ name: 'yarn', prefix: 'yarn ' },
|
|
84
|
+
{ name: 'npm', prefix: 'npm ' },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
for (const check of commandChecks) {
|
|
88
|
+
const result = detectFromCommand(check.name, check.prefix);
|
|
89
|
+
if (result.found) return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// No package manager found
|
|
93
|
+
errors.push('npm, yarn, pnpm, or bun - Install a package manager');
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Detect test framework from dependency map.
|
|
99
|
+
* @param {Object} deps - Combined dependencies object.
|
|
100
|
+
* @returns {string|null} Framework name or null.
|
|
101
|
+
*/
|
|
102
|
+
function detectTestFramework(deps) {
|
|
103
|
+
if (deps.jest) return 'jest';
|
|
104
|
+
if (deps.vitest) return 'vitest';
|
|
105
|
+
if (deps.mocha) return 'mocha';
|
|
106
|
+
if (deps['@playwright/test']) return 'playwright';
|
|
107
|
+
if (deps.cypress) return 'cypress';
|
|
108
|
+
if (deps.karma) return 'karma';
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Detect language features (TypeScript, monorepo, Docker, CI/CD).
|
|
114
|
+
* @param {Object} pkg - Parsed package.json.
|
|
115
|
+
* @param {string} projectRoot - Absolute path to project root.
|
|
116
|
+
* @returns {{ typescript: boolean, monorepo: boolean, docker: boolean, cicd: boolean }}
|
|
117
|
+
*/
|
|
118
|
+
function detectLanguageFeatures(pkg, projectRoot) {
|
|
119
|
+
const features = {
|
|
120
|
+
typescript: false,
|
|
121
|
+
monorepo: false,
|
|
122
|
+
docker: false,
|
|
123
|
+
cicd: false
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Detect TypeScript
|
|
127
|
+
if (pkg.devDependencies?.typescript || pkg.dependencies?.typescript) {
|
|
128
|
+
features.typescript = true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Detect monorepo
|
|
132
|
+
if (pkg.workspaces ||
|
|
133
|
+
fs.existsSync(path.join(projectRoot, 'pnpm-workspace.yaml')) ||
|
|
134
|
+
fs.existsSync(path.join(projectRoot, 'lerna.json'))) {
|
|
135
|
+
features.monorepo = true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Detect Docker
|
|
139
|
+
if (fs.existsSync(path.join(projectRoot, 'Dockerfile')) ||
|
|
140
|
+
fs.existsSync(path.join(projectRoot, 'docker-compose.yml'))) {
|
|
141
|
+
features.docker = true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Detect CI/CD
|
|
145
|
+
if (fs.existsSync(path.join(projectRoot, '.github/workflows')) ||
|
|
146
|
+
fs.existsSync(path.join(projectRoot, '.gitlab-ci.yml')) ||
|
|
147
|
+
fs.existsSync(path.join(projectRoot, 'azure-pipelines.yml')) ||
|
|
148
|
+
fs.existsSync(path.join(projectRoot, '.circleci/config.yml'))) {
|
|
149
|
+
features.cicd = true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return features;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Detect Next.js framework.
|
|
157
|
+
* @param {Object} deps - Combined dependencies.
|
|
158
|
+
* @returns {Object|null} Framework info or null.
|
|
159
|
+
*/
|
|
160
|
+
function detectNextJs(deps) {
|
|
161
|
+
if (!deps.next) return null;
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
framework: 'Next.js',
|
|
165
|
+
frameworkConfidence: 100,
|
|
166
|
+
projectType: 'fullstack',
|
|
167
|
+
buildTool: 'next',
|
|
168
|
+
testFramework: detectTestFramework(deps)
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Detect NestJS framework.
|
|
174
|
+
* @param {Object} deps - Combined dependencies.
|
|
175
|
+
* @returns {Object|null} Framework info or null.
|
|
176
|
+
*/
|
|
177
|
+
function detectNestJs(deps) {
|
|
178
|
+
if (!deps['@nestjs/core'] && !deps['@nestjs/common']) return null;
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
framework: 'NestJS',
|
|
182
|
+
frameworkConfidence: 100,
|
|
183
|
+
projectType: 'backend',
|
|
184
|
+
buildTool: 'nest',
|
|
185
|
+
testFramework: 'jest'
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Detect Angular framework.
|
|
191
|
+
* @param {Object} deps - Combined dependencies.
|
|
192
|
+
* @returns {Object|null} Framework info or null.
|
|
193
|
+
*/
|
|
194
|
+
function detectAngular(deps) {
|
|
195
|
+
if (!deps['@angular/core'] && !deps['@angular/cli']) return null;
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
framework: 'Angular',
|
|
199
|
+
frameworkConfidence: 100,
|
|
200
|
+
projectType: 'frontend',
|
|
201
|
+
buildTool: 'ng',
|
|
202
|
+
testFramework: 'karma'
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Detect Vue.js / Nuxt framework.
|
|
208
|
+
* @param {Object} deps - Combined dependencies.
|
|
209
|
+
* @returns {Object|null} Framework info or null.
|
|
210
|
+
*/
|
|
211
|
+
function detectVue(deps) {
|
|
212
|
+
if (!deps.vue) return null;
|
|
213
|
+
|
|
214
|
+
if (deps.nuxt) {
|
|
215
|
+
return {
|
|
216
|
+
framework: 'Nuxt',
|
|
217
|
+
frameworkConfidence: 100,
|
|
218
|
+
projectType: 'fullstack',
|
|
219
|
+
buildTool: 'nuxt',
|
|
220
|
+
testFramework: detectTestFramework(deps)
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const hasVite = deps.vite;
|
|
225
|
+
const hasWebpack = deps.webpack;
|
|
226
|
+
|
|
227
|
+
// Determine build tool without nested ternary
|
|
228
|
+
let buildTool = 'vue-cli';
|
|
229
|
+
if (hasVite) {
|
|
230
|
+
buildTool = 'vite';
|
|
231
|
+
} else if (hasWebpack) {
|
|
232
|
+
buildTool = 'webpack';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
framework: 'Vue.js',
|
|
237
|
+
frameworkConfidence: deps['@vue/cli'] ? 100 : 90,
|
|
238
|
+
projectType: 'frontend',
|
|
239
|
+
buildTool,
|
|
240
|
+
testFramework: detectTestFramework(deps)
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Detect React framework.
|
|
246
|
+
* @param {Object} deps - Combined dependencies.
|
|
247
|
+
* @returns {Object|null} Framework info or null.
|
|
248
|
+
*/
|
|
249
|
+
function detectReact(deps) {
|
|
250
|
+
if (!deps.react) return null;
|
|
251
|
+
|
|
252
|
+
const hasVite = deps.vite;
|
|
253
|
+
const hasReactScripts = deps['react-scripts'];
|
|
254
|
+
|
|
255
|
+
// Determine build tool without nested ternary
|
|
256
|
+
let buildTool = 'webpack';
|
|
257
|
+
if (hasVite) {
|
|
258
|
+
buildTool = 'vite';
|
|
259
|
+
} else if (hasReactScripts) {
|
|
260
|
+
buildTool = 'create-react-app';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
framework: 'React',
|
|
265
|
+
frameworkConfidence: 95,
|
|
266
|
+
projectType: 'frontend',
|
|
267
|
+
buildTool,
|
|
268
|
+
testFramework: detectTestFramework(deps)
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Detect Express framework.
|
|
274
|
+
* @param {Object} deps - Combined dependencies.
|
|
275
|
+
* @param {{ typescript: boolean }} features - Language features.
|
|
276
|
+
* @returns {Object|null} Framework info or null.
|
|
277
|
+
*/
|
|
278
|
+
function detectExpress(deps, features) {
|
|
279
|
+
if (!deps.express) return null;
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
framework: 'Express',
|
|
283
|
+
frameworkConfidence: 90,
|
|
284
|
+
projectType: 'backend',
|
|
285
|
+
buildTool: features.typescript ? 'tsc' : 'node',
|
|
286
|
+
testFramework: detectTestFramework(deps)
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Detect Fastify framework.
|
|
292
|
+
* @param {Object} deps - Combined dependencies.
|
|
293
|
+
* @param {{ typescript: boolean }} features - Language features.
|
|
294
|
+
* @returns {Object|null} Framework info or null.
|
|
295
|
+
*/
|
|
296
|
+
function detectFastify(deps, features) {
|
|
297
|
+
if (!deps.fastify) return null;
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
framework: 'Fastify',
|
|
301
|
+
frameworkConfidence: 95,
|
|
302
|
+
projectType: 'backend',
|
|
303
|
+
buildTool: features.typescript ? 'tsc' : 'node',
|
|
304
|
+
testFramework: detectTestFramework(deps)
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Detect Svelte / SvelteKit framework.
|
|
310
|
+
* @param {Object} deps - Combined dependencies.
|
|
311
|
+
* @returns {Object|null} Framework info or null.
|
|
312
|
+
*/
|
|
313
|
+
function detectSvelte(deps) {
|
|
314
|
+
if (!deps.svelte) return null;
|
|
315
|
+
|
|
316
|
+
if (deps['@sveltejs/kit']) {
|
|
317
|
+
return {
|
|
318
|
+
framework: 'SvelteKit',
|
|
319
|
+
frameworkConfidence: 100,
|
|
320
|
+
projectType: 'fullstack',
|
|
321
|
+
buildTool: 'vite',
|
|
322
|
+
testFramework: detectTestFramework(deps)
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
framework: 'Svelte',
|
|
328
|
+
frameworkConfidence: 95,
|
|
329
|
+
projectType: 'frontend',
|
|
330
|
+
buildTool: 'vite',
|
|
331
|
+
testFramework: detectTestFramework(deps)
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Detect Remix framework.
|
|
337
|
+
* @param {Object} deps - Combined dependencies.
|
|
338
|
+
* @returns {Object|null} Framework info or null.
|
|
339
|
+
*/
|
|
340
|
+
function detectRemix(deps) {
|
|
341
|
+
if (!deps['@remix-run/react']) return null;
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
framework: 'Remix',
|
|
345
|
+
frameworkConfidence: 100,
|
|
346
|
+
projectType: 'fullstack',
|
|
347
|
+
buildTool: 'remix',
|
|
348
|
+
testFramework: detectTestFramework(deps)
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Detect Astro framework.
|
|
354
|
+
* @param {Object} deps - Combined dependencies.
|
|
355
|
+
* @returns {Object|null} Framework info or null.
|
|
356
|
+
*/
|
|
357
|
+
function detectAstro(deps) {
|
|
358
|
+
if (!deps.astro) return null;
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
framework: 'Astro',
|
|
362
|
+
frameworkConfidence: 100,
|
|
363
|
+
projectType: 'frontend',
|
|
364
|
+
buildTool: 'astro',
|
|
365
|
+
testFramework: detectTestFramework(deps)
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Detect generic Node.js project.
|
|
371
|
+
* @param {Object} pkg - Parsed package.json.
|
|
372
|
+
* @param {Object} deps - Combined dependencies.
|
|
373
|
+
* @param {{ typescript: boolean }} features - Language features.
|
|
374
|
+
* @returns {Object|null} Framework info or null.
|
|
375
|
+
*/
|
|
376
|
+
function detectGenericNodeJs(pkg, deps, features) {
|
|
377
|
+
if (!pkg.main && !pkg.scripts?.start) return null;
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
framework: 'Node.js',
|
|
381
|
+
frameworkConfidence: 70,
|
|
382
|
+
projectType: 'backend',
|
|
383
|
+
buildTool: features.typescript ? 'tsc' : 'node',
|
|
384
|
+
testFramework: detectTestFramework(deps)
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
module.exports = {
|
|
389
|
+
detectFromLockFile,
|
|
390
|
+
detectFromCommand,
|
|
391
|
+
detectPackageManager,
|
|
392
|
+
detectTestFramework,
|
|
393
|
+
detectLanguageFeatures,
|
|
394
|
+
detectNextJs,
|
|
395
|
+
detectNestJs,
|
|
396
|
+
detectAngular,
|
|
397
|
+
detectVue,
|
|
398
|
+
detectReact,
|
|
399
|
+
detectExpress,
|
|
400
|
+
detectFastify,
|
|
401
|
+
detectSvelte,
|
|
402
|
+
detectRemix,
|
|
403
|
+
detectAstro,
|
|
404
|
+
detectGenericNodeJs,
|
|
405
|
+
};
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* file-utils.js — File I/O operations extracted from bin/forge.js
|
|
3
|
+
*
|
|
4
|
+
* All functions that previously relied on the module-level `projectRoot`
|
|
5
|
+
* variable now accept it as an explicit parameter.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read a file and return its contents as a UTF-8 string.
|
|
13
|
+
* @param {string} filePath - Absolute path to the file.
|
|
14
|
+
* @returns {string|null} File contents, or null on failure.
|
|
15
|
+
*/
|
|
16
|
+
function readFile(filePath) {
|
|
17
|
+
try {
|
|
18
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
19
|
+
} catch (err) {
|
|
20
|
+
if (process.env.DEBUG) {
|
|
21
|
+
console.warn(` ⚠ Could not read ${filePath}: ${err.message}`);
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Write content to a file, creating parent directories as needed.
|
|
29
|
+
* Blocks path traversal outside projectRoot.
|
|
30
|
+
* @param {string} filePath - Relative path within projectRoot.
|
|
31
|
+
* @param {string} content - Content to write.
|
|
32
|
+
* @param {string} projectRoot - Absolute path to the project root.
|
|
33
|
+
* @returns {boolean} True on success, false on failure or blocked traversal.
|
|
34
|
+
*/
|
|
35
|
+
function writeFile(filePath, content, projectRoot) {
|
|
36
|
+
try {
|
|
37
|
+
const fullPath = path.resolve(projectRoot, filePath);
|
|
38
|
+
const resolvedProjectRoot = path.resolve(projectRoot);
|
|
39
|
+
|
|
40
|
+
// SECURITY: Prevent path traversal
|
|
41
|
+
if (fullPath !== resolvedProjectRoot && !fullPath.startsWith(resolvedProjectRoot + path.sep)) {
|
|
42
|
+
console.error(` ✗ Security: Write path escape blocked: ${filePath}`);
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const dir = path.dirname(fullPath);
|
|
47
|
+
if (!fs.existsSync(dir)) {
|
|
48
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
fs.writeFileSync(fullPath, content, { mode: 0o644 });
|
|
51
|
+
return true;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error(` ✗ Failed to write ${filePath}: ${err.message}`);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Ensure a directory exists under projectRoot.
|
|
60
|
+
* Blocks path traversal outside projectRoot.
|
|
61
|
+
* @param {string} dir - Relative path within projectRoot.
|
|
62
|
+
* @param {string} projectRoot - Absolute path to the project root.
|
|
63
|
+
* @returns {boolean} True on success, false on blocked traversal.
|
|
64
|
+
*/
|
|
65
|
+
function ensureDir(dir, projectRoot) {
|
|
66
|
+
const fullPath = path.resolve(projectRoot, dir);
|
|
67
|
+
const resolvedProjectRoot = path.resolve(projectRoot);
|
|
68
|
+
|
|
69
|
+
// SECURITY: Prevent path traversal
|
|
70
|
+
if (fullPath !== resolvedProjectRoot && !fullPath.startsWith(resolvedProjectRoot + path.sep)) {
|
|
71
|
+
console.error(` ✗ Security: Directory path escape blocked: ${dir}`);
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!fs.existsSync(fullPath)) {
|
|
76
|
+
fs.mkdirSync(fullPath, { recursive: true });
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Creates a directory on first use and prints a one-time purpose note.
|
|
83
|
+
* @param {string} dir - Absolute path to the directory to create.
|
|
84
|
+
* @param {string} purpose - Human-readable purpose description.
|
|
85
|
+
* @returns {string|null} Purpose message if created, null if already existed.
|
|
86
|
+
*/
|
|
87
|
+
function ensureDirWithNote(dir, purpose) {
|
|
88
|
+
if (fs.existsSync(dir)) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
92
|
+
const display = dir.replaceAll('\\', '/');
|
|
93
|
+
const msg = `Created ${display} for ${purpose}`;
|
|
94
|
+
console.log(` ${msg}`);
|
|
95
|
+
return msg;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Strip YAML frontmatter from markdown content.
|
|
100
|
+
* @param {string} content - Markdown string potentially containing frontmatter.
|
|
101
|
+
* @returns {string} Content without frontmatter.
|
|
102
|
+
*/
|
|
103
|
+
function stripFrontmatter(content) {
|
|
104
|
+
const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]*)$/); // NOSONAR — RegExp.exec blocked by security hook; match() equivalent here (no g flag)
|
|
105
|
+
return match ? match[1] : content;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Read the .env.local file from projectRoot.
|
|
110
|
+
* @param {string} projectRoot - Absolute path to the project root.
|
|
111
|
+
* @returns {string} File contents, or empty string if missing.
|
|
112
|
+
*/
|
|
113
|
+
function readEnvFile(projectRoot) {
|
|
114
|
+
const envPath = path.join(projectRoot, '.env.local');
|
|
115
|
+
try {
|
|
116
|
+
if (fs.existsSync(envPath)) {
|
|
117
|
+
return fs.readFileSync(envPath, 'utf8');
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// File read failure is acceptable - file may not exist or have permission issues
|
|
121
|
+
// Return empty string to allow caller to proceed with defaults
|
|
122
|
+
console.warn('Failed to read .env.local:', err.message);
|
|
123
|
+
}
|
|
124
|
+
return '';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Parse .env.local and return key-value pairs.
|
|
129
|
+
* @param {string} projectRoot - Absolute path to the project root.
|
|
130
|
+
* @returns {Object} Parsed key-value pairs.
|
|
131
|
+
*/
|
|
132
|
+
function parseEnvFile(projectRoot) {
|
|
133
|
+
const content = readEnvFile(projectRoot);
|
|
134
|
+
const lines = content.split(/\r?\n/);
|
|
135
|
+
const vars = {};
|
|
136
|
+
lines.forEach(line => {
|
|
137
|
+
const match = line.match(/^([A-Z_]+)=(.*)$/); // NOSONAR — RegExp.exec blocked by security hook; match() equivalent here (no g flag)
|
|
138
|
+
if (match) {
|
|
139
|
+
vars[match[1]] = match[2];
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
return vars;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Write or update .env.local — PRESERVES existing values by default.
|
|
147
|
+
* @param {Object} tokens - Key-value pairs to write.
|
|
148
|
+
* @param {boolean} [preserveExisting=true] - Whether to preserve existing values.
|
|
149
|
+
* @param {string} projectRoot - Absolute path to the project root.
|
|
150
|
+
* @returns {{ added: string[], preserved: string[] }} Keys added and preserved.
|
|
151
|
+
*/
|
|
152
|
+
function writeEnvTokens(tokens, projectRoot, preserveExisting = true) {
|
|
153
|
+
const envPath = path.join(projectRoot, '.env.local');
|
|
154
|
+
let content = readEnvFile(projectRoot);
|
|
155
|
+
|
|
156
|
+
// Parse existing content (handle both CRLF and LF line endings)
|
|
157
|
+
const lines = content.split(/\r?\n/);
|
|
158
|
+
const existingVars = {};
|
|
159
|
+
const existingKeys = new Set();
|
|
160
|
+
lines.forEach(line => {
|
|
161
|
+
const match = line.match(/^([A-Z_]+)=/); // NOSONAR — RegExp.exec blocked by security hook; match() equivalent here (no g flag)
|
|
162
|
+
if (match) {
|
|
163
|
+
existingVars[match[1]] = line;
|
|
164
|
+
existingKeys.add(match[1]);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Track what was added vs preserved
|
|
169
|
+
let added = [];
|
|
170
|
+
let preserved = [];
|
|
171
|
+
|
|
172
|
+
// Add/update tokens - PRESERVE existing values if preserveExisting is true
|
|
173
|
+
Object.entries(tokens).forEach(([key, value]) => {
|
|
174
|
+
if (value?.trim()) {
|
|
175
|
+
if (preserveExisting && existingKeys.has(key)) {
|
|
176
|
+
// Keep existing value, don't overwrite
|
|
177
|
+
preserved.push(key);
|
|
178
|
+
} else {
|
|
179
|
+
// Add new token
|
|
180
|
+
existingVars[key] = `${key}=${value.trim()}`;
|
|
181
|
+
added.push(key);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Rebuild file with comments
|
|
187
|
+
const outputLines = [];
|
|
188
|
+
|
|
189
|
+
// Add header if new file
|
|
190
|
+
if (!content.includes('# External Service API Keys')) {
|
|
191
|
+
outputLines.push(
|
|
192
|
+
'# External Service API Keys for Forge Workflow',
|
|
193
|
+
'# Get your keys from:',
|
|
194
|
+
'# Parallel AI: https://platform.parallel.ai',
|
|
195
|
+
'# Greptile: https://app.greptile.com/api',
|
|
196
|
+
'# SonarCloud: https://sonarcloud.io/account/security',
|
|
197
|
+
''
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Add existing content (preserve order and comments)
|
|
202
|
+
lines.forEach(line => {
|
|
203
|
+
const match = line.match(/^([A-Z_]+)=/); // NOSONAR — RegExp.exec blocked by security hook; match() equivalent here (no g flag)
|
|
204
|
+
if (match && existingVars[match[1]]) {
|
|
205
|
+
outputLines.push(existingVars[match[1]]);
|
|
206
|
+
delete existingVars[match[1]]; // Mark as added
|
|
207
|
+
} else if (line.trim()) {
|
|
208
|
+
outputLines.push(line);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Add any new tokens not in original file
|
|
213
|
+
Object.values(existingVars).forEach(line => {
|
|
214
|
+
outputLines.push(line);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Ensure ends with newline
|
|
218
|
+
let finalContent = outputLines.join('\n').trim() + '\n';
|
|
219
|
+
|
|
220
|
+
fs.writeFileSync(envPath, finalContent);
|
|
221
|
+
|
|
222
|
+
// OWASP A02: Set restrictive permissions on .env.local (contains API keys)
|
|
223
|
+
// On Windows, chmod is a no-op so we skip it
|
|
224
|
+
if (process.platform !== 'win32') {
|
|
225
|
+
try {
|
|
226
|
+
fs.chmodSync(envPath, 0o600);
|
|
227
|
+
} catch (_err) { // NOSONAR — chmod failure is non-fatal, file was still written
|
|
228
|
+
// chmod failure is non-fatal — file was still written successfully
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Add .env.local to .gitignore if not present
|
|
233
|
+
const gitignorePath = path.join(projectRoot, '.gitignore');
|
|
234
|
+
try {
|
|
235
|
+
let gitignore = '';
|
|
236
|
+
if (fs.existsSync(gitignorePath)) {
|
|
237
|
+
gitignore = fs.readFileSync(gitignorePath, 'utf8');
|
|
238
|
+
}
|
|
239
|
+
if (!gitignore.includes('.env.local')) {
|
|
240
|
+
fs.appendFileSync(gitignorePath, '\n# Local environment variables\n.env.local\n');
|
|
241
|
+
}
|
|
242
|
+
} catch (err) {
|
|
243
|
+
// Gitignore update is optional - failure doesn't prevent .env.local creation
|
|
244
|
+
// User can manually add .env.local to .gitignore if needed
|
|
245
|
+
console.warn('Failed to update .gitignore:', err.message);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { added, preserved };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = {
|
|
252
|
+
readFile,
|
|
253
|
+
writeFile,
|
|
254
|
+
ensureDir,
|
|
255
|
+
ensureDirWithNote,
|
|
256
|
+
stripFrontmatter,
|
|
257
|
+
readEnvFile,
|
|
258
|
+
parseEnvFile,
|
|
259
|
+
writeEnvTokens,
|
|
260
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ForgeContext — Mutable state container for the Forge CLI.
|
|
5
|
+
*
|
|
6
|
+
* Replaces the module-level globals in bin/forge.js with a single
|
|
7
|
+
* injectable object, making state explicit and testable.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors the globals: projectRoot, FORCE_MODE, VERBOSE_MODE,
|
|
10
|
+
* NON_INTERACTIVE, SYMLINK_ONLY, SYNC_ENABLED, actionLog,
|
|
11
|
+
* PKG_MANAGER, and packageDir.
|
|
12
|
+
*
|
|
13
|
+
* @module forge-context
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
class ForgeContext { // NOSONAR — constructor-only class is intentional: serves as typed state container, methods will be added as CLI evolves
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} [options]
|
|
19
|
+
* @param {string} [options.projectRoot] - Project root directory
|
|
20
|
+
* @param {boolean} [options.forceMode] - Force overwrite (--force)
|
|
21
|
+
* @param {boolean} [options.verboseMode] - Verbose output (--verbose)
|
|
22
|
+
* @param {boolean} [options.nonInteractive] - Skip prompts (--quick / --yes)
|
|
23
|
+
* @param {boolean} [options.symlinkOnly] - Fail instead of copy fallback (--symlink)
|
|
24
|
+
* @param {boolean} [options.syncEnabled] - Scaffold Beads GitHub sync (--sync)
|
|
25
|
+
* @param {string} [options.pkgManager] - Detected package manager
|
|
26
|
+
* @param {Array} [options.actionLog] - Incremental setup action log
|
|
27
|
+
* @param {string} [options.packageDir] - Forge package directory
|
|
28
|
+
*/
|
|
29
|
+
constructor(options = {}) {
|
|
30
|
+
this.projectRoot = options.projectRoot || process.cwd();
|
|
31
|
+
this.forceMode = options.forceMode || false;
|
|
32
|
+
this.verboseMode = options.verboseMode || false;
|
|
33
|
+
this.nonInteractive = options.nonInteractive || false;
|
|
34
|
+
this.symlinkOnly = options.symlinkOnly || false;
|
|
35
|
+
this.syncEnabled = options.syncEnabled || false;
|
|
36
|
+
this.pkgManager = options.pkgManager || 'npm';
|
|
37
|
+
this.actionLog = options.actionLog || [];
|
|
38
|
+
this.packageDir = options.packageDir || '';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { ForgeContext };
|