@indigoai-us/hq-cli 5.103.27 → 5.103.29

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.
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Connection health classification, shared by every surface that has to explain
3
+ * why an integration is not usable.
4
+ *
5
+ * This lived inside the `hq doctor` integrations check until `list`/`show` had
6
+ * to answer the same question. It is deliberately presentation-free and makes
7
+ * no network calls: it classifies stored health signals only, so a caller can
8
+ * render a finding without deciding what the underlying codes mean. Keeping one
9
+ * copy is the point — `hq doctor` and `hq integrations show` disagreeing about
10
+ * whether a row needs a reconnect is exactly the confusion this family exists
11
+ * to remove.
12
+ *
13
+ * It never echoes untrusted provider text: upstream messages can carry tokens or
14
+ * secret-bearing URLs, so every user-visible string here is HQ-authored, and the
15
+ * only externally-sourced string that reaches a user is the server's own
16
+ * `fix_path`.
17
+ */
18
+ import { bareProvider } from "./provider-slug.js";
19
+ const RECONNECT_REASON_CODES = new Set([
20
+ "oauth_refresh_invalid_grant",
21
+ "oauth_refresh_unavailable",
22
+ "token_refresh_failed",
23
+ "credentials_rejected",
24
+ ]);
25
+ const RETRYABLE_REASON_CODES = new Set([
26
+ "oauth_refresh_transient",
27
+ "oauth_refresh_write_conflict",
28
+ ]);
29
+ const HQ_CONFIGURATION_REASON_CODE = "oauth_client_secret_unavailable";
30
+ const UNSPECIFIED_REASON_CODE = "unspecified";
31
+ /** Classify without echoing untrusted provider text, which may contain secrets. */
32
+ export function classifyConnection(connection) {
33
+ if (connection.status === "revoked")
34
+ return [];
35
+ const provider = bareProvider(connection.provider);
36
+ const reason = recordedReason(connection);
37
+ const connectedWithRecordedFailure = connection.status === "connected" && reason !== "";
38
+ const knownReasonCode = knownReasonCodeFor(connection);
39
+ // `unspecified` is a recognised member of the server vocabulary, but it is an
40
+ // absence of diagnosis rather than one. Short-circuiting on it here would skip
41
+ // the status-aware branches below and report a degraded connection as an error
42
+ // needing a reconnect. It is deliberately allowed to fall through, where the
43
+ // server's role- and credential-aware remediation is applied like any other
44
+ // undiagnosed row.
45
+ if (knownReasonCode && knownReasonCode !== UNSPECIFIED_REASON_CODE) {
46
+ const finding = findingForKnownReasonCode(connection, provider, knownReasonCode);
47
+ // Retryable and HQ-configuration reason codes diagnose conditions that a
48
+ // caller-specific remediation cannot change. Reconnect-class codes defer
49
+ // to the server's credential/role-aware remediation classification, so an
50
+ // install in progress is never interrupted by a reconnect.
51
+ return [withServerRemediation(connection, RECONNECT_REASON_CODES.has(knownReasonCode)
52
+ ? findingForServerFixKind(finding, connection.fix_kind)
53
+ : finding)];
54
+ }
55
+ // A stale remediation value does not make a clean connected row unhealthy.
56
+ if (!connectedWithRecordedFailure && connection.status === "connected")
57
+ return [];
58
+ if (isProviderBlocked(reason) || connection.status === "degraded") {
59
+ return [withServerRemediation(connection, {
60
+ provider,
61
+ connectionId: connection.id,
62
+ kind: "provider-blocked",
63
+ message: connectedWithRecordedFailure
64
+ ? "reports connected, but recorded provider health says access is blocked upstream"
65
+ : "provider-side access is blocked or unavailable",
66
+ })];
67
+ }
68
+ const fallback = genericReconnectFinding(connection, provider);
69
+ const serverClassified = findingForServerFixKind(fallback, connection.fix_kind);
70
+ if (serverClassified.kind !== "reconnect") {
71
+ return [withServerRemediation(connection, serverClassified)];
72
+ }
73
+ if (isTokenRefreshFailure(reason) || isCredentialFailure(reason)) {
74
+ const problem = isTokenRefreshFailure(reason)
75
+ ? "token refresh failed"
76
+ : connectedWithRecordedFailure
77
+ ? "reports connected, but the provider rejected the stored credentials"
78
+ : "the provider rejected the stored credentials";
79
+ return [withServerRemediation(connection, {
80
+ provider,
81
+ connectionId: connection.id,
82
+ kind: "reconnect",
83
+ message: problem,
84
+ })];
85
+ }
86
+ // Past this point the reason vocabulary has diagnosed nothing: no recognised
87
+ // reason code and no reason text this module can read. Two things can still
88
+ // have diagnosed the row, and both are honoured before HQ admits it does not
89
+ // know:
90
+ // - an explicit server `fix_kind: "reconnect"`; and
91
+ // - a Factory installation parked at `needs_credentials`, which means the
92
+ // install exists but has never been given a sign-in. `hq integrations
93
+ // list` and `show` already say exactly that, so calling the same row
94
+ // undiagnosed would print two contradictory diagnoses on one screen.
95
+ // Absent both, saying "reconnect" is a guess dressed as advice — see the
96
+ // FindingKind doc comment.
97
+ const serverSaidReconnect = connection.fix_kind === "reconnect";
98
+ const installNeedsSignIn = connection.installation?.status === "needs_credentials";
99
+ if (connection.status === "needs-reauth" || connection.status === "error") {
100
+ const diagnosed = serverSaidReconnect
101
+ ? fallback
102
+ : installNeedsSignIn
103
+ ? { ...fallback, message: "has an installation that has never been given a sign-in" }
104
+ : undefined;
105
+ return [withServerRemediation(connection, diagnosed ?? { ...fallback, kind: "undiagnosed", message: undiagnosedMessage(connection) })];
106
+ }
107
+ if (connection.status !== "connected") {
108
+ return [withServerRemediation(connection, {
109
+ provider,
110
+ connectionId: connection.id,
111
+ kind: serverSaidReconnect || installNeedsSignIn ? "reconnect" : "undiagnosed",
112
+ message: `reports an unrecognized non-healthy status (${connection.status}) and HQ was not told why`,
113
+ })];
114
+ }
115
+ return [];
116
+ }
117
+ /**
118
+ * HQ-authored wording for a fault the control plane did not explain. It states
119
+ * the observable fact and stops there, rather than naming a repair nothing has
120
+ * justified.
121
+ */
122
+ function undiagnosedMessage(connection) {
123
+ if (connection.status !== "needs-reauth")
124
+ return "is not working, and HQ was not told why";
125
+ // A server-supplied `fix_path` means HQ WAS told what to do about the row,
126
+ // just not why it happened. `withServerRemediation` prints that instruction
127
+ // directly beneath this line, so claiming HQ does not know the fix would
128
+ // contradict the advice the reader is being handed.
129
+ return connection.fix_path
130
+ ? "reports that its saved sign-in is no longer valid, but HQ was not told why"
131
+ : "reports that its saved sign-in is no longer valid, but HQ was not told why or what will fix it";
132
+ }
133
+ function genericReconnectFinding(connection, provider) {
134
+ return {
135
+ provider,
136
+ connectionId: connection.id,
137
+ kind: "reconnect",
138
+ message: connection.status === "needs-reauth"
139
+ ? "needs re-authentication"
140
+ : "is in an error state",
141
+ };
142
+ }
143
+ /** Only values emitted by hq-pro affect the classification; future values fall back. */
144
+ function findingForServerFixKind(fallback, fixKind) {
145
+ switch (fixKind) {
146
+ case "re-add":
147
+ return {
148
+ ...fallback,
149
+ kind: "re-add",
150
+ message: "requires its API key to be re-entered",
151
+ };
152
+ case "contact-admin":
153
+ return {
154
+ ...fallback,
155
+ kind: "contact-admin",
156
+ message: "must be repaired by a company owner or admin",
157
+ };
158
+ case "wait":
159
+ return {
160
+ ...fallback,
161
+ kind: "wait",
162
+ message: "is waiting for the Factory installation to complete",
163
+ };
164
+ case "reconnect":
165
+ default:
166
+ return fallback;
167
+ }
168
+ }
169
+ /**
170
+ * A fix path is server-authored, role- and credential-aware wording. It cannot
171
+ * make a connection unhealthy: the caller must first have classified a health
172
+ * signal, so a stale fix path on an otherwise clean connected row is ignored.
173
+ */
174
+ function withServerRemediation(connection, finding) {
175
+ return connection.fix_path
176
+ ? { ...finding, remediation: connection.fix_path }
177
+ : finding;
178
+ }
179
+ /**
180
+ * These are a closed hq-pro contract. Exact matching deliberately comes before
181
+ * the legacy text heuristics, which remain below for old and unknown rows.
182
+ */
183
+ function knownReasonCodeFor(connection) {
184
+ const values = [connection.errorReason, connection.needsReauthReason, connection.degradedReason];
185
+ if (values.includes(HQ_CONFIGURATION_REASON_CODE))
186
+ return HQ_CONFIGURATION_REASON_CODE;
187
+ return values.find((value) => (typeof value === "string" &&
188
+ (RECONNECT_REASON_CODES.has(value) ||
189
+ RETRYABLE_REASON_CODES.has(value) ||
190
+ value === UNSPECIFIED_REASON_CODE)));
191
+ }
192
+ function findingForKnownReasonCode(connection, provider, code) {
193
+ if (RECONNECT_REASON_CODES.has(code)) {
194
+ return {
195
+ provider,
196
+ connectionId: connection.id,
197
+ kind: "reconnect",
198
+ message: "stored credentials need re-authentication",
199
+ };
200
+ }
201
+ if (RETRYABLE_REASON_CODES.has(code)) {
202
+ return {
203
+ provider,
204
+ connectionId: connection.id,
205
+ kind: "retryable",
206
+ message: code === "oauth_refresh_write_conflict"
207
+ ? "token refresh lost a concurrent write race; the stored credential remains intact"
208
+ : "token refresh is temporarily unavailable; the stored credential remains intact",
209
+ };
210
+ }
211
+ return {
212
+ provider,
213
+ connectionId: connection.id,
214
+ kind: "hq-configuration",
215
+ message: "the HQ OAuth client secret is unavailable",
216
+ };
217
+ }
218
+ function recordedReason(connection) {
219
+ return [connection.errorReason, connection.needsReauthReason, connection.degradedReason]
220
+ .filter((value) => typeof value === "string")
221
+ .join(" ")
222
+ .toLowerCase();
223
+ }
224
+ function isTokenRefreshFailure(reason) {
225
+ return /token[\s_-]*refresh|refresh[\s_-]*token|invalid_grant/.test(reason);
226
+ }
227
+ function isCredentialFailure(reason) {
228
+ return /rejected[\s_-]*(the[\s_-]*)?stored[\s_-]*credentials|credentials[\s_-]*rejected|unauthori[sz]ed|invalid[\s_-]*(token|credentials)/.test(reason);
229
+ }
230
+ function isProviderBlocked(reason) {
231
+ return /\b403\b|access[\s_-]*denied|upstream[\s_-]*(block|blocked)|provider[\s_-]*(block|blocked|unavailable)|service[\s_-]*unavailable/.test(reason);
232
+ }
233
+ /**
234
+ * A status alone ("error") tells a user nothing they can act on. The control
235
+ * plane already sends the diagnosis and the fix path on every non-healthy row;
236
+ * this turns those into the two lines a surface should print next to the status.
237
+ *
238
+ * Revoked rows return `null` on purpose. They are tombstones rather than broken
239
+ * connections, and `revokedConnectionDetails` already produces the correct
240
+ * re-add wording for them — classifying them here would print two competing
241
+ * recovery paths for the same row.
242
+ */
243
+ export function connectionHealthNotice(connection) {
244
+ if (connection.status === "revoked")
245
+ return null;
246
+ const finding = classifyConnection(connection)[0];
247
+ if (!finding)
248
+ return null;
249
+ return {
250
+ kind: finding.kind,
251
+ headline: `${finding.provider} ${finding.message}.`,
252
+ ...(finding.remediation ? { remediation: finding.remediation } : {}),
253
+ };
254
+ }
255
+ //# sourceMappingURL=health.js.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The `factory:` runtime prefix, stripped.
3
+ *
4
+ * A leaf on purpose. Health classification needs it, and so does every command
5
+ * surface, but `commands/integrations-core.ts` imports the health module — so
6
+ * homing this there would close a runtime import cycle. Nothing else belongs in
7
+ * this file.
8
+ */
9
+ export declare function bareProvider(provider: string): string;
10
+ //# sourceMappingURL=provider-slug.d.ts.map
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The `factory:` runtime prefix, stripped.
3
+ *
4
+ * A leaf on purpose. Health classification needs it, and so does every command
5
+ * surface, but `commands/integrations-core.ts` imports the health module — so
6
+ * homing this there would close a runtime import cycle. Nothing else belongs in
7
+ * this file.
8
+ */
9
+ export function bareProvider(provider) {
10
+ return provider.replace(/^factory:/, "");
11
+ }
12
+ //# sourceMappingURL=provider-slug.js.map
@@ -130,6 +130,28 @@ export declare class QmdTerminatedError extends QmdExitError {
130
130
  export declare class QmdLlmDisabledError extends QmdExitError {
131
131
  name: string;
132
132
  }
133
+ /**
134
+ * A qmd child died on MODULE RESOLUTION — Node could not load a JavaScript
135
+ * module qmd needs (`Cannot find module …` / `ERR_MODULE_NOT_FOUND`, alongside a
136
+ * `Require stack:` or a node_modules path). hq-cli bundles qmd and its whole
137
+ * dependency tree, so a missing module means the caller's INSTALL is incomplete
138
+ * — a partial or interrupted global `npm i -g` / `pnpm add -g` left some files
139
+ * unwritten — not a bug HQ can fix in code.
140
+ *
141
+ * HQ-CLI-Y (Sentry 7690356976): a qmd child died with `Cannot find module
142
+ * './stringifyComment.js'`, its Require stack rooted at
143
+ * /usr/lib/node_modules/@indigoai-us/hq-cli/node_modules/yaml/dist/stringify/… .
144
+ * The generic template rendered that as `qmd <full argv> exited with 1: <raw
145
+ * multi-line stderr>`; the argv split the Sentry group per query, and the
146
+ * child's ` at …` stderr lines were parsed as REAL frames of the hq-cli
147
+ * process, so grouping rode foreign frames and every distinct child failure
148
+ * minted a new permanent issue. finishRunQmd now types the condition (raised
149
+ * only from qmd's OWN captured streams, message naming the subcommand only); the
150
+ * boundary prints a reinstall remedy and skips capture for caller-driven reads.
151
+ */
152
+ export declare class QmdModuleMissingError extends QmdExitError {
153
+ name: string;
154
+ }
133
155
  export type ResolveQmdBinOptions = {
134
156
  env?: Record<string, string | undefined>;
135
157
  isExecutable?: (candidate: string) => boolean;
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module';
4
4
  import * as os from 'node:os';
5
5
  import * as path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { isQmdModuleMissingError } from '../../utils/qmd-module-missing-error.js';
7
8
  import { isQmdNativeBindingError } from '../../utils/qmd-native-binding-error.js';
8
9
  import { redactErrorText } from '../../utils/redact-error-text.js';
9
10
  import { planCommandSpawn } from '../../utils/windows-spawn.js';
@@ -108,6 +109,28 @@ export class QmdTerminatedError extends QmdExitError {
108
109
  export class QmdLlmDisabledError extends QmdExitError {
109
110
  name = 'QmdLlmDisabledError';
110
111
  }
112
+ /**
113
+ * A qmd child died on MODULE RESOLUTION — Node could not load a JavaScript
114
+ * module qmd needs (`Cannot find module …` / `ERR_MODULE_NOT_FOUND`, alongside a
115
+ * `Require stack:` or a node_modules path). hq-cli bundles qmd and its whole
116
+ * dependency tree, so a missing module means the caller's INSTALL is incomplete
117
+ * — a partial or interrupted global `npm i -g` / `pnpm add -g` left some files
118
+ * unwritten — not a bug HQ can fix in code.
119
+ *
120
+ * HQ-CLI-Y (Sentry 7690356976): a qmd child died with `Cannot find module
121
+ * './stringifyComment.js'`, its Require stack rooted at
122
+ * /usr/lib/node_modules/@indigoai-us/hq-cli/node_modules/yaml/dist/stringify/… .
123
+ * The generic template rendered that as `qmd <full argv> exited with 1: <raw
124
+ * multi-line stderr>`; the argv split the Sentry group per query, and the
125
+ * child's ` at …` stderr lines were parsed as REAL frames of the hq-cli
126
+ * process, so grouping rode foreign frames and every distinct child failure
127
+ * minted a new permanent issue. finishRunQmd now types the condition (raised
128
+ * only from qmd's OWN captured streams, message naming the subcommand only); the
129
+ * boundary prints a reinstall remedy and skips capture for caller-driven reads.
130
+ */
131
+ export class QmdModuleMissingError extends QmdExitError {
132
+ name = 'QmdModuleMissingError';
133
+ }
111
134
  function isExecutable(candidate) {
112
135
  try {
113
136
  fs.accessSync(candidate, fs.constants.X_OK);
@@ -698,27 +721,31 @@ export function resolveQmdInvocation(options = {}) {
698
721
  const ANSI_ESCAPE = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;?]*[ -/]*[@-~]`, 'g');
699
722
  /** Lines qmd prints as ADVISORIES (a tip, a warning) — never a failure reason. */
700
723
  const QMD_ADVISORY_LINE = /^(?:Tip:|Warning:|QMD Warning:)/i;
701
- /**
702
- * Render qmd's captured output as a human diagnostic for a SIGNALLED run: strip
703
- * ANSI/control noise, then drop purely-advisory lines as long as a substantive
704
- * line survives. If ONLY advisory lines were printed — the reported shape, where
705
- * an embeddings tip and a GPU warning were all qmd emitted before it was killed —
706
- * say so explicitly rather than presenting an advisory as the cause of death.
707
- * Any retained qmd text is redacted + length-bounded, so a hostile line can
708
- * neither forge a second `hq:` line, emit terminal escapes, nor leak a token.
709
- */
710
- function describeQmdTermination(stdout, stderr) {
711
- const lines = `${stderr}\n${stdout}`
724
+ function summarizeQmdOutput(combined) {
725
+ const lines = combined
712
726
  .split(/\r?\n/)
713
727
  .map((line) => line.replace(ANSI_ESCAPE, '').trim())
714
728
  .filter((line) => line.length > 0);
715
729
  if (lines.length === 0)
716
- return '';
730
+ return { kind: 'empty' };
717
731
  const substantive = lines.filter((line) => !QMD_ADVISORY_LINE.test(line));
718
732
  if (substantive.length === 0)
733
+ return { kind: 'advisory-only' };
734
+ const reason = redactErrorText(substantive.join(' '));
735
+ return reason ? { kind: 'reason', reason } : { kind: 'empty' };
736
+ }
737
+ /**
738
+ * Render qmd's captured output as a human diagnostic for a SIGNALLED run.
739
+ * Byte-for-byte identical to its prior behaviour, now expressed through the
740
+ * shared {@link summarizeQmdOutput} core so the exited path renders the same way.
741
+ */
742
+ function describeQmdTermination(stdout, stderr) {
743
+ const summary = summarizeQmdOutput(`${stderr}\n${stdout}`);
744
+ if (summary.kind === 'advisory-only')
719
745
  return 'qmd printed only advisory output before it was terminated';
720
- const detail = redactErrorText(substantive.join(' '));
721
- return detail ? `qmd reported: ${detail}` : '';
746
+ if (summary.kind === 'reason')
747
+ return `qmd reported: ${summary.reason}`;
748
+ return '';
722
749
  }
723
750
  /** Normalise a spawn result and raise the typed qmd failures. */
724
751
  function finishRunQmd(result, bin, args) {
@@ -750,8 +777,24 @@ function finishRunQmd(result, bin, args) {
750
777
  : `qmd ${subcommand} was terminated by signal ${namedSignal} before it finished`;
751
778
  throw new QmdTerminatedError(signalMessage, args, normalized.status, normalized.stdout, normalized.stderr, signal);
752
779
  }
753
- const detail = normalized.stderr || normalized.stdout || 'qmd returned no diagnostic output';
754
- const message = `qmd ${args.join(' ')} exited with ${normalized.status ?? 'an unknown status'}: ${detail}`;
780
+ // Name the SUBCOMMAND only (args[0]) never args.join(' '), which used to
781
+ // interpolate the caller's whole argv (their search query, -c collection name,
782
+ // any absolute paths) straight into the Sentry issue title and split the group
783
+ // per invocation. `classifyText` keeps qmd's RAW captured streams for the
784
+ // wording-based branches below (byte-identical to the prior `detail`), while
785
+ // `reason` is the flattened, redacted, single-line rendering that goes into the
786
+ // human-facing message. Collapsing `reason` is what stops a child process's
787
+ // ` at …` stderr frames from polluting this error's stack and minting a
788
+ // brand-new Sentry issue per child failure (HQ-CLI-Y / Sentry 7690356976).
789
+ const subcommand = typeof args[0] === 'string' && args[0].length > 0 ? redactErrorText(args[0]) || 'qmd' : 'qmd';
790
+ const classifyText = normalized.stderr || normalized.stdout || 'qmd returned no diagnostic output';
791
+ const summary = summarizeQmdOutput(normalized.stderr || normalized.stdout || '');
792
+ const reason = summary.kind === 'reason'
793
+ ? summary.reason
794
+ : summary.kind === 'advisory-only'
795
+ ? 'qmd printed only advisory output'
796
+ : 'qmd returned no diagnostic output';
797
+ const message = `qmd ${subcommand} exited with ${normalized.status ?? 'an unknown status'}: ${reason}`;
755
798
  // qmd refused because its local LLM is disabled: whenever CI is set in the
756
799
  // environment, qmd hard-disables every LLM operation, so `vsearch`/`query`
757
800
  // (query expansion) and `embed` (vector build) throw `Error: LLM operations
@@ -764,10 +807,23 @@ function finishRunQmd(result, bin, args) {
764
807
  // the collection wordings: the refusal text matches none of them, so order is
765
808
  // safe, and the narrower typed signature stays first.
766
809
  if (/LLM operations are disabled/i.test(`${normalized.stderr}\n${normalized.stdout}`)) {
767
- const subcommand = typeof args[0] === 'string' && args[0].length > 0 ? redactErrorText(args[0]) || 'qmd' : 'qmd';
768
810
  throw new QmdLlmDisabledError(`qmd ${subcommand} could not run: its local LLM is disabled because CI is set in the environment`, args, normalized.status, normalized.stdout, normalized.stderr);
769
811
  }
770
- if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(detail)) {
812
+ // A qmd child that died on MODULE RESOLUTION — `Cannot find module …` with a
813
+ // Require stack / node_modules path — means the caller's hq install tree is
814
+ // incomplete (a partial or interrupted global install), not an hq-cli defect.
815
+ // Type it so the boundary can print a reinstall remedy and skip capture for a
816
+ // caller-driven read, and so its grouping is bounded. Read qmd's OWN captured
817
+ // streams only, never the synthesized message, so a query for "Cannot find
818
+ // module" cannot trip it. Placed AFTER the LLM-disabled check and BEFORE the
819
+ // collection checks: the wordings are disjoint (the collection regex needs
820
+ // `collection`/`qmd://` adjacent to a not-found token, which this stderr
821
+ // lacks), so no existing branch changes behaviour (HQ-CLI-Y / Sentry
822
+ // 7690356976).
823
+ if (isQmdModuleMissingError({ stderr: normalized.stderr, stdout: normalized.stdout })) {
824
+ throw new QmdModuleMissingError(`qmd ${subcommand} could not run: a module it needs was not found (the hq install tree looks incomplete)`, args, normalized.status, normalized.stdout, normalized.stderr);
825
+ }
826
+ if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(classifyText)) {
771
827
  throw new QmdCollectionMissingError(message, args, normalized.status, normalized.stdout, normalized.stderr);
772
828
  }
773
829
  // qmd's same-name duplicate: `collection add` targeted a name YAML already
@@ -778,7 +834,7 @@ function finishRunQmd(result, bin, args) {
778
834
  // stays a plain QmdExitError. Colour is off under capture (qmd gates on
779
835
  // isTTY) and any surrounding ANSI sits outside this substring, so the plain
780
836
  // match is robust either way.
781
- const alreadyExists = /Collection '([^']+)' already exists\./.exec(detail);
837
+ const alreadyExists = /Collection '([^']+)' already exists\./.exec(classifyText);
782
838
  if (alreadyExists) {
783
839
  throw new QmdCollectionExistsError(message, args, normalized.status, normalized.stdout, normalized.stderr, alreadyExists[1]);
784
840
  }
package/dist/main.js CHANGED
@@ -69,6 +69,7 @@ import { qmdNativeBindingErrorMessage } from "./utils/qmd-native-binding-error.j
69
69
  import { qmdMissingCollectionMessage } from "./utils/qmd-collection-missing-error.js";
70
70
  import { qmdTerminatedMessage } from "./utils/qmd-terminated-error.js";
71
71
  import { qmdLlmDisabledMessage } from "./utils/qmd-llm-disabled-error.js";
72
+ import { qmdModuleMissingMessage } from "./utils/qmd-module-missing-error.js";
72
73
  import { isExpectedUserError } from "./utils/expected-cli-error.js";
73
74
  import { isEpipe } from "./utils/epipe.js";
74
75
  import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
@@ -501,7 +502,25 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
501
502
  // that legitimately need the LLM (vsearch/query/embed); an LLM-disabled
502
503
  // failure from hq's OWN reconciliation stays a captured internal error.
503
504
  const llmDisabledMsg = qmdMsg || collectionMsg || terminatedMsg ? null : qmdLlmDisabledMessage(err);
504
- const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg
505
+ // A qmd child that died on MODULE RESOLUTION (`Cannot find module …` with a
506
+ // Require stack / node_modules path) means the caller's hq install tree is
507
+ // incomplete — a partial or interrupted global install — not an hq-cli
508
+ // defect. Before this branch the broken-install condition reached the final
509
+ // else and, because the generic message embedded the caller's whole argv
510
+ // AND the child's raw stderr frames, filed a high-priority crash and minted
511
+ // a NEW permanent issue per child failure (HQ-CLI-Y, Sentry 7690356976).
512
+ // finishRunQmd now types it QmdModuleMissingError; print the input-free
513
+ // reinstall remedy and skip capture. Evaluated AFTER the native-binding /
514
+ // collection-missing / terminated / llm-disabled checks (native-binding in
515
+ // particular MUST keep winning when a bindings failure also looks
516
+ // module-missing, since it is checked first) and BEFORE the environmental /
517
+ // transport / generic branches. Scoped to caller-supplied reads
518
+ // (search/vsearch/query/get); a module-missing raised by hq's OWN
519
+ // reconciliation names a packaging fault hq could own, so it stays captured.
520
+ const moduleMissingMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg
521
+ ? null
522
+ : qmdModuleMissingMessage(err);
523
+ const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg
505
524
  ? null
506
525
  : environmentalFsErrorMessage(err);
507
526
  // A raw network transport failure (undici's `TypeError: fetch failed`
@@ -514,7 +533,7 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
514
533
  // message that names the unreachable host, exit 1, and skip Sentry.
515
534
  // Ordered after the environmental check so a full disk keeps its exact
516
535
  // existing message.
517
- const transportMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || envMsg
536
+ const transportMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || envMsg
518
537
  ? null
519
538
  : networkTransportErrorMessage(err);
520
539
  if (qmdMsg) {
@@ -529,6 +548,9 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
529
548
  else if (llmDisabledMsg) {
530
549
  deps.stderr.write(`hq: ${llmDisabledMsg}\n`);
531
550
  }
551
+ else if (moduleMissingMsg) {
552
+ deps.stderr.write(`hq: ${moduleMissingMsg}\n`);
553
+ }
532
554
  else if (envMsg) {
533
555
  deps.stderr.write(`hq: ${envMsg}\n`);
534
556
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The actionable remedy shown to the operator. Input-free — nothing is
3
+ * interpolated from the error, the argv, or qmd's output — so there is no
4
+ * injection surface, matching the bounded-fingerprint discipline of its
5
+ * siblings. Names the concrete reinstall for both a global npm and a global
6
+ * pnpm install.
7
+ *
8
+ * Covers BOTH qmd origins deliberately, because they cannot be told apart at
9
+ * runtime: HQ_QMD_BIN is honoured verbatim, and it is also the seam a dev build
10
+ * (and this repo's e2e harness) uses to inject the bundled qmd, so a truthy
11
+ * HQ_QMD_BIN is NOT a reliable "external qmd" signal. A missing module is not an
12
+ * hq-cli code defect on either path, so the remedy — not the classification —
13
+ * carries the origin nuance: it names the bundled reinstall first, then the
14
+ * HQ_QMD_BIN case so an operator running their own qmd is pointed at that
15
+ * install rather than only at hq.
16
+ */
17
+ export declare const QMD_MODULE_MISSING_REMEDY: string;
18
+ /**
19
+ * True when `err` carries, in qmd's OWN captured streams, a module-resolution
20
+ * failure corroborated by a Node module-loader artifact — i.e. qmd's dependency
21
+ * tree is incomplete on this machine. finishRunQmd calls this on the raw streams
22
+ * to type the condition {@link import('../lib/search-index/index.js').QmdModuleMissingError};
23
+ * a true result there means the caller should reinstall.
24
+ */
25
+ export declare function isQmdModuleMissingError(err: unknown): boolean;
26
+ /**
27
+ * If `err` is a caller-driven qmd module-missing failure (a QmdModuleMissingError
28
+ * from `hq search` or its `vsearch`/`query`/`get` siblings), return the
29
+ * actionable, input-free reinstall remedy; otherwise return `null`. Mirrors
30
+ * qmdNativeBindingErrorMessage / qmdMissingCollectionMessage so the top-level
31
+ * handler branches the same way: a non-null result means print-and-skip-Sentry,
32
+ * null means "handle as usual (capture to Sentry)".
33
+ *
34
+ * Gated on the TYPED CLASS plus a caller-reads invocation allow-list: a
35
+ * module-missing raised by hq's OWN reconciliation (`collection list`, `context
36
+ * add`, …) points at a packaging fault hq itself could be responsible for, so it
37
+ * stays a captured internal error rather than being silenced as the user's
38
+ * install.
39
+ */
40
+ export declare function qmdModuleMissingMessage(err: unknown): string | null;
41
+ //# sourceMappingURL=qmd-module-missing-error.d.ts.map