@indigoai-us/hq-cli 5.109.10 → 5.109.12

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.
@@ -34,11 +34,15 @@ import * as path from "node:path";
34
34
  import { fileURLToPath } from "node:url";
35
35
  import { CLI_NAME } from "../cli-version.js";
36
36
  import { boundedDiagnosticValue } from "./package-root-diagnostics.js";
37
- import { isLockStale } from "./update-lock.js";
37
+ import { isLockStale, UPDATE_LOCK_STALE_MS } from "./update-lock.js";
38
38
  /** Byte caps for the (hq-derived, path-shaped) diagnostic strings. */
39
39
  const SPECIFIER_BYTES = 256;
40
40
  const IMPORTER_BYTES = 256;
41
41
  const PACKAGE_ROOT_BYTES = 256;
42
+ /** Byte cap for the lock holder's `tool` (the only foreign string this reads). */
43
+ const LOCK_TOOL_BYTES = 64;
44
+ /** The eval-throw error name is a short closed-set token; the cap is only a belt. */
45
+ const ERRORNAME_BYTES = 64;
42
46
  const ESM_PACKAGE_RE = /^Cannot find package '([^']+)' imported from (.+)$/s;
43
47
  const CJS_MODULE_RE = /^Cannot find module '([^']+)'/s;
44
48
  const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)$/s;
@@ -52,6 +56,96 @@ const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)
52
56
  * the settle wait. `load\b` also excludes the sibling `esm/loader` module.
53
57
  */
54
58
  const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]load\b/;
59
+ /**
60
+ * The Node module RESOLVER frame — `finalizeResolution` in
61
+ * `node:internal/modules/esm/resolve` (the observed HQ-CLI-1S route, where
62
+ * realpathSync lstat's a component that vanished after the earlier stat), or the
63
+ * CJS `Module._findPath → toRealPath` route in `node:internal/modules/cjs/loader`
64
+ * (the same stat-then-lstat TOCTOU, accepted for symmetry). Requiring a resolver
65
+ * frame — alongside `syscall === 'lstat'` — is what proves the ENOENT came from
66
+ * the resolver's realpath step, so an ordinary `fs.lstatSync` ENOENT written by
67
+ * hq's own code (no resolver frame) is NOT misclassified as a torn install.
68
+ */
69
+ const RESOLVER_FRAME = /node:internal[\\/]modules[\\/](?:esm[\\/]resolve|cjs[\\/]loader)\b/;
70
+ /** The closed set of error `name`s the eval-throw dialect accepts (HQ-CLI-1T). */
71
+ const EVAL_THROW_NAMES = new Set(["TypeError", "ReferenceError", "SyntaxError"]);
72
+ /**
73
+ * A CJS module-EVALUATION frame — `Module._compile` / `wrapSafe` in
74
+ * `node:internal/modules/cjs/loader`, where a required module's top-level code
75
+ * runs (and where a SyntaxError is raised at compile). NOT the resolve frame
76
+ * `Module._resolveFilename`, so a resolution failure is never read as an eval.
77
+ */
78
+ const CJS_EVAL_FRAME = /\bat (?:Module\._compile|wrapSafe) \(node:internal[\\/]modules[\\/]cjs[\\/]loader/;
79
+ /** An ESM module-EVALUATION frame — `ModuleJob` run/evaluate in esm/module_job. */
80
+ const ESM_EVAL_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]module_job\b/;
81
+ /** Whether `stack` carries a CJS or ESM module-EVALUATION loader frame. */
82
+ function hasModuleEvalFrame(stack) {
83
+ return CJS_EVAL_FRAME.test(stack) || ESM_EVAL_FRAME.test(stack);
84
+ }
85
+ /**
86
+ * A stack LOCATION token — the absolute filesystem path in either an
87
+ * `at <fn> (<abs>:L:C)` frame or a bare `<abs>:L` header line (the source
88
+ * location a SyntaxError prepends). Anchored at line-start or an opening paren so
89
+ * a `node:internal/…` internal frame (never an absolute FS path) is skipped, and
90
+ * a Windows drive/UNC path is matched by shape so it classifies on any host.
91
+ */
92
+ const STACK_LOCATION = /(?:^|\()((?:\/|[A-Za-z]:[\\/]|\\\\)[^():\n]*?):\d+(?::\d+)?(?:\)|$)/;
93
+ /**
94
+ * A `file:` URL location in a stack frame — the form Node uses for an ESM module
95
+ * EVALUATION frame (`at file:///abs/mod.mjs:2:1`, or the same wrapped in parens).
96
+ * The whole non-space token is captured; the caller strips the trailing paren and
97
+ * `:line[:col]` and decodes it with fileURLToPath.
98
+ */
99
+ function fileUrlFromFrame(line) {
100
+ const token = line.match(/file:\/\/\/\S+/);
101
+ if (!token)
102
+ return null;
103
+ const url = token[0].replace(/\)+$/, "").replace(/:\d+(?::\d+)?$/, "");
104
+ try {
105
+ return fileURLToPath(url);
106
+ }
107
+ catch {
108
+ return null; // not a decodable file URL
109
+ }
110
+ }
111
+ /**
112
+ * The eval-throw THROW SITE — the FIRST absolute filesystem path token in the
113
+ * stack, scanning top-down: for an ordinary error that is the throwing frame (a
114
+ * raw path for a CJS module, or a `file:` URL for an ESM one); for a SyntaxError
115
+ * it is the `<abs>:L` header line the stack carries before the `Error:` line.
116
+ * Returns null when no absolute path token is present.
117
+ */
118
+ function evalThrowSite(stack) {
119
+ for (const rawLine of stack.split("\n")) {
120
+ const line = rawLine.trim();
121
+ const plain = STACK_LOCATION.exec(line);
122
+ if (plain)
123
+ return plain[1];
124
+ const fromUrl = fileUrlFromFrame(line);
125
+ if (fromUrl)
126
+ return fromUrl;
127
+ }
128
+ return null;
129
+ }
130
+ /**
131
+ * Lexical `<root>/node_modules/` containment for the eval-throw throw-site gate.
132
+ * Separator- and (Windows-shape-only) case-folded and anchored at a true
133
+ * directory boundary, so a sibling such as `<root>-old/node_modules/…` never
134
+ * matches. Mirrors incomplete-install-error.ts's isUnderNodeModules discipline;
135
+ * kept local because that module imports THIS one (a shared import would cycle).
136
+ */
137
+ function looksWin32(p) {
138
+ return /^[a-zA-Z]:[\\/]/.test(p) || /^\\\\/.test(p);
139
+ }
140
+ function foldForCompare(p) {
141
+ const folded = p.replace(/[\\/]+/g, "/").replace(/\/+$/, "");
142
+ return looksWin32(p) ? folded.toLowerCase() : folded;
143
+ }
144
+ function isUnderNodeModules(candidate, root) {
145
+ if (!candidate || !root)
146
+ return false;
147
+ return foldForCompare(candidate).startsWith(`${foldForCompare(root)}/node_modules/`);
148
+ }
55
149
  /**
56
150
  * A relative module specifier — `./x`, `../x`, `.\x`, `..\x`, or a bare `.`/`..`.
57
151
  * Mirrors the shape src/utils/incomplete-install-error.ts uses, extended with the
@@ -120,6 +214,28 @@ export function classifyModuleNotFound(err) {
120
214
  target: { kind: "path", path: record.path },
121
215
  };
122
216
  }
217
+ // resolve-time ENOENT (HQ-CLI-1S): Node's resolver stat'ed a path OK, then
218
+ // realpathSync lstat'ed a component that a concurrent reinstall renamed away
219
+ // between the two, throwing ENOENT with syscall `lstat` from a RESOLVER frame
220
+ // (finalizeResolution / Module._findPath→toRealPath). Gated on the FULL
221
+ // conjunction so an ordinary fs.lstatSync ENOENT from hq's own code (no
222
+ // resolver frame) and a rename/unlink ENOENT (syscall not `lstat`) both stay
223
+ // null. The vanished component may be a package/scope DIRECTORY whose bare
224
+ // existence precedes its `package.json`, so the target is `exists` (presence
225
+ // only); readiness still rides the lock / manifest-health / retired-sibling /
226
+ // quiet guards. Structurally a loader race like esm-enoent — no writer gate.
227
+ if (record.syscall === "lstat" &&
228
+ typeof record.path === "string" &&
229
+ typeof record.stack === "string" &&
230
+ RESOLVER_FRAME.test(record.stack)) {
231
+ return {
232
+ code,
233
+ dialect: "resolve-enoent",
234
+ specifier: record.path,
235
+ importer: "",
236
+ target: { kind: "exists", path: record.path },
237
+ };
238
+ }
123
239
  return null;
124
240
  }
125
241
  if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND")
@@ -204,6 +320,61 @@ export function classifyModuleNotFound(err) {
204
320
  // the lock / retired-dir / quiet signals only.
205
321
  return { code, dialect: "unknown", specifier: "", importer: "", target: { kind: "unknown" } };
206
322
  }
323
+ /**
324
+ * Detect a module-EVALUATION throw STRUCTURALLY, WITHOUT the node_modules
325
+ * boundary gate: an error with NO `code`, a `name` in the closed set
326
+ * {TypeError, ReferenceError, SyntaxError}, a CJS/ESM module-evaluation loader
327
+ * frame, and a resolvable throw-site path. This is the shape a half-written
328
+ * dependency file produces — require() read an empty module and returned `{}`, so
329
+ * calling one of its exports throws a code-less TypeError while the module's own
330
+ * top-level code runs. No message text is ever parsed. The caller applies its own
331
+ * packageRoot gate ({@link classifyEvalThrow} for recovery, the boundary for
332
+ * capture context), so this stays pure and reusable. Returns null for anything
333
+ * that is not this shape — a coded error (ERR_REQUIRE_ESM etc.), an Error /
334
+ * RangeError name, a throw with no loader frame, or no absolute throw site.
335
+ */
336
+ export function evalThrowShape(err) {
337
+ if (err === null || typeof err !== "object")
338
+ return null;
339
+ const record = err;
340
+ if (record.code !== undefined)
341
+ return null;
342
+ const name = typeof record.name === "string" ? record.name : "";
343
+ if (!EVAL_THROW_NAMES.has(name))
344
+ return null;
345
+ if (typeof record.stack !== "string" || !hasModuleEvalFrame(record.stack))
346
+ return null;
347
+ const throwSite = evalThrowSite(record.stack);
348
+ if (throwSite === null)
349
+ return null;
350
+ return { errorName: name, throwSite };
351
+ }
352
+ /**
353
+ * Classify a module-EVALUATION throw as a torn-install recovery candidate
354
+ * (HQ-CLI-1T) — the {@link evalThrowShape} fingerprint AND a throw site under
355
+ * `<packageRoot>/node_modules/`. A throw under the install's own `dist/` or
356
+ * `assets/`, outside the root, or with an unresolved root is NOT classified, so a
357
+ * genuine hq-cli code defect keeps its raw capture. Unlike the loader-race
358
+ * dialects the recovery seam gates this on a live writer signal before waiting.
359
+ * `packageRoot` comes from the seam's resolvePackageRoot (running-install → derived
360
+ * fallback). The target is `unknown` — an eval-throw carries no re-resolvable
361
+ * missing file, so recovery keys entirely on the writer signal + quiet window.
362
+ */
363
+ export function classifyEvalThrow(err, packageRoot) {
364
+ const shape = evalThrowShape(err);
365
+ if (shape === null)
366
+ return null;
367
+ if (!packageRoot || !isUnderNodeModules(shape.throwSite, packageRoot))
368
+ return null;
369
+ return {
370
+ code: "EVAL_THROW",
371
+ dialect: "eval-throw",
372
+ specifier: shape.throwSite,
373
+ importer: "",
374
+ target: { kind: "unknown" },
375
+ errorName: shape.errorName,
376
+ };
377
+ }
207
378
  const nodeInstallTreeFs = {
208
379
  existsSync: (p) => nodeFs.existsSync(p),
209
380
  statSync: (p) => nodeFs.statSync(p),
@@ -287,6 +458,8 @@ function relativeTargetResolvable(base, fs) {
287
458
  * or a directory whose package.json `main` (or that main's index)
288
459
  * or own `index.*` is a real file. A bare or partially-extracted
289
460
  * directory is NOT loadable and reads as not-present.
461
+ * - `exists`: the path exists at all (any file type) — presence only, because
462
+ * the vanished component may be a package/scope directory.
290
463
  * - `unknown`: true (readiness turns on the other signals).
291
464
  * Any filesystem error reads as "not present" rather than throwing.
292
465
  */
@@ -294,6 +467,12 @@ export function installTargetPresent(target, fs = nodeInstallTreeFs) {
294
467
  try {
295
468
  if (target.kind === "unknown")
296
469
  return true;
470
+ // `exists` (resolve-enoent, HQ-CLI-1S): the lstat'ed component may be a
471
+ // package/scope DIRECTORY whose bare existence precedes its `package.json`, so
472
+ // presence alone is the signal here — the surrounding manifest-health /
473
+ // retired-sibling / quiet guards keep a premature re-exec bounded.
474
+ if (target.kind === "exists")
475
+ return fs.existsSync(target.path);
297
476
  if (target.kind === "path") {
298
477
  if (!fs.existsSync(target.path))
299
478
  return false;
@@ -332,6 +511,15 @@ export function installTargetPresent(target, fs = nodeInstallTreeFs) {
332
511
  return false;
333
512
  }
334
513
  }
514
+ /**
515
+ * Default ceiling on the CUMULATIVE time the wait may spend observing a FRESH
516
+ * foreign lock held by a live pid. Equal to the shared update-lock contract's own
517
+ * staleness window ({@link UPDATE_LOCK_STALE_MS} — "no healthy npm install -g of
518
+ * this package runs 10 minutes"), so the wait trusts a live cooperating writer for
519
+ * exactly as long as the contract legitimises holding the lock, and never longer.
520
+ * Overridable by the seam (env HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS).
521
+ */
522
+ export const DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS = UPDATE_LOCK_STALE_MS;
335
523
  /** The scope/leaf split of CLI_NAME, e.g. `@indigoai-us` / `hq-cli`. */
336
524
  function cliNameParts() {
337
525
  const slash = CLI_NAME.indexOf("/");
@@ -339,26 +527,32 @@ function cliNameParts() {
339
527
  return { scope: null, leaf: CLI_NAME };
340
528
  return { scope: CLI_NAME.slice(0, slash), leaf: CLI_NAME.slice(slash + 1) };
341
529
  }
342
- /** Whether a FRESH update lock is held by a DIFFERENT live pid (blocks readiness). */
343
- function freshForeignLockHeld(lockPath, nowMs, isPidAlive, fs) {
530
+ /**
531
+ * The FRESH update lock held by a DIFFERENT live pid, or null. A non-null result
532
+ * blocks readiness AND extends the wait under the lock ceiling; the lock file is
533
+ * only ever READ. Freshness is decided by {@link isLockStale} exactly as before
534
+ * (parseable, `startedAt` younger than {@link UPDATE_LOCK_STALE_MS}, pid alive),
535
+ * and our own pid never counts.
536
+ */
537
+ function freshForeignLock(lockPath, nowMs, isPidAlive, fs) {
344
538
  let raw;
345
539
  try {
346
540
  raw = fs.readFileSync(lockPath, "utf-8");
347
541
  }
348
542
  catch {
349
- return false; // no lock (ENOENT) or unreadable — not a fresh foreign holder
543
+ return null; // no lock (ENOENT) or unreadable — not a fresh foreign holder
350
544
  }
351
545
  if (isLockStale(raw, nowMs, isPidAlive))
352
- return false;
546
+ return null;
353
547
  try {
354
548
  const info = JSON.parse(raw);
355
549
  if (info.pid === process.pid)
356
- return false; // our own lock never blocks us
550
+ return null; // our own lock never blocks us
551
+ return info;
357
552
  }
358
553
  catch {
359
- return false; // isLockStale already treats unparseable as stale, unreachable
554
+ return null; // isLockStale already treats unparseable as stale, unreachable
360
555
  }
361
- return true;
362
556
  }
363
557
  /** Whether an npm retired/staging sibling (`.<leaf>-*`) sits beside packageRoot. */
364
558
  function retiredSiblingPresent(packageRoot, fs) {
@@ -375,13 +569,33 @@ function packageRootHealthy(packageRoot, fs) {
375
569
  }
376
570
  /**
377
571
  * Poll until the install tree has been continuously READY for `quietMs`, or the
378
- * `deadlineMs` passes. READY means: (i) no FRESH update lock held by another pid,
379
- * (ii) the missing target is present, and — when `packageRoot` is known —
380
- * (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and
381
- * (iv) no `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem
382
- * error makes its condition "not ready" rather than throwing. `deadlineMs === 0`
383
- * evaluates readiness exactly once (no waiting); otherwise the whole wait is
384
- * bounded, so the caller can never hang.
572
+ * wait exhausts its bounds. READY means: (i) no FRESH update lock held by another
573
+ * pid, (ii) the missing target is present, and — when `packageRoot` is known —
574
+ * (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and (iv) no
575
+ * `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem error makes
576
+ * its condition "not ready" rather than throwing.
577
+ *
578
+ * The wait is LOCK-AWARE and bounded on TWO independent axes, so a live
579
+ * cooperating writer (the desktop app, the box's update timer, another `hq`)
580
+ * cannot make a legitimately-in-progress reinstall look like a torn install:
581
+ * - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
582
+ * by `deadlineMs`. That is the budget for the TREE to settle once no writer is
583
+ * visible; with no lock ever seen, unlocked time equals total wait time and
584
+ * the behaviour is byte-for-byte the pre-lock-aware one (unsettled at exactly
585
+ * `deadlineMs`, and `deadlineMs === 0` evaluates readiness exactly once).
586
+ * - LOCKED time — wall time observed under a fresh foreign lock held by a live
587
+ * pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
588
+ * 10-minute staleness window) and does NOT consume the settle budget. When
589
+ * that ceiling is reached with the lock still held, the result is unsettled
590
+ * with `lockHeldAtEnd` true so the seam can report a distinct `lock-held`
591
+ * outcome and remedy rather than telling the user to reinstall over a live
592
+ * writer.
593
+ * Each poll's REAL elapsed interval (now() deltas) is attributed to the locked or
594
+ * unlocked bucket by whether a fresh foreign lock was held when the interval
595
+ * began, so a starved event loop only makes the wait coarser, never unbounded. A
596
+ * stale lock (dead pid, older than the staleness window, unparseable) or our own
597
+ * pid never extends anything. The whole wait is bounded (≤ `deadlineMs +
598
+ * lockWaitMs`), so the caller can never hang.
385
599
  */
386
600
  export async function waitForInstallTreeSettled(args) {
387
601
  const now = args.now ?? Date.now;
@@ -391,16 +605,32 @@ export async function waitForInstallTreeSettled(args) {
391
605
  const pollMs = args.pollMs ?? 250;
392
606
  const quietMs = args.quietMs ?? 1500;
393
607
  const deadlineMs = args.deadlineMs;
608
+ const lockWaitMs = args.lockWaitMs ?? DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS;
394
609
  const start = now();
395
610
  let readySince = null;
396
611
  let sawLock = false;
397
612
  let sawRetired = false;
613
+ let lockedMs = 0;
614
+ let unlockedMs = 0;
615
+ let lockTool = "";
616
+ let prevT = start;
617
+ let prevForeignLockHeld = false;
618
+ let lockExtendedFired = false;
398
619
  for (;;) {
399
620
  const t = now();
400
- const foreignLock = freshForeignLockHeld(args.lockPath, t, isPidAlive, fs);
401
- if (foreignLock)
621
+ // Attribute the interval that just elapsed [prevT, t) to the bucket for the
622
+ // lock state that held when it BEGAN (real now() deltas, never nominal pollMs).
623
+ if (prevForeignLockHeld)
624
+ lockedMs += t - prevT;
625
+ else
626
+ unlockedMs += t - prevT;
627
+ const lock = freshForeignLock(args.lockPath, t, isPidAlive, fs);
628
+ const foreignLockHeld = lock !== null;
629
+ if (foreignLockHeld) {
402
630
  sawLock = true;
403
- let ready = !foreignLock && installTargetPresent(args.target, fs);
631
+ lockTool = boundedDiagnosticValue(lock.tool, LOCK_TOOL_BYTES);
632
+ }
633
+ let ready = !foreignLockHeld && installTargetPresent(args.target, fs);
404
634
  if (ready && args.packageRoot) {
405
635
  try {
406
636
  if (!packageRootHealthy(args.packageRoot, fs))
@@ -430,14 +660,83 @@ export async function waitForInstallTreeSettled(args) {
430
660
  }
431
661
  const waitedMs = t - start;
432
662
  if (ready && (deadlineMs === 0 || t - readySince >= quietMs)) {
433
- return { settled: true, waitedMs, sawLock, sawRetired };
663
+ // Settling requires no foreign lock, so it is never held at the end here.
664
+ return { settled: true, waitedMs, sawLock, sawRetired, lockHeldAtEnd: false, lockedMs, lockTool };
434
665
  }
435
- if (waitedMs >= deadlineMs) {
436
- return { settled: false, waitedMs, sawLock, sawRetired };
666
+ // Give up when the TREE has had a full unlocked budget with no writer visible,
667
+ // OR a writer is STILL holding the lock and has held it past its own staleness
668
+ // ceiling. The lock ceiling is gated on a lock being held RIGHT NOW so that a
669
+ // ceiling of 0 (HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS=0, "do not extend under a
670
+ // lock") disables only the extension and does not collapse the ordinary
671
+ // deadlineMs settle when no lock exists — `lockedMs` is 0 ≥ 0 on every poll.
672
+ // `deadlineMs` 0 makes the unlocked test fire on the first poll (evaluate-once),
673
+ // matching the pre-lock-aware contract; `lockHeldAtEnd` distinguishes the causes.
674
+ if (unlockedMs >= deadlineMs || (foreignLockHeld && lockedMs >= lockWaitMs)) {
675
+ return { settled: false, waitedMs, sawLock, sawRetired, lockHeldAtEnd: foreignLockHeld, lockedMs, lockTool };
437
676
  }
677
+ // The first poll at which the wait has reached `deadlineMs` while a lock is
678
+ // still held is exactly where the pre-lock-aware wait gave up — announce the
679
+ // extension once, and only when we are genuinely going to keep waiting.
680
+ if (!lockExtendedFired && foreignLockHeld && waitedMs >= deadlineMs) {
681
+ lockExtendedFired = true;
682
+ args.onLockExtended?.();
683
+ }
684
+ prevT = t;
685
+ prevForeignLockHeld = foreignLockHeld;
438
686
  await sleep(pollMs);
439
687
  }
440
688
  }
689
+ /**
690
+ * Whether packageRoot's own manifest is ABSENT (ENOENT) or name-mismatched — a
691
+ * mid-extraction rewrite signal (npm has renamed the dir aside and is re-writing
692
+ * package.json). A read error OTHER than ENOENT (EACCES/EPERM) is a benign
693
+ * permission fault, not a rewrite, so it returns false rather than a false signal.
694
+ */
695
+ function manifestRewriteSignal(packageRoot, fs) {
696
+ let raw;
697
+ try {
698
+ raw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8");
699
+ }
700
+ catch (err) {
701
+ return err?.code === "ENOENT";
702
+ }
703
+ try {
704
+ return JSON.parse(raw).name !== CLI_NAME;
705
+ }
706
+ catch {
707
+ return true; // present but unparseable → mid-write
708
+ }
709
+ }
710
+ /**
711
+ * Whether a global reinstall is PROVABLY rewriting the install tree right now —
712
+ * the positive writer signal the eval-throw gate requires before it will pay a
713
+ * settle wait. True iff (i) a fresh foreign update lock is held, (ii) packageRoot's
714
+ * manifest is absent or name-mismatched (mid-extraction), or (iii) a
715
+ * `.<leaf>-<hash>` retired sibling sits beside it. Crucially it is POSITIVE: a
716
+ * benign filesystem error that merely prevents READING the manifest or LISTING the
717
+ * parent (EACCES/EPERM) is NOT a signal, so a persistent third-party load-time
718
+ * defect on an otherwise-usable install never pays a spurious 90s wait. Every
719
+ * dependency is injectable so the recovery seam stays hermetic.
720
+ */
721
+ export function installRewriteInProgress(args) {
722
+ const now = (args.now ?? Date.now)();
723
+ const fs = args.fs ?? nodeInstallTreeFs;
724
+ const isPidAlive = args.isPidAlive ?? defaultIsPidAlive;
725
+ // #570 renamed freshForeignLockHeld → freshForeignLock, which returns the lock
726
+ // info (or null) instead of a boolean; a fresh foreign lock is held iff non-null.
727
+ if (freshForeignLock(args.lockPath, now, isPidAlive, fs) !== null)
728
+ return true;
729
+ if (!args.packageRoot)
730
+ return false;
731
+ if (manifestRewriteSignal(args.packageRoot, fs))
732
+ return true;
733
+ try {
734
+ return retiredSiblingPresent(args.packageRoot, fs);
735
+ }
736
+ catch {
737
+ return false; // parent not listable — benign, not a rewrite signal
738
+ }
739
+ }
441
740
  /** The fixed, input-free carrier message (grouping cardinality stays bounded). */
442
741
  const INSTALL_TREE_TORN_MESSAGE = "hq-cli install tree was being rewritten while this command started";
443
742
  /**
@@ -464,7 +763,11 @@ export class InstallTreeTornError extends Error {
464
763
  waitedMs: init.waitedMs,
465
764
  sawLock: init.sawLock,
466
765
  sawRetired: init.sawRetired,
766
+ lockHeldAtEnd: init.lockHeldAtEnd ?? false,
767
+ lockedMs: init.lockedMs ?? 0,
768
+ lockTool: boundedDiagnosticValue(init.lockTool ?? "", LOCK_TOOL_BYTES),
467
769
  node: process.version,
770
+ errorName: boundedDiagnosticValue(init.classified.errorName ?? "", ERRORNAME_BYTES),
468
771
  };
469
772
  Object.setPrototypeOf(this, new.target.prototype);
470
773
  }
@@ -486,15 +789,26 @@ export const INSTALL_TREE_TORN_REMEDY = "the hq-cli install was being updated by
486
789
  "timer, the desktop app, or another hq command) while this command started. " +
487
790
  "Re-run your command; if it keeps failing, reinstall with " +
488
791
  "`npm i -g @indigoai-us/hq-cli` (or `pnpm add -g @indigoai-us/hq-cli`).";
792
+ /**
793
+ * The fixed, input-free remedy for the 'lock-held' outcome: a cooperating writer
794
+ * held the shared update lock past its own staleness ceiling. Telling the user to
795
+ * `npm i -g` here would collide with a live installer mid-rename — the exact race
796
+ * update-lock.ts exists to prevent — so this remedy tells them to let the running
797
+ * update finish and NEVER to reinstall over it. Interpolates NOTHING.
798
+ */
799
+ export const INSTALL_TREE_LOCK_HELD_REMEDY = "another hq updater (the desktop app, the box's hq-cli-update timer, or another " +
800
+ "hq command) was still installing hq-cli when this command stopped waiting. Let " +
801
+ "that update finish, then re-run your command; do not reinstall while it is running.";
489
802
  /**
490
803
  * The single actionable stderr line for a captured torn-install failure: the
491
- * fixed remedy plus the bounded, hq-derived missing specifier — the only
492
- * interpolated value, never argv.
804
+ * outcome's fixed remedy plus the bounded, hq-derived missing specifier — the only
805
+ * interpolated value, never argv. A 'lock-held' outcome selects the "let the
806
+ * update finish" remedy (never the reinstall advice); every other outcome keeps
807
+ * {@link INSTALL_TREE_TORN_REMEDY} byte-for-byte.
493
808
  */
494
809
  export function installTreeTornStderrLine(err) {
810
+ const remedy = err.diagnostics.outcome === "lock-held" ? INSTALL_TREE_LOCK_HELD_REMEDY : INSTALL_TREE_TORN_REMEDY;
495
811
  const specifier = err.diagnostics.specifier;
496
- return specifier
497
- ? `${INSTALL_TREE_TORN_REMEDY} (missing: ${specifier})`
498
- : INSTALL_TREE_TORN_REMEDY;
812
+ return specifier ? `${remedy} (missing: ${specifier})` : remedy;
499
813
  }
500
814
  //# sourceMappingURL=install-tree-torn.js.map
@@ -92,8 +92,8 @@ const KNOWN_ERROR_NAMES = new Set([
92
92
  // (HQ-CLI-1A).
93
93
  "QmdWorkdirMissingError",
94
94
  // A torn-install failure the bounded settle-wait + single re-exec could not
95
- // recover (HQ-CLI-1G/1H/1J/1K). Fingerprinted on its own branch below by the
96
- // bounded `diagnostics.outcome`, so the whole family groups into ≤3 issues.
95
+ // recover (HQ-CLI-1G/1H/1J/1K, HQ-CLI-1Y). Fingerprinted on its own branch below
96
+ // by the bounded `diagnostics.outcome`, so the whole family groups into ≤4 issues.
97
97
  "InstallTreeTornError",
98
98
  ]);
99
99
  /**
@@ -102,7 +102,7 @@ const KNOWN_ERROR_NAMES = new Set([
102
102
  * the carrier's message is fixed, so this outcome IS the only discriminator.
103
103
  */
104
104
  const KNOWN_INSTALL_TREE_OUTCOMES = new Set([
105
- "guarded", "unsettled", "reexec-failed",
105
+ "guarded", "unsettled", "reexec-failed", "lock-held",
106
106
  ]);
107
107
  /** Fixed bucket for any error name outside the closed allowlist. */
108
108
  const FALLBACK_ERROR_NAME = "other";
@@ -214,8 +214,8 @@ export function sentryFingerprintFor(err) {
214
214
  }
215
215
  // A torn-install failure carries neither an rpcCode nor an HTTP status; its
216
216
  // only bounded discriminator is the recovery `diagnostics.outcome`. Keyed
217
- // BEFORE the rpc/status branches so the family groups into ≤3 predictable
218
- // issues (guarded / unsettled / reexec-failed) rather than one fungible bucket.
217
+ // BEFORE the rpc/status branches so the family groups into ≤4 predictable issues
218
+ // (guarded / unsettled / reexec-failed / lock-held) rather than one fungible bucket.
219
219
  if (rawName === "InstallTreeTornError") {
220
220
  const diagnostics = record.diagnostics;
221
221
  const outcome = diagnostics !== null && typeof diagnostics === "object"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.109.10",
3
+ "version": "5.109.12",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {