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.
- package/.claude/commands/dev.md +6 -1
- package/.claude/commands/plan.md +59 -14
- package/.claude/commands/premerge.md +10 -0
- package/.claude/commands/review.md +7 -1
- package/.claude/commands/ship.md +95 -47
- package/.claude/commands/status.md +42 -0
- package/.claude/commands/validate.md +7 -1
- package/.claude/commands/verify.md +52 -4
- package/.claude/rules/workflow.md +16 -0
- package/.claude/scripts/greptile-resolve.sh +32 -0
- package/.cline/workflows/dev.md +6 -1
- package/.cline/workflows/plan.md +59 -14
- package/.cline/workflows/premerge.md +10 -0
- package/.cline/workflows/review.md +7 -1
- package/.cline/workflows/ship.md +95 -47
- package/.cline/workflows/status.md +42 -0
- package/.cline/workflows/validate.md +7 -1
- package/.cline/workflows/verify.md +52 -4
- package/.codex/skills/dev/SKILL.md +6 -1
- package/.codex/skills/plan/SKILL.md +59 -14
- package/.codex/skills/premerge/SKILL.md +10 -0
- package/.codex/skills/review/SKILL.md +7 -1
- package/.codex/skills/ship/SKILL.md +95 -47
- package/.codex/skills/status/SKILL.md +42 -0
- package/.codex/skills/validate/SKILL.md +7 -1
- package/.codex/skills/verify/SKILL.md +52 -4
- package/.cursor/commands/dev.md +6 -1
- package/.cursor/commands/plan.md +59 -14
- package/.cursor/commands/premerge.md +10 -0
- package/.cursor/commands/review.md +7 -1
- package/.cursor/commands/ship.md +95 -47
- package/.cursor/commands/status.md +42 -0
- package/.cursor/commands/validate.md +7 -1
- package/.cursor/commands/verify.md +52 -4
- package/.cursorrules +149 -0
- package/.github/prompts/dev.prompt.md +6 -1
- package/.github/prompts/plan.prompt.md +59 -14
- package/.github/prompts/premerge.prompt.md +10 -0
- package/.github/prompts/review.prompt.md +7 -1
- package/.github/prompts/ship.prompt.md +95 -47
- package/.github/prompts/status.prompt.md +42 -0
- package/.github/prompts/validate.prompt.md +7 -1
- package/.github/prompts/verify.prompt.md +52 -4
- package/.kilocode/workflows/dev.md +6 -1
- package/.kilocode/workflows/plan.md +59 -14
- package/.kilocode/workflows/premerge.md +10 -0
- package/.kilocode/workflows/review.md +7 -1
- package/.kilocode/workflows/ship.md +95 -47
- package/.kilocode/workflows/status.md +42 -0
- package/.kilocode/workflows/validate.md +7 -1
- package/.kilocode/workflows/verify.md +52 -4
- package/.opencode/commands/dev.md +6 -1
- package/.opencode/commands/plan.md +59 -14
- package/.opencode/commands/premerge.md +10 -0
- package/.opencode/commands/review.md +7 -1
- package/.opencode/commands/ship.md +95 -47
- package/.opencode/commands/status.md +42 -0
- package/.opencode/commands/validate.md +7 -1
- package/.opencode/commands/verify.md +52 -4
- package/.roo/commands/dev.md +6 -1
- package/.roo/commands/plan.md +59 -14
- package/.roo/commands/premerge.md +10 -0
- package/.roo/commands/review.md +7 -1
- package/.roo/commands/ship.md +95 -47
- package/.roo/commands/status.md +42 -0
- package/.roo/commands/validate.md +7 -1
- package/.roo/commands/verify.md +52 -4
- package/AGENTS.md +97 -0
- package/CLAUDE.md +10 -0
- package/README.md +2 -2
- package/bin/forge-cmd.js +5 -1
- package/bin/forge-preflight.js +15 -2
- package/bin/forge.js +211 -9
- package/docs/ENHANCED_ONBOARDING.md +96 -86
- package/docs/ROADMAP.md +2 -2
- package/docs/TOOLCHAIN.md +23 -0
- package/docs/VALIDATION.md +1 -1
- package/lefthook.yml +11 -0
- 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 +129 -81
- package/lib/codex-skills.js +50 -0
- package/lib/commands/_registry.js +173 -0
- package/lib/commands/clean.js +181 -0
- package/lib/commands/commands-reset.js +147 -0
- package/lib/commands/dev.js +84 -0
- package/lib/commands/plan.js +18 -0
- package/lib/commands/push.js +196 -0
- package/lib/commands/recommend.js +1 -1
- package/lib/commands/setup.js +4295 -0
- package/lib/commands/ship.js +20 -0
- package/lib/commands/status.js +210 -44
- package/lib/commands/sync.js +71 -0
- package/lib/commands/team.js +37 -0
- package/lib/commands/test.js +207 -0
- package/lib/commands/validate.js +13 -0
- package/lib/commands/worktree.js +310 -0
- package/lib/detect-agent.js +38 -8
- package/lib/detection-utils.js +405 -0
- package/lib/docs-command.js +51 -0
- package/lib/docs-copy.js +50 -0
- package/lib/file-utils.js +260 -0
- package/lib/forge-context.js +42 -0
- package/lib/freshness-token.js +148 -0
- package/lib/frontmatter.js +79 -0
- package/lib/greptile-match.js +80 -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/reset.js +309 -0
- package/lib/runtime-health.js +305 -0
- package/lib/shell-utils.js +50 -0
- package/lib/task-ownership.js +117 -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 +16 -6
- package/scripts/beads-context.sh +165 -22
- package/scripts/beads-context.test.js +5 -1
- package/scripts/check-agents.js +103 -0
- package/scripts/check-forge-token.js +98 -0
- package/scripts/conflict-detect.sh +2 -2
- package/scripts/dep-guard.sh +6 -28
- package/scripts/file-index.sh +117 -23
- package/scripts/forge-team/index.sh +86 -0
- package/scripts/forge-team/lib/agent-prompt.sh +52 -0
- package/scripts/forge-team/lib/claim.sh +256 -0
- package/scripts/forge-team/lib/dashboard.sh +341 -0
- package/scripts/forge-team/lib/epic.sh +332 -0
- package/scripts/forge-team/lib/hooks.sh +253 -0
- package/scripts/forge-team/lib/identity.sh +235 -0
- package/scripts/forge-team/lib/sync-github.sh +317 -0
- package/scripts/forge-team/lib/verify.sh +284 -0
- package/scripts/forge-team/lib/workload.sh +296 -0
- package/scripts/forge-team/tests/agent-prompt.test.sh +72 -0
- package/scripts/forge-team/tests/claim.test.sh +179 -0
- package/scripts/forge-team/tests/dashboard.test.sh +170 -0
- package/scripts/forge-team/tests/dispatcher.test.sh +79 -0
- package/scripts/forge-team/tests/epic.test.sh +176 -0
- package/scripts/forge-team/tests/hooks.test.sh +239 -0
- package/scripts/forge-team/tests/identity.test.sh +176 -0
- package/scripts/forge-team/tests/integration.test.sh +371 -0
- package/scripts/forge-team/tests/sync-github.test.sh +209 -0
- package/scripts/forge-team/tests/verify.test.sh +314 -0
- package/scripts/forge-team/tests/workflow-integration.test.sh +43 -0
- package/scripts/forge-team/tests/workload.test.sh +209 -0
- package/scripts/lib/eval-runner.js +39 -0
- package/scripts/lib/jsonl-lock.sh +48 -0
- package/scripts/lib/sanitize.sh +116 -0
- package/scripts/pr-coordinator.sh +756 -0
- package/scripts/smart-status.sh +58 -21
- package/scripts/sync-commands.js +49 -20
- package/scripts/sync-utils.sh +24 -29
- package/scripts/test.js +18 -1
|
@@ -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 };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Freshness Token — Validate/Ship stage freshness state
|
|
3
|
+
*
|
|
4
|
+
* Tracks whether `main` has moved forward since /validate ran,
|
|
5
|
+
* so /ship can warn if validation results are stale.
|
|
6
|
+
*
|
|
7
|
+
* Token file: `<projectRoot>/.forge-freshness` (ephemeral, gitignored)
|
|
8
|
+
*
|
|
9
|
+
* @module freshness-token
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { execFileSync } = require('node:child_process');
|
|
15
|
+
|
|
16
|
+
const TOKEN_FILENAME = '.forge-freshness';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get the current git branch name.
|
|
20
|
+
* @param {string} projectRoot - Absolute path to the project root.
|
|
21
|
+
* @returns {string} Current branch name.
|
|
22
|
+
*/
|
|
23
|
+
function getCurrentBranch(projectRoot) {
|
|
24
|
+
// Safe: execFileSync with array args prevents injection (OWASP A03). PATH is OS-controlled.
|
|
25
|
+
return execFileSync('git', ['-C', projectRoot, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
26
|
+
encoding: 'utf8'
|
|
27
|
+
}).trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Detect the default branch (main, master, develop, trunk).
|
|
32
|
+
* @param {string} projectRoot - Absolute path to the project root.
|
|
33
|
+
* @returns {string} Default branch name.
|
|
34
|
+
*/
|
|
35
|
+
function getDefaultBranch(projectRoot) {
|
|
36
|
+
try {
|
|
37
|
+
// Safe: execFileSync with array args prevents injection (OWASP A03). PATH is OS-controlled.
|
|
38
|
+
return execFileSync('git', ['-C', projectRoot, 'rev-parse', '--abbrev-ref', 'origin/HEAD'], {
|
|
39
|
+
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
|
|
40
|
+
}).trim().replace('origin/', '');
|
|
41
|
+
} catch (_e) { /* intentional: origin/HEAD not set, probe common branch names */ // NOSONAR S2486
|
|
42
|
+
for (const name of ['main', 'master', 'develop', 'trunk']) {
|
|
43
|
+
try {
|
|
44
|
+
// Safe: execFileSync with array args prevents injection (OWASP A03). PATH is OS-controlled.
|
|
45
|
+
execFileSync('git', ['-C', projectRoot, 'rev-parse', '--verify', name], { stdio: 'pipe' });
|
|
46
|
+
return name;
|
|
47
|
+
} catch (_e2) { /* intentional: branch doesn't exist, try next name */ } // NOSONAR S2486
|
|
48
|
+
}
|
|
49
|
+
return 'main';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get the merge-base commit between HEAD and the default branch.
|
|
55
|
+
* @param {string} projectRoot - Absolute path to the project root.
|
|
56
|
+
* @returns {string} The merge-base commit SHA.
|
|
57
|
+
* @throws {Error} If git merge-base fails (no common ancestor, detached HEAD, etc.)
|
|
58
|
+
*/
|
|
59
|
+
function getMergeBase(projectRoot) {
|
|
60
|
+
const base = getDefaultBranch(projectRoot);
|
|
61
|
+
try {
|
|
62
|
+
// Safe: execFileSync with array args prevents injection (OWASP A03). PATH is OS-controlled.
|
|
63
|
+
return execFileSync('git', ['-C', projectRoot, 'merge-base', 'HEAD', base], {
|
|
64
|
+
encoding: 'utf8',
|
|
65
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
66
|
+
}).trim();
|
|
67
|
+
} catch (err) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Failed to compute merge-base for HEAD and ${base} in "${projectRoot}": ${err.message}`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Write a freshness token to `<projectRoot>/.forge-freshness`.
|
|
76
|
+
*
|
|
77
|
+
* Records the current branch, base commit (merge-base HEAD main),
|
|
78
|
+
* and a timestamp so /ship can detect if validation is stale.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} projectRoot - Absolute path to the project root.
|
|
81
|
+
* @returns {{ timestamp: number, branch: string, baseCommit: string }} The written token.
|
|
82
|
+
* @throws {Error} If git merge-base fails (no common ancestor, detached HEAD, etc.)
|
|
83
|
+
*/
|
|
84
|
+
function writeFreshnessToken(projectRoot) {
|
|
85
|
+
const branch = getCurrentBranch(projectRoot);
|
|
86
|
+
const baseCommit = getMergeBase(projectRoot);
|
|
87
|
+
|
|
88
|
+
const token = {
|
|
89
|
+
timestamp: Date.now(),
|
|
90
|
+
branch,
|
|
91
|
+
baseCommit
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const tokenPath = path.join(projectRoot, TOKEN_FILENAME);
|
|
95
|
+
fs.writeFileSync(tokenPath, JSON.stringify(token, null, 2), 'utf8');
|
|
96
|
+
|
|
97
|
+
return token;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read and parse the freshness token from `<projectRoot>/.forge-freshness`.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} projectRoot - Absolute path to the project root.
|
|
104
|
+
* @returns {{ timestamp: number, branch: string, baseCommit: string } | null}
|
|
105
|
+
* The token object, or null if the file is missing or corrupted.
|
|
106
|
+
*/
|
|
107
|
+
function readFreshnessToken(projectRoot) {
|
|
108
|
+
const tokenPath = path.join(projectRoot, TOKEN_FILENAME);
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const content = fs.readFileSync(tokenPath, 'utf8');
|
|
112
|
+
return JSON.parse(content);
|
|
113
|
+
} catch (_err) { /* intentional: missing file (ENOENT), corrupted JSON, or empty file — return null */ // NOSONAR S2486
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check if a freshness token is stale.
|
|
120
|
+
*
|
|
121
|
+
* A token is stale when:
|
|
122
|
+
* - It is null (missing or corrupted)
|
|
123
|
+
* - The current merge-base differs from the token's baseCommit
|
|
124
|
+
* (meaning main has moved forward since validation)
|
|
125
|
+
* - git merge-base fails (can't verify freshness)
|
|
126
|
+
*
|
|
127
|
+
* @param {{ timestamp: number, branch: string, baseCommit: string } | null} token
|
|
128
|
+
* @param {string} projectRoot - Absolute path to the project root.
|
|
129
|
+
* @returns {boolean} True if validation results are stale and should be re-run.
|
|
130
|
+
*/
|
|
131
|
+
function isStale(token, projectRoot) {
|
|
132
|
+
if (token === null) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const currentBase = getMergeBase(projectRoot);
|
|
138
|
+
return currentBase !== token.baseCommit;
|
|
139
|
+
} catch (_err) { /* intentional: merge-base failed, treat as stale since freshness can't be verified */ // NOSONAR S2486
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
writeFreshnessToken,
|
|
146
|
+
readFreshnessToken,
|
|
147
|
+
isStale
|
|
148
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module frontmatter
|
|
5
|
+
*
|
|
6
|
+
* Utility for parsing and manipulating YAML frontmatter using gray-matter.
|
|
7
|
+
*
|
|
8
|
+
* This is the canonical frontmatter library for runtime code that needs
|
|
9
|
+
* full gray-matter capabilities (e.g., commands-reset, plugin transforms).
|
|
10
|
+
*
|
|
11
|
+
* Note: scripts/sync-commands.js uses its own hand-rolled YAML parser
|
|
12
|
+
* (via the `yaml` package) because sync runs at build time and must not
|
|
13
|
+
* depend on gray-matter being installed in the target project.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const { parse, stringify, stripAll, keepOnly } = require('./frontmatter');
|
|
17
|
+
* const { data, content } = parse(raw);
|
|
18
|
+
* const rebuilt = stringify(data, content);
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const matter = require('gray-matter');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse YAML frontmatter from a markdown string.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} content - Raw file content with optional frontmatter
|
|
27
|
+
* @returns {{ data: Record<string, unknown>, content: string }}
|
|
28
|
+
*/
|
|
29
|
+
function parse(content) {
|
|
30
|
+
const result = matter(content);
|
|
31
|
+
return { data: result.data, content: result.content };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build a file string from frontmatter data and body content.
|
|
36
|
+
*
|
|
37
|
+
* @param {Record<string, unknown>} data - Key-value pairs for the YAML block
|
|
38
|
+
* @param {string} content - The markdown body content
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
function stringify(data, content) {
|
|
42
|
+
return matter.stringify(content, data);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Strip all frontmatter, returning only the body content.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} content - Raw file content with optional frontmatter
|
|
49
|
+
* @returns {string} Body content without frontmatter
|
|
50
|
+
*/
|
|
51
|
+
function stripAll(content) {
|
|
52
|
+
const result = matter(content);
|
|
53
|
+
return result.content;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Keep only specified fields in frontmatter, strip the rest.
|
|
58
|
+
*
|
|
59
|
+
* If none of the specified fields exist, returns body only (no frontmatter).
|
|
60
|
+
*
|
|
61
|
+
* @param {string} content - Raw file content with optional frontmatter
|
|
62
|
+
* @param {string[]} fields - Field names to keep
|
|
63
|
+
* @returns {string} Rebuilt file with filtered frontmatter
|
|
64
|
+
*/
|
|
65
|
+
function keepOnly(content, fields) {
|
|
66
|
+
const result = matter(content);
|
|
67
|
+
const filtered = {};
|
|
68
|
+
for (const field of fields) {
|
|
69
|
+
if (Object.hasOwn(result.data, field)) {
|
|
70
|
+
filtered[field] = result.data[field];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (Object.keys(filtered).length === 0) {
|
|
74
|
+
return result.content;
|
|
75
|
+
}
|
|
76
|
+
return matter.stringify(result.content, filtered);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { parse, stringify, stripAll, keepOnly };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Match unresolved review threads to recent commits that touched the same files.
|
|
7
|
+
*
|
|
8
|
+
* For each thread, runs `git log --oneline --follow -- <file>` to find
|
|
9
|
+
* recent commits. If a commit exists, the thread is considered resolved
|
|
10
|
+
* (the file was modified after the review comment).
|
|
11
|
+
*
|
|
12
|
+
* Uses execFileSync (not exec) to prevent shell injection (OWASP A03).
|
|
13
|
+
*
|
|
14
|
+
* @param {Array<{file: string, line: number}>} threads - Review threads with file paths
|
|
15
|
+
* @param {string} projectRoot - Absolute path to the project root (used with git -C)
|
|
16
|
+
* @param {object} [opts] - Options
|
|
17
|
+
* @param {Function} [opts._exec] - Injected exec function for testing (defaults to execFileSync)
|
|
18
|
+
* @param {string} [opts.sinceCommit] - Merge-base SHA to restrict log to PR commits only
|
|
19
|
+
* @returns {Array<{file: string, line: number, resolved: boolean, sha?: string, reason?: string}>}
|
|
20
|
+
*/
|
|
21
|
+
function matchThreadsToCommits(threads, projectRoot, opts = {}) {
|
|
22
|
+
if (!threads || threads.length === 0) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const exec = opts._exec || ((cmd, args) => {
|
|
27
|
+
return execFileSync(cmd, args, { encoding: 'utf8' });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Determine commit range — restrict to PR commits when sinceCommit provided
|
|
31
|
+
let sinceCommit = opts.sinceCommit;
|
|
32
|
+
if (!sinceCommit) {
|
|
33
|
+
try {
|
|
34
|
+
sinceCommit = exec('git', ['-C', projectRoot, 'merge-base', 'HEAD', 'main']).trim();
|
|
35
|
+
} catch (_e) { // NOSONAR S2486
|
|
36
|
+
/* intentional: main branch not found, try master instead */
|
|
37
|
+
try {
|
|
38
|
+
sinceCommit = exec('git', ['-C', projectRoot, 'merge-base', 'HEAD', 'master']).trim();
|
|
39
|
+
} catch (_e2) { /* intentional: no merge-base available, fall back to unbounded log */ // NOSONAR S2486
|
|
40
|
+
sinceCommit = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return threads.map((thread) => {
|
|
46
|
+
const { file, line } = thread;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const logArgs = [
|
|
50
|
+
'-C', projectRoot,
|
|
51
|
+
'log',
|
|
52
|
+
'--oneline',
|
|
53
|
+
'--follow',
|
|
54
|
+
];
|
|
55
|
+
if (sinceCommit) {
|
|
56
|
+
logArgs.push(`${sinceCommit}..HEAD`);
|
|
57
|
+
}
|
|
58
|
+
logArgs.push('--', file);
|
|
59
|
+
|
|
60
|
+
const output = exec('git', logArgs);
|
|
61
|
+
|
|
62
|
+
const trimmed = (output || '').trim();
|
|
63
|
+
|
|
64
|
+
if (!trimmed) {
|
|
65
|
+
return { file, line, resolved: false, reason: 'no matching commit' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// git log --oneline format: "<sha> <message>"
|
|
69
|
+
// Take the first (most recent) line
|
|
70
|
+
const firstLine = trimmed.split('\n')[0];
|
|
71
|
+
const sha = firstLine.split(' ')[0];
|
|
72
|
+
|
|
73
|
+
return { file, line, resolved: true, sha };
|
|
74
|
+
} catch (_err) { /* intentional: git log failed (file missing or git unavailable), mark unresolved */ // NOSONAR S2486
|
|
75
|
+
return { file, line, resolved: false, reason: 'no matching commit' };
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { matchThreadsToCommits };
|