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/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 { mkdir, symlink, copyFile, readlink, lstat, readdir, stat, writeFile, unlink as fsUnlink } from "fs/promises";
6
- import { existsSync, appendFileSync, chmodSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs";
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, GREEN, YELLOW, CYAN, RED, RESET,
11
- bold, dim, green, yellow, cyan, red,
12
- box, stepHeader, select, multiSelect, confirm, input, createProgressBar, spinner,
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
- if (!process.env.HOME) {
21
- console.error(
22
- "Error: HOME environment variable is not set. Cannot determine install location.",
23
- );
24
- process.exit(1);
25
- }
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(process.env.HOME, ".claude", "commands");
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(readFileSync(join(FORGE_HOME, "package.json"), "utf-8"));
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 { process.stdin.setRawMode(false); } catch { /* ignore */ }
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
- ` Install: ${CYAN}https://cli.github.com${RESET}`
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
- ` Run: ${CYAN}gh auth login${RESET}`
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
- ` Install: ${CYAN}https://docs.anthropic.com/en/docs/claude-code${RESET}`
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
- ` Update: ${CYAN}https://nodejs.org${RESET}`
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
- `with authentication to function.\nResolve the issues above, then re-run the command.\n`
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", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
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("fail", `${red("[✗]")} GitHub CLI not found — install: ${cyan("https://cli.github.com")}`);
460
- throw new Error("GitHub CLI (gh) is required. Install it from https://cli.github.com");
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", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
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("success", `${green("[✓]")} GitHub Auth ${dim(`logged in as ${username}${orgLabel}`)}`);
543
+ s.stop(
544
+ "success",
545
+ `${green("[✓]")} GitHub Auth ${dim(`logged in as ${username}${orgLabel}`)}`,
546
+ );
495
547
  } catch {
496
- s.stop("fail", `${red("[✗]")} GitHub Auth not authenticated — run: ${cyan("gh auth login")}`);
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("fail", `${red("[✗]")} Node.js v${nodeVersion} (>= 18 required) — update: ${cyan("https://nodejs.org")}`);
508
- throw new Error(`Node.js >= 18 required (found v${nodeVersion}). Update at https://nodejs.org`);
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("success", `${green("[✓]")} Node.js ${dim(`v${nodeVersion} (>= 18 required)`)}`);
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", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
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("warn", `${yellow("[!]")} Git not found — install: ${cyan("https://git-scm.com")}`);
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", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
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("warn", `${yellow("[!]")} Claude Code not found — install: ${cyan("https://docs.anthropic.com/en/docs/claude-code")}`);
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": "Pipeline",
572
- "investigate": "Pipeline",
573
- "build": "Pipeline",
574
- "architect": "Pipeline",
575
- "context": "Pipeline",
576
- "implement": "Pipeline",
577
- "review": "Pipeline",
578
- "decompose": "Pipeline",
579
- "close": "Pipeline",
580
- "review-pr": "Pipeline",
581
- "review-pr-agents": "Pipeline",
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
- "orchestrate": "Pipeline",
584
- "issue": "Pipeline",
585
- "milestone": "Pipeline",
586
- "quality-gate": "Pipeline",
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
- "autopilot": "Operations",
591
- "analytics": "Operations",
592
- "geo-audit": "Operations",
593
- "security-audit": "Operations",
594
- "pipeline-health": "Operations",
595
- "audit": "Operations",
596
- "audit-agents": "Operations",
597
- "qa-sweep": "Operations",
598
- "forge-stats": "Operations",
599
- "sync-ecosystem": "Operations",
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
- "rollback": "Incident",
604
- "deploy-info": "Incident",
605
- "failure-recon": "Incident",
678
+ rollback: "Incident",
679
+ "deploy-info": "Incident",
680
+ "failure-recon": "Incident",
606
681
 
607
682
  // Ecosystem — project management utilities
608
- "validate": "Ecosystem",
609
- "cleanup": "Ecosystem",
683
+ validate: "Ecosystem",
684
+ cleanup: "Ecosystem",
610
685
 
611
686
  // Setup — initial configuration
612
- "forgedock-init": "Setup",
687
+ "forgedock-init": "Setup",
613
688
  };
614
689
 
615
690
  /** Canonical display order for categories. */
