@indigoai-us/hq-cli 5.84.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 CHANGED
@@ -2,6 +2,27 @@
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
+
16
+ ## [5.85.0]
17
+
18
+ ### Changed
19
+
20
+ - Checkpoint Stop-gate eligibility is now runtime-specific: Claude Code is
21
+ enabled for every HQ account, Codex is enabled only for the designated
22
+ operator account (including delegated identities), and other Codex accounts
23
+ and runtimes remain disabled. Runtime-specific verdict caches prevent one
24
+ host's eligibility from leaking into another. (#295)
25
+
5
26
  ## [5.84.0]
6
27
 
7
28
  ### Added
@@ -307,12 +307,23 @@ function writeStamps(liveRoot, sessionId) {
307
307
  fs.writeFileSync(stampPath, timestamp);
308
308
  return stampPaths;
309
309
  }
310
- function gateEligibility() {
310
+ const CODEX_CHECKPOINT_ACCOUNT = "hassaan@getindigo.ai";
311
+ function checkpointGateRuntime() {
312
+ const runtime = (process.env.HQ_CHECKPOINT_RUNTIME ?? "claude").trim().toLowerCase();
313
+ if (runtime === "claude" || runtime === "codex")
314
+ return runtime;
315
+ return "other";
316
+ }
317
+ function gateEligibility(runtime) {
311
318
  const forced = process.env.HQ_CHECKPOINT_GATE;
312
319
  if (forced === "0")
313
320
  return false;
314
321
  if (forced === "1")
315
322
  return true;
323
+ if (runtime === "claude")
324
+ return true;
325
+ if (runtime !== "codex")
326
+ return false;
316
327
  try {
317
328
  const tokenPath = path.join(homedir(), ".hq", "cognito-tokens.json");
318
329
  const tokens = JSON.parse(fs.readFileSync(tokenPath, "utf8"));
@@ -320,19 +331,7 @@ function gateEligibility() {
320
331
  return false;
321
332
  const claims = peekIdToken(tokens.idToken);
322
333
  const candidateEmails = [claims.email, claims["custom:delegatedEmail"]];
323
- const configured = (process.env.HQ_CHECKPOINT_GATE_DOMAINS ?? "")
324
- .split(",")
325
- .map((candidate) => candidate.trim().toLowerCase())
326
- .filter(Boolean);
327
- const hasEligibleDomain = (candidate) => {
328
- if (typeof candidate !== "string")
329
- return false;
330
- const domain = candidate.split("@").at(-1)?.toLowerCase();
331
- if (!domain)
332
- return false;
333
- return domain === "getindigo.ai" || configured.includes(domain);
334
- };
335
- return candidateEmails.some(hasEligibleDomain);
334
+ return candidateEmails.some((candidate) => typeof candidate === "string" && candidate.toLowerCase() === CODEX_CHECKPOINT_ACCOUNT);
336
335
  }
337
336
  catch {
338
337
  return false;
@@ -341,8 +340,9 @@ function gateEligibility() {
341
340
  function writeGateVerdict(liveRoot) {
342
341
  const stateDir = path.join(liveRoot, "workspace", "orchestrator", "hook-state");
343
342
  fs.mkdirSync(stateDir, { recursive: true });
344
- const eligible = gateEligibility();
345
- fs.writeFileSync(path.join(stateDir, "checkpoint-gate-eligible"), eligible ? "1" : "0");
343
+ const runtime = checkpointGateRuntime();
344
+ const eligible = gateEligibility(runtime);
345
+ fs.writeFileSync(path.join(stateDir, `checkpoint-gate-eligible-${runtime}`), eligible ? "1" : "0");
346
346
  printResult(eligible ? "eligible" : "ineligible");
347
347
  }
348
348
  function backendOnPath(name) {
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
- // A broken pipe (EPIPE) means the reader of `hq`'s output closed it early
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
@@ -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
  *
@@ -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 fs.realpathSync(p);
66
+ return fileSystem.realpathSync(p);
66
67
  }
67
68
  catch {
68
69
  return path.resolve(p);
69
70
  }
70
71
  }
71
- let cachedPackageRoot;
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
- * This package's installed root the directory holding its `package.json`.
74
- *
75
- * Walks up from the compiled module (`dist/utils/hq-roots.js`) until it finds a
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 packageRoot() {
80
- if (cachedPackageRoot === null) {
81
- throw new Error(`Could not locate the ${CLI_PACKAGE_NAME} package root from ${import.meta.url}.`);
82
- }
83
- if (cachedPackageRoot !== undefined)
84
- return cachedPackageRoot;
85
- let dir = path.dirname(fileURLToPath(import.meta.url));
86
- for (;;) {
87
- const manifest = path.join(dir, "package.json");
88
- if (fs.existsSync(manifest)) {
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(fs.readFileSync(manifest, "utf8"));
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
- // A malformed package.json on the way up is not our package; keep going.
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
- const parent = path.dirname(dir);
101
- if (parent === dir)
102
- break;
103
- dir = parent;
104
- }
105
- cachedPackageRoot = null;
106
- throw new Error(`Could not locate the ${CLI_PACKAGE_NAME} package root from ${import.meta.url}.`);
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
- cachedPackageRoot = undefined;
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.84.0",
3
+ "version": "5.85.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {