forge-workflow 0.0.5 → 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 (164) hide show
  1. package/.claude/commands/dev.md +6 -1
  2. package/.claude/commands/plan.md +59 -14
  3. package/.claude/commands/premerge.md +10 -0
  4. package/.claude/commands/review.md +7 -1
  5. package/.claude/commands/ship.md +95 -47
  6. package/.claude/commands/status.md +42 -0
  7. package/.claude/commands/validate.md +7 -1
  8. package/.claude/commands/verify.md +52 -4
  9. package/.claude/rules/workflow.md +16 -0
  10. package/.claude/scripts/greptile-resolve.sh +32 -0
  11. package/.cline/workflows/dev.md +6 -1
  12. package/.cline/workflows/plan.md +59 -14
  13. package/.cline/workflows/premerge.md +10 -0
  14. package/.cline/workflows/review.md +7 -1
  15. package/.cline/workflows/ship.md +95 -47
  16. package/.cline/workflows/status.md +42 -0
  17. package/.cline/workflows/validate.md +7 -1
  18. package/.cline/workflows/verify.md +52 -4
  19. package/.codex/skills/dev/SKILL.md +6 -1
  20. package/.codex/skills/plan/SKILL.md +59 -14
  21. package/.codex/skills/premerge/SKILL.md +10 -0
  22. package/.codex/skills/review/SKILL.md +7 -1
  23. package/.codex/skills/ship/SKILL.md +95 -47
  24. package/.codex/skills/status/SKILL.md +42 -0
  25. package/.codex/skills/validate/SKILL.md +7 -1
  26. package/.codex/skills/verify/SKILL.md +52 -4
  27. package/.cursor/commands/dev.md +6 -1
  28. package/.cursor/commands/plan.md +59 -14
  29. package/.cursor/commands/premerge.md +10 -0
  30. package/.cursor/commands/review.md +7 -1
  31. package/.cursor/commands/ship.md +95 -47
  32. package/.cursor/commands/status.md +42 -0
  33. package/.cursor/commands/validate.md +7 -1
  34. package/.cursor/commands/verify.md +52 -4
  35. package/.cursorrules +149 -0
  36. package/.github/prompts/dev.prompt.md +6 -1
  37. package/.github/prompts/plan.prompt.md +59 -14
  38. package/.github/prompts/premerge.prompt.md +10 -0
  39. package/.github/prompts/review.prompt.md +7 -1
  40. package/.github/prompts/ship.prompt.md +95 -47
  41. package/.github/prompts/status.prompt.md +42 -0
  42. package/.github/prompts/validate.prompt.md +7 -1
  43. package/.github/prompts/verify.prompt.md +52 -4
  44. package/.kilocode/workflows/dev.md +6 -1
  45. package/.kilocode/workflows/plan.md +59 -14
  46. package/.kilocode/workflows/premerge.md +10 -0
  47. package/.kilocode/workflows/review.md +7 -1
  48. package/.kilocode/workflows/ship.md +95 -47
  49. package/.kilocode/workflows/status.md +42 -0
  50. package/.kilocode/workflows/validate.md +7 -1
  51. package/.kilocode/workflows/verify.md +52 -4
  52. package/.opencode/commands/dev.md +6 -1
  53. package/.opencode/commands/plan.md +59 -14
  54. package/.opencode/commands/premerge.md +10 -0
  55. package/.opencode/commands/review.md +7 -1
  56. package/.opencode/commands/ship.md +95 -47
  57. package/.opencode/commands/status.md +42 -0
  58. package/.opencode/commands/validate.md +7 -1
  59. package/.opencode/commands/verify.md +52 -4
  60. package/.roo/commands/dev.md +6 -1
  61. package/.roo/commands/plan.md +59 -14
  62. package/.roo/commands/premerge.md +10 -0
  63. package/.roo/commands/review.md +7 -1
  64. package/.roo/commands/ship.md +95 -47
  65. package/.roo/commands/status.md +42 -0
  66. package/.roo/commands/validate.md +7 -1
  67. package/.roo/commands/verify.md +52 -4
  68. package/AGENTS.md +97 -0
  69. package/CLAUDE.md +10 -0
  70. package/README.md +2 -2
  71. package/bin/forge-cmd.js +5 -1
  72. package/bin/forge-preflight.js +15 -2
  73. package/bin/forge.js +211 -9
  74. package/docs/ENHANCED_ONBOARDING.md +96 -86
  75. package/docs/ROADMAP.md +2 -2
  76. package/docs/TOOLCHAIN.md +23 -0
  77. package/docs/VALIDATION.md +1 -1
  78. package/lefthook.yml +11 -0
  79. package/lib/agents/README.md +46 -1
  80. package/lib/agents/cline.plugin.json +11 -4
  81. package/lib/agents/codex.plugin.json +2 -2
  82. package/lib/agents/copilot.plugin.json +5 -5
  83. package/lib/agents/cursor.plugin.json +1 -1
  84. package/lib/agents/kilocode.plugin.json +1 -1
  85. package/lib/agents/opencode.plugin.json +7 -4
  86. package/lib/agents/roo.plugin.json +10 -3
  87. package/lib/agents-config.js +129 -81
  88. package/lib/codex-skills.js +50 -0
  89. package/lib/commands/_registry.js +173 -0
  90. package/lib/commands/clean.js +181 -0
  91. package/lib/commands/commands-reset.js +147 -0
  92. package/lib/commands/dev.js +84 -0
  93. package/lib/commands/plan.js +18 -0
  94. package/lib/commands/push.js +196 -0
  95. package/lib/commands/recommend.js +1 -1
  96. package/lib/commands/setup.js +4295 -0
  97. package/lib/commands/ship.js +20 -0
  98. package/lib/commands/status.js +210 -44
  99. package/lib/commands/sync.js +71 -0
  100. package/lib/commands/team.js +37 -0
  101. package/lib/commands/test.js +207 -0
  102. package/lib/commands/validate.js +13 -0
  103. package/lib/commands/worktree.js +310 -0
  104. package/lib/detect-agent.js +38 -8
  105. package/lib/detection-utils.js +405 -0
  106. package/lib/docs-command.js +51 -0
  107. package/lib/docs-copy.js +50 -0
  108. package/lib/file-utils.js +260 -0
  109. package/lib/forge-context.js +42 -0
  110. package/lib/freshness-token.js +148 -0
  111. package/lib/frontmatter.js +79 -0
  112. package/lib/greptile-match.js +80 -0
  113. package/lib/husky-migration.js +113 -12
  114. package/lib/lefthook-check.js +27 -6
  115. package/lib/plugin-manager.js +225 -72
  116. package/lib/project-discovery.js +39 -5
  117. package/lib/reset.js +309 -0
  118. package/lib/runtime-health.js +305 -0
  119. package/lib/shell-utils.js +50 -0
  120. package/lib/task-ownership.js +117 -0
  121. package/lib/ui-utils.js +43 -0
  122. package/lib/validation-utils.js +163 -0
  123. package/lib/workflow/enforce-stage.js +179 -0
  124. package/lib/workflow/stages.js +201 -0
  125. package/lib/workflow/state.js +332 -0
  126. package/opencode.json +67 -0
  127. package/package.json +16 -6
  128. package/scripts/beads-context.sh +165 -22
  129. package/scripts/beads-context.test.js +5 -1
  130. package/scripts/check-agents.js +103 -0
  131. package/scripts/check-forge-token.js +98 -0
  132. package/scripts/conflict-detect.sh +2 -2
  133. package/scripts/dep-guard.sh +6 -28
  134. package/scripts/file-index.sh +117 -23
  135. package/scripts/forge-team/index.sh +86 -0
  136. package/scripts/forge-team/lib/agent-prompt.sh +52 -0
  137. package/scripts/forge-team/lib/claim.sh +256 -0
  138. package/scripts/forge-team/lib/dashboard.sh +341 -0
  139. package/scripts/forge-team/lib/epic.sh +332 -0
  140. package/scripts/forge-team/lib/hooks.sh +253 -0
  141. package/scripts/forge-team/lib/identity.sh +235 -0
  142. package/scripts/forge-team/lib/sync-github.sh +317 -0
  143. package/scripts/forge-team/lib/verify.sh +284 -0
  144. package/scripts/forge-team/lib/workload.sh +296 -0
  145. package/scripts/forge-team/tests/agent-prompt.test.sh +72 -0
  146. package/scripts/forge-team/tests/claim.test.sh +179 -0
  147. package/scripts/forge-team/tests/dashboard.test.sh +170 -0
  148. package/scripts/forge-team/tests/dispatcher.test.sh +79 -0
  149. package/scripts/forge-team/tests/epic.test.sh +176 -0
  150. package/scripts/forge-team/tests/hooks.test.sh +239 -0
  151. package/scripts/forge-team/tests/identity.test.sh +176 -0
  152. package/scripts/forge-team/tests/integration.test.sh +371 -0
  153. package/scripts/forge-team/tests/sync-github.test.sh +209 -0
  154. package/scripts/forge-team/tests/verify.test.sh +314 -0
  155. package/scripts/forge-team/tests/workflow-integration.test.sh +43 -0
  156. package/scripts/forge-team/tests/workload.test.sh +209 -0
  157. package/scripts/lib/eval-runner.js +39 -0
  158. package/scripts/lib/jsonl-lock.sh +48 -0
  159. package/scripts/lib/sanitize.sh +116 -0
  160. package/scripts/pr-coordinator.sh +756 -0
  161. package/scripts/smart-status.sh +58 -21
  162. package/scripts/sync-commands.js +49 -20
  163. package/scripts/sync-utils.sh +24 -29
  164. package/scripts/test.js +18 -1
