@indigoai-us/hq-cli 5.103.26 → 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 +4 -0
- package/dist/commands/dm.js +3 -7
- 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 +47 -150
- 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 +44 -0
- package/dist/lib/search-index/index.js +111 -18
- package/dist/main.js +46 -2
- package/dist/utils/qmd-llm-disabled-error.d.ts +10 -0
- package/dist/utils/qmd-llm-disabled-error.js +87 -0
- 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
|
|
@@ -108,6 +108,50 @@ export declare class QmdTerminatedError extends QmdExitError {
|
|
|
108
108
|
name: string;
|
|
109
109
|
constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string, signal: string);
|
|
110
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* qmd refused an LLM-backed operation because its local LLM is DISABLED IN CI:
|
|
113
|
+
* qmd hard-disables every LLM operation whenever `CI` is truthy in the
|
|
114
|
+
* environment and throws `Error: LLM operations are disabled in CI (set
|
|
115
|
+
* CI=true)`. Its semantic/hybrid reads (`vsearch`/`query`) expand the query
|
|
116
|
+
* through that gate, and the index build (`embed`) generates vectors through it,
|
|
117
|
+
* so any of them dies immediately when CI is set. hq-cli forwards the caller's
|
|
118
|
+
* environment — including CI — to the qmd child, so this is the caller's
|
|
119
|
+
* ENVIRONMENT, not an hq-cli defect.
|
|
120
|
+
*
|
|
121
|
+
* Before this class the refusal rendered as `qmd <full argv> exited with 1:
|
|
122
|
+
* <qmd's stderr>` and, because that message interpolated the caller's whole
|
|
123
|
+
* argv, every distinct search query minted a brand-new permanent Sentry issue —
|
|
124
|
+
* the same unbounded-fingerprint failure fixed for HQ-CLI-S and HQ-CLI
|
|
125
|
+
* 7677702704. This typed subclass lets the boundary classify the condition as
|
|
126
|
+
* the caller's environment and stop fingerprinting on the query text: it is
|
|
127
|
+
* raised only from qmd's OWN captured streams and its message names the
|
|
128
|
+
* SUBCOMMAND only. Sentry HQ-CLI 7688850003.
|
|
129
|
+
*/
|
|
130
|
+
export declare class QmdLlmDisabledError extends QmdExitError {
|
|
131
|
+
name: string;
|
|
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
|
+
}
|
|
111
155
|
export type ResolveQmdBinOptions = {
|
|
112
156
|
env?: Record<string, string | undefined>;
|
|
113
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';
|
|
@@ -86,6 +87,50 @@ export class QmdTerminatedError extends QmdExitError {
|
|
|
86
87
|
this.signal = signal;
|
|
87
88
|
}
|
|
88
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* qmd refused an LLM-backed operation because its local LLM is DISABLED IN CI:
|
|
92
|
+
* qmd hard-disables every LLM operation whenever `CI` is truthy in the
|
|
93
|
+
* environment and throws `Error: LLM operations are disabled in CI (set
|
|
94
|
+
* CI=true)`. Its semantic/hybrid reads (`vsearch`/`query`) expand the query
|
|
95
|
+
* through that gate, and the index build (`embed`) generates vectors through it,
|
|
96
|
+
* so any of them dies immediately when CI is set. hq-cli forwards the caller's
|
|
97
|
+
* environment — including CI — to the qmd child, so this is the caller's
|
|
98
|
+
* ENVIRONMENT, not an hq-cli defect.
|
|
99
|
+
*
|
|
100
|
+
* Before this class the refusal rendered as `qmd <full argv> exited with 1:
|
|
101
|
+
* <qmd's stderr>` and, because that message interpolated the caller's whole
|
|
102
|
+
* argv, every distinct search query minted a brand-new permanent Sentry issue —
|
|
103
|
+
* the same unbounded-fingerprint failure fixed for HQ-CLI-S and HQ-CLI
|
|
104
|
+
* 7677702704. This typed subclass lets the boundary classify the condition as
|
|
105
|
+
* the caller's environment and stop fingerprinting on the query text: it is
|
|
106
|
+
* raised only from qmd's OWN captured streams and its message names the
|
|
107
|
+
* SUBCOMMAND only. Sentry HQ-CLI 7688850003.
|
|
108
|
+
*/
|
|
109
|
+
export class QmdLlmDisabledError extends QmdExitError {
|
|
110
|
+
name = 'QmdLlmDisabledError';
|
|
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
|
+
}
|
|
89
134
|
function isExecutable(candidate) {
|
|
90
135
|
try {
|
|
91
136
|
fs.accessSync(candidate, fs.constants.X_OK);
|
|
@@ -676,27 +721,31 @@ export function resolveQmdInvocation(options = {}) {
|
|
|
676
721
|
const ANSI_ESCAPE = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;?]*[ -/]*[@-~]`, 'g');
|
|
677
722
|
/** Lines qmd prints as ADVISORIES (a tip, a warning) — never a failure reason. */
|
|
678
723
|
const QMD_ADVISORY_LINE = /^(?:Tip:|Warning:|QMD Warning:)/i;
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
* ANSI/control noise, then drop purely-advisory lines as long as a substantive
|
|
682
|
-
* line survives. If ONLY advisory lines were printed — the reported shape, where
|
|
683
|
-
* an embeddings tip and a GPU warning were all qmd emitted before it was killed —
|
|
684
|
-
* say so explicitly rather than presenting an advisory as the cause of death.
|
|
685
|
-
* Any retained qmd text is redacted + length-bounded, so a hostile line can
|
|
686
|
-
* neither forge a second `hq:` line, emit terminal escapes, nor leak a token.
|
|
687
|
-
*/
|
|
688
|
-
function describeQmdTermination(stdout, stderr) {
|
|
689
|
-
const lines = `${stderr}\n${stdout}`
|
|
724
|
+
function summarizeQmdOutput(combined) {
|
|
725
|
+
const lines = combined
|
|
690
726
|
.split(/\r?\n/)
|
|
691
727
|
.map((line) => line.replace(ANSI_ESCAPE, '').trim())
|
|
692
728
|
.filter((line) => line.length > 0);
|
|
693
729
|
if (lines.length === 0)
|
|
694
|
-
return '';
|
|
730
|
+
return { kind: 'empty' };
|
|
695
731
|
const substantive = lines.filter((line) => !QMD_ADVISORY_LINE.test(line));
|
|
696
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')
|
|
697
745
|
return 'qmd printed only advisory output before it was terminated';
|
|
698
|
-
|
|
699
|
-
|
|
746
|
+
if (summary.kind === 'reason')
|
|
747
|
+
return `qmd reported: ${summary.reason}`;
|
|
748
|
+
return '';
|
|
700
749
|
}
|
|
701
750
|
/** Normalise a spawn result and raise the typed qmd failures. */
|
|
702
751
|
function finishRunQmd(result, bin, args) {
|
|
@@ -728,9 +777,53 @@ function finishRunQmd(result, bin, args) {
|
|
|
728
777
|
: `qmd ${subcommand} was terminated by signal ${namedSignal} before it finished`;
|
|
729
778
|
throw new QmdTerminatedError(signalMessage, args, normalized.status, normalized.stdout, normalized.stderr, signal);
|
|
730
779
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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}`;
|
|
798
|
+
// qmd refused because its local LLM is disabled: whenever CI is set in the
|
|
799
|
+
// environment, qmd hard-disables every LLM operation, so `vsearch`/`query`
|
|
800
|
+
// (query expansion) and `embed` (vector build) throw `Error: LLM operations
|
|
801
|
+
// are disabled in CI (set CI=true)` before doing any work. hq-cli inherits the
|
|
802
|
+
// caller's CI into the qmd child (runQmd forwards process.env), so this is the
|
|
803
|
+
// caller's ENVIRONMENT, not an hq-cli defect. Match qmd's OWN captured streams
|
|
804
|
+
// (stderr AND stdout) — never the synthesized `message`, which embeds the
|
|
805
|
+
// caller's whole argv — and name the SUBCOMMAND only, so the Sentry group can
|
|
806
|
+
// no longer fingerprint per search query (HQ-CLI 7688850003). Checked ahead of
|
|
807
|
+
// the collection wordings: the refusal text matches none of them, so order is
|
|
808
|
+
// safe, and the narrower typed signature stays first.
|
|
809
|
+
if (/LLM operations are disabled/i.test(`${normalized.stderr}\n${normalized.stdout}`)) {
|
|
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);
|
|
811
|
+
}
|
|
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)) {
|
|
734
827
|
throw new QmdCollectionMissingError(message, args, normalized.status, normalized.stdout, normalized.stderr);
|
|
735
828
|
}
|
|
736
829
|
// qmd's same-name duplicate: `collection add` targeted a name YAML already
|
|
@@ -741,7 +834,7 @@ function finishRunQmd(result, bin, args) {
|
|
|
741
834
|
// stays a plain QmdExitError. Colour is off under capture (qmd gates on
|
|
742
835
|
// isTTY) and any surrounding ANSI sits outside this substring, so the plain
|
|
743
836
|
// match is robust either way.
|
|
744
|
-
const alreadyExists = /Collection '([^']+)' already exists\./.exec(
|
|
837
|
+
const alreadyExists = /Collection '([^']+)' already exists\./.exec(classifyText);
|
|
745
838
|
if (alreadyExists) {
|
|
746
839
|
throw new QmdCollectionExistsError(message, args, normalized.status, normalized.stdout, normalized.stderr, alreadyExists[1]);
|
|
747
840
|
}
|
package/dist/main.js
CHANGED
|
@@ -68,6 +68,8 @@ import { networkTransportErrorMessage } from "./utils/network-transport-error.js
|
|
|
68
68
|
import { qmdNativeBindingErrorMessage } from "./utils/qmd-native-binding-error.js";
|
|
69
69
|
import { qmdMissingCollectionMessage } from "./utils/qmd-collection-missing-error.js";
|
|
70
70
|
import { qmdTerminatedMessage } from "./utils/qmd-terminated-error.js";
|
|
71
|
+
import { qmdLlmDisabledMessage } from "./utils/qmd-llm-disabled-error.js";
|
|
72
|
+
import { qmdModuleMissingMessage } from "./utils/qmd-module-missing-error.js";
|
|
71
73
|
import { isExpectedUserError } from "./utils/expected-cli-error.js";
|
|
72
74
|
import { isEpipe } from "./utils/epipe.js";
|
|
73
75
|
import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
|
|
@@ -486,7 +488,41 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
486
488
|
// collection-missing checks (both narrower typed signatures) and BEFORE the
|
|
487
489
|
// environmental / transport / generic branches.
|
|
488
490
|
const terminatedMsg = qmdMsg || collectionMsg ? null : qmdTerminatedMessage(err);
|
|
489
|
-
|
|
491
|
+
// A qmd LLM-backed operation refused because qmd's local LLM is DISABLED
|
|
492
|
+
// whenever CI is set in the environment (`vsearch`/`query` expand the
|
|
493
|
+
// query through it; `embed` builds vectors through it). hq-cli forwards the
|
|
494
|
+
// caller's CI to the qmd child, so this is the caller's ENVIRONMENT, not an
|
|
495
|
+
// hq-cli defect. Before this branch the refusal reached the final else and,
|
|
496
|
+
// because the synthesized message embedded the caller's whole argv, minted
|
|
497
|
+
// a brand-new permanent issue per query (HQ-CLI 7688850003). finishRunQmd
|
|
498
|
+
// now types it QmdLlmDisabledError; print the query-free remedy and skip
|
|
499
|
+
// capture. Evaluated AFTER the native-binding / collection-missing /
|
|
500
|
+
// terminated checks (narrower typed signatures) and BEFORE the
|
|
501
|
+
// environmental / transport / generic branches. Scoped to the subcommands
|
|
502
|
+
// that legitimately need the LLM (vsearch/query/embed); an LLM-disabled
|
|
503
|
+
// failure from hq's OWN reconciliation stays a captured internal error.
|
|
504
|
+
const llmDisabledMsg = qmdMsg || collectionMsg || terminatedMsg ? null : qmdLlmDisabledMessage(err);
|
|
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
|
|
524
|
+
? null
|
|
525
|
+
: environmentalFsErrorMessage(err);
|
|
490
526
|
// A raw network transport failure (undici's `TypeError: fetch failed`
|
|
491
527
|
// with a ConnectTimeoutError / ECONNREFUSED / ENOTFOUND cause) is the
|
|
492
528
|
// caller's connectivity, not an hq-cli defect. Before this branch it fell
|
|
@@ -497,7 +533,9 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
497
533
|
// message that names the unreachable host, exit 1, and skip Sentry.
|
|
498
534
|
// Ordered after the environmental check so a full disk keeps its exact
|
|
499
535
|
// existing message.
|
|
500
|
-
const transportMsg = qmdMsg || collectionMsg || terminatedMsg ||
|
|
536
|
+
const transportMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || envMsg
|
|
537
|
+
? null
|
|
538
|
+
: networkTransportErrorMessage(err);
|
|
501
539
|
if (qmdMsg) {
|
|
502
540
|
deps.stderr.write(`hq: ${qmdMsg}\n`);
|
|
503
541
|
}
|
|
@@ -507,6 +545,12 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
507
545
|
else if (terminatedMsg) {
|
|
508
546
|
deps.stderr.write(`hq: ${terminatedMsg}\n`);
|
|
509
547
|
}
|
|
548
|
+
else if (llmDisabledMsg) {
|
|
549
|
+
deps.stderr.write(`hq: ${llmDisabledMsg}\n`);
|
|
550
|
+
}
|
|
551
|
+
else if (moduleMissingMsg) {
|
|
552
|
+
deps.stderr.write(`hq: ${moduleMissingMsg}\n`);
|
|
553
|
+
}
|
|
510
554
|
else if (envMsg) {
|
|
511
555
|
deps.stderr.write(`hq: ${envMsg}\n`);
|
|
512
556
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* If `err` is a qmd LLM-disabled-in-CI failure on a subcommand that legitimately
|
|
3
|
+
* needs the LLM, return an actionable, query-free remedy; otherwise return
|
|
4
|
+
* `null`. Mirrors qmdTerminatedMessage / qmdMissingCollectionMessage /
|
|
5
|
+
* qmdNativeBindingErrorMessage so the top-level handler branches the same way: a
|
|
6
|
+
* non-null result means print-and-skip-Sentry, null means "handle as usual
|
|
7
|
+
* (capture to Sentry)".
|
|
8
|
+
*/
|
|
9
|
+
export declare function qmdLlmDisabledMessage(err: unknown): string | null;
|
|
10
|
+
//# sourceMappingURL=qmd-llm-disabled-error.d.ts.map
|