forge-workflow 0.0.6 → 0.0.8

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 (55) hide show
  1. package/.cursorrules +149 -0
  2. package/bin/forge.js +43 -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/_issue.js +172 -0
  14. package/lib/commands/_registry.js +40 -1
  15. package/lib/commands/claim.js +5 -0
  16. package/lib/commands/close.js +5 -0
  17. package/lib/commands/commands-reset.js +147 -0
  18. package/lib/commands/create.js +5 -0
  19. package/lib/commands/dev.js +26 -0
  20. package/lib/commands/issue.js +5 -0
  21. package/lib/commands/list.js +5 -0
  22. package/lib/commands/plan.js +18 -0
  23. package/lib/commands/ready.js +5 -0
  24. package/lib/commands/setup.js +4295 -0
  25. package/lib/commands/ship.js +20 -0
  26. package/lib/commands/show.js +5 -0
  27. package/lib/commands/status.js +210 -44
  28. package/lib/commands/sync.js +19 -1
  29. package/lib/commands/update.js +5 -0
  30. package/lib/commands/validate.js +13 -0
  31. package/lib/detect-agent.js +38 -8
  32. package/lib/detection-utils.js +405 -0
  33. package/lib/file-utils.js +260 -0
  34. package/lib/forge-context.js +42 -0
  35. package/lib/frontmatter.js +79 -0
  36. package/lib/husky-migration.js +113 -12
  37. package/lib/lefthook-check.js +27 -6
  38. package/lib/plugin-manager.js +225 -72
  39. package/lib/project-discovery.js +39 -5
  40. package/lib/runtime-health.js +305 -0
  41. package/lib/shell-utils.js +50 -0
  42. package/lib/ui-utils.js +43 -0
  43. package/lib/validation-utils.js +163 -0
  44. package/lib/workflow/enforce-stage.js +179 -0
  45. package/lib/workflow/stages.js +201 -0
  46. package/lib/workflow/state.js +332 -0
  47. package/opencode.json +67 -0
  48. package/package.json +15 -5
  49. package/scripts/beads-context.sh +12 -4
  50. package/scripts/check-agents.js +103 -0
  51. package/scripts/lib/eval-runner.js +50 -0
  52. package/scripts/pr-coordinator.sh +71 -21
  53. package/scripts/smart-status.sh +21 -11
  54. package/scripts/sync-commands.js +49 -20
  55. package/scripts/test.js +16 -1
@@ -7,6 +7,7 @@
7
7
  const fs = require('node:fs');
8
8
  const path = require('node:path');
9
9
  const { execFileSync } = require('node:child_process');
10
+ const { normalizeAgentId } = require('./detect-agent');
10
11
 
