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.
- package/.cursorrules +149 -0
- package/bin/forge.js +43 -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/_issue.js +172 -0
- package/lib/commands/_registry.js +40 -1
- package/lib/commands/claim.js +5 -0
- package/lib/commands/close.js +5 -0
- package/lib/commands/commands-reset.js +147 -0
- package/lib/commands/create.js +5 -0
- package/lib/commands/dev.js +26 -0
- package/lib/commands/issue.js +5 -0
- package/lib/commands/list.js +5 -0
- package/lib/commands/plan.js +18 -0
- package/lib/commands/ready.js +5 -0
- package/lib/commands/setup.js +4295 -0
- package/lib/commands/ship.js +20 -0
- package/lib/commands/show.js +5 -0
- package/lib/commands/status.js +210 -44
- package/lib/commands/sync.js +19 -1
- package/lib/commands/update.js +5 -0
- 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/lib/eval-runner.js +50 -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
package/lib/project-discovery.js
CHANGED
|
@@ -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: '
|
|
287
|
+
name: 'cline',
|
|
287
288
|
checks: [
|
|
288
|
-
|
|
289
|
+
async () => fs.existsSync(path.join(projectPath, '.clinerules')),
|
|
289
290
|
async () => {
|
|
290
|
-
const
|
|
291
|
-
return fs.existsSync(
|
|
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
|
+
};
|
package/lib/ui-utils.js
ADDED
|
@@ -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
|
+
};
|