@indigoai-us/hq-cli 5.109.11 → 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.
- package/CHANGELOG.md +21 -0
- package/dist/startup-registration.d.ts +3 -1
- package/dist/startup-registration.js +25 -7
- package/dist/utils/incomplete-install-error.d.ts +37 -10
- package/dist/utils/incomplete-install-error.js +126 -46
- package/dist/utils/install-tree-torn.d.ts +85 -4
- package/dist/utils/install-tree-torn.js +229 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.109.12] — 2026-09-12
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- A background reinstall no longer turns into a crash report in two more shapes
|
|
10
|
+
it was still slipping through (HQ-CLI-1S, HQ-CLI-1T). When another program
|
|
11
|
+
reinstalls hq globally while a command is starting, it renames hq's files aside
|
|
12
|
+
and rewrites them file by file over a few seconds. Two failure shapes from that
|
|
13
|
+
window were still filed as crashes instead of being recovered: (1) a file that
|
|
14
|
+
Node checked a moment earlier vanished between its two resolve steps, surfacing
|
|
15
|
+
as a low-level `ENOENT … lstat` from inside the module resolver (HQ-CLI-1S);
|
|
16
|
+
and (2) a dependency file that was found but only half-written, so loading it
|
|
17
|
+
returned an empty module and its own code threw `TypeError: … is not a
|
|
18
|
+
function` (HQ-CLI-1T). Both are now recognized as "your install was mid-update,
|
|
19
|
+
not an hq bug": the command waits for the reinstall to settle and re-runs once
|
|
20
|
+
on the healed tree instead of reporting an error. The half-written-file case
|
|
21
|
+
only ever waits when a reinstall is actually in progress — a genuine, lasting
|
|
22
|
+
defect in a third-party dependency still reports exactly once and never pays a
|
|
23
|
+
wait, and a fault in hq's own shipped files is still reported. (The two related
|
|
24
|
+
shapes HQ-CLI-1V and HQ-CLI-1W are already covered by 5.109.8 and #561.)
|
|
25
|
+
|
|
5
26
|
## [5.109.11] — 2026-09-12
|
|
6
27
|
|
|
7
28
|
### Fixed
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* style of handleTopLevelError so the whole seam is unit-testable without real
|
|
17
17
|
* spawns, waits, or a real install tree.
|
|
18
18
|
*/
|
|
19
|
-
import { type WaitForInstallTreeSettledArgs, type WaitForInstallTreeSettledResult } from "./utils/install-tree-torn.js";
|
|
19
|
+
import { type InstallRewriteSignalArgs, type WaitForInstallTreeSettledArgs, type WaitForInstallTreeSettledResult } from "./utils/install-tree-torn.js";
|
|
20
20
|
/**
|
|
21
21
|
* Set on the re-exec'd child so it can NEVER wait or re-exec again — distinct
|
|
22
22
|
* from self-update's HQ_RESCUE_SELF_UPDATED so the two recovery paths cannot
|
|
@@ -70,6 +70,8 @@ export interface RegisterRecoveryDeps {
|
|
|
70
70
|
deriveInstallRoot?: () => string | null;
|
|
71
71
|
lockPath?: () => string;
|
|
72
72
|
waitForSettled?: (args: WaitForInstallTreeSettledArgs) => Promise<WaitForInstallTreeSettledResult>;
|
|
73
|
+
/** Positive writer-signal probe for the eval-throw gate (defaults to installRewriteInProgress). */
|
|
74
|
+
rewriteInProgress?: (args: InstallRewriteSignalArgs) => boolean;
|
|
73
75
|
/** node flags to forward to the re-exec child (defaults to process.execArgv). */
|
|
74
76
|
execArgv?: readonly string[];
|
|
75
77
|
}
|
|
@@ -20,7 +20,7 @@ import { spawnSync } from "node:child_process";
|
|
|
20
20
|
import { resolveRunningInstall } from "./utils/version-gate.js";
|
|
21
21
|
import { stringDerivedPackageRoot } from "./utils/hq-roots.js";
|
|
22
22
|
import { updateLockPath, UPDATE_LOCK_STALE_MS } from "./utils/update-lock.js";
|
|
23
|
-
import { classifyModuleNotFound, InstallTreeTornError, waitForInstallTreeSettled, } from "./utils/install-tree-torn.js";
|
|
23
|
+
import { classifyEvalThrow, classifyModuleNotFound, installRewriteInProgress, InstallTreeTornError, waitForInstallTreeSettled, } from "./utils/install-tree-torn.js";
|
|
24
24
|
/**
|
|
25
25
|
* Set on the re-exec'd child so it can NEVER wait or re-exec again — distinct
|
|
26
26
|
* from self-update's HQ_RESCUE_SELF_UPDATED so the two recovery paths cannot
|
|
@@ -87,12 +87,32 @@ export async function registerCommandsWithRecovery(args) {
|
|
|
87
87
|
return {};
|
|
88
88
|
}
|
|
89
89
|
catch (err) {
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
|
|
90
|
+
// The packageRoot bounds the eval-throw classifier's throw site and feeds the
|
|
91
|
+
// settle wait's manifest-health / retired-sibling guards; resolved once here.
|
|
92
|
+
const packageRoot = resolvePackageRoot(deps);
|
|
93
|
+
// Classify the loader failure. classifyModuleNotFound covers the resolve/load
|
|
94
|
+
// dialects (incl. HQ-CLI-1S resolve-enoent); classifyEvalThrow covers a
|
|
95
|
+
// module-EVALUATION throw (HQ-CLI-1T). Anything unclassified is rethrown as the
|
|
96
|
+
// SAME object to the existing boundary, preserving today's behaviour exactly.
|
|
97
|
+
const classified = classifyModuleNotFound(err) ?? classifyEvalThrow(err, packageRoot);
|
|
93
98
|
if (!classified)
|
|
94
99
|
throw err;
|
|
95
|
-
const
|
|
100
|
+
const lockPath = (deps.lockPath ?? updateLockPath)();
|
|
101
|
+
const waitFor = deps.waitForSettled ?? waitForInstallTreeSettled;
|
|
102
|
+
// Writer-signal gate for the eval-throw dialect ONLY. A module-evaluation
|
|
103
|
+
// throw is indistinguishable from a persistent third-party load-time defect
|
|
104
|
+
// unless a global reinstall is provably in progress, so require a POSITIVE
|
|
105
|
+
// writer signal — a fresh foreign lock, an absent/name-mismatched manifest, or
|
|
106
|
+
// a retired `.hq-cli-<hash>` sibling — before entering recovery. With no such
|
|
107
|
+
// signal the SAME error is rethrown raw (today's immediate capture) and never
|
|
108
|
+
// pays a settle wait; a benign fs error (an unreadable manifest or unlistable
|
|
109
|
+
// parent) is deliberately NOT read as a signal, so a real defect never waits.
|
|
110
|
+
// The loader-race dialects need no such gate. Injected for a hermetic seam.
|
|
111
|
+
if (classified.dialect === "eval-throw") {
|
|
112
|
+
const rewriteInProgress = deps.rewriteInProgress ?? installRewriteInProgress;
|
|
113
|
+
if (!rewriteInProgress({ packageRoot, lockPath }))
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
96
116
|
// A child that already recovered must never wait or re-exec again: report
|
|
97
117
|
// once and let the boundary capture it.
|
|
98
118
|
if (env[INSTALL_TREE_RECOVERY_GUARD_ENV] === "1") {
|
|
@@ -108,8 +128,6 @@ export async function registerCommandsWithRecovery(args) {
|
|
|
108
128
|
});
|
|
109
129
|
}
|
|
110
130
|
stderr.write(`${INSTALL_TREE_WAIT_NOTICE}\n`);
|
|
111
|
-
const lockPath = (deps.lockPath ?? updateLockPath)();
|
|
112
|
-
const waitFor = deps.waitForSettled ?? waitForInstallTreeSettled;
|
|
113
131
|
const result = await waitFor({
|
|
114
132
|
target: classified.target,
|
|
115
133
|
packageRoot,
|
|
@@ -13,10 +13,11 @@ export type PackageRootResolver = () => string | null;
|
|
|
13
13
|
/** Which strategy produced the running install's root, recorded in diagnostics. */
|
|
14
14
|
export type PackageRootResolverSource = "manifest-walk" | "string-derivation" | "unresolved" | "injected";
|
|
15
15
|
/**
|
|
16
|
-
* If `err` is an in-process incomplete-install module-load failure —
|
|
17
|
-
*
|
|
18
|
-
* (HQ-CLI-
|
|
19
|
-
* — return the actionable, input-free reinstall
|
|
16
|
+
* If `err` is an in-process incomplete-install module-load failure — the CJS
|
|
17
|
+
* relative-sibling shape (HQ-CLI-1N), the ESM vanished-at-read shape (HQ-CLI-1M),
|
|
18
|
+
* or the resolve-time lstat shape (HQ-CLI-1S), with the failing file confirmed
|
|
19
|
+
* under `<packageRoot>/node_modules/` — return the actionable, input-free reinstall
|
|
20
|
+
* remedy; otherwise return null.
|
|
20
21
|
*
|
|
21
22
|
* Mirrors qmdModuleMissingMessage so the top-level handler and beforeSend branch
|
|
22
23
|
* the same way: a non-null result means print-the-remedy-and-skip-Sentry, null
|
|
@@ -24,7 +25,11 @@ export type PackageRootResolverSource = "manifest-walk" | "string-derivation" |
|
|
|
24
25
|
* fails yields null.
|
|
25
26
|
*/
|
|
26
27
|
export declare function incompleteInstallMessage(err: unknown, resolvePackageRoot?: PackageRootResolver, fileSystem?: Pick<typeof fs, "readFileSync">): string | null;
|
|
27
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Bounded, scrubber-safe diagnostics for an unattributable loader/resolver ENOENT
|
|
30
|
+
* whose `path` did not survive delivery — the esm-loader open shape (HQ-CLI-1M) or
|
|
31
|
+
* the resolve-time lstat shape (HQ-CLI-1S). The two frame booleans say which.
|
|
32
|
+
*/
|
|
28
33
|
export type IncompleteInstallEsmDiagnostics = {
|
|
29
34
|
/** The resolved install root, or the literal `<unresolved>` when none was found. */
|
|
30
35
|
packageRoot: string;
|
|
@@ -41,9 +46,28 @@ export type IncompleteInstallEsmDiagnostics = {
|
|
|
41
46
|
*/
|
|
42
47
|
packageJsonExists?: boolean;
|
|
43
48
|
nodeModulesExists?: boolean;
|
|
49
|
+
/** The stack carried an esm-loader (source-read) frame — the HQ-CLI-1M route. */
|
|
44
50
|
esmLoaderFrame: boolean;
|
|
51
|
+
/** The stack carried a module-resolver frame — the HQ-CLI-1S resolve-time route. */
|
|
52
|
+
resolverFrame: boolean;
|
|
45
53
|
code: string;
|
|
46
54
|
};
|
|
55
|
+
/**
|
|
56
|
+
* Bounded, scrubber-safe diagnostics for a module-EVALUATION throw that reached
|
|
57
|
+
* the boundary raw (HQ-CLI-1T) — a code-less TypeError/ReferenceError/SyntaxError
|
|
58
|
+
* whose throw site sits under the running install's node_modules, so the next
|
|
59
|
+
* occurrence names the offending third-party package instead of filing bare. Only
|
|
60
|
+
* the closed error name and the bounded package name are recorded; never the
|
|
61
|
+
* message, argv, or a full path.
|
|
62
|
+
*/
|
|
63
|
+
export type IncompleteInstallEvalThrowDiagnostics = {
|
|
64
|
+
evalThrow: true;
|
|
65
|
+
errorName: string;
|
|
66
|
+
throwSitePackage: string;
|
|
67
|
+
packageRoot: string;
|
|
68
|
+
packageRootResolved: boolean;
|
|
69
|
+
resolver: PackageRootResolverSource;
|
|
70
|
+
};
|
|
47
71
|
/**
|
|
48
72
|
* Bounded, scrubber-safe diagnostics for a NOT-suppressed third-party bare miss
|
|
49
73
|
* (HQ-CLI-1Q): a possible undeclared / peer-only dependency defect, made
|
|
@@ -82,7 +106,7 @@ export type IncompleteInstallRelativeDiagnostics = {
|
|
|
82
106
|
* exactly one of the strict shapes above; the looseness is only at the read
|
|
83
107
|
* boundary.
|
|
84
108
|
*/
|
|
85
|
-
export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnostics & IncompleteInstallBareDiagnostics & IncompleteInstallRelativeDiagnostics>;
|
|
109
|
+
export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnostics & IncompleteInstallBareDiagnostics & IncompleteInstallRelativeDiagnostics & IncompleteInstallEvalThrowDiagnostics>;
|
|
86
110
|
/**
|
|
87
111
|
* When an incomplete-install failure reaches the capture path WITHOUT being
|
|
88
112
|
* suppressed, return a bounded `contexts.incomplete_install` block so the next
|
|
@@ -94,10 +118,13 @@ export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnosti
|
|
|
94
118
|
* dist/ or assets/, outside the install, or under an unresolved root
|
|
95
119
|
* (HQ-CLI-1N) → { code, relativeSpecifier:true, packageRoot,
|
|
96
120
|
* packageRootResolved, resolver, requirerScope };
|
|
97
|
-
* -
|
|
98
|
-
*
|
|
99
|
-
* nor node_system_error carried a `path` → { packageRoot,
|
|
100
|
-
* nodeModulesExists, esmLoaderFrame, code }
|
|
121
|
+
* - a loader/resolver ENOENT whose path did not survive delivery (HQ-CLI-1M
|
|
122
|
+
* esm-load open, HQ-CLI-1S resolve-time lstat) — where neither the exception
|
|
123
|
+
* value nor node_system_error carried a `path` → { packageRoot,
|
|
124
|
+
* packageJsonExists, nodeModulesExists, esmLoaderFrame, resolverFrame, code };
|
|
125
|
+
* - a module-EVALUATION throw that reached the boundary raw (HQ-CLI-1T) →
|
|
126
|
+
* { evalThrow, errorName, throwSitePackage, packageRoot, packageRootResolved,
|
|
127
|
+
* resolver }.
|
|
101
128
|
* Built with the byte-capped, scrubber-safe discipline of
|
|
102
129
|
* package-root-diagnostics.ts — never a caller argv, query, or user-minted
|
|
103
130
|
* value. Never throws. main.ts attaches this on the generic capture path
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
import * as fs from "fs";
|
|
65
65
|
import * as path from "path";
|
|
66
66
|
import { packageRoot, stringDerivedPackageRoot } from "./hq-roots.js";
|
|
67
|
-
import { packageNameOf } from "./install-tree-torn.js";
|
|
67
|
+
import { evalThrowShape, packageNameOf } from "./install-tree-torn.js";
|
|
68
68
|
import { boundedDiagnosticValue } from "./package-root-diagnostics.js";
|
|
69
69
|
/**
|
|
70
70
|
* The actionable remedy shown to the operator. Input-free — nothing from the
|
|
@@ -95,6 +95,16 @@ const RELATIVE_SPECIFIER = /^\.\.?[\\/]/;
|
|
|
95
95
|
* `esm/loader` module.
|
|
96
96
|
*/
|
|
97
97
|
const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]load\b/;
|
|
98
|
+
/**
|
|
99
|
+
* The Node module RESOLVER frame — `finalizeResolution` in
|
|
100
|
+
* `node:internal/modules/esm/resolve`, or the CJS `Module._findPath → toRealPath`
|
|
101
|
+
* route in `node:internal/modules/cjs/loader`. Alongside `syscall === 'lstat'`
|
|
102
|
+
* this proves a resolve-time ENOENT (HQ-CLI-1S) — a path component that vanished
|
|
103
|
+
* between the resolver's stat and its realpathSync lstat — so an ordinary
|
|
104
|
+
* `fs.lstatSync` ENOENT from hq's own code stays captured. Mirrors the frame
|
|
105
|
+
* matcher in install-tree-torn.ts (duplicated exactly as ESM_LOADER_FRAME is).
|
|
106
|
+
*/
|
|
107
|
+
const RESOLVER_FRAME = /node:internal[\\/]modules[\\/](?:esm[\\/]resolve|cjs[\\/]loader)\b/;
|
|
98
108
|
const ROOT_DIAGNOSTIC_BYTES = 256;
|
|
99
109
|
const CODE_DIAGNOSTIC_BYTES = 32;
|
|
100
110
|
// Package names live inside hq-cli's own dependency graph, so their universe is
|
|
@@ -306,6 +316,10 @@ function requirerDeclaresDependency(requiringFile, packageName, fileSystem) {
|
|
|
306
316
|
function hasEsmLoaderFrame(stack) {
|
|
307
317
|
return typeof stack === "string" && ESM_LOADER_FRAME.test(stack);
|
|
308
318
|
}
|
|
319
|
+
/** True when `stack` carries a Node module RESOLVER frame. */
|
|
320
|
+
function hasResolverFrame(stack) {
|
|
321
|
+
return typeof stack === "string" && RESOLVER_FRAME.test(stack);
|
|
322
|
+
}
|
|
309
323
|
/**
|
|
310
324
|
* The ESM-loader ENOENT SIGNATURE, independent of whether a usable `path`
|
|
311
325
|
* survived: `code === 'ENOENT'`, `syscall === 'open'`, and an esm-loader frame
|
|
@@ -321,10 +335,25 @@ function isEsmLoaderEnoent(err) {
|
|
|
321
335
|
hasEsmLoaderFrame(record.stack));
|
|
322
336
|
}
|
|
323
337
|
/**
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
338
|
+
* The resolve-time ENOENT SIGNATURE (HQ-CLI-1S): `code === 'ENOENT'`,
|
|
339
|
+
* `syscall === 'lstat'`, and a resolver frame — independent of whether a usable
|
|
340
|
+
* `path` survived delivery. Sibling of {@link isEsmLoaderEnoent}; the message
|
|
341
|
+
* classifier additionally requires a `path` under node_modules.
|
|
342
|
+
*/
|
|
343
|
+
function isResolveEnoent(err) {
|
|
344
|
+
if (err === null || typeof err !== "object")
|
|
345
|
+
return false;
|
|
346
|
+
const record = err;
|
|
347
|
+
return (record.code === "ENOENT" &&
|
|
348
|
+
record.syscall === "lstat" &&
|
|
349
|
+
hasResolverFrame(record.stack));
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* If `err` is an in-process incomplete-install module-load failure — the CJS
|
|
353
|
+
* relative-sibling shape (HQ-CLI-1N), the ESM vanished-at-read shape (HQ-CLI-1M),
|
|
354
|
+
* or the resolve-time lstat shape (HQ-CLI-1S), with the failing file confirmed
|
|
355
|
+
* under `<packageRoot>/node_modules/` — return the actionable, input-free reinstall
|
|
356
|
+
* remedy; otherwise return null.
|
|
328
357
|
*
|
|
329
358
|
* Mirrors qmdModuleMissingMessage so the top-level handler and beforeSend branch
|
|
330
359
|
* the same way: a non-null result means print-the-remedy-and-skip-Sentry, null
|
|
@@ -375,17 +404,24 @@ export function incompleteInstallMessage(err, resolvePackageRoot = safePackageRo
|
|
|
375
404
|
? INCOMPLETE_INSTALL_REMEDY
|
|
376
405
|
: null;
|
|
377
406
|
}
|
|
378
|
-
// Shape B (ESM load, HQ-CLI-1M): an ENOENT from the module
|
|
379
|
-
//
|
|
380
|
-
if (record.syscall
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
return null;
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
407
|
+
// Shape B (ESM load, HQ-CLI-1M): an ENOENT from the module LOADER for a file
|
|
408
|
+
// present at resolve and gone at read (syscall 'open' + an esm-loader frame).
|
|
409
|
+
if (record.syscall === "open" &&
|
|
410
|
+
typeof record.path === "string" &&
|
|
411
|
+
hasEsmLoaderFrame(record.stack)) {
|
|
412
|
+
return isUnderNodeModules(record.path, root) ? INCOMPLETE_INSTALL_REMEDY : null;
|
|
413
|
+
}
|
|
414
|
+
// Shape B' (RESOLVE time, HQ-CLI-1S): an ENOENT from the resolver's realpath
|
|
415
|
+
// step (syscall 'lstat' + a resolver frame) — a path component that a concurrent
|
|
416
|
+
// reinstall renamed away between the resolver's stat and its lstat. Same
|
|
417
|
+
// disposition as B: a vanished component under the running install's
|
|
418
|
+
// node_modules is a torn install, so print the remedy and skip capture.
|
|
419
|
+
if (record.syscall === "lstat" &&
|
|
420
|
+
typeof record.path === "string" &&
|
|
421
|
+
hasResolverFrame(record.stack)) {
|
|
422
|
+
return isUnderNodeModules(record.path, root) ? INCOMPLETE_INSTALL_REMEDY : null;
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
389
425
|
}
|
|
390
426
|
/** A readFileSync surface, defaulting to the real fs when the caller injects none. */
|
|
391
427
|
function readFileFrom(fileSystem) {
|
|
@@ -513,10 +549,13 @@ function relativeSpecifierCaptureContext(err, resolvePackageRoot, fileSystem) {
|
|
|
513
549
|
* dist/ or assets/, outside the install, or under an unresolved root
|
|
514
550
|
* (HQ-CLI-1N) → { code, relativeSpecifier:true, packageRoot,
|
|
515
551
|
* packageRootResolved, resolver, requirerScope };
|
|
516
|
-
* -
|
|
517
|
-
*
|
|
518
|
-
* nor node_system_error carried a `path` → { packageRoot,
|
|
519
|
-
* nodeModulesExists, esmLoaderFrame, code }
|
|
552
|
+
* - a loader/resolver ENOENT whose path did not survive delivery (HQ-CLI-1M
|
|
553
|
+
* esm-load open, HQ-CLI-1S resolve-time lstat) — where neither the exception
|
|
554
|
+
* value nor node_system_error carried a `path` → { packageRoot,
|
|
555
|
+
* packageJsonExists, nodeModulesExists, esmLoaderFrame, resolverFrame, code };
|
|
556
|
+
* - a module-EVALUATION throw that reached the boundary raw (HQ-CLI-1T) →
|
|
557
|
+
* { evalThrow, errorName, throwSitePackage, packageRoot, packageRootResolved,
|
|
558
|
+
* resolver }.
|
|
520
559
|
* Built with the byte-capped, scrubber-safe discipline of
|
|
521
560
|
* package-root-diagnostics.ts — never a caller argv, query, or user-minted
|
|
522
561
|
* value. Never throws. main.ts attaches this on the generic capture path
|
|
@@ -531,19 +570,63 @@ export function incompleteInstallCaptureContext(err, resolvePackageRoot = safePa
|
|
|
531
570
|
const relative = relativeSpecifierCaptureContext(err, resolvePackageRoot, fileSystem);
|
|
532
571
|
if (relative)
|
|
533
572
|
return relative;
|
|
534
|
-
// (C) HQ-CLI-1M — the path-less
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
//
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
573
|
+
// (C) HQ-CLI-1M / HQ-CLI-1S — the path-less loader/resolver ENOENT, made
|
|
574
|
+
// attributable. An ENOENT whose `path` survived and sits under node_modules is
|
|
575
|
+
// classified and printed above, never captured; only the unattributable shape
|
|
576
|
+
// (no usable path, or a path outside node_modules) reaches here.
|
|
577
|
+
if (isEsmLoaderEnoent(err) || isResolveEnoent(err)) {
|
|
578
|
+
if (incompleteInstallMessage(err, resolvePackageRoot, readFileFrom(fileSystem)) === null) {
|
|
579
|
+
const record = err;
|
|
580
|
+
const code = typeof record.code === "string" ? record.code : "";
|
|
581
|
+
// The default resolver knows which strategy answered (manifest walk vs the
|
|
582
|
+
// string derivation that survives a torn tree); an injected resolver is
|
|
583
|
+
// opaque, so it is recorded as "injected" and its return used as the root.
|
|
584
|
+
const resolution = resolvePackageRoot === safePackageRoot
|
|
585
|
+
? resolvePackageRootWithSource()
|
|
586
|
+
: {
|
|
587
|
+
root: resolveRootSafely(resolvePackageRoot),
|
|
588
|
+
source: "injected",
|
|
589
|
+
};
|
|
590
|
+
const root = resolution.root;
|
|
591
|
+
const diagnostics = {
|
|
592
|
+
packageRoot: boundedDiagnosticValue(root ?? "<unresolved>", ROOT_DIAGNOSTIC_BYTES),
|
|
593
|
+
packageRootResolved: root !== null,
|
|
594
|
+
resolver: resolution.source,
|
|
595
|
+
// Which route raised it — the esm-loader (source-read) frame for 1M, the
|
|
596
|
+
// resolver frame for 1S; either or both are recorded from the stack.
|
|
597
|
+
esmLoaderFrame: hasEsmLoaderFrame(record.stack),
|
|
598
|
+
resolverFrame: hasResolverFrame(record.stack),
|
|
599
|
+
code: boundedDiagnosticValue(code, CODE_DIAGNOSTIC_BYTES),
|
|
600
|
+
};
|
|
601
|
+
// Report the existence booleans ONLY when a root was resolved. Reported for
|
|
602
|
+
// an unresolved root they were uninitialised `false`s indistinguishable from
|
|
603
|
+
// "resolved but missing" — the instrumentation defect the prior fix shipped.
|
|
604
|
+
if (root !== null) {
|
|
605
|
+
diagnostics.packageJsonExists = safeExistsSync(fileSystem, path.join(root, "package.json"));
|
|
606
|
+
diagnostics.nodeModulesExists = safeExistsSync(fileSystem, path.join(root, "node_modules"));
|
|
607
|
+
}
|
|
608
|
+
return { incomplete_install: diagnostics };
|
|
609
|
+
}
|
|
541
610
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
611
|
+
// (D) HQ-CLI-1T — a module-EVALUATION throw that reached the boundary raw (the
|
|
612
|
+
// writer-signal gate found no reinstall in progress), made attributable.
|
|
613
|
+
return evalThrowCaptureContext(err, resolvePackageRoot);
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* When a module-EVALUATION throw (HQ-CLI-1T) reached the capture path raw — a
|
|
617
|
+
* code-less TypeError/ReferenceError/SyntaxError whose top-level code ran while a
|
|
618
|
+
* half-written dependency file returned an empty module — return a bounded
|
|
619
|
+
* `incomplete_install` block naming the offending third-party package so the next
|
|
620
|
+
* occurrence is attributable instead of bare; otherwise undefined. When the root
|
|
621
|
+
* resolves, the throw site MUST sit under its node_modules (an hq-cli `dist/` or
|
|
622
|
+
* `assets/` eval throw is hq's OWN defect and stays a bare capture); when the root
|
|
623
|
+
* is unresolved the block is still attached with `packageRootResolved:false`.
|
|
624
|
+
* Never throws; never emits the message, argv, or a full path.
|
|
625
|
+
*/
|
|
626
|
+
function evalThrowCaptureContext(err, resolvePackageRoot) {
|
|
627
|
+
const shape = evalThrowShape(err);
|
|
628
|
+
if (!shape)
|
|
629
|
+
return undefined;
|
|
547
630
|
const resolution = resolvePackageRoot === safePackageRoot
|
|
548
631
|
? resolvePackageRootWithSource()
|
|
549
632
|
: {
|
|
@@ -551,21 +634,18 @@ export function incompleteInstallCaptureContext(err, resolvePackageRoot = safePa
|
|
|
551
634
|
source: "injected",
|
|
552
635
|
};
|
|
553
636
|
const root = resolution.root;
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
637
|
+
if (root !== null && !isUnderNodeModules(shape.throwSite, root))
|
|
638
|
+
return undefined;
|
|
639
|
+
return {
|
|
640
|
+
incomplete_install: {
|
|
641
|
+
evalThrow: true,
|
|
642
|
+
errorName: boundedDiagnosticValue(shape.errorName, CODE_DIAGNOSTIC_BYTES),
|
|
643
|
+
throwSitePackage: boundedDiagnosticValue(requiringPackage(shape.throwSite)?.name ?? "<unresolved>", PACKAGE_NAME_DIAGNOSTIC_BYTES),
|
|
644
|
+
packageRoot: boundedDiagnosticValue(root ?? "<unresolved>", ROOT_DIAGNOSTIC_BYTES),
|
|
645
|
+
packageRootResolved: root !== null,
|
|
646
|
+
resolver: resolution.source,
|
|
647
|
+
},
|
|
560
648
|
};
|
|
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
649
|
}
|
|
570
650
|
/** existsSync that never throws — any filesystem error reads as "absent". */
|
|
571
651
|
function safeExistsSync(fileSystem, target) {
|
|
@@ -31,10 +31,12 @@
|
|
|
31
31
|
*/
|
|
32
32
|
/**
|
|
33
33
|
* The loader-error `code`s recovery classifies. The first two mean "a module
|
|
34
|
-
* could not be resolved"; `ENOENT` is the
|
|
35
|
-
* present at
|
|
34
|
+
* could not be resolved"; `ENOENT` is the loader/resolver dialect where a module
|
|
35
|
+
* was present at one step and gone at the next (HQ-CLI-1M esm-load open, HQ-CLI-1S
|
|
36
|
+
* resolve-time lstat); `EVAL_THROW` is the synthetic code for a module-EVALUATION
|
|
37
|
+
* throw that carries NO real `code` (HQ-CLI-1T) — see {@link ModuleErrorDialect}.
|
|
36
38
|
*/
|
|
37
|
-
export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "ENOENT";
|
|
39
|
+
export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "ENOENT" | "EVAL_THROW";
|
|
38
40
|
/**
|
|
39
41
|
* The closed set of resolution-failure dialects, verified on Node v22.23.1 (the
|
|
40
42
|
* @sentry/node import-in-the-middle hook does not change the shapes):
|
|
@@ -54,10 +56,20 @@ export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "
|
|
|
54
56
|
* - `esm-enoent`: ESM loader ENOENT (HQ-CLI-1M) — a module present at RESOLVE
|
|
55
57
|
* and gone at READ, so getSourceSync/openSync raises ENOENT
|
|
56
58
|
* (not ERR_MODULE_NOT_FOUND). `err.path` is the vanished file.
|
|
59
|
+
* - `resolve-enoent`: ESM/CJS RESOLVE-time ENOENT (HQ-CLI-1S) — a path component
|
|
60
|
+
* stat'ed OK, then vanished before realpathSync's lstat, so the
|
|
61
|
+
* resolver (finalizeResolution / Module._findPath→toRealPath)
|
|
62
|
+
* raises ENOENT with syscall `lstat`. `err.path` is the vanished
|
|
63
|
+
* component (which may be a package/scope directory).
|
|
64
|
+
* - `eval-throw`: a module-EVALUATION throw (HQ-CLI-1T) — a dependency file was
|
|
65
|
+
* found but still half-written, so require() returned an empty
|
|
66
|
+
* module and its top-level code threw a code-LESS TypeError /
|
|
67
|
+
* ReferenceError / SyntaxError under Module._compile /
|
|
68
|
+
* esm/module_job. The specifier is the bounded throw-site path.
|
|
57
69
|
* - `unknown`: a module-not-found whose message did not parse; recovery
|
|
58
70
|
* still waits on the lock / retired-dir / quiet signals.
|
|
59
71
|
*/
|
|
60
|
-
export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "cjs-relative" | "esm-enoent" | "unknown";
|
|
72
|
+
export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "cjs-relative" | "esm-enoent" | "resolve-enoent" | "eval-throw" | "unknown";
|
|
61
73
|
/**
|
|
62
74
|
* The missing thing, re-resolvable by the readiness probe:
|
|
63
75
|
* - `path`: an absolute filesystem path (a `.js` file, or a package dir).
|
|
@@ -66,6 +78,12 @@ export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-
|
|
|
66
78
|
* Node's CJS resolver does from directory `from` —
|
|
67
79
|
* `path.resolve(from, specifier)` plus the `.js`/`.json`/`.node`
|
|
68
80
|
* and directory forms.
|
|
81
|
+
* - `exists`: an absolute path whose bare EXISTENCE is the readiness signal
|
|
82
|
+
* (HQ-CLI-1S) — the resolver lstat'ed this component, which may be
|
|
83
|
+
* a package/scope DIRECTORY whose contents precede a `package.json`,
|
|
84
|
+
* so the `path` kind's directory rule could stay false forever.
|
|
85
|
+
* Readiness still rides the lock / manifest-health / retired-sibling
|
|
86
|
+
* / quiet guards, exactly as for the loader-race dialects.
|
|
69
87
|
* - `unknown`: the message did not parse; treated as "present" by the probe so
|
|
70
88
|
* readiness turns only on the lock / retired-dir / quiet signals.
|
|
71
89
|
*/
|
|
@@ -80,6 +98,9 @@ export type ModuleErrorTarget = {
|
|
|
80
98
|
kind: "relative";
|
|
81
99
|
specifier: string;
|
|
82
100
|
from: string;
|
|
101
|
+
} | {
|
|
102
|
+
kind: "exists";
|
|
103
|
+
path: string;
|
|
83
104
|
} | {
|
|
84
105
|
kind: "unknown";
|
|
85
106
|
};
|
|
@@ -91,6 +112,8 @@ export interface ClassifiedModuleError {
|
|
|
91
112
|
/** The importer the message named (from-clause or requireStack[0]), or "". */
|
|
92
113
|
importer: string;
|
|
93
114
|
target: ModuleErrorTarget;
|
|
115
|
+
/** The thrown error's `name` for the eval-throw dialect (HQ-CLI-1T), else absent. */
|
|
116
|
+
errorName?: string;
|
|
94
117
|
}
|
|
95
118
|
/**
|
|
96
119
|
* Reduce a bare specifier to its PACKAGE name: `@scope/name/sub` → `@scope/name`,
|
|
@@ -109,6 +132,39 @@ export declare function packageNameOf(specifier: string): string;
|
|
|
109
132
|
* `null` so it is rethrown to the existing boundary unchanged.
|
|
110
133
|
*/
|
|
111
134
|
export declare function classifyModuleNotFound(err: unknown): ClassifiedModuleError | null;
|
|
135
|
+
/** The structural fingerprint of a module-EVALUATION throw (HQ-CLI-1T). */
|
|
136
|
+
export interface EvalThrowShape {
|
|
137
|
+
/** The thrown error's `name`, in {@link EVAL_THROW_NAMES}. */
|
|
138
|
+
errorName: string;
|
|
139
|
+
/** The absolute throw-site path (first path token in the stack). */
|
|
140
|
+
throwSite: string;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Detect a module-EVALUATION throw STRUCTURALLY, WITHOUT the node_modules
|
|
144
|
+
* boundary gate: an error with NO `code`, a `name` in the closed set
|
|
145
|
+
* {TypeError, ReferenceError, SyntaxError}, a CJS/ESM module-evaluation loader
|
|
146
|
+
* frame, and a resolvable throw-site path. This is the shape a half-written
|
|
147
|
+
* dependency file produces — require() read an empty module and returned `{}`, so
|
|
148
|
+
* calling one of its exports throws a code-less TypeError while the module's own
|
|
149
|
+
* top-level code runs. No message text is ever parsed. The caller applies its own
|
|
150
|
+
* packageRoot gate ({@link classifyEvalThrow} for recovery, the boundary for
|
|
151
|
+
* capture context), so this stays pure and reusable. Returns null for anything
|
|
152
|
+
* that is not this shape — a coded error (ERR_REQUIRE_ESM etc.), an Error /
|
|
153
|
+
* RangeError name, a throw with no loader frame, or no absolute throw site.
|
|
154
|
+
*/
|
|
155
|
+
export declare function evalThrowShape(err: unknown): EvalThrowShape | null;
|
|
156
|
+
/**
|
|
157
|
+
* Classify a module-EVALUATION throw as a torn-install recovery candidate
|
|
158
|
+
* (HQ-CLI-1T) — the {@link evalThrowShape} fingerprint AND a throw site under
|
|
159
|
+
* `<packageRoot>/node_modules/`. A throw under the install's own `dist/` or
|
|
160
|
+
* `assets/`, outside the root, or with an unresolved root is NOT classified, so a
|
|
161
|
+
* genuine hq-cli code defect keeps its raw capture. Unlike the loader-race
|
|
162
|
+
* dialects the recovery seam gates this on a live writer signal before waiting.
|
|
163
|
+
* `packageRoot` comes from the seam's resolvePackageRoot (running-install → derived
|
|
164
|
+
* fallback). The target is `unknown` — an eval-throw carries no re-resolvable
|
|
165
|
+
* missing file, so recovery keys entirely on the writer signal + quiet window.
|
|
166
|
+
*/
|
|
167
|
+
export declare function classifyEvalThrow(err: unknown, packageRoot: string | null): ClassifiedModuleError | null;
|
|
112
168
|
/** The filesystem surface the probe and wait use, injectable for hermetic tests. */
|
|
113
169
|
export interface InstallTreeFs {
|
|
114
170
|
existsSync(p: string): boolean;
|
|
@@ -133,6 +189,8 @@ export interface InstallTreeFs {
|
|
|
133
189
|
* or a directory whose package.json `main` (or that main's index)
|
|
134
190
|
* or own `index.*` is a real file. A bare or partially-extracted
|
|
135
191
|
* directory is NOT loadable and reads as not-present.
|
|
192
|
+
* - `exists`: the path exists at all (any file type) — presence only, because
|
|
193
|
+
* the vanished component may be a package/scope directory.
|
|
136
194
|
* - `unknown`: true (readiness turns on the other signals).
|
|
137
195
|
* Any filesystem error reads as "not present" rather than throwing.
|
|
138
196
|
*/
|
|
@@ -221,6 +279,27 @@ export interface WaitForInstallTreeSettledResult {
|
|
|
221
279
|
* lockWaitMs`), so the caller can never hang.
|
|
222
280
|
*/
|
|
223
281
|
export declare function waitForInstallTreeSettled(args: WaitForInstallTreeSettledArgs): Promise<WaitForInstallTreeSettledResult>;
|
|
282
|
+
export interface InstallRewriteSignalArgs {
|
|
283
|
+
/** The running install's package dir (from resolveRunningInstall), or null. */
|
|
284
|
+
packageRoot: string | null;
|
|
285
|
+
/** The shared update-lock path ($HOME/.hq/locks/cli-update.lock). */
|
|
286
|
+
lockPath: string;
|
|
287
|
+
now?: () => number;
|
|
288
|
+
fs?: InstallTreeFs;
|
|
289
|
+
isPidAlive?: (pid: number) => boolean;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Whether a global reinstall is PROVABLY rewriting the install tree right now —
|
|
293
|
+
* the positive writer signal the eval-throw gate requires before it will pay a
|
|
294
|
+
* settle wait. True iff (i) a fresh foreign update lock is held, (ii) packageRoot's
|
|
295
|
+
* manifest is absent or name-mismatched (mid-extraction), or (iii) a
|
|
296
|
+
* `.<leaf>-<hash>` retired sibling sits beside it. Crucially it is POSITIVE: a
|
|
297
|
+
* benign filesystem error that merely prevents READING the manifest or LISTING the
|
|
298
|
+
* parent (EACCES/EPERM) is NOT a signal, so a persistent third-party load-time
|
|
299
|
+
* defect on an otherwise-usable install never pays a spurious 90s wait. Every
|
|
300
|
+
* dependency is injectable so the recovery seam stays hermetic.
|
|
301
|
+
*/
|
|
302
|
+
export declare function installRewriteInProgress(args: InstallRewriteSignalArgs): boolean;
|
|
224
303
|
/** The recovery OUTCOME, which — not the dialect — decides capture. */
|
|
225
304
|
export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed" | "lock-held";
|
|
226
305
|
export type InstallTreeTornDiagnostics = {
|
|
@@ -241,6 +320,8 @@ export type InstallTreeTornDiagnostics = {
|
|
|
241
320
|
/** The bounded `tool` of the last fresh foreign lock seen, or "". */
|
|
242
321
|
lockTool: string;
|
|
243
322
|
node: string;
|
|
323
|
+
/** The thrown error's `name` for the eval-throw dialect (HQ-CLI-1T), else "". */
|
|
324
|
+
errorName: string;
|
|
244
325
|
};
|
|
245
326
|
export interface InstallTreeTornInit {
|
|
246
327
|
/** The original loader error, carried unchanged as `cause`. */
|
|
@@ -41,6 +41,8 @@ const IMPORTER_BYTES = 256;
|
|
|
41
41
|
const PACKAGE_ROOT_BYTES = 256;
|
|
42
42
|
/** Byte cap for the lock holder's `tool` (the only foreign string this reads). */
|
|
43
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;
|
|
44
46
|
const ESM_PACKAGE_RE = /^Cannot find package '([^']+)' imported from (.+)$/s;
|
|
45
47
|
const CJS_MODULE_RE = /^Cannot find module '([^']+)'/s;
|
|
46
48
|
const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)$/s;
|
|
@@ -54,6 +56,96 @@ const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)
|
|
|
54
56
|
* the settle wait. `load\b` also excludes the sibling `esm/loader` module.
|
|
55
57
|
*/
|
|
56
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
|
+
}
|
|
57
149
|
/**
|
|
58
150
|
* A relative module specifier — `./x`, `../x`, `.\x`, `..\x`, or a bare `.`/`..`.
|
|
59
151
|
* Mirrors the shape src/utils/incomplete-install-error.ts uses, extended with the
|
|
@@ -122,6 +214,28 @@ export function classifyModuleNotFound(err) {
|
|
|
122
214
|
target: { kind: "path", path: record.path },
|
|
123
215
|
};
|
|
124
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
|
+
}
|
|
125
239
|
return null;
|
|
126
240
|
}
|
|
127
241
|
if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND")
|
|
@@ -206,6 +320,61 @@ export function classifyModuleNotFound(err) {
|
|
|
206
320
|
// the lock / retired-dir / quiet signals only.
|
|
207
321
|
return { code, dialect: "unknown", specifier: "", importer: "", target: { kind: "unknown" } };
|
|
208
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
|
+
}
|
|
209
378
|
const nodeInstallTreeFs = {
|
|
210
379
|
existsSync: (p) => nodeFs.existsSync(p),
|
|
211
380
|
statSync: (p) => nodeFs.statSync(p),
|
|
@@ -289,6 +458,8 @@ function relativeTargetResolvable(base, fs) {
|
|
|
289
458
|
* or a directory whose package.json `main` (or that main's index)
|
|
290
459
|
* or own `index.*` is a real file. A bare or partially-extracted
|
|
291
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.
|
|
292
463
|
* - `unknown`: true (readiness turns on the other signals).
|
|
293
464
|
* Any filesystem error reads as "not present" rather than throwing.
|
|
294
465
|
*/
|
|
@@ -296,6 +467,12 @@ export function installTargetPresent(target, fs = nodeInstallTreeFs) {
|
|
|
296
467
|
try {
|
|
297
468
|
if (target.kind === "unknown")
|
|
298
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);
|
|
299
476
|
if (target.kind === "path") {
|
|
300
477
|
if (!fs.existsSync(target.path))
|
|
301
478
|
return false;
|
|
@@ -509,6 +686,57 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
509
686
|
await sleep(pollMs);
|
|
510
687
|
}
|
|
511
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
|
+
}
|
|
512
740
|
/** The fixed, input-free carrier message (grouping cardinality stays bounded). */
|
|
513
741
|
const INSTALL_TREE_TORN_MESSAGE = "hq-cli install tree was being rewritten while this command started";
|
|
514
742
|
/**
|
|
@@ -539,6 +767,7 @@ export class InstallTreeTornError extends Error {
|
|
|
539
767
|
lockedMs: init.lockedMs ?? 0,
|
|
540
768
|
lockTool: boundedDiagnosticValue(init.lockTool ?? "", LOCK_TOOL_BYTES),
|
|
541
769
|
node: process.version,
|
|
770
|
+
errorName: boundedDiagnosticValue(init.classified.errorName ?? "", ERRORNAME_BYTES),
|
|
542
771
|
};
|
|
543
772
|
Object.setPrototypeOf(this, new.target.prototype);
|
|
544
773
|
}
|