11
12
  async function detectFramework(projectPath) {
12
13
  try {
@@ -283,12 +284,45 @@ async function detectInstalledAgents(projectPath) {
283
284
  ]
284
285
  },
285
286
  {
286
- name: 'kilo',
287
+ name: 'cline',
287
288
  checks: [
288
- // .kilo.md file
289
+ async () => fs.existsSync(path.join(projectPath, '.clinerules')),
289
290
  async () => {
290
- const kiloMd = path.join(projectPath, '.kilo.md');
291
- return fs.existsSync(kiloMd);
291
+ const clineDir = path.join(projectPath, '.cline');
292
+ return fs.existsSync(clineDir) && (await fs.promises.stat(clineDir)).isDirectory();
293
+ }
294
+ ]
295
+ },
296
+ {
297
+ name: 'kilocode',
298
+ checks: [
299
+ async () => {
300
+ const kilocodeDir = path.join(projectPath, '.kilocode');
301
+ return fs.existsSync(kilocodeDir) && (await fs.promises.stat(kilocodeDir)).isDirectory();
302
+ },
303
+ async () => fs.existsSync(path.join(projectPath, '.kilocode', 'workflows', 'forge-workflow.md')),
304
+ async () => fs.existsSync(path.join(projectPath, '.kilocode', 'rules', 'workflow.md')),
305
+ async () => fs.existsSync(path.join(projectPath, '.kilocode', 'skills', 'forge-workflow', 'SKILL.md'))
306
+ ]
307
+ },
308
+ {
309
+ name: 'roo',
310
+ checks: [
311
+ async () => fs.existsSync(path.join(projectPath, '.roorules')),
312
+ async () => {
313
+ const rooDir = path.join(projectPath, '.roo');
314
+ return fs.existsSync(rooDir) && (await fs.promises.stat(rooDir)).isDirectory();
315
+ },
316
+ async () => fs.existsSync(path.join(projectPath, '.roo', 'rules'))
317
+ ]
318
+ },
319
+ {
320
+ name: 'codex',
321
+ checks: [
322
+ async () => fs.existsSync(path.join(projectPath, 'codex.md')),
323
+ async () => {
324
+ const codexDir = path.join(projectPath, '.codex');
325
+ return fs.existsSync(codexDir) && (await fs.promises.stat(codexDir)).isDirectory();
292
326
  }
293
327
  ]
294
328
  },
@@ -311,7 +345,7 @@ async function detectInstalledAgents(projectPath) {
311
345
  try {
312
346
  const detected = await check();
313
347
  if (detected) {
314
- detectedAgents.push(detector.name);
348
+ detectedAgents.push(normalizeAgentId(detector.name));
315
349
  break; // Agent detected, no need to check other patterns
316
350
  }
317
351
  } catch (_error) {
@@ -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
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * UI prompt and display utilities
3
+ * Extracted from bin/forge.js for reuse and testability
4
+ * @module lib/ui-utils
5
+ */
6
+
7
+ /**
8
+ * Yes/No prompt helper.
9
+ * @param {Function} question - Async function that prompts user and returns their answer string
10
+ * @param {string} prompt - The prompt text to display
11
+ * @param {boolean} [defaultNo=true] - Whether the default answer is "no"
12
+ * @param {boolean} [nonInteractive=false] - If true, returns default without prompting
13
+ * @returns {Promise<boolean>} User's answer
14
+ */
15
+ async function askYesNo(question, prompt, defaultNo = true, nonInteractive = false) {
16
+ // Non-interactive mode: return default without prompting
17
+ if (nonInteractive) {
18
+ const defaultValue = !defaultNo;
19
+ console.log(` Non-interactive mode: ${prompt} -> ${defaultValue ? 'yes' : 'no'} (default)`);
20
+ return defaultValue;
21
+ }
22
+ const defaultText = defaultNo ? '[n]' : '[y]';
23
+ while (true) {
24
+ const answer = await question(`${prompt} (y/n) ${defaultText}: `);
25
+ const normalized = answer.trim().toLowerCase();
26
+
27
+ // Handle empty input (use default)
28
+ if (normalized === '') return !defaultNo;
29
+
30
+ // Accept yes variations
31
+ if (normalized === 'y' || normalized === 'yes') return true;
32
+
33
+ // Accept no variations
34
+ if (normalized === 'n' || normalized === 'no') return false;
35
+
36
+ // Invalid input - re-prompt
37
+ console.log(' Please enter y or n');
38
+ }
39
+ }
40
+
41
+ module.exports = {
42
+ askYesNo
43
+ };
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Input validation utilities
3
+ * Extracted from bin/forge.js for reuse and testability
4
+ * @module lib/validation-utils
5
+ */
6
+
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+
10
+ /**
11
+ * Run common security checks on input.
12
+ * Checks for shell injection, URL encoding attacks, and non-ASCII characters.
13
+ * @param {string} input - Input string to validate
14
+ * @returns {{valid: boolean, error?: string}}
15
+ */
16
+ function validateCommonSecurity(input) {
17
+ // Shell injection check - common shell metacharacters
18
+ if (/[;|&$`()<>\r\n]/.test(input)) {
19
+ return { valid: false, error: 'Invalid characters detected (shell metacharacters)' };
20
+ }
21
+
22
+ // URL encoding check - prevent encoded path traversal
23
+ if (/%2[eE]|%2[fF]|%5[cC]/.test(input)) {
24
+ return { valid: false, error: 'URL-encoded characters not allowed' };
25
+ }
26
+
27
+ // ASCII-only check - prevent unicode attacks
28
+ if (!/^[\x20-\x7E]+$/.test(input)) {
29
+ return { valid: false, error: 'Only ASCII printable characters allowed' };
30
+ }
31
+
32
+ return { valid: true }; // No security issues found
33
+ }
34
+
35
+ /**
36
+ * Validate user input against security patterns.
37
+ * Prevents shell injection, path traversal, and unicode attacks.
38
+ * @param {string} input - User input to validate
39
+ * @param {string} type - Input type: 'path', 'agent', 'hash', 'directory_path'
40
+ * @param {string} [projectRoot] - Project root path (required for 'path' type)
41
+ * @returns {{valid: boolean, error?: string}}
42
+ */
43
+ function validateUserInput(input, type, projectRoot) {
44
+ // Common security checks first
45
+ const securityResult = validateCommonSecurity(input);
46
+ if (!securityResult.valid) return securityResult;
47
+
48
+ // Type-specific validation - delegated to helpers
49
+ switch (type) {
50
+ case 'path':
51
+ return validatePathInput(input, projectRoot);
52
+ case 'directory_path':
53
+ return validateDirectoryPathInput(input);
54
+ case 'agent':
55
+ return validateAgentInput(input);
56
+ case 'hash':
57
+ return validateHashInput(input);
58
+ default:
59
+ return { valid: true };
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Validate 'path' type input - ensures path stays within project root.
65
+ * @param {string} input - Path to validate
66
+ * @param {string} projectRoot - Project root directory
67
+ * @returns {{valid: boolean, error?: string}}
68
+ */
69
+ function validatePathInput(input, projectRoot) {
70
+ const resolved = path.resolve(projectRoot, input);
71
+ const resolvedRoot = path.resolve(projectRoot);
72
+ if (!resolved.startsWith(resolvedRoot + path.sep) && resolved !== resolvedRoot) {
73
+ return { valid: false, error: 'Path outside project root' };
74
+ }
75
+ return { valid: true };
76
+ }
77
+
78
+ /**
79
+ * Validate 'directory_path' type input - blocks system directories.
80
+ * @param {string} input - Directory path to validate
81
+ * @returns {{valid: boolean, error?: string}}
82
+ */
83
+ function validateDirectoryPathInput(input) {
84
+ // Block null bytes
85
+ if (input.includes('\0')) {
86
+ return { valid: false, error: 'Null bytes not allowed in path' };
87
+ }
88
+
89
+ // Block absolute paths to sensitive system directories
90
+ const resolved = path.resolve(input);
91
+ const normalizedResolved = path.normalize(resolved).toLowerCase();
92
+
93
+ // Get platform-specific blocked paths
94
+ const blockedPaths = process.platform === 'win32'
95
+ ? [String.raw`c:\windows`, String.raw`c:\program files`, String.raw`c:\program files (x86)`]
96
+ : ['/etc', '/bin', '/sbin', '/boot', '/sys', '/proc', '/dev'];
97
+ const errorMsg = process.platform === 'win32'
98
+ ? 'Cannot target Windows system directories'
99
+ : 'Cannot target system directories';
100
+
101
+ if (blockedPaths.some(blocked => normalizedResolved.startsWith(blocked))) {
102
+ return { valid: false, error: errorMsg };
103
+ }
104
+
105
+ return { valid: true };
106
+ }
107
+
108
+ /**
109
+ * Validate 'agent' type input - lowercase alphanumeric with hyphens only.
110
+ * @param {string} input - Agent name to validate
111
+ * @returns {{valid: boolean, error?: string}}
112
+ */
113
+ function validateAgentInput(input) {
114
+ if (!/^[a-z0-9-]+$/.test(input)) {
115
+ return { valid: false, error: 'Agent name must be lowercase alphanumeric with hyphens' };
116
+ }
117
+ return { valid: true };
118
+ }
119
+
120
+ /**
121
+ * Validate 'hash' type input - git commit hash (4-40 hex chars).
122
+ * @param {string} input - Hash to validate
123
+ * @returns {{valid: boolean, error?: string}}
124
+ */
125
+ function validateHashInput(input) {
126
+ if (!/^[0-9a-f]{4,40}$/i.test(input)) {
127
+ return { valid: false, error: 'Invalid commit hash format (must be 4-40 hex chars)' };
128
+ }
129
+ return { valid: true };
130
+ }
131
+
132
+ /**
133
+ * Check write permission to a directory or file.
134
+ * @param {string} filePath - Path to check
135
+ * @returns {{writable: boolean, error?: string}}
136
+ */
137
+ function _checkWritePermission(filePath) {
138
+ try {
139
+ const dir = fs.statSync(filePath).isDirectory() ? filePath : path.dirname(filePath);
140
+ const testFile = path.join(dir, `.forge-write-test-${Date.now()}`);
141
+ fs.writeFileSync(testFile, 'test');
142
+ fs.unlinkSync(testFile);
143
+ return { writable: true };
144
+ } catch (err) {
145
+ if (err.code === 'EACCES' || err.code === 'EPERM') {
146
+ const fix = process.platform === 'win32'
147
+ ? 'Run Command Prompt as Administrator'
148
+ : 'Try: sudo npx forge setup';
149
+ return { writable: false, error: `No write permission to ${filePath}. ${fix}` };
150
+ }
151
+ return { writable: false, error: err.message };
152
+ }
153
+ }
154
+
155
+ module.exports = {
156
+ validateCommonSecurity,
157
+ validateUserInput,
158
+ validatePathInput,
159
+ validateDirectoryPathInput,
160
+ validateAgentInput,
161
+ validateHashInput,
162
+ _checkWritePermission
163
+ };