forgedock 1.0.3 → 1.0.5
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 +1193 -363
- 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,15 +1,51 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import os from "os";
|
|
3
4
|
import { fileURLToPath } from "url";
|
|
4
|
-
import { dirname, join, relative, resolve } from "path";
|
|
5
|
-
import {
|
|
6
|
-
|
|
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";
|
|
17
|
+
import {
|
|
18
|
+
existsSync,
|
|
19
|
+
appendFileSync,
|
|
20
|
+
chmodSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
renameSync,
|
|
24
|
+
unlinkSync,
|
|
25
|
+
} from "fs";
|
|
7
26
|
import { execSync, execFileSync } from "child_process";
|
|
8
27
|
import { createSign } from "crypto";
|
|
9
28
|
import {
|
|
10
|
-
BOLD,
|
|
11
|
-
|
|
12
|
-
|
|
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,
|
|
13
49
|
} from "./tui.mjs";
|
|
14
50
|
|
|
15
51
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -17,14 +53,10 @@ 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
62
|
const command = args[0];
|
|
@@ -36,7 +68,9 @@ const forceYes = args.includes("--yes") || args.includes("-y");
|
|
|
36
68
|
|
|
37
69
|
function getVersion() {
|
|
38
70
|
try {
|
|
39
|
-
const pkg = JSON.parse(
|
|
71
|
+
const pkg = JSON.parse(
|
|
72
|
+
readFileSync(join(FORGE_HOME, "package.json"), "utf-8"),
|
|
73
|
+
);
|
|
40
74
|
return pkg.version || "0.0.0";
|
|
41
75
|
} catch {
|
|
42
76
|
return "0.0.0";
|
|
@@ -257,7 +291,11 @@ class StepOrchestrator {
|
|
|
257
291
|
if (process.stdout.isTTY) process.stdout.write("\x1b[?25h");
|
|
258
292
|
// Restore terminal to cooked mode in case a select/multiSelect prompt was active
|
|
259
293
|
if (process.stdin.isTTY) {
|
|
260
|
-
try {
|
|
294
|
+
try {
|
|
295
|
+
process.stdin.setRawMode(false);
|
|
296
|
+
} catch {
|
|
297
|
+
/* ignore */
|
|
298
|
+
}
|
|
261
299
|
}
|
|
262
300
|
process.exit(130);
|
|
263
301
|
};
|
|
@@ -378,7 +416,7 @@ function checkPrerequisites() {
|
|
|
378
416
|
} catch {
|
|
379
417
|
issues.push(
|
|
380
418
|
`${RED}✗${RESET} GitHub CLI (gh) is not installed.\n` +
|
|
381
|
-
|
|
419
|
+
` Install: ${CYAN}https://cli.github.com${RESET}`,
|
|
382
420
|
);
|
|
383
421
|
}
|
|
384
422
|
|
|
@@ -389,7 +427,7 @@ function checkPrerequisites() {
|
|
|
389
427
|
} catch {
|
|
390
428
|
issues.push(
|
|
391
429
|
`${RED}✗${RESET} GitHub CLI is not authenticated.\n` +
|
|
392
|
-
|
|
430
|
+
` Run: ${CYAN}gh auth login${RESET}`,
|
|
393
431
|
);
|
|
394
432
|
}
|
|
395
433
|
}
|
|
@@ -400,7 +438,7 @@ function checkPrerequisites() {
|
|
|
400
438
|
} catch {
|
|
401
439
|
warnings.push(
|
|
402
440
|
`${YELLOW}!${RESET} Claude Code CLI not found on PATH.\n` +
|
|
403
|
-
|
|
441
|
+
` Install: ${CYAN}https://docs.anthropic.com/en/docs/claude-code${RESET}`,
|
|
404
442
|
);
|
|
405
443
|
}
|
|
406
444
|
|
|
@@ -409,7 +447,7 @@ function checkPrerequisites() {
|
|
|
409
447
|
if (nodeVersion < 18) {
|
|
410
448
|
issues.push(
|
|
411
449
|
`${RED}✗${RESET} Node.js >= 18 required (found ${process.versions.node}).\n` +
|
|
412
|
-
|
|
450
|
+
` Update: ${CYAN}https://nodejs.org${RESET}`,
|
|
413
451
|
);
|
|
414
452
|
}
|
|
415
453
|
|
|
@@ -424,7 +462,7 @@ function checkPrerequisites() {
|
|
|
424
462
|
if (issues.length > 0) {
|
|
425
463
|
console.log(
|
|
426
464
|
`${RED}${BOLD}Blocking issues found.${RESET} ForgeDock requires the GitHub CLI ` +
|
|
427
|
-
|
|
465
|
+
`with authentication to function.\nResolve the issues above, then re-run the command.\n`,
|
|
428
466
|
);
|
|
429
467
|
process.exit(1);
|
|
430
468
|
}
|
|
@@ -451,13 +489,21 @@ async function runPrerequisiteChecklist() {
|
|
|
451
489
|
{
|
|
452
490
|
const s = spinner("Checking GitHub CLI…");
|
|
453
491
|
try {
|
|
454
|
-
const out = execSync("gh --version", {
|
|
492
|
+
const out = execSync("gh --version", {
|
|
493
|
+
encoding: "utf-8",
|
|
494
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
495
|
+
});
|
|
455
496
|
const match = out.match(/gh version (\d+\.\d+\.\d+)/);
|
|
456
497
|
const version = match ? `v${match[1]}` : "installed";
|
|
457
498
|
s.stop("success", `${green("[✓]")} GitHub CLI ${dim(version)}`);
|
|
458
499
|
} catch {
|
|
459
|
-
s.stop(
|
|
460
|
-
|
|
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
|
+
);
|
|
461
507
|
}
|
|
462
508
|
}
|
|
463
509
|
|
|
@@ -468,7 +514,10 @@ async function runPrerequisiteChecklist() {
|
|
|
468
514
|
// gh auth status writes to stderr; capture it via the error path too
|
|
469
515
|
let combined = "";
|
|
470
516
|
try {
|
|
471
|
-
combined = execSync("gh auth status", {
|
|
517
|
+
combined = execSync("gh auth status", {
|
|
518
|
+
encoding: "utf-8",
|
|
519
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
520
|
+
});
|
|
472
521
|
} catch (err) {
|
|
473
522
|
combined = (err.stderr || "") + (err.stdout || "");
|
|
474
523
|
if (!/Logged in/.test(combined)) {
|
|
@@ -491,9 +540,15 @@ async function runPrerequisiteChecklist() {
|
|
|
491
540
|
} catch {
|
|
492
541
|
// Org lookup is best-effort — not required
|
|
493
542
|
}
|
|
494
|
-
s.stop(
|
|
543
|
+
s.stop(
|
|
544
|
+
"success",
|
|
545
|
+
`${green("[✓]")} GitHub Auth ${dim(`logged in as ${username}${orgLabel}`)}`,
|
|
546
|
+
);
|
|
495
547
|
} catch {
|
|
496
|
-
s.stop(
|
|
548
|
+
s.stop(
|
|
549
|
+
"fail",
|
|
550
|
+
`${red("[✗]")} GitHub Auth not authenticated — run: ${cyan("gh auth login")}`,
|
|
551
|
+
);
|
|
497
552
|
throw new Error("GitHub CLI is not authenticated. Run: gh auth login");
|
|
498
553
|
}
|
|
499
554
|
}
|
|
@@ -504,22 +559,36 @@ async function runPrerequisiteChecklist() {
|
|
|
504
559
|
const nodeVersion = process.versions.node;
|
|
505
560
|
const nodeMajor = parseInt(nodeVersion.split(".")[0], 10);
|
|
506
561
|
if (nodeMajor < 18) {
|
|
507
|
-
s.stop(
|
|
508
|
-
|
|
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
|
+
);
|
|
509
569
|
}
|
|
510
|
-
s.stop(
|
|
570
|
+
s.stop(
|
|
571
|
+
"success",
|
|
572
|
+
`${green("[✓]")} Node.js ${dim(`v${nodeVersion} (>= 18 required)`)}`,
|
|
573
|
+
);
|
|
511
574
|
}
|
|
512
575
|
|
|
513
576
|
// ── 4. Git ─────────────────────────────────────────────────────────────────
|
|
514
577
|
{
|
|
515
578
|
const s = spinner("Checking Git…");
|
|
516
579
|
try {
|
|
517
|
-
const out = execSync("git --version", {
|
|
580
|
+
const out = execSync("git --version", {
|
|
581
|
+
encoding: "utf-8",
|
|
582
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
583
|
+
});
|
|
518
584
|
const match = out.match(/git version (\d+\.\d+\.\d+)/);
|
|
519
585
|
const version = match ? `v${match[1]}` : "installed";
|
|
520
586
|
s.stop("success", `${green("[✓]")} Git ${dim(version)}`);
|
|
521
587
|
} catch {
|
|
522
|
-
s.stop(
|
|
588
|
+
s.stop(
|
|
589
|
+
"warn",
|
|
590
|
+
`${yellow("[!]")} Git not found — install: ${cyan("https://git-scm.com")}`,
|
|
591
|
+
);
|
|
523
592
|
// Git is a warning — do NOT throw
|
|
524
593
|
}
|
|
525
594
|
}
|
|
@@ -528,12 +597,18 @@ async function runPrerequisiteChecklist() {
|
|
|
528
597
|
{
|
|
529
598
|
const s = spinner("Checking Claude Code…");
|
|
530
599
|
try {
|
|
531
|
-
const out = execSync("claude --version", {
|
|
600
|
+
const out = execSync("claude --version", {
|
|
601
|
+
encoding: "utf-8",
|
|
602
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
603
|
+
});
|
|
532
604
|
const match = out.trim().match(/(\d+\.\d+\.\d+)/);
|
|
533
605
|
const version = match ? `v${match[1]}` : "installed";
|
|
534
606
|
s.stop("success", `${green("[✓]")} Claude Code ${dim(version)}`);
|
|
535
607
|
} catch {
|
|
536
|
-
s.stop(
|
|
608
|
+
s.stop(
|
|
609
|
+
"warn",
|
|
610
|
+
`${yellow("[!]")} Claude Code not found — install: ${cyan("https://docs.anthropic.com/en/docs/claude-code")}`,
|
|
611
|
+
);
|
|
537
612
|
// Claude Code is a warning — do NOT throw
|
|
538
613
|
}
|
|
539
614
|
}
|
|
@@ -568,52 +643,59 @@ async function findMarkdownFiles(dir) {
|
|
|
568
643
|
*/
|
|
569
644
|
const COMMAND_CATEGORIES = {
|
|
570
645
|
// Pipeline — core orchestration commands
|
|
571
|
-
"work-on":
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
"review-pr":
|
|
581
|
-
"review-pr-agents":
|
|
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",
|
|
582
657
|
"review-pr-staging": "Pipeline",
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
"quality-gate":
|
|
587
|
-
"work-on-monolithic":"Pipeline",
|
|
658
|
+
orchestrate: "Pipeline",
|
|
659
|
+
issue: "Pipeline",
|
|
660
|
+
milestone: "Pipeline",
|
|
661
|
+
"quality-gate": "Pipeline",
|
|
662
|
+
"work-on-monolithic": "Pipeline",
|
|
588
663
|
|
|
589
664
|
// Operations — ongoing automation and monitoring
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
"geo-audit":
|
|
593
|
-
"security-audit":
|
|
594
|
-
"pipeline-health":
|
|
595
|
-
|
|
596
|
-
"audit-agents":
|
|
597
|
-
"qa-sweep":
|
|
598
|
-
"forge-stats":
|
|
599
|
-
"sync-ecosystem":
|
|
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",
|
|
600
675
|
|
|
601
676
|
// Incident — response and recovery
|
|
602
677
|
"incident-response": "Incident",
|
|
603
|
-
|
|
604
|
-
"deploy-info":
|
|
605
|
-
"failure-recon":
|
|
678
|
+
rollback: "Incident",
|
|
679
|
+
"deploy-info": "Incident",
|
|
680
|
+
"failure-recon": "Incident",
|
|
606
681
|
|
|
607
682
|
// Ecosystem — project management utilities
|
|
608
|
-
|
|
609
|
-
|
|
683
|
+
validate: "Ecosystem",
|
|
684
|
+
cleanup: "Ecosystem",
|
|
610
685
|
|
|
611
686
|
// Setup — initial configuration
|
|
612
|
-
"forgedock-init":
|
|
687
|
+
"forgedock-init": "Setup",
|
|
613
688
|
};
|
|
614
689
|
|
|
615
690
|
/** Canonical display order for categories. */
|
|
616
|
-
const CATEGORY_ORDER = [
|
|
691
|
+
const CATEGORY_ORDER = [
|
|
692
|
+
"Pipeline",
|
|
693
|
+
"Operations",
|
|
694
|
+
"Incident",
|
|
695
|
+
"Ecosystem",
|
|
696
|
+
"Setup",
|
|
697
|
+
"Other",
|
|
698
|
+
];
|
|
617
699
|
|
|
618
700
|
// ---------------------------------------------------------------------------
|
|
619
701
|
// Legacy commands (install, uninstall, update, init, help)
|
|
@@ -646,7 +728,9 @@ async function install() {
|
|
|
646
728
|
// Phase 1: Symlink loop with live progress bar
|
|
647
729
|
// -------------------------------------------------------------------------
|
|
648
730
|
|
|
649
|
-
const bar = createProgressBar(files.length, {
|
|
731
|
+
const bar = createProgressBar(files.length, {
|
|
732
|
+
label: " Installing commands...",
|
|
733
|
+
});
|
|
650
734
|
|
|
651
735
|
for (const file of files) {
|
|
652
736
|
const rel = relative(COMMANDS_DIR, file);
|
|
@@ -776,7 +860,9 @@ async function install() {
|
|
|
776
860
|
|
|
777
861
|
if (categoryLines.length > 0) {
|
|
778
862
|
console.log("");
|
|
779
|
-
process.stdout.write(
|
|
863
|
+
process.stdout.write(
|
|
864
|
+
box(["", ...categoryLines, ""], { title: "Commands installed" }),
|
|
865
|
+
);
|
|
780
866
|
}
|
|
781
867
|
|
|
782
868
|
// -------------------------------------------------------------------------
|
|
@@ -791,8 +877,10 @@ async function install() {
|
|
|
791
877
|
"",
|
|
792
878
|
];
|
|
793
879
|
if (copied > 0) {
|
|
794
|
-
summaryLines.splice(
|
|
795
|
-
|
|
880
|
+
summaryLines.splice(
|
|
881
|
+
summaryLines.length - 1,
|
|
882
|
+
0,
|
|
883
|
+
` ${cyan("Copied")} ${bold(String(copied))} ${dim("(symlinks unavailable — files copied instead)")}`,
|
|
796
884
|
);
|
|
797
885
|
}
|
|
798
886
|
process.stdout.write(box(summaryLines, { title: "Summary" }));
|
|
@@ -813,7 +901,11 @@ async function install() {
|
|
|
813
901
|
...conflicts.map((c) => ` ${dim(`rm ~/.claude/commands/${c}`)}`),
|
|
814
902
|
"",
|
|
815
903
|
];
|
|
816
|
-
process.stdout.write(
|
|
904
|
+
process.stdout.write(
|
|
905
|
+
box(conflictLines, {
|
|
906
|
+
title: `${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"}`,
|
|
907
|
+
}),
|
|
908
|
+
);
|
|
817
909
|
}
|
|
818
910
|
|
|
819
911
|
// -------------------------------------------------------------------------
|
|
@@ -822,31 +914,32 @@ async function install() {
|
|
|
822
914
|
|
|
823
915
|
console.log("");
|
|
824
916
|
let forgeHomeSet = false;
|
|
825
|
-
for (const profile of [
|
|
826
|
-
join(process.env.HOME ?? "", ".bashrc"),
|
|
827
|
-
join(process.env.HOME ?? "", ".zshrc"),
|
|
828
|
-
]) {
|
|
917
|
+
for (const profile of [join(HOME, ".bashrc"), join(HOME, ".zshrc")]) {
|
|
829
918
|
if (existsSync(profile)) {
|
|
830
919
|
const content = readFileSync(profile, "utf-8");
|
|
831
920
|
if (!content.includes("FORGE_HOME")) {
|
|
832
921
|
appendFileSync(
|
|
833
922
|
profile,
|
|
834
|
-
`\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${FORGE_HOME}"\n
|
|
923
|
+
`\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${FORGE_HOME}"\n`,
|
|
924
|
+
);
|
|
925
|
+
const profileShort = profile.replace(HOME, "~");
|
|
926
|
+
console.log(
|
|
927
|
+
` ${green("✔")} ${bold("FORGE_HOME")} set in ${cyan(profileShort)}`,
|
|
835
928
|
);
|
|
836
|
-
const profileShort = profile.replace(process.env.HOME ?? "", "~");
|
|
837
|
-
console.log(` ${green("✔")} ${bold("FORGE_HOME")} set in ${cyan(profileShort)}`);
|
|
838
929
|
forgeHomeSet = true;
|
|
839
930
|
}
|
|
840
931
|
}
|
|
841
932
|
}
|
|
842
933
|
|
|
843
934
|
if (!forgeHomeSet) {
|
|
844
|
-
console.log(
|
|
935
|
+
console.log(
|
|
936
|
+
` ${dim("✔")} ${dim("FORGE_HOME already set in shell profile")}`,
|
|
937
|
+
);
|
|
845
938
|
}
|
|
846
939
|
|
|
847
940
|
console.log("");
|
|
848
941
|
console.log(
|
|
849
|
-
`${green("ForgeDock commands are now available as slash commands in any Claude Code session.")}
|
|
942
|
+
`${green("ForgeDock commands are now available as slash commands in any Claude Code session.")}`,
|
|
850
943
|
);
|
|
851
944
|
console.log("");
|
|
852
945
|
|
|
@@ -855,7 +948,7 @@ async function install() {
|
|
|
855
948
|
if (!existsSync(forgeYamlPath)) {
|
|
856
949
|
console.log(`${yellow("No forge.yaml found in current directory.")}`);
|
|
857
950
|
console.log(
|
|
858
|
-
` Run ${cyan("npx forgedock init")} in your project root to generate forge.yaml
|
|
951
|
+
` Run ${cyan("npx forgedock init")} in your project root to generate forge.yaml`,
|
|
859
952
|
);
|
|
860
953
|
console.log("");
|
|
861
954
|
}
|
|
@@ -900,10 +993,7 @@ async function uninstall() {
|
|
|
900
993
|
|
|
901
994
|
// Scan shell profiles for FORGE_HOME block
|
|
902
995
|
const FORGE_HOME_MARKER = "# ForgeDock — autonomous development pipeline";
|
|
903
|
-
const shellProfiles = [
|
|
904
|
-
join(process.env.HOME ?? "", ".bashrc"),
|
|
905
|
-
join(process.env.HOME ?? "", ".zshrc"),
|
|
906
|
-
];
|
|
996
|
+
const shellProfiles = [join(HOME, ".bashrc"), join(HOME, ".zshrc")];
|
|
907
997
|
/** @type {string[]} Profiles that contain the FORGE_HOME block */
|
|
908
998
|
const profilesWithForgeHome = shellProfiles.filter((p) => {
|
|
909
999
|
if (!existsSync(p)) return false;
|
|
@@ -922,8 +1012,14 @@ async function uninstall() {
|
|
|
922
1012
|
// Phase 2: Pre-removal summary
|
|
923
1013
|
// -------------------------------------------------------------------------
|
|
924
1014
|
|
|
925
|
-
if (
|
|
926
|
-
|
|
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
|
+
);
|
|
927
1023
|
console.log("");
|
|
928
1024
|
return;
|
|
929
1025
|
}
|
|
@@ -931,13 +1027,15 @@ async function uninstall() {
|
|
|
931
1027
|
const summaryLines = [""];
|
|
932
1028
|
if (toRemove.length > 0) {
|
|
933
1029
|
const fileLabel = copyMode ? "files (copy-mode install)" : "symlinks";
|
|
934
|
-
summaryLines.push(
|
|
1030
|
+
summaryLines.push(
|
|
1031
|
+
` ${red("Commands")}: ${bold(String(toRemove.length))} ${fileLabel} in ${dim(TARGET_DIR)}`,
|
|
1032
|
+
);
|
|
935
1033
|
} else {
|
|
936
1034
|
summaryLines.push(` ${dim("Commands:")} none found`);
|
|
937
1035
|
}
|
|
938
1036
|
if (profilesWithForgeHome.length > 0) {
|
|
939
1037
|
summaryLines.push(
|
|
940
|
-
` ${yellow("Profiles")}: FORGE_HOME export in ${bold(profilesWithForgeHome.map((p) => p.replace(
|
|
1038
|
+
` ${yellow("Profiles")}: FORGE_HOME export in ${bold(profilesWithForgeHome.map((p) => p.replace(HOME, "~")).join(", "))}`,
|
|
941
1039
|
);
|
|
942
1040
|
}
|
|
943
1041
|
if (hasForgeYaml) {
|
|
@@ -961,10 +1059,7 @@ async function uninstall() {
|
|
|
961
1059
|
|
|
962
1060
|
let confirmed = forceYes;
|
|
963
1061
|
if (!confirmed) {
|
|
964
|
-
confirmed = await confirm(
|
|
965
|
-
`Remove ${commandLabel}${profileLabel}?`,
|
|
966
|
-
false
|
|
967
|
-
);
|
|
1062
|
+
confirmed = await confirm(`Remove ${commandLabel}${profileLabel}?`, false);
|
|
968
1063
|
}
|
|
969
1064
|
|
|
970
1065
|
if (!confirmed) {
|
|
@@ -980,7 +1075,9 @@ async function uninstall() {
|
|
|
980
1075
|
|
|
981
1076
|
if (toRemove.length > 0) {
|
|
982
1077
|
console.log("");
|
|
983
|
-
const bar = createProgressBar(toRemove.length, {
|
|
1078
|
+
const bar = createProgressBar(toRemove.length, {
|
|
1079
|
+
label: " Removing commands",
|
|
1080
|
+
});
|
|
984
1081
|
let removed = 0;
|
|
985
1082
|
|
|
986
1083
|
for (const { target, rel } of toRemove) {
|
|
@@ -1022,23 +1119,31 @@ async function uninstall() {
|
|
|
1022
1119
|
const cleaned = content
|
|
1023
1120
|
.replace(
|
|
1024
1121
|
/\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME=[^\n]*\n/g,
|
|
1025
|
-
"\n"
|
|
1122
|
+
"\n",
|
|
1026
1123
|
)
|
|
1027
1124
|
.replace(
|
|
1028
1125
|
/^# ForgeDock — autonomous development pipeline\nexport FORGE_HOME=[^\n]*\n/,
|
|
1029
|
-
""
|
|
1126
|
+
"",
|
|
1030
1127
|
);
|
|
1031
1128
|
// Write to a temp file first, then atomically rename into place.
|
|
1032
1129
|
// This prevents profile corruption if the process is killed mid-write.
|
|
1033
1130
|
writeFileSync(tmpPath, cleaned, "utf-8");
|
|
1034
1131
|
renameSync(tmpPath, profile);
|
|
1035
|
-
const profileShort = profile.replace(
|
|
1036
|
-
console.log(
|
|
1132
|
+
const profileShort = profile.replace(HOME, "~");
|
|
1133
|
+
console.log(
|
|
1134
|
+
` ${green("✔")} Removed FORGE_HOME from ${profileShort}`,
|
|
1135
|
+
);
|
|
1037
1136
|
} catch (err) {
|
|
1038
1137
|
// Clean up temp file if it was created before the error
|
|
1039
|
-
try {
|
|
1040
|
-
|
|
1041
|
-
|
|
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
|
+
);
|
|
1042
1147
|
}
|
|
1043
1148
|
}
|
|
1044
1149
|
} else {
|
|
@@ -1053,7 +1158,7 @@ async function uninstall() {
|
|
|
1053
1158
|
if (hasForgeYaml) {
|
|
1054
1159
|
console.log("");
|
|
1055
1160
|
const deleteForgeYaml = forceYes
|
|
1056
|
-
? false
|
|
1161
|
+
? false // --yes flag: keep forge.yaml by default (safe)
|
|
1057
1162
|
: await confirm("Delete forge.yaml from this project?", false);
|
|
1058
1163
|
|
|
1059
1164
|
if (deleteForgeYaml) {
|
|
@@ -1061,7 +1166,9 @@ async function uninstall() {
|
|
|
1061
1166
|
await unlink(forgeYamlPath);
|
|
1062
1167
|
console.log(` ${green("✔")} Deleted forge.yaml`);
|
|
1063
1168
|
} catch (err) {
|
|
1064
|
-
console.log(
|
|
1169
|
+
console.log(
|
|
1170
|
+
` ${red("✖")} Could not delete forge.yaml: ${err.message}`,
|
|
1171
|
+
);
|
|
1065
1172
|
}
|
|
1066
1173
|
} else {
|
|
1067
1174
|
console.log(` ${dim("forge.yaml kept.")}`);
|
|
@@ -1073,9 +1180,13 @@ async function uninstall() {
|
|
|
1073
1180
|
// -------------------------------------------------------------------------
|
|
1074
1181
|
|
|
1075
1182
|
console.log("");
|
|
1076
|
-
console.log(
|
|
1183
|
+
console.log(
|
|
1184
|
+
`${green("Uninstall complete.")} ForgeDock commands have been removed.`,
|
|
1185
|
+
);
|
|
1077
1186
|
if (profilesWithForgeHome.length > 0) {
|
|
1078
|
-
console.log(
|
|
1187
|
+
console.log(
|
|
1188
|
+
` ${dim("Restart your shell or run")} ${cyan("source ~/.bashrc")} ${dim("(or")} ${cyan("~/.zshrc")}${dim(")")} ${dim("to apply profile changes.")}`,
|
|
1189
|
+
);
|
|
1079
1190
|
}
|
|
1080
1191
|
console.log("");
|
|
1081
1192
|
}
|
|
@@ -1104,6 +1215,193 @@ function queryNpmRegistry(pkg) {
|
|
|
1104
1215
|
}
|
|
1105
1216
|
}
|
|
1106
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
|
+
// An update is only "available" when local HEAD is strictly BEHIND
|
|
1340
|
+
// origin/main. Plain SHA inequality is direction-agnostic: a checkout
|
|
1341
|
+
// that is ahead of or diverged from origin/main (e.g. a contributor with
|
|
1342
|
+
// local commits) would otherwise show a spurious "Update available"
|
|
1343
|
+
// notice. `git merge-base --is-ancestor HEAD origin/main` exits 0 when
|
|
1344
|
+
// HEAD is an ancestor of origin/main (behind or equal); combined with the
|
|
1345
|
+
// inequality below this means strictly behind. Any non-zero exit ("not an
|
|
1346
|
+
// ancestor") or error throws under execFileSync — treat all such cases as
|
|
1347
|
+
// "not behind" so we never show a false-positive notice.
|
|
1348
|
+
let headIsAncestorOfRemote = false;
|
|
1349
|
+
try {
|
|
1350
|
+
execFileSync(
|
|
1351
|
+
"git",
|
|
1352
|
+
["merge-base", "--is-ancestor", "HEAD", "origin/main"],
|
|
1353
|
+
{
|
|
1354
|
+
cwd: FORGE_HOME,
|
|
1355
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1356
|
+
timeout: 5000,
|
|
1357
|
+
},
|
|
1358
|
+
);
|
|
1359
|
+
headIsAncestorOfRemote = true;
|
|
1360
|
+
} catch {
|
|
1361
|
+
// HEAD is ahead of, diverged from, or unrelated to origin/main — or git
|
|
1362
|
+
// failed. In every case, do not report an update as available.
|
|
1363
|
+
headIsAncestorOfRemote = false;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
result = {
|
|
1367
|
+
updateAvailable: localHead !== remoteHead && headIsAncestorOfRemote,
|
|
1368
|
+
latestVersion: null,
|
|
1369
|
+
remoteHead,
|
|
1370
|
+
};
|
|
1371
|
+
} catch {
|
|
1372
|
+
return null;
|
|
1373
|
+
}
|
|
1374
|
+
} else {
|
|
1375
|
+
// npm install: query registry
|
|
1376
|
+
const latestVersion = queryNpmRegistry("forgedock");
|
|
1377
|
+
if (latestVersion === null) {
|
|
1378
|
+
// Offline or registry unreachable — skip check, don't update cache
|
|
1379
|
+
return null;
|
|
1380
|
+
}
|
|
1381
|
+
const currentVersion = getVersion();
|
|
1382
|
+
result = {
|
|
1383
|
+
updateAvailable: currentVersion !== latestVersion,
|
|
1384
|
+
latestVersion,
|
|
1385
|
+
remoteHead: null,
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// --- Write cache ---
|
|
1390
|
+
try {
|
|
1391
|
+
const cacheDir = join(HOME, ".forgedock");
|
|
1392
|
+
await mkdir(cacheDir, { recursive: true });
|
|
1393
|
+
writeFileSync(
|
|
1394
|
+
cachePath,
|
|
1395
|
+
JSON.stringify({ checkedAt: Date.now(), ...result }, null, 2) + "\n",
|
|
1396
|
+
{ encoding: "utf-8" },
|
|
1397
|
+
);
|
|
1398
|
+
} catch {
|
|
1399
|
+
// Best-effort — cache write failure is non-fatal
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
return result;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1107
1405
|
/**
|
|
1108
1406
|
* Return a Set of relative paths for all ForgeDock-managed symlinks in TARGET_DIR.
|
|
1109
1407
|
* Recursively walks subdirectories so commands installed under work-on/, work-on/build/,
|
|
@@ -1158,8 +1456,12 @@ async function getInstalledCommandNames() {
|
|
|
1158
1456
|
* @returns {string[]}
|
|
1159
1457
|
*/
|
|
1160
1458
|
function formatCommandDiff(before, after) {
|
|
1161
|
-
const added = [...after]
|
|
1162
|
-
|
|
1459
|
+
const added = [...after]
|
|
1460
|
+
.filter((n) => !before.has(n))
|
|
1461
|
+
.map((n) => n.replace(/\.md$/, ""));
|
|
1462
|
+
const removed = [...before]
|
|
1463
|
+
.filter((n) => !after.has(n))
|
|
1464
|
+
.map((n) => n.replace(/\.md$/, ""));
|
|
1163
1465
|
|
|
1164
1466
|
if (added.length === 0 && removed.length === 0) return [];
|
|
1165
1467
|
|
|
@@ -1199,7 +1501,9 @@ async function update() {
|
|
|
1199
1501
|
}).trim();
|
|
1200
1502
|
|
|
1201
1503
|
if (branch !== "main") {
|
|
1202
|
-
console.log(
|
|
1504
|
+
console.log(
|
|
1505
|
+
` ${yellow("Not on main branch")} (${branch}) — skipping automatic update.`,
|
|
1506
|
+
);
|
|
1203
1507
|
console.log(` Switch to ${cyan("main")} and re-run to update.`);
|
|
1204
1508
|
console.log("");
|
|
1205
1509
|
return;
|
|
@@ -1220,8 +1524,12 @@ async function update() {
|
|
|
1220
1524
|
timeout: 10000,
|
|
1221
1525
|
});
|
|
1222
1526
|
} catch {
|
|
1223
|
-
console.log(
|
|
1224
|
-
|
|
1527
|
+
console.log(
|
|
1528
|
+
` ${yellow("Offline or unreachable.")} Could not fetch from origin.`,
|
|
1529
|
+
);
|
|
1530
|
+
console.log(
|
|
1531
|
+
` ${dim("Skipping update check — re-run when connected.")}`,
|
|
1532
|
+
);
|
|
1225
1533
|
console.log("");
|
|
1226
1534
|
return;
|
|
1227
1535
|
}
|
|
@@ -1275,10 +1583,42 @@ async function update() {
|
|
|
1275
1583
|
timeout: 15000,
|
|
1276
1584
|
});
|
|
1277
1585
|
} catch {
|
|
1586
|
+
// Check if the only changes are whitespace/line-ending noise (CRLF drift, etc.)
|
|
1587
|
+
let isWhitespaceOnly = false;
|
|
1588
|
+
try {
|
|
1589
|
+
const diffStat = execFileSync(
|
|
1590
|
+
"git",
|
|
1591
|
+
[
|
|
1592
|
+
"diff",
|
|
1593
|
+
"--ignore-all-space",
|
|
1594
|
+
"--ignore-blank-lines",
|
|
1595
|
+
"--name-only",
|
|
1596
|
+
],
|
|
1597
|
+
{
|
|
1598
|
+
cwd: FORGE_HOME,
|
|
1599
|
+
encoding: "utf-8",
|
|
1600
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1601
|
+
timeout: 5000,
|
|
1602
|
+
},
|
|
1603
|
+
).trim();
|
|
1604
|
+
isWhitespaceOnly = diffStat === "";
|
|
1605
|
+
} catch {
|
|
1606
|
+
// Diff check failed — fall back to generic message
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1278
1609
|
console.log(
|
|
1279
|
-
` ${yellow("Cannot fast-forward")} — local changes exist. Skipping merge
|
|
1610
|
+
` ${yellow("Cannot fast-forward")} — local changes exist. Skipping merge.`,
|
|
1280
1611
|
);
|
|
1281
|
-
|
|
1612
|
+
if (isWhitespaceOnly) {
|
|
1613
|
+
console.log(
|
|
1614
|
+
` ${dim("Only whitespace/line-ending differences detected.")}`,
|
|
1615
|
+
);
|
|
1616
|
+
console.log(
|
|
1617
|
+
` ${dim("Run")} ${cyan("git checkout -- .")} ${dim("to discard them, then re-run")} ${cyan("npx forgedock update")}${dim(".")}`,
|
|
1618
|
+
);
|
|
1619
|
+
} else {
|
|
1620
|
+
console.log(` ${dim("Stash or discard local changes and re-run.")}`);
|
|
1621
|
+
}
|
|
1282
1622
|
console.log("");
|
|
1283
1623
|
return;
|
|
1284
1624
|
}
|
|
@@ -1291,11 +1631,32 @@ async function update() {
|
|
|
1291
1631
|
}).trim();
|
|
1292
1632
|
|
|
1293
1633
|
const newVersion = getVersion();
|
|
1294
|
-
console.log(
|
|
1295
|
-
` ${green("Updated")} v${currentVersion} → v${newVersion}`
|
|
1296
|
-
);
|
|
1634
|
+
console.log(` ${green("Updated")} v${currentVersion} → v${newVersion}`);
|
|
1297
1635
|
console.log("");
|
|
1298
1636
|
|
|
1637
|
+
// Invalidate the update-check cache so the next tuiOnboarding() run
|
|
1638
|
+
// reflects the freshly-applied update without hitting the network again.
|
|
1639
|
+
try {
|
|
1640
|
+
const cacheDir = join(HOME, ".forgedock");
|
|
1641
|
+
await mkdir(cacheDir, { recursive: true });
|
|
1642
|
+
writeFileSync(
|
|
1643
|
+
_getUpdateCheckCachePath(),
|
|
1644
|
+
JSON.stringify(
|
|
1645
|
+
{
|
|
1646
|
+
checkedAt: Date.now(),
|
|
1647
|
+
updateAvailable: false,
|
|
1648
|
+
latestVersion: newVersion,
|
|
1649
|
+
remoteHead: after,
|
|
1650
|
+
},
|
|
1651
|
+
null,
|
|
1652
|
+
2,
|
|
1653
|
+
) + "\n",
|
|
1654
|
+
{ encoding: "utf-8" },
|
|
1655
|
+
);
|
|
1656
|
+
} catch {
|
|
1657
|
+
// Best-effort — cache write failure is non-fatal
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1299
1660
|
// Re-run symlink installer to pick up new/removed commands
|
|
1300
1661
|
await install();
|
|
1301
1662
|
|
|
@@ -1308,7 +1669,7 @@ async function update() {
|
|
|
1308
1669
|
}
|
|
1309
1670
|
} catch (err) {
|
|
1310
1671
|
console.log(
|
|
1311
|
-
` ${RED}Update failed.${RESET} ${err instanceof Error ? err.message : String(err)}
|
|
1672
|
+
` ${RED}Update failed.${RESET} ${err instanceof Error ? err.message : String(err)}`,
|
|
1312
1673
|
);
|
|
1313
1674
|
console.log("");
|
|
1314
1675
|
}
|
|
@@ -1330,11 +1691,17 @@ async function update() {
|
|
|
1330
1691
|
|
|
1331
1692
|
if (latestVersion === null) {
|
|
1332
1693
|
if (process.stdout.isTTY) {
|
|
1333
|
-
process.stdout.write(
|
|
1694
|
+
process.stdout.write(
|
|
1695
|
+
`\r ${yellow("Offline or registry unreachable.")} Could not check for updates.\n`,
|
|
1696
|
+
);
|
|
1334
1697
|
} else {
|
|
1335
|
-
console.log(
|
|
1698
|
+
console.log(
|
|
1699
|
+
` Offline or registry unreachable. Could not check for updates.`,
|
|
1700
|
+
);
|
|
1336
1701
|
}
|
|
1337
|
-
console.log(
|
|
1702
|
+
console.log(
|
|
1703
|
+
` ${dim("Re-run when connected, or check: ")}${cyan("https://www.npmjs.com/package/forgedock")}`,
|
|
1704
|
+
);
|
|
1338
1705
|
console.log("");
|
|
1339
1706
|
return;
|
|
1340
1707
|
}
|
|
@@ -1373,7 +1740,19 @@ async function update() {
|
|
|
1373
1740
|
}
|
|
1374
1741
|
|
|
1375
1742
|
async function init() {
|
|
1376
|
-
|
|
1743
|
+
// init() generates forge.yaml from prompts and git remote detection.
|
|
1744
|
+
// It does NOT require gh CLI or gh auth for core operation (project board
|
|
1745
|
+
// auto-discovery is optional and guarded separately). Only warn if Claude
|
|
1746
|
+
// Code is missing — it's the primary consumer of the generated config.
|
|
1747
|
+
try {
|
|
1748
|
+
execSync("claude --version", { stdio: ["pipe", "pipe", "pipe"] });
|
|
1749
|
+
} catch {
|
|
1750
|
+
console.log(` ${YELLOW}!${RESET} Claude Code CLI not found on PATH.`);
|
|
1751
|
+
console.log(
|
|
1752
|
+
` Install: ${CYAN}https://docs.anthropic.com/en/docs/claude-code${RESET}`,
|
|
1753
|
+
);
|
|
1754
|
+
console.log("");
|
|
1755
|
+
}
|
|
1377
1756
|
|
|
1378
1757
|
console.log("");
|
|
1379
1758
|
console.log(`${BOLD}ForgeDock${RESET} — Generate forge.yaml`);
|
|
@@ -1401,7 +1780,9 @@ async function init() {
|
|
|
1401
1780
|
// SSH: git@github.com:owner/repo.git
|
|
1402
1781
|
const sshMatch = remoteUrl.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
1403
1782
|
// HTTPS: https://github.com/owner/repo.git
|
|
1404
|
-
const httpsMatch = remoteUrl.match(
|
|
1783
|
+
const httpsMatch = remoteUrl.match(
|
|
1784
|
+
/^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/,
|
|
1785
|
+
);
|
|
1405
1786
|
|
|
1406
1787
|
if (sshMatch) {
|
|
1407
1788
|
detectedOwner = sshMatch[1];
|
|
@@ -1463,16 +1844,21 @@ async function init() {
|
|
|
1463
1844
|
if (!process.stdin.isTTY) {
|
|
1464
1845
|
if (!remoteDetected) {
|
|
1465
1846
|
console.log(
|
|
1466
|
-
` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values
|
|
1847
|
+
` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values`,
|
|
1467
1848
|
);
|
|
1468
1849
|
}
|
|
1469
1850
|
// Silent write — same as pre-interactive behavior
|
|
1470
1851
|
_writeForgeYaml({
|
|
1471
|
-
outputPath,
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1852
|
+
outputPath,
|
|
1853
|
+
cwd,
|
|
1854
|
+
owner: detectedOwner,
|
|
1855
|
+
repo: detectedRepo,
|
|
1856
|
+
projectName: detectedName,
|
|
1857
|
+
description: "",
|
|
1858
|
+
root: cwd,
|
|
1859
|
+
worktreeBase: join(cwd, ".claude", "worktrees"),
|
|
1860
|
+
defaultBranch: detectedDefault,
|
|
1861
|
+
stagingBranch: detectedStaging,
|
|
1476
1862
|
});
|
|
1477
1863
|
console.log(` ${GREEN}Created${RESET}: forge.yaml`);
|
|
1478
1864
|
console.log("");
|
|
@@ -1489,17 +1875,29 @@ async function init() {
|
|
|
1489
1875
|
let confirmed = false;
|
|
1490
1876
|
|
|
1491
1877
|
while (!confirmed) {
|
|
1492
|
-
console.log(
|
|
1878
|
+
console.log(
|
|
1879
|
+
dim(
|
|
1880
|
+
" Auto-detected values are shown as defaults. Press Enter to accept.",
|
|
1881
|
+
),
|
|
1882
|
+
);
|
|
1493
1883
|
console.log("");
|
|
1494
1884
|
|
|
1495
1885
|
// --- Project section ---
|
|
1496
1886
|
console.log(bold(" Project"));
|
|
1497
1887
|
|
|
1498
|
-
const ownerInput = await input(
|
|
1888
|
+
const ownerInput = await input(
|
|
1889
|
+
" GitHub owner (org or user)",
|
|
1890
|
+
detectedOwner,
|
|
1891
|
+
);
|
|
1499
1892
|
const repoInput = await input(" Repository name", detectedRepo);
|
|
1500
1893
|
const nameInput = await input(
|
|
1501
1894
|
" Project name",
|
|
1502
|
-
detectedName !== "Your-repo-name"
|
|
1895
|
+
detectedName !== "Your-repo-name"
|
|
1896
|
+
? detectedName
|
|
1897
|
+
: repoInput
|
|
1898
|
+
.split(/[-_]/)
|
|
1899
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
1900
|
+
.join(" "),
|
|
1503
1901
|
);
|
|
1504
1902
|
const descInput = await input(" Brief description", "");
|
|
1505
1903
|
|
|
@@ -1510,7 +1908,7 @@ async function init() {
|
|
|
1510
1908
|
const rootInput = await input(" Repository root (absolute path)", cwd);
|
|
1511
1909
|
const worktreeInput = await input(
|
|
1512
1910
|
" Worktree base (for git worktrees)",
|
|
1513
|
-
join(rootInput || cwd, ".claude", "worktrees")
|
|
1911
|
+
join(rootInput || cwd, ".claude", "worktrees"),
|
|
1514
1912
|
);
|
|
1515
1913
|
|
|
1516
1914
|
// --- Branches section ---
|
|
@@ -1518,7 +1916,10 @@ async function init() {
|
|
|
1518
1916
|
console.log(bold(" Branches"));
|
|
1519
1917
|
|
|
1520
1918
|
const defaultBranchInput = await input(" Default branch", detectedDefault);
|
|
1521
|
-
const stagingBranchInput = await input(
|
|
1919
|
+
const stagingBranchInput = await input(
|
|
1920
|
+
" Staging branch (PR target for fast-lane changes)",
|
|
1921
|
+
detectedStaging,
|
|
1922
|
+
);
|
|
1522
1923
|
|
|
1523
1924
|
// --- Preview ---
|
|
1524
1925
|
console.log("");
|
|
@@ -1541,7 +1942,10 @@ async function init() {
|
|
|
1541
1942
|
.join("\n");
|
|
1542
1943
|
|
|
1543
1944
|
process.stdout.write(
|
|
1544
|
-
box(previewDisplay, {
|
|
1945
|
+
box(previewDisplay, {
|
|
1946
|
+
title: "forge.yaml preview (required sections)",
|
|
1947
|
+
padding: 1,
|
|
1948
|
+
}),
|
|
1545
1949
|
);
|
|
1546
1950
|
|
|
1547
1951
|
confirmed = await confirm(" Write this forge.yaml?", true);
|
|
@@ -1556,13 +1960,22 @@ async function init() {
|
|
|
1556
1960
|
// -----------------------------------------------------------------------
|
|
1557
1961
|
console.log("");
|
|
1558
1962
|
console.log(bold(" Optional Sections"));
|
|
1559
|
-
console.log(
|
|
1560
|
-
|
|
1963
|
+
console.log(
|
|
1964
|
+
dim(
|
|
1965
|
+
" Select sections to configure now. Unselected sections are written as",
|
|
1966
|
+
),
|
|
1967
|
+
);
|
|
1968
|
+
console.log(
|
|
1969
|
+
dim(
|
|
1970
|
+
" commented-out placeholders — you can enable them later by editing forge.yaml.",
|
|
1971
|
+
),
|
|
1972
|
+
);
|
|
1561
1973
|
console.log("");
|
|
1562
1974
|
|
|
1563
1975
|
const OPTIONAL_SECTION_CHOICES = [
|
|
1564
1976
|
{
|
|
1565
|
-
label:
|
|
1977
|
+
label:
|
|
1978
|
+
"Project Board — GitHub Projects v2 integration for workflow tracking",
|
|
1566
1979
|
value: "projectBoard",
|
|
1567
1980
|
},
|
|
1568
1981
|
{
|
|
@@ -1570,18 +1983,20 @@ async function init() {
|
|
|
1570
1983
|
value: "multiRepo",
|
|
1571
1984
|
},
|
|
1572
1985
|
{
|
|
1573
|
-
label:
|
|
1986
|
+
label:
|
|
1987
|
+
"Review Context — Tech stack and conventions for PR review agents",
|
|
1574
1988
|
value: "review",
|
|
1575
1989
|
},
|
|
1576
1990
|
{
|
|
1577
|
-
label:
|
|
1991
|
+
label:
|
|
1992
|
+
"Verification — Health check endpoints and response patterns",
|
|
1578
1993
|
value: "verification",
|
|
1579
1994
|
},
|
|
1580
1995
|
];
|
|
1581
1996
|
|
|
1582
1997
|
const selectedSections = await multiSelect(
|
|
1583
1998
|
" Which optional sections would you like to configure?",
|
|
1584
|
-
OPTIONAL_SECTION_CHOICES
|
|
1999
|
+
OPTIONAL_SECTION_CHOICES,
|
|
1585
2000
|
);
|
|
1586
2001
|
|
|
1587
2002
|
/** @type {Record<string, object>} */
|
|
@@ -1589,29 +2004,47 @@ async function init() {
|
|
|
1589
2004
|
|
|
1590
2005
|
// --- Project Board prompts ---
|
|
1591
2006
|
if (selectedSections.includes("projectBoard")) {
|
|
1592
|
-
const discovered = await discoverProjectBoard(
|
|
2007
|
+
const discovered = await discoverProjectBoard(
|
|
2008
|
+
ownerInput || detectedOwner,
|
|
2009
|
+
);
|
|
1593
2010
|
if (discovered) {
|
|
1594
2011
|
// Auto-discovery succeeded — use resolved IDs
|
|
1595
2012
|
optionalSections.projectBoard = {
|
|
1596
2013
|
projectNumber: discovered.projectNumber,
|
|
1597
|
-
projectId:
|
|
1598
|
-
fieldIds:
|
|
1599
|
-
optionIds:
|
|
2014
|
+
projectId: discovered.projectId,
|
|
2015
|
+
fieldIds: discovered.fieldIds,
|
|
2016
|
+
optionIds: discovered.optionIds,
|
|
1600
2017
|
};
|
|
1601
2018
|
} else {
|
|
1602
2019
|
// Fallback: manual entry (no regression from previous behaviour)
|
|
1603
2020
|
console.log("");
|
|
1604
2021
|
console.log(bold(" Project Board (manual)"));
|
|
1605
|
-
console.log(
|
|
2022
|
+
console.log(
|
|
2023
|
+
dim(
|
|
2024
|
+
" Find your project number: gh project list --owner " +
|
|
2025
|
+
(ownerInput || detectedOwner),
|
|
2026
|
+
),
|
|
2027
|
+
);
|
|
1606
2028
|
const projectNumber = await input(
|
|
1607
2029
|
" GitHub Projects v2 project number",
|
|
1608
|
-
"1"
|
|
2030
|
+
"1",
|
|
1609
2031
|
);
|
|
1610
2032
|
optionalSections.projectBoard = {
|
|
1611
2033
|
projectNumber: parseInt(projectNumber, 10) || 1,
|
|
1612
2034
|
};
|
|
1613
|
-
console.log(
|
|
1614
|
-
|
|
2035
|
+
console.log(
|
|
2036
|
+
dim(
|
|
2037
|
+
" Field IDs (PVT_/PVTSSF_ strings) must be added manually after generation.",
|
|
2038
|
+
),
|
|
2039
|
+
);
|
|
2040
|
+
console.log(
|
|
2041
|
+
dim(
|
|
2042
|
+
" Run: gh project field-list " +
|
|
2043
|
+
(projectNumber || "1") +
|
|
2044
|
+
" --owner " +
|
|
2045
|
+
(ownerInput || detectedOwner),
|
|
2046
|
+
),
|
|
2047
|
+
);
|
|
1615
2048
|
}
|
|
1616
2049
|
}
|
|
1617
2050
|
|
|
@@ -1619,18 +2052,22 @@ async function init() {
|
|
|
1619
2052
|
if (selectedSections.includes("multiRepo")) {
|
|
1620
2053
|
console.log("");
|
|
1621
2054
|
console.log(bold(" Multi-Repo"));
|
|
1622
|
-
console.log(
|
|
2055
|
+
console.log(
|
|
2056
|
+
dim(
|
|
2057
|
+
" Configure one satellite repo (add more by editing forge.yaml).",
|
|
2058
|
+
),
|
|
2059
|
+
);
|
|
1623
2060
|
const prefix = await input(
|
|
1624
2061
|
" Satellite repo prefix (e.g. 'mcp', 'sdk')",
|
|
1625
|
-
"sat"
|
|
2062
|
+
"sat",
|
|
1626
2063
|
);
|
|
1627
2064
|
const satelliteRepo = await input(
|
|
1628
2065
|
" Satellite repo name (just the name, owner will be reused)",
|
|
1629
|
-
"your-satellite-repo"
|
|
2066
|
+
"your-satellite-repo",
|
|
1630
2067
|
);
|
|
1631
2068
|
const satelliteBranch = await input(
|
|
1632
2069
|
" Satellite default/staging branch",
|
|
1633
|
-
"main"
|
|
2070
|
+
"main",
|
|
1634
2071
|
);
|
|
1635
2072
|
optionalSections.multiRepo = { prefix, satelliteRepo, satelliteBranch };
|
|
1636
2073
|
}
|
|
@@ -1641,11 +2078,11 @@ async function init() {
|
|
|
1641
2078
|
console.log(bold(" Review Context"));
|
|
1642
2079
|
const techStack = await input(
|
|
1643
2080
|
" Tech stack (e.g. Next.js, FastAPI, PostgreSQL)",
|
|
1644
|
-
"Node.js, TypeScript"
|
|
2081
|
+
"Node.js, TypeScript",
|
|
1645
2082
|
);
|
|
1646
2083
|
const context = await input(
|
|
1647
2084
|
" Architecture notes (one line; expand in forge.yaml later)",
|
|
1648
|
-
""
|
|
2085
|
+
"",
|
|
1649
2086
|
);
|
|
1650
2087
|
optionalSections.review = { techStack, context };
|
|
1651
2088
|
}
|
|
@@ -1656,7 +2093,7 @@ async function init() {
|
|
|
1656
2093
|
console.log(bold(" Verification"));
|
|
1657
2094
|
const healthEndpoint = await input(
|
|
1658
2095
|
" Health check endpoint URL",
|
|
1659
|
-
`https://api.${repoInput || detectedRepo}.io/health
|
|
2096
|
+
`https://api.${repoInput || detectedRepo}.io/health`,
|
|
1660
2097
|
);
|
|
1661
2098
|
optionalSections.verification = { healthEndpoint };
|
|
1662
2099
|
}
|
|
@@ -1669,7 +2106,7 @@ async function init() {
|
|
|
1669
2106
|
console.log(` ${YELLOW}forge.yaml already exists.${RESET}`);
|
|
1670
2107
|
const shouldOverwrite = await confirm(
|
|
1671
2108
|
" Back up existing forge.yaml and overwrite?",
|
|
1672
|
-
true
|
|
2109
|
+
true,
|
|
1673
2110
|
);
|
|
1674
2111
|
if (!shouldOverwrite) {
|
|
1675
2112
|
console.log(` ${dim("Cancelled.")} forge.yaml was not changed.`);
|
|
@@ -1680,9 +2117,12 @@ async function init() {
|
|
|
1680
2117
|
// Backup with timestamped name if .bak already exists (fix from #36)
|
|
1681
2118
|
const baseBak = join(cwd, "forge.yaml.bak");
|
|
1682
2119
|
const backupPath = existsSync(baseBak)
|
|
1683
|
-
? join(
|
|
2120
|
+
? join(
|
|
2121
|
+
cwd,
|
|
2122
|
+
`forge.yaml.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`,
|
|
2123
|
+
)
|
|
1684
2124
|
: baseBak;
|
|
1685
|
-
const backupName = backupPath
|
|
2125
|
+
const backupName = basename(backupPath);
|
|
1686
2126
|
renameSync(outputPath, backupPath);
|
|
1687
2127
|
console.log(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}`);
|
|
1688
2128
|
}
|
|
@@ -1705,12 +2145,19 @@ async function init() {
|
|
|
1705
2145
|
console.log("");
|
|
1706
2146
|
console.log(` ${GREEN}Created${RESET}: forge.yaml`);
|
|
1707
2147
|
if (selectedSections.length > 0) {
|
|
1708
|
-
console.log(
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
2148
|
+
console.log(
|
|
2149
|
+
` ${GREEN}Configured${RESET}: ${selectedSections
|
|
2150
|
+
.map(
|
|
2151
|
+
(s) =>
|
|
2152
|
+
({
|
|
2153
|
+
projectBoard: "project_board",
|
|
2154
|
+
multiRepo: "repos",
|
|
2155
|
+
review: "review",
|
|
2156
|
+
verification: "verification",
|
|
2157
|
+
})[s],
|
|
2158
|
+
)
|
|
2159
|
+
.join(", ")}`,
|
|
2160
|
+
);
|
|
1714
2161
|
}
|
|
1715
2162
|
console.log("");
|
|
1716
2163
|
_printNextSteps({ remoteDetected: ownerInput !== "your-github-org" });
|
|
@@ -1725,13 +2172,19 @@ async function init() {
|
|
|
1725
2172
|
|
|
1726
2173
|
/**
|
|
1727
2174
|
* Sanitize a string value for safe insertion into a YAML double-quoted scalar.
|
|
1728
|
-
*
|
|
2175
|
+
* Escapes backslashes (so a trailing \ does not corrupt the closing quote),
|
|
2176
|
+
* then strips double-quotes and newlines to prevent YAML injection.
|
|
2177
|
+
* Backslash escaping MUST come first — before any other replacement.
|
|
1729
2178
|
*
|
|
1730
2179
|
* @param {string} value
|
|
1731
2180
|
* @returns {string}
|
|
1732
2181
|
*/
|
|
1733
2182
|
function _sanitizeYamlValue(value) {
|
|
1734
|
-
return String(value)
|
|
2183
|
+
return String(value)
|
|
2184
|
+
.replace(/\\/g, "\\\\")
|
|
2185
|
+
.replace(/"/g, "")
|
|
2186
|
+
.replace(/[\r\n]/g, " ")
|
|
2187
|
+
.trim();
|
|
1735
2188
|
}
|
|
1736
2189
|
|
|
1737
2190
|
/**
|
|
@@ -1742,7 +2195,11 @@ function _sanitizeYamlValue(value) {
|
|
|
1742
2195
|
* @returns {string}
|
|
1743
2196
|
*/
|
|
1744
2197
|
function _sanitizePathValue(value) {
|
|
1745
|
-
return String(value)
|
|
2198
|
+
return String(value)
|
|
2199
|
+
.replace(/\\/g, "\\\\")
|
|
2200
|
+
.replace(/"/g, "")
|
|
2201
|
+
.replace(/[\r\n]/g, " ")
|
|
2202
|
+
.trim();
|
|
1746
2203
|
}
|
|
1747
2204
|
|
|
1748
2205
|
/**
|
|
@@ -1779,21 +2236,31 @@ async function discoverProjectBoard(owner) {
|
|
|
1779
2236
|
// -------------------------------------------------------------------------
|
|
1780
2237
|
let projects = [];
|
|
1781
2238
|
try {
|
|
1782
|
-
const raw = execFileSync(
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
2239
|
+
const raw = execFileSync(
|
|
2240
|
+
"gh",
|
|
2241
|
+
["project", "list", "--owner", owner, "--format", "json"],
|
|
2242
|
+
{
|
|
2243
|
+
encoding: "utf-8",
|
|
2244
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2245
|
+
},
|
|
2246
|
+
);
|
|
1786
2247
|
const parsed = JSON.parse(raw);
|
|
1787
2248
|
projects = parsed.projects || [];
|
|
1788
2249
|
} catch {
|
|
1789
2250
|
// gh not available, auth failure, or no projects access — fall back silently
|
|
1790
|
-
console.log(
|
|
2251
|
+
console.log(
|
|
2252
|
+
dim(" Could not list projects — falling back to manual entry."),
|
|
2253
|
+
);
|
|
1791
2254
|
return null;
|
|
1792
2255
|
}
|
|
1793
2256
|
|
|
1794
2257
|
if (projects.length === 0) {
|
|
1795
2258
|
console.log(dim(" No GitHub Projects v2 boards found for this owner."));
|
|
1796
|
-
console.log(
|
|
2259
|
+
console.log(
|
|
2260
|
+
dim(
|
|
2261
|
+
" You can add project_board configuration manually in forge.yaml later.",
|
|
2262
|
+
),
|
|
2263
|
+
);
|
|
1797
2264
|
return null;
|
|
1798
2265
|
}
|
|
1799
2266
|
|
|
@@ -1802,10 +2269,16 @@ async function discoverProjectBoard(owner) {
|
|
|
1802
2269
|
label: `${p.title} ${dim("(#" + p.number + ", " + (p.items?.totalCount ?? "?") + " items)")}`,
|
|
1803
2270
|
value: p.number,
|
|
1804
2271
|
}));
|
|
1805
|
-
projectChoices.push({
|
|
2272
|
+
projectChoices.push({
|
|
2273
|
+
label: dim("Skip — configure project board manually later"),
|
|
2274
|
+
value: null,
|
|
2275
|
+
});
|
|
1806
2276
|
|
|
1807
2277
|
console.log("");
|
|
1808
|
-
const selectedNumber = await select(
|
|
2278
|
+
const selectedNumber = await select(
|
|
2279
|
+
" Which GitHub Project board?",
|
|
2280
|
+
projectChoices,
|
|
2281
|
+
);
|
|
1809
2282
|
if (selectedNumber === null) {
|
|
1810
2283
|
return null;
|
|
1811
2284
|
}
|
|
@@ -1813,7 +2286,9 @@ async function discoverProjectBoard(owner) {
|
|
|
1813
2286
|
// Look up the selected project's id
|
|
1814
2287
|
const selectedProject = projects.find((p) => p.number === selectedNumber);
|
|
1815
2288
|
const projectId = selectedProject?.id ?? "";
|
|
1816
|
-
const projectTitle = _sanitizeYamlValue(
|
|
2289
|
+
const projectTitle = _sanitizeYamlValue(
|
|
2290
|
+
selectedProject?.title ?? `Project #${selectedNumber}`,
|
|
2291
|
+
);
|
|
1817
2292
|
|
|
1818
2293
|
// -------------------------------------------------------------------------
|
|
1819
2294
|
// Step 2: Fetch field list
|
|
@@ -1823,14 +2298,30 @@ async function discoverProjectBoard(owner) {
|
|
|
1823
2298
|
|
|
1824
2299
|
let fields = [];
|
|
1825
2300
|
try {
|
|
1826
|
-
const raw = execFileSync(
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
2301
|
+
const raw = execFileSync(
|
|
2302
|
+
"gh",
|
|
2303
|
+
[
|
|
2304
|
+
"project",
|
|
2305
|
+
"field-list",
|
|
2306
|
+
String(selectedNumber),
|
|
2307
|
+
"--owner",
|
|
2308
|
+
owner,
|
|
2309
|
+
"--format",
|
|
2310
|
+
"json",
|
|
2311
|
+
],
|
|
2312
|
+
{
|
|
2313
|
+
encoding: "utf-8",
|
|
2314
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2315
|
+
},
|
|
2316
|
+
);
|
|
1830
2317
|
const parsed = JSON.parse(raw);
|
|
1831
|
-
fields = (parsed.fields || []).filter(
|
|
2318
|
+
fields = (parsed.fields || []).filter(
|
|
2319
|
+
(f) => f.type === "ProjectV2SingleSelectField",
|
|
2320
|
+
);
|
|
1832
2321
|
} catch {
|
|
1833
|
-
console.log(
|
|
2322
|
+
console.log(
|
|
2323
|
+
dim(" Could not fetch project fields — falling back to manual entry."),
|
|
2324
|
+
);
|
|
1834
2325
|
return null;
|
|
1835
2326
|
}
|
|
1836
2327
|
|
|
@@ -1844,36 +2335,40 @@ async function discoverProjectBoard(owner) {
|
|
|
1844
2335
|
*/
|
|
1845
2336
|
function matchField(keyword) {
|
|
1846
2337
|
// Exact match first (case-insensitive)
|
|
1847
|
-
const exact = fields.find(
|
|
2338
|
+
const exact = fields.find(
|
|
2339
|
+
(f) => f.name.toLowerCase() === keyword.toLowerCase(),
|
|
2340
|
+
);
|
|
1848
2341
|
if (exact) return { field: exact, fuzzy: false };
|
|
1849
2342
|
// Fuzzy: name contains keyword
|
|
1850
|
-
const fuzzy = fields.find((f) =>
|
|
2343
|
+
const fuzzy = fields.find((f) =>
|
|
2344
|
+
f.name.toLowerCase().includes(keyword.toLowerCase()),
|
|
2345
|
+
);
|
|
1851
2346
|
if (fuzzy) return { field: fuzzy, fuzzy: true };
|
|
1852
2347
|
return null;
|
|
1853
2348
|
}
|
|
1854
2349
|
|
|
1855
2350
|
// Known field keys and their expected name keywords
|
|
1856
2351
|
const FIELD_TARGETS = [
|
|
1857
|
-
{ key: "status",
|
|
1858
|
-
{ key: "lane",
|
|
2352
|
+
{ key: "status", keywords: ["status"] },
|
|
2353
|
+
{ key: "lane", keywords: ["lane", "track"] },
|
|
1859
2354
|
{ key: "component", keywords: ["component"] },
|
|
1860
|
-
{ key: "priority",
|
|
1861
|
-
{ key: "workflow",
|
|
2355
|
+
{ key: "priority", keywords: ["priority"] },
|
|
2356
|
+
{ key: "workflow", keywords: ["workflow"] },
|
|
1862
2357
|
];
|
|
1863
2358
|
|
|
1864
2359
|
// Known option name → forge.yaml key mappings per field
|
|
1865
2360
|
const OPTION_MAPS = {
|
|
1866
|
-
status:
|
|
1867
|
-
lane:
|
|
1868
|
-
priority: {
|
|
2361
|
+
status: { todo: "todo", "in progress": "in_progress", done: "done" },
|
|
2362
|
+
lane: { fast: "fast", feature: "feature", sync: "sync" },
|
|
2363
|
+
priority: { p0: "p0", p1: "p1", p2: "p2", p3: "p3" },
|
|
1869
2364
|
workflow: {
|
|
1870
|
-
|
|
2365
|
+
investigating: "investigating",
|
|
1871
2366
|
"ready to build": "ready_to_build",
|
|
1872
|
-
|
|
2367
|
+
building: "building",
|
|
1873
2368
|
"in review": "in_review",
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
2369
|
+
merged: "merged",
|
|
2370
|
+
invalid: "invalid",
|
|
2371
|
+
decomposed: "decomposed",
|
|
1877
2372
|
},
|
|
1878
2373
|
component: {}, // component options vary by project — map all options by slugifying the name
|
|
1879
2374
|
};
|
|
@@ -1903,7 +2398,10 @@ async function discoverProjectBoard(owner) {
|
|
|
1903
2398
|
if (target.key === "component") {
|
|
1904
2399
|
// For component, map every option by slugifying its name (a–z, 0–9, underscore)
|
|
1905
2400
|
for (const opt of field.options || []) {
|
|
1906
|
-
const slug = opt.name
|
|
2401
|
+
const slug = opt.name
|
|
2402
|
+
.toLowerCase()
|
|
2403
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
2404
|
+
.replace(/^_|_$/g, "");
|
|
1907
2405
|
mappedOptions[slug] = opt.id;
|
|
1908
2406
|
}
|
|
1909
2407
|
} else {
|
|
@@ -1926,8 +2424,15 @@ async function discoverProjectBoard(owner) {
|
|
|
1926
2424
|
// -------------------------------------------------------------------------
|
|
1927
2425
|
for (const { key, fieldName } of fuzzyMatches) {
|
|
1928
2426
|
console.log("");
|
|
1929
|
-
console.log(
|
|
1930
|
-
|
|
2427
|
+
console.log(
|
|
2428
|
+
yellow(
|
|
2429
|
+
` Fuzzy match: field "${fieldName}" mapped to forge.yaml key "${key}"`,
|
|
2430
|
+
),
|
|
2431
|
+
);
|
|
2432
|
+
const accepted = await confirm(
|
|
2433
|
+
` Accept this mapping (${fieldName} → ${key})?`,
|
|
2434
|
+
true,
|
|
2435
|
+
);
|
|
1931
2436
|
if (!accepted) {
|
|
1932
2437
|
delete resolvedFieldIds[key];
|
|
1933
2438
|
delete resolvedOptionIds[key];
|
|
@@ -1944,14 +2449,21 @@ async function discoverProjectBoard(owner) {
|
|
|
1944
2449
|
"Fields:",
|
|
1945
2450
|
];
|
|
1946
2451
|
for (const [key, id] of Object.entries(resolvedFieldIds)) {
|
|
1947
|
-
const optCount = resolvedOptionIds[key]
|
|
1948
|
-
|
|
2452
|
+
const optCount = resolvedOptionIds[key]
|
|
2453
|
+
? Object.keys(resolvedOptionIds[key]).length
|
|
2454
|
+
: 0;
|
|
2455
|
+
const optLabel =
|
|
2456
|
+
optCount > 0 ? ` (${optCount} options)` : " (no options mapped)";
|
|
1949
2457
|
summaryLines.push(` ${key.padEnd(10)}: ${id}${optLabel}`);
|
|
1950
2458
|
}
|
|
1951
|
-
const unmapped = FIELD_TARGETS.map((t) => t.key).filter(
|
|
2459
|
+
const unmapped = FIELD_TARGETS.map((t) => t.key).filter(
|
|
2460
|
+
(k) => !resolvedFieldIds[k],
|
|
2461
|
+
);
|
|
1952
2462
|
if (unmapped.length > 0) {
|
|
1953
2463
|
summaryLines.push("");
|
|
1954
|
-
summaryLines.push(
|
|
2464
|
+
summaryLines.push(
|
|
2465
|
+
`Not found: ${unmapped.join(", ")} — placeholders will be used`,
|
|
2466
|
+
);
|
|
1955
2467
|
}
|
|
1956
2468
|
|
|
1957
2469
|
console.log("");
|
|
@@ -1959,7 +2471,11 @@ async function discoverProjectBoard(owner) {
|
|
|
1959
2471
|
|
|
1960
2472
|
const confirmed = await confirm(" Use this configuration?", true);
|
|
1961
2473
|
if (!confirmed) {
|
|
1962
|
-
console.log(
|
|
2474
|
+
console.log(
|
|
2475
|
+
dim(
|
|
2476
|
+
" Skipped auto-discovery. You can add project_board manually in forge.yaml.",
|
|
2477
|
+
),
|
|
2478
|
+
);
|
|
1963
2479
|
return null;
|
|
1964
2480
|
}
|
|
1965
2481
|
|
|
@@ -1992,40 +2508,66 @@ async function discoverProjectBoard(owner) {
|
|
|
1992
2508
|
* @param {object} [opts.optionalSections.review] - review section config
|
|
1993
2509
|
* @param {object} [opts.optionalSections.verification] - verification section config
|
|
1994
2510
|
*/
|
|
1995
|
-
function buildForgeYamlContent({
|
|
2511
|
+
function buildForgeYamlContent({
|
|
2512
|
+
owner,
|
|
2513
|
+
repo,
|
|
2514
|
+
projectName,
|
|
2515
|
+
description,
|
|
2516
|
+
root,
|
|
2517
|
+
worktreeBase,
|
|
2518
|
+
defaultBranch,
|
|
2519
|
+
stagingBranch,
|
|
2520
|
+
optionalSections = {},
|
|
2521
|
+
}) {
|
|
1996
2522
|
// Sanitize all user-supplied string values before template interpolation to prevent
|
|
1997
2523
|
// YAML injection via double-quotes or embedded newlines in double-quoted scalars.
|
|
1998
|
-
owner
|
|
1999
|
-
repo
|
|
2000
|
-
projectName
|
|
2001
|
-
description
|
|
2524
|
+
owner = _sanitizeYamlValue(owner);
|
|
2525
|
+
repo = _sanitizeYamlValue(repo);
|
|
2526
|
+
projectName = _sanitizeYamlValue(projectName);
|
|
2527
|
+
description = _sanitizeYamlValue(description);
|
|
2002
2528
|
defaultBranch = _sanitizeYamlValue(defaultBranch);
|
|
2003
2529
|
stagingBranch = _sanitizeYamlValue(stagingBranch);
|
|
2004
2530
|
// Path values: also escape backslashes so Windows paths are valid YAML.
|
|
2005
2531
|
// Preserve rawRoot for use in path.join() operations — join() expects raw OS paths,
|
|
2006
2532
|
// not YAML-escaped strings. Always apply _sanitizePathValue() to the join() result.
|
|
2007
2533
|
const rawRoot = root;
|
|
2008
|
-
root
|
|
2009
|
-
worktreeBase
|
|
2534
|
+
root = _sanitizePathValue(root);
|
|
2535
|
+
worktreeBase = _sanitizePathValue(worktreeBase);
|
|
2010
2536
|
|
|
2011
2537
|
const { projectBoard, multiRepo, review, verification } = optionalSections;
|
|
2012
2538
|
|
|
2013
2539
|
// Sanitize optional-section user-supplied strings.
|
|
2014
|
-
const safeMultiRepo = multiRepo
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
const
|
|
2027
|
-
|
|
2028
|
-
|
|
2540
|
+
const safeMultiRepo = multiRepo
|
|
2541
|
+
? {
|
|
2542
|
+
prefix: _sanitizeYamlValue(multiRepo.prefix || "sat"),
|
|
2543
|
+
satelliteRepo: _sanitizeYamlValue(
|
|
2544
|
+
multiRepo.satelliteRepo || "your-satellite-repo",
|
|
2545
|
+
),
|
|
2546
|
+
satelliteBranch: _sanitizeYamlValue(
|
|
2547
|
+
multiRepo.satelliteBranch || "main",
|
|
2548
|
+
),
|
|
2549
|
+
}
|
|
2550
|
+
: null;
|
|
2551
|
+
|
|
2552
|
+
const safeReview = review
|
|
2553
|
+
? {
|
|
2554
|
+
techStack: _sanitizeYamlValue(
|
|
2555
|
+
review.techStack || "Node.js, TypeScript, PostgreSQL",
|
|
2556
|
+
),
|
|
2557
|
+
// context uses a block scalar (|) — only strip double-quotes; newlines are handled by split/join
|
|
2558
|
+
context: (
|
|
2559
|
+
review.context || "Add architecture notes and conventions here."
|
|
2560
|
+
).replace(/"/g, ""),
|
|
2561
|
+
}
|
|
2562
|
+
: null;
|
|
2563
|
+
|
|
2564
|
+
const safeVerification = verification
|
|
2565
|
+
? {
|
|
2566
|
+
healthEndpoint: _sanitizeYamlValue(
|
|
2567
|
+
verification.healthEndpoint || `https://api.${repo}.io/health`,
|
|
2568
|
+
),
|
|
2569
|
+
}
|
|
2570
|
+
: null;
|
|
2029
2571
|
|
|
2030
2572
|
// --- repos section ---
|
|
2031
2573
|
const reposSection = safeMultiRepo
|
|
@@ -2059,7 +2601,9 @@ function buildForgeYamlContent({ owner, repo, projectName, description, root, wo
|
|
|
2059
2601
|
* Only writes fields that have at least one mapped option.
|
|
2060
2602
|
*/
|
|
2061
2603
|
function _buildOptionIdsBlock(optionIds) {
|
|
2062
|
-
const entries = Object.entries(optionIds).filter(
|
|
2604
|
+
const entries = Object.entries(optionIds).filter(
|
|
2605
|
+
([, opts]) => Object.keys(opts).length > 0,
|
|
2606
|
+
);
|
|
2063
2607
|
if (entries.length === 0) return "";
|
|
2064
2608
|
const lines = [" option_ids:"];
|
|
2065
2609
|
for (const [fieldKey, opts] of entries) {
|
|
@@ -2071,13 +2615,16 @@ function buildForgeYamlContent({ owner, repo, projectName, description, root, wo
|
|
|
2071
2615
|
return "\n" + lines.join("\n");
|
|
2072
2616
|
}
|
|
2073
2617
|
|
|
2074
|
-
const fieldIdsBlock = FIELD_KEYS
|
|
2075
|
-
|
|
2076
|
-
|
|
2618
|
+
const fieldIdsBlock = FIELD_KEYS.map(
|
|
2619
|
+
(k) =>
|
|
2620
|
+
` ${k}: "${resolvedFieldIds[k] ?? "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"}"`,
|
|
2621
|
+
).join("\n");
|
|
2077
2622
|
|
|
2078
|
-
const optionIdsBlock = projectBoard?.optionIds
|
|
2623
|
+
const optionIdsBlock = projectBoard?.optionIds
|
|
2624
|
+
? _buildOptionIdsBlock(resolvedOptionIds)
|
|
2625
|
+
: "";
|
|
2079
2626
|
|
|
2080
|
-
const hasDiscoveredId = !!
|
|
2627
|
+
const hasDiscoveredId = !!projectBoard?.projectId;
|
|
2081
2628
|
|
|
2082
2629
|
const projectBoardSection = projectBoard
|
|
2083
2630
|
? `project_board:
|
|
@@ -2212,10 +2759,16 @@ function _writeForgeYaml(opts) {
|
|
|
2212
2759
|
function _printNextSteps({ remoteDetected }) {
|
|
2213
2760
|
console.log(`${BOLD}Next steps:${RESET}`);
|
|
2214
2761
|
if (!remoteDetected) {
|
|
2215
|
-
console.log(
|
|
2762
|
+
console.log(
|
|
2763
|
+
` ${YELLOW}!${RESET} Edit ${CYAN}forge.yaml${RESET} — set project.owner and project.repo`,
|
|
2764
|
+
);
|
|
2216
2765
|
}
|
|
2217
|
-
console.log(
|
|
2218
|
-
|
|
2766
|
+
console.log(
|
|
2767
|
+
` 1. Add ${CYAN}forge.yaml${RESET} to ${CYAN}.gitignore${RESET} if it contains sensitive paths`,
|
|
2768
|
+
);
|
|
2769
|
+
console.log(
|
|
2770
|
+
` 2. Run ${CYAN}/forgedock-init${RESET} inside Claude Code for guided AI-powered setup`,
|
|
2771
|
+
);
|
|
2219
2772
|
console.log("");
|
|
2220
2773
|
}
|
|
2221
2774
|
|
|
@@ -2292,11 +2845,19 @@ function _parseSatelliteRepos(content) {
|
|
|
2292
2845
|
}
|
|
2293
2846
|
if (!inRepos) continue;
|
|
2294
2847
|
|
|
2295
|
-
if (line.trim() === "default:") {
|
|
2296
|
-
|
|
2848
|
+
if (line.trim() === "default:") {
|
|
2849
|
+
continue;
|
|
2850
|
+
}
|
|
2851
|
+
if (line.trim() === "satellites:") {
|
|
2852
|
+
inSatellites = true;
|
|
2853
|
+
continue;
|
|
2854
|
+
}
|
|
2297
2855
|
|
|
2298
2856
|
if (inSatellites && /^\s{6,}repo:/.test(line)) {
|
|
2299
|
-
const raw = line
|
|
2857
|
+
const raw = line
|
|
2858
|
+
.replace(/^\s+repo:\s*/, "")
|
|
2859
|
+
.replace(/^["']|["']$/g, "")
|
|
2860
|
+
.trim();
|
|
2300
2861
|
if (raw) repos.push(raw);
|
|
2301
2862
|
}
|
|
2302
2863
|
}
|
|
@@ -2318,7 +2879,11 @@ async function validate(forgeYamlPath) {
|
|
|
2318
2879
|
|
|
2319
2880
|
// 1. Read forge.yaml
|
|
2320
2881
|
if (!existsSync(forgeYamlPath)) {
|
|
2321
|
-
checks.push({
|
|
2882
|
+
checks.push({
|
|
2883
|
+
label: "forge.yaml found",
|
|
2884
|
+
status: "error",
|
|
2885
|
+
note: `Not found: ${forgeYamlPath}`,
|
|
2886
|
+
});
|
|
2322
2887
|
_renderValidationSummary(checks);
|
|
2323
2888
|
return { passed: false, checks };
|
|
2324
2889
|
}
|
|
@@ -2327,7 +2892,11 @@ async function validate(forgeYamlPath) {
|
|
|
2327
2892
|
try {
|
|
2328
2893
|
content = readFileSync(forgeYamlPath, "utf-8");
|
|
2329
2894
|
} catch (err) {
|
|
2330
|
-
checks.push({
|
|
2895
|
+
checks.push({
|
|
2896
|
+
label: "forge.yaml found",
|
|
2897
|
+
status: "error",
|
|
2898
|
+
note: `Cannot read: ${err.message}`,
|
|
2899
|
+
});
|
|
2331
2900
|
_renderValidationSummary(checks);
|
|
2332
2901
|
return { passed: false, checks };
|
|
2333
2902
|
}
|
|
@@ -2335,7 +2904,13 @@ async function validate(forgeYamlPath) {
|
|
|
2335
2904
|
checks.push({ label: "forge.yaml found", status: "ok", note: forgeYamlPath });
|
|
2336
2905
|
|
|
2337
2906
|
// 2. Required fields
|
|
2338
|
-
const PLACEHOLDERS = new Set([
|
|
2907
|
+
const PLACEHOLDERS = new Set([
|
|
2908
|
+
"your-github-org",
|
|
2909
|
+
"your-repo-name",
|
|
2910
|
+
"",
|
|
2911
|
+
"your-org",
|
|
2912
|
+
"your-repo",
|
|
2913
|
+
]);
|
|
2339
2914
|
|
|
2340
2915
|
const owner = _parseYamlKey(content, "project.owner");
|
|
2341
2916
|
const repo = _parseYamlKey(content, "project.repo");
|
|
@@ -2350,7 +2925,11 @@ async function validate(forgeYamlPath) {
|
|
|
2350
2925
|
if (!defaultBranch) missingFields.push("branches.default");
|
|
2351
2926
|
|
|
2352
2927
|
if (missingFields.length === 0) {
|
|
2353
|
-
checks.push({
|
|
2928
|
+
checks.push({
|
|
2929
|
+
label: "Required fields",
|
|
2930
|
+
status: "ok",
|
|
2931
|
+
note: `${owner}/${repo}`,
|
|
2932
|
+
});
|
|
2354
2933
|
} else {
|
|
2355
2934
|
checks.push({
|
|
2356
2935
|
label: "Required fields",
|
|
@@ -2364,20 +2943,36 @@ async function validate(forgeYamlPath) {
|
|
|
2364
2943
|
if (existsSync(root)) {
|
|
2365
2944
|
checks.push({ label: "paths.root exists", status: "ok", note: root });
|
|
2366
2945
|
} else {
|
|
2367
|
-
checks.push({
|
|
2946
|
+
checks.push({
|
|
2947
|
+
label: "paths.root exists",
|
|
2948
|
+
status: "warn",
|
|
2949
|
+
note: `Directory not found: ${root}`,
|
|
2950
|
+
});
|
|
2368
2951
|
}
|
|
2369
2952
|
}
|
|
2370
2953
|
|
|
2371
2954
|
// 4. Create worktree_base if missing
|
|
2372
2955
|
if (worktreeBase) {
|
|
2373
2956
|
if (existsSync(worktreeBase)) {
|
|
2374
|
-
checks.push({
|
|
2957
|
+
checks.push({
|
|
2958
|
+
label: "worktree_base exists",
|
|
2959
|
+
status: "ok",
|
|
2960
|
+
note: worktreeBase,
|
|
2961
|
+
});
|
|
2375
2962
|
} else {
|
|
2376
2963
|
try {
|
|
2377
2964
|
await mkdir(worktreeBase, { recursive: true });
|
|
2378
|
-
checks.push({
|
|
2965
|
+
checks.push({
|
|
2966
|
+
label: "worktree_base exists",
|
|
2967
|
+
status: "ok",
|
|
2968
|
+
note: `Created: ${worktreeBase}`,
|
|
2969
|
+
});
|
|
2379
2970
|
} catch (err) {
|
|
2380
|
-
checks.push({
|
|
2971
|
+
checks.push({
|
|
2972
|
+
label: "worktree_base exists",
|
|
2973
|
+
status: "warn",
|
|
2974
|
+
note: `Cannot create: ${err.message}`,
|
|
2975
|
+
});
|
|
2381
2976
|
}
|
|
2382
2977
|
}
|
|
2383
2978
|
}
|
|
@@ -2385,11 +2980,19 @@ async function validate(forgeYamlPath) {
|
|
|
2385
2980
|
// 5. GitHub repo access
|
|
2386
2981
|
if (owner && repo && !PLACEHOLDERS.has(owner) && !PLACEHOLDERS.has(repo)) {
|
|
2387
2982
|
try {
|
|
2388
|
-
execFileSync(
|
|
2389
|
-
|
|
2390
|
-
|
|
2983
|
+
execFileSync(
|
|
2984
|
+
"gh",
|
|
2985
|
+
["repo", "view", `${owner}/${repo}`, "--json", "name"],
|
|
2986
|
+
{
|
|
2987
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2988
|
+
timeout: 10000,
|
|
2989
|
+
},
|
|
2990
|
+
);
|
|
2991
|
+
checks.push({
|
|
2992
|
+
label: "GitHub repo accessible",
|
|
2993
|
+
status: "ok",
|
|
2994
|
+
note: `${owner}/${repo}`,
|
|
2391
2995
|
});
|
|
2392
|
-
checks.push({ label: "GitHub repo accessible", status: "ok", note: `${owner}/${repo}` });
|
|
2393
2996
|
} catch {
|
|
2394
2997
|
checks.push({
|
|
2395
2998
|
label: "GitHub repo accessible",
|
|
@@ -2398,29 +3001,51 @@ async function validate(forgeYamlPath) {
|
|
|
2398
3001
|
});
|
|
2399
3002
|
}
|
|
2400
3003
|
} else {
|
|
2401
|
-
checks.push({
|
|
3004
|
+
checks.push({
|
|
3005
|
+
label: "GitHub repo accessible",
|
|
3006
|
+
status: "warn",
|
|
3007
|
+
note: "Skipped — owner/repo not set",
|
|
3008
|
+
});
|
|
2402
3009
|
}
|
|
2403
3010
|
|
|
2404
3011
|
// 6. Branch existence on remote
|
|
2405
3012
|
const stagingBranch = _parseYamlKey(content, "branches.staging");
|
|
2406
|
-
const branchesToCheck = [
|
|
3013
|
+
const branchesToCheck = [
|
|
3014
|
+
...new Set([defaultBranch, stagingBranch].filter(Boolean)),
|
|
3015
|
+
];
|
|
2407
3016
|
|
|
2408
3017
|
if (root && existsSync(root) && branchesToCheck.length > 0) {
|
|
2409
3018
|
for (const branch of branchesToCheck) {
|
|
2410
3019
|
try {
|
|
2411
|
-
const result = execFileSync(
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
3020
|
+
const result = execFileSync(
|
|
3021
|
+
"git",
|
|
3022
|
+
["ls-remote", "--heads", "origin", branch],
|
|
3023
|
+
{
|
|
3024
|
+
cwd: root,
|
|
3025
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
3026
|
+
encoding: "utf-8",
|
|
3027
|
+
timeout: 10000,
|
|
3028
|
+
},
|
|
3029
|
+
);
|
|
2417
3030
|
if (result.trim()) {
|
|
2418
|
-
checks.push({
|
|
3031
|
+
checks.push({
|
|
3032
|
+
label: `Branch: ${branch}`,
|
|
3033
|
+
status: "ok",
|
|
3034
|
+
note: "Exists on remote",
|
|
3035
|
+
});
|
|
2419
3036
|
} else {
|
|
2420
|
-
checks.push({
|
|
3037
|
+
checks.push({
|
|
3038
|
+
label: `Branch: ${branch}`,
|
|
3039
|
+
status: "warn",
|
|
3040
|
+
note: "Not found on remote",
|
|
3041
|
+
});
|
|
2421
3042
|
}
|
|
2422
3043
|
} catch {
|
|
2423
|
-
checks.push({
|
|
3044
|
+
checks.push({
|
|
3045
|
+
label: `Branch: ${branch}`,
|
|
3046
|
+
status: "warn",
|
|
3047
|
+
note: "Cannot verify — git error",
|
|
3048
|
+
});
|
|
2424
3049
|
}
|
|
2425
3050
|
}
|
|
2426
3051
|
}
|
|
@@ -2448,31 +3073,58 @@ async function validate(forgeYamlPath) {
|
|
|
2448
3073
|
const result = execFileSync(
|
|
2449
3074
|
"gh",
|
|
2450
3075
|
[
|
|
2451
|
-
"api",
|
|
2452
|
-
"
|
|
2453
|
-
"-
|
|
3076
|
+
"api",
|
|
3077
|
+
"graphql",
|
|
3078
|
+
"-F",
|
|
3079
|
+
`id=${projectId}`,
|
|
3080
|
+
"-f",
|
|
3081
|
+
"query=query($id: ID!) { node(id: $id) { id __typename } }",
|
|
2454
3082
|
],
|
|
2455
|
-
{
|
|
3083
|
+
{
|
|
3084
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
3085
|
+
encoding: "utf-8",
|
|
3086
|
+
timeout: 10000,
|
|
3087
|
+
},
|
|
2456
3088
|
);
|
|
2457
3089
|
const parsed = JSON.parse(result);
|
|
2458
3090
|
if (parsed?.data?.node?.id) {
|
|
2459
|
-
checks.push({
|
|
3091
|
+
checks.push({
|
|
3092
|
+
label: "Project board configured",
|
|
3093
|
+
status: "ok",
|
|
3094
|
+
note: `${projectId.slice(0, 16)}... resolves`,
|
|
3095
|
+
});
|
|
2460
3096
|
} else {
|
|
2461
|
-
checks.push({
|
|
3097
|
+
checks.push({
|
|
3098
|
+
label: "Project board configured",
|
|
3099
|
+
status: "warn",
|
|
3100
|
+
note: "project_id may be invalid — verify with: gh project list",
|
|
3101
|
+
});
|
|
2462
3102
|
}
|
|
2463
3103
|
} catch {
|
|
2464
|
-
checks.push({
|
|
3104
|
+
checks.push({
|
|
3105
|
+
label: "Project board configured",
|
|
3106
|
+
status: "warn",
|
|
3107
|
+
note: "Cannot verify project_id — gh error",
|
|
3108
|
+
});
|
|
2465
3109
|
}
|
|
2466
3110
|
}
|
|
2467
3111
|
} else {
|
|
2468
|
-
checks.push({
|
|
3112
|
+
checks.push({
|
|
3113
|
+
label: "Project board",
|
|
3114
|
+
status: "warn",
|
|
3115
|
+
note: "Not configured (optional)",
|
|
3116
|
+
});
|
|
2469
3117
|
}
|
|
2470
3118
|
|
|
2471
3119
|
// 8. Satellite repo access
|
|
2472
3120
|
if (_sectionActive(content, "repos")) {
|
|
2473
3121
|
const satellites = _parseSatelliteRepos(content);
|
|
2474
3122
|
if (satellites.length === 0) {
|
|
2475
|
-
checks.push({
|
|
3123
|
+
checks.push({
|
|
3124
|
+
label: "Satellite repos",
|
|
3125
|
+
status: "warn",
|
|
3126
|
+
note: "repos: section active but no satellites found",
|
|
3127
|
+
});
|
|
2476
3128
|
} else {
|
|
2477
3129
|
for (const sat of satellites) {
|
|
2478
3130
|
try {
|
|
@@ -2480,14 +3132,26 @@ async function validate(forgeYamlPath) {
|
|
|
2480
3132
|
stdio: ["pipe", "pipe", "pipe"],
|
|
2481
3133
|
timeout: 10000,
|
|
2482
3134
|
});
|
|
2483
|
-
checks.push({
|
|
3135
|
+
checks.push({
|
|
3136
|
+
label: `Satellite: ${sat}`,
|
|
3137
|
+
status: "ok",
|
|
3138
|
+
note: "Accessible",
|
|
3139
|
+
});
|
|
2484
3140
|
} catch {
|
|
2485
|
-
checks.push({
|
|
3141
|
+
checks.push({
|
|
3142
|
+
label: `Satellite: ${sat}`,
|
|
3143
|
+
status: "warn",
|
|
3144
|
+
note: "Cannot access — check repo name or gh auth",
|
|
3145
|
+
});
|
|
2486
3146
|
}
|
|
2487
3147
|
}
|
|
2488
3148
|
}
|
|
2489
3149
|
} else {
|
|
2490
|
-
checks.push({
|
|
3150
|
+
checks.push({
|
|
3151
|
+
label: "Satellite repos",
|
|
3152
|
+
status: "warn",
|
|
3153
|
+
note: "Not configured (optional)",
|
|
3154
|
+
});
|
|
2491
3155
|
}
|
|
2492
3156
|
|
|
2493
3157
|
_renderValidationSummary(checks);
|
|
@@ -2521,17 +3185,25 @@ function _renderValidationSummary(checks) {
|
|
|
2521
3185
|
}
|
|
2522
3186
|
|
|
2523
3187
|
const hasErrors = checks.some((c) => c.status === "error");
|
|
2524
|
-
const boardOk = checks.some(
|
|
3188
|
+
const boardOk = checks.some(
|
|
3189
|
+
(c) => c.label === "Project board configured" && c.status === "ok",
|
|
3190
|
+
);
|
|
2525
3191
|
|
|
2526
3192
|
lines.push("");
|
|
2527
3193
|
lines.push(` ${bold("Next steps:")}`);
|
|
2528
3194
|
if (hasErrors) {
|
|
2529
|
-
lines.push(
|
|
3195
|
+
lines.push(
|
|
3196
|
+
` ${dim("•")} Edit ${cyan("forge.yaml")} to fix the errors above`,
|
|
3197
|
+
);
|
|
2530
3198
|
}
|
|
2531
3199
|
if (!boardOk) {
|
|
2532
|
-
lines.push(
|
|
3200
|
+
lines.push(
|
|
3201
|
+
` ${dim("•")} Run ${cyan("/forgedock-init")} for AI-powered optional section setup`,
|
|
3202
|
+
);
|
|
2533
3203
|
}
|
|
2534
|
-
lines.push(
|
|
3204
|
+
lines.push(
|
|
3205
|
+
` ${dim("•")} Run ${cyan("/work-on next")} to start your first task`,
|
|
3206
|
+
);
|
|
2535
3207
|
lines.push("");
|
|
2536
3208
|
|
|
2537
3209
|
process.stdout.write(box(lines, { title: "Config Validation" }));
|
|
@@ -2541,7 +3213,7 @@ function _renderValidationSummary(checks) {
|
|
|
2541
3213
|
// Bot credentials — store in ~/.forgedock/credentials.json (NOT forge.yaml)
|
|
2542
3214
|
// ---------------------------------------------------------------------------
|
|
2543
3215
|
|
|
2544
|
-
const FORGEDOCK_HOME = join(
|
|
3216
|
+
const FORGEDOCK_HOME = join(HOME, ".forgedock");
|
|
2545
3217
|
const CREDENTIALS_FILE = join(FORGEDOCK_HOME, "credentials.json");
|
|
2546
3218
|
|
|
2547
3219
|
/**
|
|
@@ -2558,7 +3230,10 @@ async function saveBotCredentials(creds) {
|
|
|
2558
3230
|
chmodSync(FORGEDOCK_HOME, 0o700);
|
|
2559
3231
|
const existing = loadBotCredentials() ?? {};
|
|
2560
3232
|
const updated = { ...existing, bot: creds };
|
|
2561
|
-
writeFileSync(CREDENTIALS_FILE, JSON.stringify(updated, null, 2) + "\n", {
|
|
3233
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(updated, null, 2) + "\n", {
|
|
3234
|
+
encoding: "utf-8",
|
|
3235
|
+
mode: 0o600,
|
|
3236
|
+
});
|
|
2562
3237
|
// Tighten permissions on files created before this fix (mode is only applied at creation time)
|
|
2563
3238
|
chmodSync(CREDENTIALS_FILE, 0o600);
|
|
2564
3239
|
}
|
|
@@ -2593,12 +3268,14 @@ function loadBotCredentials() {
|
|
|
2593
3268
|
function generateAppJwt(appId, privateKey) {
|
|
2594
3269
|
const now = Math.floor(Date.now() / 1000);
|
|
2595
3270
|
const payload = {
|
|
2596
|
-
iat: now - 60,
|
|
2597
|
-
exp: now + 600,
|
|
3271
|
+
iat: now - 60, // issued 60s ago to account for clock skew
|
|
3272
|
+
exp: now + 600, // expires in 10 minutes (GitHub max)
|
|
2598
3273
|
iss: appId,
|
|
2599
3274
|
};
|
|
2600
3275
|
|
|
2601
|
-
const header = Buffer.from(
|
|
3276
|
+
const header = Buffer.from(
|
|
3277
|
+
JSON.stringify({ alg: "RS256", typ: "JWT" }),
|
|
3278
|
+
).toString("base64url");
|
|
2602
3279
|
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
2603
3280
|
const unsigned = `${header}.${body}`;
|
|
2604
3281
|
|
|
@@ -2619,17 +3296,19 @@ function generateAppJwt(appId, privateKey) {
|
|
|
2619
3296
|
*/
|
|
2620
3297
|
async function connectExistingBot() {
|
|
2621
3298
|
console.log("");
|
|
2622
|
-
console.log(
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
3299
|
+
console.log(
|
|
3300
|
+
box(
|
|
3301
|
+
[
|
|
3302
|
+
`${bold("Connect an existing GitHub App")}`,
|
|
3303
|
+
"",
|
|
3304
|
+
"You'll need:",
|
|
3305
|
+
` • App ID (GitHub → Settings → Developer settings → GitHub Apps → your app)`,
|
|
3306
|
+
` • Private key file (.pem downloaded from app settings)`,
|
|
3307
|
+
` • Installation ID (GitHub → your org → Settings → Installed Apps → your app → configure → URL)`,
|
|
3308
|
+
].join("\n"),
|
|
3309
|
+
{ title: "Connect GitHub App" },
|
|
3310
|
+
),
|
|
3311
|
+
);
|
|
2633
3312
|
console.log("");
|
|
2634
3313
|
|
|
2635
3314
|
const appId = await input("App ID (numeric):", "");
|
|
@@ -2639,12 +3318,20 @@ async function connectExistingBot() {
|
|
|
2639
3318
|
}
|
|
2640
3319
|
|
|
2641
3320
|
const privateKeyPath = await input("Private key path (.pem):", "");
|
|
2642
|
-
const
|
|
2643
|
-
const
|
|
3321
|
+
const trimmedKeyPath = privateKeyPath.trim();
|
|
3322
|
+
const home = os.homedir();
|
|
3323
|
+
const expanded = trimmedKeyPath.startsWith("~")
|
|
3324
|
+
? join(home, trimmedKeyPath.slice(1))
|
|
3325
|
+
: trimmedKeyPath;
|
|
2644
3326
|
const resolvedKeyPath = resolve(expanded);
|
|
2645
|
-
if (
|
|
2646
|
-
|
|
2647
|
-
|
|
3327
|
+
if (trimmedKeyPath.startsWith("~")) {
|
|
3328
|
+
const rel = relative(home, resolvedKeyPath);
|
|
3329
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
3330
|
+
console.log(
|
|
3331
|
+
` ${RED}Invalid path: cannot traverse outside home directory.${RESET}`,
|
|
3332
|
+
);
|
|
3333
|
+
return false;
|
|
3334
|
+
}
|
|
2648
3335
|
}
|
|
2649
3336
|
if (!existsSync(resolvedKeyPath)) {
|
|
2650
3337
|
console.log(` ${RED}File not found: ${resolvedKeyPath}${RESET}`);
|
|
@@ -2653,7 +3340,9 @@ async function connectExistingBot() {
|
|
|
2653
3340
|
|
|
2654
3341
|
const installationId = await input("Installation ID (numeric):", "");
|
|
2655
3342
|
if (!installationId || !/^\d+$/.test(installationId.trim())) {
|
|
2656
|
-
console.log(
|
|
3343
|
+
console.log(
|
|
3344
|
+
` ${RED}Invalid Installation ID — must be a numeric string.${RESET}`,
|
|
3345
|
+
);
|
|
2657
3346
|
return false;
|
|
2658
3347
|
}
|
|
2659
3348
|
|
|
@@ -2679,11 +3368,15 @@ async function connectExistingBot() {
|
|
|
2679
3368
|
});
|
|
2680
3369
|
if (!resp.ok) {
|
|
2681
3370
|
const body = await resp.json().catch(() => ({}));
|
|
2682
|
-
console.log(
|
|
3371
|
+
console.log(
|
|
3372
|
+
` ${RED}GitHub API error ${resp.status}: ${body.message ?? "unknown"}${RESET}`,
|
|
3373
|
+
);
|
|
2683
3374
|
return false;
|
|
2684
3375
|
}
|
|
2685
3376
|
const appData = await resp.json();
|
|
2686
|
-
console.log(
|
|
3377
|
+
console.log(
|
|
3378
|
+
` ${GREEN}Connected!${RESET} App: ${bold(appData.name)} (id: ${appId.trim()})`,
|
|
3379
|
+
);
|
|
2687
3380
|
} catch (err) {
|
|
2688
3381
|
console.log(` ${RED}Validation failed: ${err.message}${RESET}`);
|
|
2689
3382
|
return false;
|
|
@@ -2716,8 +3409,8 @@ async function botStatus() {
|
|
|
2716
3409
|
if (!creds) {
|
|
2717
3410
|
console.log(
|
|
2718
3411
|
` ${YELLOW}No bot credentials found.${RESET}\n` +
|
|
2719
|
-
|
|
2720
|
-
|
|
3412
|
+
` Run ${cyan("npx forgedock bot setup")} to configure a GitHub App, or\n` +
|
|
3413
|
+
` add credentials to ${cyan(CREDENTIALS_FILE)}.`,
|
|
2721
3414
|
);
|
|
2722
3415
|
console.log("");
|
|
2723
3416
|
return;
|
|
@@ -2730,7 +3423,9 @@ async function botStatus() {
|
|
|
2730
3423
|
|
|
2731
3424
|
// Check private key file
|
|
2732
3425
|
if (!existsSync(creds.privateKeyPath)) {
|
|
2733
|
-
console.log(
|
|
3426
|
+
console.log(
|
|
3427
|
+
` ${RED}Private key file not found: ${creds.privateKeyPath}${RESET}`,
|
|
3428
|
+
);
|
|
2734
3429
|
console.log(` Run ${cyan("npx forgedock bot setup")} to reconfigure.`);
|
|
2735
3430
|
console.log("");
|
|
2736
3431
|
return;
|
|
@@ -2759,12 +3454,16 @@ async function botStatus() {
|
|
|
2759
3454
|
});
|
|
2760
3455
|
if (!appResp.ok) {
|
|
2761
3456
|
const body = await appResp.json().catch(() => ({}));
|
|
2762
|
-
console.log(
|
|
3457
|
+
console.log(
|
|
3458
|
+
` ${RED}GitHub API error ${appResp.status}: ${body.message ?? "unknown"}${RESET}`,
|
|
3459
|
+
);
|
|
2763
3460
|
console.log("");
|
|
2764
3461
|
return;
|
|
2765
3462
|
}
|
|
2766
3463
|
const appData = await appResp.json();
|
|
2767
|
-
console.log(
|
|
3464
|
+
console.log(
|
|
3465
|
+
` ${GREEN}Connected${RESET} — App: ${bold(appData.name)} (slug: @${appData.slug})`,
|
|
3466
|
+
);
|
|
2768
3467
|
|
|
2769
3468
|
// Rate limit for app
|
|
2770
3469
|
const rlResp = await fetch("https://api.github.com/rate_limit", {
|
|
@@ -2780,7 +3479,9 @@ async function botStatus() {
|
|
|
2780
3479
|
if (core) {
|
|
2781
3480
|
const used = core.limit - core.remaining;
|
|
2782
3481
|
const resetTime = new Date(core.reset * 1000).toLocaleTimeString();
|
|
2783
|
-
console.log(
|
|
3482
|
+
console.log(
|
|
3483
|
+
` Rate limit: ${cyan(core.remaining)}/${core.limit} remaining (${used} used, resets ${resetTime})`,
|
|
3484
|
+
);
|
|
2784
3485
|
}
|
|
2785
3486
|
}
|
|
2786
3487
|
} catch (err) {
|
|
@@ -2801,19 +3502,21 @@ async function botStatus() {
|
|
|
2801
3502
|
*/
|
|
2802
3503
|
async function botSetup() {
|
|
2803
3504
|
console.log("");
|
|
2804
|
-
console.log(
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
3505
|
+
console.log(
|
|
3506
|
+
box(
|
|
3507
|
+
[
|
|
3508
|
+
`${bold("GitHub Bot Setup")} ${dim("(Optional)")}`,
|
|
3509
|
+
"",
|
|
3510
|
+
"A GitHub App gives ForgeDock its own identity",
|
|
3511
|
+
"for pipeline operations. Benefits:",
|
|
3512
|
+
` ${cyan("•")} Separate audit trail for bot actions`,
|
|
3513
|
+
` ${cyan("•")} Higher API rate limits`,
|
|
3514
|
+
` ${cyan("•")} Webhook-driven automation (future)`,
|
|
3515
|
+
` ${cyan("•")} Multi-user team support`,
|
|
3516
|
+
].join("\n"),
|
|
3517
|
+
{ title: "GitHub Bot" },
|
|
3518
|
+
),
|
|
3519
|
+
);
|
|
2817
3520
|
console.log("");
|
|
2818
3521
|
|
|
2819
3522
|
const action = await select("How would you like to set up the bot?", [
|
|
@@ -2824,7 +3527,7 @@ async function botSetup() {
|
|
|
2824
3527
|
|
|
2825
3528
|
if (action === "skip") {
|
|
2826
3529
|
console.log(
|
|
2827
|
-
` ${dim("Skipped.")} Run ${cyan("npx forgedock bot setup")} later to configure a bot identity
|
|
3530
|
+
` ${dim("Skipped.")} Run ${cyan("npx forgedock bot setup")} later to configure a bot identity.`,
|
|
2828
3531
|
);
|
|
2829
3532
|
return;
|
|
2830
3533
|
}
|
|
@@ -2836,7 +3539,9 @@ async function botSetup() {
|
|
|
2836
3539
|
try {
|
|
2837
3540
|
manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
2838
3541
|
} catch {
|
|
2839
|
-
console.log(
|
|
3542
|
+
console.log(
|
|
3543
|
+
` ${RED}Could not read github-app-manifest.json — skipping.${RESET}`,
|
|
3544
|
+
);
|
|
2840
3545
|
return;
|
|
2841
3546
|
}
|
|
2842
3547
|
|
|
@@ -2844,19 +3549,26 @@ async function botSetup() {
|
|
|
2844
3549
|
const createUrl = `https://github.com/settings/apps/new?manifest=${encodedManifest}`;
|
|
2845
3550
|
|
|
2846
3551
|
console.log("");
|
|
2847
|
-
console.log(
|
|
3552
|
+
console.log(
|
|
3553
|
+
` ${bold("Opening GitHub App registration in your browser…")}`,
|
|
3554
|
+
);
|
|
2848
3555
|
console.log(` URL: ${cyan(createUrl.substring(0, 80))}…`);
|
|
2849
3556
|
console.log("");
|
|
2850
3557
|
|
|
2851
3558
|
// Try to open the browser
|
|
2852
3559
|
try {
|
|
2853
|
-
const opener =
|
|
2854
|
-
|
|
3560
|
+
const opener =
|
|
3561
|
+
process.platform === "win32"
|
|
3562
|
+
? "start"
|
|
3563
|
+
: process.platform === "darwin"
|
|
3564
|
+
? "open"
|
|
3565
|
+
: "xdg-open";
|
|
2855
3566
|
// On Windows, `start "url"` treats the first quoted arg as the window title.
|
|
2856
3567
|
// Pass an empty title placeholder so the URL is treated as the target: `start "" "url"`.
|
|
2857
|
-
const cmd =
|
|
2858
|
-
|
|
2859
|
-
|
|
3568
|
+
const cmd =
|
|
3569
|
+
process.platform === "win32"
|
|
3570
|
+
? `start "" "${createUrl}"`
|
|
3571
|
+
: `${opener} "${createUrl}"`;
|
|
2860
3572
|
execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] });
|
|
2861
3573
|
} catch {
|
|
2862
3574
|
console.log(` ${YELLOW}Could not open browser automatically.${RESET}`);
|
|
@@ -2868,7 +3580,9 @@ async function botSetup() {
|
|
|
2868
3580
|
console.log(" After creating the app in your browser:");
|
|
2869
3581
|
console.log(` 1. Download the private key (.pem) from the app settings`);
|
|
2870
3582
|
console.log(` 2. Note your App ID and Installation ID`);
|
|
2871
|
-
console.log(
|
|
3583
|
+
console.log(
|
|
3584
|
+
` 3. Run ${cyan("npx forgedock bot setup")} again and choose \"Connect existing\"`,
|
|
3585
|
+
);
|
|
2872
3586
|
console.log("");
|
|
2873
3587
|
return;
|
|
2874
3588
|
}
|
|
@@ -2882,19 +3596,39 @@ async function botSetup() {
|
|
|
2882
3596
|
|
|
2883
3597
|
function help() {
|
|
2884
3598
|
console.log("");
|
|
2885
|
-
console.log(
|
|
3599
|
+
console.log(
|
|
3600
|
+
`${BOLD}ForgeDock${RESET} — GitHub as a knowledge graph for AI agents`,
|
|
3601
|
+
);
|
|
2886
3602
|
console.log("");
|
|
2887
3603
|
console.log("Usage:");
|
|
2888
|
-
console.log(
|
|
3604
|
+
console.log(
|
|
3605
|
+
` ${CYAN}npx forgedock${RESET} Launch TUI onboarding (interactive)`,
|
|
3606
|
+
);
|
|
2889
3607
|
console.log(` ${CYAN}npx forgedock install${RESET} Install commands`);
|
|
2890
|
-
console.log(
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
console.log(
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
console.log(
|
|
2897
|
-
|
|
3608
|
+
console.log(
|
|
3609
|
+
` ${CYAN}npx forgedock init${RESET} Generate forge.yaml config for your project`,
|
|
3610
|
+
);
|
|
3611
|
+
console.log(
|
|
3612
|
+
` ${CYAN}npx forgedock validate${RESET} Validate forge.yaml configuration`,
|
|
3613
|
+
);
|
|
3614
|
+
console.log(
|
|
3615
|
+
` ${CYAN}npx forgedock uninstall${RESET} Remove commands (interactive, with confirmation)`,
|
|
3616
|
+
);
|
|
3617
|
+
console.log(
|
|
3618
|
+
` ${CYAN}npx forgedock uninstall --yes${RESET} Remove commands without prompts (non-interactive)`,
|
|
3619
|
+
);
|
|
3620
|
+
console.log(
|
|
3621
|
+
` ${CYAN}npx forgedock update${RESET} Pull latest & reinstall`,
|
|
3622
|
+
);
|
|
3623
|
+
console.log(
|
|
3624
|
+
` ${CYAN}npx forgedock bot${RESET} Manage GitHub App bot identity`,
|
|
3625
|
+
);
|
|
3626
|
+
console.log(
|
|
3627
|
+
` ${CYAN}npx forgedock bot status${RESET} Show bot health and rate limit usage`,
|
|
3628
|
+
);
|
|
3629
|
+
console.log(
|
|
3630
|
+
` ${CYAN}npx forgedock bot setup${RESET} Configure GitHub App bot (interactive)`,
|
|
3631
|
+
);
|
|
2898
3632
|
console.log(` ${CYAN}npx forgedock help${RESET} Show this help`);
|
|
2899
3633
|
console.log("");
|
|
2900
3634
|
}
|
|
@@ -2906,9 +3640,54 @@ function help() {
|
|
|
2906
3640
|
async function tuiOnboarding() {
|
|
2907
3641
|
splash();
|
|
2908
3642
|
|
|
2909
|
-
// Non-TTY fallback: skip TUI and detection, run install directly
|
|
3643
|
+
// Non-TTY fallback: skip TUI and detection, run install directly.
|
|
3644
|
+
// Print a prominent boxed notice so the user understands what happened and
|
|
3645
|
+
// what to do next. Best-effort update/checkout hints are folded into the box.
|
|
2910
3646
|
if (!process.stdout.isTTY) {
|
|
2911
|
-
|
|
3647
|
+
const noticeLines = [
|
|
3648
|
+
"",
|
|
3649
|
+
` Non-interactive environment detected.`,
|
|
3650
|
+
` ForgeDock's setup wizard requires an interactive terminal (TTY).`,
|
|
3651
|
+
"",
|
|
3652
|
+
` ${dim("To run the interactive TUI:")} open a terminal and run ${cyan("npx forgedock")}`,
|
|
3653
|
+
"",
|
|
3654
|
+
];
|
|
3655
|
+
|
|
3656
|
+
// Best-effort remote check — fold results into the box, never block install.
|
|
3657
|
+
try {
|
|
3658
|
+
const remoteCheck = await checkForRemoteUpdate();
|
|
3659
|
+
if (remoteCheck?.updateAvailable) {
|
|
3660
|
+
const isGitCheckout = existsSync(join(FORGE_HOME, ".git"));
|
|
3661
|
+
if (isGitCheckout) {
|
|
3662
|
+
// User is running from the ForgeDock source checkout and it's behind remote.
|
|
3663
|
+
noticeLines.push(
|
|
3664
|
+
` ${yellow("Stale local checkout:")} the working-tree bin is behind origin/main.`,
|
|
3665
|
+
);
|
|
3666
|
+
noticeLines.push(
|
|
3667
|
+
` ${dim("Run")} ${cyan(`git pull`)} ${dim(`in`)} ${dim(FORGE_HOME)}`,
|
|
3668
|
+
);
|
|
3669
|
+
noticeLines.push(
|
|
3670
|
+
` ${dim("Or use the published package:")} ${cyan("npx forgedock@latest")}`,
|
|
3671
|
+
);
|
|
3672
|
+
} else {
|
|
3673
|
+
const versionHint = remoteCheck.latestVersion
|
|
3674
|
+
? ` (v${getVersion()} → v${remoteCheck.latestVersion})`
|
|
3675
|
+
: "";
|
|
3676
|
+
noticeLines.push(
|
|
3677
|
+
` ${yellow(`Update available${versionHint}`)} — run: ${cyan("npx forgedock update")}`,
|
|
3678
|
+
);
|
|
3679
|
+
}
|
|
3680
|
+
noticeLines.push("");
|
|
3681
|
+
}
|
|
3682
|
+
} catch {
|
|
3683
|
+
// Best-effort — never block install
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
noticeLines.push(` Running ${cyan("install")} now...`);
|
|
3687
|
+
noticeLines.push("");
|
|
3688
|
+
process.stdout.write(
|
|
3689
|
+
box(noticeLines, { title: "Non-interactive environment" }),
|
|
3690
|
+
);
|
|
2912
3691
|
console.log("");
|
|
2913
3692
|
await install();
|
|
2914
3693
|
return;
|
|
@@ -2926,17 +3705,61 @@ async function tuiOnboarding() {
|
|
|
2926
3705
|
? `v${detection.installedVersion}`
|
|
2927
3706
|
: `v${detection.currentVersion}`;
|
|
2928
3707
|
console.log(
|
|
2929
|
-
` ${green("Everything up to date.")} ForgeDock ${versionLabel} is installed and configured
|
|
3708
|
+
` ${green("Everything up to date.")} ForgeDock ${versionLabel} is installed and configured.`,
|
|
2930
3709
|
);
|
|
2931
3710
|
console.log("");
|
|
2932
3711
|
|
|
2933
|
-
|
|
3712
|
+
// Proactive remote update check — cached, never blocks TUI startup
|
|
3713
|
+
let remoteUpdateInfo = null;
|
|
3714
|
+
try {
|
|
3715
|
+
remoteUpdateInfo = await checkForRemoteUpdate();
|
|
3716
|
+
} catch {
|
|
3717
|
+
// Best-effort — ignore all errors
|
|
3718
|
+
}
|
|
3719
|
+
|
|
3720
|
+
if (remoteUpdateInfo?.updateAvailable) {
|
|
3721
|
+
// Surface a prominent notice when behind the remote
|
|
3722
|
+
const noticeLines = [""];
|
|
3723
|
+
if (remoteUpdateInfo.latestVersion) {
|
|
3724
|
+
noticeLines.push(
|
|
3725
|
+
` ${yellow("Update available:")} v${detection.currentVersion} → ${green(`v${remoteUpdateInfo.latestVersion}`)}`,
|
|
3726
|
+
);
|
|
3727
|
+
} else {
|
|
3728
|
+
// Git install: no version string, but commits available
|
|
3729
|
+
noticeLines.push(
|
|
3730
|
+
` ${yellow("Update available")} — new commits on origin/main`,
|
|
3731
|
+
);
|
|
3732
|
+
}
|
|
3733
|
+
noticeLines.push(
|
|
3734
|
+
"",
|
|
3735
|
+
` ${dim('Choose "Update now" below or run')} ${cyan("npx forgedock update")}`,
|
|
3736
|
+
"",
|
|
3737
|
+
);
|
|
3738
|
+
process.stdout.write(box(noticeLines, { title: "Update available" }));
|
|
3739
|
+
console.log("");
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3742
|
+
// Build the action menu — insert "Update now" as the first choice when behind
|
|
3743
|
+
/** @type {Array<{label: string, value: string}>} */
|
|
3744
|
+
const menuChoices = [];
|
|
3745
|
+
if (remoteUpdateInfo?.updateAvailable) {
|
|
3746
|
+
menuChoices.push({ label: "Update now", value: "update" });
|
|
3747
|
+
}
|
|
3748
|
+
menuChoices.push(
|
|
2934
3749
|
{ label: "Nothing — exit", value: "exit" },
|
|
2935
|
-
{
|
|
3750
|
+
{
|
|
3751
|
+
label: "Reconfigure project (regenerate forge.yaml)",
|
|
3752
|
+
value: "reconfigure",
|
|
3753
|
+
},
|
|
2936
3754
|
{ label: "Reinstall commands", value: "reinstall" },
|
|
2937
|
-
|
|
3755
|
+
);
|
|
3756
|
+
|
|
3757
|
+
const action = await select("What would you like to do?", menuChoices);
|
|
2938
3758
|
|
|
2939
|
-
if (action === "
|
|
3759
|
+
if (action === "update") {
|
|
3760
|
+
console.log("");
|
|
3761
|
+
await update();
|
|
3762
|
+
} else if (action === "reconfigure") {
|
|
2940
3763
|
console.log("");
|
|
2941
3764
|
await init();
|
|
2942
3765
|
} else if (action === "reinstall") {
|
|
@@ -2954,8 +3777,8 @@ async function tuiOnboarding() {
|
|
|
2954
3777
|
if (detection.state === "update-available") {
|
|
2955
3778
|
console.log(
|
|
2956
3779
|
` ${yellow("Update available.")} ` +
|
|
2957
|
-
|
|
2958
|
-
|
|
3780
|
+
`Installed: ${dim(`v${detection.installedVersion ?? "unknown"}`)} → ` +
|
|
3781
|
+
`New: ${green(`v${detection.currentVersion}`)}`,
|
|
2959
3782
|
);
|
|
2960
3783
|
console.log("");
|
|
2961
3784
|
|
|
@@ -2985,7 +3808,10 @@ async function tuiOnboarding() {
|
|
|
2985
3808
|
if (success) {
|
|
2986
3809
|
// Show command diff if any commands were added or removed
|
|
2987
3810
|
const commandsAfterUpdate = await getInstalledCommandNames();
|
|
2988
|
-
const diffLines = formatCommandDiff(
|
|
3811
|
+
const diffLines = formatCommandDiff(
|
|
3812
|
+
commandsBeforeUpdate,
|
|
3813
|
+
commandsAfterUpdate,
|
|
3814
|
+
);
|
|
2989
3815
|
if (diffLines.length > 0) {
|
|
2990
3816
|
process.stdout.write(box(diffLines, { title: "Command changes" }));
|
|
2991
3817
|
console.log("");
|
|
@@ -2993,14 +3819,14 @@ async function tuiOnboarding() {
|
|
|
2993
3819
|
|
|
2994
3820
|
console.log(
|
|
2995
3821
|
green("Update complete!") +
|
|
2996
|
-
|
|
3822
|
+
` ForgeDock v${detection.currentVersion} is now active.`,
|
|
2997
3823
|
);
|
|
2998
3824
|
console.log("");
|
|
2999
3825
|
} else {
|
|
3000
3826
|
console.log("");
|
|
3001
3827
|
console.log(
|
|
3002
3828
|
yellow("Update incomplete.") +
|
|
3003
|
-
|
|
3829
|
+
` Re-run ${cyan("npx forgedock")} to try again.`,
|
|
3004
3830
|
);
|
|
3005
3831
|
console.log("");
|
|
3006
3832
|
process.exit(1);
|
|
@@ -3017,7 +3843,7 @@ async function tuiOnboarding() {
|
|
|
3017
3843
|
? `v${detection.installedVersion}`
|
|
3018
3844
|
: `v${detection.currentVersion}`;
|
|
3019
3845
|
console.log(
|
|
3020
|
-
` ${yellow("Commands installed")} (${versionLabel}) but no ${cyan("forge.yaml")} found in this directory
|
|
3846
|
+
` ${yellow("Commands installed")} (${versionLabel}) but no ${cyan("forge.yaml")} found in this directory.`,
|
|
3021
3847
|
);
|
|
3022
3848
|
console.log("");
|
|
3023
3849
|
|
|
@@ -3028,13 +3854,13 @@ async function tuiOnboarding() {
|
|
|
3028
3854
|
run: async () => {
|
|
3029
3855
|
const shouldInit = await confirm(
|
|
3030
3856
|
"Generate forge.yaml for this project?",
|
|
3031
|
-
true
|
|
3857
|
+
true,
|
|
3032
3858
|
);
|
|
3033
3859
|
if (shouldInit) {
|
|
3034
3860
|
await init();
|
|
3035
3861
|
} else {
|
|
3036
3862
|
console.log(
|
|
3037
|
-
` ${dim("Skipped.")} Run ${cyan("npx forgedock init")} later to generate forge.yaml
|
|
3863
|
+
` ${dim("Skipped.")} Run ${cyan("npx forgedock init")} later to generate forge.yaml.`,
|
|
3038
3864
|
);
|
|
3039
3865
|
}
|
|
3040
3866
|
},
|
|
@@ -3045,7 +3871,9 @@ async function tuiOnboarding() {
|
|
|
3045
3871
|
run: async () => {
|
|
3046
3872
|
const forgeYamlPath = join(process.cwd(), "forge.yaml");
|
|
3047
3873
|
if (!existsSync(forgeYamlPath)) {
|
|
3048
|
-
console.log(
|
|
3874
|
+
console.log(
|
|
3875
|
+
` ${dim("No forge.yaml found — skipping validation.")}`,
|
|
3876
|
+
);
|
|
3049
3877
|
return;
|
|
3050
3878
|
}
|
|
3051
3879
|
await validate(forgeYamlPath);
|
|
@@ -3059,14 +3887,14 @@ async function tuiOnboarding() {
|
|
|
3059
3887
|
if (success) {
|
|
3060
3888
|
console.log(
|
|
3061
3889
|
green("Done!") +
|
|
3062
|
-
|
|
3890
|
+
` Run ${cyan("/help")} inside Claude Code to see available commands.`,
|
|
3063
3891
|
);
|
|
3064
3892
|
console.log("");
|
|
3065
3893
|
} else {
|
|
3066
3894
|
console.log("");
|
|
3067
3895
|
console.log(
|
|
3068
3896
|
yellow("Setup incomplete.") +
|
|
3069
|
-
|
|
3897
|
+
` Re-run ${cyan("npx forgedock")} to try again.`,
|
|
3070
3898
|
);
|
|
3071
3899
|
console.log("");
|
|
3072
3900
|
process.exit(1);
|
|
@@ -3080,10 +3908,10 @@ async function tuiOnboarding() {
|
|
|
3080
3908
|
// -------------------------------------------------------------------------
|
|
3081
3909
|
if (detection.state === "version-unknown") {
|
|
3082
3910
|
console.log(
|
|
3083
|
-
` ${yellow("ForgeDock commands are installed")} but the installed version could not be determined
|
|
3911
|
+
` ${yellow("ForgeDock commands are installed")} but the installed version could not be determined.`,
|
|
3084
3912
|
);
|
|
3085
3913
|
console.log(
|
|
3086
|
-
` ${dim("The package.json for the installed copy is missing or unreadable.")}
|
|
3914
|
+
` ${dim("The package.json for the installed copy is missing or unreadable.")}`,
|
|
3087
3915
|
);
|
|
3088
3916
|
console.log("");
|
|
3089
3917
|
|
|
@@ -3125,19 +3953,21 @@ async function tuiOnboarding() {
|
|
|
3125
3953
|
run: async () => {
|
|
3126
3954
|
const forgeYamlPath = join(process.cwd(), "forge.yaml");
|
|
3127
3955
|
if (existsSync(forgeYamlPath)) {
|
|
3128
|
-
console.log(
|
|
3956
|
+
console.log(
|
|
3957
|
+
` ${green("forge.yaml")} already exists — skipping generation.`,
|
|
3958
|
+
);
|
|
3129
3959
|
return;
|
|
3130
3960
|
}
|
|
3131
3961
|
|
|
3132
3962
|
const shouldInit = await confirm(
|
|
3133
3963
|
"No forge.yaml found. Generate one now?",
|
|
3134
|
-
true
|
|
3964
|
+
true,
|
|
3135
3965
|
);
|
|
3136
3966
|
if (shouldInit) {
|
|
3137
3967
|
await init();
|
|
3138
3968
|
} else {
|
|
3139
3969
|
console.log(
|
|
3140
|
-
` ${dim("Skipped.")} Run ${cyan("npx forgedock init")} later to generate forge.yaml
|
|
3970
|
+
` ${dim("Skipped.")} Run ${cyan("npx forgedock init")} later to generate forge.yaml.`,
|
|
3141
3971
|
);
|
|
3142
3972
|
}
|
|
3143
3973
|
},
|
|
@@ -3169,14 +3999,14 @@ async function tuiOnboarding() {
|
|
|
3169
3999
|
if (success) {
|
|
3170
4000
|
console.log(
|
|
3171
4001
|
green("Setup complete!") +
|
|
3172
|
-
|
|
4002
|
+
` Run ${cyan("/help")} inside Claude Code to see available commands.`,
|
|
3173
4003
|
);
|
|
3174
4004
|
console.log("");
|
|
3175
4005
|
} else {
|
|
3176
4006
|
console.log("");
|
|
3177
4007
|
console.log(
|
|
3178
4008
|
yellow("Setup incomplete.") +
|
|
3179
|
-
|
|
4009
|
+
` Re-run ${cyan("npx forgedock")} to try again.`,
|
|
3180
4010
|
);
|
|
3181
4011
|
console.log("");
|
|
3182
4012
|
process.exit(1);
|