forgedock 1.0.1 → 1.0.3
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/README.md +19 -2
- package/bin/forgedock.mjs +3084 -80
- package/bin/github-app-manifest.json +24 -0
- package/bin/tui.mjs +702 -0
- package/commands/analytics.md +129 -52
- package/commands/audit-agents.md +29 -5
- package/commands/audit.md +55 -17
- package/commands/autopilot.md +11 -6
- package/commands/cleanup.md +44 -24
- package/commands/deploy-info.md +7 -3
- package/commands/forge-stats.md +13 -4
- package/commands/forgedock-init.md +534 -0
- package/commands/geo-audit.md +114 -26
- package/commands/incident-response.md +4 -1
- package/commands/issue.md +26 -25
- package/commands/milestone.md +80 -59
- package/commands/orchestrate.md +38 -21
- package/commands/pipeline-health.md +26 -15
- package/commands/qa-sweep.md +68 -5
- package/commands/quality-gate.md +18 -0
- package/commands/review-pr-agents.md +58 -90
- package/commands/review-pr.md +58 -16
- package/commands/rollback.md +8 -5
- package/commands/security-audit.md +68 -12
- package/commands/sync-ecosystem.md +196 -156
- package/commands/work-on/build/architect.md +2 -2
- package/commands/work-on/build/context.md +2 -2
- package/commands/work-on/build/implement.md +3 -3
- package/commands/work-on/build/validate.md +3 -3
- package/commands/work-on/build.md +6 -6
- package/commands/work-on/close.md +59 -30
- package/commands/work-on/decompose.md +6 -6
- package/commands/work-on/investigate.md +16 -20
- package/commands/work-on/review.md +4 -4
- package/commands/work-on-monolithic.md +15 -16
- package/commands/work-on.md +45 -33
- package/docs/CODEX.md +1 -1
- package/docs/CONFIG.md +398 -0
- package/docs/WORKFLOW.md +115 -72
- package/package.json +1 -1
- package/scripts/verify-host-headers.sh +14 -9
- package/scripts/verify-route-registration.sh +10 -10
- package/commands/failure-recon.md +0 -375
package/bin/forgedock.mjs
CHANGED
|
@@ -1,26 +1,547 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { fileURLToPath } from "url";
|
|
4
|
-
import { dirname, join, relative } from "path";
|
|
5
|
-
import { mkdir, symlink, readlink, lstat, readdir, stat } from "fs/promises";
|
|
6
|
-
import { existsSync, appendFileSync, readFileSync } from "fs";
|
|
7
|
-
import { execSync } from "child_process";
|
|
4
|
+
import { dirname, join, relative, resolve } from "path";
|
|
5
|
+
import { mkdir, symlink, copyFile, readlink, lstat, readdir, stat, writeFile, unlink as fsUnlink } from "fs/promises";
|
|
6
|
+
import { existsSync, appendFileSync, chmodSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs";
|
|
7
|
+
import { execSync, execFileSync } from "child_process";
|
|
8
|
+
import { createSign } from "crypto";
|
|
9
|
+
import {
|
|
10
|
+
BOLD, GREEN, YELLOW, CYAN, RED, RESET,
|
|
11
|
+
bold, dim, green, yellow, cyan, red,
|
|
12
|
+
box, stepHeader, select, multiSelect, confirm, input, createProgressBar, spinner,
|
|
13
|
+
} from "./tui.mjs";
|
|
8
14
|
|
|
9
15
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
16
|
const __dirname = dirname(__filename);
|
|
11
17
|
const FORGE_HOME = dirname(__dirname);
|
|
12
18
|
const COMMANDS_DIR = join(FORGE_HOME, "commands");
|
|
19
|
+
|
|
20
|
+
if (!process.env.HOME) {
|
|
21
|
+
console.error(
|
|
22
|
+
"Error: HOME environment variable is not set. Cannot determine install location.",
|
|
23
|
+
);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
13
27
|
const TARGET_DIR = join(process.env.HOME, ".claude", "commands");
|
|
14
28
|
|
|
15
29
|
const args = process.argv.slice(2);
|
|
16
|
-
const command = args[0]
|
|
30
|
+
const command = args[0];
|
|
31
|
+
const forceYes = args.includes("--yes") || args.includes("-y");
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Version — read dynamically from package.json
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
function getVersion() {
|
|
38
|
+
try {
|
|
39
|
+
const pkg = JSON.parse(readFileSync(join(FORGE_HOME, "package.json"), "utf-8"));
|
|
40
|
+
return pkg.version || "0.0.0";
|
|
41
|
+
} catch {
|
|
42
|
+
return "0.0.0";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Install-mode marker — written to TARGET_DIR when copy-based install is used
|
|
48
|
+
// (Windows standard users cannot create symlinks without Developer Mode).
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** Path to the install-mode marker file inside TARGET_DIR. */
|
|
52
|
+
const INSTALL_MODE_MARKER = join(TARGET_DIR, ".forgedock-install-mode");
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read the install-mode marker, returning its parsed content or null if absent/invalid.
|
|
56
|
+
* @returns {{ version: string, mode: string } | null}
|
|
57
|
+
*/
|
|
58
|
+
function readInstallModeMarker() {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(readFileSync(INSTALL_MODE_MARKER, "utf-8"));
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Write (or overwrite) the install-mode marker with the current version.
|
|
68
|
+
* No-op on write failure — marker is best-effort.
|
|
69
|
+
*/
|
|
70
|
+
async function writeInstallModeMarker() {
|
|
71
|
+
try {
|
|
72
|
+
await writeFile(
|
|
73
|
+
INSTALL_MODE_MARKER,
|
|
74
|
+
JSON.stringify({ version: getVersion(), mode: "copy" }),
|
|
75
|
+
"utf-8",
|
|
76
|
+
);
|
|
77
|
+
} catch {
|
|
78
|
+
// Best-effort — do not block install on marker write failure
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Remove the install-mode marker if it exists.
|
|
84
|
+
* No-op on failure.
|
|
85
|
+
*/
|
|
86
|
+
async function removeInstallModeMarker() {
|
|
87
|
+
try {
|
|
88
|
+
await fsUnlink(INSTALL_MODE_MARKER);
|
|
89
|
+
} catch {
|
|
90
|
+
// Already absent — nothing to do
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Install state detection
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @typedef {'fresh-install' | 'up-to-date' | 'update-available' | 'config-missing' | 'version-unknown'} InstallState
|
|
100
|
+
*
|
|
101
|
+
* @typedef {Object} InstallDetectionResult
|
|
102
|
+
* @property {InstallState} state
|
|
103
|
+
* @property {string | null} installedVersion - Version read from existing symlink target, or null
|
|
104
|
+
* @property {string} currentVersion - Version from the running package.json
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Detect whether ForgeDock has been installed before and, if so, compare versions.
|
|
109
|
+
*
|
|
110
|
+
* Algorithm:
|
|
111
|
+
* 1. Check if TARGET_DIR exists and has at least one ForgeDock-managed symlink.
|
|
112
|
+
* 2. If symlinks exist, walk the symlink target to find the installed package.json
|
|
113
|
+
* and read its version.
|
|
114
|
+
* 3. Compare installed version vs. current (running) version.
|
|
115
|
+
* 4. If commands are installed but forge.yaml is absent → 'config-missing'.
|
|
116
|
+
*
|
|
117
|
+
* Always returns a safe result — never throws.
|
|
118
|
+
*
|
|
119
|
+
* @returns {Promise<InstallDetectionResult>}
|
|
120
|
+
*/
|
|
121
|
+
async function detectInstallState() {
|
|
122
|
+
const currentVersion = getVersion();
|
|
123
|
+
|
|
124
|
+
// Safely probe TARGET_DIR for ForgeDock-managed symlinks
|
|
125
|
+
let installedVersion = null;
|
|
126
|
+
let hasSymlinks = false;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const entries = await readdir(TARGET_DIR, { withFileTypes: true });
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
if (!entry.name.endsWith(".md") && !entry.isDirectory()) continue;
|
|
132
|
+
|
|
133
|
+
const targetPath = join(TARGET_DIR, entry.name);
|
|
134
|
+
try {
|
|
135
|
+
const stats = await lstat(targetPath);
|
|
136
|
+
if (stats.isSymbolicLink()) {
|
|
137
|
+
const linkTarget = await readlink(targetPath);
|
|
138
|
+
// Only count symlinks that point into a ForgeDock commands directory
|
|
139
|
+
if (linkTarget.includes("commands") && linkTarget.endsWith(".md")) {
|
|
140
|
+
hasSymlinks = true;
|
|
141
|
+
// Derive the FORGE_HOME of the installed copy:
|
|
142
|
+
// linkTarget: /path/to/forge-home/commands/work-on.md
|
|
143
|
+
// dirname twice → /path/to/forge-home
|
|
144
|
+
const installedForgeHome = dirname(dirname(linkTarget));
|
|
145
|
+
const pkgPath = join(installedForgeHome, "package.json");
|
|
146
|
+
try {
|
|
147
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
148
|
+
installedVersion = pkg.version || null;
|
|
149
|
+
} catch {
|
|
150
|
+
// package.json missing or malformed — treat as version unknown
|
|
151
|
+
}
|
|
152
|
+
break; // One symlink is enough to establish install status
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
// lstat/readlink failed — skip this entry
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
// TARGET_DIR doesn't exist — definitively a fresh install
|
|
161
|
+
return { state: "fresh-install", installedVersion: null, currentVersion };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!hasSymlinks) {
|
|
165
|
+
// Check for copy-mode install (Windows: symlinks not available)
|
|
166
|
+
const marker = readInstallModeMarker();
|
|
167
|
+
if (marker) {
|
|
168
|
+
installedVersion = marker.version ?? null;
|
|
169
|
+
} else {
|
|
170
|
+
return { state: "fresh-install", installedVersion: null, currentVersion };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Commands are installed — now determine sub-state
|
|
175
|
+
const forgeYamlPath = join(process.cwd(), "forge.yaml");
|
|
176
|
+
const hasConfig = existsSync(forgeYamlPath);
|
|
177
|
+
|
|
178
|
+
if (!hasConfig) {
|
|
179
|
+
return { state: "config-missing", installedVersion, currentVersion };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Commands and config are present — but version may be unreadable
|
|
183
|
+
if (installedVersion === null) {
|
|
184
|
+
// package.json was missing or malformed — cannot determine installed version.
|
|
185
|
+
// Return a distinct state so the caller can inform the user and offer a resolution
|
|
186
|
+
// path (reinstall), rather than silently claiming "up to date".
|
|
187
|
+
return { state: "version-unknown", installedVersion: null, currentVersion };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Both versions known — compare them
|
|
191
|
+
if (installedVersion !== currentVersion) {
|
|
192
|
+
return { state: "update-available", installedVersion, currentVersion };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { state: "up-to-date", installedVersion, currentVersion };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Splash screen — ASCII art logo in a Unicode box
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
const LOGO_LINES = [
|
|
203
|
+
`${bold(cyan(" ╔═╗╔═╗╦═╗╔═╗╔═╗╔╦╗╔═╗╔═╗╦╔═"))}`,
|
|
204
|
+
`${bold(cyan(" ╠╣ ║ ║╠╦╝║ ╦║╣ ║║║ ║║ ╠╩╗"))}`,
|
|
205
|
+
`${bold(cyan(" ╚ ╚═╝╩╚═╚═╝╚═╝═╩╝╚═╝╚═╝╩ ╩"))}`,
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
function splash() {
|
|
209
|
+
const version = getVersion();
|
|
210
|
+
const tagline = dim("GitHub as a knowledge graph for AI agents");
|
|
211
|
+
const versionLine = dim(`v${version}`);
|
|
212
|
+
|
|
213
|
+
const content = [
|
|
214
|
+
"",
|
|
215
|
+
...LOGO_LINES,
|
|
216
|
+
"",
|
|
217
|
+
` ${tagline}`,
|
|
218
|
+
` ${versionLine}`,
|
|
219
|
+
"",
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
process.stdout.write("\n" + box(content, { padding: 2 }) + "\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Step Orchestrator — manages a sequence of named steps
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @typedef {Object} Step
|
|
231
|
+
* @property {string} name - Display name for the step
|
|
232
|
+
* @property {() => Promise<void>} run - Async function to execute
|
|
233
|
+
* @property {boolean} [optional] - If true, failure offers skip option
|
|
234
|
+
*/
|
|
235
|
+
|
|
236
|
+
class StepOrchestrator {
|
|
237
|
+
/**
|
|
238
|
+
* @param {Step[]} steps
|
|
239
|
+
*/
|
|
240
|
+
constructor(steps) {
|
|
241
|
+
/** @type {Array<Step & { status: string }>} */
|
|
242
|
+
this.steps = steps.map((s) => ({ ...s, status: "pending" }));
|
|
243
|
+
this.currentIndex = 0;
|
|
244
|
+
this._aborted = false;
|
|
245
|
+
|
|
246
|
+
// SIGINT: clean exit — restore cursor, show final state
|
|
247
|
+
this._sigintHandler = () => {
|
|
248
|
+
this._aborted = true;
|
|
249
|
+
// Mark current step as failed if it was active
|
|
250
|
+
if (this.currentIndex < this.steps.length) {
|
|
251
|
+
this.steps[this.currentIndex].status = "failed";
|
|
252
|
+
}
|
|
253
|
+
this._renderSteps();
|
|
254
|
+
process.stdout.write("\n");
|
|
255
|
+
console.log(`${red("Aborted")} — exiting cleanly.`);
|
|
256
|
+
// Show cursor
|
|
257
|
+
if (process.stdout.isTTY) process.stdout.write("\x1b[?25h");
|
|
258
|
+
// Restore terminal to cooked mode in case a select/multiSelect prompt was active
|
|
259
|
+
if (process.stdin.isTTY) {
|
|
260
|
+
try { process.stdin.setRawMode(false); } catch { /* ignore */ }
|
|
261
|
+
}
|
|
262
|
+
process.exit(130);
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Render the current step list to stdout.
|
|
268
|
+
*/
|
|
269
|
+
_renderSteps() {
|
|
270
|
+
const total = this.steps.length;
|
|
271
|
+
console.log("");
|
|
272
|
+
for (let i = 0; i < total; i++) {
|
|
273
|
+
const step = this.steps[i];
|
|
274
|
+
console.log(" " + stepHeader(i + 1, total, step.name, step.status));
|
|
275
|
+
}
|
|
276
|
+
console.log("");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Execute all steps in sequence.
|
|
281
|
+
* @returns {Promise<boolean>} true if all steps completed (or were skipped), false if aborted
|
|
282
|
+
*/
|
|
283
|
+
async run() {
|
|
284
|
+
process.on("SIGINT", this._sigintHandler);
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
this._renderSteps();
|
|
288
|
+
|
|
289
|
+
while (this.currentIndex < this.steps.length) {
|
|
290
|
+
if (this._aborted) return false;
|
|
291
|
+
|
|
292
|
+
const step = this.steps[this.currentIndex];
|
|
293
|
+
step.status = "active";
|
|
294
|
+
this._renderSteps();
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
await step.run();
|
|
298
|
+
step.status = "done";
|
|
299
|
+
} catch (err) {
|
|
300
|
+
step.status = "failed";
|
|
301
|
+
this._renderSteps();
|
|
302
|
+
|
|
303
|
+
const handled = await this._handleFailure(step, err);
|
|
304
|
+
if (!handled) {
|
|
305
|
+
// Abort
|
|
306
|
+
this._aborted = true;
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
// Handled (retried successfully or skipped) — advance normally
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
this.currentIndex++;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// All done
|
|
316
|
+
this._renderSteps();
|
|
317
|
+
return true;
|
|
318
|
+
} finally {
|
|
319
|
+
process.removeListener("SIGINT", this._sigintHandler);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Handle a step failure: offer retry/skip/abort.
|
|
325
|
+
* @returns {Promise<boolean>} true if recovered (retry succeeded or skipped), false if abort
|
|
326
|
+
*/
|
|
327
|
+
async _handleFailure(step, err) {
|
|
328
|
+
console.log(` ${red("Error")}: ${err.message || String(err)}`);
|
|
329
|
+
console.log("");
|
|
330
|
+
|
|
331
|
+
// Non-TTY: no interactive recovery — abort
|
|
332
|
+
if (!process.stdin.isTTY) {
|
|
333
|
+
console.log(" Non-interactive environment — aborting.");
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const choices = [{ label: "Retry", value: "retry" }];
|
|
338
|
+
if (step.optional) {
|
|
339
|
+
choices.push({ label: "Skip this step", value: "skip" });
|
|
340
|
+
}
|
|
341
|
+
choices.push({ label: "Abort", value: "abort" });
|
|
342
|
+
|
|
343
|
+
const action = await select("What would you like to do?", choices);
|
|
344
|
+
|
|
345
|
+
if (action === "retry") {
|
|
346
|
+
// Re-run the step
|
|
347
|
+
step.status = "active";
|
|
348
|
+
this._renderSteps();
|
|
349
|
+
try {
|
|
350
|
+
await step.run();
|
|
351
|
+
step.status = "done";
|
|
352
|
+
return true;
|
|
353
|
+
} catch (retryErr) {
|
|
354
|
+
step.status = "failed";
|
|
355
|
+
return this._handleFailure(step, retryErr);
|
|
356
|
+
}
|
|
357
|
+
} else if (action === "skip") {
|
|
358
|
+
step.status = "skipped";
|
|
359
|
+
return true;
|
|
360
|
+
} else {
|
|
361
|
+
// abort
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Prerequisites (unchanged logic, extracted for reuse)
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
function checkPrerequisites() {
|
|
372
|
+
const issues = [];
|
|
373
|
+
const warnings = [];
|
|
374
|
+
|
|
375
|
+
// Check gh CLI
|
|
376
|
+
try {
|
|
377
|
+
execSync("gh --version", { stdio: ["pipe", "pipe", "pipe"] });
|
|
378
|
+
} catch {
|
|
379
|
+
issues.push(
|
|
380
|
+
`${RED}✗${RESET} GitHub CLI (gh) is not installed.\n` +
|
|
381
|
+
` Install: ${CYAN}https://cli.github.com${RESET}`
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Check gh auth (only if gh is installed)
|
|
386
|
+
if (issues.length === 0) {
|
|
387
|
+
try {
|
|
388
|
+
execSync("gh auth status", { stdio: ["pipe", "pipe", "pipe"] });
|
|
389
|
+
} catch {
|
|
390
|
+
issues.push(
|
|
391
|
+
`${RED}✗${RESET} GitHub CLI is not authenticated.\n` +
|
|
392
|
+
` Run: ${CYAN}gh auth login${RESET}`
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Check Claude Code
|
|
398
|
+
try {
|
|
399
|
+
execSync("claude --version", { stdio: ["pipe", "pipe", "pipe"] });
|
|
400
|
+
} catch {
|
|
401
|
+
warnings.push(
|
|
402
|
+
`${YELLOW}!${RESET} Claude Code CLI not found on PATH.\n` +
|
|
403
|
+
` Install: ${CYAN}https://docs.anthropic.com/en/docs/claude-code${RESET}`
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Check Node version
|
|
408
|
+
const nodeVersion = parseInt(process.versions.node.split(".")[0], 10);
|
|
409
|
+
if (nodeVersion < 18) {
|
|
410
|
+
issues.push(
|
|
411
|
+
`${RED}✗${RESET} Node.js >= 18 required (found ${process.versions.node}).\n` +
|
|
412
|
+
` Update: ${CYAN}https://nodejs.org${RESET}`
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (issues.length > 0 || warnings.length > 0) {
|
|
417
|
+
console.log(`${BOLD}Preflight checks${RESET}`);
|
|
418
|
+
console.log("");
|
|
419
|
+
for (const issue of issues) console.log(` ${issue}`);
|
|
420
|
+
for (const warning of warnings) console.log(` ${warning}`);
|
|
421
|
+
console.log("");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (issues.length > 0) {
|
|
425
|
+
console.log(
|
|
426
|
+
`${RED}${BOLD}Blocking issues found.${RESET} ForgeDock requires the GitHub CLI ` +
|
|
427
|
+
`with authentication to function.\nResolve the issues above, then re-run the command.\n`
|
|
428
|
+
);
|
|
429
|
+
process.exit(1);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Warnings are non-blocking — continue with install
|
|
433
|
+
return warnings.length === 0;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// Interactive prerequisite checklist (TUI flow)
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Run the interactive prerequisite checklist with live spinner rendering.
|
|
442
|
+
*
|
|
443
|
+
* Each check runs individually with a spinner that resolves to ✓, ✗, or ⚠.
|
|
444
|
+
* Blocking checks (gh CLI, gh auth, Node.js) throw on failure, stopping the
|
|
445
|
+
* StepOrchestrator step. Warning checks (git, Claude Code) emit ⚠ but do NOT throw.
|
|
446
|
+
*
|
|
447
|
+
* @returns {Promise<void>}
|
|
448
|
+
*/
|
|
449
|
+
async function runPrerequisiteChecklist() {
|
|
450
|
+
// ── 1. GitHub CLI ──────────────────────────────────────────────────────────
|
|
451
|
+
{
|
|
452
|
+
const s = spinner("Checking GitHub CLI…");
|
|
453
|
+
try {
|
|
454
|
+
const out = execSync("gh --version", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
|
|
455
|
+
const match = out.match(/gh version (\d+\.\d+\.\d+)/);
|
|
456
|
+
const version = match ? `v${match[1]}` : "installed";
|
|
457
|
+
s.stop("success", `${green("[✓]")} GitHub CLI ${dim(version)}`);
|
|
458
|
+
} catch {
|
|
459
|
+
s.stop("fail", `${red("[✗]")} GitHub CLI not found — install: ${cyan("https://cli.github.com")}`);
|
|
460
|
+
throw new Error("GitHub CLI (gh) is required. Install it from https://cli.github.com");
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ── 2. GitHub Auth ─────────────────────────────────────────────────────────
|
|
465
|
+
{
|
|
466
|
+
const s = spinner("Checking GitHub Auth…");
|
|
467
|
+
try {
|
|
468
|
+
// gh auth status writes to stderr; capture it via the error path too
|
|
469
|
+
let combined = "";
|
|
470
|
+
try {
|
|
471
|
+
combined = execSync("gh auth status", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
|
|
472
|
+
} catch (err) {
|
|
473
|
+
combined = (err.stderr || "") + (err.stdout || "");
|
|
474
|
+
if (!/Logged in/.test(combined)) {
|
|
475
|
+
throw err;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// Parse "Logged in to github.com account USERNAME (keyring|oauth)" from stderr
|
|
479
|
+
const userMatch = combined.match(/account\s+(\S+)/);
|
|
480
|
+
const username = userMatch ? `@${userMatch[1]}` : "authenticated";
|
|
481
|
+
// Try to get primary org from gh api
|
|
482
|
+
let orgLabel = "";
|
|
483
|
+
try {
|
|
484
|
+
const orgsJson = execSync("gh api /user/orgs --jq '.[0].login'", {
|
|
485
|
+
encoding: "utf-8",
|
|
486
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
487
|
+
}).trim();
|
|
488
|
+
if (orgsJson && orgsJson !== "null" && orgsJson !== "") {
|
|
489
|
+
orgLabel = ` (${orgsJson})`;
|
|
490
|
+
}
|
|
491
|
+
} catch {
|
|
492
|
+
// Org lookup is best-effort — not required
|
|
493
|
+
}
|
|
494
|
+
s.stop("success", `${green("[✓]")} GitHub Auth ${dim(`logged in as ${username}${orgLabel}`)}`);
|
|
495
|
+
} catch {
|
|
496
|
+
s.stop("fail", `${red("[✗]")} GitHub Auth not authenticated — run: ${cyan("gh auth login")}`);
|
|
497
|
+
throw new Error("GitHub CLI is not authenticated. Run: gh auth login");
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ── 3. Node.js ─────────────────────────────────────────────────────────────
|
|
502
|
+
{
|
|
503
|
+
const s = spinner("Checking Node.js…");
|
|
504
|
+
const nodeVersion = process.versions.node;
|
|
505
|
+
const nodeMajor = parseInt(nodeVersion.split(".")[0], 10);
|
|
506
|
+
if (nodeMajor < 18) {
|
|
507
|
+
s.stop("fail", `${red("[✗]")} Node.js v${nodeVersion} (>= 18 required) — update: ${cyan("https://nodejs.org")}`);
|
|
508
|
+
throw new Error(`Node.js >= 18 required (found v${nodeVersion}). Update at https://nodejs.org`);
|
|
509
|
+
}
|
|
510
|
+
s.stop("success", `${green("[✓]")} Node.js ${dim(`v${nodeVersion} (>= 18 required)`)}`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ── 4. Git ─────────────────────────────────────────────────────────────────
|
|
514
|
+
{
|
|
515
|
+
const s = spinner("Checking Git…");
|
|
516
|
+
try {
|
|
517
|
+
const out = execSync("git --version", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
|
|
518
|
+
const match = out.match(/git version (\d+\.\d+\.\d+)/);
|
|
519
|
+
const version = match ? `v${match[1]}` : "installed";
|
|
520
|
+
s.stop("success", `${green("[✓]")} Git ${dim(version)}`);
|
|
521
|
+
} catch {
|
|
522
|
+
s.stop("warn", `${yellow("[!]")} Git not found — install: ${cyan("https://git-scm.com")}`);
|
|
523
|
+
// Git is a warning — do NOT throw
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ── 5. Claude Code ─────────────────────────────────────────────────────────
|
|
528
|
+
{
|
|
529
|
+
const s = spinner("Checking Claude Code…");
|
|
530
|
+
try {
|
|
531
|
+
const out = execSync("claude --version", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
|
|
532
|
+
const match = out.trim().match(/(\d+\.\d+\.\d+)/);
|
|
533
|
+
const version = match ? `v${match[1]}` : "installed";
|
|
534
|
+
s.stop("success", `${green("[✓]")} Claude Code ${dim(version)}`);
|
|
535
|
+
} catch {
|
|
536
|
+
s.stop("warn", `${yellow("[!]")} Claude Code not found — install: ${cyan("https://docs.anthropic.com/en/docs/claude-code")}`);
|
|
537
|
+
// Claude Code is a warning — do NOT throw
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
17
541
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const CYAN = "\x1b[36m";
|
|
22
|
-
const RED = "\x1b[31m";
|
|
23
|
-
const RESET = "\x1b[0m";
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
// File discovery
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
24
545
|
|
|
25
546
|
async function findMarkdownFiles(dir) {
|
|
26
547
|
const results = [];
|
|
@@ -36,11 +557,75 @@ async function findMarkdownFiles(dir) {
|
|
|
36
557
|
return results.sort();
|
|
37
558
|
}
|
|
38
559
|
|
|
560
|
+
// ---------------------------------------------------------------------------
|
|
561
|
+
// Command category mapping
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Maps command name stems (filename without .md extension, no path prefix)
|
|
566
|
+
* to display categories. Sub-commands (e.g. work-on/build/architect) are
|
|
567
|
+
* looked up by their stem — unmapped names fall into "Other".
|
|
568
|
+
*/
|
|
569
|
+
const COMMAND_CATEGORIES = {
|
|
570
|
+
// Pipeline — core orchestration commands
|
|
571
|
+
"work-on": "Pipeline",
|
|
572
|
+
"investigate": "Pipeline",
|
|
573
|
+
"build": "Pipeline",
|
|
574
|
+
"architect": "Pipeline",
|
|
575
|
+
"context": "Pipeline",
|
|
576
|
+
"implement": "Pipeline",
|
|
577
|
+
"review": "Pipeline",
|
|
578
|
+
"decompose": "Pipeline",
|
|
579
|
+
"close": "Pipeline",
|
|
580
|
+
"review-pr": "Pipeline",
|
|
581
|
+
"review-pr-agents": "Pipeline",
|
|
582
|
+
"review-pr-staging": "Pipeline",
|
|
583
|
+
"orchestrate": "Pipeline",
|
|
584
|
+
"issue": "Pipeline",
|
|
585
|
+
"milestone": "Pipeline",
|
|
586
|
+
"quality-gate": "Pipeline",
|
|
587
|
+
"work-on-monolithic":"Pipeline",
|
|
588
|
+
|
|
589
|
+
// Operations — ongoing automation and monitoring
|
|
590
|
+
"autopilot": "Operations",
|
|
591
|
+
"analytics": "Operations",
|
|
592
|
+
"geo-audit": "Operations",
|
|
593
|
+
"security-audit": "Operations",
|
|
594
|
+
"pipeline-health": "Operations",
|
|
595
|
+
"audit": "Operations",
|
|
596
|
+
"audit-agents": "Operations",
|
|
597
|
+
"qa-sweep": "Operations",
|
|
598
|
+
"forge-stats": "Operations",
|
|
599
|
+
"sync-ecosystem": "Operations",
|
|
600
|
+
|
|
601
|
+
// Incident — response and recovery
|
|
602
|
+
"incident-response": "Incident",
|
|
603
|
+
"rollback": "Incident",
|
|
604
|
+
"deploy-info": "Incident",
|
|
605
|
+
"failure-recon": "Incident",
|
|
606
|
+
|
|
607
|
+
// Ecosystem — project management utilities
|
|
608
|
+
"validate": "Ecosystem",
|
|
609
|
+
"cleanup": "Ecosystem",
|
|
610
|
+
|
|
611
|
+
// Setup — initial configuration
|
|
612
|
+
"forgedock-init": "Setup",
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
/** Canonical display order for categories. */
|
|
616
|
+
const CATEGORY_ORDER = ["Pipeline", "Operations", "Incident", "Ecosystem", "Setup", "Other"];
|
|
617
|
+
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
// Legacy commands (install, uninstall, update, init, help)
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
|
|
39
622
|
async function install() {
|
|
623
|
+
checkPrerequisites();
|
|
624
|
+
|
|
40
625
|
console.log("");
|
|
41
626
|
console.log(`${BOLD}ForgeDock${RESET} — Installing pipeline commands`);
|
|
42
|
-
console.log(` Source: ${
|
|
43
|
-
console.log(` Target: ${
|
|
627
|
+
console.log(` Source: ${dim(COMMANDS_DIR + "/")}`);
|
|
628
|
+
console.log(` Target: ${dim(TARGET_DIR + "/")}`);
|
|
44
629
|
console.log("");
|
|
45
630
|
|
|
46
631
|
await mkdir(TARGET_DIR, { recursive: true });
|
|
@@ -49,6 +634,19 @@ async function install() {
|
|
|
49
634
|
let installed = 0;
|
|
50
635
|
let updated = 0;
|
|
51
636
|
let skipped = 0;
|
|
637
|
+
/** Number of files installed via copyFile fallback (Windows: symlinks unavailable). */
|
|
638
|
+
let copied = 0;
|
|
639
|
+
|
|
640
|
+
/** @type {Array<{rel: string, action: 'installed'|'updated'|'skipped'|'conflict'|'copied'}>} */
|
|
641
|
+
const results = [];
|
|
642
|
+
/** @type {string[]} Relative paths of files that could not be symlinked (regular file conflict) */
|
|
643
|
+
const conflicts = [];
|
|
644
|
+
|
|
645
|
+
// -------------------------------------------------------------------------
|
|
646
|
+
// Phase 1: Symlink loop with live progress bar
|
|
647
|
+
// -------------------------------------------------------------------------
|
|
648
|
+
|
|
649
|
+
const bar = createProgressBar(files.length, { label: " Installing commands..." });
|
|
52
650
|
|
|
53
651
|
for (const file of files) {
|
|
54
652
|
const rel = relative(COMMANDS_DIR, file);
|
|
@@ -64,38 +662,169 @@ async function install() {
|
|
|
64
662
|
const current = await readlink(target);
|
|
65
663
|
if (current === file) {
|
|
66
664
|
skipped++;
|
|
665
|
+
results.push({ rel, action: "skipped" });
|
|
666
|
+
bar.tick(1, dim(rel));
|
|
67
667
|
} else {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
668
|
+
try {
|
|
669
|
+
await symlink(file, target + ".tmp");
|
|
670
|
+
const { rename } = await import("fs/promises");
|
|
671
|
+
await rename(target + ".tmp", target);
|
|
672
|
+
updated++;
|
|
673
|
+
results.push({ rel, action: "updated" });
|
|
674
|
+
bar.tick(1, rel);
|
|
675
|
+
} catch (symlinkErr) {
|
|
676
|
+
if (symlinkErr.code === "EPERM" || symlinkErr.code === "ENOTSUP") {
|
|
677
|
+
// Symlink not permitted (Windows standard user) — fall back to copy
|
|
678
|
+
await copyFile(file, target);
|
|
679
|
+
copied++;
|
|
680
|
+
results.push({ rel, action: "copied" });
|
|
681
|
+
bar.tick(1, rel);
|
|
682
|
+
} else {
|
|
683
|
+
throw symlinkErr;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
73
686
|
}
|
|
74
687
|
} else {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
);
|
|
78
|
-
|
|
688
|
+
// Regular file present — could be a previous copy-mode install or a user conflict.
|
|
689
|
+
// Distinguish: if the install-mode marker exists, treat as ForgeDock-managed copy.
|
|
690
|
+
const isCopyModeInstall = readInstallModeMarker() !== null;
|
|
691
|
+
if (isCopyModeInstall) {
|
|
692
|
+
// Re-copy to update the file
|
|
693
|
+
await copyFile(file, target);
|
|
694
|
+
copied++;
|
|
695
|
+
results.push({ rel, action: "copied" });
|
|
696
|
+
bar.tick(1, rel);
|
|
697
|
+
} else {
|
|
698
|
+
// User-owned regular file is blocking — record as conflict
|
|
699
|
+
skipped++;
|
|
700
|
+
conflicts.push(rel);
|
|
701
|
+
results.push({ rel, action: "conflict" });
|
|
702
|
+
bar.tick(1, rel);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
} catch (err) {
|
|
706
|
+
if (err.code === "ENOENT") {
|
|
707
|
+
// Target doesn't exist — try symlink, fall back to copy on EPERM/ENOTSUP
|
|
708
|
+
try {
|
|
709
|
+
await symlink(file, target);
|
|
710
|
+
installed++;
|
|
711
|
+
results.push({ rel, action: "installed" });
|
|
712
|
+
bar.tick(1, rel);
|
|
713
|
+
} catch (symlinkErr) {
|
|
714
|
+
if (symlinkErr.code === "EPERM" || symlinkErr.code === "ENOTSUP") {
|
|
715
|
+
// Symlink not permitted (Windows standard user) — fall back to copy
|
|
716
|
+
await copyFile(file, target);
|
|
717
|
+
copied++;
|
|
718
|
+
results.push({ rel, action: "copied" });
|
|
719
|
+
bar.tick(1, rel);
|
|
720
|
+
} else {
|
|
721
|
+
throw symlinkErr;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
} else {
|
|
725
|
+
throw err;
|
|
79
726
|
}
|
|
80
|
-
} catch {
|
|
81
|
-
// Doesn't exist — create symlink
|
|
82
|
-
await symlink(file, target);
|
|
83
|
-
console.log(` ${GREEN}Installed${RESET}: ${rel}`);
|
|
84
|
-
installed++;
|
|
85
727
|
}
|
|
86
728
|
}
|
|
87
729
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
)
|
|
92
|
-
|
|
730
|
+
// If any files were installed via copy, write the install-mode marker so that
|
|
731
|
+
// subsequent runs (detectInstallState, getInstalledCommandNames, uninstall) know
|
|
732
|
+
// that regular .md files in TARGET_DIR are ForgeDock-managed.
|
|
733
|
+
if (copied > 0) {
|
|
734
|
+
await writeInstallModeMarker();
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// totalInstalled: fresh installs + copy-mode installs. Updated separately — mirrors
|
|
738
|
+
// pre-existing behaviour where only `installed > 0` (not updates) triggers this branch.
|
|
739
|
+
const totalInstalled = installed + copied;
|
|
740
|
+
const totalLabel =
|
|
741
|
+
totalInstalled > 0
|
|
742
|
+
? `${green("✔")} Installed ${totalInstalled + updated}/${files.length} commands`
|
|
743
|
+
: updated > 0
|
|
744
|
+
? `${green("✔")} Updated ${updated} command${updated === 1 ? "" : "s"}`
|
|
745
|
+
: `${dim("✔")} Commands up to date (${skipped} skipped)`;
|
|
746
|
+
bar.done(totalLabel);
|
|
93
747
|
|
|
94
|
-
//
|
|
95
|
-
|
|
748
|
+
// -------------------------------------------------------------------------
|
|
749
|
+
// Phase 2: Category grouping summary
|
|
750
|
+
// -------------------------------------------------------------------------
|
|
751
|
+
|
|
752
|
+
// Group installed/updated commands by category
|
|
753
|
+
/** @type {Map<string, string[]>} category → command stems */
|
|
754
|
+
const categoryMap = new Map(CATEGORY_ORDER.map((c) => [c, []]));
|
|
755
|
+
|
|
756
|
+
for (const { rel, action } of results) {
|
|
757
|
+
if (action === "skipped" || action === "conflict") continue;
|
|
758
|
+
const stem = rel.replace(/\.md$/, "").split("/").pop() ?? rel;
|
|
759
|
+
// 'copied' actions are treated the same as 'installed' for category display
|
|
760
|
+
const category = COMMAND_CATEGORIES[stem] ?? "Other";
|
|
761
|
+
const bucket = categoryMap.get(category) ?? categoryMap.get("Other") ?? [];
|
|
762
|
+
if (!categoryMap.has(category)) categoryMap.set(category, bucket);
|
|
763
|
+
bucket.push(stem);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Build category lines — only show categories with at least one command
|
|
767
|
+
const categoryLines = [];
|
|
768
|
+
for (const cat of CATEGORY_ORDER) {
|
|
769
|
+
const cmds = categoryMap.get(cat) ?? [];
|
|
770
|
+
if (cmds.length === 0) continue;
|
|
771
|
+
const label = cat.padEnd(12);
|
|
772
|
+
const cmdList = cmds.join(", ");
|
|
773
|
+
const count = `(${cmds.length})`;
|
|
774
|
+
categoryLines.push(` ${bold(cyan(label))} ${cmdList} ${dim(count)}`);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (categoryLines.length > 0) {
|
|
778
|
+
console.log("");
|
|
779
|
+
process.stdout.write(box(["", ...categoryLines, ""], { title: "Commands installed" }));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// -------------------------------------------------------------------------
|
|
783
|
+
// Phase 3: Summary box — total counts
|
|
784
|
+
// -------------------------------------------------------------------------
|
|
785
|
+
|
|
786
|
+
const summaryLines = [
|
|
787
|
+
"",
|
|
788
|
+
` ${green("Installed")} ${bold(String(installed))}`,
|
|
789
|
+
` ${yellow("Updated")} ${bold(String(updated))}`,
|
|
790
|
+
` ${dim("Skipped")} ${dim(String(skipped - conflicts.length))}`,
|
|
791
|
+
"",
|
|
792
|
+
];
|
|
793
|
+
if (copied > 0) {
|
|
794
|
+
summaryLines.splice(summaryLines.length - 1, 0,
|
|
795
|
+
` ${cyan("Copied")} ${bold(String(copied))} ${dim("(symlinks unavailable — files copied instead)")}`
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
process.stdout.write(box(summaryLines, { title: "Summary" }));
|
|
799
|
+
|
|
800
|
+
// -------------------------------------------------------------------------
|
|
801
|
+
// Phase 4: Conflict highlighting (prominent red block)
|
|
802
|
+
// -------------------------------------------------------------------------
|
|
803
|
+
|
|
804
|
+
if (conflicts.length > 0) {
|
|
805
|
+
console.log("");
|
|
806
|
+
const conflictLines = [
|
|
807
|
+
"",
|
|
808
|
+
` ${bold(red("⚠ Regular files blocking symlink creation:"))}`,
|
|
809
|
+
"",
|
|
810
|
+
...conflicts.map((c) => ` ${red("✖")} ${c}`),
|
|
811
|
+
"",
|
|
812
|
+
` ${dim("To let ForgeDock manage these, remove or rename each file:")}`,
|
|
813
|
+
...conflicts.map((c) => ` ${dim(`rm ~/.claude/commands/${c}`)}`),
|
|
814
|
+
"",
|
|
815
|
+
];
|
|
816
|
+
process.stdout.write(box(conflictLines, { title: `${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"}` }));
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// -------------------------------------------------------------------------
|
|
820
|
+
// Phase 5: FORGE_HOME setup — distinct status line
|
|
821
|
+
// -------------------------------------------------------------------------
|
|
822
|
+
|
|
823
|
+
console.log("");
|
|
824
|
+
let forgeHomeSet = false;
|
|
96
825
|
for (const profile of [
|
|
97
|
-
join(process.env.HOME, ".bashrc"),
|
|
98
|
-
join(process.env.HOME, ".zshrc"),
|
|
826
|
+
join(process.env.HOME ?? "", ".bashrc"),
|
|
827
|
+
join(process.env.HOME ?? "", ".zshrc"),
|
|
99
828
|
]) {
|
|
100
829
|
if (existsSync(profile)) {
|
|
101
830
|
const content = readFileSync(profile, "utf-8");
|
|
@@ -104,97 +833,2051 @@ async function install() {
|
|
|
104
833
|
profile,
|
|
105
834
|
`\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${FORGE_HOME}"\n`
|
|
106
835
|
);
|
|
107
|
-
|
|
108
|
-
|
|
836
|
+
const profileShort = profile.replace(process.env.HOME ?? "", "~");
|
|
837
|
+
console.log(` ${green("✔")} ${bold("FORGE_HOME")} set in ${cyan(profileShort)}`);
|
|
838
|
+
forgeHomeSet = true;
|
|
109
839
|
}
|
|
110
840
|
}
|
|
111
841
|
}
|
|
112
842
|
|
|
843
|
+
if (!forgeHomeSet) {
|
|
844
|
+
console.log(` ${dim("✔")} ${dim("FORGE_HOME already set in shell profile")}`);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
console.log("");
|
|
113
848
|
console.log(
|
|
114
|
-
`${
|
|
849
|
+
`${green("ForgeDock commands are now available as slash commands in any Claude Code session.")}`
|
|
115
850
|
);
|
|
116
851
|
console.log("");
|
|
852
|
+
|
|
853
|
+
// forge.yaml advisory — guide users to run init if config is missing
|
|
854
|
+
const forgeYamlPath = join(process.cwd(), "forge.yaml");
|
|
855
|
+
if (!existsSync(forgeYamlPath)) {
|
|
856
|
+
console.log(`${yellow("No forge.yaml found in current directory.")}`);
|
|
857
|
+
console.log(
|
|
858
|
+
` Run ${cyan("npx forgedock init")} in your project root to generate forge.yaml`
|
|
859
|
+
);
|
|
860
|
+
console.log("");
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
return { installed, updated, skipped, copied };
|
|
117
864
|
}
|
|
118
865
|
|
|
119
866
|
async function uninstall() {
|
|
867
|
+
const { unlink } = await import("fs/promises");
|
|
868
|
+
|
|
120
869
|
console.log("");
|
|
121
|
-
console.log(`${BOLD}ForgeDock${RESET} —
|
|
870
|
+
console.log(`${BOLD}ForgeDock${RESET} — Uninstall`);
|
|
122
871
|
console.log("");
|
|
123
872
|
|
|
873
|
+
// -------------------------------------------------------------------------
|
|
874
|
+
// Phase 1: Dry-run scan — compute what would be removed
|
|
875
|
+
// -------------------------------------------------------------------------
|
|
876
|
+
|
|
124
877
|
const files = await findMarkdownFiles(COMMANDS_DIR);
|
|
125
|
-
|
|
878
|
+
/** @type {Array<{file: string, rel: string, target: string}>} */
|
|
879
|
+
const toRemove = [];
|
|
880
|
+
const copyMode = readInstallModeMarker() !== null;
|
|
126
881
|
|
|
127
882
|
for (const file of files) {
|
|
128
883
|
const rel = relative(COMMANDS_DIR, file);
|
|
129
884
|
const target = join(TARGET_DIR, rel);
|
|
130
|
-
|
|
131
885
|
try {
|
|
132
886
|
const stats = await lstat(target);
|
|
133
887
|
if (stats.isSymbolicLink()) {
|
|
134
888
|
const current = await readlink(target);
|
|
135
889
|
if (current === file) {
|
|
136
|
-
|
|
137
|
-
await unlink(target);
|
|
138
|
-
console.log(` ${RED}Removed${RESET}: ${rel}`);
|
|
139
|
-
removed++;
|
|
890
|
+
toRemove.push({ file, rel, target });
|
|
140
891
|
}
|
|
892
|
+
} else if (copyMode && rel.endsWith(".md")) {
|
|
893
|
+
// Copy-mode install — regular files managed by ForgeDock
|
|
894
|
+
toRemove.push({ file, rel, target });
|
|
141
895
|
}
|
|
142
896
|
} catch {
|
|
143
|
-
//
|
|
897
|
+
// Target doesn't exist — nothing to remove
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Scan shell profiles for FORGE_HOME block
|
|
902
|
+
const FORGE_HOME_MARKER = "# ForgeDock — autonomous development pipeline";
|
|
903
|
+
const shellProfiles = [
|
|
904
|
+
join(process.env.HOME ?? "", ".bashrc"),
|
|
905
|
+
join(process.env.HOME ?? "", ".zshrc"),
|
|
906
|
+
];
|
|
907
|
+
/** @type {string[]} Profiles that contain the FORGE_HOME block */
|
|
908
|
+
const profilesWithForgeHome = shellProfiles.filter((p) => {
|
|
909
|
+
if (!existsSync(p)) return false;
|
|
910
|
+
try {
|
|
911
|
+
return readFileSync(p, "utf-8").includes(FORGE_HOME_MARKER);
|
|
912
|
+
} catch {
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
// Check for forge.yaml in cwd
|
|
918
|
+
const forgeYamlPath = join(process.cwd(), "forge.yaml");
|
|
919
|
+
const hasForgeYaml = existsSync(forgeYamlPath);
|
|
920
|
+
|
|
921
|
+
// -------------------------------------------------------------------------
|
|
922
|
+
// Phase 2: Pre-removal summary
|
|
923
|
+
// -------------------------------------------------------------------------
|
|
924
|
+
|
|
925
|
+
if (toRemove.length === 0 && profilesWithForgeHome.length === 0 && !hasForgeYaml) {
|
|
926
|
+
console.log(` ${dim("Nothing to remove — ForgeDock does not appear to be installed.")}`);
|
|
927
|
+
console.log("");
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const summaryLines = [""];
|
|
932
|
+
if (toRemove.length > 0) {
|
|
933
|
+
const fileLabel = copyMode ? "files (copy-mode install)" : "symlinks";
|
|
934
|
+
summaryLines.push(` ${red("Commands")}: ${bold(String(toRemove.length))} ${fileLabel} in ${dim(TARGET_DIR)}`);
|
|
935
|
+
} else {
|
|
936
|
+
summaryLines.push(` ${dim("Commands:")} none found`);
|
|
937
|
+
}
|
|
938
|
+
if (profilesWithForgeHome.length > 0) {
|
|
939
|
+
summaryLines.push(
|
|
940
|
+
` ${yellow("Profiles")}: FORGE_HOME export in ${bold(profilesWithForgeHome.map((p) => p.replace(process.env.HOME ?? "", "~")).join(", "))}`
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
if (hasForgeYaml) {
|
|
944
|
+
summaryLines.push(` ${cyan("forge.yaml")}: present in current directory`);
|
|
945
|
+
}
|
|
946
|
+
summaryLines.push("");
|
|
947
|
+
|
|
948
|
+
process.stdout.write(box(summaryLines, { title: "What will be removed" }));
|
|
949
|
+
console.log("");
|
|
950
|
+
|
|
951
|
+
// -------------------------------------------------------------------------
|
|
952
|
+
// Phase 3: Main confirmation — default N (safe)
|
|
953
|
+
// -------------------------------------------------------------------------
|
|
954
|
+
|
|
955
|
+
const commandLabel =
|
|
956
|
+
toRemove.length === 1 ? "1 command" : `${toRemove.length} commands`;
|
|
957
|
+
const profileLabel =
|
|
958
|
+
profilesWithForgeHome.length > 0
|
|
959
|
+
? ` and FORGE_HOME from ${profilesWithForgeHome.length === 1 ? "shell profile" : "shell profiles"}`
|
|
960
|
+
: "";
|
|
961
|
+
|
|
962
|
+
let confirmed = forceYes;
|
|
963
|
+
if (!confirmed) {
|
|
964
|
+
confirmed = await confirm(
|
|
965
|
+
`Remove ${commandLabel}${profileLabel}?`,
|
|
966
|
+
false
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
if (!confirmed) {
|
|
971
|
+
console.log("");
|
|
972
|
+
console.log(` ${dim("Aborted — nothing was removed.")}`);
|
|
973
|
+
console.log("");
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// -------------------------------------------------------------------------
|
|
978
|
+
// Phase 4: Remove symlinks with progress bar
|
|
979
|
+
// -------------------------------------------------------------------------
|
|
980
|
+
|
|
981
|
+
if (toRemove.length > 0) {
|
|
982
|
+
console.log("");
|
|
983
|
+
const bar = createProgressBar(toRemove.length, { label: " Removing commands" });
|
|
984
|
+
let removed = 0;
|
|
985
|
+
|
|
986
|
+
for (const { target, rel } of toRemove) {
|
|
987
|
+
try {
|
|
988
|
+
await unlink(target);
|
|
989
|
+
removed++;
|
|
990
|
+
bar.tick(1, rel);
|
|
991
|
+
} catch (err) {
|
|
992
|
+
bar.tick(1, `${red("failed:")} ${rel}`);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
bar.done(`${green("✔")} Removed ${removed}/${toRemove.length} commands`);
|
|
997
|
+
|
|
998
|
+
// Clean up install-mode marker if copy-mode install was active
|
|
999
|
+
if (copyMode) {
|
|
1000
|
+
await removeInstallModeMarker();
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// -------------------------------------------------------------------------
|
|
1005
|
+
// Phase 5: FORGE_HOME cleanup (only if detected)
|
|
1006
|
+
// -------------------------------------------------------------------------
|
|
1007
|
+
|
|
1008
|
+
if (profilesWithForgeHome.length > 0) {
|
|
1009
|
+
console.log("");
|
|
1010
|
+
const cleanProfiles = forceYes
|
|
1011
|
+
? true
|
|
1012
|
+
: await confirm("Remove FORGE_HOME export from shell profiles?", true);
|
|
1013
|
+
|
|
1014
|
+
if (cleanProfiles) {
|
|
1015
|
+
for (const profile of profilesWithForgeHome) {
|
|
1016
|
+
const tmpPath = profile + ".forgedock.tmp";
|
|
1017
|
+
try {
|
|
1018
|
+
const content = readFileSync(profile, "utf-8");
|
|
1019
|
+
// Remove the two-line block written by install():
|
|
1020
|
+
// \n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="..."\n
|
|
1021
|
+
// Also handle the case where it's at the very start of the file (no leading \n)
|
|
1022
|
+
const cleaned = content
|
|
1023
|
+
.replace(
|
|
1024
|
+
/\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME=[^\n]*\n/g,
|
|
1025
|
+
"\n"
|
|
1026
|
+
)
|
|
1027
|
+
.replace(
|
|
1028
|
+
/^# ForgeDock — autonomous development pipeline\nexport FORGE_HOME=[^\n]*\n/,
|
|
1029
|
+
""
|
|
1030
|
+
);
|
|
1031
|
+
// Write to a temp file first, then atomically rename into place.
|
|
1032
|
+
// This prevents profile corruption if the process is killed mid-write.
|
|
1033
|
+
writeFileSync(tmpPath, cleaned, "utf-8");
|
|
1034
|
+
renameSync(tmpPath, profile);
|
|
1035
|
+
const profileShort = profile.replace(process.env.HOME ?? "", "~");
|
|
1036
|
+
console.log(` ${green("✔")} Removed FORGE_HOME from ${profileShort}`);
|
|
1037
|
+
} catch (err) {
|
|
1038
|
+
// Clean up temp file if it was created before the error
|
|
1039
|
+
try { unlinkSync(tmpPath); } catch { /* already gone or never created */ }
|
|
1040
|
+
const profileShort = profile.replace(process.env.HOME ?? "", "~");
|
|
1041
|
+
console.log(` ${red("✖")} Could not update ${profileShort}: ${err.message}`);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
} else {
|
|
1045
|
+
console.log(` ${dim("Skipped — FORGE_HOME left in shell profiles.")}`);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// -------------------------------------------------------------------------
|
|
1050
|
+
// Phase 6: forge.yaml handling (default: keep)
|
|
1051
|
+
// -------------------------------------------------------------------------
|
|
1052
|
+
|
|
1053
|
+
if (hasForgeYaml) {
|
|
1054
|
+
console.log("");
|
|
1055
|
+
const deleteForgeYaml = forceYes
|
|
1056
|
+
? false // --yes flag: keep forge.yaml by default (safe)
|
|
1057
|
+
: await confirm("Delete forge.yaml from this project?", false);
|
|
1058
|
+
|
|
1059
|
+
if (deleteForgeYaml) {
|
|
1060
|
+
try {
|
|
1061
|
+
await unlink(forgeYamlPath);
|
|
1062
|
+
console.log(` ${green("✔")} Deleted forge.yaml`);
|
|
1063
|
+
} catch (err) {
|
|
1064
|
+
console.log(` ${red("✖")} Could not delete forge.yaml: ${err.message}`);
|
|
1065
|
+
}
|
|
1066
|
+
} else {
|
|
1067
|
+
console.log(` ${dim("forge.yaml kept.")}`);
|
|
144
1068
|
}
|
|
145
1069
|
}
|
|
146
1070
|
|
|
1071
|
+
// -------------------------------------------------------------------------
|
|
1072
|
+
// Phase 7: Post-removal summary
|
|
1073
|
+
// -------------------------------------------------------------------------
|
|
1074
|
+
|
|
147
1075
|
console.log("");
|
|
148
|
-
console.log(
|
|
1076
|
+
console.log(`${green("Uninstall complete.")} ForgeDock commands have been removed.`);
|
|
1077
|
+
if (profilesWithForgeHome.length > 0) {
|
|
1078
|
+
console.log(` ${dim("Restart your shell or run")} ${cyan("source ~/.bashrc")} ${dim("(or")} ${cyan("~/.zshrc")}${dim(")")} ${dim("to apply profile changes.")}`);
|
|
1079
|
+
}
|
|
149
1080
|
console.log("");
|
|
150
1081
|
}
|
|
151
1082
|
|
|
1083
|
+
// ---------------------------------------------------------------------------
|
|
1084
|
+
// Update helpers
|
|
1085
|
+
// ---------------------------------------------------------------------------
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Query the npm registry for the latest published version of a package.
|
|
1089
|
+
* Returns null on any failure (offline, timeout, package not found).
|
|
1090
|
+
*
|
|
1091
|
+
* @param {string} pkg - npm package name (e.g. "forgedock")
|
|
1092
|
+
* @returns {string | null}
|
|
1093
|
+
*/
|
|
1094
|
+
function queryNpmRegistry(pkg) {
|
|
1095
|
+
try {
|
|
1096
|
+
const result = execFileSync("npm", ["view", pkg, "version"], {
|
|
1097
|
+
encoding: "utf-8",
|
|
1098
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1099
|
+
timeout: 5000,
|
|
1100
|
+
});
|
|
1101
|
+
return result.trim() || null;
|
|
1102
|
+
} catch {
|
|
1103
|
+
return null;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* Return a Set of relative paths for all ForgeDock-managed symlinks in TARGET_DIR.
|
|
1109
|
+
* Recursively walks subdirectories so commands installed under work-on/, work-on/build/,
|
|
1110
|
+
* etc. are included. Returns an empty Set if TARGET_DIR does not exist or cannot be read.
|
|
1111
|
+
*
|
|
1112
|
+
* @returns {Promise<Set<string>>}
|
|
1113
|
+
*/
|
|
1114
|
+
async function getInstalledCommandNames() {
|
|
1115
|
+
const names = new Set();
|
|
1116
|
+
const copyMode = readInstallModeMarker() !== null;
|
|
1117
|
+
|
|
1118
|
+
async function walk(dir) {
|
|
1119
|
+
let entries;
|
|
1120
|
+
try {
|
|
1121
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
1122
|
+
} catch {
|
|
1123
|
+
return; // unreadable directory — skip
|
|
1124
|
+
}
|
|
1125
|
+
for (const entry of entries) {
|
|
1126
|
+
const fullPath = join(dir, entry.name);
|
|
1127
|
+
if (entry.isDirectory()) {
|
|
1128
|
+
await walk(fullPath);
|
|
1129
|
+
} else {
|
|
1130
|
+
try {
|
|
1131
|
+
const stats = await lstat(fullPath);
|
|
1132
|
+
if (stats.isSymbolicLink()) {
|
|
1133
|
+
const linkTarget = await readlink(fullPath);
|
|
1134
|
+
if (linkTarget.includes("commands") && linkTarget.endsWith(".md")) {
|
|
1135
|
+
names.add(relative(TARGET_DIR, fullPath));
|
|
1136
|
+
}
|
|
1137
|
+
} else if (copyMode && entry.name.endsWith(".md")) {
|
|
1138
|
+
// Copy-mode install: regular .md files in TARGET_DIR are ForgeDock-managed
|
|
1139
|
+
names.add(relative(TARGET_DIR, fullPath));
|
|
1140
|
+
}
|
|
1141
|
+
} catch {
|
|
1142
|
+
// Skip unreadable entries
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
await walk(TARGET_DIR);
|
|
1149
|
+
return names;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Compute the added and removed command names between two Sets.
|
|
1154
|
+
* Returns formatted display lines, or an empty array if no diff.
|
|
1155
|
+
*
|
|
1156
|
+
* @param {Set<string>} before
|
|
1157
|
+
* @param {Set<string>} after
|
|
1158
|
+
* @returns {string[]}
|
|
1159
|
+
*/
|
|
1160
|
+
function formatCommandDiff(before, after) {
|
|
1161
|
+
const added = [...after].filter((n) => !before.has(n)).map((n) => n.replace(/\.md$/, ""));
|
|
1162
|
+
const removed = [...before].filter((n) => !after.has(n)).map((n) => n.replace(/\.md$/, ""));
|
|
1163
|
+
|
|
1164
|
+
if (added.length === 0 && removed.length === 0) return [];
|
|
1165
|
+
|
|
1166
|
+
const lines = [""];
|
|
1167
|
+
if (added.length > 0) {
|
|
1168
|
+
lines.push(` ${green("Added")} ${added.join(", ")}`);
|
|
1169
|
+
}
|
|
1170
|
+
if (removed.length > 0) {
|
|
1171
|
+
lines.push(` ${red("Removed")} ${removed.join(", ")}`);
|
|
1172
|
+
}
|
|
1173
|
+
lines.push("");
|
|
1174
|
+
return lines;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
152
1177
|
async function update() {
|
|
1178
|
+
const currentVersion = getVersion();
|
|
1179
|
+
|
|
153
1180
|
console.log("");
|
|
154
1181
|
console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
|
|
1182
|
+
console.log(` ${dim(`Current version: v${currentVersion}`)}`);
|
|
155
1183
|
console.log("");
|
|
156
1184
|
|
|
157
|
-
//
|
|
1185
|
+
// Determine install type: git clone (has .git) or npm global install
|
|
158
1186
|
const gitDir = join(FORGE_HOME, ".git");
|
|
159
1187
|
if (existsSync(gitDir)) {
|
|
1188
|
+
// -----------------------------------------------------------------------
|
|
1189
|
+
// Git install path
|
|
1190
|
+
// -----------------------------------------------------------------------
|
|
1191
|
+
// Snapshot installed commands before the update so we can show a diff after
|
|
1192
|
+
const commandsBefore = await getInstalledCommandNames();
|
|
160
1193
|
try {
|
|
161
1194
|
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
162
1195
|
cwd: FORGE_HOME,
|
|
163
1196
|
encoding: "utf-8",
|
|
1197
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1198
|
+
timeout: 5000,
|
|
164
1199
|
}).trim();
|
|
1200
|
+
|
|
165
1201
|
if (branch !== "main") {
|
|
166
|
-
console.log(` Not on main branch (${branch}) — skipping
|
|
1202
|
+
console.log(` ${yellow("Not on main branch")} (${branch}) — skipping automatic update.`);
|
|
1203
|
+
console.log(` Switch to ${cyan("main")} and re-run to update.`);
|
|
1204
|
+
console.log("");
|
|
167
1205
|
return;
|
|
168
1206
|
}
|
|
169
1207
|
|
|
170
1208
|
const before = execSync("git rev-parse HEAD", {
|
|
171
1209
|
cwd: FORGE_HOME,
|
|
172
1210
|
encoding: "utf-8",
|
|
1211
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1212
|
+
timeout: 5000,
|
|
173
1213
|
}).trim();
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
1214
|
+
|
|
1215
|
+
// Fetch — graceful offline fallback
|
|
1216
|
+
try {
|
|
1217
|
+
execSync("git fetch origin main --quiet", {
|
|
1218
|
+
cwd: FORGE_HOME,
|
|
1219
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1220
|
+
timeout: 10000,
|
|
1221
|
+
});
|
|
1222
|
+
} catch {
|
|
1223
|
+
console.log(` ${yellow("Offline or unreachable.")} Could not fetch from origin.`);
|
|
1224
|
+
console.log(` ${dim("Skipping update check — re-run when connected.")}`);
|
|
1225
|
+
console.log("");
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const remoteHead = execSync("git rev-parse origin/main", {
|
|
1230
|
+
cwd: FORGE_HOME,
|
|
1231
|
+
encoding: "utf-8",
|
|
1232
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1233
|
+
timeout: 5000,
|
|
1234
|
+
}).trim();
|
|
1235
|
+
|
|
1236
|
+
if (before === remoteHead) {
|
|
1237
|
+
// Already up to date
|
|
1238
|
+
const lines = [
|
|
1239
|
+
"",
|
|
1240
|
+
` ${green("✔")} ForgeDock v${currentVersion} — already up to date`,
|
|
1241
|
+
"",
|
|
1242
|
+
];
|
|
1243
|
+
process.stdout.write(box(lines, { title: "Up to date" }));
|
|
1244
|
+
console.log("");
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// Show changelog between current HEAD and remote HEAD
|
|
1249
|
+
let changelogLines = [];
|
|
1250
|
+
try {
|
|
1251
|
+
const log = execSync(`git log --oneline ${before}..origin/main`, {
|
|
1252
|
+
cwd: FORGE_HOME,
|
|
1253
|
+
encoding: "utf-8",
|
|
1254
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1255
|
+
timeout: 5000,
|
|
1256
|
+
}).trim();
|
|
1257
|
+
if (log) {
|
|
1258
|
+
changelogLines = log.split("\n").map((l) => ` ${dim(l)}`);
|
|
1259
|
+
}
|
|
1260
|
+
} catch {
|
|
1261
|
+
// Non-blocking — skip changelog if log fails
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
if (changelogLines.length > 0) {
|
|
1265
|
+
const boxLines = ["", ...changelogLines, ""];
|
|
1266
|
+
process.stdout.write(box(boxLines, { title: "What's new" }));
|
|
1267
|
+
console.log("");
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Apply update
|
|
1271
|
+
try {
|
|
1272
|
+
execSync("git merge --ff-only origin/main --quiet", {
|
|
1273
|
+
cwd: FORGE_HOME,
|
|
1274
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1275
|
+
timeout: 15000,
|
|
1276
|
+
});
|
|
1277
|
+
} catch {
|
|
1278
|
+
console.log(
|
|
1279
|
+
` ${yellow("Cannot fast-forward")} — local changes exist. Skipping merge.`
|
|
1280
|
+
);
|
|
1281
|
+
console.log(` ${dim("Stash or discard local changes and re-run.")}`);
|
|
1282
|
+
console.log("");
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
178
1286
|
const after = execSync("git rev-parse HEAD", {
|
|
179
1287
|
cwd: FORGE_HOME,
|
|
180
1288
|
encoding: "utf-8",
|
|
1289
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1290
|
+
timeout: 5000,
|
|
181
1291
|
}).trim();
|
|
182
1292
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
1293
|
+
const newVersion = getVersion();
|
|
1294
|
+
console.log(
|
|
1295
|
+
` ${green("Updated")} v${currentVersion} → v${newVersion}`
|
|
1296
|
+
);
|
|
1297
|
+
console.log("");
|
|
1298
|
+
|
|
1299
|
+
// Re-run symlink installer to pick up new/removed commands
|
|
1300
|
+
await install();
|
|
1301
|
+
|
|
1302
|
+
// Show command diff
|
|
1303
|
+
const commandsAfter = await getInstalledCommandNames();
|
|
1304
|
+
const diffLines = formatCommandDiff(commandsBefore, commandsAfter);
|
|
1305
|
+
if (diffLines.length > 0) {
|
|
1306
|
+
process.stdout.write(box(diffLines, { title: "Command changes" }));
|
|
1307
|
+
console.log("");
|
|
188
1308
|
}
|
|
189
1309
|
} catch (err) {
|
|
190
1310
|
console.log(
|
|
191
|
-
` ${
|
|
1311
|
+
` ${RED}Update failed.${RESET} ${err instanceof Error ? err.message : String(err)}`
|
|
192
1312
|
);
|
|
1313
|
+
console.log("");
|
|
193
1314
|
}
|
|
194
1315
|
} else {
|
|
195
|
-
|
|
1316
|
+
// -----------------------------------------------------------------------
|
|
1317
|
+
// npm install path
|
|
1318
|
+
// -----------------------------------------------------------------------
|
|
1319
|
+
console.log(` ${dim("Installed via npm.")}`);
|
|
1320
|
+
console.log("");
|
|
1321
|
+
|
|
1322
|
+
// Query registry for latest version — graceful offline fallback
|
|
1323
|
+
// Only use \r line-clearing trick in TTY environments; in non-TTY just print a static line
|
|
1324
|
+
if (process.stdout.isTTY) {
|
|
1325
|
+
process.stdout.write(` Checking npm registry...`);
|
|
1326
|
+
} else {
|
|
1327
|
+
console.log(` Checking npm registry...`);
|
|
1328
|
+
}
|
|
1329
|
+
const latestVersion = queryNpmRegistry("forgedock");
|
|
1330
|
+
|
|
1331
|
+
if (latestVersion === null) {
|
|
1332
|
+
if (process.stdout.isTTY) {
|
|
1333
|
+
process.stdout.write(`\r ${yellow("Offline or registry unreachable.")} Could not check for updates.\n`);
|
|
1334
|
+
} else {
|
|
1335
|
+
console.log(` Offline or registry unreachable. Could not check for updates.`);
|
|
1336
|
+
}
|
|
1337
|
+
console.log(` ${dim("Re-run when connected, or check: ")}${cyan("https://www.npmjs.com/package/forgedock")}`);
|
|
1338
|
+
console.log("");
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// Clear the "Checking..." line (TTY only — non-TTY already printed a newline above)
|
|
1343
|
+
if (process.stdout.isTTY) {
|
|
1344
|
+
process.stdout.write(`\r${" ".repeat(50)}\r`);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
if (currentVersion === latestVersion) {
|
|
1348
|
+
const lines = [
|
|
1349
|
+
"",
|
|
1350
|
+
` ${green("✔")} ForgeDock v${currentVersion} — already up to date`,
|
|
1351
|
+
"",
|
|
1352
|
+
];
|
|
1353
|
+
process.stdout.write(box(lines, { title: "Up to date" }));
|
|
1354
|
+
console.log("");
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// Update available
|
|
1359
|
+
const lines = [
|
|
1360
|
+
"",
|
|
1361
|
+
` ${yellow("Update available:")} v${currentVersion} → ${green(`v${latestVersion}`)}`,
|
|
1362
|
+
"",
|
|
1363
|
+
` Run the following command to update:`,
|
|
1364
|
+
"",
|
|
1365
|
+
` ${cyan(`npm update -g forgedock`)}`,
|
|
1366
|
+
"",
|
|
1367
|
+
` Then re-run ${cyan("npx forgedock")} to refresh your commands.`,
|
|
1368
|
+
"",
|
|
1369
|
+
];
|
|
1370
|
+
process.stdout.write(box(lines, { title: "npm update available" }));
|
|
1371
|
+
console.log("");
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
async function init() {
|
|
1376
|
+
checkPrerequisites();
|
|
1377
|
+
|
|
1378
|
+
console.log("");
|
|
1379
|
+
console.log(`${BOLD}ForgeDock${RESET} — Generate forge.yaml`);
|
|
1380
|
+
console.log("");
|
|
1381
|
+
|
|
1382
|
+
const cwd = process.cwd();
|
|
1383
|
+
const outputPath = join(cwd, "forge.yaml");
|
|
1384
|
+
|
|
1385
|
+
// ---------------------------------------------------------------------------
|
|
1386
|
+
// Auto-detect defaults (silent — used as pre-fill values for prompts)
|
|
1387
|
+
// ---------------------------------------------------------------------------
|
|
1388
|
+
|
|
1389
|
+
// Detect git remote URL and parse owner/repo
|
|
1390
|
+
let detectedOwner = "your-github-org";
|
|
1391
|
+
let detectedRepo = "your-repo-name";
|
|
1392
|
+
let remoteDetected = false;
|
|
1393
|
+
|
|
1394
|
+
try {
|
|
1395
|
+
const remoteUrl = execSync("git remote get-url origin", {
|
|
1396
|
+
cwd,
|
|
1397
|
+
encoding: "utf-8",
|
|
1398
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1399
|
+
}).trim();
|
|
1400
|
+
|
|
1401
|
+
// SSH: git@github.com:owner/repo.git
|
|
1402
|
+
const sshMatch = remoteUrl.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
1403
|
+
// HTTPS: https://github.com/owner/repo.git
|
|
1404
|
+
const httpsMatch = remoteUrl.match(/^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
1405
|
+
|
|
1406
|
+
if (sshMatch) {
|
|
1407
|
+
detectedOwner = sshMatch[1];
|
|
1408
|
+
detectedRepo = sshMatch[2];
|
|
1409
|
+
remoteDetected = true;
|
|
1410
|
+
} else if (httpsMatch) {
|
|
1411
|
+
detectedOwner = httpsMatch[1];
|
|
1412
|
+
detectedRepo = httpsMatch[2];
|
|
1413
|
+
remoteDetected = true;
|
|
1414
|
+
}
|
|
1415
|
+
} catch {
|
|
1416
|
+
// No remote — use placeholders
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Detect default branch
|
|
1420
|
+
let detectedDefault = "main";
|
|
1421
|
+
try {
|
|
1422
|
+
const headRef = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
|
|
1423
|
+
cwd,
|
|
1424
|
+
encoding: "utf-8",
|
|
1425
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1426
|
+
}).trim();
|
|
1427
|
+
detectedDefault = headRef.replace(/^refs\/remotes\/origin\//, "");
|
|
1428
|
+
} catch {
|
|
1429
|
+
try {
|
|
1430
|
+
const cur = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
1431
|
+
cwd,
|
|
1432
|
+
encoding: "utf-8",
|
|
1433
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1434
|
+
}).trim();
|
|
1435
|
+
if (cur && cur !== "HEAD") detectedDefault = cur;
|
|
1436
|
+
} catch {
|
|
1437
|
+
// Keep "main"
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// Detect staging branch
|
|
1442
|
+
let detectedStaging = detectedDefault;
|
|
1443
|
+
try {
|
|
1444
|
+
const remoteBranches = execSync("git branch -r", {
|
|
1445
|
+
cwd,
|
|
1446
|
+
encoding: "utf-8",
|
|
1447
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1448
|
+
});
|
|
1449
|
+
if (remoteBranches.includes("origin/staging")) {
|
|
1450
|
+
detectedStaging = "staging";
|
|
1451
|
+
}
|
|
1452
|
+
} catch {
|
|
1453
|
+
// Keep default
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// Derive project name from repo slug
|
|
1457
|
+
const detectedName = detectedRepo
|
|
1458
|
+
.split(/[-_]/)
|
|
1459
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
1460
|
+
.join(" ");
|
|
1461
|
+
|
|
1462
|
+
// Non-TTY: skip interactive prompts and use detected values directly
|
|
1463
|
+
if (!process.stdin.isTTY) {
|
|
1464
|
+
if (!remoteDetected) {
|
|
1465
|
+
console.log(
|
|
1466
|
+
` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values`
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1469
|
+
// Silent write — same as pre-interactive behavior
|
|
1470
|
+
_writeForgeYaml({
|
|
1471
|
+
outputPath, cwd,
|
|
1472
|
+
owner: detectedOwner, repo: detectedRepo,
|
|
1473
|
+
projectName: detectedName, description: "",
|
|
1474
|
+
root: cwd, worktreeBase: join(cwd, ".claude", "worktrees"),
|
|
1475
|
+
defaultBranch: detectedDefault, stagingBranch: detectedStaging,
|
|
1476
|
+
});
|
|
1477
|
+
console.log(` ${GREEN}Created${RESET}: forge.yaml`);
|
|
1478
|
+
console.log("");
|
|
1479
|
+
_printNextSteps({ remoteDetected });
|
|
1480
|
+
await validate(outputPath);
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// ---------------------------------------------------------------------------
|
|
1485
|
+
// Interactive flow — prompt for each required section
|
|
1486
|
+
// ---------------------------------------------------------------------------
|
|
1487
|
+
|
|
1488
|
+
// Loop until user confirms the preview
|
|
1489
|
+
let confirmed = false;
|
|
1490
|
+
|
|
1491
|
+
while (!confirmed) {
|
|
1492
|
+
console.log(dim(" Auto-detected values are shown as defaults. Press Enter to accept."));
|
|
1493
|
+
console.log("");
|
|
1494
|
+
|
|
1495
|
+
// --- Project section ---
|
|
1496
|
+
console.log(bold(" Project"));
|
|
1497
|
+
|
|
1498
|
+
const ownerInput = await input(" GitHub owner (org or user)", detectedOwner);
|
|
1499
|
+
const repoInput = await input(" Repository name", detectedRepo);
|
|
1500
|
+
const nameInput = await input(
|
|
1501
|
+
" Project name",
|
|
1502
|
+
detectedName !== "Your-repo-name" ? detectedName : repoInput.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")
|
|
1503
|
+
);
|
|
1504
|
+
const descInput = await input(" Brief description", "");
|
|
1505
|
+
|
|
1506
|
+
// --- Paths section ---
|
|
1507
|
+
console.log("");
|
|
1508
|
+
console.log(bold(" Paths"));
|
|
1509
|
+
|
|
1510
|
+
const rootInput = await input(" Repository root (absolute path)", cwd);
|
|
1511
|
+
const worktreeInput = await input(
|
|
1512
|
+
" Worktree base (for git worktrees)",
|
|
1513
|
+
join(rootInput || cwd, ".claude", "worktrees")
|
|
1514
|
+
);
|
|
1515
|
+
|
|
1516
|
+
// --- Branches section ---
|
|
1517
|
+
console.log("");
|
|
1518
|
+
console.log(bold(" Branches"));
|
|
1519
|
+
|
|
1520
|
+
const defaultBranchInput = await input(" Default branch", detectedDefault);
|
|
1521
|
+
const stagingBranchInput = await input(" Staging branch (PR target for fast-lane changes)", detectedStaging);
|
|
1522
|
+
|
|
1523
|
+
// --- Preview ---
|
|
1524
|
+
console.log("");
|
|
1525
|
+
|
|
1526
|
+
const previewLines = buildForgeYamlContent({
|
|
1527
|
+
owner: ownerInput,
|
|
1528
|
+
repo: repoInput,
|
|
1529
|
+
projectName: nameInput,
|
|
1530
|
+
description: descInput,
|
|
1531
|
+
root: rootInput || cwd,
|
|
1532
|
+
worktreeBase: worktreeInput || join(cwd, ".claude", "worktrees"),
|
|
1533
|
+
defaultBranch: defaultBranchInput,
|
|
1534
|
+
stagingBranch: stagingBranchInput,
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
// Show preview in a box
|
|
1538
|
+
const previewDisplay = previewLines
|
|
1539
|
+
.split("\n")
|
|
1540
|
+
.slice(0, 30) // show first 30 lines of required sections only
|
|
1541
|
+
.join("\n");
|
|
1542
|
+
|
|
1543
|
+
process.stdout.write(
|
|
1544
|
+
box(previewDisplay, { title: "forge.yaml preview (required sections)", padding: 1 })
|
|
1545
|
+
);
|
|
1546
|
+
|
|
1547
|
+
confirmed = await confirm(" Write this forge.yaml?", true);
|
|
1548
|
+
|
|
1549
|
+
if (!confirmed) {
|
|
1550
|
+
console.log("");
|
|
1551
|
+
console.log(dim(" Starting over — re-enter values below."));
|
|
1552
|
+
console.log("");
|
|
1553
|
+
} else {
|
|
1554
|
+
// -----------------------------------------------------------------------
|
|
1555
|
+
// Optional sections — multi-select, then guided prompts per section
|
|
1556
|
+
// -----------------------------------------------------------------------
|
|
1557
|
+
console.log("");
|
|
1558
|
+
console.log(bold(" Optional Sections"));
|
|
1559
|
+
console.log(dim(" Select sections to configure now. Unselected sections are written as"));
|
|
1560
|
+
console.log(dim(" commented-out placeholders — you can enable them later by editing forge.yaml."));
|
|
1561
|
+
console.log("");
|
|
1562
|
+
|
|
1563
|
+
const OPTIONAL_SECTION_CHOICES = [
|
|
1564
|
+
{
|
|
1565
|
+
label: "Project Board — GitHub Projects v2 integration for workflow tracking",
|
|
1566
|
+
value: "projectBoard",
|
|
1567
|
+
},
|
|
1568
|
+
{
|
|
1569
|
+
label: "Multi-Repo — Satellite repos for cross-repo milestones",
|
|
1570
|
+
value: "multiRepo",
|
|
1571
|
+
},
|
|
1572
|
+
{
|
|
1573
|
+
label: "Review Context — Tech stack and conventions for PR review agents",
|
|
1574
|
+
value: "review",
|
|
1575
|
+
},
|
|
1576
|
+
{
|
|
1577
|
+
label: "Verification — Health check endpoints and response patterns",
|
|
1578
|
+
value: "verification",
|
|
1579
|
+
},
|
|
1580
|
+
];
|
|
1581
|
+
|
|
1582
|
+
const selectedSections = await multiSelect(
|
|
1583
|
+
" Which optional sections would you like to configure?",
|
|
1584
|
+
OPTIONAL_SECTION_CHOICES
|
|
1585
|
+
);
|
|
1586
|
+
|
|
1587
|
+
/** @type {Record<string, object>} */
|
|
1588
|
+
const optionalSections = {};
|
|
1589
|
+
|
|
1590
|
+
// --- Project Board prompts ---
|
|
1591
|
+
if (selectedSections.includes("projectBoard")) {
|
|
1592
|
+
const discovered = await discoverProjectBoard(ownerInput || detectedOwner);
|
|
1593
|
+
if (discovered) {
|
|
1594
|
+
// Auto-discovery succeeded — use resolved IDs
|
|
1595
|
+
optionalSections.projectBoard = {
|
|
1596
|
+
projectNumber: discovered.projectNumber,
|
|
1597
|
+
projectId: discovered.projectId,
|
|
1598
|
+
fieldIds: discovered.fieldIds,
|
|
1599
|
+
optionIds: discovered.optionIds,
|
|
1600
|
+
};
|
|
1601
|
+
} else {
|
|
1602
|
+
// Fallback: manual entry (no regression from previous behaviour)
|
|
1603
|
+
console.log("");
|
|
1604
|
+
console.log(bold(" Project Board (manual)"));
|
|
1605
|
+
console.log(dim(" Find your project number: gh project list --owner " + (ownerInput || detectedOwner)));
|
|
1606
|
+
const projectNumber = await input(
|
|
1607
|
+
" GitHub Projects v2 project number",
|
|
1608
|
+
"1"
|
|
1609
|
+
);
|
|
1610
|
+
optionalSections.projectBoard = {
|
|
1611
|
+
projectNumber: parseInt(projectNumber, 10) || 1,
|
|
1612
|
+
};
|
|
1613
|
+
console.log(dim(" Field IDs (PVT_/PVTSSF_ strings) must be added manually after generation."));
|
|
1614
|
+
console.log(dim(" Run: gh project field-list " + (projectNumber || "1") + " --owner " + (ownerInput || detectedOwner)));
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// --- Multi-Repo prompts ---
|
|
1619
|
+
if (selectedSections.includes("multiRepo")) {
|
|
1620
|
+
console.log("");
|
|
1621
|
+
console.log(bold(" Multi-Repo"));
|
|
1622
|
+
console.log(dim(" Configure one satellite repo (add more by editing forge.yaml)."));
|
|
1623
|
+
const prefix = await input(
|
|
1624
|
+
" Satellite repo prefix (e.g. 'mcp', 'sdk')",
|
|
1625
|
+
"sat"
|
|
1626
|
+
);
|
|
1627
|
+
const satelliteRepo = await input(
|
|
1628
|
+
" Satellite repo name (just the name, owner will be reused)",
|
|
1629
|
+
"your-satellite-repo"
|
|
1630
|
+
);
|
|
1631
|
+
const satelliteBranch = await input(
|
|
1632
|
+
" Satellite default/staging branch",
|
|
1633
|
+
"main"
|
|
1634
|
+
);
|
|
1635
|
+
optionalSections.multiRepo = { prefix, satelliteRepo, satelliteBranch };
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// --- Review Context prompts ---
|
|
1639
|
+
if (selectedSections.includes("review")) {
|
|
1640
|
+
console.log("");
|
|
1641
|
+
console.log(bold(" Review Context"));
|
|
1642
|
+
const techStack = await input(
|
|
1643
|
+
" Tech stack (e.g. Next.js, FastAPI, PostgreSQL)",
|
|
1644
|
+
"Node.js, TypeScript"
|
|
1645
|
+
);
|
|
1646
|
+
const context = await input(
|
|
1647
|
+
" Architecture notes (one line; expand in forge.yaml later)",
|
|
1648
|
+
""
|
|
1649
|
+
);
|
|
1650
|
+
optionalSections.review = { techStack, context };
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// --- Verification prompts ---
|
|
1654
|
+
if (selectedSections.includes("verification")) {
|
|
1655
|
+
console.log("");
|
|
1656
|
+
console.log(bold(" Verification"));
|
|
1657
|
+
const healthEndpoint = await input(
|
|
1658
|
+
" Health check endpoint URL",
|
|
1659
|
+
`https://api.${repoInput || detectedRepo}.io/health`
|
|
1660
|
+
);
|
|
1661
|
+
optionalSections.verification = { healthEndpoint };
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// -----------------------------------------------------------------------
|
|
1665
|
+
// Handle existing forge.yaml with confirmation
|
|
1666
|
+
// -----------------------------------------------------------------------
|
|
1667
|
+
if (existsSync(outputPath)) {
|
|
1668
|
+
console.log("");
|
|
1669
|
+
console.log(` ${YELLOW}forge.yaml already exists.${RESET}`);
|
|
1670
|
+
const shouldOverwrite = await confirm(
|
|
1671
|
+
" Back up existing forge.yaml and overwrite?",
|
|
1672
|
+
true
|
|
1673
|
+
);
|
|
1674
|
+
if (!shouldOverwrite) {
|
|
1675
|
+
console.log(` ${dim("Cancelled.")} forge.yaml was not changed.`);
|
|
1676
|
+
console.log("");
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// Backup with timestamped name if .bak already exists (fix from #36)
|
|
1681
|
+
const baseBak = join(cwd, "forge.yaml.bak");
|
|
1682
|
+
const backupPath = existsSync(baseBak)
|
|
1683
|
+
? join(cwd, `forge.yaml.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`)
|
|
1684
|
+
: baseBak;
|
|
1685
|
+
const backupName = backupPath.split("/").pop();
|
|
1686
|
+
renameSync(outputPath, backupPath);
|
|
1687
|
+
console.log(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}`);
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// Write the file
|
|
1691
|
+
_writeForgeYaml({
|
|
1692
|
+
outputPath,
|
|
1693
|
+
cwd,
|
|
1694
|
+
owner: ownerInput,
|
|
1695
|
+
repo: repoInput,
|
|
1696
|
+
projectName: nameInput,
|
|
1697
|
+
description: descInput,
|
|
1698
|
+
root: rootInput || cwd,
|
|
1699
|
+
worktreeBase: worktreeInput || join(cwd, ".claude", "worktrees"),
|
|
1700
|
+
defaultBranch: defaultBranchInput,
|
|
1701
|
+
stagingBranch: stagingBranchInput,
|
|
1702
|
+
optionalSections,
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
console.log("");
|
|
1706
|
+
console.log(` ${GREEN}Created${RESET}: forge.yaml`);
|
|
1707
|
+
if (selectedSections.length > 0) {
|
|
1708
|
+
console.log(` ${GREEN}Configured${RESET}: ${selectedSections.map((s) => ({
|
|
1709
|
+
projectBoard: "project_board",
|
|
1710
|
+
multiRepo: "repos",
|
|
1711
|
+
review: "review",
|
|
1712
|
+
verification: "verification",
|
|
1713
|
+
})[s]).join(", ")}`);
|
|
1714
|
+
}
|
|
1715
|
+
console.log("");
|
|
1716
|
+
_printNextSteps({ remoteDetected: ownerInput !== "your-github-org" });
|
|
1717
|
+
await validate(outputPath);
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// ---------------------------------------------------------------------------
|
|
1723
|
+
// Helpers for init()
|
|
1724
|
+
// ---------------------------------------------------------------------------
|
|
1725
|
+
|
|
1726
|
+
/**
|
|
1727
|
+
* Sanitize a string value for safe insertion into a YAML double-quoted scalar.
|
|
1728
|
+
* Strips double-quotes and newlines to prevent YAML injection.
|
|
1729
|
+
*
|
|
1730
|
+
* @param {string} value
|
|
1731
|
+
* @returns {string}
|
|
1732
|
+
*/
|
|
1733
|
+
function _sanitizeYamlValue(value) {
|
|
1734
|
+
return String(value).replace(/"/g, "").replace(/[\r\n]/g, " ").trim();
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
/**
|
|
1738
|
+
* Sanitize a file-system path value for safe insertion into a YAML double-quoted scalar.
|
|
1739
|
+
* Escapes backslashes (Windows paths) in addition to stripping double-quotes and newlines.
|
|
1740
|
+
*
|
|
1741
|
+
* @param {string} value
|
|
1742
|
+
* @returns {string}
|
|
1743
|
+
*/
|
|
1744
|
+
function _sanitizePathValue(value) {
|
|
1745
|
+
return String(value).replace(/\\/g, "\\\\").replace(/"/g, "").replace(/[\r\n]/g, " ").trim();
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/**
|
|
1749
|
+
* Attempt to auto-discover GitHub Projects v2 configuration for the given owner.
|
|
1750
|
+
*
|
|
1751
|
+
* Runs two gh CLI calls:
|
|
1752
|
+
* 1. gh project list --owner {owner} --format json → project select menu
|
|
1753
|
+
* 2. gh project field-list {number} --owner {owner} --format json → field/option mapping
|
|
1754
|
+
*
|
|
1755
|
+
* Shows a summary box and asks the user to confirm before returning data.
|
|
1756
|
+
*
|
|
1757
|
+
* @param {string} owner - GitHub org or username (user-typed via \`input()\` — untrusted)
|
|
1758
|
+
* @returns {Promise<{projectNumber: number, projectId: string, fieldIds: object, optionIds: object} | null>}
|
|
1759
|
+
* Resolved project board config, or null if skipped / discovery failed.
|
|
1760
|
+
*
|
|
1761
|
+
* @security \`owner\` is a raw user-typed value from an interactive prompt and is UNTRUSTED.
|
|
1762
|
+
* All gh CLI invocations in this function MUST use \`execFileSync\` with an argument array —
|
|
1763
|
+
* never interpolate \`owner\` into an \`execSync\` shell string. execFileSync does not invoke
|
|
1764
|
+
* a shell, so metacharacters in \`owner\` are treated literally and cannot escape argument
|
|
1765
|
+
* boundaries. (Ref: review-finding #149 — PR #148 originally used execSync template literals)
|
|
1766
|
+
*/
|
|
1767
|
+
async function discoverProjectBoard(owner) {
|
|
1768
|
+
// Non-TTY: skip auto-discovery — can't prompt
|
|
1769
|
+
if (!process.stdout.isTTY) {
|
|
1770
|
+
return null;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
console.log("");
|
|
1774
|
+
console.log(bold(" Project Board"));
|
|
1775
|
+
console.log(dim(" Searching for GitHub Projects v2…"));
|
|
1776
|
+
|
|
1777
|
+
// -------------------------------------------------------------------------
|
|
1778
|
+
// Step 1: List projects
|
|
1779
|
+
// -------------------------------------------------------------------------
|
|
1780
|
+
let projects = [];
|
|
1781
|
+
try {
|
|
1782
|
+
const raw = execFileSync("gh", ["project", "list", "--owner", owner, "--format", "json"], {
|
|
1783
|
+
encoding: "utf-8",
|
|
1784
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1785
|
+
});
|
|
1786
|
+
const parsed = JSON.parse(raw);
|
|
1787
|
+
projects = parsed.projects || [];
|
|
1788
|
+
} catch {
|
|
1789
|
+
// gh not available, auth failure, or no projects access — fall back silently
|
|
1790
|
+
console.log(dim(" Could not list projects — falling back to manual entry."));
|
|
1791
|
+
return null;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
if (projects.length === 0) {
|
|
1795
|
+
console.log(dim(" No GitHub Projects v2 boards found for this owner."));
|
|
1796
|
+
console.log(dim(" You can add project_board configuration manually in forge.yaml later."));
|
|
1797
|
+
return null;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// Build select menu choices
|
|
1801
|
+
const projectChoices = projects.map((p) => ({
|
|
1802
|
+
label: `${p.title} ${dim("(#" + p.number + ", " + (p.items?.totalCount ?? "?") + " items)")}`,
|
|
1803
|
+
value: p.number,
|
|
1804
|
+
}));
|
|
1805
|
+
projectChoices.push({ label: dim("Skip — configure project board manually later"), value: null });
|
|
1806
|
+
|
|
1807
|
+
console.log("");
|
|
1808
|
+
const selectedNumber = await select(" Which GitHub Project board?", projectChoices);
|
|
1809
|
+
if (selectedNumber === null) {
|
|
1810
|
+
return null;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// Look up the selected project's id
|
|
1814
|
+
const selectedProject = projects.find((p) => p.number === selectedNumber);
|
|
1815
|
+
const projectId = selectedProject?.id ?? "";
|
|
1816
|
+
const projectTitle = _sanitizeYamlValue(selectedProject?.title ?? `Project #${selectedNumber}`);
|
|
1817
|
+
|
|
1818
|
+
// -------------------------------------------------------------------------
|
|
1819
|
+
// Step 2: Fetch field list
|
|
1820
|
+
// -------------------------------------------------------------------------
|
|
1821
|
+
console.log("");
|
|
1822
|
+
console.log(dim(" Fetching project fields…"));
|
|
1823
|
+
|
|
1824
|
+
let fields = [];
|
|
1825
|
+
try {
|
|
1826
|
+
const raw = execFileSync("gh", ["project", "field-list", String(selectedNumber), "--owner", owner, "--format", "json"], {
|
|
1827
|
+
encoding: "utf-8",
|
|
1828
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1829
|
+
});
|
|
1830
|
+
const parsed = JSON.parse(raw);
|
|
1831
|
+
fields = (parsed.fields || []).filter((f) => f.type === "ProjectV2SingleSelectField");
|
|
1832
|
+
} catch {
|
|
1833
|
+
console.log(dim(" Could not fetch project fields — falling back to manual entry."));
|
|
1834
|
+
return null;
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
// -------------------------------------------------------------------------
|
|
1838
|
+
// Step 3: Match fields and options by name
|
|
1839
|
+
// -------------------------------------------------------------------------
|
|
1840
|
+
|
|
1841
|
+
/**
|
|
1842
|
+
* Match a field by checking if its name case-insensitively includes the target keyword.
|
|
1843
|
+
* Returns the field or null.
|
|
1844
|
+
*/
|
|
1845
|
+
function matchField(keyword) {
|
|
1846
|
+
// Exact match first (case-insensitive)
|
|
1847
|
+
const exact = fields.find((f) => f.name.toLowerCase() === keyword.toLowerCase());
|
|
1848
|
+
if (exact) return { field: exact, fuzzy: false };
|
|
1849
|
+
// Fuzzy: name contains keyword
|
|
1850
|
+
const fuzzy = fields.find((f) => f.name.toLowerCase().includes(keyword.toLowerCase()));
|
|
1851
|
+
if (fuzzy) return { field: fuzzy, fuzzy: true };
|
|
1852
|
+
return null;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
// Known field keys and their expected name keywords
|
|
1856
|
+
const FIELD_TARGETS = [
|
|
1857
|
+
{ key: "status", keywords: ["status"] },
|
|
1858
|
+
{ key: "lane", keywords: ["lane", "track"] },
|
|
1859
|
+
{ key: "component", keywords: ["component"] },
|
|
1860
|
+
{ key: "priority", keywords: ["priority"] },
|
|
1861
|
+
{ key: "workflow", keywords: ["workflow"] },
|
|
1862
|
+
];
|
|
1863
|
+
|
|
1864
|
+
// Known option name → forge.yaml key mappings per field
|
|
1865
|
+
const OPTION_MAPS = {
|
|
1866
|
+
status: { "todo": "todo", "in progress": "in_progress", "done": "done" },
|
|
1867
|
+
lane: { "fast": "fast", "feature": "feature", "sync": "sync" },
|
|
1868
|
+
priority: { "p0": "p0", "p1": "p1", "p2": "p2", "p3": "p3" },
|
|
1869
|
+
workflow: {
|
|
1870
|
+
"investigating": "investigating",
|
|
1871
|
+
"ready to build": "ready_to_build",
|
|
1872
|
+
"building": "building",
|
|
1873
|
+
"in review": "in_review",
|
|
1874
|
+
"merged": "merged",
|
|
1875
|
+
"invalid": "invalid",
|
|
1876
|
+
"decomposed": "decomposed",
|
|
1877
|
+
},
|
|
1878
|
+
component: {}, // component options vary by project — map all options by slugifying the name
|
|
1879
|
+
};
|
|
1880
|
+
|
|
1881
|
+
const resolvedFieldIds = {};
|
|
1882
|
+
const resolvedOptionIds = {};
|
|
1883
|
+
const fuzzyMatches = [];
|
|
1884
|
+
|
|
1885
|
+
for (const target of FIELD_TARGETS) {
|
|
1886
|
+
let match = null;
|
|
1887
|
+
for (const kw of target.keywords) {
|
|
1888
|
+
match = matchField(kw);
|
|
1889
|
+
if (match) break;
|
|
1890
|
+
}
|
|
1891
|
+
if (!match) continue;
|
|
1892
|
+
|
|
1893
|
+
const { field, fuzzy } = match;
|
|
1894
|
+
if (fuzzy) {
|
|
1895
|
+
fuzzyMatches.push({ key: target.key, fieldName: field.name });
|
|
1896
|
+
}
|
|
1897
|
+
resolvedFieldIds[target.key] = field.id;
|
|
1898
|
+
|
|
1899
|
+
// Map option IDs
|
|
1900
|
+
const optMap = OPTION_MAPS[target.key] ?? {};
|
|
1901
|
+
const mappedOptions = {};
|
|
1902
|
+
|
|
1903
|
+
if (target.key === "component") {
|
|
1904
|
+
// For component, map every option by slugifying its name (a–z, 0–9, underscore)
|
|
1905
|
+
for (const opt of field.options || []) {
|
|
1906
|
+
const slug = opt.name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
|
|
1907
|
+
mappedOptions[slug] = opt.id;
|
|
1908
|
+
}
|
|
1909
|
+
} else {
|
|
1910
|
+
for (const opt of field.options || []) {
|
|
1911
|
+
const nameLower = opt.name.toLowerCase();
|
|
1912
|
+
const mappedKey = optMap[nameLower];
|
|
1913
|
+
if (mappedKey) {
|
|
1914
|
+
mappedOptions[mappedKey] = opt.id;
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
if (Object.keys(mappedOptions).length > 0) {
|
|
1920
|
+
resolvedOptionIds[target.key] = mappedOptions;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// -------------------------------------------------------------------------
|
|
1925
|
+
// Step 4: Confirm fuzzy matches (if any)
|
|
1926
|
+
// -------------------------------------------------------------------------
|
|
1927
|
+
for (const { key, fieldName } of fuzzyMatches) {
|
|
1928
|
+
console.log("");
|
|
1929
|
+
console.log(yellow(` Fuzzy match: field "${fieldName}" mapped to forge.yaml key "${key}"`));
|
|
1930
|
+
const accepted = await confirm(` Accept this mapping (${fieldName} → ${key})?`, true);
|
|
1931
|
+
if (!accepted) {
|
|
1932
|
+
delete resolvedFieldIds[key];
|
|
1933
|
+
delete resolvedOptionIds[key];
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// -------------------------------------------------------------------------
|
|
1938
|
+
// Step 5: Show summary box and confirm
|
|
1939
|
+
// -------------------------------------------------------------------------
|
|
1940
|
+
const summaryLines = [
|
|
1941
|
+
`Project: ${projectTitle} (#${selectedNumber})`,
|
|
1942
|
+
`Project ID: ${projectId}`,
|
|
1943
|
+
"",
|
|
1944
|
+
"Fields:",
|
|
1945
|
+
];
|
|
1946
|
+
for (const [key, id] of Object.entries(resolvedFieldIds)) {
|
|
1947
|
+
const optCount = resolvedOptionIds[key] ? Object.keys(resolvedOptionIds[key]).length : 0;
|
|
1948
|
+
const optLabel = optCount > 0 ? ` (${optCount} options)` : " (no options mapped)";
|
|
1949
|
+
summaryLines.push(` ${key.padEnd(10)}: ${id}${optLabel}`);
|
|
1950
|
+
}
|
|
1951
|
+
const unmapped = FIELD_TARGETS.map((t) => t.key).filter((k) => !resolvedFieldIds[k]);
|
|
1952
|
+
if (unmapped.length > 0) {
|
|
1953
|
+
summaryLines.push("");
|
|
1954
|
+
summaryLines.push(`Not found: ${unmapped.join(", ")} — placeholders will be used`);
|
|
196
1955
|
}
|
|
1956
|
+
|
|
197
1957
|
console.log("");
|
|
1958
|
+
console.log(box(summaryLines, { title: "Project Board Config" }));
|
|
1959
|
+
|
|
1960
|
+
const confirmed = await confirm(" Use this configuration?", true);
|
|
1961
|
+
if (!confirmed) {
|
|
1962
|
+
console.log(dim(" Skipped auto-discovery. You can add project_board manually in forge.yaml."));
|
|
1963
|
+
return null;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
return {
|
|
1967
|
+
projectNumber: selectedNumber,
|
|
1968
|
+
projectId,
|
|
1969
|
+
fieldIds: resolvedFieldIds,
|
|
1970
|
+
optionIds: resolvedOptionIds,
|
|
1971
|
+
};
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
/**
|
|
1975
|
+
* Build the forge.yaml file content string from gathered values.
|
|
1976
|
+
* Required sections are always written as active YAML.
|
|
1977
|
+
* Optional sections are written as active YAML when config is provided in `optionalSections`,
|
|
1978
|
+
* or as commented-out placeholders when absent.
|
|
1979
|
+
*
|
|
1980
|
+
* @param {object} opts
|
|
1981
|
+
* @param {string} opts.owner
|
|
1982
|
+
* @param {string} opts.repo
|
|
1983
|
+
* @param {string} opts.projectName
|
|
1984
|
+
* @param {string} opts.description
|
|
1985
|
+
* @param {string} opts.root
|
|
1986
|
+
* @param {string} opts.worktreeBase
|
|
1987
|
+
* @param {string} opts.defaultBranch
|
|
1988
|
+
* @param {string} opts.stagingBranch
|
|
1989
|
+
* @param {object} [opts.optionalSections] - Optional section configs; absent key = commented-out
|
|
1990
|
+
* @param {object} [opts.optionalSections.projectBoard] - project_board section config
|
|
1991
|
+
* @param {object} [opts.optionalSections.multiRepo] - repos section config
|
|
1992
|
+
* @param {object} [opts.optionalSections.review] - review section config
|
|
1993
|
+
* @param {object} [opts.optionalSections.verification] - verification section config
|
|
1994
|
+
*/
|
|
1995
|
+
function buildForgeYamlContent({ owner, repo, projectName, description, root, worktreeBase, defaultBranch, stagingBranch, optionalSections = {} }) {
|
|
1996
|
+
// Sanitize all user-supplied string values before template interpolation to prevent
|
|
1997
|
+
// YAML injection via double-quotes or embedded newlines in double-quoted scalars.
|
|
1998
|
+
owner = _sanitizeYamlValue(owner);
|
|
1999
|
+
repo = _sanitizeYamlValue(repo);
|
|
2000
|
+
projectName = _sanitizeYamlValue(projectName);
|
|
2001
|
+
description = _sanitizeYamlValue(description);
|
|
2002
|
+
defaultBranch = _sanitizeYamlValue(defaultBranch);
|
|
2003
|
+
stagingBranch = _sanitizeYamlValue(stagingBranch);
|
|
2004
|
+
// Path values: also escape backslashes so Windows paths are valid YAML.
|
|
2005
|
+
// Preserve rawRoot for use in path.join() operations — join() expects raw OS paths,
|
|
2006
|
+
// not YAML-escaped strings. Always apply _sanitizePathValue() to the join() result.
|
|
2007
|
+
const rawRoot = root;
|
|
2008
|
+
root = _sanitizePathValue(root);
|
|
2009
|
+
worktreeBase = _sanitizePathValue(worktreeBase);
|
|
2010
|
+
|
|
2011
|
+
const { projectBoard, multiRepo, review, verification } = optionalSections;
|
|
2012
|
+
|
|
2013
|
+
// Sanitize optional-section user-supplied strings.
|
|
2014
|
+
const safeMultiRepo = multiRepo ? {
|
|
2015
|
+
prefix: _sanitizeYamlValue(multiRepo.prefix || "sat"),
|
|
2016
|
+
satelliteRepo: _sanitizeYamlValue(multiRepo.satelliteRepo || "your-satellite-repo"),
|
|
2017
|
+
satelliteBranch: _sanitizeYamlValue(multiRepo.satelliteBranch || "main"),
|
|
2018
|
+
} : null;
|
|
2019
|
+
|
|
2020
|
+
const safeReview = review ? {
|
|
2021
|
+
techStack: _sanitizeYamlValue(review.techStack || "Node.js, TypeScript, PostgreSQL"),
|
|
2022
|
+
// context uses a block scalar (|) — only strip double-quotes; newlines are handled by split/join
|
|
2023
|
+
context: (review.context || "Add architecture notes and conventions here.").replace(/"/g, ""),
|
|
2024
|
+
} : null;
|
|
2025
|
+
|
|
2026
|
+
const safeVerification = verification ? {
|
|
2027
|
+
healthEndpoint: _sanitizeYamlValue(verification.healthEndpoint || `https://api.${repo}.io/health`),
|
|
2028
|
+
} : null;
|
|
2029
|
+
|
|
2030
|
+
// --- repos section ---
|
|
2031
|
+
const reposSection = safeMultiRepo
|
|
2032
|
+
? `repos:
|
|
2033
|
+
default:
|
|
2034
|
+
repo: "${owner}/${repo}"
|
|
2035
|
+
staging_branch: "${stagingBranch}"
|
|
2036
|
+
satellites:
|
|
2037
|
+
- prefix: "${safeMultiRepo.prefix}"
|
|
2038
|
+
repo: "${owner}/${safeMultiRepo.satelliteRepo}"
|
|
2039
|
+
staging_branch: "${safeMultiRepo.satelliteBranch}"
|
|
2040
|
+
local_path: "${_sanitizePathValue(join(rawRoot, "..", safeMultiRepo.satelliteRepo))}"`
|
|
2041
|
+
: `# repos:
|
|
2042
|
+
# default:
|
|
2043
|
+
# repo: "${owner}/${repo}"
|
|
2044
|
+
# staging_branch: "${stagingBranch}"
|
|
2045
|
+
# satellites:
|
|
2046
|
+
# - prefix: "mcp"
|
|
2047
|
+
# repo: "${owner}/your-satellite-repo"
|
|
2048
|
+
# staging_branch: "main"
|
|
2049
|
+
# local_path: "${_sanitizePathValue(join(rawRoot, "..", "your-satellite-repo"))}"`;
|
|
2050
|
+
|
|
2051
|
+
// --- project_board section ---
|
|
2052
|
+
// Build field_ids block — use resolved IDs where available, placeholders otherwise
|
|
2053
|
+
const FIELD_KEYS = ["status", "lane", "component", "priority", "workflow"];
|
|
2054
|
+
const resolvedFieldIds = projectBoard?.fieldIds ?? {};
|
|
2055
|
+
const resolvedOptionIds = projectBoard?.optionIds ?? {};
|
|
2056
|
+
|
|
2057
|
+
/**
|
|
2058
|
+
* Build a YAML block for option_ids given a nested map of { fieldKey: { optionKey: id } }.
|
|
2059
|
+
* Only writes fields that have at least one mapped option.
|
|
2060
|
+
*/
|
|
2061
|
+
function _buildOptionIdsBlock(optionIds) {
|
|
2062
|
+
const entries = Object.entries(optionIds).filter(([, opts]) => Object.keys(opts).length > 0);
|
|
2063
|
+
if (entries.length === 0) return "";
|
|
2064
|
+
const lines = [" option_ids:"];
|
|
2065
|
+
for (const [fieldKey, opts] of entries) {
|
|
2066
|
+
lines.push(` ${fieldKey}:`);
|
|
2067
|
+
for (const [optKey, optId] of Object.entries(opts)) {
|
|
2068
|
+
lines.push(` ${optKey}: "${optId}"`);
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
return "\n" + lines.join("\n");
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
const fieldIdsBlock = FIELD_KEYS
|
|
2075
|
+
.map((k) => ` ${k}: "${resolvedFieldIds[k] ?? "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"}"`)
|
|
2076
|
+
.join("\n");
|
|
2077
|
+
|
|
2078
|
+
const optionIdsBlock = projectBoard?.optionIds ? _buildOptionIdsBlock(resolvedOptionIds) : "";
|
|
2079
|
+
|
|
2080
|
+
const hasDiscoveredId = !!(projectBoard?.projectId);
|
|
2081
|
+
|
|
2082
|
+
const projectBoardSection = projectBoard
|
|
2083
|
+
? `project_board:
|
|
2084
|
+
owner: "${owner}"
|
|
2085
|
+
project_number: ${projectBoard.projectNumber || 1}
|
|
2086
|
+
project_id: "${hasDiscoveredId ? projectBoard.projectId : "PVT_kwHOxxxxxxxxxxxxxxxx"}"
|
|
2087
|
+
field_ids:
|
|
2088
|
+
${fieldIdsBlock}${optionIdsBlock}${!hasDiscoveredId ? `\n# To find IDs: gh project field-list ${projectBoard.projectNumber || 1} --owner ${owner}` : ""}`
|
|
2089
|
+
: `# project_board:
|
|
2090
|
+
# owner: "${owner}"
|
|
2091
|
+
# project_number: 1
|
|
2092
|
+
# project_id: "PVT_kwHOxxxxxxxxxxxxxxxx"
|
|
2093
|
+
# field_ids:
|
|
2094
|
+
# status: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
2095
|
+
# lane: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
2096
|
+
# component: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
2097
|
+
# priority: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
2098
|
+
# workflow: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"`;
|
|
2099
|
+
|
|
2100
|
+
// --- review section ---
|
|
2101
|
+
const reviewSection = safeReview
|
|
2102
|
+
? `review:
|
|
2103
|
+
tech_stack: "${safeReview.techStack}"
|
|
2104
|
+
context: |
|
|
2105
|
+
${safeReview.context.split("\n").join("\n ")}`
|
|
2106
|
+
: `# review:
|
|
2107
|
+
# tech_stack: "Node.js, TypeScript, PostgreSQL"
|
|
2108
|
+
# context: |
|
|
2109
|
+
# Describe your repo structure and any unusual conventions here.`;
|
|
2110
|
+
|
|
2111
|
+
// --- verification section ---
|
|
2112
|
+
const verificationSection = safeVerification
|
|
2113
|
+
? `verification:
|
|
2114
|
+
health_endpoint: "${safeVerification.healthEndpoint}"
|
|
2115
|
+
health_patterns:
|
|
2116
|
+
- '"status": "ok"'`
|
|
2117
|
+
: `# verification:
|
|
2118
|
+
# health_endpoint: "https://api.${repo}.io/health"
|
|
2119
|
+
# health_patterns:
|
|
2120
|
+
# - '"status": "ok"'`;
|
|
2121
|
+
|
|
2122
|
+
return `# forge.yaml — ForgeDock Configuration
|
|
2123
|
+
#
|
|
2124
|
+
# Generated by: npx forgedock init
|
|
2125
|
+
# Edit this file with your project details.
|
|
2126
|
+
#
|
|
2127
|
+
# Required sections: project, paths, branches
|
|
2128
|
+
# Optional sections: repos, project_board, services, review, verification
|
|
2129
|
+
#
|
|
2130
|
+
# See docs/CONFIG.md for full reference.
|
|
2131
|
+
|
|
2132
|
+
# =============================================================================
|
|
2133
|
+
# PROJECT (REQUIRED)
|
|
2134
|
+
# =============================================================================
|
|
2135
|
+
|
|
2136
|
+
project:
|
|
2137
|
+
name: "${projectName}"
|
|
2138
|
+
owner: "${owner}"
|
|
2139
|
+
repo: "${repo}"
|
|
2140
|
+
description: "${description}"
|
|
2141
|
+
|
|
2142
|
+
# =============================================================================
|
|
2143
|
+
# PATHS (REQUIRED)
|
|
2144
|
+
# =============================================================================
|
|
2145
|
+
|
|
2146
|
+
paths:
|
|
2147
|
+
root: "${root}"
|
|
2148
|
+
worktree_base: "${worktreeBase}"
|
|
2149
|
+
|
|
2150
|
+
# =============================================================================
|
|
2151
|
+
# BRANCHES (REQUIRED)
|
|
2152
|
+
# =============================================================================
|
|
2153
|
+
|
|
2154
|
+
branches:
|
|
2155
|
+
default: "${defaultBranch}"
|
|
2156
|
+
staging: "${stagingBranch}"
|
|
2157
|
+
feature_pattern: "milestone/{slug}"
|
|
2158
|
+
|
|
2159
|
+
# =============================================================================
|
|
2160
|
+
# REPOS (OPTIONAL)
|
|
2161
|
+
# Multi-repo configuration. Remove the # to enable.
|
|
2162
|
+
# =============================================================================
|
|
2163
|
+
|
|
2164
|
+
${reposSection}
|
|
2165
|
+
|
|
2166
|
+
# =============================================================================
|
|
2167
|
+
# PROJECT BOARD (OPTIONAL)
|
|
2168
|
+
# GitHub Projects v2 integration.
|
|
2169
|
+
# To find IDs: gh project list --owner ${owner}
|
|
2170
|
+
# =============================================================================
|
|
2171
|
+
|
|
2172
|
+
${projectBoardSection}
|
|
2173
|
+
|
|
2174
|
+
# =============================================================================
|
|
2175
|
+
# REVIEW (OPTIONAL)
|
|
2176
|
+
# Context injected into review agent prompts.
|
|
2177
|
+
# =============================================================================
|
|
2178
|
+
|
|
2179
|
+
${reviewSection}
|
|
2180
|
+
|
|
2181
|
+
# =============================================================================
|
|
2182
|
+
# VERIFICATION (OPTIONAL)
|
|
2183
|
+
# Health-check patterns for quality gate and validate commands.
|
|
2184
|
+
# =============================================================================
|
|
2185
|
+
|
|
2186
|
+
${verificationSection}
|
|
2187
|
+
|
|
2188
|
+
# =============================================================================
|
|
2189
|
+
# BOT (OPTIONAL)
|
|
2190
|
+
# GitHub App bot identity for pipeline operations.
|
|
2191
|
+
# Credentials are stored separately in ~/.forgedock/credentials.json — not here.
|
|
2192
|
+
# Run: npx forgedock bot setup to configure interactively.
|
|
2193
|
+
# Run: npx forgedock bot status to verify.
|
|
2194
|
+
# =============================================================================
|
|
2195
|
+
|
|
2196
|
+
# bot:
|
|
2197
|
+
# app_id: 12345
|
|
2198
|
+
# installation_id: 67890
|
|
2199
|
+
# private_key_path: "~/.forgedock/forgedock-bot.private-key.pem"
|
|
2200
|
+
# # When configured, pipeline commands use the app token instead of personal token.
|
|
2201
|
+
# # Bot-authored comments include <!-- forgedock-bot --> for audit filtering.
|
|
2202
|
+
`;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
/** Write forge.yaml to disk. Accepts all params for buildForgeYamlContent plus outputPath. */
|
|
2206
|
+
function _writeForgeYaml(opts) {
|
|
2207
|
+
const content = buildForgeYamlContent(opts);
|
|
2208
|
+
writeFileSync(opts.outputPath, content, "utf-8");
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
/** Print next-steps guidance after forge.yaml is written. */
|
|
2212
|
+
function _printNextSteps({ remoteDetected }) {
|
|
2213
|
+
console.log(`${BOLD}Next steps:${RESET}`);
|
|
2214
|
+
if (!remoteDetected) {
|
|
2215
|
+
console.log(` ${YELLOW}!${RESET} Edit ${CYAN}forge.yaml${RESET} — set project.owner and project.repo`);
|
|
2216
|
+
}
|
|
2217
|
+
console.log(` 1. Add ${CYAN}forge.yaml${RESET} to ${CYAN}.gitignore${RESET} if it contains sensitive paths`);
|
|
2218
|
+
console.log(` 2. Run ${CYAN}/forgedock-init${RESET} inside Claude Code for guided AI-powered setup`);
|
|
2219
|
+
console.log("");
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
// ---------------------------------------------------------------------------
|
|
2223
|
+
// Config Validation
|
|
2224
|
+
// ---------------------------------------------------------------------------
|
|
2225
|
+
|
|
2226
|
+
/**
|
|
2227
|
+
* Parse a simple top-level YAML key from a forge.yaml string.
|
|
2228
|
+
* Only handles the specific patterns used in forge.yaml required sections.
|
|
2229
|
+
*
|
|
2230
|
+
* @param {string} content - File content
|
|
2231
|
+
* @param {string} key - Dotted key, e.g. "project.owner"
|
|
2232
|
+
* @returns {string} - Trimmed value with surrounding quotes stripped, or empty string if not found
|
|
2233
|
+
*
|
|
2234
|
+
* @security Values returned by this function are UNTRUSTED user-controlled strings from forge.yaml.
|
|
2235
|
+
* - Callers MUST pass returned values as discrete arguments to execFileSync (args array) — never
|
|
2236
|
+
* interpolate into an execSync shell command string.
|
|
2237
|
+
* - execFileSync does not invoke a shell, so metacharacters in values are treated literally.
|
|
2238
|
+
* - When interpolation into a string argument is unavoidable (e.g. GraphQL query fields),
|
|
2239
|
+
* validate the value against a strict allowlist regex before use.
|
|
2240
|
+
*/
|
|
2241
|
+
function _parseYamlKey(content, key) {
|
|
2242
|
+
const parts = key.split(".");
|
|
2243
|
+
const lines = content.split("\n");
|
|
2244
|
+
|
|
2245
|
+
if (parts.length === 2) {
|
|
2246
|
+
const [section, field] = parts;
|
|
2247
|
+
let inSection = false;
|
|
2248
|
+
for (const line of lines) {
|
|
2249
|
+
if (/^[a-z_]+:/.test(line)) {
|
|
2250
|
+
inSection = line.startsWith(`${section}:`);
|
|
2251
|
+
continue;
|
|
2252
|
+
}
|
|
2253
|
+
if (inSection && line.startsWith(` ${field}:`)) {
|
|
2254
|
+
const raw = line.replace(` ${field}:`, "").trim();
|
|
2255
|
+
return raw.replace(/^["']|["']$/g, "");
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
return "";
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
/**
|
|
2263
|
+
* Check whether a YAML section is active (uncommented) in the content.
|
|
2264
|
+
* @param {string} content
|
|
2265
|
+
* @param {string} section - top-level section name, e.g. "project_board"
|
|
2266
|
+
* @returns {boolean}
|
|
2267
|
+
*/
|
|
2268
|
+
function _sectionActive(content, section) {
|
|
2269
|
+
return content.split("\n").some((line) => line === `${section}:`);
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
/**
|
|
2273
|
+
* Extract all satellite repo values from an active repos: section.
|
|
2274
|
+
* Returns an array of "owner/repo" strings from lines like:
|
|
2275
|
+
* repo: "owner/satellite-repo"
|
|
2276
|
+
* under the satellites: list (skipping the default: sub-key).
|
|
2277
|
+
*
|
|
2278
|
+
* @param {string} content
|
|
2279
|
+
* @returns {string[]}
|
|
2280
|
+
*/
|
|
2281
|
+
function _parseSatelliteRepos(content) {
|
|
2282
|
+
const lines = content.split("\n");
|
|
2283
|
+
const repos = [];
|
|
2284
|
+
let inRepos = false;
|
|
2285
|
+
let inSatellites = false;
|
|
2286
|
+
|
|
2287
|
+
for (const line of lines) {
|
|
2288
|
+
if (/^[a-z_]+:/.test(line)) {
|
|
2289
|
+
inRepos = line.startsWith("repos:");
|
|
2290
|
+
inSatellites = false;
|
|
2291
|
+
continue;
|
|
2292
|
+
}
|
|
2293
|
+
if (!inRepos) continue;
|
|
2294
|
+
|
|
2295
|
+
if (line.trim() === "default:") { continue; }
|
|
2296
|
+
if (line.trim() === "satellites:") { inSatellites = true; continue; }
|
|
2297
|
+
|
|
2298
|
+
if (inSatellites && /^\s{6,}repo:/.test(line)) {
|
|
2299
|
+
const raw = line.replace(/^\s+repo:\s*/, "").replace(/^["']|["']$/g, "").trim();
|
|
2300
|
+
if (raw) repos.push(raw);
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
return repos;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
/**
|
|
2307
|
+
* Validate the forge.yaml at the given path.
|
|
2308
|
+
*
|
|
2309
|
+
* Runs a series of checks and renders a summary box.
|
|
2310
|
+
* Never throws — all check failures are surfaced as warnings/errors in the output.
|
|
2311
|
+
*
|
|
2312
|
+
* @param {string} forgeYamlPath - Absolute path to forge.yaml
|
|
2313
|
+
* @returns {Promise<{ passed: boolean, checks: Array<{label: string, status: 'ok'|'warn'|'error', note: string}> }>}
|
|
2314
|
+
*/
|
|
2315
|
+
async function validate(forgeYamlPath) {
|
|
2316
|
+
/** @type {Array<{label: string, status: 'ok'|'warn'|'error', note: string}>} */
|
|
2317
|
+
const checks = [];
|
|
2318
|
+
|
|
2319
|
+
// 1. Read forge.yaml
|
|
2320
|
+
if (!existsSync(forgeYamlPath)) {
|
|
2321
|
+
checks.push({ label: "forge.yaml found", status: "error", note: `Not found: ${forgeYamlPath}` });
|
|
2322
|
+
_renderValidationSummary(checks);
|
|
2323
|
+
return { passed: false, checks };
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
let content;
|
|
2327
|
+
try {
|
|
2328
|
+
content = readFileSync(forgeYamlPath, "utf-8");
|
|
2329
|
+
} catch (err) {
|
|
2330
|
+
checks.push({ label: "forge.yaml found", status: "error", note: `Cannot read: ${err.message}` });
|
|
2331
|
+
_renderValidationSummary(checks);
|
|
2332
|
+
return { passed: false, checks };
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
checks.push({ label: "forge.yaml found", status: "ok", note: forgeYamlPath });
|
|
2336
|
+
|
|
2337
|
+
// 2. Required fields
|
|
2338
|
+
const PLACEHOLDERS = new Set(["your-github-org", "your-repo-name", "", "your-org", "your-repo"]);
|
|
2339
|
+
|
|
2340
|
+
const owner = _parseYamlKey(content, "project.owner");
|
|
2341
|
+
const repo = _parseYamlKey(content, "project.repo");
|
|
2342
|
+
const root = _parseYamlKey(content, "paths.root");
|
|
2343
|
+
const worktreeBase = _parseYamlKey(content, "paths.worktree_base");
|
|
2344
|
+
const defaultBranch = _parseYamlKey(content, "branches.default");
|
|
2345
|
+
|
|
2346
|
+
const missingFields = [];
|
|
2347
|
+
if (!owner || PLACEHOLDERS.has(owner)) missingFields.push("project.owner");
|
|
2348
|
+
if (!repo || PLACEHOLDERS.has(repo)) missingFields.push("project.repo");
|
|
2349
|
+
if (!root) missingFields.push("paths.root");
|
|
2350
|
+
if (!defaultBranch) missingFields.push("branches.default");
|
|
2351
|
+
|
|
2352
|
+
if (missingFields.length === 0) {
|
|
2353
|
+
checks.push({ label: "Required fields", status: "ok", note: `${owner}/${repo}` });
|
|
2354
|
+
} else {
|
|
2355
|
+
checks.push({
|
|
2356
|
+
label: "Required fields",
|
|
2357
|
+
status: "error",
|
|
2358
|
+
note: `Missing or placeholder: ${missingFields.join(", ")}`,
|
|
2359
|
+
});
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
// 3. paths.root exists
|
|
2363
|
+
if (root) {
|
|
2364
|
+
if (existsSync(root)) {
|
|
2365
|
+
checks.push({ label: "paths.root exists", status: "ok", note: root });
|
|
2366
|
+
} else {
|
|
2367
|
+
checks.push({ label: "paths.root exists", status: "warn", note: `Directory not found: ${root}` });
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
// 4. Create worktree_base if missing
|
|
2372
|
+
if (worktreeBase) {
|
|
2373
|
+
if (existsSync(worktreeBase)) {
|
|
2374
|
+
checks.push({ label: "worktree_base exists", status: "ok", note: worktreeBase });
|
|
2375
|
+
} else {
|
|
2376
|
+
try {
|
|
2377
|
+
await mkdir(worktreeBase, { recursive: true });
|
|
2378
|
+
checks.push({ label: "worktree_base exists", status: "ok", note: `Created: ${worktreeBase}` });
|
|
2379
|
+
} catch (err) {
|
|
2380
|
+
checks.push({ label: "worktree_base exists", status: "warn", note: `Cannot create: ${err.message}` });
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
// 5. GitHub repo access
|
|
2386
|
+
if (owner && repo && !PLACEHOLDERS.has(owner) && !PLACEHOLDERS.has(repo)) {
|
|
2387
|
+
try {
|
|
2388
|
+
execFileSync("gh", ["repo", "view", `${owner}/${repo}`, "--json", "name"], {
|
|
2389
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2390
|
+
timeout: 10000,
|
|
2391
|
+
});
|
|
2392
|
+
checks.push({ label: "GitHub repo accessible", status: "ok", note: `${owner}/${repo}` });
|
|
2393
|
+
} catch {
|
|
2394
|
+
checks.push({
|
|
2395
|
+
label: "GitHub repo accessible",
|
|
2396
|
+
status: "warn",
|
|
2397
|
+
note: `Cannot access ${owner}/${repo} — check owner/repo or run: gh auth login`,
|
|
2398
|
+
});
|
|
2399
|
+
}
|
|
2400
|
+
} else {
|
|
2401
|
+
checks.push({ label: "GitHub repo accessible", status: "warn", note: "Skipped — owner/repo not set" });
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
// 6. Branch existence on remote
|
|
2405
|
+
const stagingBranch = _parseYamlKey(content, "branches.staging");
|
|
2406
|
+
const branchesToCheck = [...new Set([defaultBranch, stagingBranch].filter(Boolean))];
|
|
2407
|
+
|
|
2408
|
+
if (root && existsSync(root) && branchesToCheck.length > 0) {
|
|
2409
|
+
for (const branch of branchesToCheck) {
|
|
2410
|
+
try {
|
|
2411
|
+
const result = execFileSync("git", ["ls-remote", "--heads", "origin", branch], {
|
|
2412
|
+
cwd: root,
|
|
2413
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2414
|
+
encoding: "utf-8",
|
|
2415
|
+
timeout: 10000,
|
|
2416
|
+
});
|
|
2417
|
+
if (result.trim()) {
|
|
2418
|
+
checks.push({ label: `Branch: ${branch}`, status: "ok", note: "Exists on remote" });
|
|
2419
|
+
} else {
|
|
2420
|
+
checks.push({ label: `Branch: ${branch}`, status: "warn", note: "Not found on remote" });
|
|
2421
|
+
}
|
|
2422
|
+
} catch {
|
|
2423
|
+
checks.push({ label: `Branch: ${branch}`, status: "warn", note: "Cannot verify — git error" });
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
// 7. Project board validation
|
|
2429
|
+
if (_sectionActive(content, "project_board")) {
|
|
2430
|
+
const projectId = _parseYamlKey(content, "project_board.project_id");
|
|
2431
|
+
if (!projectId || projectId.includes("xxxx")) {
|
|
2432
|
+
checks.push({
|
|
2433
|
+
label: "Project board configured",
|
|
2434
|
+
status: "warn",
|
|
2435
|
+
note: "project_id is a placeholder — run /forgedock-init to configure",
|
|
2436
|
+
});
|
|
2437
|
+
} else if (!/^PVT_[A-Za-z0-9_=\-]+$/.test(projectId)) {
|
|
2438
|
+
checks.push({
|
|
2439
|
+
label: "Project board configured",
|
|
2440
|
+
status: "error",
|
|
2441
|
+
note: `project_id must match /^PVT_[A-Za-z0-9_=\\-]+$/ (got: ${projectId.slice(0, 12)}...)`,
|
|
2442
|
+
});
|
|
2443
|
+
} else {
|
|
2444
|
+
try {
|
|
2445
|
+
// Use -F (typed variable) to pass projectId as a GraphQL variable rather than
|
|
2446
|
+
// interpolating it into the query string, even though the regex above already
|
|
2447
|
+
// validates it to safe characters.
|
|
2448
|
+
const result = execFileSync(
|
|
2449
|
+
"gh",
|
|
2450
|
+
[
|
|
2451
|
+
"api", "graphql",
|
|
2452
|
+
"-F", `id=${projectId}`,
|
|
2453
|
+
"-f", "query=query($id: ID!) { node(id: $id) { id __typename } }",
|
|
2454
|
+
],
|
|
2455
|
+
{ stdio: ["pipe", "pipe", "pipe"], encoding: "utf-8", timeout: 10000 }
|
|
2456
|
+
);
|
|
2457
|
+
const parsed = JSON.parse(result);
|
|
2458
|
+
if (parsed?.data?.node?.id) {
|
|
2459
|
+
checks.push({ label: "Project board configured", status: "ok", note: `${projectId.slice(0, 16)}... resolves` });
|
|
2460
|
+
} else {
|
|
2461
|
+
checks.push({ label: "Project board configured", status: "warn", note: "project_id may be invalid — verify with: gh project list" });
|
|
2462
|
+
}
|
|
2463
|
+
} catch {
|
|
2464
|
+
checks.push({ label: "Project board configured", status: "warn", note: "Cannot verify project_id — gh error" });
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
} else {
|
|
2468
|
+
checks.push({ label: "Project board", status: "warn", note: "Not configured (optional)" });
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
// 8. Satellite repo access
|
|
2472
|
+
if (_sectionActive(content, "repos")) {
|
|
2473
|
+
const satellites = _parseSatelliteRepos(content);
|
|
2474
|
+
if (satellites.length === 0) {
|
|
2475
|
+
checks.push({ label: "Satellite repos", status: "warn", note: "repos: section active but no satellites found" });
|
|
2476
|
+
} else {
|
|
2477
|
+
for (const sat of satellites) {
|
|
2478
|
+
try {
|
|
2479
|
+
execFileSync("gh", ["repo", "view", sat, "--json", "name"], {
|
|
2480
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2481
|
+
timeout: 10000,
|
|
2482
|
+
});
|
|
2483
|
+
checks.push({ label: `Satellite: ${sat}`, status: "ok", note: "Accessible" });
|
|
2484
|
+
} catch {
|
|
2485
|
+
checks.push({ label: `Satellite: ${sat}`, status: "warn", note: "Cannot access — check repo name or gh auth" });
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
} else {
|
|
2490
|
+
checks.push({ label: "Satellite repos", status: "warn", note: "Not configured (optional)" });
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
_renderValidationSummary(checks);
|
|
2494
|
+
const hasError = checks.some((c) => c.status === "error");
|
|
2495
|
+
return { passed: !hasError, checks };
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
/**
|
|
2499
|
+
* Render the validation summary box to stdout.
|
|
2500
|
+
* @param {Array<{label: string, status: 'ok'|'warn'|'error', note: string}>} checks
|
|
2501
|
+
*/
|
|
2502
|
+
function _renderValidationSummary(checks) {
|
|
2503
|
+
const lines = [""];
|
|
2504
|
+
|
|
2505
|
+
for (const check of checks) {
|
|
2506
|
+
let icon;
|
|
2507
|
+
let labelFn;
|
|
2508
|
+
if (check.status === "ok") {
|
|
2509
|
+
icon = green("✓");
|
|
2510
|
+
labelFn = (s) => s;
|
|
2511
|
+
} else if (check.status === "warn") {
|
|
2512
|
+
icon = yellow("!");
|
|
2513
|
+
labelFn = yellow;
|
|
2514
|
+
} else {
|
|
2515
|
+
icon = red("✗");
|
|
2516
|
+
labelFn = red;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
const noteStr = check.note ? ` ${dim("(" + check.note + ")")}` : "";
|
|
2520
|
+
lines.push(` ${icon} ${labelFn(check.label)}${noteStr}`);
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
const hasErrors = checks.some((c) => c.status === "error");
|
|
2524
|
+
const boardOk = checks.some((c) => c.label === "Project board configured" && c.status === "ok");
|
|
2525
|
+
|
|
2526
|
+
lines.push("");
|
|
2527
|
+
lines.push(` ${bold("Next steps:")}`);
|
|
2528
|
+
if (hasErrors) {
|
|
2529
|
+
lines.push(` ${dim("•")} Edit ${cyan("forge.yaml")} to fix the errors above`);
|
|
2530
|
+
}
|
|
2531
|
+
if (!boardOk) {
|
|
2532
|
+
lines.push(` ${dim("•")} Run ${cyan("/forgedock-init")} for AI-powered optional section setup`);
|
|
2533
|
+
}
|
|
2534
|
+
lines.push(` ${dim("•")} Run ${cyan("/work-on next")} to start your first task`);
|
|
2535
|
+
lines.push("");
|
|
2536
|
+
|
|
2537
|
+
process.stdout.write(box(lines, { title: "Config Validation" }));
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
// ---------------------------------------------------------------------------
|
|
2541
|
+
// Bot credentials — store in ~/.forgedock/credentials.json (NOT forge.yaml)
|
|
2542
|
+
// ---------------------------------------------------------------------------
|
|
2543
|
+
|
|
2544
|
+
const FORGEDOCK_HOME = join(process.env.HOME, ".forgedock");
|
|
2545
|
+
const CREDENTIALS_FILE = join(FORGEDOCK_HOME, "credentials.json");
|
|
2546
|
+
|
|
2547
|
+
/**
|
|
2548
|
+
* Save bot credentials to ~/.forgedock/credentials.json.
|
|
2549
|
+
* The private key itself is NOT stored — only the path.
|
|
2550
|
+
*
|
|
2551
|
+
* @param {{ appId: string, installationId: string, privateKeyPath: string }} creds
|
|
2552
|
+
*/
|
|
2553
|
+
async function saveBotCredentials(creds) {
|
|
2554
|
+
await mkdir(FORGEDOCK_HOME, { recursive: true, mode: 0o700 });
|
|
2555
|
+
// Tighten permissions on pre-existing installs: fs.mkdir({ recursive: true }) ignores
|
|
2556
|
+
// mode when the directory already exists (Linux kernel behaviour). chmodSync enforces
|
|
2557
|
+
// 0o700 unconditionally, matching the intent of the mode option above.
|
|
2558
|
+
chmodSync(FORGEDOCK_HOME, 0o700);
|
|
2559
|
+
const existing = loadBotCredentials() ?? {};
|
|
2560
|
+
const updated = { ...existing, bot: creds };
|
|
2561
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(updated, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
2562
|
+
// Tighten permissions on files created before this fix (mode is only applied at creation time)
|
|
2563
|
+
chmodSync(CREDENTIALS_FILE, 0o600);
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
/**
|
|
2567
|
+
* Load bot credentials from ~/.forgedock/credentials.json.
|
|
2568
|
+
* Returns null if the file is absent or malformed.
|
|
2569
|
+
*
|
|
2570
|
+
* @returns {{ appId: string, installationId: string, privateKeyPath: string } | null}
|
|
2571
|
+
*/
|
|
2572
|
+
function loadBotCredentials() {
|
|
2573
|
+
try {
|
|
2574
|
+
const raw = readFileSync(CREDENTIALS_FILE, "utf-8");
|
|
2575
|
+
const parsed = JSON.parse(raw);
|
|
2576
|
+
return parsed.bot ?? null;
|
|
2577
|
+
} catch {
|
|
2578
|
+
return null;
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
// ---------------------------------------------------------------------------
|
|
2583
|
+
// GitHub App JWT — generates a short-lived RS256 JWT for App-level API calls
|
|
2584
|
+
// ---------------------------------------------------------------------------
|
|
2585
|
+
|
|
2586
|
+
/**
|
|
2587
|
+
* Generate a GitHub App JWT (valid for 10 minutes).
|
|
2588
|
+
*
|
|
2589
|
+
* @param {string} appId - GitHub App ID (numeric string)
|
|
2590
|
+
* @param {string} privateKey - PEM-encoded PKCS#8 or PKCS#1 RSA private key content
|
|
2591
|
+
* @returns {string} Signed JWT
|
|
2592
|
+
*/
|
|
2593
|
+
function generateAppJwt(appId, privateKey) {
|
|
2594
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2595
|
+
const payload = {
|
|
2596
|
+
iat: now - 60, // issued 60s ago to account for clock skew
|
|
2597
|
+
exp: now + 600, // expires in 10 minutes (GitHub max)
|
|
2598
|
+
iss: appId,
|
|
2599
|
+
};
|
|
2600
|
+
|
|
2601
|
+
const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
|
|
2602
|
+
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
2603
|
+
const unsigned = `${header}.${body}`;
|
|
2604
|
+
|
|
2605
|
+
const sign = createSign("RSA-SHA256");
|
|
2606
|
+
sign.update(unsigned);
|
|
2607
|
+
const signature = sign.sign(privateKey, "base64url");
|
|
2608
|
+
|
|
2609
|
+
return `${unsigned}.${signature}`;
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
// ---------------------------------------------------------------------------
|
|
2613
|
+
// Bot — connect existing GitHub App
|
|
2614
|
+
// ---------------------------------------------------------------------------
|
|
2615
|
+
|
|
2616
|
+
/**
|
|
2617
|
+
* Interactive flow: prompt for App ID, private key path, installation ID.
|
|
2618
|
+
* Validates credentials against the GitHub API before saving.
|
|
2619
|
+
*/
|
|
2620
|
+
async function connectExistingBot() {
|
|
2621
|
+
console.log("");
|
|
2622
|
+
console.log(box(
|
|
2623
|
+
[
|
|
2624
|
+
`${bold("Connect an existing GitHub App")}`,
|
|
2625
|
+
"",
|
|
2626
|
+
"You'll need:",
|
|
2627
|
+
` • App ID (GitHub → Settings → Developer settings → GitHub Apps → your app)`,
|
|
2628
|
+
` • Private key file (.pem downloaded from app settings)`,
|
|
2629
|
+
` • Installation ID (GitHub → your org → Settings → Installed Apps → your app → configure → URL)`,
|
|
2630
|
+
].join("\n"),
|
|
2631
|
+
{ title: "Connect GitHub App" }
|
|
2632
|
+
));
|
|
2633
|
+
console.log("");
|
|
2634
|
+
|
|
2635
|
+
const appId = await input("App ID (numeric):", "");
|
|
2636
|
+
if (!appId || !/^\d+$/.test(appId.trim())) {
|
|
2637
|
+
console.log(` ${RED}Invalid App ID — must be a numeric string.${RESET}`);
|
|
2638
|
+
return false;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
const privateKeyPath = await input("Private key path (.pem):", "");
|
|
2642
|
+
const HOME = process.env.HOME ?? "";
|
|
2643
|
+
const expanded = privateKeyPath.trim().replace(/^~/, HOME);
|
|
2644
|
+
const resolvedKeyPath = resolve(expanded);
|
|
2645
|
+
if (privateKeyPath.trim().startsWith("~") && HOME && !resolvedKeyPath.startsWith(HOME + "/") && resolvedKeyPath !== HOME) {
|
|
2646
|
+
console.log(` ${RED}Invalid path: cannot traverse outside home directory.${RESET}`);
|
|
2647
|
+
return false;
|
|
2648
|
+
}
|
|
2649
|
+
if (!existsSync(resolvedKeyPath)) {
|
|
2650
|
+
console.log(` ${RED}File not found: ${resolvedKeyPath}${RESET}`);
|
|
2651
|
+
return false;
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
const installationId = await input("Installation ID (numeric):", "");
|
|
2655
|
+
if (!installationId || !/^\d+$/.test(installationId.trim())) {
|
|
2656
|
+
console.log(` ${RED}Invalid Installation ID — must be a numeric string.${RESET}`);
|
|
2657
|
+
return false;
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
// Validate credentials with GitHub API
|
|
2661
|
+
console.log("");
|
|
2662
|
+
console.log(` Validating credentials…`);
|
|
2663
|
+
let privateKey;
|
|
2664
|
+
try {
|
|
2665
|
+
privateKey = readFileSync(resolvedKeyPath, "utf-8");
|
|
2666
|
+
} catch (err) {
|
|
2667
|
+
console.log(` ${RED}Cannot read private key: ${err.message}${RESET}`);
|
|
2668
|
+
return false;
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
try {
|
|
2672
|
+
const jwt = generateAppJwt(appId.trim(), privateKey);
|
|
2673
|
+
const resp = await fetch("https://api.github.com/app", {
|
|
2674
|
+
headers: {
|
|
2675
|
+
Authorization: `Bearer ${jwt}`,
|
|
2676
|
+
Accept: "application/vnd.github+json",
|
|
2677
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
2678
|
+
},
|
|
2679
|
+
});
|
|
2680
|
+
if (!resp.ok) {
|
|
2681
|
+
const body = await resp.json().catch(() => ({}));
|
|
2682
|
+
console.log(` ${RED}GitHub API error ${resp.status}: ${body.message ?? "unknown"}${RESET}`);
|
|
2683
|
+
return false;
|
|
2684
|
+
}
|
|
2685
|
+
const appData = await resp.json();
|
|
2686
|
+
console.log(` ${GREEN}Connected!${RESET} App: ${bold(appData.name)} (id: ${appId.trim()})`);
|
|
2687
|
+
} catch (err) {
|
|
2688
|
+
console.log(` ${RED}Validation failed: ${err.message}${RESET}`);
|
|
2689
|
+
return false;
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
await saveBotCredentials({
|
|
2693
|
+
appId: appId.trim(),
|
|
2694
|
+
installationId: installationId.trim(),
|
|
2695
|
+
privateKeyPath: resolvedKeyPath,
|
|
2696
|
+
});
|
|
2697
|
+
|
|
2698
|
+
console.log(` Credentials saved to ${cyan(CREDENTIALS_FILE)}`);
|
|
2699
|
+
return true;
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
// ---------------------------------------------------------------------------
|
|
2703
|
+
// Bot — status command
|
|
2704
|
+
// ---------------------------------------------------------------------------
|
|
2705
|
+
|
|
2706
|
+
/**
|
|
2707
|
+
* Show bot health, identity, and rate limit usage.
|
|
2708
|
+
* Gracefully handles missing credentials.
|
|
2709
|
+
*/
|
|
2710
|
+
async function botStatus() {
|
|
2711
|
+
console.log("");
|
|
2712
|
+
console.log(`${BOLD}ForgeDock Bot Status${RESET}`);
|
|
2713
|
+
console.log("");
|
|
2714
|
+
|
|
2715
|
+
const creds = loadBotCredentials();
|
|
2716
|
+
if (!creds) {
|
|
2717
|
+
console.log(
|
|
2718
|
+
` ${YELLOW}No bot credentials found.${RESET}\n` +
|
|
2719
|
+
` Run ${cyan("npx forgedock bot setup")} to configure a GitHub App, or\n` +
|
|
2720
|
+
` add credentials to ${cyan(CREDENTIALS_FILE)}.`
|
|
2721
|
+
);
|
|
2722
|
+
console.log("");
|
|
2723
|
+
return;
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
console.log(` App ID: ${cyan(creds.appId)}`);
|
|
2727
|
+
console.log(` Installation ID: ${cyan(creds.installationId)}`);
|
|
2728
|
+
console.log(` Private key: ${cyan(creds.privateKeyPath)}`);
|
|
2729
|
+
console.log("");
|
|
2730
|
+
|
|
2731
|
+
// Check private key file
|
|
2732
|
+
if (!existsSync(creds.privateKeyPath)) {
|
|
2733
|
+
console.log(` ${RED}Private key file not found: ${creds.privateKeyPath}${RESET}`);
|
|
2734
|
+
console.log(` Run ${cyan("npx forgedock bot setup")} to reconfigure.`);
|
|
2735
|
+
console.log("");
|
|
2736
|
+
return;
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
console.log(" Verifying with GitHub API…");
|
|
2740
|
+
let privateKey;
|
|
2741
|
+
try {
|
|
2742
|
+
privateKey = readFileSync(creds.privateKeyPath, "utf-8");
|
|
2743
|
+
} catch (err) {
|
|
2744
|
+
console.log(` ${RED}Cannot read private key: ${err.message}${RESET}`);
|
|
2745
|
+
console.log("");
|
|
2746
|
+
return;
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
try {
|
|
2750
|
+
const jwt = generateAppJwt(creds.appId, privateKey);
|
|
2751
|
+
|
|
2752
|
+
// App identity
|
|
2753
|
+
const appResp = await fetch("https://api.github.com/app", {
|
|
2754
|
+
headers: {
|
|
2755
|
+
Authorization: `Bearer ${jwt}`,
|
|
2756
|
+
Accept: "application/vnd.github+json",
|
|
2757
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
2758
|
+
},
|
|
2759
|
+
});
|
|
2760
|
+
if (!appResp.ok) {
|
|
2761
|
+
const body = await appResp.json().catch(() => ({}));
|
|
2762
|
+
console.log(` ${RED}GitHub API error ${appResp.status}: ${body.message ?? "unknown"}${RESET}`);
|
|
2763
|
+
console.log("");
|
|
2764
|
+
return;
|
|
2765
|
+
}
|
|
2766
|
+
const appData = await appResp.json();
|
|
2767
|
+
console.log(` ${GREEN}Connected${RESET} — App: ${bold(appData.name)} (slug: @${appData.slug})`);
|
|
2768
|
+
|
|
2769
|
+
// Rate limit for app
|
|
2770
|
+
const rlResp = await fetch("https://api.github.com/rate_limit", {
|
|
2771
|
+
headers: {
|
|
2772
|
+
Authorization: `Bearer ${jwt}`,
|
|
2773
|
+
Accept: "application/vnd.github+json",
|
|
2774
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
2775
|
+
},
|
|
2776
|
+
});
|
|
2777
|
+
if (rlResp.ok) {
|
|
2778
|
+
const rl = await rlResp.json();
|
|
2779
|
+
const core = rl.resources?.core;
|
|
2780
|
+
if (core) {
|
|
2781
|
+
const used = core.limit - core.remaining;
|
|
2782
|
+
const resetTime = new Date(core.reset * 1000).toLocaleTimeString();
|
|
2783
|
+
console.log(` Rate limit: ${cyan(core.remaining)}/${core.limit} remaining (${used} used, resets ${resetTime})`);
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
} catch (err) {
|
|
2787
|
+
console.log(` ${RED}Error: ${err.message}${RESET}`);
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
console.log("");
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
// ---------------------------------------------------------------------------
|
|
2794
|
+
// Bot — setup TUI step (called from StepOrchestrator or directly)
|
|
2795
|
+
// ---------------------------------------------------------------------------
|
|
2796
|
+
|
|
2797
|
+
/**
|
|
2798
|
+
* Interactive bot setup step.
|
|
2799
|
+
* Shows options: Create new app / Connect existing / Skip.
|
|
2800
|
+
* Called as an optional step in tuiOnboarding() or via `npx forgedock bot setup`.
|
|
2801
|
+
*/
|
|
2802
|
+
async function botSetup() {
|
|
2803
|
+
console.log("");
|
|
2804
|
+
console.log(box(
|
|
2805
|
+
[
|
|
2806
|
+
`${bold("GitHub Bot Setup")} ${dim("(Optional)")}`,
|
|
2807
|
+
"",
|
|
2808
|
+
"A GitHub App gives ForgeDock its own identity",
|
|
2809
|
+
"for pipeline operations. Benefits:",
|
|
2810
|
+
` ${cyan("•")} Separate audit trail for bot actions`,
|
|
2811
|
+
` ${cyan("•")} Higher API rate limits`,
|
|
2812
|
+
` ${cyan("•")} Webhook-driven automation (future)`,
|
|
2813
|
+
` ${cyan("•")} Multi-user team support`,
|
|
2814
|
+
].join("\n"),
|
|
2815
|
+
{ title: "GitHub Bot" }
|
|
2816
|
+
));
|
|
2817
|
+
console.log("");
|
|
2818
|
+
|
|
2819
|
+
const action = await select("How would you like to set up the bot?", [
|
|
2820
|
+
{ label: "Create new GitHub App (opens browser)", value: "create" },
|
|
2821
|
+
{ label: "Connect existing GitHub App", value: "connect" },
|
|
2822
|
+
{ label: "Skip — use personal token (current default)", value: "skip" },
|
|
2823
|
+
]);
|
|
2824
|
+
|
|
2825
|
+
if (action === "skip") {
|
|
2826
|
+
console.log(
|
|
2827
|
+
` ${dim("Skipped.")} Run ${cyan("npx forgedock bot setup")} later to configure a bot identity.`
|
|
2828
|
+
);
|
|
2829
|
+
return;
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
if (action === "create") {
|
|
2833
|
+
// Build the GitHub App manifest URL — opens the browser-based creation flow
|
|
2834
|
+
const manifestPath = join(dirname(__filename), "github-app-manifest.json");
|
|
2835
|
+
let manifest;
|
|
2836
|
+
try {
|
|
2837
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
2838
|
+
} catch {
|
|
2839
|
+
console.log(` ${RED}Could not read github-app-manifest.json — skipping.${RESET}`);
|
|
2840
|
+
return;
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
const encodedManifest = encodeURIComponent(JSON.stringify(manifest));
|
|
2844
|
+
const createUrl = `https://github.com/settings/apps/new?manifest=${encodedManifest}`;
|
|
2845
|
+
|
|
2846
|
+
console.log("");
|
|
2847
|
+
console.log(` ${bold("Opening GitHub App registration in your browser…")}`);
|
|
2848
|
+
console.log(` URL: ${cyan(createUrl.substring(0, 80))}…`);
|
|
2849
|
+
console.log("");
|
|
2850
|
+
|
|
2851
|
+
// Try to open the browser
|
|
2852
|
+
try {
|
|
2853
|
+
const opener = process.platform === "win32" ? "start" :
|
|
2854
|
+
process.platform === "darwin" ? "open" : "xdg-open";
|
|
2855
|
+
// On Windows, `start "url"` treats the first quoted arg as the window title.
|
|
2856
|
+
// Pass an empty title placeholder so the URL is treated as the target: `start "" "url"`.
|
|
2857
|
+
const cmd = process.platform === "win32"
|
|
2858
|
+
? `start "" "${createUrl}"`
|
|
2859
|
+
: `${opener} "${createUrl}"`;
|
|
2860
|
+
execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] });
|
|
2861
|
+
} catch {
|
|
2862
|
+
console.log(` ${YELLOW}Could not open browser automatically.${RESET}`);
|
|
2863
|
+
console.log(` Please open this URL manually:`);
|
|
2864
|
+
console.log(` ${cyan(createUrl)}`);
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
console.log("");
|
|
2868
|
+
console.log(" After creating the app in your browser:");
|
|
2869
|
+
console.log(` 1. Download the private key (.pem) from the app settings`);
|
|
2870
|
+
console.log(` 2. Note your App ID and Installation ID`);
|
|
2871
|
+
console.log(` 3. Run ${cyan("npx forgedock bot setup")} again and choose \"Connect existing\"`);
|
|
2872
|
+
console.log("");
|
|
2873
|
+
return;
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
// action === "connect"
|
|
2877
|
+
const connected = await connectExistingBot();
|
|
2878
|
+
if (!connected) {
|
|
2879
|
+
throw new Error("Bot connection failed — credentials were not saved.");
|
|
2880
|
+
}
|
|
198
2881
|
}
|
|
199
2882
|
|
|
200
2883
|
function help() {
|
|
@@ -202,31 +2885,352 @@ function help() {
|
|
|
202
2885
|
console.log(`${BOLD}ForgeDock${RESET} — GitHub as a knowledge graph for AI agents`);
|
|
203
2886
|
console.log("");
|
|
204
2887
|
console.log("Usage:");
|
|
205
|
-
console.log(` ${CYAN}npx forgedock${RESET}
|
|
2888
|
+
console.log(` ${CYAN}npx forgedock${RESET} Launch TUI onboarding (interactive)`);
|
|
206
2889
|
console.log(` ${CYAN}npx forgedock install${RESET} Install commands`);
|
|
207
|
-
console.log(` ${CYAN}npx forgedock
|
|
2890
|
+
console.log(` ${CYAN}npx forgedock init${RESET} Generate forge.yaml config for your project`);
|
|
2891
|
+
console.log(` ${CYAN}npx forgedock validate${RESET} Validate forge.yaml configuration`);
|
|
2892
|
+
console.log(` ${CYAN}npx forgedock uninstall${RESET} Remove commands (interactive, with confirmation)`);
|
|
2893
|
+
console.log(` ${CYAN}npx forgedock uninstall --yes${RESET} Remove commands without prompts (non-interactive)`);
|
|
208
2894
|
console.log(` ${CYAN}npx forgedock update${RESET} Pull latest & reinstall`);
|
|
2895
|
+
console.log(` ${CYAN}npx forgedock bot${RESET} Manage GitHub App bot identity`);
|
|
2896
|
+
console.log(` ${CYAN}npx forgedock bot status${RESET} Show bot health and rate limit usage`);
|
|
2897
|
+
console.log(` ${CYAN}npx forgedock bot setup${RESET} Configure GitHub App bot (interactive)`);
|
|
209
2898
|
console.log(` ${CYAN}npx forgedock help${RESET} Show this help`);
|
|
210
2899
|
console.log("");
|
|
211
2900
|
}
|
|
212
2901
|
|
|
213
|
-
|
|
214
|
-
|
|
2902
|
+
// ---------------------------------------------------------------------------
|
|
2903
|
+
// TUI Onboarding — interactive step-based flow (default when no command given)
|
|
2904
|
+
// ---------------------------------------------------------------------------
|
|
2905
|
+
|
|
2906
|
+
async function tuiOnboarding() {
|
|
2907
|
+
splash();
|
|
2908
|
+
|
|
2909
|
+
// Non-TTY fallback: skip TUI and detection, run install directly
|
|
2910
|
+
if (!process.stdout.isTTY) {
|
|
2911
|
+
console.log(dim(" Non-interactive environment detected — running install."));
|
|
2912
|
+
console.log("");
|
|
215
2913
|
await install();
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
2914
|
+
return;
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
// Detect install state before routing into a flow
|
|
2918
|
+
const detection = await detectInstallState();
|
|
2919
|
+
|
|
2920
|
+
// -------------------------------------------------------------------------
|
|
2921
|
+
// Flow: up-to-date
|
|
2922
|
+
// Commands installed, forge.yaml present, same version — show summary
|
|
2923
|
+
// -------------------------------------------------------------------------
|
|
2924
|
+
if (detection.state === "up-to-date") {
|
|
2925
|
+
const versionLabel = detection.installedVersion
|
|
2926
|
+
? `v${detection.installedVersion}`
|
|
2927
|
+
: `v${detection.currentVersion}`;
|
|
2928
|
+
console.log(
|
|
2929
|
+
` ${green("Everything up to date.")} ForgeDock ${versionLabel} is installed and configured.`
|
|
2930
|
+
);
|
|
2931
|
+
console.log("");
|
|
2932
|
+
|
|
2933
|
+
const action = await select("What would you like to do?", [
|
|
2934
|
+
{ label: "Nothing — exit", value: "exit" },
|
|
2935
|
+
{ label: "Reconfigure project (regenerate forge.yaml)", value: "reconfigure" },
|
|
2936
|
+
{ label: "Reinstall commands", value: "reinstall" },
|
|
2937
|
+
]);
|
|
2938
|
+
|
|
2939
|
+
if (action === "reconfigure") {
|
|
2940
|
+
console.log("");
|
|
2941
|
+
await init();
|
|
2942
|
+
} else if (action === "reinstall") {
|
|
2943
|
+
console.log("");
|
|
2944
|
+
await install();
|
|
2945
|
+
}
|
|
2946
|
+
console.log("");
|
|
2947
|
+
return;
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
// -------------------------------------------------------------------------
|
|
2951
|
+
// Flow: update-available
|
|
2952
|
+
// Commands installed, forge.yaml present, newer version running — update
|
|
2953
|
+
// -------------------------------------------------------------------------
|
|
2954
|
+
if (detection.state === "update-available") {
|
|
2955
|
+
console.log(
|
|
2956
|
+
` ${yellow("Update available.")} ` +
|
|
2957
|
+
`Installed: ${dim(`v${detection.installedVersion ?? "unknown"}`)} → ` +
|
|
2958
|
+
`New: ${green(`v${detection.currentVersion}`)}`
|
|
2959
|
+
);
|
|
2960
|
+
console.log("");
|
|
2961
|
+
|
|
2962
|
+
// Snapshot commands before update so we can show a diff after
|
|
2963
|
+
const commandsBeforeUpdate = await getInstalledCommandNames();
|
|
2964
|
+
|
|
2965
|
+
const steps = [
|
|
2966
|
+
{
|
|
2967
|
+
name: "Preflight Checks",
|
|
2968
|
+
optional: false,
|
|
2969
|
+
run: async () => {
|
|
2970
|
+
await runPrerequisiteChecklist();
|
|
2971
|
+
},
|
|
2972
|
+
},
|
|
2973
|
+
{
|
|
2974
|
+
name: "Update Commands",
|
|
2975
|
+
optional: false,
|
|
2976
|
+
run: async () => {
|
|
2977
|
+
await install();
|
|
2978
|
+
},
|
|
2979
|
+
},
|
|
2980
|
+
];
|
|
2981
|
+
|
|
2982
|
+
const orchestrator = new StepOrchestrator(steps);
|
|
2983
|
+
const success = await orchestrator.run();
|
|
2984
|
+
|
|
2985
|
+
if (success) {
|
|
2986
|
+
// Show command diff if any commands were added or removed
|
|
2987
|
+
const commandsAfterUpdate = await getInstalledCommandNames();
|
|
2988
|
+
const diffLines = formatCommandDiff(commandsBeforeUpdate, commandsAfterUpdate);
|
|
2989
|
+
if (diffLines.length > 0) {
|
|
2990
|
+
process.stdout.write(box(diffLines, { title: "Command changes" }));
|
|
2991
|
+
console.log("");
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
console.log(
|
|
2995
|
+
green("Update complete!") +
|
|
2996
|
+
` ForgeDock v${detection.currentVersion} is now active.`
|
|
2997
|
+
);
|
|
2998
|
+
console.log("");
|
|
2999
|
+
} else {
|
|
3000
|
+
console.log("");
|
|
3001
|
+
console.log(
|
|
3002
|
+
yellow("Update incomplete.") +
|
|
3003
|
+
` Re-run ${cyan("npx forgedock")} to try again.`
|
|
3004
|
+
);
|
|
3005
|
+
console.log("");
|
|
3006
|
+
process.exit(1);
|
|
3007
|
+
}
|
|
3008
|
+
return;
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
// -------------------------------------------------------------------------
|
|
3012
|
+
// Flow: config-missing
|
|
3013
|
+
// Commands installed but forge.yaml not found — skip to config step
|
|
3014
|
+
// -------------------------------------------------------------------------
|
|
3015
|
+
if (detection.state === "config-missing") {
|
|
3016
|
+
const versionLabel = detection.installedVersion
|
|
3017
|
+
? `v${detection.installedVersion}`
|
|
3018
|
+
: `v${detection.currentVersion}`;
|
|
3019
|
+
console.log(
|
|
3020
|
+
` ${yellow("Commands installed")} (${versionLabel}) but no ${cyan("forge.yaml")} found in this directory.`
|
|
3021
|
+
);
|
|
3022
|
+
console.log("");
|
|
3023
|
+
|
|
3024
|
+
const steps = [
|
|
3025
|
+
{
|
|
3026
|
+
name: "Project Configuration",
|
|
3027
|
+
optional: true,
|
|
3028
|
+
run: async () => {
|
|
3029
|
+
const shouldInit = await confirm(
|
|
3030
|
+
"Generate forge.yaml for this project?",
|
|
3031
|
+
true
|
|
3032
|
+
);
|
|
3033
|
+
if (shouldInit) {
|
|
3034
|
+
await init();
|
|
3035
|
+
} else {
|
|
3036
|
+
console.log(
|
|
3037
|
+
` ${dim("Skipped.")} Run ${cyan("npx forgedock init")} later to generate forge.yaml.`
|
|
3038
|
+
);
|
|
3039
|
+
}
|
|
3040
|
+
},
|
|
3041
|
+
},
|
|
3042
|
+
{
|
|
3043
|
+
name: "Validate Configuration",
|
|
3044
|
+
optional: true,
|
|
3045
|
+
run: async () => {
|
|
3046
|
+
const forgeYamlPath = join(process.cwd(), "forge.yaml");
|
|
3047
|
+
if (!existsSync(forgeYamlPath)) {
|
|
3048
|
+
console.log(` ${dim("No forge.yaml found — skipping validation.")}`);
|
|
3049
|
+
return;
|
|
3050
|
+
}
|
|
3051
|
+
await validate(forgeYamlPath);
|
|
3052
|
+
},
|
|
3053
|
+
},
|
|
3054
|
+
];
|
|
3055
|
+
|
|
3056
|
+
const orchestrator = new StepOrchestrator(steps);
|
|
3057
|
+
const success = await orchestrator.run();
|
|
3058
|
+
|
|
3059
|
+
if (success) {
|
|
3060
|
+
console.log(
|
|
3061
|
+
green("Done!") +
|
|
3062
|
+
` Run ${cyan("/help")} inside Claude Code to see available commands.`
|
|
3063
|
+
);
|
|
3064
|
+
console.log("");
|
|
3065
|
+
} else {
|
|
3066
|
+
console.log("");
|
|
3067
|
+
console.log(
|
|
3068
|
+
yellow("Setup incomplete.") +
|
|
3069
|
+
` Re-run ${cyan("npx forgedock")} to try again.`
|
|
3070
|
+
);
|
|
3071
|
+
console.log("");
|
|
3072
|
+
process.exit(1);
|
|
3073
|
+
}
|
|
3074
|
+
return;
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
// -------------------------------------------------------------------------
|
|
3078
|
+
// Flow: version-unknown
|
|
3079
|
+
// Commands installed (symlinks found) but package.json unreadable — reinstall
|
|
3080
|
+
// -------------------------------------------------------------------------
|
|
3081
|
+
if (detection.state === "version-unknown") {
|
|
3082
|
+
console.log(
|
|
3083
|
+
` ${yellow("ForgeDock commands are installed")} but the installed version could not be determined.`
|
|
3084
|
+
);
|
|
3085
|
+
console.log(
|
|
3086
|
+
` ${dim("The package.json for the installed copy is missing or unreadable.")}`
|
|
3087
|
+
);
|
|
3088
|
+
console.log("");
|
|
3089
|
+
|
|
3090
|
+
const action = await select("How would you like to proceed?", [
|
|
3091
|
+
{ label: "Reinstall commands (recommended)", value: "reinstall" },
|
|
3092
|
+
{ label: "Nothing — exit", value: "exit" },
|
|
3093
|
+
]);
|
|
3094
|
+
|
|
3095
|
+
if (action === "reinstall") {
|
|
3096
|
+
console.log("");
|
|
3097
|
+
await install();
|
|
3098
|
+
}
|
|
3099
|
+
console.log("");
|
|
3100
|
+
return;
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
// -------------------------------------------------------------------------
|
|
3104
|
+
// Flow: fresh-install (default)
|
|
3105
|
+
// No existing symlinks — run full onboarding
|
|
3106
|
+
// -------------------------------------------------------------------------
|
|
3107
|
+
const steps = [
|
|
3108
|
+
{
|
|
3109
|
+
name: "Preflight Checks",
|
|
3110
|
+
optional: false,
|
|
3111
|
+
run: async () => {
|
|
3112
|
+
await runPrerequisiteChecklist();
|
|
3113
|
+
},
|
|
3114
|
+
},
|
|
3115
|
+
{
|
|
3116
|
+
name: "Install Commands",
|
|
3117
|
+
optional: false,
|
|
3118
|
+
run: async () => {
|
|
3119
|
+
await install();
|
|
3120
|
+
},
|
|
3121
|
+
},
|
|
3122
|
+
{
|
|
3123
|
+
name: "Project Configuration",
|
|
3124
|
+
optional: true,
|
|
3125
|
+
run: async () => {
|
|
3126
|
+
const forgeYamlPath = join(process.cwd(), "forge.yaml");
|
|
3127
|
+
if (existsSync(forgeYamlPath)) {
|
|
3128
|
+
console.log(` ${green("forge.yaml")} already exists — skipping generation.`);
|
|
3129
|
+
return;
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
const shouldInit = await confirm(
|
|
3133
|
+
"No forge.yaml found. Generate one now?",
|
|
3134
|
+
true
|
|
3135
|
+
);
|
|
3136
|
+
if (shouldInit) {
|
|
3137
|
+
await init();
|
|
3138
|
+
} else {
|
|
3139
|
+
console.log(
|
|
3140
|
+
` ${dim("Skipped.")} Run ${cyan("npx forgedock init")} later to generate forge.yaml.`
|
|
3141
|
+
);
|
|
3142
|
+
}
|
|
3143
|
+
},
|
|
3144
|
+
},
|
|
3145
|
+
{
|
|
3146
|
+
name: "Validate Configuration",
|
|
3147
|
+
optional: true,
|
|
3148
|
+
run: async () => {
|
|
3149
|
+
const forgeYamlPath = join(process.cwd(), "forge.yaml");
|
|
3150
|
+
if (!existsSync(forgeYamlPath)) {
|
|
3151
|
+
console.log(` ${dim("No forge.yaml found — skipping validation.")}`);
|
|
3152
|
+
return;
|
|
3153
|
+
}
|
|
3154
|
+
await validate(forgeYamlPath);
|
|
3155
|
+
},
|
|
3156
|
+
},
|
|
3157
|
+
{
|
|
3158
|
+
name: "GitHub Bot Setup",
|
|
3159
|
+
optional: true,
|
|
3160
|
+
run: async () => {
|
|
3161
|
+
await botSetup();
|
|
3162
|
+
},
|
|
3163
|
+
},
|
|
3164
|
+
];
|
|
3165
|
+
|
|
3166
|
+
const orchestrator = new StepOrchestrator(steps);
|
|
3167
|
+
const success = await orchestrator.run();
|
|
3168
|
+
|
|
3169
|
+
if (success) {
|
|
3170
|
+
console.log(
|
|
3171
|
+
green("Setup complete!") +
|
|
3172
|
+
` Run ${cyan("/help")} inside Claude Code to see available commands.`
|
|
3173
|
+
);
|
|
3174
|
+
console.log("");
|
|
3175
|
+
} else {
|
|
3176
|
+
console.log("");
|
|
3177
|
+
console.log(
|
|
3178
|
+
yellow("Setup incomplete.") +
|
|
3179
|
+
` Re-run ${cyan("npx forgedock")} to try again.`
|
|
3180
|
+
);
|
|
3181
|
+
console.log("");
|
|
231
3182
|
process.exit(1);
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
// ---------------------------------------------------------------------------
|
|
3187
|
+
// Command dispatch
|
|
3188
|
+
// ---------------------------------------------------------------------------
|
|
3189
|
+
|
|
3190
|
+
if (!command) {
|
|
3191
|
+
// No arguments — launch TUI onboarding
|
|
3192
|
+
await tuiOnboarding();
|
|
3193
|
+
} else {
|
|
3194
|
+
switch (command) {
|
|
3195
|
+
case "install":
|
|
3196
|
+
await install();
|
|
3197
|
+
break;
|
|
3198
|
+
case "init":
|
|
3199
|
+
await init();
|
|
3200
|
+
break;
|
|
3201
|
+
case "validate": {
|
|
3202
|
+
const forgeYamlPath = join(process.cwd(), "forge.yaml");
|
|
3203
|
+
const result = await validate(forgeYamlPath);
|
|
3204
|
+
if (!result.passed) process.exit(1);
|
|
3205
|
+
break;
|
|
3206
|
+
}
|
|
3207
|
+
case "uninstall":
|
|
3208
|
+
await uninstall();
|
|
3209
|
+
break;
|
|
3210
|
+
case "update":
|
|
3211
|
+
await update();
|
|
3212
|
+
break;
|
|
3213
|
+
case "bot": {
|
|
3214
|
+
const subcommand = args[1];
|
|
3215
|
+
if (!subcommand || subcommand === "status") {
|
|
3216
|
+
await botStatus();
|
|
3217
|
+
} else if (subcommand === "setup") {
|
|
3218
|
+
await botSetup();
|
|
3219
|
+
} else {
|
|
3220
|
+
console.log(`${RED}Unknown bot subcommand: ${subcommand}${RESET}`);
|
|
3221
|
+
console.log(`Usage: ${CYAN}npx forgedock bot [status|setup]${RESET}`);
|
|
3222
|
+
process.exit(1);
|
|
3223
|
+
}
|
|
3224
|
+
break;
|
|
3225
|
+
}
|
|
3226
|
+
case "help":
|
|
3227
|
+
case "--help":
|
|
3228
|
+
case "-h":
|
|
3229
|
+
help();
|
|
3230
|
+
break;
|
|
3231
|
+
default:
|
|
3232
|
+
console.log(`${RED}Unknown command: ${command}${RESET}`);
|
|
3233
|
+
help();
|
|
3234
|
+
process.exit(1);
|
|
3235
|
+
}
|
|
232
3236
|
}
|