@indigoai-us/hq-cli 5.85.0 → 5.85.1
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 +11 -0
- package/dist/main.d.ts +8 -0
- package/dist/main.js +88 -65
- package/dist/utils/hq-roots.d.ts +16 -0
- package/dist/utils/hq-roots.js +112 -30
- package/dist/utils/package-root-diagnostics.d.ts +47 -0
- package/dist/utils/package-root-diagnostics.js +120 -0
- package/dist/utils/unexpected-cli-error.d.ts +8 -0
- package/dist/utils/unexpected-cli-error.js +24 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.85.1]
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Integration/provider failures now print a bounded, credential-scrubbed
|
|
10
|
+
diagnostic to stderr while remaining captured in Sentry, so failed remote
|
|
11
|
+
MCP calls no longer exit silently. (#298)
|
|
12
|
+
- Package-root resolution now recovers safely from transient manifest loss and
|
|
13
|
+
reports bounded diagnostics when a packaged install is genuinely broken.
|
|
14
|
+
(#297)
|
|
15
|
+
|
|
5
16
|
## [5.85.0]
|
|
6
17
|
|
|
7
18
|
### Changed
|
package/dist/main.d.ts
CHANGED
|
@@ -3,5 +3,13 @@
|
|
|
3
3
|
* HQ CLI - Module management, package management, and cloud sync for HQ
|
|
4
4
|
*/
|
|
5
5
|
import "./node-preflight.js";
|
|
6
|
+
import { Sentry } from "./sentry.js";
|
|
6
7
|
export declare function runCli(): Promise<void>;
|
|
8
|
+
export type TopLevelErrorDependencies = {
|
|
9
|
+
sentry: Pick<typeof Sentry, "captureException">;
|
|
10
|
+
stderr: Pick<typeof process.stderr, "write">;
|
|
11
|
+
setExitCode: (code: number) => void;
|
|
12
|
+
};
|
|
13
|
+
/** Classify a top-level failure without making the CLI process boundary opaque to tests. */
|
|
14
|
+
export declare function handleTopLevelError(err: unknown, deps?: TopLevelErrorDependencies): void;
|
|
7
15
|
//# sourceMappingURL=main.d.ts.map
|
package/dist/main.js
CHANGED
|
@@ -70,6 +70,8 @@ import { CLI_VERSION } from "./cli-version.js";
|
|
|
70
70
|
import { emitCliSessionStarted } from "./utils/cli-telemetry.js";
|
|
71
71
|
import { settleWithin } from "./utils/settle-with-timeout.js";
|
|
72
72
|
import { emitPlanLimitNag } from "./lib/plan-limit-nag.js";
|
|
73
|
+
import { isPackageRootResolutionError, packageRootCaptureContext, } from "./utils/package-root-diagnostics.js";
|
|
74
|
+
import { unexpectedCliErrorMessage } from "./utils/unexpected-cli-error.js";
|
|
73
75
|
/** Hard upper bound for non-user-visible release-health finalization. */
|
|
74
76
|
const RELEASE_HEALTH_SETTLE_TIMEOUT_MS = 3_000;
|
|
75
77
|
// Swallow EPIPE when a downstream reader (e.g. `source <(…)`, `| head`) closes
|
|
@@ -247,71 +249,7 @@ export async function runCli() {
|
|
|
247
249
|
await program.parseAsync();
|
|
248
250
|
}
|
|
249
251
|
catch (err) {
|
|
250
|
-
|
|
251
|
-
// (`hq … | head`, `source <(hq …)`, a parent that exited). That is normal
|
|
252
|
-
// Unix behavior with no user-facing degradation — exit cleanly (0) and
|
|
253
|
-
// skip Sentry capture instead of shipping a fatal (HQ-6B). A synchronous
|
|
254
|
-
// `write EPIPE` thrown out of console.log lands here rather than on the
|
|
255
|
-
// stream 'error' listener above.
|
|
256
|
-
if (isEpipe(err)) {
|
|
257
|
-
process.exitCode = 0;
|
|
258
|
-
}
|
|
259
|
-
else if (isInterceptedProcessExit(err)) {
|
|
260
|
-
// A security/audit FUZZ harness replaced `process.exit` with a throw so it
|
|
261
|
-
// can keep exercising the binary. Commander calling `process.exit` for
|
|
262
|
-
// normal CLI control flow (e.g. an unknown command → exit 1) then surfaces
|
|
263
|
-
// here as that synthetic marker. It is a test-harness artifact, NOT an
|
|
264
|
-
// hq-cli defect — a real user's `process.exit` just exits, so nothing is
|
|
265
|
-
// thrown or captured. Skip Sentry capture (no signal, no user-facing
|
|
266
|
-
// degradation) and preserve the intended non-zero exit (HQ-CLI-3).
|
|
267
|
-
process.exitCode = 1;
|
|
268
|
-
}
|
|
269
|
-
else if (isCompanySelectionError(err)) {
|
|
270
|
-
// The user has multiple (or zero) active company memberships and ran a
|
|
271
|
-
// command that needs exactly one without `--company`, or a `--company`
|
|
272
|
-
// slug collided across companies. That's an expected, user-actionable
|
|
273
|
-
// disambiguation prompt — the message already tells them exactly how to
|
|
274
|
-
// proceed (re-run with `--company <slug-or-uid>`) — not an hq-cli defect.
|
|
275
|
-
// The CLI can't pick a company for them. Print the actionable message and
|
|
276
|
-
// exit non-zero, but skip Sentry capture so a normal "pick a company"
|
|
277
|
-
// prompt doesn't flood the tracker with unfixable "crashes" (HQ-CLI-7).
|
|
278
|
-
process.stderr.write(`hq: ${err.message}\n`);
|
|
279
|
-
process.exitCode = 1;
|
|
280
|
-
}
|
|
281
|
-
else if (isAuthError(err)) {
|
|
282
|
-
// HQ-CLI-8: the vault API returned 401 Unauthorized — the caller's HQ
|
|
283
|
-
// session is expired or missing. That's an expected auth state the user
|
|
284
|
-
// fixes with `hq login`, not an hq-cli defect. Print the actionable
|
|
285
|
-
// message and skip Sentry so an expired login doesn't flood the tracker
|
|
286
|
-
// with identical, unfixable "crashes".
|
|
287
|
-
process.stderr.write(`hq: ${err.message}\n`);
|
|
288
|
-
process.exitCode = 1;
|
|
289
|
-
}
|
|
290
|
-
else if (isExpectedUserError(err)) {
|
|
291
|
-
// HQ-CLI-6: a user-facing, client-caused error (a non-owner running
|
|
292
|
-
// `hq integrations approve`, a stale queueId, a bad --args, an unknown
|
|
293
|
-
// connection) is the caller's request/state/permission, not an hq-cli
|
|
294
|
-
// defect. Print the actionable message and skip Sentry so a correctly-
|
|
295
|
-
// denied 4xx doesn't flood the tracker with identical, unfixable crash
|
|
296
|
-
// reports. Genuine server (5xx) / unknown failures still capture below.
|
|
297
|
-
process.stderr.write(`hq: ${err.message}\n`);
|
|
298
|
-
process.exitCode = 1;
|
|
299
|
-
}
|
|
300
|
-
else {
|
|
301
|
-
// A full disk / exhausted quota / read-only filesystem is the user's
|
|
302
|
-
// machine, not an HQ code defect. Surface a clear, actionable message and
|
|
303
|
-
// skip Sentry capture so one full disk doesn't flood the tracker with
|
|
304
|
-
// identical, unfixable crash reports (HQ-CLI-2). Genuine errors still go
|
|
305
|
-
// to Sentry and still exit 1.
|
|
306
|
-
const envMsg = environmentalFsErrorMessage(err);
|
|
307
|
-
if (envMsg) {
|
|
308
|
-
process.stderr.write(`hq: ${envMsg}\n`);
|
|
309
|
-
}
|
|
310
|
-
else {
|
|
311
|
-
Sentry.captureException(err);
|
|
312
|
-
}
|
|
313
|
-
process.exitCode = 1;
|
|
314
|
-
}
|
|
252
|
+
handleTopLevelError(err);
|
|
315
253
|
}
|
|
316
254
|
finally {
|
|
317
255
|
// Plan-limit nag (US-016): stderr-only, never throws, never touches
|
|
@@ -326,4 +264,89 @@ export async function runCli() {
|
|
|
326
264
|
await settleWithin([refreshVersionCache(), Sentry.flush(2000)], RELEASE_HEALTH_SETTLE_TIMEOUT_MS);
|
|
327
265
|
}
|
|
328
266
|
}
|
|
267
|
+
const defaultTopLevelErrorDependencies = {
|
|
268
|
+
sentry: Sentry,
|
|
269
|
+
stderr: process.stderr,
|
|
270
|
+
setExitCode: (code) => {
|
|
271
|
+
process.exitCode = code;
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
/** Classify a top-level failure without making the CLI process boundary opaque to tests. */
|
|
275
|
+
export function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies) {
|
|
276
|
+
// A broken pipe (EPIPE) means the reader of `hq`'s output closed it early
|
|
277
|
+
// (`hq … | head`, `source <(hq …)`, a parent that exited). That is normal
|
|
278
|
+
// Unix behavior with no user-facing degradation — exit cleanly (0) and
|
|
279
|
+
// skip Sentry capture instead of shipping a fatal (HQ-6B). A synchronous
|
|
280
|
+
// `write EPIPE` thrown out of console.log lands here rather than on the
|
|
281
|
+
// stream 'error' listener above.
|
|
282
|
+
if (isEpipe(err)) {
|
|
283
|
+
deps.setExitCode(0);
|
|
284
|
+
}
|
|
285
|
+
else if (isInterceptedProcessExit(err)) {
|
|
286
|
+
// A security/audit FUZZ harness replaced `process.exit` with a throw so it
|
|
287
|
+
// can keep exercising the binary. Commander calling `process.exit` for
|
|
288
|
+
// normal CLI control flow (e.g. an unknown command → exit 1) then surfaces
|
|
289
|
+
// here as that synthetic marker. It is a test-harness artifact, NOT an
|
|
290
|
+
// hq-cli defect — a real user's `process.exit` just exits, so nothing is
|
|
291
|
+
// thrown or captured. Skip Sentry capture (no signal, no user-facing
|
|
292
|
+
// degradation) and preserve the intended non-zero exit (HQ-CLI-3).
|
|
293
|
+
deps.setExitCode(1);
|
|
294
|
+
}
|
|
295
|
+
else if (isCompanySelectionError(err)) {
|
|
296
|
+
// The user has multiple (or zero) active company memberships and ran a
|
|
297
|
+
// command that needs exactly one without `--company`, or a `--company`
|
|
298
|
+
// slug collided across companies. That's an expected, user-actionable
|
|
299
|
+
// disambiguation prompt — the message already tells them exactly how to
|
|
300
|
+
// proceed (re-run with `--company <slug-or-uid>`) — not an hq-cli defect.
|
|
301
|
+
// The CLI can't pick a company for them. Print the actionable message and
|
|
302
|
+
// exit non-zero, but skip Sentry capture so a normal "pick a company"
|
|
303
|
+
// prompt doesn't flood the tracker with unfixable "crashes" (HQ-CLI-7).
|
|
304
|
+
deps.stderr.write(`hq: ${err.message}\n`);
|
|
305
|
+
deps.setExitCode(1);
|
|
306
|
+
}
|
|
307
|
+
else if (isAuthError(err)) {
|
|
308
|
+
// HQ-CLI-8: the vault API returned 401 Unauthorized — the caller's HQ
|
|
309
|
+
// session is expired or missing. That's an expected auth state the user
|
|
310
|
+
// fixes with `hq login`, not an hq-cli defect. Print the actionable
|
|
311
|
+
// message and skip Sentry so an expired login doesn't flood the tracker
|
|
312
|
+
// with identical, unfixable "crashes".
|
|
313
|
+
deps.stderr.write(`hq: ${err.message}\n`);
|
|
314
|
+
deps.setExitCode(1);
|
|
315
|
+
}
|
|
316
|
+
else if (isExpectedUserError(err)) {
|
|
317
|
+
// HQ-CLI-6: a user-facing, client-caused error (a non-owner running
|
|
318
|
+
// `hq integrations approve`, a stale queueId, a bad --args, an unknown
|
|
319
|
+
// connection) is the caller's request/state/permission, not an hq-cli
|
|
320
|
+
// defect. Print the actionable message and skip Sentry so a correctly-
|
|
321
|
+
// denied 4xx doesn't flood the tracker with identical, unfixable crash
|
|
322
|
+
// reports. Genuine server (5xx) / unknown failures still capture below.
|
|
323
|
+
deps.stderr.write(`hq: ${err.message}\n`);
|
|
324
|
+
deps.setExitCode(1);
|
|
325
|
+
}
|
|
326
|
+
else if (isPackageRootResolutionError(err)) {
|
|
327
|
+
deps.stderr.write(`hq: ${err.message}\n`);
|
|
328
|
+
deps.sentry.captureException(err, {
|
|
329
|
+
contexts: packageRootCaptureContext(err),
|
|
330
|
+
});
|
|
331
|
+
deps.setExitCode(1);
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
// A full disk / exhausted quota / read-only filesystem is the user's
|
|
335
|
+
// machine, not an HQ code defect. Surface a clear, actionable message and
|
|
336
|
+
// skip Sentry capture so one full disk doesn't flood the tracker with
|
|
337
|
+
// identical, unfixable crash reports (HQ-CLI-2). Genuine errors still go
|
|
338
|
+
// to Sentry and still exit 1.
|
|
339
|
+
const envMsg = environmentalFsErrorMessage(err);
|
|
340
|
+
if (envMsg) {
|
|
341
|
+
deps.stderr.write(`hq: ${envMsg}\n`);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
deps.sentry.captureException(err);
|
|
345
|
+
const userMessage = unexpectedCliErrorMessage(err);
|
|
346
|
+
if (userMessage)
|
|
347
|
+
deps.stderr.write(`hq: ${userMessage}\n`);
|
|
348
|
+
}
|
|
349
|
+
deps.setExitCode(1);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
329
352
|
//# sourceMappingURL=main.js.map
|
package/dist/utils/hq-roots.d.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* `import.meta.url`. A relocated script asking "where is HQ" must get the live
|
|
25
25
|
* tree, not the package it happens to be bundled in.
|
|
26
26
|
*/
|
|
27
|
+
import * as fs from "fs";
|
|
27
28
|
/** Where bundled scaffold assets live, relative to the package root. */
|
|
28
29
|
export declare const BUNDLED_ASSET_DIR: string;
|
|
29
30
|
/**
|
|
@@ -35,6 +36,21 @@ export declare const BUNDLED_ASSET_DIR: string;
|
|
|
35
36
|
* checkout without a personal overlay, and a personal-only overlay.
|
|
36
37
|
*/
|
|
37
38
|
export declare function isHqRoot(dir: string): boolean;
|
|
39
|
+
export type PackageRootFs = Pick<typeof fs, "existsSync" | "readFileSync" | "realpathSync" | "statSync">;
|
|
40
|
+
export type PackageRootResolverOptions = {
|
|
41
|
+
/** Directory containing the currently-running compiled module. */
|
|
42
|
+
moduleDir: string;
|
|
43
|
+
/** Full module path used only in safe diagnostic output. */
|
|
44
|
+
modulePath?: string;
|
|
45
|
+
/** Injectable filesystem seam for resolver tests. */
|
|
46
|
+
fs?: PackageRootFs;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Make a resolver for this package's root. It caches only a successful lookup:
|
|
50
|
+
* a concurrently replaced manifest must be retried by the next command, not
|
|
51
|
+
* converted into a permanent process failure.
|
|
52
|
+
*/
|
|
53
|
+
export declare function createPackageRootResolver(options: PackageRootResolverOptions): () => string;
|
|
38
54
|
/**
|
|
39
55
|
* This package's installed root — the directory holding its `package.json`.
|
|
40
56
|
*
|
package/dist/utils/hq-roots.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import * as fs from "fs";
|
|
28
28
|
import * as path from "path";
|
|
29
29
|
import { fileURLToPath } from "url";
|
|
30
|
+
import { PackageRootResolutionError, } from "./package-root-diagnostics.js";
|
|
30
31
|
/** This package's name, used to identify its root when walking upward. */
|
|
31
32
|
const CLI_PACKAGE_NAME = "@indigoai-us/hq-cli";
|
|
32
33
|
/**
|
|
@@ -60,54 +61,135 @@ export function isHqRoot(dir) {
|
|
|
60
61
|
fs.existsSync(path.join(dir, "core")) ||
|
|
61
62
|
fs.existsSync(path.join(dir, "personal"))));
|
|
62
63
|
}
|
|
63
|
-
function realpathOrResolve(p) {
|
|
64
|
+
function realpathOrResolve(p, fileSystem = fs) {
|
|
64
65
|
try {
|
|
65
|
-
return
|
|
66
|
+
return fileSystem.realpathSync(p);
|
|
66
67
|
}
|
|
67
68
|
catch {
|
|
68
69
|
return path.resolve(p);
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
|
-
|
|
72
|
+
function errorCode(err) {
|
|
73
|
+
if (err && typeof err === "object" && "code" in err) {
|
|
74
|
+
const code = err.code;
|
|
75
|
+
return typeof code === "string" ? code : undefined;
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
function failureOutcome(code) {
|
|
80
|
+
if (code === "ENOENT")
|
|
81
|
+
return "no-manifest";
|
|
82
|
+
if (code === "EACCES")
|
|
83
|
+
return "eacces";
|
|
84
|
+
if (code === "EISDIR")
|
|
85
|
+
return "eisdir";
|
|
86
|
+
return "read-error";
|
|
87
|
+
}
|
|
72
88
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* `package.json` whose name matches, rather than hard-coding `..` hops, so the
|
|
77
|
-
* answer survives a change in output layout.
|
|
89
|
+
* Make a resolver for this package's root. It caches only a successful lookup:
|
|
90
|
+
* a concurrently replaced manifest must be retried by the next command, not
|
|
91
|
+
* converted into a permanent process failure.
|
|
78
92
|
*/
|
|
79
|
-
export function
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
93
|
+
export function createPackageRootResolver(options) {
|
|
94
|
+
const fileSystem = options.fs ?? fs;
|
|
95
|
+
const modulePath = options.modulePath ?? options.moduleDir;
|
|
96
|
+
let cachedPackageRoot;
|
|
97
|
+
return () => {
|
|
98
|
+
if (cachedPackageRoot !== undefined)
|
|
99
|
+
return cachedPackageRoot;
|
|
100
|
+
const visited = [];
|
|
101
|
+
let dir = options.moduleDir;
|
|
102
|
+
for (;;) {
|
|
103
|
+
const manifest = path.join(dir, "package.json");
|
|
89
104
|
try {
|
|
90
|
-
const parsed = JSON.parse(
|
|
105
|
+
const parsed = JSON.parse(fileSystem.readFileSync(manifest, "utf8"));
|
|
91
106
|
if (parsed.name === CLI_PACKAGE_NAME) {
|
|
92
|
-
cachedPackageRoot = realpathOrResolve(dir);
|
|
107
|
+
cachedPackageRoot = realpathOrResolve(dir, fileSystem);
|
|
93
108
|
return cachedPackageRoot;
|
|
94
109
|
}
|
|
110
|
+
visited.push({
|
|
111
|
+
directory: dir,
|
|
112
|
+
outcome: "name-mismatch",
|
|
113
|
+
manifestName: String(parsed.name),
|
|
114
|
+
});
|
|
95
115
|
}
|
|
96
|
-
catch {
|
|
97
|
-
|
|
116
|
+
catch (err) {
|
|
117
|
+
const code = errorCode(err);
|
|
118
|
+
if (code) {
|
|
119
|
+
visited.push({ directory: dir, outcome: failureOutcome(code), code });
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
visited.push({ directory: dir, outcome: "parse-error" });
|
|
123
|
+
}
|
|
98
124
|
}
|
|
125
|
+
const parent = path.dirname(dir);
|
|
126
|
+
if (parent === dir)
|
|
127
|
+
break;
|
|
128
|
+
dir = parent;
|
|
99
129
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
130
|
+
let fallbackRejectedBecause = "no dist-owner ancestor";
|
|
131
|
+
try {
|
|
132
|
+
const resolvedModuleDir = fileSystem.realpathSync(options.moduleDir);
|
|
133
|
+
let candidate = resolvedModuleDir;
|
|
134
|
+
for (;;) {
|
|
135
|
+
const relativeModulePath = path.relative(candidate, resolvedModuleDir);
|
|
136
|
+
if (relativeModulePath.split(path.sep)[0] === "dist") {
|
|
137
|
+
const assetDir = path.join(candidate, BUNDLED_ASSET_DIR);
|
|
138
|
+
try {
|
|
139
|
+
if (fileSystem.existsSync(assetDir) && fileSystem.statSync(assetDir).isDirectory()) {
|
|
140
|
+
cachedPackageRoot = candidate;
|
|
141
|
+
return cachedPackageRoot;
|
|
142
|
+
}
|
|
143
|
+
fallbackRejectedBecause =
|
|
144
|
+
"deepest dist-owner lacks assets/scaffold";
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
fallbackRejectedBecause =
|
|
148
|
+
`could not inspect deepest dist-owner assets/scaffold (${errorCode(err) ?? "unknown"})`;
|
|
149
|
+
}
|
|
150
|
+
// Never climb to a shallower dist owner after the nearest owner fails.
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
const parent = path.dirname(candidate);
|
|
154
|
+
if (parent === candidate)
|
|
155
|
+
break;
|
|
156
|
+
candidate = parent;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
fallbackRejectedBecause =
|
|
161
|
+
`could not realpath module directory (${errorCode(err) ?? "unknown"})`;
|
|
162
|
+
}
|
|
163
|
+
throw new PackageRootResolutionError({
|
|
164
|
+
modulePath,
|
|
165
|
+
startDir: options.moduleDir,
|
|
166
|
+
strategiesTried: ["manifest-walk", "dist-owner-fallback"],
|
|
167
|
+
visited,
|
|
168
|
+
fallbackRejectedBecause,
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const currentModulePath = fileURLToPath(import.meta.url);
|
|
173
|
+
let defaultPackageRootResolver = createPackageRootResolver({
|
|
174
|
+
moduleDir: path.dirname(currentModulePath),
|
|
175
|
+
modulePath: currentModulePath,
|
|
176
|
+
});
|
|
177
|
+
/**
|
|
178
|
+
* This package's installed root — the directory holding its `package.json`.
|
|
179
|
+
*
|
|
180
|
+
* Walks up from the compiled module (`dist/utils/hq-roots.js`) until it finds a
|
|
181
|
+
* `package.json` whose name matches, rather than hard-coding `..` hops, so the
|
|
182
|
+
* answer survives a change in output layout.
|
|
183
|
+
*/
|
|
184
|
+
export function packageRoot() {
|
|
185
|
+
return defaultPackageRootResolver();
|
|
107
186
|
}
|
|
108
187
|
/** Reset the memoized package root. Tests only. */
|
|
109
188
|
export function __resetPackageRootCache() {
|
|
110
|
-
|
|
189
|
+
defaultPackageRootResolver = createPackageRootResolver({
|
|
190
|
+
moduleDir: path.dirname(currentModulePath),
|
|
191
|
+
modulePath: currentModulePath,
|
|
192
|
+
});
|
|
111
193
|
}
|
|
112
194
|
/**
|
|
113
195
|
* Resolve the user's live HQ installation.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded diagnostics for failures to locate the CLI package root.
|
|
3
|
+
*
|
|
4
|
+
* These values are sent to Sentry, whose general scrubber deliberately keeps
|
|
5
|
+
* filesystem paths intact. Keep this module the single construction boundary
|
|
6
|
+
* so a malformed install cannot turn an error report into an unbounded or
|
|
7
|
+
* multi-line payload.
|
|
8
|
+
*/
|
|
9
|
+
export type PackageRootVisitOutcome = "no-manifest" | "eacces" | "eisdir" | "parse-error" | "name-mismatch" | "read-error" | "omitted";
|
|
10
|
+
export type PackageRootVisit = {
|
|
11
|
+
directory: string;
|
|
12
|
+
outcome: PackageRootVisitOutcome;
|
|
13
|
+
code?: string;
|
|
14
|
+
manifestName?: string;
|
|
15
|
+
};
|
|
16
|
+
export type PackageRootResolutionDiagnostics = {
|
|
17
|
+
modulePath: string;
|
|
18
|
+
startDir: string;
|
|
19
|
+
strategiesTried: string[];
|
|
20
|
+
visited: PackageRootVisit[];
|
|
21
|
+
fallbackRejectedBecause: string;
|
|
22
|
+
};
|
|
23
|
+
type RawDiagnostics = {
|
|
24
|
+
modulePath: string;
|
|
25
|
+
startDir: string;
|
|
26
|
+
strategiesTried: string[];
|
|
27
|
+
visited: PackageRootVisit[];
|
|
28
|
+
fallbackRejectedBecause: string;
|
|
29
|
+
};
|
|
30
|
+
/** Replace every Unicode control character before bounding a diagnostic value. */
|
|
31
|
+
export declare function boundedDiagnosticValue(value: unknown, maxBytes: number): string;
|
|
32
|
+
/** An unmarked packaging fault that should remain visible in Sentry. */
|
|
33
|
+
export declare class PackageRootResolutionError extends Error {
|
|
34
|
+
readonly diagnostics: PackageRootResolutionDiagnostics;
|
|
35
|
+
constructor(raw: RawDiagnostics);
|
|
36
|
+
}
|
|
37
|
+
export declare function isPackageRootResolutionError(err: unknown): err is PackageRootResolutionError;
|
|
38
|
+
/**
|
|
39
|
+
* Produce the exact Sentry contexts shape while enforcing a whole-payload cap.
|
|
40
|
+
* The tail is dropped first because nearest directories contain the useful
|
|
41
|
+
* evidence for a package-root walk.
|
|
42
|
+
*/
|
|
43
|
+
export declare function packageRootCaptureContext(err: PackageRootResolutionError): {
|
|
44
|
+
package_root_resolution: PackageRootResolutionDiagnostics;
|
|
45
|
+
};
|
|
46
|
+
export {};
|
|
47
|
+
//# sourceMappingURL=package-root-diagnostics.d.ts.map
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded diagnostics for failures to locate the CLI package root.
|
|
3
|
+
*
|
|
4
|
+
* These values are sent to Sentry, whose general scrubber deliberately keeps
|
|
5
|
+
* filesystem paths intact. Keep this module the single construction boundary
|
|
6
|
+
* so a malformed install cannot turn an error report into an unbounded or
|
|
7
|
+
* multi-line payload.
|
|
8
|
+
*/
|
|
9
|
+
import { beforeSend } from "../sentry-before-send.js";
|
|
10
|
+
const CONTROL_CHARACTER = /[\p{C}\p{Zl}\p{Zp}]/gu;
|
|
11
|
+
const REPLACEMENT = "?";
|
|
12
|
+
const ELLIPSIS = "…";
|
|
13
|
+
const MODULE_PATH_BYTES = 256;
|
|
14
|
+
const START_DIR_BYTES = 256;
|
|
15
|
+
const DIRECTORY_BYTES = 256;
|
|
16
|
+
const MANIFEST_NAME_BYTES = 64;
|
|
17
|
+
const CODE_BYTES = 32;
|
|
18
|
+
const MESSAGE_BYTES = 2048;
|
|
19
|
+
const CONTEXT_BYTES = 4096;
|
|
20
|
+
const MAX_VISITED = 12;
|
|
21
|
+
/** Replace every Unicode control character before bounding a diagnostic value. */
|
|
22
|
+
export function boundedDiagnosticValue(value, maxBytes) {
|
|
23
|
+
const normalized = String(value).replace(CONTROL_CHARACTER, REPLACEMENT);
|
|
24
|
+
if (Buffer.byteLength(normalized, "utf8") <= maxBytes)
|
|
25
|
+
return normalized;
|
|
26
|
+
const markerBytes = Buffer.byteLength(ELLIPSIS, "utf8");
|
|
27
|
+
let result = "";
|
|
28
|
+
let used = 0;
|
|
29
|
+
for (const character of normalized) {
|
|
30
|
+
const bytes = Buffer.byteLength(character, "utf8");
|
|
31
|
+
if (used + bytes + markerBytes > maxBytes)
|
|
32
|
+
break;
|
|
33
|
+
result += character;
|
|
34
|
+
used += bytes;
|
|
35
|
+
}
|
|
36
|
+
return result + ELLIPSIS;
|
|
37
|
+
}
|
|
38
|
+
function boundedVisit(visit) {
|
|
39
|
+
return {
|
|
40
|
+
directory: boundedDiagnosticValue(visit.directory, DIRECTORY_BYTES),
|
|
41
|
+
outcome: visit.outcome,
|
|
42
|
+
...(visit.code === undefined
|
|
43
|
+
? {}
|
|
44
|
+
: { code: boundedDiagnosticValue(visit.code, CODE_BYTES) }),
|
|
45
|
+
...(visit.manifestName === undefined
|
|
46
|
+
? {}
|
|
47
|
+
: { manifestName: boundedDiagnosticValue(visit.manifestName, MANIFEST_NAME_BYTES) }),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function boundedVisits(visits) {
|
|
51
|
+
if (visits.length <= MAX_VISITED)
|
|
52
|
+
return visits.map(boundedVisit);
|
|
53
|
+
const shown = visits.slice(0, MAX_VISITED - 1).map(boundedVisit);
|
|
54
|
+
shown.push({
|
|
55
|
+
directory: `${visits.length - shown.length} more omitted`,
|
|
56
|
+
outcome: "omitted",
|
|
57
|
+
});
|
|
58
|
+
return shown;
|
|
59
|
+
}
|
|
60
|
+
function normalizeDiagnostics(raw) {
|
|
61
|
+
return {
|
|
62
|
+
modulePath: boundedDiagnosticValue(raw.modulePath, MODULE_PATH_BYTES),
|
|
63
|
+
startDir: boundedDiagnosticValue(raw.startDir, START_DIR_BYTES),
|
|
64
|
+
strategiesTried: raw.strategiesTried.map((strategy) => boundedDiagnosticValue(strategy, CODE_BYTES)),
|
|
65
|
+
visited: boundedVisits(raw.visited),
|
|
66
|
+
fallbackRejectedBecause: boundedDiagnosticValue(raw.fallbackRejectedBecause, DIRECTORY_BYTES),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function messageFor(diagnostics) {
|
|
70
|
+
return boundedDiagnosticValue("Could not locate the @indigoai-us/hq-cli package root from " +
|
|
71
|
+
`${diagnostics.modulePath}; manifest walk exhausted from ${diagnostics.startDir}; ` +
|
|
72
|
+
`dist-owner fallback ${diagnostics.fallbackRejectedBecause}.`, MESSAGE_BYTES);
|
|
73
|
+
}
|
|
74
|
+
/** An unmarked packaging fault that should remain visible in Sentry. */
|
|
75
|
+
export class PackageRootResolutionError extends Error {
|
|
76
|
+
diagnostics;
|
|
77
|
+
constructor(raw) {
|
|
78
|
+
const diagnostics = normalizeDiagnostics(raw);
|
|
79
|
+
super(messageFor(diagnostics));
|
|
80
|
+
this.name = "PackageRootResolutionError";
|
|
81
|
+
this.diagnostics = diagnostics;
|
|
82
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function isPackageRootResolutionError(err) {
|
|
86
|
+
return err instanceof PackageRootResolutionError;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Produce the exact Sentry contexts shape while enforcing a whole-payload cap.
|
|
90
|
+
* The tail is dropped first because nearest directories contain the useful
|
|
91
|
+
* evidence for a package-root walk.
|
|
92
|
+
*/
|
|
93
|
+
export function packageRootCaptureContext(err) {
|
|
94
|
+
const context = {
|
|
95
|
+
...err.diagnostics,
|
|
96
|
+
strategiesTried: [...err.diagnostics.strategiesTried],
|
|
97
|
+
visited: err.diagnostics.visited.map((visit) => ({ ...visit })),
|
|
98
|
+
};
|
|
99
|
+
let sentryContext = scrubbedContext(context);
|
|
100
|
+
while (Buffer.byteLength(JSON.stringify({ package_root_resolution: sentryContext }), "utf8") >
|
|
101
|
+
CONTEXT_BYTES &&
|
|
102
|
+
context.visited.length > 0) {
|
|
103
|
+
context.visited.pop();
|
|
104
|
+
sentryContext = scrubbedContext(context);
|
|
105
|
+
}
|
|
106
|
+
return { package_root_resolution: sentryContext };
|
|
107
|
+
}
|
|
108
|
+
/** Apply the repository's canonical credential scrubber before calculating caps. */
|
|
109
|
+
function scrubbedContext(context) {
|
|
110
|
+
const event = {
|
|
111
|
+
type: undefined,
|
|
112
|
+
contexts: { package_root_resolution: context },
|
|
113
|
+
};
|
|
114
|
+
const scrubbed = beforeSend(event, {});
|
|
115
|
+
if (!scrubbed?.contexts?.package_root_resolution) {
|
|
116
|
+
throw new Error("Package root diagnostics were unexpectedly dropped by Sentry scrubbing.");
|
|
117
|
+
}
|
|
118
|
+
return scrubbed.contexts.package_root_resolution;
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=package-root-diagnostics.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Return a bounded operator-facing diagnostic for unexpected errors whose
|
|
3
|
+
* messages are already part of a user-facing protocol contract. Unknown
|
|
4
|
+
* exceptions remain Sentry-only so local implementation details and secrets
|
|
5
|
+
* are not printed indiscriminately.
|
|
6
|
+
*/
|
|
7
|
+
export declare function unexpectedCliErrorMessage(err: unknown): string | null;
|
|
8
|
+
//# sourceMappingURL=unexpected-cli-error.d.ts.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { IntegrationsCliError } from "../commands/integrations.js";
|
|
2
|
+
/**
|
|
3
|
+
* Return a bounded operator-facing diagnostic for unexpected errors whose
|
|
4
|
+
* messages are already part of a user-facing protocol contract. Unknown
|
|
5
|
+
* exceptions remain Sentry-only so local implementation details and secrets
|
|
6
|
+
* are not printed indiscriminately.
|
|
7
|
+
*/
|
|
8
|
+
export function unexpectedCliErrorMessage(err) {
|
|
9
|
+
if (!(err instanceof IntegrationsCliError) || err.expected)
|
|
10
|
+
return null;
|
|
11
|
+
const message = err.message
|
|
12
|
+
.replace(/-----BEGIN[^-]+PRIVATE KEY-----[\s\S]*?-----END[^-]+PRIVATE KEY-----/g, "[REDACTED]")
|
|
13
|
+
.replace(/\p{Cc}/gu, " ")
|
|
14
|
+
.replace(/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "[REDACTED]")
|
|
15
|
+
.replace(/\b(password|secret|client[_-]?secret|api[_-]?key|(?:id|access|refresh)[_-]?token|token|authorization)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s&"'<>;,}]+)/gi, (_match, label) => `${label}=[REDACTED]`)
|
|
16
|
+
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, "[REDACTED]")
|
|
17
|
+
.replace(/\b(?:hqk_|xox[abprs]-|github_pat_|gh[pousr]_)[A-Za-z0-9_-]+\b/gi, "[REDACTED]")
|
|
18
|
+
.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[REDACTED]")
|
|
19
|
+
.replace(/\s+/g, " ")
|
|
20
|
+
.trim()
|
|
21
|
+
.slice(0, 1_000);
|
|
22
|
+
return message || "Integration request failed";
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=unexpected-cli-error.js.map
|