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.
Files changed (45) hide show
  1. package/.cursorrules +149 -0
  2. package/bin/forge.js +36 -3
  3. package/lib/agents/README.md +46 -1
  4. package/lib/agents/cline.plugin.json +11 -4
  5. package/lib/agents/codex.plugin.json +2 -2
  6. package/lib/agents/copilot.plugin.json +5 -5
  7. package/lib/agents/cursor.plugin.json +1 -1
  8. package/lib/agents/kilocode.plugin.json +1 -1
  9. package/lib/agents/opencode.plugin.json +7 -4
  10. package/lib/agents/roo.plugin.json +10 -3
  11. package/lib/agents-config.js +127 -79
  12. package/lib/codex-skills.js +50 -0
  13. package/lib/commands/_registry.js +40 -1
  14. package/lib/commands/commands-reset.js +147 -0
  15. package/lib/commands/dev.js +26 -0
  16. package/lib/commands/plan.js +18 -0
  17. package/lib/commands/setup.js +4295 -0
  18. package/lib/commands/ship.js +20 -0
  19. package/lib/commands/status.js +210 -44
  20. package/lib/commands/sync.js +17 -1
  21. package/lib/commands/validate.js +13 -0
  22. package/lib/detect-agent.js +38 -8
  23. package/lib/detection-utils.js +405 -0
  24. package/lib/file-utils.js +260 -0
  25. package/lib/forge-context.js +42 -0
  26. package/lib/frontmatter.js +79 -0
  27. package/lib/husky-migration.js +113 -12
  28. package/lib/lefthook-check.js +27 -6
  29. package/lib/plugin-manager.js +225 -72
  30. package/lib/project-discovery.js +39 -5
  31. package/lib/runtime-health.js +305 -0
  32. package/lib/shell-utils.js +50 -0
  33. package/lib/ui-utils.js +43 -0
  34. package/lib/validation-utils.js +163 -0
  35. package/lib/workflow/enforce-stage.js +179 -0
  36. package/lib/workflow/stages.js +201 -0
  37. package/lib/workflow/state.js +332 -0
  38. package/opencode.json +67 -0
  39. package/package.json +15 -5
  40. package/scripts/beads-context.sh +12 -4
  41. package/scripts/check-agents.js +103 -0
  42. package/scripts/pr-coordinator.sh +71 -21
  43. package/scripts/smart-status.sh +21 -11
  44. package/scripts/sync-commands.js +49 -20
  45. 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 };