@indigoai-us/hq-cli 5.103.27 → 5.103.28
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 +2 -0
- package/dist/commands/integrations-core.d.ts +15 -3
- package/dist/commands/integrations-core.js +25 -3
- package/dist/commands/integrations-manage.js +4 -1
- package/dist/commands/integrations.js +2 -1
- package/dist/lib/doctor/checks/integrations.d.ts +8 -23
- package/dist/lib/doctor/checks/integrations.js +38 -166
- package/dist/lib/integrations/health.d.ts +78 -0
- package/dist/lib/integrations/health.js +255 -0
- package/dist/lib/integrations/provider-slug.d.ts +10 -0
- package/dist/lib/integrations/provider-slug.js +12 -0
- package/dist/lib/search-index/index.d.ts +22 -0
- package/dist/lib/search-index/index.js +75 -19
- package/dist/main.js +24 -2
- package/dist/utils/qmd-module-missing-error.d.ts +41 -0
- package/dist/utils/qmd-module-missing-error.js +130 -0
- package/dist/utils/sentry-fingerprint.js +96 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
721
|
-
|
|
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
|
-
|
|
754
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|