@indigoai-us/hq-cli 5.109.7 → 5.109.8

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/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.109.8] — 2026-09-11
6
+
7
+ ### Fixed
8
+
9
+ - A background reinstall no longer turns into a crash report (HQ-CLI-1M,
10
+ HQ-CLI-1N). When another program reinstalls hq globally while a command is
11
+ running — the box's own update timer, the desktop app, or a second `hq` — it
12
+ renames hq's files aside and rewrites them file by file over a few seconds, and
13
+ a command already running can try to load a file that vanished in that window.
14
+ hq already knew to treat that as "your install was mid-update, not an hq bug"
15
+ and print a re-run/reinstall note instead of a crash, but it could only do so
16
+ while it could still find its own install directory on disk — which, in this
17
+ exact situation, it usually could not, because that directory is what just got
18
+ renamed away. That one blind spot silenced the recovery for two different crash
19
+ shapes: the ESM-loader `ENOENT` seen in production (HQ-CLI-1M) and a lazy
20
+ CommonJS `require('./sibling')` from a bundled dependency that resolves after
21
+ the tear (HQ-CLI-1N). hq now locates its own directory without reading the
22
+ disk, so the mid-update case is recognized for both: a registration-time tear
23
+ waits for the install to settle and re-runs once, and a tear anywhere else
24
+ prints the reinstall note. A genuine packaging fault in hq's own shipped files
25
+ (a miss under the install's own `dist/` or `assets/`) is still reported.
26
+
5
27
  ## [5.109.7] — 2026-09-10
6
28
 
7
29
  ### Fixed
@@ -48,6 +48,8 @@ export interface RegisterRecoveryDeps {
48
48
  resolveInstall?: () => {
49
49
  packageRoot: string | null;
50
50
  };
51
+ /** Filesystem-free fallback root for the torn window (defaults to stringDerivedPackageRoot). */
52
+ deriveInstallRoot?: () => string | null;
51
53
  lockPath?: () => string;
52
54
  waitForSettled?: (args: WaitForInstallTreeSettledArgs) => Promise<WaitForInstallTreeSettledResult>;
53
55
  /** node flags to forward to the re-exec child (defaults to process.execArgv). */
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { spawnSync } from "node:child_process";
20
20
  import { resolveRunningInstall } from "./utils/version-gate.js";
21
+ import { stringDerivedPackageRoot } from "./utils/hq-roots.js";
21
22
  import { updateLockPath } from "./utils/update-lock.js";
22
23
  import { classifyModuleNotFound, InstallTreeTornError, waitForInstallTreeSettled, } from "./utils/install-tree-torn.js";
23
24
  /**
@@ -122,13 +123,23 @@ export async function registerCommandsWithRecovery(args) {
122
123
  return { reexecStatus: child.status ?? 1 };
123
124
  }
124
125
  }
125
- /** Resolve the running install's package dir, tolerating any resolver failure. */
126
+ /**
127
+ * Resolve the running install's package dir for the settle wait's manifest-health
128
+ * and retired-sibling guards. The on-disk resolver returns null in exactly the
129
+ * window this recovery targets — the package directory has been renamed aside, so
130
+ * it cannot be found by reading disk — which would leave readiness keyed on the
131
+ * single missing file alone and let a premature re-exec run against a still-
132
+ * incomplete tree. Fall back to the filesystem-free derived root so those guards
133
+ * stay active while npm is mid-extraction. Tolerates any resolver failure.
134
+ */
126
135
  function resolvePackageRoot(deps) {
136
+ let root;
127
137
  try {
128
- return (deps.resolveInstall ?? resolveRunningInstall)().packageRoot;
138
+ root = (deps.resolveInstall ?? resolveRunningInstall)().packageRoot;
129
139
  }
130
140
  catch {
131
- return null;
141
+ root = null;
132
142
  }
143
+ return root ?? (deps.deriveInstallRoot ?? stringDerivedPackageRoot)();
133
144
  }
134
145
  //# sourceMappingURL=startup-registration.js.map
@@ -61,6 +61,31 @@ export declare function createPackageRootResolver(options: PackageRootResolverOp
61
61
  export declare function packageRoot(): string;
62
62
  /** Reset the memoized package root. Tests only. */
63
63
  export declare function __resetPackageRootCache(): void;
64
+ /**
65
+ * Derive this package's installed root from the running module's OWN path using
66
+ * STRING operations only — no `readFileSync`, `realpathSync`, or `existsSync`.
67
+ *
68
+ * {@link packageRoot} must read `<dir>/package.json` and `realpathSync` the
69
+ * module directory to answer, so it THROWS in exactly the situation that most
70
+ * needs an answer: a concurrent global install has renamed the installed
71
+ * package directory aside (`.hq-cli-<rand>`) and is re-extracting it file by
72
+ * file, so both the manifest walk and the dist-owner fallback fail on a
73
+ * directory that is momentarily gone. This walks the compiled module's
74
+ * ancestors as strings and returns the DEEPEST one whose trailing segments are
75
+ * `node_modules/@indigoai-us/hq-cli`, which stays correct while that directory
76
+ * does not exist on disk.
77
+ *
78
+ * It is a FALLBACK only — {@link packageRoot} validates the manifest name and
79
+ * is authoritative whenever it succeeds. Returns null for a dev checkout or any
80
+ * layout without that segment triple, so a caller that falls back to it can
81
+ * only ever degrade to today's behaviour (no resolution), never widen it.
82
+ *
83
+ * The deepest match is deliberate: a nested `node_modules/@indigoai-us/hq-cli`
84
+ * inside another package resolves to ITSELF (the copy the module belongs to),
85
+ * never an outer decoy. Case is folded only for a Windows-shaped path, matching
86
+ * the POSIX case-sensitivity the rest of this module assumes.
87
+ */
88
+ export declare function stringDerivedPackageRoot(moduleFilePath?: string): string | null;
64
89
  export type LiveRootOptions = {
65
90
  /** Explicit `--hq-root` value. Highest precedence. */
66
91
  hqRoot?: string;
@@ -191,6 +191,73 @@ export function __resetPackageRootCache() {
191
191
  modulePath: currentModulePath,
192
192
  });
193
193
  }
194
+ /**
195
+ * The trailing path segments that mark an installed copy of this package:
196
+ * `node_modules` followed by the package name's own segments (for
197
+ * `@indigoai-us/hq-cli`, that is `node_modules/@indigoai-us/hq-cli`).
198
+ */
199
+ const INSTALLED_ROOT_SEGMENTS = ["node_modules", ...CLI_PACKAGE_NAME.split("/")];
200
+ /** A Windows-shaped absolute path (drive letter or UNC), regardless of host OS. */
201
+ function looksWin32Path(p) {
202
+ return /^[a-zA-Z]:[\\/]/.test(p) || /^\\\\/.test(p);
203
+ }
204
+ /**
205
+ * Derive this package's installed root from the running module's OWN path using
206
+ * STRING operations only — no `readFileSync`, `realpathSync`, or `existsSync`.
207
+ *
208
+ * {@link packageRoot} must read `<dir>/package.json` and `realpathSync` the
209
+ * module directory to answer, so it THROWS in exactly the situation that most
210
+ * needs an answer: a concurrent global install has renamed the installed
211
+ * package directory aside (`.hq-cli-<rand>`) and is re-extracting it file by
212
+ * file, so both the manifest walk and the dist-owner fallback fail on a
213
+ * directory that is momentarily gone. This walks the compiled module's
214
+ * ancestors as strings and returns the DEEPEST one whose trailing segments are
215
+ * `node_modules/@indigoai-us/hq-cli`, which stays correct while that directory
216
+ * does not exist on disk.
217
+ *
218
+ * It is a FALLBACK only — {@link packageRoot} validates the manifest name and
219
+ * is authoritative whenever it succeeds. Returns null for a dev checkout or any
220
+ * layout without that segment triple, so a caller that falls back to it can
221
+ * only ever degrade to today's behaviour (no resolution), never widen it.
222
+ *
223
+ * The deepest match is deliberate: a nested `node_modules/@indigoai-us/hq-cli`
224
+ * inside another package resolves to ITSELF (the copy the module belongs to),
225
+ * never an outer decoy. Case is folded only for a Windows-shaped path, matching
226
+ * the POSIX case-sensitivity the rest of this module assumes.
227
+ */
228
+ export function stringDerivedPackageRoot(moduleFilePath = currentModulePath) {
229
+ if (typeof moduleFilePath !== "string" || moduleFilePath.length === 0) {
230
+ return null;
231
+ }
232
+ const wanted = INSTALLED_ROOT_SEGMENTS;
233
+ const caseInsensitive = looksWin32Path(moduleFilePath);
234
+ const sameSegment = (a, b) => caseInsensitive ? a.toLowerCase() === b.toLowerCase() : a === b;
235
+ // Tokenise into segments with each one's end offset in the ORIGINAL string,
236
+ // so the returned root keeps the source path's leading root and separators
237
+ // verbatim (a leading `/`, or a `C:\` drive) rather than a rejoined guess.
238
+ const segments = [];
239
+ const segmentPattern = /[^\\/]+/g;
240
+ let match;
241
+ while ((match = segmentPattern.exec(moduleFilePath)) !== null) {
242
+ segments.push({ text: match[0], end: match.index + match[0].length });
243
+ }
244
+ if (segments.length < wanted.length)
245
+ return null;
246
+ // Scan from the deepest segment upward; the first (deepest) leaf whose
247
+ // preceding segments complete the triple wins.
248
+ for (let leaf = segments.length - 1; leaf >= wanted.length - 1; leaf--) {
249
+ let matched = true;
250
+ for (let k = 0; k < wanted.length; k++) {
251
+ if (!sameSegment(segments[leaf - (wanted.length - 1) + k].text, wanted[k])) {
252
+ matched = false;
253
+ break;
254
+ }
255
+ }
256
+ if (matched)
257
+ return moduleFilePath.slice(0, segments[leaf].end);
258
+ }
259
+ return null;
260
+ }
194
261
  /**
195
262
  * Resolve the user's live HQ installation.
196
263
  *
@@ -10,6 +10,8 @@ import * as fs from "fs";
10
10
  export declare const INCOMPLETE_INSTALL_REMEDY: string;
11
11
  /** A resolver for the running install's root; returns null instead of throwing. */
12
12
  export type PackageRootResolver = () => string | null;
13
+ /** Which strategy produced the running install's root, recorded in diagnostics. */
14
+ export type PackageRootResolverSource = "manifest-walk" | "string-derivation" | "unresolved" | "injected";
13
15
  /**
14
16
  * If `err` is an in-process incomplete-install module-load failure — either the
15
17
  * CJS relative-sibling shape (HQ-CLI-1N) or the ESM vanished-file shape
@@ -24,9 +26,21 @@ export type PackageRootResolver = () => string | null;
24
26
  export declare function incompleteInstallMessage(err: unknown, resolvePackageRoot?: PackageRootResolver, fileSystem?: Pick<typeof fs, "readFileSync">): string | null;
25
27
  /** Bounded, scrubber-safe diagnostics for an unattributable esm-loader ENOENT (HQ-CLI-1M). */
26
28
  export type IncompleteInstallEsmDiagnostics = {
29
+ /** The resolved install root, or the literal `<unresolved>` when none was found. */
27
30
  packageRoot: string;
28
- packageJsonExists: boolean;
29
- nodeModulesExists: boolean;
31
+ /** True only when a root was actually resolved (by any strategy). */
32
+ packageRootResolved: boolean;
33
+ /** Which resolver answered — so an unresolved root is never read as resolved-but-missing. */
34
+ resolver: PackageRootResolverSource;
35
+ /**
36
+ * Whether `<root>/package.json` and `<root>/node_modules` exist on disk.
37
+ * Present ONLY when a root was resolved: reported for an unresolved root,
38
+ * a bare `false` is indistinguishable from "resolved but the file is missing"
39
+ * — the ambiguity that made the delivered HQ-CLI-1M evidence weaker than
40
+ * intended (the two booleans were uninitialised defaults).
41
+ */
42
+ packageJsonExists?: boolean;
43
+ nodeModulesExists?: boolean;
30
44
  esmLoaderFrame: boolean;
31
45
  code: string;
32
46
  };
@@ -42,21 +56,44 @@ export type IncompleteInstallBareDiagnostics = {
42
56
  requiringPackage: string;
43
57
  requirerDeclaresMissing: false;
44
58
  };
59
+ /** The four lexical scopes a NOT-suppressed CJS relative requirer can fall into. */
60
+ export type RelativeRequirerScope = "dist" | "assets" | "outside-root" | "unresolved-root";
45
61
  /**
46
- * Both enriched shapes as a single OPEN record — every field optional so a
47
- * consumer can forward either shape to Sentry without narrowing (the boundary
48
- * and beforeSend only pass the block through). Every value CONSTRUCTED here is
49
- * exactly one of the two strict shapes above; the looseness is only at the read
62
+ * Bounded, scrubber-safe diagnostics for a NOT-suppressed CJS RELATIVE-specifier
63
+ * miss (HQ-CLI-1N): the CJS relative-sibling shape that reached the capture path
64
+ * WITHOUT being suppressed its requirer sits under the install's own `dist/` or
65
+ * `assets/`, outside the running install entirely, or the install root could not
66
+ * be resolved. Records only a bounded, four-value LEXICAL scope of the requirer
67
+ * against the root — never the requirer path and never the specifier — so
68
+ * grouping cardinality cannot inflate. `code` is always `MODULE_NOT_FOUND`.
69
+ */
70
+ export type IncompleteInstallRelativeDiagnostics = {
71
+ code: string;
72
+ relativeSpecifier: true;
73
+ packageRoot: string;
74
+ packageRootResolved: boolean;
75
+ resolver: PackageRootResolverSource;
76
+ requirerScope: RelativeRequirerScope;
77
+ };
78
+ /**
79
+ * The enriched shapes as a single OPEN record — every field optional so a
80
+ * consumer can forward any shape to Sentry without narrowing (the boundary and
81
+ * beforeSend only pass the block through). Every value CONSTRUCTED here is
82
+ * exactly one of the strict shapes above; the looseness is only at the read
50
83
  * boundary.
51
84
  */
52
- export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnostics & IncompleteInstallBareDiagnostics>;
85
+ export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnostics & IncompleteInstallBareDiagnostics & IncompleteInstallRelativeDiagnostics>;
53
86
  /**
54
87
  * When an incomplete-install failure reaches the capture path WITHOUT being
55
88
  * suppressed, return a bounded `contexts.incomplete_install` block so the next
56
89
  * occurrence carries the evidence this one lacked; otherwise return undefined
57
- * (bare capture). Two enriched shapes:
90
+ * (bare capture). Three enriched shapes:
58
91
  * - a third-party BARE-specifier miss the requirer did not declare (HQ-CLI-1Q)
59
92
  * → { missingPackage, requiringPackage, requirerDeclaresMissing:false };
93
+ * - a CJS RELATIVE-specifier miss whose requirer sits under the install's own
94
+ * dist/ or assets/, outside the install, or under an unresolved root
95
+ * (HQ-CLI-1N) → { code, relativeSpecifier:true, packageRoot,
96
+ * packageRootResolved, resolver, requirerScope };
60
97
  * - an esm-loader ENOENT whose path did not survive delivery (HQ-CLI-1M) — the
61
98
  * shape the delivered payload arrived in, where neither the exception value
62
99
  * nor node_system_error carried a `path` → { packageRoot, packageJsonExists,
@@ -63,7 +63,7 @@
63
63
  // `fs.readFileSync` ENOENT written by hq's own code stays captured.
64
64
  import * as fs from "fs";
65
65
  import * as path from "path";
66
- import { packageRoot } from "./hq-roots.js";
66
+ import { packageRoot, stringDerivedPackageRoot } from "./hq-roots.js";
67
67
  import { packageNameOf } from "./install-tree-torn.js";
68
68
  import { boundedDiagnosticValue } from "./package-root-diagnostics.js";
69
69
  /**
@@ -85,8 +85,16 @@ export const INCOMPLETE_INSTALL_REMEDY = "hq couldn't load part of its own insta
85
85
  "`pnpm add -g @indigoai-us/hq-cli`).";
86
86
  /** A relative module specifier — `./x`, `../x`, `.\x`, `..\x`. */
87
87
  const RELATIVE_SPECIFIER = /^\.\.?[\\/]/;
88
- /** A Node ESM loader frame — proves the ENOENT came from the module loader, not hq's own fs call. */
89
- const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]/;
88
+ /**
89
+ * The Node ESM loader's SOURCE-READ frame — `getSourceSync`/`defaultLoad` in
90
+ * `node:internal/modules/esm/load`. Requiring the `esm/load` module frame (not
91
+ * merely any frame under `modules/esm/`) proves the loader was READING the
92
+ * module's source. An ordinary `fs.openSync`/`readFileSync` ENOENT that merely
93
+ * ESCAPES a module's EVALUATION runs under `esm/module_job`, never `esm/load`,
94
+ * so it stays captured rather than suppressed. `load\b` excludes the sibling
95
+ * `esm/loader` module.
96
+ */
97
+ const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]load\b/;
90
98
  const ROOT_DIAGNOSTIC_BYTES = 256;
91
99
  const CODE_DIAGNOSTIC_BYTES = 32;
92
100
  // Package names live inside hq-cli's own dependency graph, so their universe is
@@ -95,19 +103,42 @@ const PACKAGE_NAME_DIAGNOSTIC_BYTES = 128;
95
103
  /** The `<something>/node_modules/<something>` directory-boundary marker. */
96
104
  const NODE_MODULES_SEGMENT = "/node_modules/";
97
105
  /**
98
- * packageRoot() walks up from the compiled module and THROWS
99
- * PackageRootResolutionError when it cannot resolve. This classifier runs inside
100
- * beforeSend on EVERY event, so it must never throw a resolution failure
101
- * returns null and the error stays captured.
106
+ * Resolve the running install's root, trying the authoritative on-disk manifest
107
+ * walk first and falling back to the filesystem-free string derivation ONLY when
108
+ * it throws the torn-tree case, where a concurrent global install has renamed
109
+ * the package directory aside so packageRoot() cannot read a manifest. Never
110
+ * throws; reports which strategy answered so a captured event is attributable.
111
+ *
112
+ * packageRoot() itself is deliberately NOT widened: resolveBundledAsset() and
113
+ * isWithinPackage() require a root that exists on disk, and handing them a
114
+ * string-derived phantom directory would break them. The fallback lives here,
115
+ * where the only consumer is classification (a lexical prefix test) that needs
116
+ * no live filesystem.
102
117
  */
103
- function safePackageRoot() {
118
+ function resolvePackageRootWithSource() {
104
119
  try {
105
- return packageRoot();
120
+ return { root: packageRoot(), source: "manifest-walk" };
106
121
  }
107
122
  catch {
108
- return null;
123
+ const derived = stringDerivedPackageRoot();
124
+ return derived
125
+ ? { root: derived, source: "string-derivation" }
126
+ : { root: null, source: "unresolved" };
109
127
  }
110
128
  }
129
+ /**
130
+ * packageRoot() walks up from the compiled module and THROWS
131
+ * PackageRootResolutionError when it cannot resolve. This classifier runs inside
132
+ * beforeSend on EVERY event, so it must never throw — a resolution failure
133
+ * returns null and the error stays captured. When the on-disk walk fails because
134
+ * a concurrent install renamed the package directory aside, the filesystem-free
135
+ * string derivation still supplies the root: that is the HQ-CLI-1M repair —
136
+ * the running install's root must be known even while its directory is
137
+ * momentarily gone.
138
+ */
139
+ function safePackageRoot() {
140
+ return resolvePackageRootWithSource().root;
141
+ }
111
142
  /** Call a (possibly injected) resolver without letting it throw. */
112
143
  function resolveRootSafely(resolve) {
113
144
  try {
@@ -137,16 +168,25 @@ function normalizeForCompare(p) {
137
168
  return looksWin32(p) ? folded.toLowerCase() : folded;
138
169
  }
139
170
  /**
140
- * True when `candidate` lives under `<root>/node_modules/`. Anchored at a true
141
- * directory boundary (`<root>` + sep + `node_modules` + sep) so a sibling such
142
- * as `<root>-old/node_modules/...` can never match.
171
+ * True when `candidate` lives directly under `<root>/<subdir>/`. Anchored at a
172
+ * true directory boundary (`<root>` + sep + `<subdir>` + sep) so a sibling such
173
+ * as `<root>-old/<subdir>/...` can never match. `<subdir>` carries no separator,
174
+ * so this stays a single lexical prefix test that needs no live filesystem.
143
175
  */
144
- function isUnderNodeModules(candidate, root) {
176
+ function isUnderSubdir(candidate, root, subdir) {
145
177
  if (!candidate || !root)
146
178
  return false;
147
- const prefix = `${normalizeForCompare(root)}/node_modules/`;
179
+ const prefix = `${normalizeForCompare(root)}/${subdir}/`;
148
180
  return normalizeForCompare(candidate).startsWith(prefix);
149
181
  }
182
+ /**
183
+ * True when `candidate` lives under `<root>/node_modules/`. Anchored at a true
184
+ * directory boundary so a sibling such as `<root>-old/node_modules/...` can
185
+ * never match.
186
+ */
187
+ function isUnderNodeModules(candidate, root) {
188
+ return isUnderSubdir(candidate, root, "node_modules");
189
+ }
150
190
  /** The failing specifier from a `Cannot find module '<spec>'` message, or null. */
151
191
  function parseMissingSpecifier(message) {
152
192
  if (typeof message !== "string")
@@ -392,13 +432,87 @@ function bareSpecifierCaptureContext(err, resolvePackageRoot, fileSystem) {
392
432
  },
393
433
  };
394
434
  }
435
+ /**
436
+ * Classify a NOT-suppressed CJS relative requirer LEXICALLY against the resolved
437
+ * root — never emitting the path itself, only one of four fixed values. A
438
+ * requirer under `<root>/node_modules/` with a resolved root is ALWAYS suppressed
439
+ * upstream, so that scope is unreachable here and deliberately absent from the
440
+ * enum.
441
+ * - root null (both resolvers failed) → 'unresolved-root'
442
+ * - under `<root>/dist/` → 'dist' (hq-cli's own shipped output)
443
+ * - under `<root>/assets/` → 'assets' (hq-cli's own bundled assets)
444
+ * - anything else (a user project, a `<root>-old` sibling, …) → 'outside-root'
445
+ */
446
+ function classifyRelativeRequirerScope(requiringFile, root) {
447
+ if (!root)
448
+ return "unresolved-root";
449
+ if (isUnderSubdir(requiringFile, root, "dist"))
450
+ return "dist";
451
+ if (isUnderSubdir(requiringFile, root, "assets"))
452
+ return "assets";
453
+ return "outside-root";
454
+ }
455
+ /**
456
+ * When a CJS RELATIVE-specifier miss (HQ-CLI-1N's shape) reached the capture path
457
+ * WITHOUT being suppressed — its requirer sits under the install's own `dist/` or
458
+ * `assets/`, outside the running install entirely, or the install root could not
459
+ * be resolved — return a bounded `incomplete_install` block recording a
460
+ * four-value lexical scope so the next occurrence is attributable instead of
461
+ * bare; otherwise undefined. The suppression decision is delegated to
462
+ * incompleteInstallMessage against the SAME injected resolver, so a suppressed
463
+ * relative miss (requirer under `<root>/node_modules/` with a resolved root) is
464
+ * never double-attributed here. Never throws; never emits a path or the specifier.
465
+ */
466
+ function relativeSpecifierCaptureContext(err, resolvePackageRoot, fileSystem) {
467
+ if (err === null || typeof err !== "object")
468
+ return undefined;
469
+ const record = err;
470
+ if (record.code !== "MODULE_NOT_FOUND")
471
+ return undefined;
472
+ const requireStack = record.requireStack;
473
+ if (!Array.isArray(requireStack) || typeof requireStack[0] !== "string")
474
+ return undefined;
475
+ const requiringFile = requireStack[0];
476
+ const specifier = parseMissingSpecifier(record.message);
477
+ if (specifier === null || !RELATIVE_SPECIFIER.test(specifier))
478
+ return undefined;
479
+ // Suppressed (requirer under <root>/node_modules with a resolved root) →
480
+ // printed-and-skipped, never captured. Delegated to the classifier against the
481
+ // SAME resolver so the two decisions can never diverge and nothing that would
482
+ // be suppressed is ever double-attributed.
483
+ if (incompleteInstallMessage(err, resolvePackageRoot, readFileFrom(fileSystem)) !== null) {
484
+ return undefined;
485
+ }
486
+ // Not suppressed: record which resolver answered and the lexical requirer scope.
487
+ const resolution = resolvePackageRoot === safePackageRoot
488
+ ? resolvePackageRootWithSource()
489
+ : {
490
+ root: resolveRootSafely(resolvePackageRoot),
491
+ source: "injected",
492
+ };
493
+ const root = resolution.root;
494
+ return {
495
+ incomplete_install: {
496
+ code: "MODULE_NOT_FOUND",
497
+ relativeSpecifier: true,
498
+ packageRoot: boundedDiagnosticValue(root ?? "<unresolved>", ROOT_DIAGNOSTIC_BYTES),
499
+ packageRootResolved: root !== null,
500
+ resolver: resolution.source,
501
+ requirerScope: classifyRelativeRequirerScope(requiringFile, root),
502
+ },
503
+ };
504
+ }
395
505
  /**
396
506
  * When an incomplete-install failure reaches the capture path WITHOUT being
397
507
  * suppressed, return a bounded `contexts.incomplete_install` block so the next
398
508
  * occurrence carries the evidence this one lacked; otherwise return undefined
399
- * (bare capture). Two enriched shapes:
509
+ * (bare capture). Three enriched shapes:
400
510
  * - a third-party BARE-specifier miss the requirer did not declare (HQ-CLI-1Q)
401
511
  * → { missingPackage, requiringPackage, requirerDeclaresMissing:false };
512
+ * - a CJS RELATIVE-specifier miss whose requirer sits under the install's own
513
+ * dist/ or assets/, outside the install, or under an unresolved root
514
+ * (HQ-CLI-1N) → { code, relativeSpecifier:true, packageRoot,
515
+ * packageRootResolved, resolver, requirerScope };
402
516
  * - an esm-loader ENOENT whose path did not survive delivery (HQ-CLI-1M) — the
403
517
  * shape the delivered payload arrived in, where neither the exception value
404
518
  * nor node_system_error carried a `path` → { packageRoot, packageJsonExists,
@@ -413,7 +527,11 @@ export function incompleteInstallCaptureContext(err, resolvePackageRoot = safePa
413
527
  const bare = bareSpecifierCaptureContext(err, resolvePackageRoot, fileSystem);
414
528
  if (bare)
415
529
  return bare;
416
- // (B) HQ-CLI-1M — the path-less esm-loader ENOENT, unchanged.
530
+ // (B) HQ-CLI-1N — the not-suppressed CJS relative-sibling miss, made attributable.
531
+ const relative = relativeSpecifierCaptureContext(err, resolvePackageRoot, fileSystem);
532
+ if (relative)
533
+ return relative;
534
+ // (C) HQ-CLI-1M — the path-less esm-loader ENOENT, unchanged.
417
535
  if (!isEsmLoaderEnoent(err))
418
536
  return undefined;
419
537
  // Only instrument what we did NOT already confidently suppress: a path under
@@ -422,32 +540,40 @@ export function incompleteInstallCaptureContext(err, resolvePackageRoot = safePa
422
540
  return undefined;
423
541
  }
424
542
  const record = err;
425
- const root = resolveRootSafely(resolvePackageRoot);
426
543
  const code = typeof record.code === "string" ? record.code : "";
427
- let packageJsonExists = false;
428
- let nodeModulesExists = false;
429
- if (root) {
430
- try {
431
- packageJsonExists = fileSystem.existsSync(path.join(root, "package.json"));
432
- }
433
- catch {
434
- packageJsonExists = false;
435
- }
436
- try {
437
- nodeModulesExists = fileSystem.existsSync(path.join(root, "node_modules"));
438
- }
439
- catch {
440
- nodeModulesExists = false;
441
- }
442
- }
443
- return {
444
- incomplete_install: {
445
- packageRoot: boundedDiagnosticValue(root ?? "<unresolved>", ROOT_DIAGNOSTIC_BYTES),
446
- packageJsonExists,
447
- nodeModulesExists,
448
- esmLoaderFrame: true,
449
- code: boundedDiagnosticValue(code, CODE_DIAGNOSTIC_BYTES),
450
- },
544
+ // The default resolver knows which strategy answered (manifest walk vs the
545
+ // string derivation that survives a torn tree); an injected resolver is
546
+ // opaque, so it is recorded as "injected" and its return used as the root.
547
+ const resolution = resolvePackageRoot === safePackageRoot
548
+ ? resolvePackageRootWithSource()
549
+ : {
550
+ root: resolveRootSafely(resolvePackageRoot),
551
+ source: "injected",
552
+ };
553
+ const root = resolution.root;
554
+ const diagnostics = {
555
+ packageRoot: boundedDiagnosticValue(root ?? "<unresolved>", ROOT_DIAGNOSTIC_BYTES),
556
+ packageRootResolved: root !== null,
557
+ resolver: resolution.source,
558
+ esmLoaderFrame: true,
559
+ code: boundedDiagnosticValue(code, CODE_DIAGNOSTIC_BYTES),
451
560
  };
561
+ // Report the existence booleans ONLY when a root was resolved. Reported for an
562
+ // unresolved root they were uninitialised `false`s indistinguishable from
563
+ // "resolved but missing" — the instrumentation defect the prior fix shipped.
564
+ if (root !== null) {
565
+ diagnostics.packageJsonExists = safeExistsSync(fileSystem, path.join(root, "package.json"));
566
+ diagnostics.nodeModulesExists = safeExistsSync(fileSystem, path.join(root, "node_modules"));
567
+ }
568
+ return { incomplete_install: diagnostics };
569
+ }
570
+ /** existsSync that never throws — any filesystem error reads as "absent". */
571
+ function safeExistsSync(fileSystem, target) {
572
+ try {
573
+ return fileSystem.existsSync(target);
574
+ }
575
+ catch {
576
+ return false;
577
+ }
452
578
  }
453
579
  //# sourceMappingURL=incomplete-install-error.js.map
@@ -29,8 +29,12 @@
29
29
  * ../startup-registration.ts; version-gate.ts / self-update.ts / update-lock.ts
30
30
  * are only READ (their exported symbols), never modified.
31
31
  */
32
- /** The two loader-error `code`s that mean "a module could not be resolved". */
33
- export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND";
32
+ /**
33
+ * The loader-error `code`s recovery classifies. The first two mean "a module
34
+ * could not be resolved"; `ENOENT` is the esm-loader dialect where a module was
35
+ * present at resolve and gone at read (HQ-CLI-1M) — see {@link ModuleErrorDialect}.
36
+ */
37
+ export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "ENOENT";
34
38
  /**
35
39
  * The closed set of resolution-failure dialects, verified on Node v22.23.1 (the
36
40
  * @sentry/node import-in-the-middle hook does not change the shapes):
@@ -42,10 +46,13 @@ export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND";
42
46
  * '<abs>'` + `requireStack`.
43
47
  * - `cjs-package`: CJS require of a bare specifier — `Cannot find module
44
48
  * '<name>'` + `requireStack`.
49
+ * - `esm-enoent`: ESM loader ENOENT (HQ-CLI-1M) — a module present at RESOLVE
50
+ * and gone at READ, so getSourceSync/openSync raises ENOENT
51
+ * (not ERR_MODULE_NOT_FOUND). `err.path` is the vanished file.
45
52
  * - `unknown`: a module-not-found whose message did not parse; recovery
46
53
  * still waits on the lock / retired-dir / quiet signals.
47
54
  */
48
- export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "unknown";
55
+ export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "esm-enoent" | "unknown";
49
56
  /**
50
57
  * The missing thing, re-resolvable by the readiness probe:
51
58
  * - `path`: an absolute filesystem path (a `.js` file, or a package dir).
@@ -82,10 +89,11 @@ export declare function packageNameOf(specifier: string): string;
82
89
  /**
83
90
  * Classify a thrown value as a module-resolution failure and extract the missing
84
91
  * target, or return `null` for anything that is not one. The decision is
85
- * STRUCTURAL: `err.code` must be exactly `ERR_MODULE_NOT_FOUND` or
86
- * `MODULE_NOT_FOUND`no argv, env, or free text is ever consulted, and any
87
- * other error (including an import-time throw of another class) returns `null`
88
- * so it is rethrown to the existing boundary unchanged.
92
+ * STRUCTURAL: `err.code` must be exactly `ERR_MODULE_NOT_FOUND`,
93
+ * `MODULE_NOT_FOUND`, or for the esm-loader ENOENT dialect `ENOENT` under
94
+ * the full conjunction below. No argv, env, or free text is ever consulted, and
95
+ * any other error (including an import-time throw of another class) returns
96
+ * `null` so it is rethrown to the existing boundary unchanged.
89
97
  */
90
98
  export declare function classifyModuleNotFound(err: unknown): ClassifiedModuleError | null;
91
99
  /** The filesystem surface the probe and wait use, injectable for hermetic tests. */
@@ -42,6 +42,16 @@ const PACKAGE_ROOT_BYTES = 256;
42
42
  const ESM_PACKAGE_RE = /^Cannot find package '([^']+)' imported from (.+)$/s;
43
43
  const CJS_MODULE_RE = /^Cannot find module '([^']+)'/s;
44
44
  const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)$/s;
45
+ /**
46
+ * The Node ESM loader's SOURCE-READ frame — `getSourceSync`/`defaultLoad` in
47
+ * `node:internal/modules/esm/load`. Requiring the `esm/load` module frame (not
48
+ * merely any frame under `modules/esm/`) is what proves the loader was READING
49
+ * the module's source, so an ordinary `fs.openSync`/`readFileSync` ENOENT that
50
+ * merely ESCAPES a module's EVALUATION — which runs under `esm/module_job`,
51
+ * never `esm/load` — is NOT misclassified as a torn install and does not trigger
52
+ * the settle wait. `load\b` also excludes the sibling `esm/loader` module.
53
+ */
54
+ const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]load\b/;
45
55
  /**
46
56
  * Reduce a bare specifier to its PACKAGE name: `@scope/name/sub` → `@scope/name`,
47
57
  * `name/sub` → `name`, `name` → `name`. This is the unit the readiness probe
@@ -57,16 +67,40 @@ export function packageNameOf(specifier) {
57
67
  /**
58
68
  * Classify a thrown value as a module-resolution failure and extract the missing
59
69
  * target, or return `null` for anything that is not one. The decision is
60
- * STRUCTURAL: `err.code` must be exactly `ERR_MODULE_NOT_FOUND` or
61
- * `MODULE_NOT_FOUND`no argv, env, or free text is ever consulted, and any
62
- * other error (including an import-time throw of another class) returns `null`
63
- * so it is rethrown to the existing boundary unchanged.
70
+ * STRUCTURAL: `err.code` must be exactly `ERR_MODULE_NOT_FOUND`,
71
+ * `MODULE_NOT_FOUND`, or for the esm-loader ENOENT dialect `ENOENT` under
72
+ * the full conjunction below. No argv, env, or free text is ever consulted, and
73
+ * any other error (including an import-time throw of another class) returns
74
+ * `null` so it is rethrown to the existing boundary unchanged.
64
75
  */
65
76
  export function classifyModuleNotFound(err) {
66
77
  if (err === null || typeof err !== "object")
67
78
  return null;
68
79
  const record = err;
69
80
  const code = record.code;
81
+ // (0) esm-loader ENOENT (HQ-CLI-1M): a module present at RESOLVE and gone at
82
+ // READ, so Node's ESM loader raises ENOENT from getSourceSync/openSync rather
83
+ // than ERR_MODULE_NOT_FOUND at resolve. Gated on the FULL conjunction — code
84
+ // ENOENT AND syscall 'open' AND an esm-loader stack frame AND a string path —
85
+ // so an ordinary fs.readFileSync/openSync ENOENT written by hq's own code (no
86
+ // loader frame) cannot enter it. The vanished file IS the re-resolvable target
87
+ // the settle wait polls; recovery then waits and re-execs exactly as for the
88
+ // other dialects.
89
+ if (code === "ENOENT") {
90
+ if (record.syscall === "open" &&
91
+ typeof record.path === "string" &&
92
+ typeof record.stack === "string" &&
93
+ ESM_LOADER_FRAME.test(record.stack)) {
94
+ return {
95
+ code,
96
+ dialect: "esm-enoent",
97
+ specifier: record.path,
98
+ importer: "",
99
+ target: { kind: "path", path: record.path },
100
+ };
101
+ }
102
+ return null;
103
+ }
70
104
  if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND")
71
105
  return null;
72
106
  const message = typeof record.message === "string" ? record.message : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.109.7",
3
+ "version": "5.109.8",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {