forgedock 1.5.0 → 1.6.0

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
@@ -59,8 +59,8 @@ import {
59
59
  detectClaudeVersion,
60
60
  loadBreakpoints,
61
61
  hasFeature,
62
- getPersistedHomeState,
63
62
  setPersistedHomeState,
63
+ getInstalledCommandsDriftStatus,
64
64
  } from "./registry.mjs";
65
65
  import { renderMark, ember } from "./cinema.mjs";
66
66
  import {
@@ -1513,6 +1513,51 @@ function resolveGlobalNpmForgedockDir() {
1513
1513
  }
1514
1514
  }
1515
1515
 
1516
+ /**
1517
+ * Compare two already-`resolve()`d absolute paths for equality or containment
1518
+ * (child equals parent, or sits strictly under it). On case-insensitive
1519
+ * filesystems (win32/darwin) the same physical directory can resolve with
1520
+ * differing letter casing (drive letter, path segments), so both operands are
1521
+ * lowercased before comparing there; on Linux the comparison stays
1522
+ * case-sensitive byte-for-byte (forge#2668). On the case-insensitive branch
1523
+ * both operands are also Unicode-normalized to NFC: macOS APFS/HFS+ enumerate
1524
+ * filenames NFD-normalized while CLI args/env vars typically arrive NFC, and
1525
+ * `resolve()` preserves the input form — so without normalization two paths
1526
+ * for the same physical directory containing accented characters can compare
1527
+ * unequal (forge#2674). NFC is idempotent for pure-ASCII and pre-composed
1528
+ * paths, so ASCII behavior is unchanged; win32 normalizes harmlessly. The
1529
+ * `+ sep` boundary keeps `/foo-bar` from matching parent `/foo`. Callers pass
1530
+ * paths that are already resolved — this helper deliberately does not
1531
+ * re-resolve.
1532
+ *
1533
+ * Known limitation: `caseInsensitive` is gated on `process.platform`, an
1534
+ * OS-level constant, not on the actual case-sensitivity of the volume
1535
+ * FORGE_HOME/the npm global root live on. This is the correct assumption for
1536
+ * the default filesystem format on each OS (NTFS on win32, APFS/HFS+ on
1537
+ * darwin), but a volume explicitly formatted case-sensitive (e.g. opt-in
1538
+ * case-sensitive APFS) would still be case-folded here, and two distinct
1539
+ * directories differing only by case could be treated as equal. This is an
1540
+ * accepted approximation, not a probe of real volume semantics — both call
1541
+ * sites (`isGlobalNpmInstall`, `findSeparateGlobalInstall`) already fail
1542
+ * closed / stay advisory-only, so a false match here degrades to their
1543
+ * pre-existing "detection inconclusive" behavior rather than causing any
1544
+ * destructive or incorrect action (forge#2675). Probing the actual volume
1545
+ * (e.g. an `existsSync` case-twist check) is deliberately not done — the
1546
+ * added complexity and syscall aren't warranted for a purely advisory path.
1547
+ *
1548
+ * @param {string} child - resolved absolute path being tested
1549
+ * @param {string} parent - resolved absolute path of the candidate ancestor
1550
+ * @returns {boolean}
1551
+ */
1552
+ function samePathOrUnder(child, parent) {
1553
+ const caseInsensitive =
1554
+ process.platform === "win32" || process.platform === "darwin";
1555
+ const fold = (p) => (caseInsensitive ? p.normalize("NFC").toLowerCase() : p);
1556
+ const a = fold(child);
1557
+ const b = fold(parent);
1558
+ return a === b || a.startsWith(b + sep);
1559
+ }
1560
+
1516
1561
  /**
1517
1562
  * Detect whether FORGE_HOME resolves inside npm's global install tree, i.e.
1518
1563
  * this process is running as a globally-installed `forgedock` package (not
@@ -1533,7 +1578,7 @@ function isGlobalNpmInstall() {
1533
1578
  const globalPkgDir = resolveGlobalNpmForgedockDir();
1534
1579
  if (!globalPkgDir) return false;
1535
1580
  const home = resolve(FORGE_HOME);
1536
- return home === globalPkgDir || home.startsWith(globalPkgDir + sep);
1581
+ return samePathOrUnder(home, globalPkgDir);
1537
1582
  }
1538
1583
 
1539
1584
  /**
@@ -1558,7 +1603,7 @@ function findSeparateGlobalInstall() {
1558
1603
  const globalPkgDir = resolveGlobalNpmForgedockDir();
1559
1604
  if (!globalPkgDir) return null;
1560
1605
  const home = resolve(FORGE_HOME);
1561
- if (home === globalPkgDir || home.startsWith(globalPkgDir + sep)) {
1606
+ if (samePathOrUnder(home, globalPkgDir)) {
1562
1607
  // Current FORGE_HOME already IS the global install — nothing separate.
1563
1608
  return null;
1564
1609
  }
@@ -1804,9 +1849,10 @@ async function update() {
1804
1849
  // dirty tree. HEAD is still never moved in either case.
1805
1850
  const cwdResolved = resolve(process.cwd());
1806
1851
  const forgeHomeResolved = resolve(FORGE_HOME);
1807
- const isSourceRepoSelf =
1808
- cwdResolved === forgeHomeResolved ||
1809
- cwdResolved.startsWith(forgeHomeResolved + sep);
1852
+ const isSourceRepoSelf = samePathOrUnder(
1853
+ cwdResolved,
1854
+ forgeHomeResolved,
1855
+ );
1810
1856
 
1811
1857
  if (isSourceRepoSelf) {
1812
1858
  console.log(
@@ -2694,27 +2740,16 @@ async function doctor(fix = false) {
2694
2740
  `incomplete (missing: ${missingDirs.join(", ")}). Run: npx forgedock install`,
2695
2741
  );
2696
2742
  } else {
2697
- // Prefer the version registry.mjs last recorded for this exact
2698
- // write (getPersistedHomeState()) — the single source of truth
2699
- // update()/install also write through — falling back to reading
2700
- // ~/.forge/version directly on disk when the registry has no entry
2701
- // (e.g. an older ForgeDock version wrote ~/.forge/ before #1943
2702
- // added registry tracking). Reading both keeps doctor() honest
2703
- // even if the registry ever drifts from what's actually on disk.
2704
- const recorded = getPersistedHomeState();
2705
- let persistedVersion = recorded?.version || "";
2706
- if (!persistedVersion) {
2707
- try {
2708
- persistedVersion = readFileSync(versionPath, "utf-8").trim();
2709
- } catch {
2710
- // version file missing/unreadable — reported below as "unknown"
2711
- }
2712
- }
2713
- const sourceVersion = getVersion();
2714
- if (persistedVersion && sourceVersion && compareVersions(persistedVersion, sourceVersion) < 0) {
2743
+ // Package-vs-installed-commands drift comparison is factored into
2744
+ // a single shared helper (bin/registry.mjs's
2745
+ // getInstalledCommandsDriftStatus, forge#2719) so doctor, the
2746
+ // `version` command, and the SessionStart hook all agree on one
2747
+ // source of truth instead of re-deriving this logic independently.
2748
+ const { persistedVersion, isStale, unknown } = getInstalledCommandsDriftStatus(FORGE_HOME);
2749
+ if (!unknown && isStale) {
2715
2750
  warn(
2716
2751
  "Persisted toolset home (~/.forge)",
2717
- `v${persistedVersion} — source is v${sourceVersion}. Run: npx forgedock update`,
2752
+ `v${persistedVersion} — source is v${getVersion()}. Run: npx forgedock update`,
2718
2753
  );
2719
2754
  } else {
2720
2755
  pass("Persisted toolset home (~/.forge)", `v${persistedVersion || "unknown"} at ${persistedHome}`);
@@ -3913,6 +3948,18 @@ switch (command) {
3913
3948
  `Run ${CYAN}npx forgedock update${RESET} to fetch it.`,
3914
3949
  );
3915
3950
  }
3951
+
3952
+ // forge#2719 — surface package-vs-installed-commands drift (previously
3953
+ // only visible via `doctor` Check 5d). Uses the same shared helper doctor
3954
+ // calls, so both surfaces agree. Silent when versions match or when
3955
+ // either version is unknown (e.g. a git-clone dev install, which never
3956
+ // persists to ~/.forge — see registry.mjs's getInstalledCommandsDriftStatus).
3957
+ const driftStatus = getInstalledCommandsDriftStatus(FORGE_HOME);
3958
+ if (!driftStatus.unknown && driftStatus.isStale) {
3959
+ console.log(
3960
+ ` ${YELLOW}Installed commands are stale (v${driftStatus.persistedVersion}) vs package v${driftStatus.sourceVersion}${RESET} — run: ${CYAN}npx forgedock update${RESET}`,
3961
+ );
3962
+ }
3916
3963
  break;
3917
3964
  }
3918
3965
  case "help":
@@ -51,6 +51,8 @@ const FORGE_HOME = resolve(__dirname, "..", "..");
51
51
  let resolveState;
52
52
  let nudgeSeen;
53
53
  let markNudgeSeen;
54
+ // Assigned alongside the other registry.mjs helpers below — see forge#2719.
55
+ let getInstalledCommandsDriftStatus;
54
56
 
55
57
  // ---------------------------------------------------------------------------
56
58
  // forge-utils helpers — populated inside the try block to honour fail-open
@@ -88,7 +90,7 @@ try {
88
90
  // that import() rejects with ERR_UNSUPPORTED_ESM_URL_SCHEME.
89
91
  /** @type {import('../registry.mjs')} */
90
92
  (
91
- { resolveState, nudgeSeen, markNudgeSeen } = await import(
93
+ { resolveState, nudgeSeen, markNudgeSeen, getInstalledCommandsDriftStatus } = await import(
92
94
  pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
93
95
  )
94
96
  );
@@ -126,6 +128,25 @@ try {
126
128
  const cwd = process.cwd();
127
129
  const state = resolveState(cwd);
128
130
 
131
+ // Surface package-vs-installed-commands drift as a single non-fatal
132
+ // advisory line (forge#2719). Uses the same shared helper doctor's
133
+ // Check 5d and the `version` command call, so all three surfaces agree.
134
+ // Best-effort: any failure here must never block the session, and
135
+ // managed-optedout directories stay completely silent per this hook's
136
+ // existing contract for that state (see the switch below).
137
+ if (state !== "managed-optedout") {
138
+ try {
139
+ const driftStatus = getInstalledCommandsDriftStatus(FORGE_HOME);
140
+ if (!driftStatus.unknown && driftStatus.isStale) {
141
+ console.log(
142
+ `ForgeDock: installed commands are stale (v${driftStatus.persistedVersion}) vs package v${driftStatus.sourceVersion} — run: npx forgedock update`,
143
+ );
144
+ }
145
+ } catch {
146
+ // Fail open — never let a drift-check error affect session start.
147
+ }
148
+ }
149
+
129
150
  switch (state) {
130
151
  case "managed-active":
131
152
  await handleManagedActive(cwd);