package/lib/reset.js ADDED
@@ -0,0 +1,309 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+
4
+ /**
5
+ * Known forge-created file patterns.
6
+ * Used to distinguish forge files from user-created files.
7
+ */
8
+ const FORGE_COMMANDS = [
9
+ '.claude/commands/plan.md',
10
+ '.claude/commands/dev.md',
11
+ '.claude/commands/validate.md',
12
+ '.claude/commands/ship.md',
13
+ '.claude/commands/review.md',
14
+ '.claude/commands/premerge.md',
15
+ '.claude/commands/verify.md',
16
+ '.claude/commands/status.md',
17
+ '.claude/commands/preflight.md',
18
+ ];
19
+
20
+ const FORGE_RULES = [
21
+ '.claude/rules/workflow.md',
22
+ '.claude/rules/greptile-review-process.md',
23
+ ];
24
+
25
+ const FORGE_SCRIPTS = [
26
+ '.claude/scripts/greptile-resolve.sh',
27
+ '.claude/scripts/validate.sh',
28
+ ];
29
+
30
+ const FORGE_WORKFLOWS = [
31
+ '.github/workflows/beads-to-github.yml',
32
+ '.github/workflows/github-to-beads.yml',
33
+ ];
34
+
35
+ const FORGE_DOCS = [
36
+ 'docs/forge/TOOLCHAIN.md',
37
+ 'docs/forge/VALIDATION.md',
38
+ ];
39
+
40
+ const FORGE_AGENT_DIRS = [
41
+ '.cursor',
42
+ '.cline',
43
+ '.roo',
44
+ '.codex',
45
+ '.kilocode',
46
+ '.opencode',
47
+ ];
48
+
49
+ const FORGE_SYNC_SCRIPTS_DIR = 'scripts/github-beads-sync';
50
+
51
+ /**
52
+ * Get categorized lists of forge-managed files in a project.
53
+ * Only returns files that actually exist on disk and are known forge templates.
54
+ * User-created files are excluded.
55
+ *
56
+ * @param {string} projectRoot - Project root directory
57
+ * @returns {{
58
+ * config: string[],
59
+ * commands: string[],
60
+ * rules: string[],
61
+ * scripts: string[],
62
+ * docs: string[],
63
+ * agentDirs: string[],
64
+ * workflows: string[],
65
+ * syncScripts: string[]
66
+ * }}
67
+ */
68
+ function getForgeFiles(projectRoot) {
69
+ const result = {
70
+ config: [],
71
+ commands: [],
72
+ rules: [],
73
+ scripts: [],
74
+ docs: [],
75
+ agentDirs: [],
76
+ workflows: [],
77
+ syncScripts: [],
78
+ };
79
+
80
+ // .forge/ directory
81
+ if (fs.existsSync(path.join(projectRoot, '.forge'))) {
82
+ result.config.push('.forge');
83
+ }
84
+
85
+ // Known forge commands (static list)
86
+ for (const f of FORGE_COMMANDS) {
87
+ if (fs.existsSync(path.join(projectRoot, f))) {
88
+ result.commands.push(f);
89
+ }
90
+ }
91
+
92
+ // Dynamic discovery: scan the package's .claude/commands/ directory to find
93
+ // any commands that setup may have copied but aren't in the static list.
94
+ // Only files that exist in both the package source AND the project are included.
95
+ const packageCommandsDir = path.join(__dirname, '..', '.claude', 'commands');
96
+ try {
97
+ const packageEntries = fs.readdirSync(packageCommandsDir);
98
+ for (const entry of packageEntries) {
99
+ if (entry.endsWith('.md')) {
100
+ const relPath = `.claude/commands/${entry}`;
101
+ if (!result.commands.includes(relPath) &&
102
+ fs.existsSync(path.join(projectRoot, relPath))) {
103
+ result.commands.push(relPath);
104
+ }
105
+ }
106
+ }
107
+ } catch (_error) {
108
+ // Package commands directory read failed, skip
109
+ }
110
+
111
+ // Known forge rules
112
+ for (const f of FORGE_RULES) {
113
+ if (fs.existsSync(path.join(projectRoot, f))) {
114
+ result.rules.push(f);
115
+ }
116
+ }
117
+
118
+ // Known forge scripts
119
+ for (const f of FORGE_SCRIPTS) {
120
+ if (fs.existsSync(path.join(projectRoot, f))) {
121
+ result.scripts.push(f);
122
+ }
123
+ }
124
+
125
+ // Known forge docs
126
+ for (const f of FORGE_DOCS) {
127
+ if (fs.existsSync(path.join(projectRoot, f))) {
128
+ result.docs.push(f);
129
+ }
130
+ }
131
+
132
+ // Agent directories (only if they exist)
133
+ for (const dir of FORGE_AGENT_DIRS) {
134
+ if (fs.existsSync(path.join(projectRoot, dir))) {
135
+ result.agentDirs.push(dir);
136
+ }
137
+ }
138
+
139
+ // Beads workflow files
140
+ for (const f of FORGE_WORKFLOWS) {
141
+ if (fs.existsSync(path.join(projectRoot, f))) {
142
+ result.workflows.push(f);
143
+ }
144
+ }
145
+
146
+ // Sync scripts directory
147
+ const syncDir = path.join(projectRoot, FORGE_SYNC_SCRIPTS_DIR);
148
+ if (fs.existsSync(syncDir)) {
149
+ try {
150
+ const entries = fs.readdirSync(syncDir);
151
+ for (const entry of entries) {
152
+ const relPath = [FORGE_SYNC_SCRIPTS_DIR, entry].join('/');
153
+ result.syncScripts.push(relPath);
154
+ }
155
+ } catch (_error) {
156
+ // Directory read failed, skip
157
+ }
158
+ }
159
+
160
+ return result;
161
+ }
162
+
163
+ /**
164
+ * Remove .forge/ directory only (soft reset).
165
+ * Requires force=true to proceed.
166
+ *
167
+ * @param {string} projectRoot - Project root directory
168
+ * @param {{ force?: boolean }} options
169
+ * @returns {{ removed: string[], preserved: string[] }}
170
+ */
171
+ function resetSoft(projectRoot, { force = false } = {}) {
172
+ if (!force) {
173
+ throw new Error('Soft reset requires --force flag. This will remove .forge/ directory.');
174
+ }
175
+
176
+ const removed = [];
177
+ const preserved = [];
178
+
179
+ const forgePath = path.join(projectRoot, '.forge');
180
+ if (fs.existsSync(forgePath)) {
181
+ fs.rmSync(forgePath, { recursive: true, force: true });
182
+ removed.push('.forge');
183
+ }
184
+
185
+ // Record what was preserved
186
+ const inventory = getForgeFiles(projectRoot);
187
+ for (const f of [...inventory.commands, ...inventory.rules, ...inventory.scripts]) {
188
+ preserved.push(f);
189
+ }
190
+ for (const dir of inventory.agentDirs) {
191
+ preserved.push(dir);
192
+ }
193
+
194
+ return { removed, preserved };
195
+ }
196
+
197
+ /**
198
+ * Remove ALL forge-managed files (hard reset).
199
+ * Preserves user-created files not in forge template list.
200
+ * Requires force=true to proceed.
201
+ *
202
+ * @param {string} projectRoot - Project root directory
203
+ * @param {{ force?: boolean }} options
204
+ * @returns {{ removed: string[], preserved: string[] }}
205
+ */
206
+ function resetHard(projectRoot, { force = false } = {}) {
207
+ if (!force) {
208
+ throw new Error('Hard reset requires --force flag. This will remove ALL forge files.');
209
+ }
210
+
211
+ const removed = [];
212
+ const preserved = [];
213
+ const inventory = getForgeFiles(projectRoot);
214
+
215
+ // Remove .forge/ directory
216
+ for (const dir of inventory.config) {
217
+ const fullPath = path.join(projectRoot, dir);
218
+ if (fs.existsSync(fullPath)) {
219
+ fs.rmSync(fullPath, { recursive: true, force: true });
220
+ removed.push(dir);
221
+ }
222
+ }
223
+
224
+ // Remove individual forge files
225
+ const allFiles = [
226
+ ...inventory.commands,
227
+ ...inventory.rules,
228
+ ...inventory.scripts,
229
+ ...inventory.docs,
230
+ ...inventory.workflows,
231
+ ...inventory.syncScripts,
232
+ ];
233
+
234
+ for (const f of allFiles) {
235
+ const fullPath = path.join(projectRoot, f);
236
+ if (fs.existsSync(fullPath)) {
237
+ fs.rmSync(fullPath, { recursive: true, force: true });
238
+ removed.push(f);
239
+ }
240
+ }
241
+
242
+ // Remove sync scripts directory if it still exists (empty after file removal)
243
+ const syncDir = path.join(projectRoot, FORGE_SYNC_SCRIPTS_DIR);
244
+ if (fs.existsSync(syncDir)) {
245
+ fs.rmSync(syncDir, { recursive: true, force: true });
246
+ removed.push(FORGE_SYNC_SCRIPTS_DIR);
247
+ }
248
+
249
+ // Remove docs/forge/ directory if empty after file removal
250
+ const docsForgeDir = path.join(projectRoot, 'docs', 'forge');
251
+ if (fs.existsSync(docsForgeDir)) {
252
+ try {
253
+ const remaining = fs.readdirSync(docsForgeDir);
254
+ if (remaining.length === 0) {
255
+ fs.rmSync(docsForgeDir, { recursive: true, force: true });
256
+ removed.push('docs/forge');
257
+ }
258
+ } catch (_error) {
259
+ // Directory read failed, skip
260
+ }
261
+ }
262
+
263
+ // Remove agent directories
264
+ for (const dir of inventory.agentDirs) {
265
+ const fullPath = path.join(projectRoot, dir);
266
+ if (fs.existsSync(fullPath)) {
267
+ fs.rmSync(fullPath, { recursive: true, force: true });
268
+ removed.push(dir);
269
+ }
270
+ }
271
+
272
+ return { removed, preserved };
273
+ }
274
+
275
+ /**
276
+ * Remove all forge files and re-run setup (reinstall).
277
+ * Accepts a setupFn parameter for testability.
278
+ *
279
+ * @param {string} projectRoot - Project root directory
280
+ * @param {{ force?: boolean, setupFn?: Function }} options
281
+ * @returns {Promise<{ resetResult: object, setupResult: any }>}
282
+ */
283
+ async function reinstall(projectRoot, { force = false, setupFn } = {}) {
284
+ if (!force) {
285
+ throw new Error('Reinstall requires --force flag.');
286
+ }
287
+
288
+ const resetResult = resetHard(projectRoot, { force: true });
289
+
290
+ let setupResult = null;
291
+ if (setupFn) {
292
+ setupResult = await setupFn(projectRoot);
293
+ }
294
+
295
+ return { resetResult, setupResult };
296
+ }
297
+
298
+ module.exports = {
299
+ getForgeFiles,
300
+ resetSoft,
301
+ resetHard,
302
+ reinstall,
303
+ FORGE_COMMANDS,
304
+ FORGE_RULES,
305
+ FORGE_SCRIPTS,
306
+ FORGE_DOCS,
307
+ FORGE_WORKFLOWS,
308
+ FORGE_AGENT_DIRS,
309
+ };
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Runtime prerequisite checks for stage entry.
3
+ *
4
+ * This module centralizes the hard-stop decision for hooks, shell helpers,
5
+ * and toolchain prerequisites so stage commands do not have to infer readiness.
6
+ *
7
+ * @module lib/runtime-health
8
+ */
9
+
10
+ const fs = require('node:fs');
11
+ const { execFileSync: defaultExecFileSync } = require('node:child_process');
12
+
13
+ const { checkLefthookStatus } = require('./lefthook-check');
14
+
15
+ const WINDOWS_GIT_BASH_CANDIDATES = [
16
+ String.raw`C:\Program Files\Git\bin\bash.exe`,
17
+ String.raw`C:\Program Files (x86)\Git\bin\bash.exe`,
18
+ `${process.env.LOCALAPPDATA ?? ''}${String.raw`\Programs\Git\bin\bash.exe`}`
19
+ ].filter(Boolean);
20
+
21
+ function toText(output) {
22
+ if (typeof output === 'string') return output;
23
+ if (Buffer.isBuffer(output)) return output.toString('utf8');
24
+ return output == null ? '' : String(output);
25
+ }
26
+
27
+ function normalizeHooksPath(value, platform = process.platform) {
28
+ let normalized = toText(value).trim().replaceAll('\\', '/');
29
+
30
+ while (normalized.endsWith('/')) {
31
+ normalized = normalized.slice(0, -1);
32
+ }
33
+
34
+ while (normalized.startsWith('./')) {
35
+ normalized = normalized.slice(2);
36
+ }
37
+
38
+ if (platform === 'win32') {
39
+ normalized = normalized.toLowerCase();
40
+ }
41
+
42
+ return normalized;
43
+ }
44
+
45
+ function createDiagnostic(code, subject, message, repair) {
46
+ return {
47
+ code,
48
+ subject,
49
+ severity: 'hard-stop',
50
+ message,
51
+ ...(repair ? { repair } : {})
52
+ };
53
+ }
54
+
55
+ function isUsableWindowsShellCandidate(candidate, options = {}) {
56
+ if (typeof options._canExecute === 'function') {
57
+ try {
58
+ return Boolean(options._canExecute(candidate));
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ try {
65
+ defaultExecFileSync(candidate, ['--version'], {
66
+ encoding: 'utf8',
67
+ stdio: ['ignore', 'pipe', 'ignore']
68
+ });
69
+ return true;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ function checkHookInstallation(projectRoot, options = {}) {
76
+ const exec = options._exec || defaultExecFileSync;
77
+ const platform = options.platform || process.platform;
78
+ const expectedRelativeHooksPath = normalizeHooksPath('.lefthook/hooks', platform);
79
+ const expectedAbsoluteHooksPath = normalizeHooksPath(`${projectRoot}/${expectedRelativeHooksPath}`, platform);
80
+
81
+ try {
82
+ const output = exec('git', ['config', '--get', 'core.hooksPath'], {
83
+ cwd: projectRoot,
84
+ encoding: 'utf8'
85
+ });
86
+
87
+ const hooksPath = normalizeHooksPath(output, platform);
88
+ const active = hooksPath === expectedRelativeHooksPath || hooksPath === expectedAbsoluteHooksPath;
89
+
90
+ return {
91
+ active,
92
+ state: active ? 'active' : 'inactive',
93
+ hooksPath: hooksPath || null,
94
+ message: active ? '' : 'Git hooks are not pointed at .lefthook/hooks.'
95
+ };
96
+ } catch {
97
+ return {
98
+ active: false,
99
+ state: 'unverified',
100
+ hooksPath: null,
101
+ message: 'Git hooks could not be verified.'
102
+ };
103
+ }
104
+ }
105
+
106
+ function checkCommandAvailability(command, projectRoot, options = {}) {
107
+ const exec = options._exec || defaultExecFileSync;
108
+
109
+ try {
110
+ const output = exec(command, ['--version'], {
111
+ cwd: projectRoot,
112
+ encoding: 'utf8'
113
+ });
114
+
115
+ return {
116
+ available: true,
117
+ state: 'available',
118
+ command,
119
+ output: toText(output).trim(),
120
+ message: ''
121
+ };
122
+ } catch (err) {
123
+ return {
124
+ available: false,
125
+ state: 'missing',
126
+ command,
127
+ output: '',
128
+ message: err?.message ?? `${command} is unavailable`
129
+ };
130
+ }
131
+ }
132
+
133
+ function resolveShellRuntime(options = {}) {
134
+ const platform = options.platform || process.platform;
135
+
136
+ if (platform !== 'win32') {
137
+ return {
138
+ available: true,
139
+ state: 'available',
140
+ platform,
141
+ policy: 'system-shell',
142
+ command: options.command || 'sh',
143
+ message: ''
144
+ };
145
+ }
146
+
147
+ const candidates = Object.hasOwn(options, 'candidates')
148
+ ? options.candidates
149
+ : WINDOWS_GIT_BASH_CANDIDATES;
150
+
151
+ const exists = options._exists || fs.existsSync;
152
+ if (Array.isArray(candidates)) {
153
+ let unusableCandidate = null;
154
+
155
+ for (const candidate of candidates) {
156
+ if (!candidate || !exists(candidate)) {
157
+ continue;
158
+ }
159
+
160
+ if (isUsableWindowsShellCandidate(candidate, options)) {
161
+ return {
162
+ available: true,
163
+ state: 'available',
164
+ platform,
165
+ policy: 'git-bash',
166
+ command: candidate,
167
+ message: ''
168
+ };
169
+ }
170
+
171
+ unusableCandidate = candidate;
172
+ }
173
+
174
+ if (unusableCandidate) {
175
+ return {
176
+ available: false,
177
+ state: 'unusable',
178
+ platform,
179
+ policy: 'git-bash',
180
+ command: unusableCandidate,
181
+ message: 'Git Bash candidate exists but is not executable.'
182
+ };
183
+ }
184
+ }
185
+
186
+ return {
187
+ available: false,
188
+ state: 'missing',
189
+ platform,
190
+ policy: 'git-bash',
191
+ command: null,
192
+ message: 'Git Bash is required on Windows for helper-backed flows.'
193
+ };
194
+ }
195
+
196
+ function normalizeShellRuntime(shellRuntime, platform, options = {}) {
197
+ if (shellRuntime && typeof shellRuntime === 'object') {
198
+ const state = shellRuntime.state || (shellRuntime.available ? 'available' : 'missing');
199
+ return {
200
+ available: Boolean(shellRuntime.available),
201
+ state,
202
+ platform,
203
+ policy: shellRuntime.policy || (platform === 'win32' ? 'git-bash' : 'system-shell'),
204
+ command: shellRuntime.command || null,
205
+ message: shellRuntime.message || ''
206
+ };
207
+ }
208
+
209
+ return resolveShellRuntime({ ...options, platform });
210
+ }
211
+
212
+ function normalizeProjectRoot(projectRoot) {
213
+ return typeof projectRoot === 'string' && projectRoot.trim()
214
+ ? projectRoot
215
+ : process.cwd();
216
+ }
217
+
218
+ function checkRuntimeHealth(projectRoot, options = {}) {
219
+ const platform = options.platform || process.platform;
220
+ const root = normalizeProjectRoot(projectRoot);
221
+
222
+ const lefthook = checkLefthookStatus(root);
223
+ const hooks = checkHookInstallation(root, options);
224
+ const bd = checkCommandAvailability('bd', root, options);
225
+ const gh = checkCommandAvailability('gh', root, options);
226
+ const jq = checkCommandAvailability('jq', root, options);
227
+ const shell = normalizeShellRuntime(options.shellRuntime, platform, options);
228
+
229
+ const diagnostics = [];
230
+
231
+ if (lefthook.state !== 'installed') {
232
+ diagnostics.push(createDiagnostic(
233
+ 'LEFTHOOK_MISSING',
234
+ 'lefthook',
235
+ lefthook.message || 'lefthook is required for hook installation.',
236
+ 'bun add -D lefthook && bun install'
237
+ ));
238
+ }
239
+
240
+ if (!hooks.active) {
241
+ diagnostics.push(createDiagnostic(
242
+ 'HOOKS_NOT_ACTIVE',
243
+ 'git-hooks',
244
+ hooks.message || 'Git hooks are not installed.',
245
+ 'bunx lefthook install'
246
+ ));
247
+ }
248
+
249
+ if (!bd.available) {
250
+ diagnostics.push(createDiagnostic(
251
+ 'BD_MISSING',
252
+ 'bd',
253
+ 'bd is required for stage-entry workflow checks.'
254
+ ));
255
+ }
256
+
257
+ if (!gh.available) {
258
+ diagnostics.push(createDiagnostic(
259
+ 'GH_MISSING',
260
+ 'gh',
261
+ 'gh is required for stage-entry workflow checks.'
262
+ ));
263
+ }
264
+
265
+ if (!jq.available) {
266
+ diagnostics.push(createDiagnostic(
267
+ 'JQ_MISSING',
268
+ 'jq',
269
+ 'jq is required for stage-entry workflow checks.'
270
+ ));
271
+ }
272
+
273
+ if (platform === 'win32' && !shell.available) {
274
+ diagnostics.push(createDiagnostic(
275
+ 'SHELL_RUNTIME_MISSING',
276
+ 'shell-runtime',
277
+ shell.message || 'Git Bash is required on Windows for helper-backed flows.'
278
+ ));
279
+ }
280
+
281
+ const healthy = diagnostics.length === 0;
282
+
283
+ return {
284
+ healthy,
285
+ ready: healthy,
286
+ hardStop: !healthy,
287
+ diagnostics,
288
+ checks: {
289
+ projectRoot: root,
290
+ lefthook,
291
+ hooks,
292
+ bd,
293
+ gh,
294
+ jq,
295
+ shell
296
+ }
297
+ };
298
+ }
299
+
300
+ module.exports = {
301
+ checkRuntimeHealth,
302
+ checkHookInstallation,
303
+ checkCommandAvailability,
304
+ resolveShellRuntime
305
+ };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Shell execution utility wrappers
3
+ * Extracted from bin/forge.js for reuse and testability
4
+ * @module lib/shell-utils
5
+ */
6
+
7
+ const { execFileSync, spawnSync } = require('node:child_process');
8
+
9
+ /**
10
+ * Securely execute a command with PATH validation.
11
+ * Mitigates SonarCloud S4036: Ensures executables are from trusted locations.
12
+ * @param {string} command - The command to execute
13
+ * @param {string[]} [args=[]] - Command arguments
14
+ * @param {object} [options={}] - execFileSync options
15
+ * @returns {Buffer|string} Command output
16
+ */
17
+ function secureExecFileSync(command, args = [], options = {}) {
18
+ const {
19
+ _execFileSync = execFileSync,
20
+ _spawnSync = spawnSync,
21
+ ...execOptions
22
+ } = options;
23
+
24
+ let resolvedPath = null;
25
+
26
+ try {
27
+ // Resolve command's full path to validate it's in a trusted location
28
+ const isWindows = process.platform === 'win32';
29
+ const pathResolver = isWindows ? 'where.exe' : 'which';
30
+
31
+ const result = _spawnSync(pathResolver, [command], {
32
+ encoding: 'utf8',
33
+ stdio: ['ignore', 'pipe', 'ignore']
34
+ });
35
+
36
+ if (result.status === 0 && result.stdout) {
37
+ // Handle both CRLF (Windows) and LF (Unix) line endings
38
+ resolvedPath = result.stdout.trim().split(/\r?\n/)[0].trim();
39
+ }
40
+ } catch (_err) { // NOSONAR - S2486: Intentionally ignored; falls back to direct command execution below
41
+ }
42
+
43
+ // Fall back only when resolution failed. If execution of the resolved binary
44
+ // throws, propagate that error instead of retrying with the unresolved name.
45
+ return _execFileSync(resolvedPath || command, args, execOptions);
46
+ }
47
+
48
+ module.exports = {
49
+ secureExecFileSync
50
+ };