@indigoai-us/hq-cli 5.205.0 → 5.206.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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.206.0] — 2026-09-26
6
+
7
+ ### Fixed
8
+
9
+ - Startup recovery no longer waits the full install deadline when an unchanged retired hq-cli package remains beside the install. Node 24 ESM loader file-read `ENOENT` errors also enter the existing recovery path and receive a report tag.
10
+
5
11
  ## [5.205.0] — 2026-09-26
6
12
 
7
13
  ### Added
@@ -158,6 +158,7 @@ export async function registerCommandsWithRecovery(args) {
158
158
  waitedMs: result.waitedMs,
159
159
  sawLock: result.sawLock,
160
160
  sawRetired: result.sawRetired,
161
+ retiredSiblingPresent: result.retiredSiblingPresent,
161
162
  sawTreeChange: result.sawTreeChange,
162
163
  lockHeldAtEnd: result.lockHeldAtEnd,
163
164
  lockedMs: result.lockedMs,
@@ -188,6 +189,7 @@ export async function registerCommandsWithRecovery(args) {
188
189
  waitedMs: result.waitedMs,
189
190
  sawLock: result.sawLock,
190
191
  sawRetired: result.sawRetired,
192
+ retiredSiblingPresent: result.retiredSiblingPresent,
191
193
  sawTreeChange: result.sawTreeChange,
192
194
  lockTool: result.lockTool,
193
195
  });
@@ -39,8 +39,9 @@
39
39
  */
40
40
  export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "ENOENT" | "EVAL_THROW";
41
41
  /**
42
- * The closed set of resolution-failure dialects, verified on Node v22.23.1 (the
43
- * @sentry/node import-in-the-middle hook does not change the shapes):
42
+ * The closed set of resolution-failure dialects, verified on Node v22.23.1 and
43
+ * the observed Node 24 synchronous ESM loader shape (the @sentry/node
44
+ * import-in-the-middle hook does not change the stack transfer):
44
45
  * - `esm-path`: ESM importer, an absolute file (or legacyMainResolve dir)
45
46
  * target — `err.url` set, or `Cannot find package '<abs>'…`.
46
47
  * - `esm-package`: ESM importer, a bare specifier — `Cannot find package
@@ -62,7 +63,7 @@ export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "
62
63
  * held in Module._pathCache) and gone at READ, so the CJS
63
64
  * loader's source read (defaultLoadImpl/loadSource, or the older
64
65
  * Module._extensions..js handler) raises ENOENT with syscall
65
- * 'open' and a cjs/loader frame but NO esm/load frame. `err.path`
66
+ * 'open' and a cjs/loader frame but NO ESM source-read frame. `err.path`
66
67
  * is the vanished file.
67
68
  * - `resolve-enoent`: ESM/CJS RESOLVE-time ENOENT (HQ-CLI-1S) — a path component
68
69
  * stat'ed OK, then vanished before realpathSync's lstat, so the
@@ -179,6 +180,13 @@ export interface InstallTreeFs {
179
180
  existsSync(p: string): boolean;
180
181
  statSync(p: string): {
181
182
  isDirectory(): boolean;
183
+ isSymbolicLink?(): boolean;
184
+ size?: number;
185
+ mtimeMs?: number;
186
+ };
187
+ lstatSync?(p: string): {
188
+ isDirectory(): boolean;
189
+ isSymbolicLink?(): boolean;
182
190
  size?: number;
183
191
  mtimeMs?: number;
184
192
  };
@@ -264,6 +272,8 @@ export interface WaitForInstallTreeSettledResult {
264
272
  sawTreeChange?: boolean;
265
273
  /** True when the lock-free tree stayed unchanged for `staleProbeMs`. */
266
274
  staleTree?: boolean;
275
+ /** Presence on the final poll; null means the sibling directory was unreadable. */
276
+ retiredSiblingPresent?: boolean | null;
267
277
  }
268
278
  export declare const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1500;
