@indigoai-us/hq-cli 5.109.4 → 5.109.6
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 +36 -6
- package/dist/main.js +18 -15
- package/dist/sentry.js +25 -9
- package/dist/utils/incomplete-install-error.d.ts +38 -16
- package/dist/utils/incomplete-install-error.js +227 -24
- package/dist/utils/network-transport-error.js +10 -0
- package/dist/utils/vault-api.js +45 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.109.6] — 2026-09-10
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- A torn hq install no longer files an unactionable crash when a bundled
|
|
10
|
+
third-party dependency of a dependency is missing (HQ-CLI-1Q). `hq packs list`
|
|
11
|
+
loaded the mesh presence client, whose `import mqtt` chain reached
|
|
12
|
+
mqtt-packet's `parser.js` requiring `bl` — mqtt-packet's OWN declared
|
|
13
|
+
transitive dependency — which a partial Windows global install had left
|
|
14
|
+
unwritten. The in-process incomplete-install classifier shipped in 5.108.14
|
|
15
|
+
recognised only a RELATIVE missing specifier for the CJS shape, so this BARE
|
|
16
|
+
specifier fell through to a bare Sentry crash plus an `hq:` line the operator
|
|
17
|
+
could not act on. The classifier now also recognises a bare miss from UNDER
|
|
18
|
+
the running install's own `node_modules/`, but ONLY when the missing specifier
|
|
19
|
+
names a WHOLE package (not a subpath like `bl/lib/x`, which can mean the
|
|
20
|
+
package is present and only that file gone to a version mismatch) AND the
|
|
21
|
+
REQUIRING third-party package's own manifest declares it in its REQUIRED
|
|
22
|
+
`dependencies` (`optionalDependencies` are excluded, being not guaranteed
|
|
23
|
+
installed) — a correct install always writes such a
|
|
24
|
+
dependency, so its absence beside a present requirer can only be a torn
|
|
25
|
+
install, never an hq-cli manifest defect. Those are printed with the same
|
|
26
|
+
input-free reinstall remedy and skipped, at the top-level boundary and in the
|
|
27
|
+
shared `beforeSend`. A bare miss the requirer does NOT declare (a possible
|
|
28
|
+
undeclared or peer-only dependency) stays reported, now enriched on every
|
|
29
|
+
capture route with a bounded `incomplete_install` context naming both packages
|
|
30
|
+
so the next occurrence is attributable instead of bare.
|
|
31
|
+
|
|
32
|
+
## [5.109.5] — 2026-09-10
|
|
33
|
+
|
|
5
34
|
## [5.109.4] — 2026-09-09
|
|
6
35
|
|
|
7
36
|
### Fixed
|
|
@@ -342,12 +371,13 @@
|
|
|
342
371
|
`npm i -g @indigoai-us/hq-cli` / `pnpm add -g @indigoai-us/hq-cli`) while
|
|
343
372
|
skipping Sentry capture, the same disposition established for the qmd child in
|
|
344
373
|
HQ-CLI-Y. An hq-cli packaging fault stays reportable: a miss under the
|
|
345
|
-
package's own `dist/` or `assets/`, a bare-specifier miss (a
|
|
346
|
-
undeclared dependency
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
374
|
+
package's own `dist/` or `assets/`, a bare-specifier miss (then kept loud as a
|
|
375
|
+
possible undeclared dependency — refined for a dependency the requiring package
|
|
376
|
+
actually declares in HQ-CLI-1Q, see Unreleased), and an esm-loader ENOENT whose
|
|
377
|
+
path did not survive delivery are NOT suppressed — the last is captured WITH a
|
|
378
|
+
bounded `incomplete_install` context so the next occurrence is attributable.
|
|
379
|
+
The drop is wired both at the top-level boundary and in the shared
|
|
380
|
+
`beforeSend`, so it covers every capture route.
|
|
351
381
|
|
|
352
382
|
## [5.108.13] — 2026-09-06
|
|
353
383
|
|
package/dist/main.js
CHANGED
|
@@ -563,21 +563,24 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
563
563
|
// An IN-PROCESS module-load failure that means hq-cli's OWN installed
|
|
564
564
|
// package tree is incomplete at load time — a partial/interrupted global
|
|
565
565
|
// install left a bundled file unwritten (HQ-CLI-1N, a CJS relative-sibling
|
|
566
|
-
// MODULE_NOT_FOUND
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
// the
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
566
|
+
// MODULE_NOT_FOUND; HQ-CLI-1Q, a CJS BARE-specifier MODULE_NOT_FOUND for a
|
|
567
|
+
// dependency the requiring third-party package's own manifest declares), or
|
|
568
|
+
// a concurrent global install rewrote the running tree so an ESM module
|
|
569
|
+
// present at resolve was gone at read (HQ-CLI-1M, an esm-loader ENOENT).
|
|
570
|
+
// All carry no hq-cli frames and reached the final else, filing a bare
|
|
571
|
+
// crash and an unactionable line. An incomplete install is the caller's
|
|
572
|
+
// machine, the disposition HQ-CLI-Y already established for the qmd CHILD —
|
|
573
|
+
// print the input-free reinstall remedy and skip capture. Placed with the
|
|
574
|
+
// environmental family (after the typed qmd carriers and the hq state-write
|
|
575
|
+
// carrier, before environmentalFsErrorMessage): the signatures are disjoint
|
|
576
|
+
// — ENVIRONMENTAL_FS_CODES is only ENOSPC/EDQUOT/EROFS (never
|
|
577
|
+
// ENOENT/MODULE_NOT_FOUND), no qmd carrier sets requireStack or an
|
|
578
|
+
// esm-loader frame, and the classified file must sit under
|
|
579
|
+
// <packageRoot>/node_modules — so ordering changes nothing that exists. The
|
|
580
|
+
// UNATTRIBUTABLE shapes are deliberately NOT suppressed and are captured
|
|
581
|
+
// WITH bounded context on the generic path below: an esm-loader ENOENT
|
|
582
|
+
// whose path did not survive, and a bare miss the requirer does NOT declare
|
|
583
|
+
// (a possible undeclared/peer dependency, now named in the context).
|
|
581
584
|
const incompleteInstallMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || workdirMissingMsg || stateWriteMsg
|
|
582
585
|
? null
|
|
583
586
|
: incompleteInstallMessage(err);
|
package/dist/sentry.js
CHANGED
|
@@ -6,7 +6,7 @@ import { CLI_VERSION } from "./cli-version.js";
|
|
|
6
6
|
import { getCachedSentryUser } from "./utils/sentry-identity.js";
|
|
7
7
|
import { isEpipe } from "./utils/epipe.js";
|
|
8
8
|
import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
|
|
9
|
-
import { incompleteInstallMessage } from "./utils/incomplete-install-error.js";
|
|
9
|
+
import { incompleteInstallCaptureContext, incompleteInstallMessage, } from "./utils/incomplete-install-error.js";
|
|
10
10
|
import { sentryFingerprintFor } from "./utils/sentry-fingerprint.js";
|
|
11
11
|
/**
|
|
12
12
|
* Drop broken-pipe (EPIPE) crashes before scrubbing/send. A closed downstream
|
|
@@ -35,17 +35,33 @@ export function epipeAwareBeforeSend(event, hint) {
|
|
|
35
35
|
// Path-independent belt for an IN-PROCESS incomplete-install module-load
|
|
36
36
|
// failure — hq-cli's own globally installed tree is not intact at load time
|
|
37
37
|
// (HQ-CLI-1N, a CJS relative-sibling MODULE_NOT_FOUND under its bundled
|
|
38
|
-
// node_modules; HQ-CLI-
|
|
39
|
-
//
|
|
40
|
-
// for
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
38
|
+
// node_modules; HQ-CLI-1Q, a CJS BARE-specifier MODULE_NOT_FOUND the requiring
|
|
39
|
+
// third-party package's own manifest declares; HQ-CLI-1M, an esm-loader ENOENT
|
|
40
|
+
// for a file present at resolve and gone at read). handleTopLevelError already
|
|
41
|
+
// prints the reinstall remedy for the top-level route; dropping the event here
|
|
42
|
+
// suppresses the fatal regardless of route — the unhandled-rejection boundary,
|
|
43
|
+
// the command-level captureException sites, and bin/hq-auth-refresh —
|
|
44
|
+
// mirroring the EPIPE and environmental-fs drops above. The classifier reads
|
|
45
|
+
// only structured fields (plus, for the bare shape, the requiring package's
|
|
46
|
+
// own manifest) and the failing file must sit under the running install's
|
|
47
|
+
// node_modules, so an hq-cli packaging fault (a dist/ miss, or a bare miss the
|
|
48
|
+
// requirer does NOT declare — a possible undeclared/peer dependency) and the
|
|
46
49
|
// path-less unattributable shape are NOT dropped here and stay captured.
|
|
47
50
|
if (incompleteInstallMessage(hint?.originalException))
|
|
48
51
|
return null;
|
|
52
|
+
// An incomplete-install shape that is NOT suppressed (a bare miss the requirer
|
|
53
|
+
// does not declare, or a path-less esm-loader ENOENT) should still arrive
|
|
54
|
+
// ATTRIBUTABLE on every capture route, not only the top-level boundary:
|
|
55
|
+
// handleTopLevelError in main.ts is the ONLY caller of
|
|
56
|
+
// incompleteInstallCaptureContext, so events reaching this hook directly — the
|
|
57
|
+
// unhandled-rejection integration, the command-level captureException sites,
|
|
58
|
+
// and bin/hq-auth-refresh — would otherwise survive bare. Merge the bounded
|
|
59
|
+
// context here too. Idempotent: main.ts attaches the same block on its route,
|
|
60
|
+
// and an already-present context wins the spread.
|
|
61
|
+
const installContext = incompleteInstallCaptureContext(hint?.originalException);
|
|
62
|
+
if (installContext) {
|
|
63
|
+
event.contexts = { ...installContext, ...event.contexts };
|
|
64
|
+
}
|
|
49
65
|
// Group an event that survives to send by a BOUNDED machine discriminator so
|
|
50
66
|
// unrelated gateway/HTTP failures stop colliding into one fungible issue
|
|
51
67
|
// (HQ-CLI collision, Sentry 7642756130). Placed here — path-independent,
|
|
@@ -21,9 +21,9 @@ export type PackageRootResolver = () => string | null;
|
|
|
21
21
|
* means "handle as usual (capture to Sentry)". Never throws — a resolver that
|
|
22
22
|
* fails yields null.
|
|
23
23
|
*/
|
|
24
|
-
export declare function incompleteInstallMessage(err: unknown, resolvePackageRoot?: PackageRootResolver): string | null;
|
|
25
|
-
/** Bounded, scrubber-safe diagnostics for an unattributable esm-loader ENOENT. */
|
|
26
|
-
export type
|
|
24
|
+
export declare function incompleteInstallMessage(err: unknown, resolvePackageRoot?: PackageRootResolver, fileSystem?: Pick<typeof fs, "readFileSync">): string | null;
|
|
25
|
+
/** Bounded, scrubber-safe diagnostics for an unattributable esm-loader ENOENT (HQ-CLI-1M). */
|
|
26
|
+
export type IncompleteInstallEsmDiagnostics = {
|
|
27
27
|
packageRoot: string;
|
|
28
28
|
packageJsonExists: boolean;
|
|
29
29
|
nodeModulesExists: boolean;
|
|
@@ -31,20 +31,42 @@ export type IncompleteInstallDiagnostics = {
|
|
|
31
31
|
code: string;
|
|
32
32
|
};
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
34
|
+
* Bounded, scrubber-safe diagnostics for a NOT-suppressed third-party bare miss
|
|
35
|
+
* (HQ-CLI-1Q): a possible undeclared / peer-only dependency defect, made
|
|
36
|
+
* attributable — the missing package, the requiring package, and the fact that
|
|
37
|
+
* the requirer's own manifest did NOT declare it. Both names live inside
|
|
38
|
+
* hq-cli's dependency graph, so grouping cardinality stays bounded.
|
|
39
|
+
*/
|
|
40
|
+
export type IncompleteInstallBareDiagnostics = {
|
|
41
|
+
missingPackage: string;
|
|
42
|
+
requiringPackage: string;
|
|
43
|
+
requirerDeclaresMissing: false;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Both enriched shapes as a single OPEN record — every field optional so a
|
|
47
|
+
* consumer can forward either shape to Sentry without narrowing (the boundary
|
|
48
|
+
* and beforeSend only pass the block through). Every value CONSTRUCTED here is
|
|
49
|
+
* exactly one of the two strict shapes above; the looseness is only at the read
|
|
50
|
+
* boundary.
|
|
51
|
+
*/
|
|
52
|
+
export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnostics & IncompleteInstallBareDiagnostics>;
|
|
53
|
+
/**
|
|
54
|
+
* When an incomplete-install failure reaches the capture path WITHOUT being
|
|
55
|
+
* suppressed, return a bounded `contexts.incomplete_install` block so the next
|
|
56
|
+
* occurrence carries the evidence this one lacked; otherwise return undefined
|
|
57
|
+
* (bare capture). Two enriched shapes:
|
|
58
|
+
* - a third-party BARE-specifier miss the requirer did not declare (HQ-CLI-1Q)
|
|
59
|
+
* → { missingPackage, requiringPackage, requirerDeclaresMissing:false };
|
|
60
|
+
* - an esm-loader ENOENT whose path did not survive delivery (HQ-CLI-1M) — the
|
|
61
|
+
* shape the delivered payload arrived in, where neither the exception value
|
|
62
|
+
* nor node_system_error carried a `path` → { packageRoot, packageJsonExists,
|
|
63
|
+
* nodeModulesExists, esmLoaderFrame, code }.
|
|
64
|
+
* Built with the byte-capped, scrubber-safe discipline of
|
|
65
|
+
* package-root-diagnostics.ts — never a caller argv, query, or user-minted
|
|
66
|
+
* value. Never throws. main.ts attaches this on the generic capture path
|
|
67
|
+
* exactly as qmdSpawnFailureCaptureContext already does.
|
|
46
68
|
*/
|
|
47
|
-
export declare function incompleteInstallCaptureContext(err: unknown, resolvePackageRoot?: PackageRootResolver, fileSystem?: Pick<typeof fs, "existsSync">): {
|
|
69
|
+
export declare function incompleteInstallCaptureContext(err: unknown, resolvePackageRoot?: PackageRootResolver, fileSystem?: Pick<typeof fs, "existsSync"> & Partial<Pick<typeof fs, "readFileSync">>): {
|
|
48
70
|
incomplete_install: IncompleteInstallDiagnostics;
|
|
49
71
|
} | undefined;
|
|
50
72
|
//# sourceMappingURL=incomplete-install-error.d.ts.map
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// failure inside THIS process rather than a qmd child's captured stderr, which
|
|
8
8
|
// is the gap HQ-CLI-Y's classifier cannot cover.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
10
|
+
// Three shapes, one cause — a partial/interrupted global install left a file
|
|
11
11
|
// unwritten, or a concurrent global install rewrote the running tree
|
|
12
12
|
// underneath a command:
|
|
13
13
|
//
|
|
@@ -19,6 +19,16 @@
|
|
|
19
19
|
// specifier internal to a third-party package can only be a truncated on-disk
|
|
20
20
|
// copy, never an hq-cli manifest defect.
|
|
21
21
|
//
|
|
22
|
+
// HQ-CLI-1Q (Sentry 7716586928) — CJS, in-process. The SAME `import mqtt`
|
|
23
|
+
// chain, one hop further down: mqtt-packet's `parser.js` requires the BARE
|
|
24
|
+
// specifier `bl` — mqtt-packet's OWN declared transitive dependency — and it
|
|
25
|
+
// is absent inside hq-cli's bundled node_modules after a torn Windows global
|
|
26
|
+
// install. A bare miss looks like an undeclared dependency, but when the
|
|
27
|
+
// REQUIRING third-party package's own manifest declares it, its absence
|
|
28
|
+
// beside a present requirer can only be a torn install. hq-cli declares
|
|
29
|
+
// nothing about a contract between two third-party packages, so this is never
|
|
30
|
+
// an hq-cli manifest defect.
|
|
31
|
+
//
|
|
22
32
|
// HQ-CLI-1M (Sentry 7714870912) — ESM load, in-process. A module that existed
|
|
23
33
|
// at RESOLVE was gone at READ (a concurrent writer rewrote the install tree),
|
|
24
34
|
// so Node's ESM loader raised `ENOENT` from getSourceSync/readFileSync/openSync
|
|
@@ -26,7 +36,7 @@
|
|
|
26
36
|
// ERR_MODULE_NOT_FOUND at resolve, never ENOENT at load; an ENOENT at load
|
|
27
37
|
// proves the file vanished between resolve and read.
|
|
28
38
|
//
|
|
29
|
-
//
|
|
39
|
+
// All shapes carry no hq-cli frames, reach the boundary's final `else`, and —
|
|
30
40
|
// before this classifier — filed a bare captureException plus an unactionable
|
|
31
41
|
// `hq: <fallback>` line. The disposition is the one HQ-CLI-Y already
|
|
32
42
|
// established: an incomplete install is the caller's machine, so the CLI prints
|
|
@@ -35,19 +45,26 @@
|
|
|
35
45
|
// The gate is deliberately narrow so neither an hq-cli packaging fault nor
|
|
36
46
|
// user free-text can trip it. Only structured fields are read — `code`,
|
|
37
47
|
// `syscall`, `path`, `requireStack`, and the stack's loader-frame marker, plus
|
|
38
|
-
// the first message line for the CJS specifier
|
|
48
|
+
// the first message line for the CJS specifier — plus, for the bare CJS shape
|
|
49
|
+
// only, the requiring package's own on-disk manifest. Independent narrowings
|
|
39
50
|
// keep a genuine hq-cli defect reportable:
|
|
40
51
|
// 1. The failing file must sit under `<packageRoot>/node_modules/` — a
|
|
41
52
|
// third-party file hq-cli does not author. A miss under `<packageRoot>/dist`
|
|
42
53
|
// or `/assets` is hq-cli's OWN shipped output and stays captured.
|
|
43
|
-
// 2. The CJS shape
|
|
44
|
-
//
|
|
45
|
-
//
|
|
54
|
+
// 2. The CJS shape splits on the specifier. A RELATIVE specifier internal to
|
|
55
|
+
// a package under node_modules can only be a truncated copy (HQ-CLI-1N). A
|
|
56
|
+
// BARE PACKAGE-ROOT specifier stays captured UNLESS the requiring
|
|
57
|
+
// third-party package's own manifest declares it in its REQUIRED
|
|
58
|
+
// `dependencies` (HQ-CLI-1Q). A subpath miss (`bl/lib/x`), an
|
|
59
|
+
// optional-dependency miss (not guaranteed installed), and an undeclared or
|
|
60
|
+
// peer-only miss (`Cannot find module 'mqtt'` from a requirer that does not
|
|
61
|
+
// declare it) all stay loud — the last two enriched with both package names.
|
|
46
62
|
// 3. The ESM shape additionally requires an esm-loader frame — an ordinary
|
|
47
63
|
// `fs.readFileSync` ENOENT written by hq's own code stays captured.
|
|
48
64
|
import * as fs from "fs";
|
|
49
65
|
import * as path from "path";
|
|
50
66
|
import { packageRoot } from "./hq-roots.js";
|
|
67
|
+
import { packageNameOf } from "./install-tree-torn.js";
|
|
51
68
|
import { boundedDiagnosticValue } from "./package-root-diagnostics.js";
|
|
52
69
|
/**
|
|
53
70
|
* The actionable remedy shown to the operator. Input-free — nothing from the
|
|
@@ -72,6 +89,11 @@ const RELATIVE_SPECIFIER = /^\.\.?[\\/]/;
|
|
|
72
89
|
const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]/;
|
|
73
90
|
const ROOT_DIAGNOSTIC_BYTES = 256;
|
|
74
91
|
const CODE_DIAGNOSTIC_BYTES = 32;
|
|
92
|
+
// Package names live inside hq-cli's own dependency graph, so their universe is
|
|
93
|
+
// bounded; the cap is a belt for a pathological requireStack, not a real limit.
|
|
94
|
+
const PACKAGE_NAME_DIAGNOSTIC_BYTES = 128;
|
|
95
|
+
/** The `<something>/node_modules/<something>` directory-boundary marker. */
|
|
96
|
+
const NODE_MODULES_SEGMENT = "/node_modules/";
|
|
75
97
|
/**
|
|
76
98
|
* packageRoot() walks up from the compiled module and THROWS
|
|
77
99
|
* PackageRootResolutionError when it cannot resolve. This classifier runs inside
|
|
@@ -132,6 +154,114 @@ function parseMissingSpecifier(message) {
|
|
|
132
154
|
const match = message.match(/Cannot find module ['"]([^'"]+)['"]/);
|
|
133
155
|
return match ? match[1] : null;
|
|
134
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* True when `specifier` is a BARE package specifier — neither a RELATIVE
|
|
159
|
+
* specifier (`./x`, `..\x`) nor an ABSOLUTE POSIX (`/x`) or Windows (`C:\x`,
|
|
160
|
+
* `\\unc`) path. Shape-based (never `process.platform`) so a reported Windows
|
|
161
|
+
* path classifies on a Linux CI runner, mirroring normalizeForCompare's win32
|
|
162
|
+
* detection.
|
|
163
|
+
*/
|
|
164
|
+
function isBareSpecifier(specifier) {
|
|
165
|
+
if (specifier.length === 0)
|
|
166
|
+
return false;
|
|
167
|
+
if (RELATIVE_SPECIFIER.test(specifier))
|
|
168
|
+
return false;
|
|
169
|
+
if (specifier.startsWith("/"))
|
|
170
|
+
return false; // absolute POSIX
|
|
171
|
+
if (looksWin32(specifier))
|
|
172
|
+
return false; // absolute Windows (drive letter or UNC)
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* True when `specifier` is a bare specifier naming a PACKAGE ROOT with no
|
|
177
|
+
* subpath — `bl` or `@scope/name`, never `bl/lib/inner.js` or `@scope/name/sub`.
|
|
178
|
+
* Only a WHOLE-package miss is a proven torn install; a subpath miss can mean the
|
|
179
|
+
* package IS installed but that one file is absent because of a version or
|
|
180
|
+
* packaging mismatch — a real defect that must stay reported, not be silenced as
|
|
181
|
+
* a torn install.
|
|
182
|
+
*/
|
|
183
|
+
function isBarePackageRoot(specifier) {
|
|
184
|
+
return isBareSpecifier(specifier) && specifier === packageNameOf(specifier);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* From a file under `<root>/node_modules/…`, derive the REQUIRING package's
|
|
188
|
+
* directory and its package name, nesting- and scope-aware:
|
|
189
|
+
* `<root>/node_modules/a/node_modules/b/index.js` → dir `…/b`, name `b`
|
|
190
|
+
* `<root>/node_modules/@scope/pkg/index.js` → dir `…/@scope/pkg`, name `@scope/pkg`
|
|
191
|
+
* Keyed on the LAST `node_modules` segment so the INNERMOST (actually requiring)
|
|
192
|
+
* package is chosen, consuming two path segments for an `@scope/name` dir. The
|
|
193
|
+
* returned dir preserves the original case and is folded to `/` (Node's fs
|
|
194
|
+
* accepts `/` on every platform); the win32 shape only case-folds the SEARCH so
|
|
195
|
+
* the segment is found, never the returned value. Returns null when the path
|
|
196
|
+
* holds no usable package segment.
|
|
197
|
+
*/
|
|
198
|
+
function requiringPackage(requiringFile) {
|
|
199
|
+
const folded = foldSeparators(requiringFile);
|
|
200
|
+
const win = looksWin32(requiringFile);
|
|
201
|
+
const haystack = win ? folded.toLowerCase() : folded;
|
|
202
|
+
const marker = win ? NODE_MODULES_SEGMENT.toLowerCase() : NODE_MODULES_SEGMENT;
|
|
203
|
+
const lastNm = haystack.lastIndexOf(marker);
|
|
204
|
+
if (lastNm === -1)
|
|
205
|
+
return null;
|
|
206
|
+
const prefixEnd = lastNm + NODE_MODULES_SEGMENT.length;
|
|
207
|
+
const prefix = folded.slice(0, prefixEnd); // `…/node_modules/`, original case
|
|
208
|
+
const rest = folded
|
|
209
|
+
.slice(prefixEnd)
|
|
210
|
+
.split("/")
|
|
211
|
+
.filter((segment) => segment.length > 0);
|
|
212
|
+
if (rest.length === 0)
|
|
213
|
+
return null;
|
|
214
|
+
const take = rest[0].startsWith("@") ? 2 : 1;
|
|
215
|
+
if (rest.length < take)
|
|
216
|
+
return null;
|
|
217
|
+
const name = rest.slice(0, take).join("/");
|
|
218
|
+
return { dir: prefix + name, name };
|
|
219
|
+
}
|
|
220
|
+
/** True when `deps` is an object carrying `packageName` as an OWN key. */
|
|
221
|
+
function declaresDependency(deps, packageName) {
|
|
222
|
+
return (typeof deps === "object" &&
|
|
223
|
+
deps !== null &&
|
|
224
|
+
Object.prototype.hasOwnProperty.call(deps, packageName));
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Whether the third-party package that REQUIRED `requiringFile` declares
|
|
228
|
+
* `packageName` in its own REQUIRED `dependencies` — the decisive discriminator
|
|
229
|
+
* for Shape C (HQ-CLI-1Q). A correct install of a package that declares a
|
|
230
|
+
* REQUIRED dependency ALWAYS writes it, so its absence beside a present requirer
|
|
231
|
+
* can only be a torn install. `optionalDependencies` are deliberately EXCLUDED:
|
|
232
|
+
* they are not guaranteed installed (`npm install --omit optional`, or a
|
|
233
|
+
* tolerated optional-install failure), so a missing optional package can be a
|
|
234
|
+
* persistent, expected omission — not a torn install — and must stay captured. A
|
|
235
|
+
* peer-only / undeclared / hoisting assumption is likewise NOT declared here.
|
|
236
|
+
* Fails CLOSED: an unresolved dir, an unreadable or invalid-JSON manifest, or a
|
|
237
|
+
* non-object dependency map all return false. Never throws — every fs and
|
|
238
|
+
* JSON.parse call is individually guarded. Reached only after the cheap
|
|
239
|
+
* in-memory narrowings of Shape C pass, so at most one small manifest read per
|
|
240
|
+
* process.
|
|
241
|
+
*/
|
|
242
|
+
function requirerDeclaresDependency(requiringFile, packageName, fileSystem) {
|
|
243
|
+
const requirer = requiringPackage(requiringFile);
|
|
244
|
+
if (!requirer)
|
|
245
|
+
return false;
|
|
246
|
+
let raw;
|
|
247
|
+
try {
|
|
248
|
+
raw = fileSystem.readFileSync(`${requirer.dir}/package.json`, "utf-8");
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
let manifest;
|
|
254
|
+
try {
|
|
255
|
+
manifest = JSON.parse(raw);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
if (manifest === null || typeof manifest !== "object")
|
|
261
|
+
return false;
|
|
262
|
+
const record = manifest;
|
|
263
|
+
return declaresDependency(record.dependencies, packageName);
|
|
264
|
+
}
|
|
135
265
|
/** True when `stack` carries a Node ESM loader frame. */
|
|
136
266
|
function hasEsmLoaderFrame(stack) {
|
|
137
267
|
return typeof stack === "string" && ESM_LOADER_FRAME.test(stack);
|
|
@@ -161,7 +291,7 @@ function isEsmLoaderEnoent(err) {
|
|
|
161
291
|
* means "handle as usual (capture to Sentry)". Never throws — a resolver that
|
|
162
292
|
* fails yields null.
|
|
163
293
|
*/
|
|
164
|
-
export function incompleteInstallMessage(err, resolvePackageRoot = safePackageRoot) {
|
|
294
|
+
export function incompleteInstallMessage(err, resolvePackageRoot = safePackageRoot, fileSystem = fs) {
|
|
165
295
|
if (err === null || typeof err !== "object")
|
|
166
296
|
return null;
|
|
167
297
|
const record = err;
|
|
@@ -172,16 +302,36 @@ export function incompleteInstallMessage(err, resolvePackageRoot = safePackageRo
|
|
|
172
302
|
if (!root)
|
|
173
303
|
return null;
|
|
174
304
|
if (code === "MODULE_NOT_FOUND") {
|
|
175
|
-
// Shape A (CJS, HQ-CLI-1N): a RELATIVE specifier internal to a package under
|
|
176
|
-
// the running install's node_modules can only be a truncated on-disk copy.
|
|
177
305
|
const requireStack = record.requireStack;
|
|
178
306
|
if (!Array.isArray(requireStack) || typeof requireStack[0] !== "string") {
|
|
179
307
|
return null;
|
|
180
308
|
}
|
|
309
|
+
const requiringFile = requireStack[0];
|
|
181
310
|
const specifier = parseMissingSpecifier(record.message);
|
|
182
|
-
if (specifier === null
|
|
311
|
+
if (specifier === null)
|
|
312
|
+
return null;
|
|
313
|
+
// Shape A (CJS, HQ-CLI-1N) — UNCHANGED: a RELATIVE specifier internal to a
|
|
314
|
+
// package under the running install's node_modules can only be a truncated
|
|
315
|
+
// on-disk copy. Kept byte-for-byte so 1N cannot regress.
|
|
316
|
+
if (RELATIVE_SPECIFIER.test(specifier)) {
|
|
317
|
+
return isUnderNodeModules(requiringFile, root)
|
|
318
|
+
? INCOMPLETE_INSTALL_REMEDY
|
|
319
|
+
: null;
|
|
320
|
+
}
|
|
321
|
+
// Shape C (CJS, HQ-CLI-1Q): a BARE PACKAGE-ROOT specifier missing from UNDER
|
|
322
|
+
// the running install's node_modules is a torn install ONLY when the
|
|
323
|
+
// REQUIRING third-party package's own manifest declares it in its required
|
|
324
|
+
// `dependencies`. A subpath specifier (`bl/lib/inner.js`) is excluded — the
|
|
325
|
+
// package may be present and only that file gone to a version mismatch — and
|
|
326
|
+
// an absolute-path specifier is neither relative nor bare, so both stay
|
|
327
|
+
// captured. The manifest read is the ONLY filesystem access here and is
|
|
328
|
+
// reached only after the cheap in-memory narrowings above — a combination
|
|
329
|
+
// that cannot occur on a healthy run — so a healthy event never touches disk.
|
|
330
|
+
if (!isBarePackageRoot(specifier))
|
|
331
|
+
return null;
|
|
332
|
+
if (!isUnderNodeModules(requiringFile, root))
|
|
183
333
|
return null;
|
|
184
|
-
return
|
|
334
|
+
return requirerDeclaresDependency(requiringFile, specifier, fileSystem)
|
|
185
335
|
? INCOMPLETE_INSTALL_REMEDY
|
|
186
336
|
: null;
|
|
187
337
|
}
|
|
@@ -197,27 +347,80 @@ export function incompleteInstallMessage(err, resolvePackageRoot = safePackageRo
|
|
|
197
347
|
? INCOMPLETE_INSTALL_REMEDY
|
|
198
348
|
: null;
|
|
199
349
|
}
|
|
350
|
+
/** A readFileSync surface, defaulting to the real fs when the caller injects none. */
|
|
351
|
+
function readFileFrom(fileSystem) {
|
|
352
|
+
return { readFileSync: fileSystem.readFileSync ?? fs.readFileSync };
|
|
353
|
+
}
|
|
200
354
|
/**
|
|
201
|
-
* When
|
|
202
|
-
* the
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
355
|
+
* When a third-party BARE-specifier CJS miss UNDER the running install's
|
|
356
|
+
* node_modules reached the capture path WITHOUT being suppressed (HQ-CLI-1Q) —
|
|
357
|
+
* i.e. the requiring package did NOT declare it, so it is a possible undeclared
|
|
358
|
+
* or peer-only dependency defect — return a bounded `incomplete_install` block
|
|
359
|
+
* naming both packages so the next occurrence is attributable instead of bare;
|
|
360
|
+
* otherwise undefined. The suppression decision is delegated to
|
|
361
|
+
* incompleteInstallMessage against the SAME injected filesystem, so a declared
|
|
362
|
+
* (torn-install) miss is never double-attributed here. Never throws.
|
|
363
|
+
*/
|
|
364
|
+
function bareSpecifierCaptureContext(err, resolvePackageRoot, fileSystem) {
|
|
365
|
+
if (err === null || typeof err !== "object")
|
|
366
|
+
return undefined;
|
|
367
|
+
const record = err;
|
|
368
|
+
if (record.code !== "MODULE_NOT_FOUND")
|
|
369
|
+
return undefined;
|
|
370
|
+
const requireStack = record.requireStack;
|
|
371
|
+
if (!Array.isArray(requireStack) || typeof requireStack[0] !== "string")
|
|
372
|
+
return undefined;
|
|
373
|
+
const requiringFile = requireStack[0];
|
|
374
|
+
const specifier = parseMissingSpecifier(record.message);
|
|
375
|
+
// Only a WHOLE-package bare miss is attributed here, matching the classifier's
|
|
376
|
+
// Shape C gate: a subpath miss is a version/packaging defect, not "the package
|
|
377
|
+
// is missing", so naming the package would misattribute it.
|
|
378
|
+
if (specifier === null || !isBarePackageRoot(specifier))
|
|
379
|
+
return undefined;
|
|
380
|
+
const root = resolveRootSafely(resolvePackageRoot);
|
|
381
|
+
if (!root || !isUnderNodeModules(requiringFile, root))
|
|
382
|
+
return undefined;
|
|
383
|
+
// Suppressed (the requirer declares it) → printed-and-skipped, never captured.
|
|
384
|
+
if (incompleteInstallMessage(err, resolvePackageRoot, readFileFrom(fileSystem)) !== null) {
|
|
385
|
+
return undefined;
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
incomplete_install: {
|
|
389
|
+
missingPackage: boundedDiagnosticValue(specifier, PACKAGE_NAME_DIAGNOSTIC_BYTES),
|
|
390
|
+
requiringPackage: boundedDiagnosticValue(requiringPackage(requiringFile)?.name ?? "<unresolved>", PACKAGE_NAME_DIAGNOSTIC_BYTES),
|
|
391
|
+
requirerDeclaresMissing: false,
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* When an incomplete-install failure reaches the capture path WITHOUT being
|
|
397
|
+
* suppressed, return a bounded `contexts.incomplete_install` block so the next
|
|
398
|
+
* occurrence carries the evidence this one lacked; otherwise return undefined
|
|
399
|
+
* (bare capture). Two enriched shapes:
|
|
400
|
+
* - a third-party BARE-specifier miss the requirer did not declare (HQ-CLI-1Q)
|
|
401
|
+
* → { missingPackage, requiringPackage, requirerDeclaresMissing:false };
|
|
402
|
+
* - an esm-loader ENOENT whose path did not survive delivery (HQ-CLI-1M) — the
|
|
403
|
+
* shape the delivered payload arrived in, where neither the exception value
|
|
404
|
+
* nor node_system_error carried a `path` → { packageRoot, packageJsonExists,
|
|
405
|
+
* nodeModulesExists, esmLoaderFrame, code }.
|
|
406
|
+
* Built with the byte-capped, scrubber-safe discipline of
|
|
407
|
+
* package-root-diagnostics.ts — never a caller argv, query, or user-minted
|
|
408
|
+
* value. Never throws. main.ts attaches this on the generic capture path
|
|
409
|
+
* exactly as qmdSpawnFailureCaptureContext already does.
|
|
213
410
|
*/
|
|
214
411
|
export function incompleteInstallCaptureContext(err, resolvePackageRoot = safePackageRoot, fileSystem = fs) {
|
|
412
|
+
// (A) HQ-CLI-1Q — the not-suppressed third-party bare miss, made attributable.
|
|
413
|
+
const bare = bareSpecifierCaptureContext(err, resolvePackageRoot, fileSystem);
|
|
414
|
+
if (bare)
|
|
415
|
+
return bare;
|
|
416
|
+
// (B) HQ-CLI-1M — the path-less esm-loader ENOENT, unchanged.
|
|
215
417
|
if (!isEsmLoaderEnoent(err))
|
|
216
418
|
return undefined;
|
|
217
419
|
// Only instrument what we did NOT already confidently suppress: a path under
|
|
218
420
|
// node_modules is classified and printed above, never captured.
|
|
219
|
-
if (incompleteInstallMessage(err, resolvePackageRoot) !== null)
|
|
421
|
+
if (incompleteInstallMessage(err, resolvePackageRoot, readFileFrom(fileSystem)) !== null) {
|
|
220
422
|
return undefined;
|
|
423
|
+
}
|
|
221
424
|
const record = err;
|
|
222
425
|
const root = resolveRootSafely(resolvePackageRoot);
|
|
223
426
|
const code = typeof record.code === "string" ? record.code : "";
|
|
@@ -53,11 +53,21 @@ const TRANSPORT_CODE_REASONS = {
|
|
|
53
53
|
* Recognized undici error names, for builds where the `code` property is
|
|
54
54
|
* absent but the typed error still identifies itself (the shape Sentry
|
|
55
55
|
* recorded for HQ-CLI-G was `ConnectTimeoutError`).
|
|
56
|
+
*
|
|
57
|
+
* `TimeoutError` is the DOMException `AbortSignal.timeout(...)` rejects a
|
|
58
|
+
* bounded `fetch()` with when the peer accepts the connection but never answers
|
|
59
|
+
* — Sentry HQ-CLI-1P (issue 7716090679). It is the one transport failure here
|
|
60
|
+
* that identifies itself ONLY by name: its legacy DOMException `code` is the
|
|
61
|
+
* NUMBER 23, so the string-typed `readStringProperty(node, "code")` above skips
|
|
62
|
+
* it, and it carries no `cause` to walk, leaving just the name. `AbortError` is
|
|
63
|
+
* deliberately NOT listed — a caller-initiated abort is not a transport fault
|
|
64
|
+
* and must stay reportable.
|
|
56
65
|
*/
|
|
57
66
|
const TRANSPORT_NAME_REASONS = {
|
|
58
67
|
ConnectTimeoutError: "the connection timed out",
|
|
59
68
|
HeadersTimeoutError: "the server did not respond in time",
|
|
60
69
|
SocketError: "the connection closed unexpectedly",
|
|
70
|
+
TimeoutError: "the server did not respond in time",
|
|
61
71
|
};
|
|
62
72
|
/**
|
|
63
73
|
* Depth cap for `cause` traversal. A self-referential or mutually-referential
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -19,6 +19,24 @@ import { planGateErrorFromResponse } from './plan-gate-error.js';
|
|
|
19
19
|
* whole-process watchdog.
|
|
20
20
|
*/
|
|
21
21
|
const IDENTITY_LOOKUP_TIMEOUT_MS = 15_000;
|
|
22
|
+
/**
|
|
23
|
+
* The identity-lookup abort bound, in milliseconds. Defaults to the shipped
|
|
24
|
+
* IDENTITY_LOOKUP_TIMEOUT_MS (15s). An optional HQ_IDENTITY_LOOKUP_TIMEOUT_MS
|
|
25
|
+
* override (clamped to a sane 100..120_000 ms window; absent or non-numeric
|
|
26
|
+
* input keeps the 15s default) exists purely so the artifact E2E can force the
|
|
27
|
+
* abort in well under a second instead of stalling a spawned CLI for the full
|
|
28
|
+
* 15s per case. It does NOT change shipped behaviour: with the var unset every
|
|
29
|
+
* call uses 15s exactly as before.
|
|
30
|
+
*/
|
|
31
|
+
function identityLookupTimeoutMs() {
|
|
32
|
+
const raw = process.env.HQ_IDENTITY_LOOKUP_TIMEOUT_MS?.trim();
|
|
33
|
+
if (!raw)
|
|
34
|
+
return IDENTITY_LOOKUP_TIMEOUT_MS;
|
|
35
|
+
const parsed = Number(raw);
|
|
36
|
+
if (!Number.isFinite(parsed))
|
|
37
|
+
return IDENTITY_LOOKUP_TIMEOUT_MS;
|
|
38
|
+
return Math.min(120_000, Math.max(100, Math.trunc(parsed)));
|
|
39
|
+
}
|
|
22
40
|
/**
|
|
23
41
|
* Best-effort peek of a 2xx JSON body for plan-limit status (US-016).
|
|
24
42
|
*
|
|
@@ -279,7 +297,7 @@ async function resolveCompanyByUid(token, uid) {
|
|
|
279
297
|
const res = await vaultApiFetch({
|
|
280
298
|
token,
|
|
281
299
|
path: `/entity/${encodeURIComponent(uid)}`,
|
|
282
|
-
signal: AbortSignal.timeout(
|
|
300
|
+
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
283
301
|
});
|
|
284
302
|
if (!res.ok) {
|
|
285
303
|
raiseIfUnauthorized(res);
|
|
@@ -312,7 +330,7 @@ async function resolveSlugInCallerNamespace(token, slug) {
|
|
|
312
330
|
token,
|
|
313
331
|
path: '/entity/check-slug/me',
|
|
314
332
|
query: { type: 'company', slug },
|
|
315
|
-
signal: AbortSignal.timeout(
|
|
333
|
+
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
316
334
|
});
|
|
317
335
|
if (!res.ok) {
|
|
318
336
|
raiseIfUnauthorized(res);
|
|
@@ -336,7 +354,28 @@ async function resolveCompanyUid(token, ref) {
|
|
|
336
354
|
// PRIMARY PATH — caller-scoped slug resolution. Resolves the slug to the
|
|
337
355
|
// caller's OWN company (unique within their namespace by the invariant
|
|
338
356
|
// above), making a stranger's same-slug company invisible.
|
|
339
|
-
|
|
357
|
+
//
|
|
358
|
+
// A non-2xx already degrades to the global fallback below (via `return null`).
|
|
359
|
+
// A TRANSPORT failure must degrade the SAME way, not abort the command:
|
|
360
|
+
// `resolveSlugInCallerNamespace`'s fetch is bounded by AbortSignal.timeout, so
|
|
361
|
+
// a vault gateway that accepts the connection but never answers rejects with a
|
|
362
|
+
// DOMException named `TimeoutError`. Before this catch, that rejection threw
|
|
363
|
+
// straight out of `resolveCompanyUid`, skipped the global by-slug lookup, and
|
|
364
|
+
// — being unclassified by main.ts — filed a Sentry crash report instead of an
|
|
365
|
+
// actionable connectivity message (Sentry HQ-CLI-1P / issue 7716090679).
|
|
366
|
+
// Degrade ONLY on a recognized transport failure; re-throw everything else
|
|
367
|
+
// unchanged, so a genuine hq-cli defect stays fatal and the 401 AuthError that
|
|
368
|
+
// `raiseIfUnauthorized` throws still short-circuits before any fallback.
|
|
369
|
+
let mine;
|
|
370
|
+
try {
|
|
371
|
+
mine = await resolveSlugInCallerNamespace(token, ref);
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
if (networkTransportErrorCode(err) === null) {
|
|
375
|
+
throw err;
|
|
376
|
+
}
|
|
377
|
+
mine = null;
|
|
378
|
+
}
|
|
340
379
|
if (mine) {
|
|
341
380
|
return mine;
|
|
342
381
|
}
|
|
@@ -347,7 +386,7 @@ async function resolveCompanyUid(token, ref) {
|
|
|
347
386
|
const res = await vaultApiFetch({
|
|
348
387
|
token,
|
|
349
388
|
path: `/entity/by-slug/company/${encodeURIComponent(ref)}`,
|
|
350
|
-
signal: AbortSignal.timeout(
|
|
389
|
+
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
351
390
|
});
|
|
352
391
|
if (!res.ok) {
|
|
353
392
|
raiseIfUnauthorized(res);
|
|
@@ -374,7 +413,7 @@ async function resolveCompanyFromMemberships(token) {
|
|
|
374
413
|
const res = await vaultApiFetch({
|
|
375
414
|
token,
|
|
376
415
|
path: '/membership/me',
|
|
377
|
-
signal: AbortSignal.timeout(
|
|
416
|
+
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
378
417
|
});
|
|
379
418
|
if (!res.ok) {
|
|
380
419
|
throw new Error("Failed to fetch memberships — run `hq login` and try again");
|
|
@@ -421,7 +460,7 @@ export async function resolveCallerPersonUid(token, baseUrl) {
|
|
|
421
460
|
token,
|
|
422
461
|
path: '/entity/by-type/person',
|
|
423
462
|
baseUrl,
|
|
424
|
-
signal: AbortSignal.timeout(
|
|
463
|
+
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
425
464
|
});
|
|
426
465
|
if (!res.ok) {
|
|
427
466
|
throw new Error("Failed to fetch person entity — run `hq login` and try again");
|