forgedock 1.1.3 → 1.1.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
@@ -7,6 +7,7 @@ import { dirname, join, relative, resolve } from "path";
7
7
  import { mkdir, lstat, readlink, readdir, unlink, readFile, writeFile } from "fs/promises";
8
8
  import {
9
9
  existsSync,
10
+ lstatSync,
10
11
  readFileSync,
11
12
  writeFileSync,
12
13
  renameSync,
@@ -63,7 +64,64 @@ import { buildMinimalForgeYaml } from "./init-detect.mjs";
63
64
 
64
65
  const __filename = fileURLToPath(import.meta.url);
65
66
  const __dirname = dirname(__filename);
66
- const FORGE_HOME = dirname(__dirname);
67
+
68
+ /**
69
+ * Resolve the real ForgeDock installation root, guarding against the case
70
+ * where forgedock.mjs is executed from inside a git worktree.
71
+ *
72
+ * A git worktree always has a `.git` FILE (not directory) at its root that
73
+ * points back to the main repository's `.git` directory. When forgedock runs
74
+ * from a worktree, `dirname(__dirname)` resolves to the ephemeral worktree
75
+ * path — causing symlinks in `~/.claude/commands/` to point inside the
76
+ * worktree. After the worktree is cleaned up, those symlinks become dangling
77
+ * refs and all slash commands fail.
78
+ *
79
+ * Detection: if `dir/.git` is a regular file, we are inside a worktree.
80
+ * Resolution: run `git rev-parse --git-common-dir` (cwd: dir) which returns
81
+ * the path to the main `.git` directory; its parent is the stable repo root.
82
+ *
83
+ * Falls back to `dir` unchanged when:
84
+ * - `dir/.git` is a directory (normal git clone — already correct)
85
+ * - `dir/.git` does not exist (npm-installed package — already correct)
86
+ * - Any error occurs (git not available, unexpected structure, etc.)
87
+ *
88
+ * @param {string} dir - Candidate FORGE_HOME (typically dirname(__dirname))
89
+ * @returns {string} Stable repo root, or `dir` if no worktree is detected
90
+ */
91
+ function resolveRealForgeHome(dir) {
92
+ try {
93
+ const gitEntry = join(dir, ".git");
94
+ let stat;
95
+ try {
96
+ stat = lstatSync(gitEntry);
97
+ } catch {
98
+ // ENOENT → npm-installed package with no .git at all — return as-is
99
+ return dir;
100
+ }
101
+ if (!stat.isFile()) {
102
+ // .git is a directory → normal git clone, already points at the real root
103
+ return dir;
104
+ }
105
+ // .git is a file → we are inside a git worktree.
106
+ // `git rev-parse --git-common-dir` returns the path to the shared .git dir
107
+ // of the main worktree. On some git versions the path is relative to cwd,
108
+ // so resolve it against `dir` before taking dirname.
109
+ const commonDir = execSync("git rev-parse --git-common-dir", {
110
+ cwd: dir,
111
+ encoding: "utf-8",
112
+ stdio: ["pipe", "pipe", "pipe"],
113
+ timeout: 5000,
114
+ }).trim();
115
+ // dirname(".git") = "." when commonDir is a bare ".git" string
116
+ const mainRepoRoot = dirname(resolve(dir, commonDir));
117
+ return mainRepoRoot;
118
+ } catch {
119
+ // Any failure (git not found, exit non-zero, etc.) — fail safe
120
+ return dir;
121
+ }
122
+ }
123
+
124
+ const FORGE_HOME = resolveRealForgeHome(dirname(__dirname));
67
125
  const COMMANDS_DIR = join(FORGE_HOME, "commands");
68
126
 
69
127
  // Resolve home cross-platform: HOME on Unix, USERPROFILE on Windows, with
@@ -929,9 +987,23 @@ async function update() {
929
987
  cwd: FORGE_HOME,
930
988
  encoding: "utf-8",
931
989
  }).trim();
932
- if (branch !== "main") {
933
- console.log(` Not on main branch (${branch}) — skipping`);
934
- return;
990
+
991
+ // If on a non-main branch, switch to main for the update then restore.
992
+ // Detached HEAD ("HEAD") is treated as non-restorable — we leave on main
993
+ // after the update and warn the user.
994
+ const isDetached = branch === "HEAD";
995
+ const needsCheckout = branch !== "main";
996
+ if (needsCheckout) {
997
+ if (isDetached) {
998
+ console.log(
999
+ ` ${YELLOW}Detached HEAD state — switching to main for update.${RESET}`,
1000
+ );
1001
+ } else {
1002
+ console.log(
1003
+ ` On branch ${CYAN}${branch}${RESET} — switching to ${CYAN}main${RESET} for update...`,
1004
+ );
1005
+ }
1006
+ execSync("git checkout main --quiet", { cwd: FORGE_HOME });
935
1007
  }
936
1008
 
937
1009
  const before = execSync("git rev-parse HEAD", {
@@ -953,6 +1025,20 @@ async function update() {
953
1025
  console.log(` ${GREEN}Updated to latest.${RESET}`);
954
1026
  }
955
1027
  await relinkAndHint();
1028
+
1029
+ // Restore original branch after a successful update.
1030
+ if (needsCheckout && !isDetached) {
1031
+ try {
1032
+ execSync(`git checkout ${branch} --quiet`, { cwd: FORGE_HOME });
1033
+ console.log(
1034
+ ` Restored branch ${CYAN}${branch}${RESET}.`,
1035
+ );
1036
+ } catch {
1037
+ console.log(
1038
+ ` ${YELLOW}⚠ Could not restore branch ${branch} — you are now on main.${RESET}`,
1039
+ );
1040
+ }
1041
+ }
956
1042
  } catch (err) {
957
1043
  console.log(
958
1044
  ` ${YELLOW}Cannot fast-forward — local changes exist. Skipping.${RESET}`,
package/bin/journey.mjs CHANGED
@@ -470,6 +470,68 @@ async function saveCopiedManifest(manifestPath, manifest) {
470
470
 
471
471
  const isLinkPermissionError = (err) => err.code === "EPERM" || err.code === "EACCES";
472
472
 
473
+ /**
474
+ * Recursively walk targetDir and remove any symlink whose target begins with
475
+ * commandsDir (i.e. a ForgeDock-managed link) but whose target file no longer
476
+ * exists on disk. These are "orphaned" symlinks left behind when a command is
477
+ * renamed or deleted.
478
+ *
479
+ * Safety invariant: only symlinks whose readlink() result starts with
480
+ * commandsDir + "/" are touched. User-owned symlinks and third-party links are
481
+ * never removed.
482
+ *
483
+ * @param {string} targetDir - ~/.claude/commands (installed commands root)
484
+ * @param {string} commandsDir - FORGE_HOME/commands (source commands root)
485
+ * @returns {Promise<number>} Number of orphaned symlinks removed.
486
+ */
487
+ async function pruneOrphanedSymlinks(targetDir, commandsDir) {
488
+ const prefix = commandsDir + "/";
489
+ let pruned = 0;
490
+
491
+ async function walk(dir) {
492
+ let entries;
493
+ try {
494
+ entries = await readdir(dir, { withFileTypes: true });
495
+ } catch (err) {
496
+ if (err.code === "ENOENT") return; // targetDir doesn't exist yet — nothing to prune
497
+ throw err;
498
+ }
499
+ for (const entry of entries) {
500
+ const full = join(dir, entry.name);
501
+ if (entry.isDirectory()) {
502
+ await walk(full);
503
+ } else if (entry.isSymbolicLink()) {
504
+ let target;
505
+ try {
506
+ target = await readlink(full);
507
+ } catch {
508
+ continue; // can't read link — skip
509
+ }
510
+ // Only manage links that point into our commandsDir
511
+ if (!target.startsWith(prefix)) continue;
512
+ // Check whether the target file still exists
513
+ try {
514
+ await lstat(target);
515
+ // target exists — not orphaned
516
+ } catch (err) {
517
+ if (err.code !== "ENOENT") continue; // unexpected error — skip to be safe
518
+ // target is gone → orphaned symlink
519
+ try {
520
+ await unlink(full);
521
+ pruned++;
522
+ } catch (unlinkErr) {
523
+ if (unlinkErr.code !== "ENOENT") throw unlinkErr;
524
+ // already gone — race condition, that's fine
525
+ }
526
+ }
527
+ }
528
+ }
529
+ }
530
+
531
+ await walk(targetDir);
532
+ return pruned;
533
+ }
534
+
473
535
  /**
474
536
  * Act II: link commands into ~/.claude/commands with a molten progress line,
475
537
  * then register the SessionStart hook.
@@ -611,6 +673,10 @@ export async function forge(ctx) {
611
673
  }
612
674
  if (barShown) w.write("\x1b[1A\x1b[2K");
613
675
 
676
+ // Prune orphaned symlinks: links that point into commandsDir but whose
677
+ // target file no longer exists (e.g. after a command is renamed or deleted).
678
+ const pruned = await pruneOrphanedSymlinks(targetDir, commandsDir);
679
+
614
680
  const hookScript = join(ctx.forgeHome, "bin", "hooks", "session-start.mjs");
615
681
  const settingsPath = join(ctx.home, ".claude", "settings.json");
616
682
  const { status: hookStatus } = installSessionStartHook(settingsPath, hookScript);
@@ -648,6 +714,9 @@ export async function forge(ctx) {
648
714
  ? `(new ${installed}, copied ${copied}, updated ${updated}, unchanged ${skipped})`
649
715
  : `(new ${installed}, updated ${updated}, unchanged ${skipped})`;
650
716
  w.write(` ${glyph(true)} ${files.length} slash commands ${headlineVerb} ${dimLine(ctx, headlineDetail)}\n`);
717
+ if (pruned > 0) {
718
+ w.write(` ${glyph(true)} ${pruned} orphaned symlink${pruned === 1 ? "" : "s"} removed ${dimLine(ctx, "(commands deleted or renamed since last install)")}\n`);
719
+ }
651
720
  if (copied > 0) {
652
721
  w.write(` ${glyph(false)} ${copied} copied (not linked) ${dimLine(ctx, "— enable Windows Developer Mode for live-updating links")}\n`);
653
722
  }
@@ -677,7 +746,7 @@ export async function forge(ctx) {
677
746
  w.write(` ${glyph(true)} SubagentStop enforcement hook removed ${dimLine(ctx, "(non-functional — see forge#1527)")}\n`);
678
747
  }
679
748
 
680
- return { installed, updated, skipped, copied, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus };
749
+ return { installed, updated, skipped, copied, pruned, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus };
681
750
  }
682
751
 
683
752
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "description": "Autonomous dev pipeline for Claude Code. GitHub as institutional memory — every issue, PR, and annotation survives context resets and makes the next agent smarter.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"