@indigoai-us/hq-cli 5.109.3 → 5.109.5
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 +17 -0
- package/dist/commands/integrations-connect.js +62 -2
- package/dist/commands/integrations-oauth.d.ts +11 -0
- package/dist/commands/integrations-oauth.js +18 -1
- package/dist/utils/network-transport-error.js +10 -0
- package/dist/utils/vault-api.js +45 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.109.5] — 2026-09-10
|
|
6
|
+
|
|
7
|
+
## [5.109.4] — 2026-09-09
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- A sign-in that never comes back no longer dead-ends. `hq integrations connect`
|
|
12
|
+
used to say "Timed out waiting for the browser sign-in. Run the connect
|
|
13
|
+
command again." — right for one of the three things that cause it and useless
|
|
14
|
+
for the other two. An app that will not accept HQ's temporary local return
|
|
15
|
+
address does not redirect back at all, so its refusal reaches HQ as silence;
|
|
16
|
+
retrying from the terminal fails identically every time. So does an app that
|
|
17
|
+
needs your workspace or instance address before its sign-in page will load.
|
|
18
|
+
HQ cannot tell these apart, so it now names all three and gives the recovery
|
|
19
|
+
for each: run it again, finish in the console (which uses a fixed address the
|
|
20
|
+
app accepts), or re-run with `--mcp-url` pointed at your own instance.
|
|
21
|
+
|
|
5
22
|
## [5.109.3] — 2026-09-09
|
|
6
23
|
|
|
7
24
|
### Fixed
|
|
@@ -22,7 +22,7 @@ import { DEFAULT_VAULT_API_URL, ensureCognitoIdToken, } from "../utils/cognito-s
|
|
|
22
22
|
import { getCompanyUid } from "../utils/vault-api.js";
|
|
23
23
|
import { IntegrationsCliError, bareProvider, connectionDomain, fetchAdminSurface, printJson, revokedConnectionDetails, resolveConnection, } from "./integrations-core.js";
|
|
24
24
|
import { completeOAuth, discoverDocs, installIntegration, listCatalog, pullBlueprint, startOAuth, } from "./integrations-api.js";
|
|
25
|
-
import { startLoopbackListener } from "./integrations-oauth.js";
|
|
25
|
+
import { OAUTH_LOOPBACK_TIMEOUT_CODE, startLoopbackListener, } from "./integrations-oauth.js";
|
|
26
26
|
/** hq-pro's machine code for "this endpoint needs a browser sign-in". */
|
|
27
27
|
const OAUTH_REQUIRED_CODE = "INTEGRATION_FACTORY_OAUTH_REQUIRED";
|
|
28
28
|
/**
|
|
@@ -518,7 +518,17 @@ async function connectViaOAuth(token, companyUid, target, opts) {
|
|
|
518
518
|
console.error(`Open this URL to sign in:\n ${started.authorizationUrl}`);
|
|
519
519
|
});
|
|
520
520
|
}
|
|
521
|
-
|
|
521
|
+
let code;
|
|
522
|
+
try {
|
|
523
|
+
code = await pendingCode;
|
|
524
|
+
}
|
|
525
|
+
catch (err) {
|
|
526
|
+
if (err instanceof IntegrationsCliError &&
|
|
527
|
+
err.code === OAUTH_LOOPBACK_TIMEOUT_CODE) {
|
|
528
|
+
return loopbackTimeoutHandoff(target, opts);
|
|
529
|
+
}
|
|
530
|
+
throw err;
|
|
531
|
+
}
|
|
522
532
|
return await completeOAuth(token, companyUid, { state: started.state, code });
|
|
523
533
|
}
|
|
524
534
|
finally {
|
|
@@ -594,6 +604,56 @@ function targetSelectionHint(opts) {
|
|
|
594
604
|
return undefined;
|
|
595
605
|
}
|
|
596
606
|
/** Shared tail of every console handoff: the page, then what to connect there. */
|
|
607
|
+
/**
|
|
608
|
+
* The loopback callback never arrived. Offer every recovery, claim no cause.
|
|
609
|
+
*
|
|
610
|
+
* This used to say "Timed out waiting for the browser sign-in. Run the connect
|
|
611
|
+
* command again." — advice that is right for one of the three things that
|
|
612
|
+
* produce this timeout and useless for the other two, with nothing to tell the
|
|
613
|
+
* user which they hit:
|
|
614
|
+
*
|
|
615
|
+
* 1. They did not finish in time. Retrying works.
|
|
616
|
+
* 2. The provider rejected HQ's `127.0.0.1:<port>` redirect_uri. RFC 8252
|
|
617
|
+
* allows the variable port; plenty of providers demand an exactly
|
|
618
|
+
* registered URI anyway and show their own error page. Because the URI is
|
|
619
|
+
* unregistered they will not redirect to it, so their refusal reaches HQ
|
|
620
|
+
* as silence. Retrying from the terminal fails identically forever; the
|
|
621
|
+
* console's pinned callback IS registered, so finishing there works.
|
|
622
|
+
* 3. The app needs a workspace/instance address before its sign-in page will
|
|
623
|
+
* load at all. Its generic authorize URL cannot complete for anyone.
|
|
624
|
+
* `--mcp-url` already takes an instance-specific endpoint — the capability
|
|
625
|
+
* exists, it was just never mentioned at the moment it is needed.
|
|
626
|
+
*
|
|
627
|
+
* HQ genuinely cannot tell these apart from here, so it names all three rather
|
|
628
|
+
* than picking one and sounding certain.
|
|
629
|
+
*/
|
|
630
|
+
function loopbackTimeoutHandoff(target, opts) {
|
|
631
|
+
console.error(chalk.yellow(`The sign-in for ${target.label} never came back, so nothing was connected.`));
|
|
632
|
+
console.error("Three things cause this, and HQ cannot tell which from here:");
|
|
633
|
+
console.error(" 1. The sign-in was not finished in time — run the same command again.");
|
|
634
|
+
console.error(` 2. ${target.label} will not accept HQ's temporary local address as a ` +
|
|
635
|
+
"return URL. Retrying here always fails; finish it in the console " +
|
|
636
|
+
"instead, which uses a fixed address the app accepts.");
|
|
637
|
+
console.error(` 3. ${target.label} needs your workspace or instance address before its ` +
|
|
638
|
+
"sign-in page works at all. Re-run with that server's own endpoint:\n" +
|
|
639
|
+
" hq integrations connect --mcp-url https://<your-workspace>.example.com/mcp");
|
|
640
|
+
const url = consoleIntegrationsUrl(opts.company, consoleOrigin());
|
|
641
|
+
printConsoleDestination(target, opts, url);
|
|
642
|
+
// Nothing was connected, so the command must not exit 0. Returning null the
|
|
643
|
+
// way consoleHandoff does would let `connectApp` complete normally and hand a
|
|
644
|
+
// script a success it did not get — the same unbacked claim this whole change
|
|
645
|
+
// is about, made in the exit status instead of in prose.
|
|
646
|
+
//
|
|
647
|
+
// A handoff and a timeout are different outcomes and deserve different exit
|
|
648
|
+
// codes: consoleHandoff means HQ knows what happens next and has routed you
|
|
649
|
+
// there, while this means the sign-in failed and HQ cannot say why. Set the
|
|
650
|
+
// status directly rather than throwing, so the three routes above stay the
|
|
651
|
+
// whole message instead of being followed by a duplicate headline — the same
|
|
652
|
+
// print-then-mark-failed shape integrations.ts already uses for a tool call
|
|
653
|
+
// that ran and failed.
|
|
654
|
+
process.exitCode = 1;
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
597
657
|
function printConsoleDestination(target, opts, url) {
|
|
598
658
|
if (url) {
|
|
599
659
|
console.error(`Open this page and connect ${target.label} from it:\n ${url}`);
|
|
@@ -19,6 +19,17 @@
|
|
|
19
19
|
* as `OAUTH_REDIRECT_URI_NOT_ALLOWED` at connect time.
|
|
20
20
|
*/
|
|
21
21
|
export declare const LOOPBACK_CALLBACK_PATH = "/hq/integrations/oauth/callback";
|
|
22
|
+
/**
|
|
23
|
+
* The loopback callback never arrived before the bound.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately a code and not a message match: this is the ONLY signal HQ gets
|
|
26
|
+
* for a provider that rejects the ephemeral `127.0.0.1:<port>` redirect_uri,
|
|
27
|
+
* because such a provider refuses to redirect to an unregistered address and
|
|
28
|
+
* so sends nothing back at all. It is also what a person who simply did not
|
|
29
|
+
* finish in time produces. The two are indistinguishable from here, which is
|
|
30
|
+
* exactly why the caller must offer both recoveries rather than assert one.
|
|
31
|
+
*/
|
|
32
|
+
export declare const OAUTH_LOOPBACK_TIMEOUT_CODE = "OAUTH_LOOPBACK_TIMEOUT";
|
|
22
33
|
/**
|
|
23
34
|
* Turn an `?error=` redirect into what to tell the user, and whether it is
|
|
24
35
|
* their doing.
|
|
@@ -23,6 +23,17 @@ import { IntegrationsCliError } from "./integrations-core.js";
|
|
|
23
23
|
export const LOOPBACK_CALLBACK_PATH = "/hq/integrations/oauth/callback";
|
|
24
24
|
/** How long to wait for the browser round trip before giving the port back. */
|
|
25
25
|
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
26
|
+
/**
|
|
27
|
+
* The loopback callback never arrived before the bound.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately a code and not a message match: this is the ONLY signal HQ gets
|
|
30
|
+
* for a provider that rejects the ephemeral `127.0.0.1:<port>` redirect_uri,
|
|
31
|
+
* because such a provider refuses to redirect to an unregistered address and
|
|
32
|
+
* so sends nothing back at all. It is also what a person who simply did not
|
|
33
|
+
* finish in time produces. The two are indistinguishable from here, which is
|
|
34
|
+
* exactly why the caller must offer both recoveries rather than assert one.
|
|
35
|
+
*/
|
|
36
|
+
export const OAUTH_LOOPBACK_TIMEOUT_CODE = "OAUTH_LOOPBACK_TIMEOUT";
|
|
26
37
|
/**
|
|
27
38
|
* The authorization-endpoint error codes RFC 6749 §4.1.2.1 defines. Anything
|
|
28
39
|
* outside this set is reported as `other`: the value arrives in a redirect
|
|
@@ -250,7 +261,13 @@ export async function startLoopbackListener(opts = {}) {
|
|
|
250
261
|
}
|
|
251
262
|
timer = setTimeout(() => {
|
|
252
263
|
close();
|
|
253
|
-
reject(new IntegrationsCliError("
|
|
264
|
+
reject(new IntegrationsCliError("The sign-in never came back.",
|
|
265
|
+
// Typed, so the caller can offer the recovery routes without
|
|
266
|
+
// matching on this sentence. A timeout is NOT self-evidently
|
|
267
|
+
// "you were too slow": a provider that refuses HQ's ephemeral
|
|
268
|
+
// loopback redirect_uri generally will not redirect back to it
|
|
269
|
+
// at all, so its refusal reaches this branch as silence.
|
|
270
|
+
{ expected: true, code: OAUTH_LOOPBACK_TIMEOUT_CODE }));
|
|
254
271
|
}, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
255
272
|
// Don't let a pending sign-in wait keep an otherwise-finished process
|
|
256
273
|
// alive: the explicit timeout above is the bound, not the event loop.
|
|
@@ -53,11 +53,21 @@ const TRANSPORT_CODE_REASONS = {
|
|
|
53
53
|
* Recognized undici error names, for builds where the `code` property is
|
|
54
54
|
* absent but the typed error still identifies itself (the shape Sentry
|
|
55
55
|
* recorded for HQ-CLI-G was `ConnectTimeoutError`).
|
|
56
|
+
*
|
|
57
|
+
* `TimeoutError` is the DOMException `AbortSignal.timeout(...)` rejects a
|
|
58
|
+
* bounded `fetch()` with when the peer accepts the connection but never answers
|
|
59
|
+
* — Sentry HQ-CLI-1P (issue 7716090679). It is the one transport failure here
|
|
60
|
+
* that identifies itself ONLY by name: its legacy DOMException `code` is the
|
|
61
|
+
* NUMBER 23, so the string-typed `readStringProperty(node, "code")` above skips
|
|
62
|
+
* it, and it carries no `cause` to walk, leaving just the name. `AbortError` is
|
|
63
|
+
* deliberately NOT listed — a caller-initiated abort is not a transport fault
|
|
64
|
+
* and must stay reportable.
|
|
56
65
|
*/
|
|
57
66
|
const TRANSPORT_NAME_REASONS = {
|
|
58
67
|
ConnectTimeoutError: "the connection timed out",
|
|
59
68
|
HeadersTimeoutError: "the server did not respond in time",
|
|
60
69
|
SocketError: "the connection closed unexpectedly",
|
|
70
|
+
TimeoutError: "the server did not respond in time",
|
|
61
71
|
};
|
|
62
72
|
/**
|
|
63
73
|
* Depth cap for `cause` traversal. A self-referential or mutually-referential
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -19,6 +19,24 @@ import { planGateErrorFromResponse } from './plan-gate-error.js';
|
|
|
19
19
|
* whole-process watchdog.
|
|
20
20
|
*/
|
|
21
21
|
const IDENTITY_LOOKUP_TIMEOUT_MS = 15_000;
|
|
22
|
+
/**
|
|
23
|
+
* The identity-lookup abort bound, in milliseconds. Defaults to the shipped
|
|
24
|
+
* IDENTITY_LOOKUP_TIMEOUT_MS (15s). An optional HQ_IDENTITY_LOOKUP_TIMEOUT_MS
|
|
25
|
+
* override (clamped to a sane 100..120_000 ms window; absent or non-numeric
|
|
26
|
+
* input keeps the 15s default) exists purely so the artifact E2E can force the
|
|
27
|
+
* abort in well under a second instead of stalling a spawned CLI for the full
|
|
28
|
+
* 15s per case. It does NOT change shipped behaviour: with the var unset every
|
|
29
|
+
* call uses 15s exactly as before.
|
|
30
|
+
*/
|
|
31
|
+
function identityLookupTimeoutMs() {
|
|
32
|
+
const raw = process.env.HQ_IDENTITY_LOOKUP_TIMEOUT_MS?.trim();
|
|
33
|
+
if (!raw)
|
|
34
|
+
return IDENTITY_LOOKUP_TIMEOUT_MS;
|
|
35
|
+
const parsed = Number(raw);
|
|
36
|
+
if (!Number.isFinite(parsed))
|
|
37
|
+
return IDENTITY_LOOKUP_TIMEOUT_MS;
|
|
38
|
+
return Math.min(120_000, Math.max(100, Math.trunc(parsed)));
|
|
39
|
+
}
|
|
22
40
|
/**
|
|
23
41
|
* Best-effort peek of a 2xx JSON body for plan-limit status (US-016).
|
|
24
42
|
*
|
|
@@ -279,7 +297,7 @@ async function resolveCompanyByUid(token, uid) {
|
|
|
279
297
|
const res = await vaultApiFetch({
|
|
280
298
|
token,
|
|
281
299
|
path: `/entity/${encodeURIComponent(uid)}`,
|
|
282
|
-
signal: AbortSignal.timeout(
|
|
300
|
+
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
283
301
|
});
|
|
284
302
|
if (!res.ok) {
|
|
285
303
|
raiseIfUnauthorized(res);
|
|
@@ -312,7 +330,7 @@ async function resolveSlugInCallerNamespace(token, slug) {
|
|
|
312
330
|
token,
|
|
313
331
|
path: '/entity/check-slug/me',
|
|
314
332
|
query: { type: 'company', slug },
|
|
315
|
-
signal: AbortSignal.timeout(
|
|
333
|
+
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
316
334
|
});
|
|
317
335
|
if (!res.ok) {
|
|
318
336
|
raiseIfUnauthorized(res);
|
|
@@ -336,7 +354,28 @@ async function resolveCompanyUid(token, ref) {
|
|
|
336
354
|
// PRIMARY PATH — caller-scoped slug resolution. Resolves the slug to the
|
|
337
355
|
// caller's OWN company (unique within their namespace by the invariant
|
|
338
356
|
// above), making a stranger's same-slug company invisible.
|
|
339
|
-
|
|
357
|
+
//
|
|
358
|
+
// A non-2xx already degrades to the global fallback below (via `return null`).
|
|
359
|
+
// A TRANSPORT failure must degrade the SAME way, not abort the command:
|
|
360
|
+
// `resolveSlugInCallerNamespace`'s fetch is bounded by AbortSignal.timeout, so
|
|
361
|
+
// a vault gateway that accepts the connection but never answers rejects with a
|
|
362
|
+
// DOMException named `TimeoutError`. Before this catch, that rejection threw
|
|
363
|
+
// straight out of `resolveCompanyUid`, skipped the global by-slug lookup, and
|
|
364
|
+
// — being unclassified by main.ts — filed a Sentry crash report instead of an
|
|
365
|
+
// actionable connectivity message (Sentry HQ-CLI-1P / issue 7716090679).
|
|
366
|
+
// Degrade ONLY on a recognized transport failure; re-throw everything else
|
|
367
|
+
// unchanged, so a genuine hq-cli defect stays fatal and the 401 AuthError that
|
|
368
|
+
// `raiseIfUnauthorized` throws still short-circuits before any fallback.
|
|
369
|
+
let mine;
|
|
370
|
+
try {
|
|
371
|
+
mine = await resolveSlugInCallerNamespace(token, ref);
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
if (networkTransportErrorCode(err) === null) {
|
|
375
|
+
throw err;
|
|
376
|
+
}
|
|
377
|
+
mine = null;
|
|
378
|
+
}
|
|
340
379
|
if (mine) {
|
|
341
380
|
return mine;
|
|
342
381
|
}
|
|
@@ -347,7 +386,7 @@ async function resolveCompanyUid(token, ref) {
|
|
|
347
386
|
const res = await vaultApiFetch({
|
|
348
387
|
token,
|
|
349
388
|
path: `/entity/by-slug/company/${encodeURIComponent(ref)}`,
|
|
350
|
-
signal: AbortSignal.timeout(
|
|
389
|
+
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
351
390
|
});
|
|
352
391
|
if (!res.ok) {
|
|
353
392
|
raiseIfUnauthorized(res);
|
|
@@ -374,7 +413,7 @@ async function resolveCompanyFromMemberships(token) {
|
|
|
374
413
|
const res = await vaultApiFetch({
|
|
375
414
|
token,
|
|
376
415
|
path: '/membership/me',
|
|
377
|
-
signal: AbortSignal.timeout(
|
|
416
|
+
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
378
417
|
});
|
|
379
418
|
if (!res.ok) {
|
|
380
419
|
throw new Error("Failed to fetch memberships — run `hq login` and try again");
|
|
@@ -421,7 +460,7 @@ export async function resolveCallerPersonUid(token, baseUrl) {
|
|
|
421
460
|
token,
|
|
422
461
|
path: '/entity/by-type/person',
|
|
423
462
|
baseUrl,
|
|
424
|
-
signal: AbortSignal.timeout(
|
|
463
|
+
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
425
464
|
});
|
|
426
465
|
if (!res.ok) {
|
|
427
466
|
throw new Error("Failed to fetch person entity — run `hq login` and try again");
|