616
- const CATEGORY_ORDER = ["Pipeline", "Operations", "Incident", "Ecosystem", "Setup", "Other"];
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, { label: " Installing commands..." });
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(box(["", ...categoryLines, ""], { title: "Commands installed" }));
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(summaryLines.length - 1, 0,
795
- ` ${cyan("Copied")} ${bold(String(copied))} ${dim("(symlinks unavailable — files copied instead)")}`
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(box(conflictLines, { title: `${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"}` }));
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(` ${dim("✔")} ${dim("FORGE_HOME already set in shell profile")}`);
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 (toRemove.length === 0 && profilesWithForgeHome.length === 0 && !hasForgeYaml) {
926
- console.log(` ${dim("Nothing to remove — ForgeDock does not appear to be installed.")}`);
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(` ${red("Commands")}: ${bold(String(toRemove.length))} ${fileLabel} in ${dim(TARGET_DIR)}`);
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(process.env.HOME ?? "", "~")).join(", "))}`
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, { label: " Removing commands" });
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(process.env.HOME ?? "", "~");
1036
- console.log(` ${green("✔")} Removed FORGE_HOME from ${profileShort}`);
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 { unlinkSync(tmpPath); } catch { /* already gone or never created */ }
1040
- const profileShort = profile.replace(process.env.HOME ?? "", "~");
1041
- console.log(` ${red("✖")} Could not update ${profileShort}: ${err.message}`);
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 // --yes flag: keep forge.yaml by default (safe)
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(` ${red("✖")} Could not delete forge.yaml: ${err.message}`);
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(`${green("Uninstall complete.")} ForgeDock commands have been removed.`);
1183
+ console.log(
1184
+ `${green("Uninstall complete.")} ForgeDock commands have been removed.`,
1185
+ );
1077
1186
  if (profilesWithForgeHome.length > 0) {
1078
- console.log(` ${dim("Restart your shell or run")} ${cyan("source ~/.bashrc")} ${dim("(or")} ${cyan("~/.zshrc")}${dim(")")} ${dim("to apply profile changes.")}`);
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].filter((n) => !before.has(n)).map((n) => n.replace(/\.md$/, ""));
1162
- const removed = [...before].filter((n) => !after.has(n)).map((n) => n.replace(/\.md$/, ""));
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(` ${yellow("Not on main branch")} (${branch}) — skipping automatic update.`);
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(` ${yellow("Offline or unreachable.")} Could not fetch from origin.`);
1224
- console.log(` ${dim("Skipping update check — re-run when connected.")}`);
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
- console.log(` ${dim("Stash or discard local changes and re-run.")}`);
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(`\r ${yellow("Offline or registry unreachable.")} Could not check for updates.\n`);
1667
+ process.stdout.write(
1668
+ `\r ${yellow("Offline or registry unreachable.")} Could not check for updates.\n`,
1669
+ );
1334
1670
  } else {
1335
- console.log(` Offline or registry unreachable. Could not check for updates.`);
1671
+ console.log(
1672
+ ` Offline or registry unreachable. Could not check for updates.`,
1673
+ );
1336
1674
  }
1337
- console.log(` ${dim("Re-run when connected, or check: ")}${cyan("https://www.npmjs.com/package/forgedock")}`);
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
- checkPrerequisites();
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(/^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/);
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, cwd,
1472
- owner: detectedOwner, repo: detectedRepo,
1473
- projectName: detectedName, description: "",
1474
- root: cwd, worktreeBase: join(cwd, ".claude", "worktrees"),
1475
- defaultBranch: detectedDefault, stagingBranch: detectedStaging,
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(dim(" Auto-detected values are shown as defaults. Press Enter to accept."));
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(" GitHub owner (org or user)", detectedOwner);
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" ? detectedName : repoInput.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")
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(" Staging branch (PR target for fast-lane changes)", detectedStaging);
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, { title: "forge.yaml preview (required sections)", padding: 1 })
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(dim(" Select sections to configure now. Unselected sections are written as"));
1560
- console.log(dim(" commented-out placeholders — you can enable them later by editing forge.yaml."));
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: "Project Board — GitHub Projects v2 integration for workflow tracking",
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: "Review Context — Tech stack and conventions for PR review agents",
1959
+ label:
1960
+ "Review Context — Tech stack and conventions for PR review agents",
1574
1961
  value: "review",
1575
1962
  },
1576
1963
  {
1577
- label: "Verification — Health check endpoints and response patterns",
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(ownerInput || detectedOwner);
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: discovered.projectId,
1598
- fieldIds: discovered.fieldIds,
1599
- optionIds: discovered.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(dim(" Find your project number: gh project list --owner " + (ownerInput || detectedOwner)));
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(dim(" Field IDs (PVT_/PVTSSF_ strings) must be added manually after generation."));
1614
- console.log(dim(" Run: gh project field-list " + (projectNumber || "1") + " --owner " + (ownerInput || detectedOwner)));
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(dim(" Configure one satellite repo (add more by editing forge.yaml)."));
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(cwd, `forge.yaml.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`)
2093
+ ? join(
2094
+ cwd,
2095
+ `forge.yaml.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`,
2096
+ )
1684
2097
  : baseBak;
1685
- const backupName = backupPath.split("/").pop();
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(` ${GREEN}Configured${RESET}: ${selectedSections.map((s) => ({
1709
- projectBoard: "project_board",
1710
- multiRepo: "repos",
1711
- review: "review",
1712
- verification: "verification",
1713
- })[s]).join(", ")}`);
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
- * Strips double-quotes and newlines to prevent YAML injection.
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).replace(/"/g, "").replace(/[\r\n]/g, " ").trim();
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).replace(/\\/g, "\\\\").replace(/"/g, "").replace(/[\r\n]/g, " ").trim();
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("gh", ["project", "list", "--owner", owner, "--format", "json"], {
1783
- encoding: "utf-8",
1784
- stdio: ["pipe", "pipe", "pipe"],
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(dim(" Could not list projects — falling back to manual entry."));
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(dim(" You can add project_board configuration manually in forge.yaml later."));
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({ label: dim("Skip — configure project board manually later"), value: null });
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(" Which GitHub Project board?", projectChoices);
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(selectedProject?.title ?? `Project #${selectedNumber}`);
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("gh", ["project", "field-list", String(selectedNumber), "--owner", owner, "--format", "json"], {
1827
- encoding: "utf-8",
1828
- stdio: ["pipe", "pipe", "pipe"],
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((f) => f.type === "ProjectV2SingleSelectField");
2291
+ fields = (parsed.fields || []).filter(
2292
+ (f) => f.type === "ProjectV2SingleSelectField",
2293
+ );
1832
2294
  } catch {
1833
- console.log(dim(" Could not fetch project fields — falling back to manual entry."));
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((f) => f.name.toLowerCase() === keyword.toLowerCase());
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) => f.name.toLowerCase().includes(keyword.toLowerCase()));
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", keywords: ["status"] },
1858
- { key: "lane", keywords: ["lane", "track"] },
2325
+ { key: "status", keywords: ["status"] },
2326
+ { key: "lane", keywords: ["lane", "track"] },
1859
2327
  { key: "component", keywords: ["component"] },
1860
- { key: "priority", keywords: ["priority"] },
1861
- { key: "workflow", keywords: ["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: { "todo": "todo", "in progress": "in_progress", "done": "done" },
1867
- lane: { "fast": "fast", "feature": "feature", "sync": "sync" },
1868
- priority: { "p0": "p0", "p1": "p1", "p2": "p2", "p3": "p3" },
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
- "investigating": "investigating",
2338
+ investigating: "investigating",
1871
2339
  "ready to build": "ready_to_build",
1872
- "building": "building",
2340
+ building: "building",
1873
2341
  "in review": "in_review",
1874
- "merged": "merged",
1875
- "invalid": "invalid",
1876
- "decomposed": "decomposed",
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.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
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(yellow(` Fuzzy match: field "${fieldName}" mapped to forge.yaml key "${key}"`));
1930
- const accepted = await confirm(` Accept this mapping (${fieldName} → ${key})?`, true);
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] ? Object.keys(resolvedOptionIds[key]).length : 0;
1948
- const optLabel = optCount > 0 ? ` (${optCount} options)` : " (no options mapped)";
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((k) => !resolvedFieldIds[k]);
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(`Not found: ${unmapped.join(", ")} — placeholders will be used`);
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(dim(" Skipped auto-discovery. You can add project_board manually in forge.yaml."));
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({ owner, repo, projectName, description, root, worktreeBase, defaultBranch, stagingBranch, optionalSections = {} }) {
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 = _sanitizeYamlValue(owner);
1999
- repo = _sanitizeYamlValue(repo);
2000
- projectName = _sanitizeYamlValue(projectName);
2001
- description = _sanitizeYamlValue(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 = _sanitizePathValue(root);
2009
- worktreeBase = _sanitizePathValue(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
- prefix: _sanitizeYamlValue(multiRepo.prefix || "sat"),
2016
- satelliteRepo: _sanitizeYamlValue(multiRepo.satelliteRepo || "your-satellite-repo"),
2017
- satelliteBranch: _sanitizeYamlValue(multiRepo.satelliteBranch || "main"),
2018
- } : null;
2019
-
2020
- const safeReview = review ? {
2021
- techStack: _sanitizeYamlValue(review.techStack || "Node.js, TypeScript, PostgreSQL"),
2022
- // context uses a block scalar (|) — only strip double-quotes; newlines are handled by split/join
2023
- context: (review.context || "Add architecture notes and conventions here.").replace(/"/g, ""),
2024
- } : null;
2025
-
2026
- const safeVerification = verification ? {
2027
- healthEndpoint: _sanitizeYamlValue(verification.healthEndpoint || `https://api.${repo}.io/health`),
2028
- } : null;
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(([, opts]) => Object.keys(opts).length > 0);
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
- .map((k) => ` ${k}: "${resolvedFieldIds[k] ?? "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"}"`)
2076
- .join("\n");
2591
+ const fieldIdsBlock = FIELD_KEYS.map(
2592
+ (k) =>
2593
+ ` ${k}: "${resolvedFieldIds[k] ?? "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"}"`,
2594
+ ).join("\n");
2077
2595
 
2078
- const optionIdsBlock = projectBoard?.optionIds ? _buildOptionIdsBlock(resolvedOptionIds) : "";
2596
+ const optionIdsBlock = projectBoard?.optionIds
2597
+ ? _buildOptionIdsBlock(resolvedOptionIds)
2598
+ : "";
2079
2599
 
2080
- const hasDiscoveredId = !!(projectBoard?.projectId);
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(` ${YELLOW}!${RESET} Edit ${CYAN}forge.yaml${RESET} — set project.owner and project.repo`);
2735
+ console.log(
2736
+ ` ${YELLOW}!${RESET} Edit ${CYAN}forge.yaml${RESET} — set project.owner and project.repo`,
2737
+ );
2216
2738
  }
2217
- console.log(` 1. Add ${CYAN}forge.yaml${RESET} to ${CYAN}.gitignore${RESET} if it contains sensitive paths`);
2218
- console.log(` 2. Run ${CYAN}/forgedock-init${RESET} inside Claude Code for guided AI-powered setup`);
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:") { continue; }
2296
- if (line.trim() === "satellites:") { inSatellites = true; continue; }
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.replace(/^\s+repo:\s*/, "").replace(/^["']|["']$/g, "").trim();
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({ label: "forge.yaml found", status: "error", note: `Not found: ${forgeYamlPath}` });
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({ label: "forge.yaml found", status: "error", note: `Cannot read: ${err.message}` });
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(["your-github-org", "your-repo-name", "", "your-org", "your-repo"]);
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({ label: "Required fields", status: "ok", note: `${owner}/${repo}` });
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({ label: "paths.root exists", status: "warn", note: `Directory not found: ${root}` });
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({ label: "worktree_base exists", status: "ok", note: worktreeBase });
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({ label: "worktree_base exists", status: "ok", note: `Created: ${worktreeBase}` });
2938
+ checks.push({
2939
+ label: "worktree_base exists",
2940
+ status: "ok",
2941
+ note: `Created: ${worktreeBase}`,
2942
+ });
2379
2943
  } catch (err) {
2380
- checks.push({ label: "worktree_base exists", status: "warn", note: `Cannot create: ${err.message}` });
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("gh", ["repo", "view", `${owner}/${repo}`, "--json", "name"], {
2389
- stdio: ["pipe", "pipe", "pipe"],
2390
- timeout: 10000,
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({ label: "GitHub repo accessible", status: "warn", note: "Skipped — owner/repo not set" });
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 = [...new Set([defaultBranch, stagingBranch].filter(Boolean))];
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("git", ["ls-remote", "--heads", "origin", branch], {
2412
- cwd: root,
2413
- stdio: ["pipe", "pipe", "pipe"],
2414
- encoding: "utf-8",
2415
- timeout: 10000,
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({ label: `Branch: ${branch}`, status: "ok", note: "Exists on remote" });
3004
+ checks.push({
3005
+ label: `Branch: ${branch}`,
3006
+ status: "ok",
3007
+ note: "Exists on remote",
3008
+ });
2419
3009
  } else {
2420
- checks.push({ label: `Branch: ${branch}`, status: "warn", note: "Not found on remote" });
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({ label: `Branch: ${branch}`, status: "warn", note: "Cannot verify — git error" });
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", "graphql",
2452
- "-F", `id=${projectId}`,
2453
- "-f", "query=query($id: ID!) { node(id: $id) { id __typename } }",
3049
+ "api",
3050
+ "graphql",
3051
+ "-F",
3052
+ `id=${projectId}`,
3053
+ "-f",
3054
+ "query=query($id: ID!) { node(id: $id) { id __typename } }",
2454
3055
  ],
2455
- { stdio: ["pipe", "pipe", "pipe"], encoding: "utf-8", timeout: 10000 }
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({ label: "Project board configured", status: "ok", note: `${projectId.slice(0, 16)}... resolves` });
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({ label: "Project board configured", status: "warn", note: "project_id may be invalid — verify with: gh project list" });
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({ label: "Project board configured", status: "warn", note: "Cannot verify project_id — gh error" });
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({ label: "Project board", status: "warn", note: "Not configured (optional)" });
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({ label: "Satellite repos", status: "warn", note: "repos: section active but no satellites found" });
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({ label: `Satellite: ${sat}`, status: "ok", note: "Accessible" });
3108
+ checks.push({
3109
+ label: `Satellite: ${sat}`,
3110
+ status: "ok",
3111
+ note: "Accessible",
3112
+ });
2484
3113
  } catch {
2485
- checks.push({ label: `Satellite: ${sat}`, status: "warn", note: "Cannot access — check repo name or gh auth" });
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({ label: "Satellite repos", status: "warn", note: "Not configured (optional)" });
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((c) => c.label === "Project board configured" && c.status === "ok");
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(` ${dim("•")} Edit ${cyan("forge.yaml")} to fix the errors above`);
3168
+ lines.push(
3169
+ ` ${dim("•")} Edit ${cyan("forge.yaml")} to fix the errors above`,
3170
+ );
2530
3171
  }
2531
3172
  if (!boardOk) {
2532
- lines.push(` ${dim("•")} Run ${cyan("/forgedock-init")} for AI-powered optional section setup`);
3173
+ lines.push(
3174
+ ` ${dim("•")} Run ${cyan("/forgedock-init")} for AI-powered optional section setup`,
3175
+ );
2533
3176
  }
2534
- lines.push(` ${dim("•")} Run ${cyan("/work-on next")} to start your first task`);
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(process.env.HOME, ".forgedock");
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", { encoding: "utf-8", mode: 0o600 });
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, // issued 60s ago to account for clock skew
2597
- exp: now + 600, // expires in 10 minutes (GitHub max)
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(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
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(box(
2623
- [
2624
- `${bold("Connect an existing GitHub App")}`,
2625
- "",
2626
- "You'll need:",
2627
- ` • App ID (GitHub → Settings → Developer settings → GitHub Apps → your app)`,
2628
- ` • Private key file (.pem downloaded from app settings)`,
2629
- ` • Installation ID (GitHub → your org → Settings → Installed Apps → your app → configure → URL)`,
2630
- ].join("\n"),
2631
- { title: "Connect GitHub App" }
2632
- ));
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 HOME = process.env.HOME ?? "";
2643
- const expanded = privateKeyPath.trim().replace(/^~/, HOME);
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 (privateKeyPath.trim().startsWith("~") && HOME && !resolvedKeyPath.startsWith(HOME + "/") && resolvedKeyPath !== HOME) {
2646
- console.log(` ${RED}Invalid path: cannot traverse outside home directory.${RESET}`);
2647
- return false;
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(` ${RED}Invalid Installation ID — must be a numeric string.${RESET}`);
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(` ${RED}GitHub API error ${resp.status}: ${body.message ?? "unknown"}${RESET}`);
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(` ${GREEN}Connected!${RESET} App: ${bold(appData.name)} (id: ${appId.trim()})`);
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
- ` Run ${cyan("npx forgedock bot setup")} to configure a GitHub App, or\n` +
2720
- ` add credentials to ${cyan(CREDENTIALS_FILE)}.`
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(` ${RED}Private key file not found: ${creds.privateKeyPath}${RESET}`);
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(` ${RED}GitHub API error ${appResp.status}: ${body.message ?? "unknown"}${RESET}`);
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(` ${GREEN}Connected${RESET} — App: ${bold(appData.name)} (slug: @${appData.slug})`);
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(` Rate limit: ${cyan(core.remaining)}/${core.limit} remaining (${used} used, resets ${resetTime})`);
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(box(
2805
- [
2806
- `${bold("GitHub Bot Setup")} ${dim("(Optional)")}`,
2807
- "",
2808
- "A GitHub App gives ForgeDock its own identity",
2809
- "for pipeline operations. Benefits:",
2810
- ` ${cyan("•")} Separate audit trail for bot actions`,
2811
- ` ${cyan("•")} Higher API rate limits`,
2812
- ` ${cyan("•")} Webhook-driven automation (future)`,
2813
- ` ${cyan("•")} Multi-user team support`,
2814
- ].join("\n"),
2815
- { title: "GitHub Bot" }
2816
- ));
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(` ${RED}Could not read github-app-manifest.json — skipping.${RESET}`);
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(` ${bold("Opening GitHub App registration in your browser…")}`);
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 = process.platform === "win32" ? "start" :
2854
- process.platform === "darwin" ? "open" : "xdg-open";
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 = process.platform === "win32"
2858
- ? `start "" "${createUrl}"`
2859
- : `${opener} "${createUrl}"`;
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(` 3. Run ${cyan("npx forgedock bot setup")} again and choose \"Connect existing\"`);
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(`${BOLD}ForgeDock${RESET} — GitHub as a knowledge graph for AI agents`);
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(` ${CYAN}npx forgedock${RESET} Launch TUI onboarding (interactive)`);
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(` ${CYAN}npx forgedock init${RESET} Generate forge.yaml config for your project`);
2891
- console.log(` ${CYAN}npx forgedock validate${RESET} Validate forge.yaml configuration`);
2892
- console.log(` ${CYAN}npx forgedock uninstall${RESET} Remove commands (interactive, with confirmation)`);
2893
- console.log(` ${CYAN}npx forgedock uninstall --yes${RESET} Remove commands without prompts (non-interactive)`);
2894
- console.log(` ${CYAN}npx forgedock update${RESET} Pull latest & reinstall`);
2895
- console.log(` ${CYAN}npx forgedock bot${RESET} Manage GitHub App bot identity`);
2896
- console.log(` ${CYAN}npx forgedock bot status${RESET} Show bot health and rate limit usage`);
2897
- console.log(` ${CYAN}npx forgedock bot setup${RESET} Configure GitHub App bot (interactive)`);
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
- console.log(dim(" Non-interactive environment detected — running install."));
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
- const action = await select("What would you like to do?", [
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
- { label: "Reconfigure project (regenerate forge.yaml)", value: "reconfigure" },
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 === "reconfigure") {
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
- `Installed: ${dim(`v${detection.installedVersion ?? "unknown"}`)} → ` +
2958
- `New: ${green(`v${detection.currentVersion}`)}`
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(commandsBeforeUpdate, commandsAfterUpdate);
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
- ` ForgeDock v${detection.currentVersion} is now active.`
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
- ` Re-run ${cyan("npx forgedock")} to try again.`
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(` ${dim("No forge.yaml found — skipping validation.")}`);
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
- ` Run ${cyan("/help")} inside Claude Code to see available commands.`
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
- ` Re-run ${cyan("npx forgedock")} to try again.`
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(` ${green("forge.yaml")} already exists — skipping generation.`);
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
- ` Run ${cyan("/help")} inside Claude Code to see available commands.`
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
- ` Re-run ${cyan("npx forgedock")} to try again.`
3982
+ ` Re-run ${cyan("npx forgedock")} to try again.`,
3180
3983
  );
3181
3984
  console.log("");
3182
3985
  process.exit(1);