forgedock 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/bin/forgedock.mjs +1166 -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,166 @@ 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
|
+
result = {
|
|
1340
|
+
updateAvailable: localHead !== remoteHead,
|
|
1341
|
+
latestVersion: null,
|
|
1342
|
+
remoteHead,
|
|
1343
|
+
};
|
|
1344
|
+
} catch {
|
|
1345
|
+
return null;
|
|
1346
|
+
}
|
|
1347
|
+
} else {
|
|
1348
|
+
// npm install: query registry
|
|
1349
|
+
const latestVersion = queryNpmRegistry("forgedock");
|
|
1350
|
+
if (latestVersion === null) {
|
|
1351
|
+
// Offline or registry unreachable — skip check, don't update cache
|
|
1352
|
+
return null;
|
|
1353
|
+
}
|
|
1354
|
+
const currentVersion = getVersion();
|
|
1355
|
+
result = {
|
|
1356
|
+
updateAvailable: currentVersion !== latestVersion,
|
|
1357
|
+
latestVersion,
|
|
1358
|
+
remoteHead: null,
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// --- Write cache ---
|
|
1363
|
+
try {
|
|
1364
|
+
const cacheDir = join(HOME, ".forgedock");
|
|
1365
|
+
await mkdir(cacheDir, { recursive: true });
|
|
1366
|
+
writeFileSync(
|
|
1367
|
+
cachePath,
|
|
1368
|
+
JSON.stringify({ checkedAt: Date.now(), ...result }, null, 2) + "\n",
|
|
1369
|
+
{ encoding: "utf-8" },
|
|
1370
|
+
);
|
|
1371
|
+
} catch {
|
|
1372
|
+
// Best-effort — cache write failure is non-fatal
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
return result;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1107
1378
|
/**
|
|
1108
1379
|
* Return a Set of relative paths for all ForgeDock-managed symlinks in TARGET_DIR.
|
|
1109
1380
|
* Recursively walks subdirectories so commands installed under work-on/, work-on/build/,
|
|
@@ -1158,8 +1429,12 @@ async function getInstalledCommandNames() {
|
|
|
1158
1429
|
* @returns {string[]}
|
|
1159
1430
|
*/
|
|
1160
1431
|
function formatCommandDiff(before, after) {
|
|
1161
|
-
const added = [...after]
|
|
1162
|
-
|
|
1432
|
+
const added = [...after]
|
|
1433
|
+
.filter((n) => !before.has(n))
|
|
1434
|
+
.map((n) => n.replace(/\.md$/, ""));
|
|
1435
|
+
const removed = [...before]
|
|
1436
|
+
.filter((n) => !after.has(n))
|
|
1437
|
+
.map((n) => n.replace(/\.md$/, ""));
|
|
1163
1438
|
|
|
1164
1439
|
if (added.length === 0 && removed.length === 0) return [];
|
|
1165
1440
|
|
|
@@ -1199,7 +1474,9 @@ async function update() {
|
|
|
1199
1474
|
}).trim();
|
|
1200
1475
|
|
|
1201
1476
|
if (branch !== "main") {
|
|
1202
|
-
console.log(
|
|
1477
|
+
console.log(
|
|
1478
|
+
` ${yellow("Not on main branch")} (${branch}) — skipping automatic update.`,
|
|
1479
|
+
);
|
|
1203
1480
|
console.log(` Switch to ${cyan("main")} and re-run to update.`);
|
|
1204
1481
|
console.log("");
|
|
1205
1482
|
return;
|
|
@@ -1220,8 +1497,12 @@ async function update() {
|
|
|
1220
1497
|
timeout: 10000,
|
|
1221
1498
|
});
|
|
1222
1499
|
} catch {
|
|
1223
|
-
console.log(
|
|
1224
|
-
|
|
1500
|
+
console.log(
|
|
1501
|
+
` ${yellow("Offline or unreachable.")} Could not fetch from origin.`,
|
|
1502
|
+
);
|
|
1503
|
+
console.log(
|
|
1504
|
+
` ${dim("Skipping update check — re-run when connected.")}`,
|
|
1505
|
+
);
|
|
1225
1506
|
console.log("");
|
|
1226
1507
|
return;
|
|
1227
1508
|
}
|
|
@@ -1275,10 +1556,42 @@ async function update() {
|
|
|
1275
1556
|
timeout: 15000,
|
|
1276
1557
|
});
|
|
1277
1558
|
} catch {
|
|
1559
|
+
// Check if the only changes are whitespace/line-ending noise (CRLF drift, etc.)
|
|
1560
|
+
let isWhitespaceOnly = false;
|
|
1561
|
+
try {
|
|
1562
|
+
const diffStat = execFileSync(
|
|
1563
|
+
"git",
|
|
1564
|
+
[
|
|
1565
|
+
"diff",
|
|
1566
|
+
"--ignore-all-space",
|
|
1567
|
+
"--ignore-blank-lines",
|
|
1568
|
+
"--name-only",
|
|
1569
|
+
],
|
|
1570
|
+
{
|
|
1571
|
+
cwd: FORGE_HOME,
|
|
1572
|
+
encoding: "utf-8",
|
|
1573
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1574
|
+
timeout: 5000,
|
|
1575
|
+
},
|
|
1576
|
+
).trim();
|
|
1577
|
+
isWhitespaceOnly = diffStat === "";
|
|
1578
|
+
} catch {
|
|
1579
|
+
// Diff check failed — fall back to generic message
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1278
1582
|
console.log(
|
|
1279
|
-
` ${yellow("Cannot fast-forward")} — local changes exist. Skipping merge
|
|
1583
|
+
` ${yellow("Cannot fast-forward")} — local changes exist. Skipping merge.`,
|
|
1280
1584
|
);
|
|
1281
|
-
|
|
1585
|
+
if (isWhitespaceOnly) {
|
|
1586
|
+
console.log(
|
|
1587
|
+
` ${dim("Only whitespace/line-ending differences detected.")}`,
|
|
1588
|
+
);
|
|
1589
|
+
console.log(
|
|
1590
|
+
` ${dim("Run")} ${cyan("git checkout -- .")} ${dim("to discard them, then re-run")} ${cyan("npx forgedock update")}${dim(".")}`,
|
|
1591
|
+
);
|
|
1592
|
+
} else {
|
|
1593
|
+
console.log(` ${dim("Stash or discard local changes and re-run.")}`);
|
|
1594
|
+
}
|
|
1282
1595
|
console.log("");
|
|
1283
1596
|
return;
|
|
1284
1597
|
}
|
|
@@ -1291,11 +1604,32 @@ async function update() {
|
|
|
1291
1604
|
}).trim();
|
|
1292
1605
|
|
|
1293
1606
|
const newVersion = getVersion();
|
|
1294
|
-
console.log(
|
|
1295
|
-
` ${green("Updated")} v${currentVersion} → v${newVersion}`
|
|
1296
|
-
);
|
|
1607
|
+
console.log(` ${green("Updated")} v${currentVersion} → v${newVersion}`);
|
|
1297
1608
|
console.log("");
|
|
1298
1609
|
|
|
1610
|
+
// Invalidate the update-check cache so the next tuiOnboarding() run
|
|
1611
|
+
// reflects the freshly-applied update without hitting the network again.
|
|
1612
|
+
try {
|
|
1613
|
+
const cacheDir = join(HOME, ".forgedock");
|
|
1614
|
+
await mkdir(cacheDir, { recursive: true });
|
|
1615
|
+
writeFileSync(
|
|
1616
|
+
_getUpdateCheckCachePath(),
|
|
1617
|
+
JSON.stringify(
|
|
1618
|
+
{
|
|
1619
|
+
checkedAt: Date.now(),
|
|
1620
|
+
updateAvailable: false,
|
|
1621
|
+
latestVersion: newVersion,
|
|
1622
|
+
remoteHead: after,
|
|
1623
|
+
},
|
|
1624
|
+
null,
|
|
1625
|
+
2,
|
|
1626
|
+
) + "\n",
|
|
1627
|
+
{ encoding: "utf-8" },
|
|
1628
|
+
);
|
|
1629
|
+
} catch {
|
|
1630
|
+
// Best-effort — cache write failure is non-fatal
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1299
1633
|
// Re-run symlink installer to pick up new/removed commands
|
|
1300
1634
|
await install();
|
|
1301
1635
|
|
|
@@ -1308,7 +1642,7 @@ async function update() {
|
|
|
1308
1642
|
}
|
|
1309
1643
|
} catch (err) {
|
|
1310
1644
|
console.log(
|
|
1311
|
-
` ${RED}Update failed.${RESET} ${err instanceof Error ? err.message : String(err)}
|
|
1645
|
+
` ${RED}Update failed.${RESET} ${err instanceof Error ? err.message : String(err)}`,
|
|
1312
1646
|
);
|
|
1313
1647
|
console.log("");
|
|
1314
1648
|
}
|
|
@@ -1330,11 +1664,17 @@ async function update() {
|
|
|
1330
1664
|
|
|
1331
1665
|
if (latestVersion === null) {
|
|
1332
1666
|
if (process.stdout.isTTY) {
|
|
1333
|
-
process.stdout.write(
|
|
1667
|
+
process.stdout.write(
|
|
1668
|
+
`\r ${yellow("Offline or registry unreachable.")} Could not check for updates.\n`,
|
|
1669
|
+
);
|
|
1334
1670
|
} else {
|
|
1335
|
-
console.log(
|
|
1671
|
+
console.log(
|
|
1672
|
+
` Offline or registry unreachable. Could not check for updates.`,
|
|
1673
|
+
);
|
|
1336
1674
|
}
|
|
1337
|
-
console.log(
|
|
1675
|
+
console.log(
|
|
1676
|
+
` ${dim("Re-run when connected, or check: ")}${cyan("https://www.npmjs.com/package/forgedock")}`,
|
|
1677
|
+
);
|
|
1338
1678
|
console.log("");
|
|
1339
1679
|
return;
|
|
1340
1680
|
}
|
|
@@ -1373,7 +1713,19 @@ async function update() {
|
|
|
1373
1713
|
}
|
|
1374
1714
|
|
|
1375
1715
|
async function init() {
|
|
1376
|
-
|
|
1716
|
+
// init() generates forge.yaml from prompts and git remote detection.
|
|
1717
|
+
// It does NOT require gh CLI or gh auth for core operation (project board
|
|
1718
|
+
// auto-discovery is optional and guarded separately). Only warn if Claude
|
|
1719
|
+
// Code is missing — it's the primary consumer of the generated config.
|
|
1720
|
+
try {
|
|
1721
|
+
execSync("claude --version", { stdio: ["pipe", "pipe", "pipe"] });
|
|
1722
|
+
} catch {
|
|
1723
|
+
console.log(` ${YELLOW}!${RESET} Claude Code CLI not found on PATH.`);
|
|
1724
|
+
console.log(
|
|
1725
|
+
` Install: ${CYAN}https://docs.anthropic.com/en/docs/claude-code${RESET}`,
|
|
1726
|
+
);
|
|
1727
|
+
console.log("");
|
|
1728
|
+
}
|
|
1377
1729
|
|
|
1378
1730
|
console.log("");
|
|
1379
1731
|
console.log(`${BOLD}ForgeDock${RESET} — Generate forge.yaml`);
|
|
@@ -1401,7 +1753,9 @@ async function init() {
|
|
|
1401
1753
|
// SSH: git@github.com:owner/repo.git
|
|
1402
1754
|
const sshMatch = remoteUrl.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
1403
1755
|
// HTTPS: https://github.com/owner/repo.git
|
|
1404
|
-
const httpsMatch = remoteUrl.match(
|
|
1756
|
+
const httpsMatch = remoteUrl.match(
|
|
1757
|
+
/^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/,
|
|
1758
|
+
);
|
|
1405
1759
|
|
|
1406
1760
|
if (sshMatch) {
|
|
1407
1761
|
detectedOwner = sshMatch[1];
|
|
@@ -1463,16 +1817,21 @@ async function init() {
|
|
|
1463
1817
|
if (!process.stdin.isTTY) {
|
|
1464
1818
|
if (!remoteDetected) {
|
|
1465
1819
|
console.log(
|
|
1466
|
-
` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values
|
|
1820
|
+
` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values`,
|
|
1467
1821
|
);
|
|
1468
1822
|
}
|
|
1469
1823
|
// Silent write — same as pre-interactive behavior
|
|
1470
1824
|
_writeForgeYaml({
|
|
1471
|
-
outputPath,
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1825
|
+
outputPath,
|
|
1826
|
+
cwd,
|
|
1827
|
+
owner: detectedOwner,
|
|
1828
|
+
repo: detectedRepo,
|
|
1829
|
+
projectName: detectedName,
|
|
1830
|
+
description: "",
|
|
1831
|
+
root: cwd,
|
|
1832
|
+
worktreeBase: join(cwd, ".claude", "worktrees"),
|
|
1833
|
+
defaultBranch: detectedDefault,
|
|
1834
|
+
stagingBranch: detectedStaging,
|
|
1476
1835
|
});
|
|
1477
1836
|
console.log(` ${GREEN}Created${RESET}: forge.yaml`);
|
|
1478
1837
|
console.log("");
|
|
@@ -1489,17 +1848,29 @@ async function init() {
|
|
|
1489
1848
|
let confirmed = false;
|
|
1490
1849
|
|
|
1491
1850
|
while (!confirmed) {
|
|
1492
|
-
console.log(
|
|
1851
|
+
console.log(
|
|
1852
|
+
dim(
|
|
1853
|
+
" Auto-detected values are shown as defaults. Press Enter to accept.",
|
|
1854
|
+
),
|
|
1855
|
+
);
|
|
1493
1856
|
console.log("");
|
|
1494
1857
|
|
|
1495
1858
|
// --- Project section ---
|
|
1496
1859
|
console.log(bold(" Project"));
|
|
1497
1860
|
|
|
1498
|
-
const ownerInput = await input(
|
|
1861
|
+
const ownerInput = await input(
|
|
1862
|
+
" GitHub owner (org or user)",
|
|
1863
|
+
detectedOwner,
|
|
1864
|
+
);
|
|
1499
1865
|
const repoInput = await input(" Repository name", detectedRepo);
|
|
1500
1866
|
const nameInput = await input(
|
|
1501
1867
|
" Project name",
|
|
1502
|
-
detectedName !== "Your-repo-name"
|
|
1868
|
+
detectedName !== "Your-repo-name"
|
|
1869
|
+
? detectedName
|
|
1870
|
+
: repoInput
|
|
1871
|
+
.split(/[-_]/)
|
|
1872
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
1873
|
+
.join(" "),
|
|
1503
1874
|
);
|
|
1504
1875
|
const descInput = await input(" Brief description", "");
|
|
1505
1876
|
|
|
@@ -1510,7 +1881,7 @@ async function init() {
|
|
|
1510
1881
|
const rootInput = await input(" Repository root (absolute path)", cwd);
|
|
1511
1882
|
const worktreeInput = await input(
|
|
1512
1883
|
" Worktree base (for git worktrees)",
|
|
1513
|
-
join(rootInput || cwd, ".claude", "worktrees")
|
|
1884
|
+
join(rootInput || cwd, ".claude", "worktrees"),
|
|
1514
1885
|
);
|
|
1515
1886
|
|
|
1516
1887
|
// --- Branches section ---
|
|
@@ -1518,7 +1889,10 @@ async function init() {
|
|
|
1518
1889
|
console.log(bold(" Branches"));
|
|
1519
1890
|
|
|
1520
1891
|
const defaultBranchInput = await input(" Default branch", detectedDefault);
|
|
1521
|
-
const stagingBranchInput = await input(
|
|
1892
|
+
const stagingBranchInput = await input(
|
|
1893
|
+
" Staging branch (PR target for fast-lane changes)",
|
|
1894
|
+
detectedStaging,
|
|
1895
|
+
);
|
|
1522
1896
|
|
|
1523
1897
|
// --- Preview ---
|
|
1524
1898
|
console.log("");
|
|
@@ -1541,7 +1915,10 @@ async function init() {
|
|
|
1541
1915
|
.join("\n");
|
|
1542
1916
|
|
|
1543
1917
|
process.stdout.write(
|
|
1544
|
-
box(previewDisplay, {
|
|
1918
|
+
box(previewDisplay, {
|
|
1919
|
+
title: "forge.yaml preview (required sections)",
|
|
1920
|
+
padding: 1,
|
|
1921
|
+
}),
|
|
1545
1922
|
);
|
|
1546
1923
|
|
|
1547
1924
|
confirmed = await confirm(" Write this forge.yaml?", true);
|
|
@@ -1556,13 +1933,22 @@ async function init() {
|
|
|
1556
1933
|
// -----------------------------------------------------------------------
|
|
1557
1934
|
console.log("");
|
|
1558
1935
|
console.log(bold(" Optional Sections"));
|
|
1559
|
-
console.log(
|
|
1560
|
-
|
|
1936
|
+
console.log(
|
|
1937
|
+
dim(
|
|
1938
|
+
" Select sections to configure now. Unselected sections are written as",
|
|
1939
|
+
),
|
|
1940
|
+
);
|
|
1941
|
+
console.log(
|
|
1942
|
+
dim(
|
|
1943
|
+
" commented-out placeholders — you can enable them later by editing forge.yaml.",
|
|
1944
|
+
),
|
|
1945
|
+
);
|
|
1561
1946
|
console.log("");
|
|
1562
1947
|
|
|
1563
1948
|
const OPTIONAL_SECTION_CHOICES = [
|
|
1564
1949
|
{
|
|
1565
|
-
label:
|
|
1950
|
+
label:
|
|
1951
|
+
"Project Board — GitHub Projects v2 integration for workflow tracking",
|
|
1566
1952
|
value: "projectBoard",
|
|
1567
1953
|
},
|
|
1568
1954
|
{
|
|
@@ -1570,18 +1956,20 @@ async function init() {
|
|
|
1570
1956
|
value: "multiRepo",
|
|
1571
1957
|
},
|
|
1572
1958
|
{
|
|
1573
|
-
label:
|
|
1959
|
+
label:
|
|
1960
|
+
"Review Context — Tech stack and conventions for PR review agents",
|
|
1574
1961
|
value: "review",
|
|
1575
1962
|
},
|
|
1576
1963
|
{
|
|
1577
|
-
label:
|
|
1964
|
+
label:
|
|
1965
|
+
"Verification — Health check endpoints and response patterns",
|
|
1578
1966
|
value: "verification",
|
|
1579
1967
|
},
|
|
1580
1968
|
];
|
|
1581
1969
|
|
|
1582
1970
|
const selectedSections = await multiSelect(
|
|
1583
1971
|
" Which optional sections would you like to configure?",
|
|
1584
|
-
OPTIONAL_SECTION_CHOICES
|
|
1972
|
+
OPTIONAL_SECTION_CHOICES,
|
|
1585
1973
|
);
|
|
1586
1974
|
|
|
1587
1975
|
/** @type {Record<string, object>} */
|
|
@@ -1589,29 +1977,47 @@ async function init() {
|
|
|
1589
1977
|
|
|
1590
1978
|
// --- Project Board prompts ---
|
|
1591
1979
|
if (selectedSections.includes("projectBoard")) {
|
|
1592
|
-
const discovered = await discoverProjectBoard(
|
|
1980
|
+
const discovered = await discoverProjectBoard(
|
|
1981
|
+
ownerInput || detectedOwner,
|
|
1982
|
+
);
|
|
1593
1983
|
if (discovered) {
|
|
1594
1984
|
// Auto-discovery succeeded — use resolved IDs
|
|
1595
1985
|
optionalSections.projectBoard = {
|
|
1596
1986
|
projectNumber: discovered.projectNumber,
|
|
1597
|
-
projectId:
|
|
1598
|
-
fieldIds:
|
|
1599
|
-
optionIds:
|
|
1987
|
+
projectId: discovered.projectId,
|
|
1988
|
+
fieldIds: discovered.fieldIds,
|
|
1989
|
+
optionIds: discovered.optionIds,
|
|
1600
1990
|
};
|
|
1601
1991
|
} else {
|
|
1602
1992
|
// Fallback: manual entry (no regression from previous behaviour)
|
|
1603
1993
|
console.log("");
|
|
1604
1994
|
console.log(bold(" Project Board (manual)"));
|
|
1605
|
-
console.log(
|
|
1995
|
+
console.log(
|
|
1996
|
+
dim(
|
|
1997
|
+
" Find your project number: gh project list --owner " +
|
|
1998
|
+
(ownerInput || detectedOwner),
|
|
1999
|
+
),
|
|
2000
|
+
);
|
|
1606
2001
|
const projectNumber = await input(
|
|
1607
2002
|
" GitHub Projects v2 project number",
|
|
1608
|
-
"1"
|
|
2003
|
+
"1",
|
|
1609
2004
|
);
|
|
1610
2005
|
optionalSections.projectBoard = {
|
|
1611
2006
|
projectNumber: parseInt(projectNumber, 10) || 1,
|
|
1612
2007
|
};
|
|
1613
|
-
console.log(
|
|
1614
|
-
|
|
2008
|
+
console.log(
|
|
2009
|
+
dim(
|
|
2010
|
+
" Field IDs (PVT_/PVTSSF_ strings) must be added manually after generation.",
|
|
2011
|
+
),
|
|
2012
|
+
);
|
|
2013
|
+
console.log(
|
|
2014
|
+
dim(
|
|
2015
|
+
" Run: gh project field-list " +
|
|
2016
|
+
(projectNumber || "1") +
|
|
2017
|
+
" --owner " +
|
|
2018
|
+
(ownerInput || detectedOwner),
|
|
2019
|
+
),
|
|
2020
|
+
);
|
|
1615
2021
|
}
|
|
1616
2022
|
}
|
|
1617
2023
|
|
|
@@ -1619,18 +2025,22 @@ async function init() {
|
|
|
1619
2025
|
if (selectedSections.includes("multiRepo")) {
|
|
1620
2026
|
console.log("");
|
|
1621
2027
|
console.log(bold(" Multi-Repo"));
|
|
1622
|
-
console.log(
|
|
2028
|
+
console.log(
|
|
2029
|
+
dim(
|
|
2030
|
+
" Configure one satellite repo (add more by editing forge.yaml).",
|
|
2031
|
+
),
|
|
2032
|
+
);
|
|
1623
2033
|
const prefix = await input(
|
|
1624
2034
|
" Satellite repo prefix (e.g. 'mcp', 'sdk')",
|
|
1625
|
-
"sat"
|
|
2035
|
+
"sat",
|
|
1626
2036
|
);
|
|
1627
2037
|
const satelliteRepo = await input(
|
|
1628
2038
|
" Satellite repo name (just the name, owner will be reused)",
|
|
1629
|
-
"your-satellite-repo"
|
|
2039
|
+
"your-satellite-repo",
|
|
1630
2040
|
);
|
|
1631
2041
|
const satelliteBranch = await input(
|
|
1632
2042
|
" Satellite default/staging branch",
|
|
1633
|
-
"main"
|
|
2043
|
+
"main",
|
|
1634
2044
|
);
|
|
1635
2045
|
optionalSections.multiRepo = { prefix, satelliteRepo, satelliteBranch };
|
|
1636
2046
|
}
|
|
@@ -1641,11 +2051,11 @@ async function init() {
|
|
|
1641
2051
|
console.log(bold(" Review Context"));
|
|
1642
2052
|
const techStack = await input(
|
|
1643
2053
|
" Tech stack (e.g. Next.js, FastAPI, PostgreSQL)",
|
|
1644
|
-
"Node.js, TypeScript"
|
|
2054
|
+
"Node.js, TypeScript",
|
|
1645
2055
|
);
|
|
1646
2056
|
const context = await input(
|
|
1647
2057
|
" Architecture notes (one line; expand in forge.yaml later)",
|
|
1648
|
-
""
|
|
2058
|
+
"",
|
|
1649
2059
|
);
|
|
1650
2060
|
optionalSections.review = { techStack, context };
|
|
1651
2061
|
}
|
|
@@ -1656,7 +2066,7 @@ async function init() {
|
|
|
1656
2066
|
console.log(bold(" Verification"));
|
|
1657
2067
|
const healthEndpoint = await input(
|
|
1658
2068
|
" Health check endpoint URL",
|
|
1659
|
-
`https://api.${repoInput || detectedRepo}.io/health
|
|
2069
|
+
`https://api.${repoInput || detectedRepo}.io/health`,
|
|
1660
2070
|
);
|
|
1661
2071
|
optionalSections.verification = { healthEndpoint };
|
|
1662
2072
|
}
|
|
@@ -1669,7 +2079,7 @@ async function init() {
|
|
|
1669
2079
|
console.log(` ${YELLOW}forge.yaml already exists.${RESET}`);
|
|
1670
2080
|
const shouldOverwrite = await confirm(
|
|
1671
2081
|
" Back up existing forge.yaml and overwrite?",
|
|
1672
|
-
true
|
|
2082
|
+
true,
|
|
1673
2083
|
);
|
|
1674
2084
|
if (!shouldOverwrite) {
|
|
1675
2085
|
console.log(` ${dim("Cancelled.")} forge.yaml was not changed.`);
|
|
@@ -1680,9 +2090,12 @@ async function init() {
|
|
|
1680
2090
|
// Backup with timestamped name if .bak already exists (fix from #36)
|
|
1681
2091
|
const baseBak = join(cwd, "forge.yaml.bak");
|
|
1682
2092
|
const backupPath = existsSync(baseBak)
|
|
1683
|
-
? join(
|
|
2093
|
+
? join(
|
|
2094
|
+
cwd,
|
|
2095
|
+
`forge.yaml.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`,
|
|
2096
|
+
)
|
|
1684
2097
|
: baseBak;
|
|
1685
|
-
const backupName = backupPath
|
|
2098
|
+
const backupName = basename(backupPath);
|
|
1686
2099
|
renameSync(outputPath, backupPath);
|
|
1687
2100
|
console.log(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}`);
|
|
1688
2101
|
}
|
|
@@ -1705,12 +2118,19 @@ async function init() {
|
|
|
1705
2118
|
console.log("");
|
|
1706
2119
|
console.log(` ${GREEN}Created${RESET}: forge.yaml`);
|
|
1707
2120
|
if (selectedSections.length > 0) {
|
|
1708
|
-
console.log(
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
2121
|
+
console.log(
|
|
2122
|
+
` ${GREEN}Configured${RESET}: ${selectedSections
|
|
2123
|
+
.map(
|
|
2124
|
+
(s) =>
|
|
2125
|
+
({
|
|
2126
|
+
projectBoard: "project_board",
|
|
2127
|
+
multiRepo: "repos",
|
|
2128
|
+
review: "review",
|
|
2129
|
+
verification: "verification",
|
|
2130
|
+
})[s],
|
|
2131
|
+
)
|
|
2132
|
+
.join(", ")}`,
|
|
2133
|
+
);
|
|
1714
2134
|
}
|
|
1715
2135
|
console.log("");
|
|
1716
2136
|
_printNextSteps({ remoteDetected: ownerInput !== "your-github-org" });
|
|
@@ -1725,13 +2145,19 @@ async function init() {
|
|
|
1725
2145
|
|
|
1726
2146
|
/**
|
|
1727
2147
|
* Sanitize a string value for safe insertion into a YAML double-quoted scalar.
|
|
1728
|
-
*
|
|
2148
|
+
* Escapes backslashes (so a trailing \ does not corrupt the closing quote),
|
|
2149
|
+
* then strips double-quotes and newlines to prevent YAML injection.
|
|
2150
|
+
* Backslash escaping MUST come first — before any other replacement.
|
|
1729
2151
|
*
|
|
1730
2152
|
* @param {string} value
|
|
1731
2153
|
* @returns {string}
|
|
1732
2154
|
*/
|
|
1733
2155
|
function _sanitizeYamlValue(value) {
|
|
1734
|
-
return String(value)
|
|
2156
|
+
return String(value)
|
|
2157
|
+
.replace(/\\/g, "\\\\")
|
|
2158
|
+
.replace(/"/g, "")
|
|
2159
|
+
.replace(/[\r\n]/g, " ")
|
|
2160
|
+
.trim();
|
|
1735
2161
|
}
|
|
1736
2162
|
|
|
1737
2163
|
/**
|
|
@@ -1742,7 +2168,11 @@ function _sanitizeYamlValue(value) {
|
|
|
1742
2168
|
* @returns {string}
|
|
1743
2169
|
*/
|
|
1744
2170
|
function _sanitizePathValue(value) {
|
|
1745
|
-
return String(value)
|
|
2171
|
+
return String(value)
|
|
2172
|
+
.replace(/\\/g, "\\\\")
|
|
2173
|
+
.replace(/"/g, "")
|
|
2174
|
+
.replace(/[\r\n]/g, " ")
|
|
2175
|
+
.trim();
|
|
1746
2176
|
}
|
|
1747
2177
|
|
|
1748
2178
|
/**
|
|
@@ -1779,21 +2209,31 @@ async function discoverProjectBoard(owner) {
|
|
|
1779
2209
|
// -------------------------------------------------------------------------
|
|
1780
2210
|
let projects = [];
|
|
1781
2211
|
try {
|
|
1782
|
-
const raw = execFileSync(
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
2212
|
+
const raw = execFileSync(
|
|
2213
|
+
"gh",
|
|
2214
|
+
["project", "list", "--owner", owner, "--format", "json"],
|
|
2215
|
+
{
|
|
2216
|
+
encoding: "utf-8",
|
|
2217
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2218
|
+
},
|
|
2219
|
+
);
|
|
1786
2220
|
const parsed = JSON.parse(raw);
|
|
1787
2221
|
projects = parsed.projects || [];
|
|
1788
2222
|
} catch {
|
|
1789
2223
|
// gh not available, auth failure, or no projects access — fall back silently
|
|
1790
|
-
console.log(
|
|
2224
|
+
console.log(
|
|
2225
|
+
dim(" Could not list projects — falling back to manual entry."),
|
|
2226
|
+
);
|
|
1791
2227
|
return null;
|
|
1792
2228
|
}
|
|
1793
2229
|
|
|
1794
2230
|
if (projects.length === 0) {
|
|
1795
2231
|
console.log(dim(" No GitHub Projects v2 boards found for this owner."));
|
|
1796
|
-
console.log(
|
|
2232
|
+
console.log(
|
|
2233
|
+
dim(
|
|
2234
|
+
" You can add project_board configuration manually in forge.yaml later.",
|
|
2235
|
+
),
|
|
2236
|
+
);
|
|
1797
2237
|
return null;
|
|
1798
2238
|
}
|
|
1799
2239
|
|
|
@@ -1802,10 +2242,16 @@ async function discoverProjectBoard(owner) {
|
|
|
1802
2242
|
label: `${p.title} ${dim("(#" + p.number + ", " + (p.items?.totalCount ?? "?") + " items)")}`,
|
|
1803
2243
|
value: p.number,
|
|
1804
2244
|
}));
|
|
1805
|
-
projectChoices.push({
|
|
2245
|
+
projectChoices.push({
|
|
2246
|
+
label: dim("Skip — configure project board manually later"),
|
|
2247
|
+
value: null,
|
|
2248
|
+
});
|
|
1806
2249
|
|
|
1807
2250
|
console.log("");
|
|
1808
|
-
const selectedNumber = await select(
|
|
2251
|
+
const selectedNumber = await select(
|
|
2252
|
+
" Which GitHub Project board?",
|
|
2253
|
+
projectChoices,
|
|
2254
|
+
);
|
|
1809
2255
|
if (selectedNumber === null) {
|
|
1810
2256
|
return null;
|
|
1811
2257
|
}
|
|
@@ -1813,7 +2259,9 @@ async function discoverProjectBoard(owner) {
|
|
|
1813
2259
|
// Look up the selected project's id
|
|
1814
2260
|
const selectedProject = projects.find((p) => p.number === selectedNumber);
|
|
1815
2261
|
const projectId = selectedProject?.id ?? "";
|
|
1816
|
-
const projectTitle = _sanitizeYamlValue(
|
|
2262
|
+
const projectTitle = _sanitizeYamlValue(
|
|
2263
|
+
selectedProject?.title ?? `Project #${selectedNumber}`,
|
|
2264
|
+
);
|
|
1817
2265
|
|
|
1818
2266
|
// -------------------------------------------------------------------------
|
|
1819
2267
|
// Step 2: Fetch field list
|
|
@@ -1823,14 +2271,30 @@ async function discoverProjectBoard(owner) {
|
|
|
1823
2271
|
|
|
1824
2272
|
let fields = [];
|
|
1825
2273
|
try {
|
|
1826
|
-
const raw = execFileSync(
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
2274
|
+
const raw = execFileSync(
|
|
2275
|
+
"gh",
|
|
2276
|
+
[
|
|
2277
|
+
"project",
|
|
2278
|
+
"field-list",
|
|
2279
|
+
String(selectedNumber),
|
|
2280
|
+
"--owner",
|
|
2281
|
+
owner,
|
|
2282
|
+
"--format",
|
|
2283
|
+
"json",
|
|
2284
|
+
],
|
|
2285
|
+
{
|
|
2286
|
+
encoding: "utf-8",
|
|
2287
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2288
|
+
},
|
|
2289
|
+
);
|
|
1830
2290
|
const parsed = JSON.parse(raw);
|
|
1831
|
-
fields = (parsed.fields || []).filter(
|
|
2291
|
+
fields = (parsed.fields || []).filter(
|
|
2292
|
+
(f) => f.type === "ProjectV2SingleSelectField",
|
|
2293
|
+
);
|
|
1832
2294
|
} catch {
|
|
1833
|
-
console.log(
|
|
2295
|
+
console.log(
|
|
2296
|
+
dim(" Could not fetch project fields — falling back to manual entry."),
|
|
2297
|
+
);
|
|
1834
2298
|
return null;
|
|
1835
2299
|
}
|
|
1836
2300
|
|
|
@@ -1844,36 +2308,40 @@ async function discoverProjectBoard(owner) {
|
|
|
1844
2308
|
*/
|
|
1845
2309
|
function matchField(keyword) {
|
|
1846
2310
|
// Exact match first (case-insensitive)
|
|
1847
|
-
const exact = fields.find(
|
|
2311
|
+
const exact = fields.find(
|
|
2312
|
+
(f) => f.name.toLowerCase() === keyword.toLowerCase(),
|
|
2313
|
+
);
|
|
1848
2314
|
if (exact) return { field: exact, fuzzy: false };
|
|
1849
2315
|
// Fuzzy: name contains keyword
|
|
1850
|
-
const fuzzy = fields.find((f) =>
|
|
2316
|
+
const fuzzy = fields.find((f) =>
|
|
2317
|
+
f.name.toLowerCase().includes(keyword.toLowerCase()),
|
|
2318
|
+
);
|
|
1851
2319
|
if (fuzzy) return { field: fuzzy, fuzzy: true };
|
|
1852
2320
|
return null;
|
|
1853
2321
|
}
|
|
1854
2322
|
|
|
1855
2323
|
// Known field keys and their expected name keywords
|
|
1856
2324
|
const FIELD_TARGETS = [
|
|
1857
|
-
{ key: "status",
|
|
1858
|
-
{ key: "lane",
|
|
2325
|
+
{ key: "status", keywords: ["status"] },
|
|
2326
|
+
{ key: "lane", keywords: ["lane", "track"] },
|
|
1859
2327
|
{ key: "component", keywords: ["component"] },
|
|
1860
|
-
{ key: "priority",
|
|
1861
|
-
{ key: "workflow",
|
|
2328
|
+
{ key: "priority", keywords: ["priority"] },
|
|
2329
|
+
{ key: "workflow", keywords: ["workflow"] },
|
|
1862
2330
|
];
|
|
1863
2331
|
|
|
1864
2332
|
// Known option name → forge.yaml key mappings per field
|
|
1865
2333
|
const OPTION_MAPS = {
|
|
1866
|
-
status:
|
|
1867
|
-
lane:
|
|
1868
|
-
priority: {
|
|
2334
|
+
status: { todo: "todo", "in progress": "in_progress", done: "done" },
|
|
2335
|
+
lane: { fast: "fast", feature: "feature", sync: "sync" },
|
|
2336
|
+
priority: { p0: "p0", p1: "p1", p2: "p2", p3: "p3" },
|
|
1869
2337
|
workflow: {
|
|
1870
|
-
|
|
2338
|
+
investigating: "investigating",
|
|
1871
2339
|
"ready to build": "ready_to_build",
|
|
1872
|
-
|
|
2340
|
+
building: "building",
|
|
1873
2341
|
"in review": "in_review",
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
2342
|
+
merged: "merged",
|
|
2343
|
+
invalid: "invalid",
|
|
2344
|
+
decomposed: "decomposed",
|
|
1877
2345
|
},
|
|
1878
2346
|
component: {}, // component options vary by project — map all options by slugifying the name
|
|
1879
2347
|
};
|
|
@@ -1903,7 +2371,10 @@ async function discoverProjectBoard(owner) {
|
|
|
1903
2371
|
if (target.key === "component") {
|
|
1904
2372
|
// For component, map every option by slugifying its name (a–z, 0–9, underscore)
|
|
1905
2373
|
for (const opt of field.options || []) {
|
|
1906
|
-
const slug = opt.name
|
|
2374
|
+
const slug = opt.name
|
|
2375
|
+
.toLowerCase()
|
|
2376
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
2377
|
+
.replace(/^_|_$/g, "");
|
|
1907
2378
|
mappedOptions[slug] = opt.id;
|
|
1908
2379
|
}
|
|
1909
2380
|
} else {
|
|
@@ -1926,8 +2397,15 @@ async function discoverProjectBoard(owner) {
|
|
|
1926
2397
|
// -------------------------------------------------------------------------
|
|
1927
2398
|
for (const { key, fieldName } of fuzzyMatches) {
|
|
1928
2399
|
console.log("");
|
|
1929
|
-
console.log(
|
|
1930
|
-
|
|
2400
|
+
console.log(
|
|
2401
|
+
yellow(
|
|
2402
|
+
` Fuzzy match: field "${fieldName}" mapped to forge.yaml key "${key}"`,
|
|
2403
|
+
),
|
|
2404
|
+
);
|
|
2405
|
+
const accepted = await confirm(
|
|
2406
|
+
` Accept this mapping (${fieldName} → ${key})?`,
|
|
2407
|
+
true,
|
|
2408
|
+
);
|
|
1931
2409
|
if (!accepted) {
|
|
1932
2410
|
delete resolvedFieldIds[key];
|
|
1933
2411
|
delete resolvedOptionIds[key];
|
|
@@ -1944,14 +2422,21 @@ async function discoverProjectBoard(owner) {
|
|
|
1944
2422
|
"Fields:",
|
|
1945
2423
|
];
|
|
1946
2424
|
for (const [key, id] of Object.entries(resolvedFieldIds)) {
|
|
1947
|
-
const optCount = resolvedOptionIds[key]
|
|
1948
|
-
|
|
2425
|
+
const optCount = resolvedOptionIds[key]
|
|
2426
|
+
? Object.keys(resolvedOptionIds[key]).length
|
|
2427
|
+
: 0;
|
|
2428
|
+
const optLabel =
|
|
2429
|
+
optCount > 0 ? ` (${optCount} options)` : " (no options mapped)";
|
|
1949
2430
|
summaryLines.push(` ${key.padEnd(10)}: ${id}${optLabel}`);
|
|
1950
2431
|
}
|
|
1951
|
-
const unmapped = FIELD_TARGETS.map((t) => t.key).filter(
|
|
2432
|
+
const unmapped = FIELD_TARGETS.map((t) => t.key).filter(
|
|
2433
|
+
(k) => !resolvedFieldIds[k],
|
|
2434
|
+
);
|
|
1952
2435
|
if (unmapped.length > 0) {
|
|
1953
2436
|
summaryLines.push("");
|
|
1954
|
-
summaryLines.push(
|
|
2437
|
+
summaryLines.push(
|
|
2438
|
+
`Not found: ${unmapped.join(", ")} — placeholders will be used`,
|
|
2439
|
+
);
|
|
1955
2440
|
}
|
|
1956
2441
|
|
|
1957
2442
|
console.log("");
|
|
@@ -1959,7 +2444,11 @@ async function discoverProjectBoard(owner) {
|
|
|
1959
2444
|
|
|
1960
2445
|
const confirmed = await confirm(" Use this configuration?", true);
|
|
1961
2446
|
if (!confirmed) {
|
|
1962
|
-
console.log(
|
|
2447
|
+
console.log(
|
|
2448
|
+
dim(
|
|
2449
|
+
" Skipped auto-discovery. You can add project_board manually in forge.yaml.",
|
|
2450
|
+
),
|
|
2451
|
+
);
|
|
1963
2452
|
return null;
|
|
1964
2453
|
}
|
|
1965
2454
|
|
|
@@ -1992,40 +2481,66 @@ async function discoverProjectBoard(owner) {
|
|
|
1992
2481
|
* @param {object} [opts.optionalSections.review] - review section config
|
|
1993
2482
|
* @param {object} [opts.optionalSections.verification] - verification section config
|
|
1994
2483
|
*/
|
|
1995
|
-
function buildForgeYamlContent({
|
|
2484
|
+
function buildForgeYamlContent({
|
|
2485
|
+
owner,
|
|
2486
|
+
repo,
|
|
2487
|
+
projectName,
|
|
2488
|
+
description,
|
|
2489
|
+
root,
|
|
2490
|
+
worktreeBase,
|
|
2491
|
+
defaultBranch,
|
|
2492
|
+
stagingBranch,
|
|
2493
|
+
optionalSections = {},
|
|
2494
|
+
}) {
|
|
1996
2495
|
// Sanitize all user-supplied string values before template interpolation to prevent
|
|
1997
2496
|
// YAML injection via double-quotes or embedded newlines in double-quoted scalars.
|
|
1998
|
-
owner
|
|
1999
|
-
repo
|
|
2000
|
-
projectName
|
|
2001
|
-
description
|
|
2497
|
+
owner = _sanitizeYamlValue(owner);
|
|
2498
|
+
repo = _sanitizeYamlValue(repo);
|
|
2499
|
+
projectName = _sanitizeYamlValue(projectName);
|
|
2500
|
+
description = _sanitizeYamlValue(description);
|
|
2002
2501
|
defaultBranch = _sanitizeYamlValue(defaultBranch);
|
|
2003
2502
|
stagingBranch = _sanitizeYamlValue(stagingBranch);
|
|
2004
2503
|
// Path values: also escape backslashes so Windows paths are valid YAML.
|
|
2005
2504
|
// Preserve rawRoot for use in path.join() operations — join() expects raw OS paths,
|
|
2006
2505
|
// not YAML-escaped strings. Always apply _sanitizePathValue() to the join() result.
|
|
2007
2506
|
const rawRoot = root;
|
|
2008
|
-
root
|
|
2009
|
-
worktreeBase
|
|
2507
|
+
root = _sanitizePathValue(root);
|
|
2508
|
+
worktreeBase = _sanitizePathValue(worktreeBase);
|
|
2010
2509
|
|
|
2011
2510
|
const { projectBoard, multiRepo, review, verification } = optionalSections;
|
|
2012
2511
|
|
|
2013
2512
|
// 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
|
-
|
|
2513
|
+
const safeMultiRepo = multiRepo
|
|
2514
|
+
? {
|
|
2515
|
+
prefix: _sanitizeYamlValue(multiRepo.prefix || "sat"),
|
|
2516
|
+
satelliteRepo: _sanitizeYamlValue(
|
|
2517
|
+
multiRepo.satelliteRepo || "your-satellite-repo",
|
|
2518
|
+
),
|
|
2519
|
+
satelliteBranch: _sanitizeYamlValue(
|
|
2520
|
+
multiRepo.satelliteBranch || "main",
|
|
2521
|
+
),
|
|
2522
|
+
}
|
|
2523
|
+
: null;
|
|
2524
|
+
|
|
2525
|
+
const safeReview = review
|
|
2526
|
+
? {
|
|
2527
|
+
techStack: _sanitizeYamlValue(
|
|
2528
|
+
review.techStack || "Node.js, TypeScript, PostgreSQL",
|
|
2529
|
+
),
|
|
2530
|
+
// context uses a block scalar (|) — only strip double-quotes; newlines are handled by split/join
|
|
2531
|
+
context: (
|
|
2532
|
+
review.context || "Add architecture notes and conventions here."
|
|
2533
|
+
).replace(/"/g, ""),
|
|
2534
|
+
}
|
|
2535
|
+
: null;
|
|
2536
|
+
|
|
2537
|
+
const safeVerification = verification
|
|
2538
|
+
? {
|
|
2539
|
+
healthEndpoint: _sanitizeYamlValue(
|
|
2540
|
+
verification.healthEndpoint || `https://api.${repo}.io/health`,
|
|
2541
|
+
),
|
|
2542
|
+
}
|
|
2543
|
+
: null;
|
|
2029
2544
|
|
|
2030
2545
|
// --- repos section ---
|
|
2031
2546
|
const reposSection = safeMultiRepo
|
|
@@ -2059,7 +2574,9 @@ function buildForgeYamlContent({ owner, repo, projectName, description, root, wo
|
|
|
2059
2574
|
* Only writes fields that have at least one mapped option.
|
|
2060
2575
|
*/
|
|
2061
2576
|
function _buildOptionIdsBlock(optionIds) {
|
|
2062
|
-
const entries = Object.entries(optionIds).filter(
|
|
2577
|
+
const entries = Object.entries(optionIds).filter(
|
|
2578
|
+
([, opts]) => Object.keys(opts).length > 0,
|
|
2579
|
+
);
|
|
2063
2580
|
if (entries.length === 0) return "";
|
|
2064
2581
|
const lines = [" option_ids:"];
|
|
2065
2582
|
for (const [fieldKey, opts] of entries) {
|
|
@@ -2071,13 +2588,16 @@ function buildForgeYamlContent({ owner, repo, projectName, description, root, wo
|
|
|
2071
2588
|
return "\n" + lines.join("\n");
|
|
2072
2589
|
}
|
|
2073
2590
|
|
|
2074
|
-
const fieldIdsBlock = FIELD_KEYS
|
|
2075
|
-
|
|
2076
|
-
|
|
2591
|
+
const fieldIdsBlock = FIELD_KEYS.map(
|
|
2592
|
+
(k) =>
|
|
2593
|
+
` ${k}: "${resolvedFieldIds[k] ?? "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"}"`,
|
|
2594
|
+
).join("\n");
|
|
2077
2595
|
|
|
2078
|
-
const optionIdsBlock = projectBoard?.optionIds
|
|
2596
|
+
const optionIdsBlock = projectBoard?.optionIds
|
|
2597
|
+
? _buildOptionIdsBlock(resolvedOptionIds)
|
|
2598
|
+
: "";
|
|
2079
2599
|
|
|
2080
|
-
const hasDiscoveredId = !!
|
|
2600
|
+
const hasDiscoveredId = !!projectBoard?.projectId;
|
|
2081
2601
|
|
|
2082
2602
|
const projectBoardSection = projectBoard
|
|
2083
2603
|
? `project_board:
|
|
@@ -2212,10 +2732,16 @@ function _writeForgeYaml(opts) {
|
|
|
2212
2732
|
function _printNextSteps({ remoteDetected }) {
|
|
2213
2733
|
console.log(`${BOLD}Next steps:${RESET}`);
|
|
2214
2734
|
if (!remoteDetected) {
|
|
2215
|
-
console.log(
|
|
2735
|
+
console.log(
|
|
2736
|
+
` ${YELLOW}!${RESET} Edit ${CYAN}forge.yaml${RESET} — set project.owner and project.repo`,
|
|
2737
|
+
);
|
|
2216
2738
|
}
|
|
2217
|
-
console.log(
|
|
2218
|
-
|
|
2739
|
+
console.log(
|
|
2740
|
+
` 1. Add ${CYAN}forge.yaml${RESET} to ${CYAN}.gitignore${RESET} if it contains sensitive paths`,
|
|
2741
|
+
);
|
|
2742
|
+
console.log(
|
|
2743
|
+
` 2. Run ${CYAN}/forgedock-init${RESET} inside Claude Code for guided AI-powered setup`,
|
|
2744
|
+
);
|
|
2219
2745
|
console.log("");
|
|
2220
2746
|
}
|
|
2221
2747
|
|
|
@@ -2292,11 +2818,19 @@ function _parseSatelliteRepos(content) {
|
|
|
2292
2818
|
}
|
|
2293
2819
|
if (!inRepos) continue;
|
|
2294
2820
|
|
|
2295
|
-
if (line.trim() === "default:") {
|
|
2296
|
-
|
|
2821
|
+
if (line.trim() === "default:") {
|
|
2822
|
+
continue;
|
|
2823
|
+
}
|
|
2824
|
+
if (line.trim() === "satellites:") {
|
|
2825
|
+
inSatellites = true;
|
|
2826
|
+
continue;
|
|
2827
|
+
}
|
|
2297
2828
|
|
|
2298
2829
|
if (inSatellites && /^\s{6,}repo:/.test(line)) {
|
|
2299
|
-
const raw = line
|
|
2830
|
+
const raw = line
|
|
2831
|
+
.replace(/^\s+repo:\s*/, "")
|
|
2832
|
+
.replace(/^["']|["']$/g, "")
|
|
2833
|
+
.trim();
|
|
2300
2834
|
if (raw) repos.push(raw);
|
|
2301
2835
|
}
|
|
2302
2836
|
}
|
|
@@ -2318,7 +2852,11 @@ async function validate(forgeYamlPath) {
|
|
|
2318
2852
|
|
|
2319
2853
|
// 1. Read forge.yaml
|
|
2320
2854
|
if (!existsSync(forgeYamlPath)) {
|
|
2321
|
-
checks.push({
|
|
2855
|
+
checks.push({
|
|
2856
|
+
label: "forge.yaml found",
|
|
2857
|
+
status: "error",
|
|
2858
|
+
note: `Not found: ${forgeYamlPath}`,
|
|
2859
|
+
});
|
|
2322
2860
|
_renderValidationSummary(checks);
|
|
2323
2861
|
return { passed: false, checks };
|
|
2324
2862
|
}
|
|
@@ -2327,7 +2865,11 @@ async function validate(forgeYamlPath) {
|
|
|
2327
2865
|
try {
|
|
2328
2866
|
content = readFileSync(forgeYamlPath, "utf-8");
|
|
2329
2867
|
} catch (err) {
|
|
2330
|
-
checks.push({
|
|
2868
|
+
checks.push({
|
|
2869
|
+
label: "forge.yaml found",
|
|
2870
|
+
status: "error",
|
|
2871
|
+
note: `Cannot read: ${err.message}`,
|
|
2872
|
+
});
|
|
2331
2873
|
_renderValidationSummary(checks);
|
|
2332
2874
|
return { passed: false, checks };
|
|
2333
2875
|
}
|
|
@@ -2335,7 +2877,13 @@ async function validate(forgeYamlPath) {
|
|
|
2335
2877
|
checks.push({ label: "forge.yaml found", status: "ok", note: forgeYamlPath });
|
|
2336
2878
|
|
|
2337
2879
|
// 2. Required fields
|
|
2338
|
-
const PLACEHOLDERS = new Set([
|
|
2880
|
+
const PLACEHOLDERS = new Set([
|
|
2881
|
+
"your-github-org",
|
|
2882
|
+
"your-repo-name",
|
|
2883
|
+
"",
|
|
2884
|
+
"your-org",
|
|
2885
|
+
"your-repo",
|
|
2886
|
+
]);
|
|
2339
2887
|
|
|
2340
2888
|
const owner = _parseYamlKey(content, "project.owner");
|
|
2341
2889
|
const repo = _parseYamlKey(content, "project.repo");
|
|
@@ -2350,7 +2898,11 @@ async function validate(forgeYamlPath) {
|
|
|
2350
2898
|
if (!defaultBranch) missingFields.push("branches.default");
|
|
2351
2899
|
|
|
2352
2900
|
if (missingFields.length === 0) {
|
|
2353
|
-
checks.push({
|
|
2901
|
+
checks.push({
|
|
2902
|
+
label: "Required fields",
|
|
2903
|
+
status: "ok",
|
|
2904
|
+
note: `${owner}/${repo}`,
|
|
2905
|
+
});
|
|
2354
2906
|
} else {
|
|
2355
2907
|
checks.push({
|
|
2356
2908
|
label: "Required fields",
|
|
@@ -2364,20 +2916,36 @@ async function validate(forgeYamlPath) {
|
|
|
2364
2916
|
if (existsSync(root)) {
|
|
2365
2917
|
checks.push({ label: "paths.root exists", status: "ok", note: root });
|
|
2366
2918
|
} else {
|
|
2367
|
-
checks.push({
|
|
2919
|
+
checks.push({
|
|
2920
|
+
label: "paths.root exists",
|
|
2921
|
+
status: "warn",
|
|
2922
|
+
note: `Directory not found: ${root}`,
|
|
2923
|
+
});
|
|
2368
2924
|
}
|
|
2369
2925
|
}
|
|
2370
2926
|
|
|
2371
2927
|
// 4. Create worktree_base if missing
|
|
2372
2928
|
if (worktreeBase) {
|
|
2373
2929
|
if (existsSync(worktreeBase)) {
|
|
2374
|
-
checks.push({
|
|
2930
|
+
checks.push({
|
|
2931
|
+
label: "worktree_base exists",
|
|
2932
|
+
status: "ok",
|
|
2933
|
+
note: worktreeBase,
|
|
2934
|
+
});
|
|
2375
2935
|
} else {
|
|
2376
2936
|
try {
|
|
2377
2937
|
await mkdir(worktreeBase, { recursive: true });
|
|
2378
|
-
checks.push({
|
|
2938
|
+
checks.push({
|
|
2939
|
+
label: "worktree_base exists",
|
|
2940
|
+
status: "ok",
|
|
2941
|
+
note: `Created: ${worktreeBase}`,
|
|
2942
|
+
});
|
|
2379
2943
|
} catch (err) {
|
|
2380
|
-
checks.push({
|
|
2944
|
+
checks.push({
|
|
2945
|
+
label: "worktree_base exists",
|
|
2946
|
+
status: "warn",
|
|
2947
|
+
note: `Cannot create: ${err.message}`,
|
|
2948
|
+
});
|
|
2381
2949
|
}
|
|
2382
2950
|
}
|
|
2383
2951
|
}
|
|
@@ -2385,11 +2953,19 @@ async function validate(forgeYamlPath) {
|
|
|
2385
2953
|
// 5. GitHub repo access
|
|
2386
2954
|
if (owner && repo && !PLACEHOLDERS.has(owner) && !PLACEHOLDERS.has(repo)) {
|
|
2387
2955
|
try {
|
|
2388
|
-
execFileSync(
|
|
2389
|
-
|
|
2390
|
-
|
|
2956
|
+
execFileSync(
|
|
2957
|
+
"gh",
|
|
2958
|
+
["repo", "view", `${owner}/${repo}`, "--json", "name"],
|
|
2959
|
+
{
|
|
2960
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2961
|
+
timeout: 10000,
|
|
2962
|
+
},
|
|
2963
|
+
);
|
|
2964
|
+
checks.push({
|
|
2965
|
+
label: "GitHub repo accessible",
|
|
2966
|
+
status: "ok",
|
|
2967
|
+
note: `${owner}/${repo}`,
|
|
2391
2968
|
});
|
|
2392
|
-
checks.push({ label: "GitHub repo accessible", status: "ok", note: `${owner}/${repo}` });
|
|
2393
2969
|
} catch {
|
|
2394
2970
|
checks.push({
|
|
2395
2971
|
label: "GitHub repo accessible",
|
|
@@ -2398,29 +2974,51 @@ async function validate(forgeYamlPath) {
|
|
|
2398
2974
|
});
|
|
2399
2975
|
}
|
|
2400
2976
|
} else {
|
|
2401
|
-
checks.push({
|
|
2977
|
+
checks.push({
|
|
2978
|
+
label: "GitHub repo accessible",
|
|
2979
|
+
status: "warn",
|
|
2980
|
+
note: "Skipped — owner/repo not set",
|
|
2981
|
+
});
|
|
2402
2982
|
}
|
|
2403
2983
|
|
|
2404
2984
|
// 6. Branch existence on remote
|
|
2405
2985
|
const stagingBranch = _parseYamlKey(content, "branches.staging");
|
|
2406
|
-
const branchesToCheck = [
|
|
2986
|
+
const branchesToCheck = [
|
|
2987
|
+
...new Set([defaultBranch, stagingBranch].filter(Boolean)),
|
|
2988
|
+
];
|
|
2407
2989
|
|
|
2408
2990
|
if (root && existsSync(root) && branchesToCheck.length > 0) {
|
|
2409
2991
|
for (const branch of branchesToCheck) {
|
|
2410
2992
|
try {
|
|
2411
|
-
const result = execFileSync(
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2993
|
+
const result = execFileSync(
|
|
2994
|
+
"git",
|
|
2995
|
+
["ls-remote", "--heads", "origin", branch],
|
|
2996
|
+
{
|
|
2997
|
+
cwd: root,
|
|
2998
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2999
|
+
encoding: "utf-8",
|
|
3000
|
+
timeout: 10000,
|
|
3001
|
+
},
|
|
3002
|
+
);
|
|
2417
3003
|
if (result.trim()) {
|
|
2418
|
-
checks.push({
|
|
3004
|
+
checks.push({
|
|
3005
|
+
label: `Branch: ${branch}`,
|
|
3006
|
+
status: "ok",
|
|
3007
|
+
note: "Exists on remote",
|
|
3008
|
+
});
|
|
2419
3009
|
} else {
|
|
2420
|
-
checks.push({
|
|
3010
|
+
checks.push({
|
|
3011
|
+
label: `Branch: ${branch}`,
|
|
3012
|
+
status: "warn",
|
|
3013
|
+
note: "Not found on remote",
|
|
3014
|
+
});
|
|
2421
3015
|
}
|
|
2422
3016
|
} catch {
|
|
2423
|
-
checks.push({
|
|
3017
|
+
checks.push({
|
|
3018
|
+
label: `Branch: ${branch}`,
|
|
3019
|
+
status: "warn",
|
|
3020
|
+
note: "Cannot verify — git error",
|
|
3021
|
+
});
|
|
2424
3022
|
}
|
|
2425
3023
|
}
|
|
2426
3024
|
}
|
|
@@ -2448,31 +3046,58 @@ async function validate(forgeYamlPath) {
|
|
|
2448
3046
|
const result = execFileSync(
|
|
2449
3047
|
"gh",
|
|
2450
3048
|
[
|
|
2451
|
-
"api",
|
|
2452
|
-
"
|
|
2453
|
-
"-
|
|
3049
|
+
"api",
|
|
3050
|
+
"graphql",
|
|
3051
|
+
"-F",
|
|
3052
|
+
`id=${projectId}`,
|
|
3053
|
+
"-f",
|
|
3054
|
+
"query=query($id: ID!) { node(id: $id) { id __typename } }",
|
|
2454
3055
|
],
|
|
2455
|
-
{
|
|
3056
|
+
{
|
|
3057
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
3058
|
+
encoding: "utf-8",
|
|
3059
|
+
timeout: 10000,
|
|
3060
|
+
},
|
|
2456
3061
|
);
|
|
2457
3062
|
const parsed = JSON.parse(result);
|
|
2458
3063
|
if (parsed?.data?.node?.id) {
|
|
2459
|
-
checks.push({
|
|
3064
|
+
checks.push({
|
|
3065
|
+
label: "Project board configured",
|
|
3066
|
+
status: "ok",
|
|
3067
|
+
note: `${projectId.slice(0, 16)}... resolves`,
|
|
3068
|
+
});
|
|
2460
3069
|
} else {
|
|
2461
|
-
checks.push({
|
|
3070
|
+
checks.push({
|
|
3071
|
+
label: "Project board configured",
|
|
3072
|
+
status: "warn",
|
|
3073
|
+
note: "project_id may be invalid — verify with: gh project list",
|
|
3074
|
+
});
|
|
2462
3075
|
}
|
|
2463
3076
|
} catch {
|
|
2464
|
-
checks.push({
|
|
3077
|
+
checks.push({
|
|
3078
|
+
label: "Project board configured",
|
|
3079
|
+
status: "warn",
|
|
3080
|
+
note: "Cannot verify project_id — gh error",
|
|
3081
|
+
});
|
|
2465
3082
|
}
|
|
2466
3083
|
}
|
|
2467
3084
|
} else {
|
|
2468
|
-
checks.push({
|
|
3085
|
+
checks.push({
|
|
3086
|
+
label: "Project board",
|
|
3087
|
+
status: "warn",
|
|
3088
|
+
note: "Not configured (optional)",
|
|
3089
|
+
});
|
|
2469
3090
|
}
|
|
2470
3091
|
|
|
2471
3092
|
// 8. Satellite repo access
|
|
2472
3093
|
if (_sectionActive(content, "repos")) {
|
|
2473
3094
|
const satellites = _parseSatelliteRepos(content);
|
|
2474
3095
|
if (satellites.length === 0) {
|
|
2475
|
-
checks.push({
|
|
3096
|
+
checks.push({
|
|
3097
|
+
label: "Satellite repos",
|
|
3098
|
+
status: "warn",
|
|
3099
|
+
note: "repos: section active but no satellites found",
|
|
3100
|
+
});
|
|
2476
3101
|
} else {
|
|
2477
3102
|
for (const sat of satellites) {
|
|
2478
3103
|
try {
|
|
@@ -2480,14 +3105,26 @@ async function validate(forgeYamlPath) {
|
|
|
2480
3105
|
stdio: ["pipe", "pipe", "pipe"],
|
|
2481
3106
|
timeout: 10000,
|
|
2482
3107
|
});
|
|
2483
|
-
checks.push({
|
|
3108
|
+
checks.push({
|
|
3109
|
+
label: `Satellite: ${sat}`,
|
|
3110
|
+
status: "ok",
|
|
3111
|
+
note: "Accessible",
|
|
3112
|
+
});
|
|
2484
3113
|
} catch {
|
|
2485
|
-
checks.push({
|
|
3114
|
+
checks.push({
|
|
3115
|
+
label: `Satellite: ${sat}`,
|
|
3116
|
+
status: "warn",
|
|
3117
|
+
note: "Cannot access — check repo name or gh auth",
|
|
3118
|
+
});
|
|
2486
3119
|
}
|
|
2487
3120
|
}
|
|
2488
3121
|
}
|
|
2489
3122
|
} else {
|
|
2490
|
-
checks.push({
|
|
3123
|
+
checks.push({
|
|
3124
|
+
label: "Satellite repos",
|
|
3125
|
+
status: "warn",
|
|
3126
|
+
note: "Not configured (optional)",
|
|
3127
|
+
});
|
|
2491
3128
|
}
|
|
2492
3129
|
|
|
2493
3130
|
_renderValidationSummary(checks);
|
|
@@ -2521,17 +3158,25 @@ function _renderValidationSummary(checks) {
|
|
|
2521
3158
|
}
|
|
2522
3159
|
|
|
2523
3160
|
const hasErrors = checks.some((c) => c.status === "error");
|
|
2524
|
-
const boardOk = checks.some(
|
|
3161
|
+
const boardOk = checks.some(
|
|
3162
|
+
(c) => c.label === "Project board configured" && c.status === "ok",
|
|
3163
|
+
);
|
|
2525
3164
|
|
|
2526
3165
|
lines.push("");
|
|
2527
3166
|
lines.push(` ${bold("Next steps:")}`);
|
|
2528
3167
|
if (hasErrors) {
|
|
2529
|
-
lines.push(
|
|
3168
|
+
lines.push(
|
|
3169
|
+
` ${dim("•")} Edit ${cyan("forge.yaml")} to fix the errors above`,
|
|
3170
|
+
);
|
|
2530
3171
|
}
|
|
2531
3172
|
if (!boardOk) {
|
|
2532
|
-
lines.push(
|
|
3173
|
+
lines.push(
|
|
3174
|
+
` ${dim("•")} Run ${cyan("/forgedock-init")} for AI-powered optional section setup`,
|
|
3175
|
+
);
|
|
2533
3176
|
}
|
|
2534
|
-
lines.push(
|
|
3177
|
+
lines.push(
|
|
3178
|
+
` ${dim("•")} Run ${cyan("/work-on next")} to start your first task`,
|
|
3179
|
+
);
|
|
2535
3180
|
lines.push("");
|
|
2536
3181
|
|
|
2537
3182
|
process.stdout.write(box(lines, { title: "Config Validation" }));
|
|
@@ -2541,7 +3186,7 @@ function _renderValidationSummary(checks) {
|
|
|
2541
3186
|
// Bot credentials — store in ~/.forgedock/credentials.json (NOT forge.yaml)
|
|
2542
3187
|
// ---------------------------------------------------------------------------
|
|
2543
3188
|
|
|
2544
|
-
const FORGEDOCK_HOME = join(
|
|
3189
|
+
const FORGEDOCK_HOME = join(HOME, ".forgedock");
|
|
2545
3190
|
const CREDENTIALS_FILE = join(FORGEDOCK_HOME, "credentials.json");
|
|
2546
3191
|
|
|
2547
3192
|
/**
|
|
@@ -2558,7 +3203,10 @@ async function saveBotCredentials(creds) {
|
|
|
2558
3203
|
chmodSync(FORGEDOCK_HOME, 0o700);
|
|
2559
3204
|
const existing = loadBotCredentials() ?? {};
|
|
2560
3205
|
const updated = { ...existing, bot: creds };
|
|
2561
|
-
writeFileSync(CREDENTIALS_FILE, JSON.stringify(updated, null, 2) + "\n", {
|
|
3206
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(updated, null, 2) + "\n", {
|
|
3207
|
+
encoding: "utf-8",
|
|
3208
|
+
mode: 0o600,
|
|
3209
|
+
});
|
|
2562
3210
|
// Tighten permissions on files created before this fix (mode is only applied at creation time)
|
|
2563
3211
|
chmodSync(CREDENTIALS_FILE, 0o600);
|
|
2564
3212
|
}
|
|
@@ -2593,12 +3241,14 @@ function loadBotCredentials() {
|
|
|
2593
3241
|
function generateAppJwt(appId, privateKey) {
|
|
2594
3242
|
const now = Math.floor(Date.now() / 1000);
|
|
2595
3243
|
const payload = {
|
|
2596
|
-
iat: now - 60,
|
|
2597
|
-
exp: now + 600,
|
|
3244
|
+
iat: now - 60, // issued 60s ago to account for clock skew
|
|
3245
|
+
exp: now + 600, // expires in 10 minutes (GitHub max)
|
|
2598
3246
|
iss: appId,
|
|
2599
3247
|
};
|
|
2600
3248
|
|
|
2601
|
-
const header = Buffer.from(
|
|
3249
|
+
const header = Buffer.from(
|
|
3250
|
+
JSON.stringify({ alg: "RS256", typ: "JWT" }),
|
|
3251
|
+
).toString("base64url");
|
|
2602
3252
|
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
2603
3253
|
const unsigned = `${header}.${body}`;
|
|
2604
3254
|
|
|
@@ -2619,17 +3269,19 @@ function generateAppJwt(appId, privateKey) {
|
|
|
2619
3269
|
*/
|
|
2620
3270
|
async function connectExistingBot() {
|
|
2621
3271
|
console.log("");
|
|
2622
|
-
console.log(
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
3272
|
+
console.log(
|
|
3273
|
+
box(
|
|
3274
|
+
[
|
|
3275
|
+
`${bold("Connect an existing GitHub App")}`,
|
|
3276
|
+
"",
|
|
3277
|
+
"You'll need:",
|
|
3278
|
+
` • App ID (GitHub → Settings → Developer settings → GitHub Apps → your app)`,
|
|
3279
|
+
` • Private key file (.pem downloaded from app settings)`,
|
|
3280
|
+
` • Installation ID (GitHub → your org → Settings → Installed Apps → your app → configure → URL)`,
|
|
3281
|
+
].join("\n"),
|
|
3282
|
+
{ title: "Connect GitHub App" },
|
|
3283
|
+
),
|
|
3284
|
+
);
|
|
2633
3285
|
console.log("");
|
|
2634
3286
|
|
|
2635
3287
|
const appId = await input("App ID (numeric):", "");
|
|
@@ -2639,12 +3291,20 @@ async function connectExistingBot() {
|
|
|
2639
3291
|
}
|
|
2640
3292
|
|
|
2641
3293
|
const privateKeyPath = await input("Private key path (.pem):", "");
|
|
2642
|
-
const
|
|
2643
|
-
const
|
|
3294
|
+
const trimmedKeyPath = privateKeyPath.trim();
|
|
3295
|
+
const home = os.homedir();
|
|
3296
|
+
const expanded = trimmedKeyPath.startsWith("~")
|
|
3297
|
+
? join(home, trimmedKeyPath.slice(1))
|
|
3298
|
+
: trimmedKeyPath;
|
|
2644
3299
|
const resolvedKeyPath = resolve(expanded);
|
|
2645
|
-
if (
|
|
2646
|
-
|
|
2647
|
-
|
|
3300
|
+
if (trimmedKeyPath.startsWith("~")) {
|
|
3301
|
+
const rel = relative(home, resolvedKeyPath);
|
|
3302
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
3303
|
+
console.log(
|
|
3304
|
+
` ${RED}Invalid path: cannot traverse outside home directory.${RESET}`,
|
|
3305
|
+
);
|
|
3306
|
+
return false;
|
|
3307
|
+
}
|
|
2648
3308
|
}
|
|
2649
3309
|
if (!existsSync(resolvedKeyPath)) {
|
|
2650
3310
|
console.log(` ${RED}File not found: ${resolvedKeyPath}${RESET}`);
|
|
@@ -2653,7 +3313,9 @@ async function connectExistingBot() {
|
|
|
2653
3313
|
|
|
2654
3314
|
const installationId = await input("Installation ID (numeric):", "");
|
|
2655
3315
|
if (!installationId || !/^\d+$/.test(installationId.trim())) {
|
|
2656
|
-
console.log(
|
|
3316
|
+
console.log(
|
|
3317
|
+
` ${RED}Invalid Installation ID — must be a numeric string.${RESET}`,
|
|
3318
|
+
);
|
|
2657
3319
|
return false;
|
|
2658
3320
|
}
|
|
2659
3321
|
|
|
@@ -2679,11 +3341,15 @@ async function connectExistingBot() {
|
|
|
2679
3341
|
});
|
|
2680
3342
|
if (!resp.ok) {
|
|
2681
3343
|
const body = await resp.json().catch(() => ({}));
|
|
2682
|
-
console.log(
|
|
3344
|
+
console.log(
|
|
3345
|
+
` ${RED}GitHub API error ${resp.status}: ${body.message ?? "unknown"}${RESET}`,
|
|
3346
|
+
);
|
|
2683
3347
|
return false;
|
|
2684
3348
|
}
|
|
2685
3349
|
const appData = await resp.json();
|
|
2686
|
-
console.log(
|
|
3350
|
+
console.log(
|
|
3351
|
+
` ${GREEN}Connected!${RESET} App: ${bold(appData.name)} (id: ${appId.trim()})`,
|
|
3352
|
+
);
|
|
2687
3353
|
} catch (err) {
|
|
2688
3354
|
console.log(` ${RED}Validation failed: ${err.message}${RESET}`);
|
|
2689
3355
|
return false;
|
|
@@ -2716,8 +3382,8 @@ async function botStatus() {
|
|
|
2716
3382
|
if (!creds) {
|
|
2717
3383
|
console.log(
|
|
2718
3384
|
` ${YELLOW}No bot credentials found.${RESET}\n` +
|
|
2719
|
-
|
|
2720
|
-
|
|
3385
|
+
` Run ${cyan("npx forgedock bot setup")} to configure a GitHub App, or\n` +
|
|
3386
|
+
` add credentials to ${cyan(CREDENTIALS_FILE)}.`,
|
|
2721
3387
|
);
|
|
2722
3388
|
console.log("");
|
|
2723
3389
|
return;
|
|
@@ -2730,7 +3396,9 @@ async function botStatus() {
|
|
|
2730
3396
|
|
|
2731
3397
|
// Check private key file
|
|
2732
3398
|
if (!existsSync(creds.privateKeyPath)) {
|
|
2733
|
-
console.log(
|
|
3399
|
+
console.log(
|
|
3400
|
+
` ${RED}Private key file not found: ${creds.privateKeyPath}${RESET}`,
|
|
3401
|
+
);
|
|
2734
3402
|
console.log(` Run ${cyan("npx forgedock bot setup")} to reconfigure.`);
|
|
2735
3403
|
console.log("");
|
|
2736
3404
|
return;
|
|
@@ -2759,12 +3427,16 @@ async function botStatus() {
|
|
|
2759
3427
|
});
|
|
2760
3428
|
if (!appResp.ok) {
|
|
2761
3429
|
const body = await appResp.json().catch(() => ({}));
|
|
2762
|
-
console.log(
|
|
3430
|
+
console.log(
|
|
3431
|
+
` ${RED}GitHub API error ${appResp.status}: ${body.message ?? "unknown"}${RESET}`,
|
|
3432
|
+
);
|
|
2763
3433
|
console.log("");
|
|
2764
3434
|
return;
|
|
2765
3435
|
}
|
|
2766
3436
|
const appData = await appResp.json();
|
|
2767
|
-
console.log(
|
|
3437
|
+
console.log(
|
|
3438
|
+
` ${GREEN}Connected${RESET} — App: ${bold(appData.name)} (slug: @${appData.slug})`,
|
|
3439
|
+
);
|
|
2768
3440
|
|
|
2769
3441
|
// Rate limit for app
|
|
2770
3442
|
const rlResp = await fetch("https://api.github.com/rate_limit", {
|
|
@@ -2780,7 +3452,9 @@ async function botStatus() {
|
|
|
2780
3452
|
if (core) {
|
|
2781
3453
|
const used = core.limit - core.remaining;
|
|
2782
3454
|
const resetTime = new Date(core.reset * 1000).toLocaleTimeString();
|
|
2783
|
-
console.log(
|
|
3455
|
+
console.log(
|
|
3456
|
+
` Rate limit: ${cyan(core.remaining)}/${core.limit} remaining (${used} used, resets ${resetTime})`,
|
|
3457
|
+
);
|
|
2784
3458
|
}
|
|
2785
3459
|
}
|
|
2786
3460
|
} catch (err) {
|
|
@@ -2801,19 +3475,21 @@ async function botStatus() {
|
|
|
2801
3475
|
*/
|
|
2802
3476
|
async function botSetup() {
|
|
2803
3477
|
console.log("");
|
|
2804
|
-
console.log(
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
3478
|
+
console.log(
|
|
3479
|
+
box(
|
|
3480
|
+
[
|
|
3481
|
+
`${bold("GitHub Bot Setup")} ${dim("(Optional)")}`,
|
|
3482
|
+
"",
|
|
3483
|
+
"A GitHub App gives ForgeDock its own identity",
|
|
3484
|
+
"for pipeline operations. Benefits:",
|
|
3485
|
+
` ${cyan("•")} Separate audit trail for bot actions`,
|
|
3486
|
+
` ${cyan("•")} Higher API rate limits`,
|
|
3487
|
+
` ${cyan("•")} Webhook-driven automation (future)`,
|
|
3488
|
+
` ${cyan("•")} Multi-user team support`,
|
|
3489
|
+
].join("\n"),
|
|
3490
|
+
{ title: "GitHub Bot" },
|
|
3491
|
+
),
|
|
3492
|
+
);
|
|
2817
3493
|
console.log("");
|
|
2818
3494
|
|
|
2819
3495
|
const action = await select("How would you like to set up the bot?", [
|
|
@@ -2824,7 +3500,7 @@ async function botSetup() {
|
|
|
2824
3500
|
|
|
2825
3501
|
if (action === "skip") {
|
|
2826
3502
|
console.log(
|
|
2827
|
-
` ${dim("Skipped.")} Run ${cyan("npx forgedock bot setup")} later to configure a bot identity
|
|
3503
|
+
` ${dim("Skipped.")} Run ${cyan("npx forgedock bot setup")} later to configure a bot identity.`,
|
|
2828
3504
|
);
|
|
2829
3505
|
return;
|
|
2830
3506
|
}
|
|
@@ -2836,7 +3512,9 @@ async function botSetup() {
|
|
|
2836
3512
|
try {
|
|
2837
3513
|
manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
2838
3514
|
} catch {
|
|
2839
|
-
console.log(
|
|
3515
|
+
console.log(
|
|
3516
|
+
` ${RED}Could not read github-app-manifest.json — skipping.${RESET}`,
|
|
3517
|
+
);
|
|
2840
3518
|
return;
|
|
2841
3519
|
}
|
|
2842
3520
|
|
|
@@ -2844,19 +3522,26 @@ async function botSetup() {
|
|
|
2844
3522
|
const createUrl = `https://github.com/settings/apps/new?manifest=${encodedManifest}`;
|
|
2845
3523
|
|
|
2846
3524
|
console.log("");
|
|
2847
|
-
console.log(
|
|
3525
|
+
console.log(
|
|
3526
|
+
` ${bold("Opening GitHub App registration in your browser…")}`,
|
|
3527
|
+
);
|
|
2848
3528
|
console.log(` URL: ${cyan(createUrl.substring(0, 80))}…`);
|
|
2849
3529
|
console.log("");
|
|
2850
3530
|
|
|
2851
3531
|
// Try to open the browser
|
|
2852
3532
|
try {
|
|
2853
|
-
const opener =
|
|
2854
|
-
|
|
3533
|
+
const opener =
|
|
3534
|
+
process.platform === "win32"
|
|
3535
|
+
? "start"
|
|
3536
|
+
: process.platform === "darwin"
|
|
3537
|
+
? "open"
|
|
3538
|
+
: "xdg-open";
|
|
2855
3539
|
// On Windows, `start "url"` treats the first quoted arg as the window title.
|
|
2856
3540
|
// Pass an empty title placeholder so the URL is treated as the target: `start "" "url"`.
|
|
2857
|
-
const cmd =
|
|
2858
|
-
|
|
2859
|
-
|
|
3541
|
+
const cmd =
|
|
3542
|
+
process.platform === "win32"
|
|
3543
|
+
? `start "" "${createUrl}"`
|
|
3544
|
+
: `${opener} "${createUrl}"`;
|
|
2860
3545
|
execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] });
|
|
2861
3546
|
} catch {
|
|
2862
3547
|
console.log(` ${YELLOW}Could not open browser automatically.${RESET}`);
|
|
@@ -2868,7 +3553,9 @@ async function botSetup() {
|
|
|
2868
3553
|
console.log(" After creating the app in your browser:");
|
|
2869
3554
|
console.log(` 1. Download the private key (.pem) from the app settings`);
|
|
2870
3555
|
console.log(` 2. Note your App ID and Installation ID`);
|
|
2871
|
-
console.log(
|
|
3556
|
+
console.log(
|
|
3557
|
+
` 3. Run ${cyan("npx forgedock bot setup")} again and choose \"Connect existing\"`,
|
|
3558
|
+
);
|
|
2872
3559
|
console.log("");
|
|
2873
3560
|
return;
|
|
2874
3561
|
}
|
|
@@ -2882,19 +3569,39 @@ async function botSetup() {
|
|
|
2882
3569
|
|
|
2883
3570
|
function help() {
|
|
2884
3571
|
console.log("");
|
|
2885
|
-
console.log(
|
|
3572
|
+
console.log(
|
|
3573
|
+
`${BOLD}ForgeDock${RESET} — GitHub as a knowledge graph for AI agents`,
|
|
3574
|
+
);
|
|
2886
3575
|
console.log("");
|
|
2887
3576
|
console.log("Usage:");
|
|
2888
|
-
console.log(
|
|
3577
|
+
console.log(
|
|
3578
|
+
` ${CYAN}npx forgedock${RESET} Launch TUI onboarding (interactive)`,
|
|
3579
|
+
);
|
|
2889
3580
|
console.log(` ${CYAN}npx forgedock install${RESET} Install commands`);
|
|
2890
|
-
console.log(
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
console.log(
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
console.log(
|
|
2897
|
-
|
|
3581
|
+
console.log(
|
|
3582
|
+
` ${CYAN}npx forgedock init${RESET} Generate forge.yaml config for your project`,
|
|
3583
|
+
);
|
|
3584
|
+
console.log(
|
|
3585
|
+
` ${CYAN}npx forgedock validate${RESET} Validate forge.yaml configuration`,
|
|
3586
|
+
);
|
|
3587
|
+
console.log(
|
|
3588
|
+
` ${CYAN}npx forgedock uninstall${RESET} Remove commands (interactive, with confirmation)`,
|
|
3589
|
+
);
|
|
3590
|
+
console.log(
|
|
3591
|
+
` ${CYAN}npx forgedock uninstall --yes${RESET} Remove commands without prompts (non-interactive)`,
|
|
3592
|
+
);
|
|
3593
|
+
console.log(
|
|
3594
|
+
` ${CYAN}npx forgedock update${RESET} Pull latest & reinstall`,
|
|
3595
|
+
);
|
|
3596
|
+
console.log(
|
|
3597
|
+
` ${CYAN}npx forgedock bot${RESET} Manage GitHub App bot identity`,
|
|
3598
|
+
);
|
|
3599
|
+
console.log(
|
|
3600
|
+
` ${CYAN}npx forgedock bot status${RESET} Show bot health and rate limit usage`,
|
|
3601
|
+
);
|
|
3602
|
+
console.log(
|
|
3603
|
+
` ${CYAN}npx forgedock bot setup${RESET} Configure GitHub App bot (interactive)`,
|
|
3604
|
+
);
|
|
2898
3605
|
console.log(` ${CYAN}npx forgedock help${RESET} Show this help`);
|
|
2899
3606
|
console.log("");
|
|
2900
3607
|
}
|
|
@@ -2906,9 +3613,54 @@ function help() {
|
|
|
2906
3613
|
async function tuiOnboarding() {
|
|
2907
3614
|
splash();
|
|
2908
3615
|
|
|
2909
|
-
// Non-TTY fallback: skip TUI and detection, run install directly
|
|
3616
|
+
// Non-TTY fallback: skip TUI and detection, run install directly.
|
|
3617
|
+
// Print a prominent boxed notice so the user understands what happened and
|
|
3618
|
+
// what to do next. Best-effort update/checkout hints are folded into the box.
|
|
2910
3619
|
if (!process.stdout.isTTY) {
|
|
2911
|
-
|
|
3620
|
+
const noticeLines = [
|
|
3621
|
+
"",
|
|
3622
|
+
` Non-interactive environment detected.`,
|
|
3623
|
+
` ForgeDock's setup wizard requires an interactive terminal (TTY).`,
|
|
3624
|
+
"",
|
|
3625
|
+
` ${dim("To run the interactive TUI:")} open a terminal and run ${cyan("npx forgedock")}`,
|
|
3626
|
+
"",
|
|
3627
|
+
];
|
|
3628
|
+
|
|
3629
|
+
// Best-effort remote check — fold results into the box, never block install.
|
|
3630
|
+
try {
|
|
3631
|
+
const remoteCheck = await checkForRemoteUpdate();
|
|
3632
|
+
if (remoteCheck?.updateAvailable) {
|
|
3633
|
+
const isGitCheckout = existsSync(join(FORGE_HOME, ".git"));
|
|
3634
|
+
if (isGitCheckout) {
|
|
3635
|
+
// User is running from the ForgeDock source checkout and it's behind remote.
|
|
3636
|
+
noticeLines.push(
|
|
3637
|
+
` ${yellow("Stale local checkout:")} the working-tree bin is behind origin/main.`,
|
|
3638
|
+
);
|
|
3639
|
+
noticeLines.push(
|
|
3640
|
+
` ${dim("Run")} ${cyan(`git pull`)} ${dim(`in`)} ${dim(FORGE_HOME)}`,
|
|
3641
|
+
);
|
|
3642
|
+
noticeLines.push(
|
|
3643
|
+
` ${dim("Or use the published package:")} ${cyan("npx forgedock@latest")}`,
|
|
3644
|
+
);
|
|
3645
|
+
} else {
|
|
3646
|
+
const versionHint = remoteCheck.latestVersion
|
|
3647
|
+
? ` (v${getVersion()} → v${remoteCheck.latestVersion})`
|
|
3648
|
+
: "";
|
|
3649
|
+
noticeLines.push(
|
|
3650
|
+
` ${yellow(`Update available${versionHint}`)} — run: ${cyan("npx forgedock update")}`,
|
|
3651
|
+
);
|
|
3652
|
+
}
|
|
3653
|
+
noticeLines.push("");
|
|
3654
|
+
}
|
|
3655
|
+
} catch {
|
|
3656
|
+
// Best-effort — never block install
|
|
3657
|
+
}
|
|
3658
|
+
|
|
3659
|
+
noticeLines.push(` Running ${cyan("install")} now...`);
|
|
3660
|
+
noticeLines.push("");
|
|
3661
|
+
process.stdout.write(
|
|
3662
|
+
box(noticeLines, { title: "Non-interactive environment" }),
|
|
3663
|
+
);
|
|
2912
3664
|
console.log("");
|
|
2913
3665
|
await install();
|
|
2914
3666
|
return;
|
|
@@ -2926,17 +3678,61 @@ async function tuiOnboarding() {
|
|
|
2926
3678
|
? `v${detection.installedVersion}`
|
|
2927
3679
|
: `v${detection.currentVersion}`;
|
|
2928
3680
|
console.log(
|
|
2929
|
-
` ${green("Everything up to date.")} ForgeDock ${versionLabel} is installed and configured
|
|
3681
|
+
` ${green("Everything up to date.")} ForgeDock ${versionLabel} is installed and configured.`,
|
|
2930
3682
|
);
|
|
2931
3683
|
console.log("");
|
|
2932
3684
|
|
|
2933
|
-
|
|
3685
|
+
// Proactive remote update check — cached, never blocks TUI startup
|
|
3686
|
+
let remoteUpdateInfo = null;
|
|
3687
|
+
try {
|
|
3688
|
+
remoteUpdateInfo = await checkForRemoteUpdate();
|
|
3689
|
+
} catch {
|
|
3690
|
+
// Best-effort — ignore all errors
|
|
3691
|
+
}
|
|
3692
|
+
|
|
3693
|
+
if (remoteUpdateInfo?.updateAvailable) {
|
|
3694
|
+
// Surface a prominent notice when behind the remote
|
|
3695
|
+
const noticeLines = [""];
|
|
3696
|
+
if (remoteUpdateInfo.latestVersion) {
|
|
3697
|
+
noticeLines.push(
|
|
3698
|
+
` ${yellow("Update available:")} v${detection.currentVersion} → ${green(`v${remoteUpdateInfo.latestVersion}`)}`,
|
|
3699
|
+
);
|
|
3700
|
+
} else {
|
|
3701
|
+
// Git install: no version string, but commits available
|
|
3702
|
+
noticeLines.push(
|
|
3703
|
+
` ${yellow("Update available")} — new commits on origin/main`,
|
|
3704
|
+
);
|
|
3705
|
+
}
|
|
3706
|
+
noticeLines.push(
|
|
3707
|
+
"",
|
|
3708
|
+
` ${dim('Choose "Update now" below or run')} ${cyan("npx forgedock update")}`,
|
|
3709
|
+
"",
|
|
3710
|
+
);
|
|
3711
|
+
process.stdout.write(box(noticeLines, { title: "Update available" }));
|
|
3712
|
+
console.log("");
|
|
3713
|
+
}
|
|
3714
|
+
|
|
3715
|
+
// Build the action menu — insert "Update now" as the first choice when behind
|
|
3716
|
+
/** @type {Array<{label: string, value: string}>} */
|
|
3717
|
+
const menuChoices = [];
|
|
3718
|
+
if (remoteUpdateInfo?.updateAvailable) {
|
|
3719
|
+
menuChoices.push({ label: "Update now", value: "update" });
|
|
3720
|
+
}
|
|
3721
|
+
menuChoices.push(
|
|
2934
3722
|
{ label: "Nothing — exit", value: "exit" },
|
|
2935
|
-
{
|
|
3723
|
+
{
|
|
3724
|
+
label: "Reconfigure project (regenerate forge.yaml)",
|
|
3725
|
+
value: "reconfigure",
|
|
3726
|
+
},
|
|
2936
3727
|
{ label: "Reinstall commands", value: "reinstall" },
|
|
2937
|
-
|
|
3728
|
+
);
|
|
3729
|
+
|
|
3730
|
+
const action = await select("What would you like to do?", menuChoices);
|
|
2938
3731
|
|
|
2939
|
-
if (action === "
|
|
3732
|
+
if (action === "update") {
|
|
3733
|
+
console.log("");
|
|
3734
|
+
await update();
|
|
3735
|
+
} else if (action === "reconfigure") {
|
|
2940
3736
|
console.log("");
|
|
2941
3737
|
await init();
|
|
2942
3738
|
} else if (action === "reinstall") {
|
|
@@ -2954,8 +3750,8 @@ async function tuiOnboarding() {
|
|
|
2954
3750
|
if (detection.state === "update-available") {
|
|
2955
3751
|
console.log(
|
|
2956
3752
|
` ${yellow("Update available.")} ` +
|
|
2957
|
-
|
|
2958
|
-
|
|
3753
|
+
`Installed: ${dim(`v${detection.installedVersion ?? "unknown"}`)} → ` +
|
|
3754
|
+
`New: ${green(`v${detection.currentVersion}`)}`,
|
|
2959
3755
|
);
|
|
2960
3756
|
console.log("");
|
|
2961
3757
|
|
|
@@ -2985,7 +3781,10 @@ async function tuiOnboarding() {
|
|
|
2985
3781
|
if (success) {
|
|
2986
3782
|
// Show command diff if any commands were added or removed
|
|
2987
3783
|
const commandsAfterUpdate = await getInstalledCommandNames();
|
|
2988
|
-
const diffLines = formatCommandDiff(
|
|
3784
|
+
const diffLines = formatCommandDiff(
|
|
3785
|
+
commandsBeforeUpdate,
|
|
3786
|
+
commandsAfterUpdate,
|
|
3787
|
+
);
|
|
2989
3788
|
if (diffLines.length > 0) {
|
|
2990
3789
|
process.stdout.write(box(diffLines, { title: "Command changes" }));
|
|
2991
3790
|
console.log("");
|
|
@@ -2993,14 +3792,14 @@ async function tuiOnboarding() {
|
|
|
2993
3792
|
|
|
2994
3793
|
console.log(
|
|
2995
3794
|
green("Update complete!") +
|
|
2996
|
-
|
|
3795
|
+
` ForgeDock v${detection.currentVersion} is now active.`,
|
|
2997
3796
|
);
|
|
2998
3797
|
console.log("");
|
|
2999
3798
|
} else {
|
|
3000
3799
|
console.log("");
|
|
3001
3800
|
console.log(
|
|
3002
3801
|
yellow("Update incomplete.") +
|
|
3003
|
-
|
|
3802
|
+
` Re-run ${cyan("npx forgedock")} to try again.`,
|
|
3004
3803
|
);
|
|
3005
3804
|
console.log("");
|
|
3006
3805
|
process.exit(1);
|
|
@@ -3017,7 +3816,7 @@ async function tuiOnboarding() {
|
|
|
3017
3816
|
? `v${detection.installedVersion}`
|
|
3018
3817
|
: `v${detection.currentVersion}`;
|
|
3019
3818
|
console.log(
|
|
3020
|
-
` ${yellow("Commands installed")} (${versionLabel}) but no ${cyan("forge.yaml")} found in this directory
|
|
3819
|
+
` ${yellow("Commands installed")} (${versionLabel}) but no ${cyan("forge.yaml")} found in this directory.`,
|
|
3021
3820
|
);
|
|
3022
3821
|
console.log("");
|
|
3023
3822
|
|
|
@@ -3028,13 +3827,13 @@ async function tuiOnboarding() {
|
|
|
3028
3827
|
run: async () => {
|
|
3029
3828
|
const shouldInit = await confirm(
|
|
3030
3829
|
"Generate forge.yaml for this project?",
|
|
3031
|
-
true
|
|
3830
|
+
true,
|
|
3032
3831
|
);
|
|
3033
3832
|
if (shouldInit) {
|
|
3034
3833
|
await init();
|
|
3035
3834
|
} else {
|
|
3036
3835
|
console.log(
|
|
3037
|
-
` ${dim("Skipped.")} Run ${cyan("npx forgedock init")} later to generate forge.yaml
|
|
3836
|
+
` ${dim("Skipped.")} Run ${cyan("npx forgedock init")} later to generate forge.yaml.`,
|
|
3038
3837
|
);
|
|
3039
3838
|
}
|
|
3040
3839
|
},
|
|
@@ -3045,7 +3844,9 @@ async function tuiOnboarding() {
|
|
|
3045
3844
|
run: async () => {
|
|
3046
3845
|
const forgeYamlPath = join(process.cwd(), "forge.yaml");
|
|
3047
3846
|
if (!existsSync(forgeYamlPath)) {
|
|
3048
|
-
console.log(
|
|
3847
|
+
console.log(
|
|
3848
|
+
` ${dim("No forge.yaml found — skipping validation.")}`,
|
|
3849
|
+
);
|
|
3049
3850
|
return;
|
|
3050
3851
|
}
|
|
3051
3852
|
await validate(forgeYamlPath);
|
|
@@ -3059,14 +3860,14 @@ async function tuiOnboarding() {
|
|
|
3059
3860
|
if (success) {
|
|
3060
3861
|
console.log(
|
|
3061
3862
|
green("Done!") +
|
|
3062
|
-
|
|
3863
|
+
` Run ${cyan("/help")} inside Claude Code to see available commands.`,
|
|
3063
3864
|
);
|
|
3064
3865
|
console.log("");
|
|
3065
3866
|
} else {
|
|
3066
3867
|
console.log("");
|
|
3067
3868
|
console.log(
|
|
3068
3869
|
yellow("Setup incomplete.") +
|
|
3069
|
-
|
|
3870
|
+
` Re-run ${cyan("npx forgedock")} to try again.`,
|
|
3070
3871
|
);
|
|
3071
3872
|
console.log("");
|
|
3072
3873
|
process.exit(1);
|
|
@@ -3080,10 +3881,10 @@ async function tuiOnboarding() {
|
|
|
3080
3881
|
// -------------------------------------------------------------------------
|
|
3081
3882
|
if (detection.state === "version-unknown") {
|
|
3082
3883
|
console.log(
|
|
3083
|
-
` ${yellow("ForgeDock commands are installed")} but the installed version could not be determined
|
|
3884
|
+
` ${yellow("ForgeDock commands are installed")} but the installed version could not be determined.`,
|
|
3084
3885
|
);
|
|
3085
3886
|
console.log(
|
|
3086
|
-
` ${dim("The package.json for the installed copy is missing or unreadable.")}
|
|
3887
|
+
` ${dim("The package.json for the installed copy is missing or unreadable.")}`,
|
|
3087
3888
|
);
|
|
3088
3889
|
console.log("");
|
|
3089
3890
|
|
|
@@ -3125,19 +3926,21 @@ async function tuiOnboarding() {
|
|
|
3125
3926
|
run: async () => {
|
|
3126
3927
|
const forgeYamlPath = join(process.cwd(), "forge.yaml");
|
|
3127
3928
|
if (existsSync(forgeYamlPath)) {
|
|
3128
|
-
console.log(
|
|
3929
|
+
console.log(
|
|
3930
|
+
` ${green("forge.yaml")} already exists — skipping generation.`,
|
|
3931
|
+
);
|
|
3129
3932
|
return;
|
|
3130
3933
|
}
|
|
3131
3934
|
|
|
3132
3935
|
const shouldInit = await confirm(
|
|
3133
3936
|
"No forge.yaml found. Generate one now?",
|
|
3134
|
-
true
|
|
3937
|
+
true,
|
|
3135
3938
|
);
|
|
3136
3939
|
if (shouldInit) {
|
|
3137
3940
|
await init();
|
|
3138
3941
|
} else {
|
|
3139
3942
|
console.log(
|
|
3140
|
-
` ${dim("Skipped.")} Run ${cyan("npx forgedock init")} later to generate forge.yaml
|
|
3943
|
+
` ${dim("Skipped.")} Run ${cyan("npx forgedock init")} later to generate forge.yaml.`,
|
|
3141
3944
|
);
|
|
3142
3945
|
}
|
|
3143
3946
|
},
|
|
@@ -3169,14 +3972,14 @@ async function tuiOnboarding() {
|
|
|
3169
3972
|
if (success) {
|
|
3170
3973
|
console.log(
|
|
3171
3974
|
green("Setup complete!") +
|
|
3172
|
-
|
|
3975
|
+
` Run ${cyan("/help")} inside Claude Code to see available commands.`,
|
|
3173
3976
|
);
|
|
3174
3977
|
console.log("");
|
|
3175
3978
|
} else {
|
|
3176
3979
|
console.log("");
|
|
3177
3980
|
console.log(
|
|
3178
3981
|
yellow("Setup incomplete.") +
|
|
3179
|
-
|
|
3982
|
+
` Re-run ${cyan("npx forgedock")} to try again.`,
|
|
3180
3983
|
);
|
|
3181
3984
|
console.log("");
|
|
3182
3985
|
process.exit(1);
|