@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.
- package/CHANGELOG.md +39 -0
- package/dist/startup-registration.d.ts +21 -1
- package/dist/startup-registration.js +62 -9
- 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 +168 -14
- package/dist/utils/install-tree-torn.js +340 -26
- package/dist/utils/sentry-fingerprint.js +5 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,45 @@
|
|
|
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
|
+
|
|
26
|
+
## [5.109.11] — 2026-09-12
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- A torn-install recovery no longer gives up after 90 seconds while another hq
|
|
31
|
+
updater is still legitimately installing hq-cli. When a command starts just as
|
|
32
|
+
the desktop app (or the box's `hq-cli-update` timer, or another `hq`) is
|
|
33
|
+
rewriting the install, the recovery waits for that writer to finish — bounded by
|
|
34
|
+
the shared install lock's own 10-minute staleness window rather than the 90-second
|
|
35
|
+
settle deadline — and then re-runs once on the healed tree, so the common case
|
|
36
|
+
(an install that finishes within the window) no longer fails at all. A short
|
|
37
|
+
second line explains the pause while it waits. If a writer holds the lock past
|
|
38
|
+
that ceiling, the command still fails once, but with a "let the update finish,
|
|
39
|
+
then re-run — do not reinstall while it is running" remedy and a distinct
|
|
40
|
+
`lock-held` outcome, instead of advice to reinstall over a live installer
|
|
41
|
+
(Sentry HQ-CLI-1Y, HQ-CLI-23). The ceiling is overridable with
|
|
42
|
+
`HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS`.
|
|
43
|
+
|
|
5
44
|
## [5.109.10] — 2026-09-12
|
|
6
45
|
|
|
7
46
|
### 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
|
|
@@ -35,6 +35,24 @@ export declare const DEFAULT_INSTALL_SETTLE_TIMEOUT_MS = 90000;
|
|
|
35
35
|
export declare const INSTALL_TREE_WAIT_NOTICE = "hq: the hq-cli install is being updated underneath this command; waiting for it to finish\u2026";
|
|
36
36
|
/** Resolve the settle deadline from the environment (0 = evaluate once; default on invalid). */
|
|
37
37
|
export declare function resolveSettleTimeoutMs(env: NodeJS.ProcessEnv): number;
|
|
38
|
+
/** Operator override for the settle wait's LOCK ceiling (time under a live writer). */
|
|
39
|
+
export declare const INSTALL_SETTLE_LOCK_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS";
|
|
40
|
+
/**
|
|
41
|
+
* Default ceiling (ms) on the time the settle wait spends under a fresh foreign
|
|
42
|
+
* lock held by a live cooperating writer — the update-lock contract's own
|
|
43
|
+
* staleness window. Overridable by {@link INSTALL_SETTLE_LOCK_TIMEOUT_ENV}.
|
|
44
|
+
*/
|
|
45
|
+
export declare const DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS: number;
|
|
46
|
+
/**
|
|
47
|
+
* The single dim line emitted once when the wait continues PAST the settle
|
|
48
|
+
* deadline because a live updater still holds the shared install lock — so a human
|
|
49
|
+
* knows the pause is a running update, not a hang. Never a remedy, never an exit
|
|
50
|
+
* code; worded distinctly from {@link INSTALL_TREE_WAIT_NOTICE} so the e2e can
|
|
51
|
+
* synchronize on it.
|
|
52
|
+
*/
|
|
53
|
+
export declare const INSTALL_TREE_LOCK_WAIT_NOTICE = "hq: another hq updater is still installing hq-cli; waiting for it to finish\u2026";
|
|
54
|
+
/** Resolve the lock ceiling from the environment (0 = do not extend; default on invalid). */
|
|
55
|
+
export declare function resolveSettleLockTimeoutMs(env: NodeJS.ProcessEnv): number;
|
|
38
56
|
/** Minimal view of a `spawnSync` result the seam relies on. */
|
|
39
57
|
export interface RecoverySpawnResult {
|
|
40
58
|
status: number | null;
|
|
@@ -52,6 +70,8 @@ export interface RegisterRecoveryDeps {
|
|
|
52
70
|
deriveInstallRoot?: () => string | null;
|
|
53
71
|
lockPath?: () => string;
|
|
54
72
|
waitForSettled?: (args: WaitForInstallTreeSettledArgs) => Promise<WaitForInstallTreeSettledResult>;
|
|
73
|
+
/** Positive writer-signal probe for the eval-throw gate (defaults to installRewriteInProgress). */
|
|
74
|
+
rewriteInProgress?: (args: InstallRewriteSignalArgs) => boolean;
|
|
55
75
|
/** node flags to forward to the re-exec child (defaults to process.execArgv). */
|
|
56
76
|
execArgv?: readonly string[];
|
|
57
77
|
}
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
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
|
-
import { updateLockPath } from "./utils/update-lock.js";
|
|
23
|
-
import { classifyModuleNotFound, InstallTreeTornError, waitForInstallTreeSettled, } from "./utils/install-tree-torn.js";
|
|
22
|
+
import { updateLockPath, UPDATE_LOCK_STALE_MS } from "./utils/update-lock.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
|
|
@@ -46,6 +46,31 @@ export function resolveSettleTimeoutMs(env) {
|
|
|
46
46
|
return DEFAULT_INSTALL_SETTLE_TIMEOUT_MS;
|
|
47
47
|
return Number.parseInt(raw.trim(), 10);
|
|
48
48
|
}
|
|
49
|
+
/** Operator override for the settle wait's LOCK ceiling (time under a live writer). */
|
|
50
|
+
export const INSTALL_SETTLE_LOCK_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS";
|
|
51
|
+
/**
|
|
52
|
+
* Default ceiling (ms) on the time the settle wait spends under a fresh foreign
|
|
53
|
+
* lock held by a live cooperating writer — the update-lock contract's own
|
|
54
|
+
* staleness window. Overridable by {@link INSTALL_SETTLE_LOCK_TIMEOUT_ENV}.
|
|
55
|
+
*/
|
|
56
|
+
export const DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS = UPDATE_LOCK_STALE_MS;
|
|
57
|
+
/**
|
|
58
|
+
* The single dim line emitted once when the wait continues PAST the settle
|
|
59
|
+
* deadline because a live updater still holds the shared install lock — so a human
|
|
60
|
+
* knows the pause is a running update, not a hang. Never a remedy, never an exit
|
|
61
|
+
* code; worded distinctly from {@link INSTALL_TREE_WAIT_NOTICE} so the e2e can
|
|
62
|
+
* synchronize on it.
|
|
63
|
+
*/
|
|
64
|
+
export const INSTALL_TREE_LOCK_WAIT_NOTICE = "hq: another hq updater is still installing hq-cli; waiting for it to finish…";
|
|
65
|
+
/** Resolve the lock ceiling from the environment (0 = do not extend; default on invalid). */
|
|
66
|
+
export function resolveSettleLockTimeoutMs(env) {
|
|
67
|
+
const raw = env[INSTALL_SETTLE_LOCK_TIMEOUT_ENV];
|
|
68
|
+
if (typeof raw !== "string" || raw.trim() === "")
|
|
69
|
+
return DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS;
|
|
70
|
+
if (!/^\d+$/.test(raw.trim()))
|
|
71
|
+
return DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS;
|
|
72
|
+
return Number.parseInt(raw.trim(), 10);
|
|
73
|
+
}
|
|
49
74
|
/**
|
|
50
75
|
* Register commands, recovering once from a torn-install module-resolution
|
|
51
76
|
* failure. On a clean registration this returns `{}` and touches nothing else.
|
|
@@ -62,12 +87,32 @@ export async function registerCommandsWithRecovery(args) {
|
|
|
62
87
|
return {};
|
|
63
88
|
}
|
|
64
89
|
catch (err) {
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
|
|
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);
|
|
68
98
|
if (!classified)
|
|
69
99
|
throw err;
|
|
70
|
-
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
|
+
}
|
|
71
116
|
// A child that already recovered must never wait or re-exec again: report
|
|
72
117
|
// once and let the boundary capture it.
|
|
73
118
|
if (env[INSTALL_TREE_RECOVERY_GUARD_ENV] === "1") {
|
|
@@ -83,24 +128,32 @@ export async function registerCommandsWithRecovery(args) {
|
|
|
83
128
|
});
|
|
84
129
|
}
|
|
85
130
|
stderr.write(`${INSTALL_TREE_WAIT_NOTICE}\n`);
|
|
86
|
-
const lockPath = (deps.lockPath ?? updateLockPath)();
|
|
87
|
-
const waitFor = deps.waitForSettled ?? waitForInstallTreeSettled;
|
|
88
131
|
const result = await waitFor({
|
|
89
132
|
target: classified.target,
|
|
90
133
|
packageRoot,
|
|
91
134
|
lockPath,
|
|
92
135
|
deadlineMs: resolveSettleTimeoutMs(env),
|
|
136
|
+
lockWaitMs: resolveSettleLockTimeoutMs(env),
|
|
137
|
+
onLockExtended: () => stderr.write(`${INSTALL_TREE_LOCK_WAIT_NOTICE}\n`),
|
|
93
138
|
});
|
|
94
139
|
if (!result.settled) {
|
|
95
140
|
throw new InstallTreeTornError({
|
|
96
141
|
cause: err,
|
|
97
142
|
classified,
|
|
98
143
|
packageRoot,
|
|
99
|
-
|
|
144
|
+
// A fresh foreign lock still held at the end means a live cooperating
|
|
145
|
+
// writer held the shared lock past its own ceiling — report 'lock-held'
|
|
146
|
+
// (its remedy tells the user to let the update finish, never to reinstall
|
|
147
|
+
// over it). Otherwise the tree genuinely never settled with no writer
|
|
148
|
+
// visible, which keeps meaning 'unsettled'.
|
|
149
|
+
outcome: result.lockHeldAtEnd ? "lock-held" : "unsettled",
|
|
100
150
|
attempt: 1,
|
|
101
151
|
waitedMs: result.waitedMs,
|
|
102
152
|
sawLock: result.sawLock,
|
|
103
153
|
sawRetired: result.sawRetired,
|
|
154
|
+
lockHeldAtEnd: result.lockHeldAtEnd,
|
|
155
|
+
lockedMs: result.lockedMs,
|
|
156
|
+
lockTool: result.lockTool,
|
|
104
157
|
});
|
|
105
158
|
}
|
|
106
159
|
const spawn = deps.spawn ?? spawnSync;
|
|
@@ -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) {
|