@indigoai-us/hq-cli 5.94.1 → 5.94.3
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 +36 -0
- package/dist/commands/company-transfer.d.ts +185 -0
- package/dist/commands/company-transfer.js +664 -0
- package/dist/commands/company.js +3 -0
- package/dist/commands/core-checkpoint.js +157 -36
- package/dist/commands/integrations.js +79 -6
- package/dist/commands/search.js +23 -11
- package/dist/lib/search-index/index.d.ts +1 -0
- package/dist/lib/search-index/index.js +41 -5
- package/dist/main.js +23 -4
- package/dist/utils/network-transport-error.d.ts +16 -0
- package/dist/utils/network-transport-error.js +184 -0
- package/dist/utils/redact-error-text.d.ts +10 -0
- package/dist/utils/redact-error-text.js +33 -0
- package/dist/utils/unexpected-cli-error.d.ts +16 -0
- package/dist/utils/unexpected-cli-error.js +25 -12
- package/dist/utils/vault-api.js +29 -9
- package/package.json +1 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// src/utils/network-transport-error.ts
|
|
2
|
+
//
|
|
3
|
+
// Classify raw NETWORK TRANSPORT failures — the request never reached (or
|
|
4
|
+
// never completed against) the server — as the user's connectivity rather
|
|
5
|
+
// than an hq-cli code defect. Sibling of `environmental-error.ts` (HQ-CLI-2,
|
|
6
|
+
// full disk / read-only fs) and `expected-cli-error.ts` (HQ-CLI-6, client
|
|
7
|
+
// 4xx): a failure that is not an hq-cli defect is surfaced to the user with an
|
|
8
|
+
// actionable message and skipped for Sentry capture.
|
|
9
|
+
//
|
|
10
|
+
// HQ-CLI-G (Sentry 7652140783): `hq integrations list` resolved the caller's
|
|
11
|
+
// company through `vaultApiFetch` → bare `fetch`; undici could not connect and
|
|
12
|
+
// threw `TypeError: fetch failed` whose `cause` was
|
|
13
|
+
// `ConnectTimeoutError: Connect Timeout Error (attempted address:
|
|
14
|
+
// hqapi.hq.computer:443, timeout: 10000ms)`. That value is not an
|
|
15
|
+
// `IntegrationsCliError`, so `environmentalFsErrorMessage` and
|
|
16
|
+
// `unexpectedCliErrorMessage` both returned null and the top-level handler
|
|
17
|
+
// took its last-resort branch: capture to Sentry (culprit `?(undici)`, no
|
|
18
|
+
// actionable stack) and print NOTHING. The same process had reached the same
|
|
19
|
+
// host one second earlier, so this was a transient client-side connect
|
|
20
|
+
// failure. Suppressing the crash report is only half the fix — the silent
|
|
21
|
+
// exit is the other half, which is why this returns a MESSAGE, never a bare
|
|
22
|
+
// boolean.
|
|
23
|
+
//
|
|
24
|
+
// Matching is deliberately conservative. A recognized errno / undici code (or
|
|
25
|
+
// a recognized undici error name) must appear on the error or somewhere in its
|
|
26
|
+
// bounded cause chain; message text alone never qualifies. That keeps an
|
|
27
|
+
// hq-cli bug that happens to surface as a `TypeError` reportable.
|
|
28
|
+
import { redactErrorText } from "./redact-error-text.js";
|
|
29
|
+
/** The exact message undici gives the `fetch()` wrapper around a transport fault. */
|
|
30
|
+
const FETCH_FAILED_MESSAGE = "fetch failed";
|
|
31
|
+
/**
|
|
32
|
+
* Recognized transport codes → the phrase shown to the user.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately absent:
|
|
35
|
+
* - EPIPE — a closed downstream reader, handled earlier and exits 0 (HQ-6B).
|
|
36
|
+
* - UND_ERR_RESPONSE_STATUS_CODE — the server answered; that is an HTTP
|
|
37
|
+
* status to classify at the call site, not a transport failure.
|
|
38
|
+
*/
|
|
39
|
+
const TRANSPORT_CODE_REASONS = {
|
|
40
|
+
ECONNREFUSED: "the connection was refused",
|
|
41
|
+
ECONNRESET: "the connection was reset",
|
|
42
|
+
ENOTFOUND: "the hostname could not be resolved",
|
|
43
|
+
EAI_AGAIN: "the DNS lookup failed temporarily",
|
|
44
|
+
ETIMEDOUT: "the connection timed out",
|
|
45
|
+
EHOSTUNREACH: "the host is unreachable",
|
|
46
|
+
ENETUNREACH: "the network is unreachable",
|
|
47
|
+
ENETDOWN: "the network is down",
|
|
48
|
+
UND_ERR_CONNECT_TIMEOUT: "the connection timed out",
|
|
49
|
+
UND_ERR_HEADERS_TIMEOUT: "the server did not respond in time",
|
|
50
|
+
UND_ERR_SOCKET: "the connection closed unexpectedly",
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Recognized undici error names, for builds where the `code` property is
|
|
54
|
+
* absent but the typed error still identifies itself (the shape Sentry
|
|
55
|
+
* recorded for HQ-CLI-G was `ConnectTimeoutError`).
|
|
56
|
+
*/
|
|
57
|
+
const TRANSPORT_NAME_REASONS = {
|
|
58
|
+
ConnectTimeoutError: "the connection timed out",
|
|
59
|
+
HeadersTimeoutError: "the server did not respond in time",
|
|
60
|
+
SocketError: "the connection closed unexpectedly",
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Depth cap for `cause` traversal. A self-referential or mutually-referential
|
|
64
|
+
* cause chain must terminate rather than hang the error boundary, so the walk
|
|
65
|
+
* is bounded on BOTH depth and a visited set.
|
|
66
|
+
*/
|
|
67
|
+
const MAX_CAUSE_DEPTH = 8;
|
|
68
|
+
/** Hostnames/IPs only — anything else is dropped before it reaches stderr. */
|
|
69
|
+
const SAFE_HOST_CHARACTERS = /[^A-Za-z0-9._:[\]-]/g;
|
|
70
|
+
const MAX_HOST_LENGTH = 80;
|
|
71
|
+
function readStringProperty(node, key) {
|
|
72
|
+
const value = node[key];
|
|
73
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
74
|
+
}
|
|
75
|
+
function errorNameOf(node) {
|
|
76
|
+
return (readStringProperty(node, "name") ??
|
|
77
|
+
(typeof node.constructor?.name === "string"
|
|
78
|
+
? (node.constructor.name)
|
|
79
|
+
: null));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Best-effort host recovery: Node errno errors carry `hostname`/`address`
|
|
83
|
+
* (+ `port`); undici's ConnectTimeoutError only names the peer in its message
|
|
84
|
+
* ("attempted address: host:443").
|
|
85
|
+
*/
|
|
86
|
+
function hostFrom(node) {
|
|
87
|
+
const base = readStringProperty(node, "hostname") ?? readStringProperty(node, "address");
|
|
88
|
+
if (base) {
|
|
89
|
+
const port = node.port;
|
|
90
|
+
return typeof port === "number" && Number.isFinite(port) ? `${base}:${port}` : base;
|
|
91
|
+
}
|
|
92
|
+
const message = readStringProperty(node, "message");
|
|
93
|
+
if (message) {
|
|
94
|
+
const match = /attempted address:\s*([^\s,)]+)/i.exec(message);
|
|
95
|
+
if (match)
|
|
96
|
+
return match[1];
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Walk `root` and its `cause` chain (plus any `AggregateError.errors`, which
|
|
102
|
+
* is how undici reports a multi-address connect failure) for a recognized
|
|
103
|
+
* transport code or error name. Bounded by depth AND a visited set.
|
|
104
|
+
*/
|
|
105
|
+
function findTransportFailure(root) {
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
const queue = [{ node: root, depth: 0 }];
|
|
108
|
+
let host = null;
|
|
109
|
+
while (queue.length > 0) {
|
|
110
|
+
const { node, depth } = queue.shift();
|
|
111
|
+
if (node === null || typeof node !== "object")
|
|
112
|
+
continue;
|
|
113
|
+
if (seen.has(node))
|
|
114
|
+
continue;
|
|
115
|
+
seen.add(node);
|
|
116
|
+
host = host ?? hostFrom(node);
|
|
117
|
+
const code = readStringProperty(node, "code");
|
|
118
|
+
if (code && TRANSPORT_CODE_REASONS[code]) {
|
|
119
|
+
return { code, reason: TRANSPORT_CODE_REASONS[code], host };
|
|
120
|
+
}
|
|
121
|
+
const name = errorNameOf(node);
|
|
122
|
+
if (name && TRANSPORT_NAME_REASONS[name]) {
|
|
123
|
+
return { code: name, reason: TRANSPORT_NAME_REASONS[name], host };
|
|
124
|
+
}
|
|
125
|
+
if (depth >= MAX_CAUSE_DEPTH)
|
|
126
|
+
continue;
|
|
127
|
+
const cause = node.cause;
|
|
128
|
+
if (cause !== undefined && cause !== null) {
|
|
129
|
+
queue.push({ node: cause, depth: depth + 1 });
|
|
130
|
+
}
|
|
131
|
+
const aggregated = node.errors;
|
|
132
|
+
if (Array.isArray(aggregated)) {
|
|
133
|
+
for (const entry of aggregated)
|
|
134
|
+
queue.push({ node: entry, depth: depth + 1 });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Reject anything whose SHAPE says "this may be an hq-cli defect" before the
|
|
141
|
+
* allowlist even runs. A `TypeError` is the one thing undici reuses for a
|
|
142
|
+
* transport fault, and only with the exact `fetch failed` message; every other
|
|
143
|
+
* `TypeError` is far likelier to be our own bug (calling a non-function,
|
|
144
|
+
* reading a property of undefined) and must stay reportable.
|
|
145
|
+
*/
|
|
146
|
+
function hasTransportShape(err) {
|
|
147
|
+
if (!(err instanceof Error))
|
|
148
|
+
return false;
|
|
149
|
+
if (err instanceof TypeError && err.message !== FETCH_FAILED_MESSAGE)
|
|
150
|
+
return false;
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
function classify(err) {
|
|
154
|
+
if (!hasTransportShape(err))
|
|
155
|
+
return null;
|
|
156
|
+
return findTransportFailure(err);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The recognized transport code for `err`, or null when `err` is not a
|
|
160
|
+
* transport failure. Used for breadcrumb annotation at the fetch call site —
|
|
161
|
+
* a bounded, non-secret label, never the error text itself.
|
|
162
|
+
*/
|
|
163
|
+
export function networkTransportErrorCode(err) {
|
|
164
|
+
return classify(err)?.code ?? null;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* If `err` is a raw network transport failure, return a short, actionable
|
|
168
|
+
* user-facing message; otherwise return `null`.
|
|
169
|
+
*
|
|
170
|
+
* A non-null result means the caller should PRINT the message, exit non-zero,
|
|
171
|
+
* and SKIP Sentry capture — the condition is the caller's network, not a bug
|
|
172
|
+
* HQ can fix. A null result means "handle this as usual (capture to Sentry)".
|
|
173
|
+
*/
|
|
174
|
+
export function networkTransportErrorMessage(err) {
|
|
175
|
+
const failure = classify(err);
|
|
176
|
+
if (!failure)
|
|
177
|
+
return null;
|
|
178
|
+
const host = failure.host
|
|
179
|
+
? redactErrorText(failure.host).replace(SAFE_HOST_CHARACTERS, "").slice(0, MAX_HOST_LENGTH)
|
|
180
|
+
: "";
|
|
181
|
+
const where = host ? ` (${host})` : "";
|
|
182
|
+
return `Could not reach HQ${where}: ${failure.reason}. Check your network connection and try again.`;
|
|
183
|
+
}
|
|
184
|
+
//# sourceMappingURL=network-transport-error.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Upper bound on any redacted diagnostic — a message, not a payload dump. */
|
|
2
|
+
export declare const REDACTED_TEXT_MAX_LENGTH = 1000;
|
|
3
|
+
/**
|
|
4
|
+
* Strip credentials from `text`, flatten control characters/whitespace, and
|
|
5
|
+
* bound the length. Idempotent, so applying it at both the throw site and the
|
|
6
|
+
* print site is safe. Returns `""` when nothing survives — callers supply
|
|
7
|
+
* their own fallback wording.
|
|
8
|
+
*/
|
|
9
|
+
export declare function redactErrorText(text: string): string;
|
|
10
|
+
//# sourceMappingURL=redact-error-text.d.ts.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// src/utils/redact-error-text.ts
|
|
2
|
+
//
|
|
3
|
+
// One credential-redaction + bounding chain for error text that leaves the
|
|
4
|
+
// process — printed to the user's terminal or shipped to Sentry.
|
|
5
|
+
//
|
|
6
|
+
// It was originally inline in `unexpected-cli-error.ts`, which only runs on the
|
|
7
|
+
// UNEXPECTED path. Once upstream-minted gateway text can be classified
|
|
8
|
+
// `expected` (HQ-CLI-F), that text is printed by the top-level handler's
|
|
9
|
+
// expected branch instead, which prints `err.message` verbatim. Rather than
|
|
10
|
+
// duplicate the chain at the second print site, it lives here and both callers
|
|
11
|
+
// share it: provider-minted text is untrusted and must never be printed raw.
|
|
12
|
+
/** Upper bound on any redacted diagnostic — a message, not a payload dump. */
|
|
13
|
+
export const REDACTED_TEXT_MAX_LENGTH = 1_000;
|
|
14
|
+
/**
|
|
15
|
+
* Strip credentials from `text`, flatten control characters/whitespace, and
|
|
16
|
+
* bound the length. Idempotent, so applying it at both the throw site and the
|
|
17
|
+
* print site is safe. Returns `""` when nothing survives — callers supply
|
|
18
|
+
* their own fallback wording.
|
|
19
|
+
*/
|
|
20
|
+
export function redactErrorText(text) {
|
|
21
|
+
return text
|
|
22
|
+
.replace(/-----BEGIN[^-]+PRIVATE KEY-----[\s\S]*?-----END[^-]+PRIVATE KEY-----/g, "[REDACTED]")
|
|
23
|
+
.replace(/\p{Cc}/gu, " ")
|
|
24
|
+
.replace(/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "[REDACTED]")
|
|
25
|
+
.replace(/\b(password|secret|client[_-]?secret|api[_-]?key|(?:id|access|refresh)[_-]?token|token|authorization)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s&"'<>;,}]+)/gi, (_match, label) => `${label}=[REDACTED]`)
|
|
26
|
+
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, "[REDACTED]")
|
|
27
|
+
.replace(/\b(?:hqk_|xox[abprs]-|github_pat_|gh[pousr]_)[A-Za-z0-9_-]+\b/gi, "[REDACTED]")
|
|
28
|
+
.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[REDACTED]")
|
|
29
|
+
.replace(/\s+/g, " ")
|
|
30
|
+
.trim()
|
|
31
|
+
.slice(0, REDACTED_TEXT_MAX_LENGTH);
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=redact-error-text.js.map
|
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
* messages are already part of a user-facing protocol contract. Unknown
|
|
4
4
|
* exceptions remain Sentry-only so local implementation details and secrets
|
|
5
5
|
* are not printed indiscriminately.
|
|
6
|
+
*
|
|
7
|
+
* The redaction chain lives in `redact-error-text.ts` so the EXPECTED print
|
|
8
|
+
* path (which prints `err.message` directly from the top-level handler) can
|
|
9
|
+
* scrub the same upstream-minted text with the same rules.
|
|
6
10
|
*/
|
|
7
11
|
export declare function unexpectedCliErrorMessage(err: unknown): string | null;
|
|
12
|
+
/**
|
|
13
|
+
* Last-resort operator message for an error the CLI has no specific handling
|
|
14
|
+
* for. Redacted through the same chain as the integrations path; falls back to
|
|
15
|
+
* a fixed string when the value carries no usable message.
|
|
16
|
+
*
|
|
17
|
+
* Exists so the top-level handler can ALWAYS emit a diagnostic. It previously
|
|
18
|
+
* printed only when unexpectedCliErrorMessage() returned a value, and that
|
|
19
|
+
* returns null for everything except IntegrationsCliError — so any other
|
|
20
|
+
* failure (a qmd crash, for instance) exited 1 with zero bytes on both
|
|
21
|
+
* streams, which is indistinguishable from success-with-no-output.
|
|
22
|
+
*/
|
|
23
|
+
export declare function fallbackOperatorMessage(err: unknown): string;
|
|
8
24
|
//# sourceMappingURL=unexpected-cli-error.d.ts.map
|
|
@@ -1,24 +1,37 @@
|
|
|
1
1
|
import { IntegrationsCliError } from "../commands/integrations.js";
|
|
2
|
+
import { redactErrorText } from "./redact-error-text.js";
|
|
2
3
|
/**
|
|
3
4
|
* Return a bounded operator-facing diagnostic for unexpected errors whose
|
|
4
5
|
* messages are already part of a user-facing protocol contract. Unknown
|
|
5
6
|
* exceptions remain Sentry-only so local implementation details and secrets
|
|
6
7
|
* are not printed indiscriminately.
|
|
8
|
+
*
|
|
9
|
+
* The redaction chain lives in `redact-error-text.ts` so the EXPECTED print
|
|
10
|
+
* path (which prints `err.message` directly from the top-level handler) can
|
|
11
|
+
* scrub the same upstream-minted text with the same rules.
|
|
7
12
|
*/
|
|
8
13
|
export function unexpectedCliErrorMessage(err) {
|
|
9
14
|
if (!(err instanceof IntegrationsCliError) || err.expected)
|
|
10
15
|
return null;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
return redactErrorText(err.message) || "Integration request failed";
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Last-resort operator message for an error the CLI has no specific handling
|
|
20
|
+
* for. Redacted through the same chain as the integrations path; falls back to
|
|
21
|
+
* a fixed string when the value carries no usable message.
|
|
22
|
+
*
|
|
23
|
+
* Exists so the top-level handler can ALWAYS emit a diagnostic. It previously
|
|
24
|
+
* printed only when unexpectedCliErrorMessage() returned a value, and that
|
|
25
|
+
* returns null for everything except IntegrationsCliError — so any other
|
|
26
|
+
* failure (a qmd crash, for instance) exited 1 with zero bytes on both
|
|
27
|
+
* streams, which is indistinguishable from success-with-no-output.
|
|
28
|
+
*/
|
|
29
|
+
export function fallbackOperatorMessage(err) {
|
|
30
|
+
const raw = err instanceof Error
|
|
31
|
+
? `${err.name}: ${err.message}`
|
|
32
|
+
: typeof err === "string"
|
|
33
|
+
? err
|
|
34
|
+
: "";
|
|
35
|
+
return redactErrorText(raw) || "command failed with an unreported error";
|
|
23
36
|
}
|
|
24
37
|
//# sourceMappingURL=unexpected-cli-error.js.map
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -3,6 +3,7 @@ import { Sentry } from '../sentry.js';
|
|
|
3
3
|
import { AuthError } from './auth-error.js';
|
|
4
4
|
import { CompanySelectionError } from './company-selection-error.js';
|
|
5
5
|
import { recordPlanLimitStatus } from '../lib/plan-limit-nag.js';
|
|
6
|
+
import { networkTransportErrorCode } from './network-transport-error.js';
|
|
6
7
|
/**
|
|
7
8
|
* Best-effort peek of a 2xx JSON body for plan-limit status (US-016).
|
|
8
9
|
*
|
|
@@ -134,15 +135,34 @@ export async function vaultApiFetch(opts) {
|
|
|
134
135
|
level: "info",
|
|
135
136
|
data: { url: safeUrl, method },
|
|
136
137
|
});
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
138
|
+
let response;
|
|
139
|
+
try {
|
|
140
|
+
response = await fetch(url.toString(), {
|
|
141
|
+
method,
|
|
142
|
+
headers: {
|
|
143
|
+
Authorization: `Bearer ${opts.token}`,
|
|
144
|
+
'Content-Type': 'application/json',
|
|
145
|
+
},
|
|
146
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
147
|
+
signal: opts.signal,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
// A transport failure never reaches the server, so the non-2xx breadcrumb
|
|
152
|
+
// below never fires and the run's last HTTP trace is the request that
|
|
153
|
+
// silently vanished (exactly what made HQ-CLI-G's event unreadable). Leave
|
|
154
|
+
// a bounded, non-secret trail — the already-redacted safeUrl plus the
|
|
155
|
+
// recognized transport code — then RE-THROW the original error untouched,
|
|
156
|
+
// so `main.ts`'s classifier stays the single decision point.
|
|
157
|
+
const code = networkTransportErrorCode(err);
|
|
158
|
+
Sentry.addBreadcrumb({
|
|
159
|
+
category: "http",
|
|
160
|
+
message: `${method} ${path} → transport error`,
|
|
161
|
+
level: "warning",
|
|
162
|
+
data: { url: safeUrl, method, ...(code ? { code } : {}) },
|
|
163
|
+
});
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
146
166
|
if (!response.ok) {
|
|
147
167
|
Sentry.addBreadcrumb({
|
|
148
168
|
category: "http",
|