269
279
  /**
@@ -278,10 +288,11 @@ export declare const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1500;
278
288
  * app, the box's update timer, another `hq`) cannot make a legitimately-in-progress
279
289
  * reinstall look stale:
280
290
  * - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
281
- * by `deadlineMs`. If the target is still missing, no lock or retired sibling
282
- * is present, and the observed tree stays unchanged for `staleProbeMs`, the
283
- * wait returns `staleTree` early. Any observed tree change keeps the ordinary
284
- * settle deadline in force. `deadlineMs === 0` evaluates readiness once.
291
+ * by `deadlineMs`. If the target is still missing and the observed tree,
292
+ * including retired sibling names and mtimes, stays unchanged for
293
+ * `staleProbeMs`, the wait returns `staleTree` early. Any observed tree
294
+ * change keeps the ordinary settle deadline in force. `deadlineMs === 0`
295
+ * evaluates readiness once.
285
296
  * - LOCKED time — wall time observed under a fresh foreign lock held by a live
286
297
  * pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
287
298
  * 10-minute staleness window) and does NOT consume the settle budget. When
@@ -342,6 +353,8 @@ export type InstallTreeTornDiagnostics = {
342
353
  /** The bounded `tool` of the last fresh foreign lock seen, or "". */
343
354
  lockTool: string;
344
355
  node: string;
356
+ /** Presence on the final poll when the scope directory could be observed. */
357
+ retiredSiblingPresent?: boolean;
345
358
  /** The thrown error's `name` for the eval-throw dialect (HQ-CLI-1T), else "". */
346
359
  errorName: string;
347
360
  };
@@ -363,6 +376,8 @@ export interface InstallTreeTornInit {
363
376
  lockedMs?: number;
364
377
  /** The last fresh foreign lock's `tool`, bounded on construction (default ""). */
365
378
  lockTool?: string;
379
+ /** Final retired sibling presence, or null when the scope directory was unreadable. */
380
+ retiredSiblingPresent?: boolean | null;
366
381
  }
367
382
  /**
368
383
  * A torn-install failure that stays VISIBLE in Sentry with bounded, hq-derived
@@ -48,15 +48,33 @@ const ESM_PACKAGE_RE = /^Cannot find package '([^']+)' imported from (.+)$/s;
48
48
  const CJS_MODULE_RE = /^Cannot find module '([^']+)'/s;
49
49
  const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)$/s;
50
50
  /**
51
- * The Node ESM loader's SOURCE-READ frame — `getSourceSync`/`defaultLoad` in
52
- * `node:internal/modules/esm/load`. Requiring the `esm/load` module frame (not
53
- * merely any frame under `modules/esm/`) is what proves the loader was READING
54
- * the module's source, so an ordinary `fs.openSync`/`readFileSync` ENOENT that
55
- * merely ESCAPES a module's EVALUATION — which runs under `esm/module_job`,
56
- * never `esm/load` — is NOT misclassified as a torn install and does not trigger
57
- * the settle wait. `load\b` also excludes the sibling `esm/loader` module.
51
+ * The Node ESM loader's SOURCE-READ helper in `node:internal/modules/esm/load`
52
+ * (Node 22) or `node:internal/modules/esm/loader` (Node 24's sync loader).
53
+ * `getSourceSync` must be the first non-`node:fs` caller after the source read;
54
+ * an ordinary fs ENOENT that escapes module evaluation therefore stays outside
55
+ * recovery even when an outer ModuleLoader frame exists.
58
56
  */
59
- const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]load\b/;
57
+ const ESM_SOURCE_READ_FRAME = /^at getSourceSync \(node:internal[\\/]modules[\\/]esm[\\/](?:load|loader)\b/;
58
+ const NODE_FS_FRAME = /^at .* \(node:fs\b/;
59
+ /** True only when Node's ESM source-read helper called the failing fs read. */
60
+ function hasEsmLoaderSourceReadFrame(stack) {
61
+ const frames = stack
62
+ .split("\n")
63
+ .map((line) => line.trim())
64
+ .filter((line) => line.startsWith("at "));
65
+ let sawFsRead = false;
66
+ for (const frame of frames) {
67
+ if (!sawFsRead) {
68
+ if (NODE_FS_READ_FRAME.test(frame))
69
+ sawFsRead = true;
70
+ continue;
71
+ }
72
+ if (NODE_FS_FRAME.test(frame))
73
+ continue;
74
+ return ESM_SOURCE_READ_FRAME.test(frame);
75
+ }
76
+ return false;
77
+ }
60
78
  /**
61
79
  * The Node module RESOLVER frame — `finalizeResolution` in
62
80
  * `node:internal/modules/esm/resolve` (the observed HQ-CLI-1S route, where
@@ -85,12 +103,12 @@ function hasModuleEvalFrame(stack) {
85
103
  }
86
104
  /**
87
105
  * The Node CJS loader's SOURCE-READ position (HQ-CLI-20) — the CJS twin of
88
- * {@link ESM_LOADER_FRAME}. A CJS require whose target was present at RESOLVE (its
106
+ * {@link hasEsmLoaderSourceReadFrame}. A CJS require whose target was present at RESOLVE (its
89
107
  * filename cached in Module._pathCache) and gone at READ fails inside the loader's
90
108
  * source read: `readFileSync (node:fs)` called by `defaultLoadImpl`/`loadSource`
91
109
  * (Node ≥22) or, on older Node, the `Module._extensions..js` extension handler —
92
110
  * all frames in `node:internal/modules/cjs/loader`, and (unlike HQ-CLI-1M) with NO
93
- * `esm/load` frame.
111
+ * ESM source-read frame.
94
112
  *
95
113
  * The discriminator is POSITIONAL: the innermost `node:fs` read frame's
96
114
  * IMMEDIATELY-following (caller) frame must be a cjs/loader frame that is NOT a
@@ -251,7 +269,7 @@ export function classifyModuleNotFound(err) {
251
269
  if (record.syscall === "open" &&
252
270
  typeof record.path === "string" &&
253
271
  typeof record.stack === "string" &&
254
- ESM_LOADER_FRAME.test(record.stack)) {
272
+ hasEsmLoaderSourceReadFrame(record.stack)) {
255
273
  return {
256
274
  code,
257
275
  dialect: "esm-enoent",
@@ -263,7 +281,7 @@ export function classifyModuleNotFound(err) {
263
281
  // cjs-loader source-read ENOENT (HQ-CLI-20): the CJS twin of esm-enoent — a
264
282
  // require() whose target was present at RESOLVE (its filename held in
265
283
  // Module._pathCache) and gone at READ, so the CJS loader's source read raises
266
- // ENOENT with syscall 'open' and a cjs/loader frame but NO esm/load frame
284
+ // ENOENT with syscall 'open' and a cjs/loader frame but NO ESM source-read frame
267
285
  // (which is why arm (0) missed it and classifyModuleNotFound returned null,
268
286
  // filing the bare HQ-CLI-20 capture). Gated on the FULL conjunction — code
269
287
  // ENOENT AND syscall 'open' AND a string path AND the POSITIONAL CJS
@@ -449,6 +467,7 @@ export function classifyEvalThrow(err, packageRoot) {
449
467
  const nodeInstallTreeFs = {
450
468
  existsSync: (p) => nodeFs.existsSync(p),
451
469
  statSync: (p) => nodeFs.statSync(p),
470
+ lstatSync: (p) => nodeFs.lstatSync(p),
452
471
  readFileSync: (p, encoding) => nodeFs.readFileSync(p, encoding),
453
472
  readdirSync: (p) => nodeFs.readdirSync(p),
454
473
  };
@@ -633,6 +652,56 @@ function retiredSiblingPresent(packageRoot, fs) {
633
652
  const entries = fs.readdirSync(parent); // caller treats a throw as "not ready"
634
653
  return entries.some((entry) => entry.startsWith(stagingPrefix));
635
654
  }
655
+ /** Snapshot retired sibling trees so changes below their root stay observable. */
656
+ function retiredSiblingActivitySignature(packageRoot, fs) {
657
+ const { leaf } = cliNameParts();
658
+ const stagingPrefix = `.${leaf}-`;
659
+ const parent = path.dirname(packageRoot);
660
+ const siblings = fs.readdirSync(parent).filter((entry) => entry.startsWith(stagingPrefix)).sort();
661
+ const snapshots = [["names", siblings]];
662
+ let observed = true;
663
+ let visited = 0;
664
+ const maxEntries = 20_000;
665
+ const maxDepth = 64;
666
+ const visit = (absolutePath, relativePath, depth) => {
667
+ visited += 1;
668
+ if (visited > maxEntries) {
669
+ observed = false;
670
+ return;
671
+ }
672
+ try {
673
+ const stat = fs.lstatSync ? fs.lstatSync(absolutePath) : fs.statSync(absolutePath);
674
+ const symbolicLink = stat.isSymbolicLink?.() ?? false;
675
+ const directory = !symbolicLink && stat.isDirectory();
676
+ const mtimeMs = stat.mtimeMs;
677
+ if (typeof mtimeMs !== "number" || !Number.isFinite(mtimeMs))
678
+ observed = false;
679
+ snapshots.push([
680
+ relativePath,
681
+ symbolicLink ? "symlink" : directory ? "directory" : "file",
682
+ stat.size ?? null,
683
+ typeof mtimeMs === "number" && Number.isFinite(mtimeMs) ? mtimeMs : null,
684
+ ]);
685
+ if (!directory)
686
+ return;
687
+ if (depth >= maxDepth) {
688
+ observed = false;
689
+ return;
690
+ }
691
+ for (const child of fs.readdirSync(absolutePath).sort()) {
692
+ visit(path.join(absolutePath, child), path.join(relativePath, child), depth + 1);
693
+ }
694
+ }
695
+ catch {
696
+ observed = false;
697
+ snapshots.push([relativePath, "unreadable"]);
698
+ }
699
+ };
700
+ for (const sibling of siblings) {
701
+ visit(path.join(parent, sibling), sibling, 0);
702
+ }
703
+ return { value: JSON.stringify(snapshots), observed };
704
+ }
636
705
  /** Whether packageRoot's own manifest is healthy (`name === CLI_NAME`). */
637
706
  function packageRootHealthy(packageRoot, fs) {
638
707
  const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"));
@@ -754,6 +823,18 @@ function installTreeActivitySignature(target, packageRoot, fs) {
754
823
  snapshots.push([directory, `entries-unreadable:${errorCode(err)}`]);
755
824
  }
756
825
  }
826
+ if (packageRoot) {
827
+ try {
828
+ const retired = retiredSiblingActivitySignature(packageRoot, fs);
829
+ snapshots.push(["retired-siblings", retired.value]);
830
+ if (!retired.observed)
831
+ observed = false;
832
+ }
833
+ catch (err) {
834
+ observed = false;
835
+ snapshots.push(["retired-siblings-unreadable", errorCode(err)]);
836
+ }
837
+ }
757
838
  return { value: JSON.stringify(snapshots), observed };
758
839
  }
759
840
  export const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1_500;
@@ -769,10 +850,11 @@ export const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1_500;
769
850
  * app, the box's update timer, another `hq`) cannot make a legitimately-in-progress
770
851
  * reinstall look stale:
771
852
  * - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
772
- * by `deadlineMs`. If the target is still missing, no lock or retired sibling
773
- * is present, and the observed tree stays unchanged for `staleProbeMs`, the
774
- * wait returns `staleTree` early. Any observed tree change keeps the ordinary
775
- * settle deadline in force. `deadlineMs === 0` evaluates readiness once.
853
+ * by `deadlineMs`. If the target is still missing and the observed tree,
854
+ * including retired sibling names and mtimes, stays unchanged for
855
+ * `staleProbeMs`, the wait returns `staleTree` early. Any observed tree
856
+ * change keeps the ordinary settle deadline in force. `deadlineMs === 0`
857
+ * evaluates readiness once.
776
858
  * - LOCKED time — wall time observed under a fresh foreign lock held by a live
777
859
  * pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
778
860
  * 10-minute staleness window) and does NOT consume the settle budget. When
@@ -839,7 +921,7 @@ export async function waitForInstallTreeSettled(args) {
839
921
  }
840
922
  lastActivitySnapshot = activitySnapshot;
841
923
  }
842
- let retiredPresent = false;
924
+ let retiredPresent = null;
843
925
  let retiredCheckFailed = false;
844
926
  if (args.packageRoot) {
845
927
  try {
@@ -848,6 +930,7 @@ export async function waitForInstallTreeSettled(args) {
848
930
  sawRetired = true;
849
931
  }
850
932
  catch {
933
+ retiredPresent = null;
851
934
  retiredCheckFailed = true;
852
935
  }
853
936
  }
@@ -883,14 +966,13 @@ export async function waitForInstallTreeSettled(args) {
883
966
  lockTool,
884
967
  sawTreeChange,
885
968
  staleTree: false,
969
+ retiredSiblingPresent: retiredPresent,
886
970
  };
887
971
  }
888
972
  if (!ready &&
889
973
  !foreignLockHeld &&
890
- !retiredPresent &&
891
974
  !retiredCheckFailed &&
892
975
  !sawLock &&
893
- !sawRetired &&
894
976
  !sawTreeChange &&
895
977
  staleObservationStartedAt !== null &&
896
978
  t - staleObservationStartedAt >= staleProbeMs) {
@@ -904,6 +986,7 @@ export async function waitForInstallTreeSettled(args) {
904
986
  lockTool,
905
987
  sawTreeChange,
906
988
  staleTree: true,
989
+ retiredSiblingPresent: retiredPresent,
907
990
  };
908
991
  }
909
992
  // Give up when the TREE has had a full unlocked budget with no writer visible,
@@ -925,6 +1008,7 @@ export async function waitForInstallTreeSettled(args) {
925
1008
  lockTool,
926
1009
  sawTreeChange,
927
1010
  staleTree: false,
1011
+ retiredSiblingPresent: retiredPresent,
928
1012
  };
929
1013
  }
930
1014
  // The first poll at which the wait has reached `deadlineMs` while a lock is
@@ -1007,7 +1091,7 @@ export class InstallTreeTornError extends Error {
1007
1091
  this.cause = init.cause;
1008
1092
  const sawTreeChange = init.sawTreeChange ?? false;
1009
1093
  const treeState = init.treeState ?? (init.outcome === "stale_tree" ? "stale" : "in_flight");
1010
- this.diagnostics = {
1094
+ const diagnostics = {
1011
1095
  dialect: init.classified.dialect,
1012
1096
  code: init.classified.code,
1013
1097
  specifier: boundedDiagnosticValue(init.classified.specifier, SPECIFIER_BYTES),
@@ -1026,6 +1110,10 @@ export class InstallTreeTornError extends Error {
1026
1110
  node: process.version,
1027
1111
  errorName: boundedDiagnosticValue(init.classified.errorName ?? "", ERRORNAME_BYTES),
1028
1112
  };
1113
+ if (typeof init.retiredSiblingPresent === "boolean") {
1114
+ diagnostics.retiredSiblingPresent = init.retiredSiblingPresent;
1115
+ }
1116
+ this.diagnostics = diagnostics;
1029
1117
  Object.setPrototypeOf(this, new.target.prototype);
1030
1118
  }
1031
1119
  }
@@ -240,6 +240,15 @@ export function installTreeTornSentryTagsFor(err) {
240
240
  tree_state: installTreeStateTag(diagnostics.treeState),
241
241
  os: installTreeOsTag(process.platform),
242
242
  };
243
+ if (diagnostics.code === "ERR_MODULE_NOT_FOUND" || diagnostics.code === "MODULE_NOT_FOUND") {
244
+ tags.error_shape = "module_not_found";
245
+ }
246
+ else if (diagnostics.code === "ENOENT") {
247
+ tags.error_shape = "loader_enoent";
248
+ }
249
+ if (typeof diagnostics.retiredSiblingPresent === "boolean") {
250
+ tags.retired_sibling = diagnostics.retiredSiblingPresent ? "present" : "absent";
251
+ }
243
252
  if (recoveryOutcome)
244
253
  tags.recovery_outcome = recoveryOutcome;
245
254
  const lockTool = diagnostics.lockTool;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.205.0",
3
+ "version": "5.206.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {