@indigoai-us/hq-cli 5.109.2 → 5.109.4

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 CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.109.4] — 2026-09-09
6
+
7
+ ### Fixed
8
+
9
+ - A sign-in that never comes back no longer dead-ends. `hq integrations connect`
10
+ used to say "Timed out waiting for the browser sign-in. Run the connect
11
+ command again." — right for one of the three things that cause it and useless
12
+ for the other two. An app that will not accept HQ's temporary local return
13
+ address does not redirect back at all, so its refusal reaches HQ as silence;
14
+ retrying from the terminal fails identically every time. So does an app that
15
+ needs your workspace or instance address before its sign-in page will load.
16
+ HQ cannot tell these apart, so it now names all three and gives the recovery
17
+ for each: run it again, finish in the console (which uses a fixed address the
18
+ app accepts), or re-run with `--mcp-url` pointed at your own instance.
19
+
20
+ ## [5.109.3] — 2026-09-09
21
+
22
+ ### Fixed
23
+
24
+ - A provider that refuses the sign-in request is no longer reported as you
25
+ declining it. `hq integrations connect` treated every `?error=` on the
26
+ callback as "Sign-in was declined", so a scope the provider will not grant,
27
+ a misconfigured client, or a provider outage all read as a choice you made —
28
+ and, because declines are treated as expected, none of them reached error
29
+ reporting. Only `access_denied` is a decline now; anything else reports as a
30
+ provider refusal and is visible. The error code is matched against the
31
+ RFC 6749 set and reported as `other` when it is outside it, so nothing the
32
+ provider sends is echoed back.
33
+
5
34
  ## [5.109.2] — 2026-09-09
6
35
 
7
36
  ### 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
- const code = await pendingCode;
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,12 +19,43 @@
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";
33
+ /**
34
+ * Turn an `?error=` redirect into what to tell the user, and whether it is
35
+ * their doing.
36
+ *
37
+ * Every `?error=` used to be reported as "Sign-in was declined", marked
38
+ * expected, and therefore skipped for Sentry capture. Only `access_denied` is
39
+ * the person declining. `invalid_scope` is HQ or the catalog asking for
40
+ * something the provider does not grant; `unauthorized_client` and
41
+ * `invalid_request` are a misconfigured client; `server_error` is the provider
42
+ * failing. Reporting those as a decision the user made both misdirects the
43
+ * user — there is nothing for them to do differently — and hid the real,
44
+ * fixable causes from error reporting, because "expected" suppresses capture.
45
+ * So: `access_denied` stays expected and quiet; everything else is a provider
46
+ * rejection that reaches Sentry.
47
+ */
48
+ export declare function authorizeErrorOutcome(raw: string): {
49
+ message: string;
50
+ expected: boolean;
51
+ };
22
52
  export interface LoopbackListener {
23
53
  /** The redirect URI to hand hq-pro — includes the OS-assigned port. */
24
54
  redirectUri: string;
25
55
  /**
26
56
  * Resolves once the authorization server redirects back. Rejects on timeout,
27
- * on an `?error=` response (the person clicked Deny), or on a state mismatch.
57
+ * on an `?error=` response, or on a state mismatch. Only `access_denied`
58
+ * means the person clicked Deny — see {@link authorizeErrorOutcome}.
28
59
  */
29
60
  waitForCode(expectedState: string): Promise<string>;
30
61
  close(): void;
@@ -23,6 +23,59 @@ 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";
37
+ /**
38
+ * The authorization-endpoint error codes RFC 6749 §4.1.2.1 defines. Anything
39
+ * outside this set is reported as `other`: the value arrives in a redirect
40
+ * query string a third party controls, so it is matched against a closed list
41
+ * and never echoed. `error_description` is deliberately never read at all.
42
+ */
43
+ const OAUTH_AUTHORIZE_ERROR_CODES = new Set([
44
+ "invalid_request",
45
+ "unauthorized_client",
46
+ "access_denied",
47
+ "unsupported_response_type",
48
+ "invalid_scope",
49
+ "server_error",
50
+ "temporarily_unavailable",
51
+ ]);
52
+ /**
53
+ * Turn an `?error=` redirect into what to tell the user, and whether it is
54
+ * their doing.
55
+ *
56
+ * Every `?error=` used to be reported as "Sign-in was declined", marked
57
+ * expected, and therefore skipped for Sentry capture. Only `access_denied` is
58
+ * the person declining. `invalid_scope` is HQ or the catalog asking for
59
+ * something the provider does not grant; `unauthorized_client` and
60
+ * `invalid_request` are a misconfigured client; `server_error` is the provider
61
+ * failing. Reporting those as a decision the user made both misdirects the
62
+ * user — there is nothing for them to do differently — and hid the real,
63
+ * fixable causes from error reporting, because "expected" suppresses capture.
64
+ * So: `access_denied` stays expected and quiet; everything else is a provider
65
+ * rejection that reaches Sentry.
66
+ */
67
+ export function authorizeErrorOutcome(raw) {
68
+ const code = raw.trim().toLowerCase();
69
+ if (code === "access_denied") {
70
+ return { message: "Sign-in was declined.", expected: true };
71
+ }
72
+ const known = OAUTH_AUTHORIZE_ERROR_CODES.has(code) ? code : "other";
73
+ return {
74
+ message: `The provider refused the sign-in request (${known}). ` +
75
+ "Nothing was connected.",
76
+ expected: false,
77
+ };
78
+ }
26
79
  /**
27
80
  * Escapes text destined for the callback page.
28
81
  *
@@ -180,7 +233,10 @@ export async function startLoopbackListener(opts = {}) {
180
233
  timer = null;
181
234
  const { code, state, error } = result;
182
235
  if (error) {
183
- reject(new IntegrationsCliError(`Sign-in was declined (${error}).`, { expected: true }));
236
+ const outcome = authorizeErrorOutcome(error);
237
+ reject(new IntegrationsCliError(outcome.message, {
238
+ expected: outcome.expected,
239
+ }));
184
240
  return;
185
241
  }
186
242
  if (!code || !state) {
@@ -205,7 +261,13 @@ export async function startLoopbackListener(opts = {}) {
205
261
  }
206
262
  timer = setTimeout(() => {
207
263
  close();
208
- reject(new IntegrationsCliError("Timed out waiting for the browser sign-in. Run the connect command again.", { expected: true }));
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 }));
209
271
  }, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
210
272
  // Don't let a pending sign-in wait keep an otherwise-finished process
211
273
  // alive: the explicit timeout above is the bound, not the event loop.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.109.2",
3
+ "version": "5.109.4",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {