@indigoai-us/hq-cli 5.109.1 → 5.109.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 CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.109.3] — 2026-09-09
6
+
7
+ ### Fixed
8
+
9
+ - A provider that refuses the sign-in request is no longer reported as you
10
+ declining it. `hq integrations connect` treated every `?error=` on the
11
+ callback as "Sign-in was declined", so a scope the provider will not grant,
12
+ a misconfigured client, or a provider outage all read as a choice you made —
13
+ and, because declines are treated as expected, none of them reached error
14
+ reporting. Only `access_denied` is a decline now; anything else reports as a
15
+ provider refusal and is visible. The error code is matched against the
16
+ RFC 6749 set and reported as `other` when it is outside it, so nothing the
17
+ provider sends is echoed back.
18
+
19
+ ## [5.109.2] — 2026-09-09
20
+
21
+ ### Fixed
22
+
23
+ - `hq integrations connect <app>` now says so when the app is already
24
+ connected, instead of opening a browser and waiting for a sign-in that
25
+ changes nothing. It prints the reconnect command for re-authorizing the
26
+ existing connection, and `--force` still adds a second one. Across a
27
+ few-hundred-app sweep the old behavior read as a silent failure.
28
+
29
+ The check only short-circuits a connection that is genuinely finished. A row
30
+ whose installation is still at `needs_credentials`, `pending`, `error`, or
31
+ `revoked` falls through to the ordinary connect, because that connect is the
32
+ only thing that repairs those states. An installation status the CLI does not
33
+ recognize is treated the same way, so an unknown state costs a redundant
34
+ sign-in rather than a stranded integration.
35
+
5
36
  ## [5.109.1] — 2026-09-08
6
37
 
7
38
  ### Added
@@ -17,6 +17,7 @@
17
17
  * "key" but is really OAuth-protected still connects on the first try.
18
18
  */
19
19
  import { Command } from "commander";
20
+ import type { AdminConnection } from "./integrations-core.js";
20
21
  /**
21
22
  * Validate `--auth` against the supported enum, rejecting an unknown value
22
23
  * before anything is installed. A finite provider/runtime option must never be
@@ -46,5 +47,6 @@ export declare function consoleOrigin(vaultApiUrl?: string): string | undefined;
46
47
  * by slug), or undefined when the console for this control plane is unknown.
47
48
  */
48
49
  export declare function consoleIntegrationsUrl(companySlug: string | undefined, origin: string | undefined): string | undefined;
50
+ export declare function findAlreadyConnected(connections: readonly AdminConnection[], app: string): AdminConnection | undefined;
49
51
  export declare function registerConnectCommands(integrations: Command): void;
50
52
  //# sourceMappingURL=integrations-connect.d.ts.map
@@ -20,7 +20,7 @@ import chalk from "chalk";
20
20
  import open from "open";
21
21
  import { DEFAULT_VAULT_API_URL, ensureCognitoIdToken, } from "../utils/cognito-session.js";
22
22
  import { getCompanyUid } from "../utils/vault-api.js";
23
- import { IntegrationsCliError, bareProvider, connectionDomain, printJson, revokedConnectionDetails, resolveConnection, } from "./integrations-core.js";
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
25
  import { startLoopbackListener } from "./integrations-oauth.js";
26
26
  /** hq-pro's machine code for "this endpoint needs a browser sign-in". */
@@ -886,6 +886,115 @@ async function connectApp(token, companyUid, app, opts, expectedRevivedConnectio
886
886
  throw err;
887
887
  }
888
888
  }
889
+ /**
890
+ * The connection `app` already names, if it is connected RIGHT NOW.
891
+ *
892
+ * `connect` used to start a fresh sign-in for an app that was already
893
+ * connected: it opened a browser, printed an authorize URL and waited, saying
894
+ * nothing about the connection already sitting there. Sweeping a few hundred
895
+ * apps, that reads as a silent failure — you approve a vendor a second time and
896
+ * get no acknowledgement that the first one exists.
897
+ *
898
+ * Only `status === "connected"` counts. A `needs-reauth`, `error` or `revoked`
899
+ * row is NOT a reason to refuse: those are exactly the rows a person runs this
900
+ * command to repair, and blocking them would strand the app.
901
+ *
902
+ * Matching is deliberately anchored on the app's own name. A connected
903
+ * `installation.domain` matches when it IS the app, or is a subdomain of it
904
+ * (`notion.com` is served by `mcp.notion.com`) — never the reverse, so an
905
+ * unrelated host can never be read as this app. The provider slug and display
906
+ * name are matched too, because `connect notion` and `connect notion.com` are
907
+ * the same request.
908
+ */
909
+ /**
910
+ * Whether a row is finished enough that starting another sign-in would be
911
+ * redundant work rather than the repair the caller needs.
912
+ *
913
+ * `connection.status === "connected"` is NOT sufficient on its own. A row can
914
+ * be connected at the connection level while its installation still sits at
915
+ * `needs_credentials`, `pending`, `error`, or `revoked` — states whose ONLY
916
+ * remedy is the very connect the caller just asked for. Short-circuiting those
917
+ * would strand them: the command would report "already connected" about a thing
918
+ * that cannot be used, and the person would have no way to fix it.
919
+ *
920
+ * So this is an allowlist, not a denylist of known-bad states. An absent
921
+ * installation is treated as settled (a non-factory connection has no
922
+ * installation to be mid-flight), but any PRESENT status other than `installed`
923
+ * is treated as unfinished. A status this CLI has not heard of therefore falls
924
+ * through to the ordinary connect — the safe direction, since a redundant
925
+ * sign-in costs a browser round-trip while a wrong skip costs a broken
926
+ * integration with no visible path out.
927
+ */
928
+ function isSettledInstallation(connection) {
929
+ const status = connection.installation?.status?.trim().toLowerCase();
930
+ return status === undefined || status === "" || status === "installed";
931
+ }
932
+ export function findAlreadyConnected(connections, app) {
933
+ const want = app.trim().toLowerCase();
934
+ if (want === "")
935
+ return undefined;
936
+ const wantSlug = displayNameSlug(want);
937
+ return connections.find((connection) => {
938
+ if (connection.status !== "connected")
939
+ return false;
940
+ if (!isSettledInstallation(connection))
941
+ return false;
942
+ const domain = (connectionDomain(connection) ?? "").toLowerCase();
943
+ if (domain === want || domain.endsWith(`.${want}`))
944
+ return true;
945
+ if (bareProvider(connection.provider).toLowerCase() === want)
946
+ return true;
947
+ const displayName = (connection.installation?.displayName ?? "").trim();
948
+ if (displayName === "")
949
+ return false;
950
+ if (displayName.toLowerCase() === want)
951
+ return true;
952
+ // `connect aws-marketplace` is the copy-pasteable form of the display name
953
+ // "AWS Marketplace", and it is what the catalog resolver already accepts.
954
+ // Compare on the same slug both ways or this check misses every app whose
955
+ // name has a space in it.
956
+ return wantSlug !== "" && displayNameSlug(displayName) === wantSlug;
957
+ });
958
+ }
959
+ /**
960
+ * Report an app as already connected instead of opening a second sign-in.
961
+ * Returns true when the connect should stop here.
962
+ */
963
+ async function refuseIfAlreadyConnected(token, companyUid, app, opts) {
964
+ // Only a plain `connect <app>`. An explicit --mcp-url / --docs-url /
965
+ // --entry-id names an endpoint rather than an installed app, and --force is
966
+ // the deliberate "yes, add another one" escape hatch.
967
+ if (!app || opts.force || opts.mcpUrl || opts.docsUrl || opts.entryId)
968
+ return false;
969
+ let connections;
970
+ try {
971
+ connections = (await fetchAdminSurface(token, companyUid)).connections ?? [];
972
+ }
973
+ catch {
974
+ // Never let this convenience check block a connect. If the list cannot be
975
+ // read, fall through and let the real flow report whatever is wrong.
976
+ return false;
977
+ }
978
+ const existing = findAlreadyConnected(connections, app);
979
+ if (!existing)
980
+ return false;
981
+ const slug = bareProvider(existing.provider);
982
+ if (opts.json) {
983
+ printJson({
984
+ ok: true,
985
+ alreadyConnected: true,
986
+ provider: slug,
987
+ connection: existing.id,
988
+ ...(connectionDomain(existing) ? { domain: connectionDomain(existing) } : {}),
989
+ });
990
+ return true;
991
+ }
992
+ const label = existing.installation?.displayName ?? slug;
993
+ console.log(chalk.green(`${label} is already connected.`));
994
+ console.log(chalk.dim(` Sign in again: hq integrations reconnect --provider ${slug}\n` +
995
+ ` Add a second: hq integrations connect ${shellQuote(app)} --force`));
996
+ return true;
997
+ }
889
998
  export function registerConnectCommands(integrations) {
890
999
  integrations
891
1000
  .command("catalog [query]")
@@ -1014,11 +1123,14 @@ export function registerConnectCommands(integrations) {
1014
1123
  .option("--scopes <list>", "Permissions to request, space- or comma-separated (default: what the app needs)")
1015
1124
  .option("--no-browser", "Print the sign-in URL instead of opening a browser")
1016
1125
  .option("--timeout <seconds>", "How long to wait for a browser sign-in (default 300)")
1126
+ .option("--force", "Connect again even if this app is already connected")
1017
1127
  .option("--json", "Machine-readable output")
1018
1128
  .action(async (app, opts) => {
1019
1129
  assertAuthMode(opts.auth);
1020
1130
  const token = await ensureCognitoIdToken();
1021
1131
  const companyUid = await getCompanyUid(token, opts.company);
1132
+ if (await refuseIfAlreadyConnected(token, companyUid, app, opts))
1133
+ return;
1022
1134
  await connectApp(token, companyUid, app, opts);
1023
1135
  });
1024
1136
  integrations
@@ -19,12 +19,32 @@
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
+ * Turn an `?error=` redirect into what to tell the user, and whether it is
24
+ * their doing.
25
+ *
26
+ * Every `?error=` used to be reported as "Sign-in was declined", marked
27
+ * expected, and therefore skipped for Sentry capture. Only `access_denied` is
28
+ * the person declining. `invalid_scope` is HQ or the catalog asking for
29
+ * something the provider does not grant; `unauthorized_client` and
30
+ * `invalid_request` are a misconfigured client; `server_error` is the provider
31
+ * failing. Reporting those as a decision the user made both misdirects the
32
+ * user — there is nothing for them to do differently — and hid the real,
33
+ * fixable causes from error reporting, because "expected" suppresses capture.
34
+ * So: `access_denied` stays expected and quiet; everything else is a provider
35
+ * rejection that reaches Sentry.
36
+ */
37
+ export declare function authorizeErrorOutcome(raw: string): {
38
+ message: string;
39
+ expected: boolean;
40
+ };
22
41
  export interface LoopbackListener {
23
42
  /** The redirect URI to hand hq-pro — includes the OS-assigned port. */
24
43
  redirectUri: string;
25
44
  /**
26
45
  * Resolves once the authorization server redirects back. Rejects on timeout,
27
- * on an `?error=` response (the person clicked Deny), or on a state mismatch.
46
+ * on an `?error=` response, or on a state mismatch. Only `access_denied`
47
+ * means the person clicked Deny — see {@link authorizeErrorOutcome}.
28
48
  */
29
49
  waitForCode(expectedState: string): Promise<string>;
30
50
  close(): void;
@@ -23,6 +23,48 @@ 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 authorization-endpoint error codes RFC 6749 §4.1.2.1 defines. Anything
28
+ * outside this set is reported as `other`: the value arrives in a redirect
29
+ * query string a third party controls, so it is matched against a closed list
30
+ * and never echoed. `error_description` is deliberately never read at all.
31
+ */
32
+ const OAUTH_AUTHORIZE_ERROR_CODES = new Set([
33
+ "invalid_request",
34
+ "unauthorized_client",
35
+ "access_denied",
36
+ "unsupported_response_type",
37
+ "invalid_scope",
38
+ "server_error",
39
+ "temporarily_unavailable",
40
+ ]);
41
+ /**
42
+ * Turn an `?error=` redirect into what to tell the user, and whether it is
43
+ * their doing.
44
+ *
45
+ * Every `?error=` used to be reported as "Sign-in was declined", marked
46
+ * expected, and therefore skipped for Sentry capture. Only `access_denied` is
47
+ * the person declining. `invalid_scope` is HQ or the catalog asking for
48
+ * something the provider does not grant; `unauthorized_client` and
49
+ * `invalid_request` are a misconfigured client; `server_error` is the provider
50
+ * failing. Reporting those as a decision the user made both misdirects the
51
+ * user — there is nothing for them to do differently — and hid the real,
52
+ * fixable causes from error reporting, because "expected" suppresses capture.
53
+ * So: `access_denied` stays expected and quiet; everything else is a provider
54
+ * rejection that reaches Sentry.
55
+ */
56
+ export function authorizeErrorOutcome(raw) {
57
+ const code = raw.trim().toLowerCase();
58
+ if (code === "access_denied") {
59
+ return { message: "Sign-in was declined.", expected: true };
60
+ }
61
+ const known = OAUTH_AUTHORIZE_ERROR_CODES.has(code) ? code : "other";
62
+ return {
63
+ message: `The provider refused the sign-in request (${known}). ` +
64
+ "Nothing was connected.",
65
+ expected: false,
66
+ };
67
+ }
26
68
  /**
27
69
  * Escapes text destined for the callback page.
28
70
  *
@@ -180,7 +222,10 @@ export async function startLoopbackListener(opts = {}) {
180
222
  timer = null;
181
223
  const { code, state, error } = result;
182
224
  if (error) {
183
- reject(new IntegrationsCliError(`Sign-in was declined (${error}).`, { expected: true }));
225
+ const outcome = authorizeErrorOutcome(error);
226
+ reject(new IntegrationsCliError(outcome.message, {
227
+ expected: outcome.expected,
228
+ }));
184
229
  return;
185
230
  }
186
231
  if (!code || !state) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.109.1",
3
+ "version": "5.109.3",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {