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