@indigoai-us/hq-cli 5.105.1 → 5.106.0

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,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.106.0] — 2026-09-02
6
+
7
+ ### Fixed
8
+
9
+ - `hq integrations inspect` now accepts a pasted endpoint URL. Passing
10
+ `https://mcp.example.com/mcp` used to be treated as free-text search and
11
+ answered "No integrations.sh results found" for an app listed under exactly
12
+ that host; it is now looked up by host.
13
+
14
+ ### Changed
15
+
16
+ - The Connect-an-app catalog now says when a listed app's availability could
17
+ not be re-confirmed on the last sweep. Such apps are tagged
18
+ `not recently verified` so a stale entry reads as unconfirmed rather than as
19
+ freshly checked. The apps are still listed and still connectable.
20
+
21
+ ## [5.105.2] — 2026-09-02
22
+
23
+ ### Fixed
24
+
25
+ - The bundled cloud sync engine now uses `@indigoai-us/hq-cloud` 6.16.3.
26
+ Session logs include hidden files, retain capture state on large first runs,
27
+ continue capturing after one bad file, and are saved only after capture
28
+ succeeds.
29
+
5
30
  ## [5.105.1] — 2026-08-31
6
31
 
7
32
  ### Fixed
@@ -133,7 +133,7 @@ export interface ProvisionAgentInput {
133
133
  name: string;
134
134
  slug: string;
135
135
  codexAuthMode: "subscription" | "apiKey";
136
- provider?: "codex" | "grok" | "claude";
136
+ provider?: "codex" | "grok" | "claude" | "agents-v2";
137
137
  codexApiKey?: string;
138
138
  idempotencyKey: string;
139
139
  title?: string;
@@ -45,7 +45,7 @@ export const VALID_EFFORTS = new Set([
45
45
  /** Service-tier (speed) values hq-pro accepts on `runtime-config`. */
46
46
  export const VALID_TIERS = new Set(["default", "priority"]);
47
47
  /** Agent runtimes hq-pro accepts on `POST /v1/agents` (`AgentProvider`). */
48
- export const VALID_PROVIDERS = new Set(["codex", "grok", "claude"]);
48
+ export const VALID_PROVIDERS = new Set(["codex", "grok", "claude", "agents-v2"]);
49
49
  /** Auth modes hq-pro accepts on `POST /v1/agents` (`CodexAuthMode`). */
50
50
  export const VALID_AUTH_MODES = new Set(["subscription", "apiKey"]);
51
51
  /** Customer-facing agent size keys served by hq-pro's authoritative catalog. */
@@ -764,7 +764,7 @@ export function registerAgentsCommand(program) {
764
764
  .description("Provision a new cloud agent (company-specific monthly price shown before creation)")
765
765
  .option("--company <slug>", "Company slug (resolves to companyUid)")
766
766
  .option("--slug <slug>", "Agent slug (defaults to a slug of <name>)")
767
- .option("--provider <provider>", "Runtime: codex | grok | claude (default codex). claude is subscription-only")
767
+ .option("--provider <provider>", "Runtime: codex | grok | claude | agents-v2 (default codex). claude is subscription-only; agents-v2 boots its brain box (codex, or grok via a grok-* model) and receives the v2 runtime post-boot")
768
768
  .option("--auth-mode <mode>", "Auth: subscription | apiKey (default subscription)", "subscription")
769
769
  .option("--api-key-env <VAR>", "Env var holding the API key for --auth-mode apiKey (never pass the key as a flag)")
770
770
  .option("--title <title>", "Org-chart job title")
@@ -22,6 +22,17 @@ export interface CatalogEntry {
22
22
  */
23
23
  source?: "hq-discovered" | "integrations.sh" | "hq-recommended";
24
24
  authClass?: "none" | "key" | "oauth";
25
+ /**
26
+ * Present when the app is listed on a CARRIED-FORWARD verdict: the latest
27
+ * catalog generation's probe answered HTTP 403, which establishes nothing,
28
+ * so the previous run's classification was kept rather than dropping the app
29
+ * on ambiguous evidence.
30
+ *
31
+ * The app is still offered — this qualifies the listing, it does not retract
32
+ * it. Absent (never `false`) on a freshly verified entry, so "no claim" and
33
+ * "claim is false" stay distinguishable.
34
+ */
35
+ verificationCarriedForward?: true;
25
36
  /** Opaque server-owned id; prefer it over echoing connection details back. */
26
37
  entryId?: string;
27
38
  }
@@ -186,6 +197,21 @@ export interface OAuthStartInput {
186
197
  * omitting it pins the console callback instead.
187
198
  */
188
199
  redirectUri?: string;
200
+ /**
201
+ * An OAuth app the admin registered with the provider themselves, for the
202
+ * providers that refuse to register HQ dynamically (they only issue
203
+ * confidential clients). hq-pro reads these ONLY on the direct `mcpUrl`
204
+ * path — a catalog, domain, or discovery-receipt connect keeps server-owned
205
+ * client resolution and drops them silently — so callers must pin an
206
+ * `mcpUrl` alongside them rather than hoping they were honoured.
207
+ */
208
+ clientId?: string;
209
+ /**
210
+ * The registered app's secret, for a confidential client. Optional: a public
211
+ * client is a `clientId` on its own. hq-pro keeps it on the server-side
212
+ * state row and attaches it at code exchange; it is never logged.
213
+ */
214
+ clientSecret?: string;
189
215
  }
190
216
  export declare function startOAuth(token: string, companyUid: string, input: OAuthStartInput): Promise<OAuthStartResult>;
191
217
  export declare function completeOAuth(token: string, companyUid: string, input: {
@@ -26,5 +26,25 @@ import { Command } from "commander";
26
26
  export declare function assertAuthMode(value: string | undefined): void;
27
27
  /** Single-quote a value for a copy-pasteable shell command. */
28
28
  export declare function shellQuote(value: string): string;
29
+ /**
30
+ * The console that fronts the control plane this CLI is pointed at.
31
+ *
32
+ * A handoff must land on the console for the SAME backend, because that
33
+ * console starts its own OAuth flow against its own backend. Sending a staging
34
+ * session to the production console would connect the app in the wrong place —
35
+ * so when the control plane is overridden and its console cannot be derived,
36
+ * return undefined and say so rather than guess a host.
37
+ *
38
+ * `HQ_CONSOLE_URL` is the explicit override; otherwise the console is the
39
+ * control-plane host with its `hqapi.` prefix removed.
40
+ */
41
+ export declare function consoleOrigin(vaultApiUrl?: string): string | undefined;
42
+ /**
43
+ * The console's Integrations page for a company, the console root when the
44
+ * caller never named one (a single-membership session resolves the company
45
+ * server-side, so hq-cli holds a uid here, not a slug — and the console routes
46
+ * by slug), or undefined when the console for this control plane is unknown.
47
+ */
48
+ export declare function consoleIntegrationsUrl(companySlug: string | undefined, origin: string | undefined): string | undefined;
29
49
  export declare function registerConnectCommands(integrations: Command): void;
30
50
  //# sourceMappingURL=integrations-connect.d.ts.map
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import chalk from "chalk";
20
20
  import open from "open";
21
- import { ensureCognitoIdToken } from "../utils/cognito-session.js";
21
+ import { DEFAULT_VAULT_API_URL, ensureCognitoIdToken, } from "../utils/cognito-session.js";
22
22
  import { getCompanyUid } from "../utils/vault-api.js";
23
23
  import { IntegrationsCliError, bareProvider, connectionDomain, printJson, revokedConnectionDetails, resolveConnection, } from "./integrations-core.js";
24
24
  import { completeOAuth, discoverDocs, installIntegration, listCatalog, pullBlueprint, startOAuth, } from "./integrations-api.js";
@@ -61,6 +61,21 @@ const CONSOLE_HANDOFF_CODES = new Set([
61
61
  "OAUTH_REDIRECT_URI_NOT_ALLOWED",
62
62
  "OAUTH_LOOPBACK_NOT_SUPPORTED",
63
63
  ]);
64
+ /**
65
+ * Codes meaning "this provider will not register HQ automatically; an admin
66
+ * must supply their own OAuth app's credentials".
67
+ *
68
+ * hq-pro attaches `redirectUri` + `advancedClientSupported` to exactly these
69
+ * (`withAdvancedClientHint`), and the console turns that into its
70
+ * bring-your-own-credentials form. hq-cli has no flag for a client id or
71
+ * secret, so on its own it can only restate the refusal — which reads as a
72
+ * dead end even though the recovery exists one surface over. Route to the
73
+ * console instead.
74
+ */
75
+ const BRING_YOUR_OWN_CLIENT_CODES = new Set([
76
+ "OAUTH_REGISTRATION_UNSUPPORTED",
77
+ "CLIENT_REGISTRATION_REFUSED",
78
+ ]);
64
79
  /**
65
80
  * Looks-like-a-domain test for the positional `<app>` argument. Deliberately
66
81
  * loose — hq-pro does the real resolution — but tight enough that `linear.app`
@@ -69,6 +84,27 @@ const CONSOLE_HANDOFF_CODES = new Set([
69
84
  function looksLikeDomain(value) {
70
85
  return /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(value) && !value.includes(" ");
71
86
  }
87
+ /**
88
+ * The host inside an endpoint URL, or the value unchanged when it is not one.
89
+ *
90
+ * `hq integrations inspect` is the "what does this expose before I connect it"
91
+ * command, so the natural thing to paste is the endpoint URL itself. Without
92
+ * this, `https://vyg.app/mcp` failed `looksLikeDomain` and went out as a free
93
+ * text SEARCH, which answered "No integrations.sh results found" for an app
94
+ * that is in the catalog under exactly that host. hq-pro accepts a URL-shaped
95
+ * domain, so the only thing missing was sending it as one.
96
+ */
97
+ function hostFromEndpointUrl(value) {
98
+ const trimmed = value.trim();
99
+ if (!/^https?:\/\//i.test(trimmed))
100
+ return trimmed;
101
+ try {
102
+ return new URL(trimmed).hostname;
103
+ }
104
+ catch {
105
+ return trimmed;
106
+ }
107
+ }
72
108
  /**
73
109
  * True when this shell is almost certainly on a different machine from the
74
110
  * person's browser. Not a security boundary — just the signal that a loopback
@@ -325,6 +361,26 @@ async function findCatalogEntryByName(token, companyUid, name) {
325
361
  return null;
326
362
  }
327
363
  }
364
+ /**
365
+ * Confirm the backend recognises this target, without connecting anything.
366
+ *
367
+ * `pullBlueprint` is the read-only half of the install: it takes the same
368
+ * domain / query / catalogEntryId handles and 404s an unknown one. It has no
369
+ * shape for the two remaining refs, and neither needs it — a discovery receipt
370
+ * was minted by `discoverDocs` moments earlier in `resolveTarget`, and an
371
+ * `--mcp-url` is an endpoint the caller typed rather than a handle the backend
372
+ * could confirm (the handoff prints it back so they can see what they gave).
373
+ */
374
+ async function assertTargetResolvable(token, companyUid, target) {
375
+ const { domain, query, catalogEntryId } = target.ref;
376
+ if (!domain && !query && !catalogEntryId)
377
+ return;
378
+ await pullBlueprint(token, companyUid, {
379
+ ...(domain ? { domain } : {}),
380
+ ...(query ? { query } : {}),
381
+ ...(catalogEntryId ? { catalogEntryId } : {}),
382
+ });
383
+ }
328
384
  /**
329
385
  * Run the browser sign-in and finish the install.
330
386
  *
@@ -343,13 +399,26 @@ async function connectViaOAuth(token, companyUid, target, opts) {
343
399
  });
344
400
  }
345
401
  const startInput = { ...target.ref };
402
+ // A user-registered app pins the console callback and skips the loopback
403
+ // entirely — see `userClientSignIn` for why an ephemeral port cannot be the
404
+ // redirect URI here.
405
+ const userClient = await resolveUserOAuthClient(opts, target.label);
406
+ if (userClient)
407
+ return await userClientSignIn(token, companyUid, target, opts, userClient);
346
408
  // A loopback listener only works if the browser runs on THIS machine. Over
347
409
  // SSH the person opens the printed URL on their workstation, so the provider
348
410
  // redirects to the workstation's 127.0.0.1 while the listener sits on the
349
411
  // remote host — the callback can never arrive and the command just times
350
412
  // out. Go straight to the console handoff instead of failing slowly.
351
413
  if (opts.browser === false && isRemoteShell()) {
352
- return await consoleHandoff(token, companyUid, startInput, target, opts);
414
+ // Every other route into the handoff has already put the target in front
415
+ // of the backend (`/oauth/start` rejects one it cannot resolve). This one
416
+ // returns before any request, so a target hq-pro would refuse — a stale
417
+ // `--entry-id`, an unmatched name that fell through to a raw query — would
418
+ // be answered with a confident "go finish it in the console" for an app
419
+ // that is not there. Ask the backend first.
420
+ await assertTargetResolvable(token, companyUid, target);
421
+ return await consoleHandoff(target, opts);
353
422
  }
354
423
  const listener = await startLoopbackListener(timeoutMs === undefined ? {} : { timeoutMs });
355
424
  try {
@@ -361,13 +430,16 @@ async function connectViaOAuth(token, companyUid, target, opts) {
361
430
  });
362
431
  }
363
432
  catch (err) {
364
- if (!(err instanceof IntegrationsCliError) ||
365
- !err.code ||
366
- !CONSOLE_HANDOFF_CODES.has(err.code)) {
433
+ if (!(err instanceof IntegrationsCliError) || !err.code)
367
434
  throw err;
435
+ if (BRING_YOUR_OWN_CLIENT_CODES.has(err.code)) {
436
+ listener.close();
437
+ return await bringYourOwnClientHandoff(target, opts, err.message, err.redirectUri);
368
438
  }
439
+ if (!CONSOLE_HANDOFF_CODES.has(err.code))
440
+ throw err;
369
441
  listener.close();
370
- return await consoleHandoff(token, companyUid, startInput, target, opts);
442
+ return await consoleHandoff(target, opts);
371
443
  }
372
444
  // Arm the waiter BEFORE the browser opens. The listener buffers a redirect
373
445
  // that beats it, but ordering it this way means the happy path never
@@ -391,20 +463,260 @@ async function connectViaOAuth(token, companyUid, target, opts) {
391
463
  listener.close();
392
464
  }
393
465
  }
466
+ const PRODUCTION_CONSOLE_ORIGIN = "https://hq.computer";
467
+ const PRODUCTION_VAULT_API_HOST = "hqapi.hq.computer";
394
468
  /**
395
- * Fallback for a backend that will not accept a loopback callback: start the
396
- * sign-in against hq-pro's own console callback and hand the URL over. The
397
- * console route finishes the install, so this command cannot report the
398
- * result it reports the handoff truthfully instead of pretending.
469
+ * The console that fronts the control plane this CLI is pointed at.
470
+ *
471
+ * A handoff must land on the console for the SAME backend, because that
472
+ * console starts its own OAuth flow against its own backend. Sending a staging
473
+ * session to the production console would connect the app in the wrong place —
474
+ * so when the control plane is overridden and its console cannot be derived,
475
+ * return undefined and say so rather than guess a host.
476
+ *
477
+ * `HQ_CONSOLE_URL` is the explicit override; otherwise the console is the
478
+ * control-plane host with its `hqapi.` prefix removed.
399
479
  */
400
- async function consoleHandoff(token, companyUid, startInput, target, opts) {
401
- const started = await startOAuth(token, companyUid, startInput);
402
- const name = started.displayName || target.label;
403
- console.error(chalk.yellow(`${name} signs in through the browser, and this HQ backend finishes those sign-ins in the console.`));
404
- console.error(`Open this URL to sign in:\n ${started.authorizationUrl}`);
480
+ export function consoleOrigin(vaultApiUrl = DEFAULT_VAULT_API_URL) {
481
+ const explicit = process.env.HQ_CONSOLE_URL?.trim();
482
+ if (explicit)
483
+ return explicit.replace(/\/+$/, "");
484
+ let api;
485
+ try {
486
+ api = new URL(vaultApiUrl);
487
+ }
488
+ catch {
489
+ return undefined;
490
+ }
491
+ if (api.host === PRODUCTION_VAULT_API_HOST)
492
+ return PRODUCTION_CONSOLE_ORIGIN;
493
+ if (!api.hostname.startsWith("hqapi."))
494
+ return undefined;
495
+ return `${api.protocol}//${api.host.slice("hqapi.".length)}`;
496
+ }
497
+ /**
498
+ * The console's Integrations page for a company, the console root when the
499
+ * caller never named one (a single-membership session resolves the company
500
+ * server-side, so hq-cli holds a uid here, not a slug — and the console routes
501
+ * by slug), or undefined when the console for this control plane is unknown.
502
+ */
503
+ export function consoleIntegrationsUrl(
504
+ // Required, deliberately: a default of `consoleOrigin()` would make an
505
+ // explicit `undefined` — exactly what an unresolvable control plane yields —
506
+ // silently fall back to the production console.
507
+ companySlug, origin) {
508
+ if (!origin)
509
+ return undefined;
510
+ const slug = companySlug?.trim();
511
+ // A `cmp_…` uid is not a slug; the console has no route for it.
512
+ if (!slug || slug.startsWith("cmp_"))
513
+ return origin;
514
+ return `${origin}/companies/${encodeURIComponent(slug)}/integrations`;
515
+ }
516
+ /**
517
+ * What the caller named, phrased for someone about to retype it in the console.
518
+ *
519
+ * A `--mcp-url` or `--docs-url` target has no catalog row to search for over
520
+ * there — `resolveTarget` reduces a docs page to an opaque discovery receipt
521
+ * the console cannot take — so unless the handoff carries the address back,
522
+ * the only copy of it is in the caller's scrollback. A catalog target needs no
523
+ * hint: its label IS what they type into the console's search box.
524
+ */
525
+ function targetSelectionHint(opts) {
526
+ if (opts.mcpUrl)
527
+ return `Server address to enter there: ${opts.mcpUrl}`;
528
+ if (opts.docsUrl)
529
+ return `Found from these docs: ${opts.docsUrl}`;
530
+ if (opts.entryId)
531
+ return `Catalog entry: ${opts.entryId}`;
532
+ return undefined;
533
+ }
534
+ /** Shared tail of every console handoff: the page, then what to connect there. */
535
+ function printConsoleDestination(target, opts, url) {
536
+ if (url) {
537
+ console.error(`Open this page and connect ${target.label} from it:\n ${url}`);
538
+ }
539
+ else {
540
+ console.error(`Open your HQ console's Integrations page and connect ${target.label} there. ` +
541
+ "(This session points at a control plane whose console address is not " +
542
+ "derivable — set HQ_CONSOLE_URL to name it.)");
543
+ }
544
+ const hint = targetSelectionHint(opts);
545
+ if (hint)
546
+ console.error(` ${hint}`);
547
+ }
548
+ /**
549
+ * Fallback for a backend that will not accept a loopback callback: send the
550
+ * caller to the console and let it run the whole sign-in.
551
+ *
552
+ * This deliberately does NOT start an OAuth flow. It used to: it called
553
+ * `/oauth/start` against hq-pro's console callback and printed the vendor
554
+ * authorization URL. That URL could never complete. The console's callback
555
+ * route resolves which company to return to from a short-lived httpOnly cookie
556
+ * that only its own `startFactoryOAuthSignIn` server action sets, so a browser
557
+ * arriving from a CLI-minted URL carries no cookie, and the route — by design,
558
+ * rather than erroring — redirects to the console root. The user signed in
559
+ * successfully at the vendor every time and landed on the home page with no
560
+ * explanation, and the burned single-use state row made it look intermittent.
561
+ *
562
+ * Handing over the console page instead means the sign-in is started by the
563
+ * surface that can finish it. hq-cli still cannot report the outcome, so it
564
+ * says so rather than pretending.
565
+ */
566
+ async function consoleHandoff(target, opts) {
567
+ const url = consoleIntegrationsUrl(opts.company, consoleOrigin());
568
+ console.error(chalk.yellow(`${target.label} signs in through the console, not the terminal — this HQ backend finishes these sign-ins there.`));
569
+ printConsoleDestination(target, opts, url);
570
+ if (url && opts.browser !== false)
571
+ await open(url).catch(() => { });
572
+ console.error(chalk.dim("When the browser says it connected, run `hq integrations list` to confirm."));
573
+ return null;
574
+ }
575
+ /**
576
+ * The provider refuses to register HQ automatically. That is recoverable — an
577
+ * admin registers their own OAuth app with the provider and pastes its
578
+ * credentials — but only the console can collect them, so say what is needed
579
+ * and hand the page over rather than restating the refusal and stopping.
580
+ */
581
+ async function bringYourOwnClientHandoff(target, opts, reason, redirectUri) {
582
+ console.error(chalk.yellow(reason));
583
+ console.error(`${target.label} needs an OAuth app you register with the provider yourself.`);
584
+ // Prefer finishing here. The recovery needs two things the terminal can
585
+ // supply — the callback URL to register, and a client id + secret — and
586
+ // hq-pro sends the first one back on exactly these errors. Falling through
587
+ // to the console when either half is missing keeps the old behaviour rather
588
+ // than printing a half-command that cannot work.
589
+ const command = byoClientCommand(target, opts);
590
+ if (redirectUri && command) {
591
+ console.error("");
592
+ console.error(`In the provider's developer portal, register this callback URL:`);
593
+ console.error(` ${redirectUri}`);
594
+ console.error(`Then run:`);
595
+ console.error(` ${command}`);
596
+ console.error(chalk.dim("The secret is read from stdin, so it stays out of your shell history. " +
597
+ "Omit --client-secret-stdin for a public app that has no secret."));
598
+ return null;
599
+ }
600
+ const url = consoleIntegrationsUrl(opts.company, consoleOrigin());
601
+ console.error("The console shows the callback URL to register and takes the credentials.");
602
+ printConsoleDestination(target, opts, url);
603
+ if (url && opts.browser !== false)
604
+ await open(url).catch(() => { });
605
+ return null;
606
+ }
607
+ /**
608
+ * The copy-pasteable command that finishes a bring-your-own-app connect.
609
+ *
610
+ * Always `connect --mcp-url`, even when the caller reached here through
611
+ * `reconnect`: re-installing the same endpoint IS the reconnect, and the
612
+ * direct-endpoint path is the only one hq-pro reads user-supplied client
613
+ * credentials on. Null when the endpoint is unknown (a bare-name or domain
614
+ * connect that never resolved to a URL) — there is no honest command to print
615
+ * then, so the caller falls back to the console.
616
+ */
617
+ function byoClientCommand(target, opts) {
618
+ const { mcpUrl, provider } = target.ref;
619
+ if (!mcpUrl)
620
+ return null;
621
+ const parts = ["hq integrations connect", `--mcp-url ${shellQuote(mcpUrl)}`];
622
+ if (opts.company)
623
+ parts.push(`--company ${shellQuote(opts.company)}`);
624
+ if (provider)
625
+ parts.push(`--provider ${shellQuote(provider)}`);
626
+ parts.push("--client-id <client-id-from-the-provider>", "--client-secret-stdin");
627
+ return parts.join(" ");
628
+ }
629
+ /**
630
+ * Refuse a `--client-id` that hq-pro would silently ignore, before anything is
631
+ * installed.
632
+ *
633
+ * hq-pro reads user-supplied client credentials only on the direct `mcpUrl`
634
+ * path — on a catalog, domain, or discovery-receipt connect it nulls them out
635
+ * and resolves its own client instead. Passing them there would not fail: the
636
+ * connect would attempt dynamic registration exactly as before and come back
637
+ * with the same refusal, leaving the caller certain they had supplied
638
+ * credentials that were never read. Say so instead.
639
+ */
640
+ function assertUserClientApplicable(opts, target, authMode) {
641
+ if (!opts.clientId && !opts.clientSecretStdin)
642
+ return;
643
+ if (!opts.clientId) {
644
+ throw new IntegrationsCliError("--client-secret-stdin needs --client-id — a secret identifies no app on its own.", { expected: true });
645
+ }
646
+ if (opts.token || opts.tokenStdin) {
647
+ throw new IntegrationsCliError("--client-id is for a browser sign-in and --token is for an API key; pass one or the other, not both.", { expected: true });
648
+ }
649
+ // Only an mode the caller or the catalog stated. An UNKNOWN mode is left
650
+ // alone on purpose: the direct-endpoint path routinely discovers it is
651
+ // OAuth-protected only when hq-pro says so, and rejecting here would block
652
+ // the exact providers this flag exists for.
653
+ if (authMode === "key" || authMode === "none") {
654
+ throw new IntegrationsCliError(`${target.label} does not use a browser sign-in, so --client-id does not apply to it.`, { expected: true });
655
+ }
656
+ if (!target.ref.mcpUrl) {
657
+ throw new IntegrationsCliError("Your own OAuth app can only be used with a specific endpoint. " +
658
+ "Re-run with --mcp-url <endpoint> (find it with `hq integrations inspect`), " +
659
+ "or connect it from the console.", { expected: true });
660
+ }
661
+ }
662
+ /**
663
+ * Collect the user-registered OAuth app, if one was asked for.
664
+ *
665
+ * The secret has no argv flag by design. A client secret is a long-lived
666
+ * credential, and argv is visible in shell history and to `ps`; stdin covers
667
+ * the scripted case and a hidden prompt covers the interactive one, so there
668
+ * is no case left that an argv flag would serve.
669
+ */
670
+ async function resolveUserOAuthClient(opts, appLabel) {
671
+ const clientId = opts.clientId?.trim();
672
+ if (!clientId)
673
+ return null;
674
+ if (opts.clientSecretStdin) {
675
+ const piped = (await readStdin()).trim();
676
+ if (!piped) {
677
+ throw new IntegrationsCliError("--client-secret-stdin was set but stdin was empty.", { expected: true });
678
+ }
679
+ return { clientId, clientSecret: piped };
680
+ }
681
+ if (process.stdin.isTTY) {
682
+ const value = (await promptForKey(`Client secret for ${appLabel} (leave blank if the app has none)`)).trim();
683
+ return value ? { clientId, clientSecret: value } : { clientId };
684
+ }
685
+ // Non-interactive and no secret piped: a public client is legitimate, so
686
+ // proceed with the id alone rather than inventing a blank secret.
687
+ return { clientId };
688
+ }
689
+ /**
690
+ * Sign in with an OAuth app the admin registered themselves.
691
+ *
692
+ * No loopback listener, deliberately. The whole premise is that the person
693
+ * registered ONE redirect URI with the provider ahead of time, and an
694
+ * ephemeral loopback port can never be one of them — so this pins hq-pro's
695
+ * console callback by omitting `redirectUri`, and the sign-in completes on the
696
+ * console. hq-cli cannot observe that completion, so it says what it knows and
697
+ * points at the check, rather than reporting an install it never saw.
698
+ *
699
+ * This requires the console callback to be able to finish a flow it did not
700
+ * start. It could not until hq-pro/#2823 + hq-console/#783: the return company
701
+ * came from a cookie only the console's own start action set, so a CLI-minted
702
+ * URL arrived cookie-less and the route dropped the user on the console root
703
+ * with nothing connected — see `consoleHandoff` below, which exists because of
704
+ * exactly that. hq-pro now resolves the company from the state row when
705
+ * `companyUid` is omitted, so the callback completes and the console names the
706
+ * app it connected. Ship this only against a backend carrying both.
707
+ */
708
+ async function userClientSignIn(token, companyUid, target, opts, client) {
709
+ const started = await startOAuth(token, companyUid, {
710
+ ...target.ref,
711
+ clientId: client.clientId,
712
+ ...(client.clientSecret ? { clientSecret: client.clientSecret } : {}),
713
+ });
714
+ console.error(`Open this URL to sign in to ${started.displayName || target.label}:`);
715
+ console.error(` ${started.authorizationUrl}`);
405
716
  if (opts.browser !== false)
406
717
  await open(started.authorizationUrl).catch(() => { });
407
- console.error(chalk.dim("When the browser says it connected, run `hq integrations list` to confirm."));
718
+ console.error(chalk.dim("The browser finishes on the HQ console. When it says connected, " +
719
+ "run `hq integrations list` to confirm."));
408
720
  return null;
409
721
  }
410
722
  /**
@@ -459,6 +771,7 @@ function reportInstall(result, opts, expectedRevivedConnectionId) {
459
771
  async function connectApp(token, companyUid, app, opts, expectedRevivedConnectionId) {
460
772
  const target = await resolveTarget(token, companyUid, app, opts, expectedRevivedConnectionId !== undefined);
461
773
  const authMode = opts.auth ?? target.authClass;
774
+ assertUserClientApplicable(opts, target, authMode);
462
775
  if (authMode === "oauth") {
463
776
  const result = await connectViaOAuth(token, companyUid, target, opts);
464
777
  if (result)
@@ -536,6 +849,11 @@ export function registerConnectCommands(integrations) {
536
849
  entry.source === "hq-recommended" ? "recommended" : null,
537
850
  entry.source === "hq-discovered" ? "community" : null,
538
851
  entry.mcpReady ? null : "not one-click",
852
+ // The listing is a claim about connectability, and for these apps HQ
853
+ // could not re-confirm it: the last probe was inconclusive and the
854
+ // previous verdict was carried forward. Say so on the row rather
855
+ // than let it read the same as an app checked this morning.
856
+ entry.verificationCarriedForward ? "not recently verified" : null,
539
857
  ]
540
858
  .filter(Boolean)
541
859
  .join(" · ");
@@ -553,7 +871,10 @@ export function registerConnectCommands(integrations) {
553
871
  .action(async (app, opts) => {
554
872
  const token = await ensureCognitoIdToken();
555
873
  const companyUid = await getCompanyUid(token, opts.company);
556
- const blueprint = await pullBlueprint(token, companyUid, looksLikeDomain(app) ? { domain: app } : { query: app });
874
+ // A pasted endpoint URL names one host; look that host up rather than
875
+ // searching the catalog for the URL as free text.
876
+ const host = hostFromEndpointUrl(app);
877
+ const blueprint = await pullBlueprint(token, companyUid, looksLikeDomain(host) ? { domain: host } : { query: app });
557
878
  if (opts.json) {
558
879
  printJson(blueprint);
559
880
  return;
@@ -609,6 +930,8 @@ export function registerConnectCommands(integrations) {
609
930
  .option("--token <key>", "API key (prefer --token-stdin: --token lands in shell history)")
610
931
  .option("--token-stdin", "Read the API key from stdin")
611
932
  .option("--auth <mode>", "Force the auth mode: none, key, or oauth (default: detect)")
933
+ .option("--client-id <id>", "Client id of an OAuth app you registered with the provider yourself")
934
+ .option("--client-secret-stdin", "Read that app's client secret from stdin")
612
935
  .option("--no-browser", "Print the sign-in URL instead of opening a browser")
613
936
  .option("--timeout <seconds>", "How long to wait for a browser sign-in (default 300)")
614
937
  .option("--json", "Machine-readable output")
@@ -627,6 +950,8 @@ export function registerConnectCommands(integrations) {
627
950
  .option("--token <key>", "API key (prefer --token-stdin)")
628
951
  .option("--token-stdin", "Read the API key from stdin")
629
952
  .option("--auth <mode>", "Force the auth mode: none, key, or oauth (default: detect)")
953
+ .option("--client-id <id>", "Client id of an OAuth app you registered with the provider yourself")
954
+ .option("--client-secret-stdin", "Read that app's client secret from stdin")
630
955
  .option("--no-browser", "Print the sign-in URL instead of opening a browser")
631
956
  .option("--timeout <seconds>", "How long to wait for a browser sign-in (default 300)")
632
957
  .option("--connect", "For a revoked row, run `connect <domain>` to re-add and revive it")
@@ -673,6 +998,13 @@ export function registerConnectCommands(integrations) {
673
998
  ...(opts.auth ? { authClass: opts.auth } : {}),
674
999
  label: connection.installation?.displayName ?? bareProvider(connection.provider),
675
1000
  };
1001
+ // Same gate `connectApp` applies. This path does not go through it —
1002
+ // it resolves the endpoint off the existing connection and branches
1003
+ // itself — so without this, flags the CLI promises to reject are
1004
+ // silently ignored instead: `--client-secret-stdin` with no id would
1005
+ // start an ordinary sign-in, and `--client-id` beside `--token` would
1006
+ // reinstall with the bearer token and drop the OAuth app.
1007
+ assertUserClientApplicable(opts, target, target.authClass);
676
1008
  if (target.authClass === "oauth") {
677
1009
  const result = await connectViaOAuth(token, companyUid, target, opts);
678
1010
  if (result)
@@ -150,12 +150,23 @@ export declare class IntegrationsCliError extends Error {
150
150
  * fingerprint as anything but the finite allowlist in sentry-fingerprint.ts.
151
151
  */
152
152
  readonly rpcCode?: number;
153
+ /**
154
+ * HQ's own OAuth callback URL, carried on the errors that say a provider
155
+ * refused to register HQ automatically. It is what the admin must register
156
+ * with the provider before their own OAuth app can be used, and it is NOT a
157
+ * secret — every authorization server already receives it in the authorize
158
+ * URL. Taken from the response rather than assembled locally so the CLI
159
+ * prints the callback this backend will actually redirect to, instead of a
160
+ * guess at its route that would send the admin to register the wrong URL.
161
+ */
162
+ readonly redirectUri?: string;
153
163
  constructor(message: string, opts?: {
154
164
  expected?: boolean;
155
165
  code?: string;
156
166
  status?: number;
157
167
  oauthProtected?: boolean;
158
168
  rpcCode?: number;
169
+ redirectUri?: string;
159
170
  });
160
171
  }
161
172
  /**
@@ -51,6 +51,16 @@ export class IntegrationsCliError extends Error {
51
51
  * fingerprint as anything but the finite allowlist in sentry-fingerprint.ts.
52
52
  */
53
53
  rpcCode;
54
+ /**
55
+ * HQ's own OAuth callback URL, carried on the errors that say a provider
56
+ * refused to register HQ automatically. It is what the admin must register
57
+ * with the provider before their own OAuth app can be used, and it is NOT a
58
+ * secret — every authorization server already receives it in the authorize
59
+ * URL. Taken from the response rather than assembled locally so the CLI
60
+ * prints the callback this backend will actually redirect to, instead of a
61
+ * guess at its route that would send the admin to register the wrong URL.
62
+ */
63
+ redirectUri;
54
64
  constructor(message, opts = {}) {
55
65
  super(message);
56
66
  this.name = "IntegrationsCliError";
@@ -63,6 +73,8 @@ export class IntegrationsCliError extends Error {
63
73
  this.oauthProtected = opts.oauthProtected;
64
74
  if (opts.rpcCode !== undefined)
65
75
  this.rpcCode = opts.rpcCode;
76
+ if (opts.redirectUri !== undefined)
77
+ this.redirectUri = opts.redirectUri;
66
78
  }
67
79
  }
68
80
  /**
@@ -184,13 +196,34 @@ export async function raiseForResponse(res, fallback) {
184
196
  // print verbatim — scrub credentials out before it can reach a terminal or a
185
197
  // Sentry crash report (same reasoning as the gateway path below).
186
198
  const detail = redactErrorText(body.error ?? "");
199
+ const redirectUri = httpsUrlOrNull(body.redirectUri);
187
200
  throw new IntegrationsCliError(detail || `${fallback} (HTTP ${res.status})`, {
188
201
  expected: isClientError(res.status),
189
202
  status: res.status,
190
203
  ...(body.code ? { code: body.code } : {}),
191
204
  ...(body.oauthProtected === true ? { oauthProtected: true } : {}),
205
+ ...(redirectUri ? { redirectUri } : {}),
192
206
  });
193
207
  }
208
+ /**
209
+ * Accept a response field only if it really is an absolute https URL.
210
+ *
211
+ * This one is printed as an instruction — "register THIS with the provider" —
212
+ * so a malformed or non-https value must be dropped rather than echoed: the
213
+ * caller's fallback (send them to the console, which shows the same callback)
214
+ * is correct, while a bad URL would have them register a callback that can
215
+ * never complete a sign-in.
216
+ */
217
+ function httpsUrlOrNull(value) {
218
+ if (typeof value !== "string" || !value.trim())
219
+ return null;
220
+ try {
221
+ return new URL(value).protocol === "https:" ? value : null;
222
+ }
223
+ catch {
224
+ return null;
225
+ }
226
+ }
194
227
  /** "factory:linear" → "linear"; mirrors hq-pro's factoryToolPrefix. */
195
228
  export function toolPrefixForProvider(provider) {
196
229
  return provider
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.105.1",
3
+ "version": "5.106.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -30,7 +30,7 @@
30
30
  "dependencies": {
31
31
  "@aws-sdk/client-iot-data-plane": "^3.1096.0",
32
32
  "@aws-sdk/client-s3": "^3.1049.0",
33
- "@indigoai-us/hq-cloud": "~6.16.1",
33
+ "@indigoai-us/hq-cloud": "~6.16.3",
34
34
  "@indigoai-us/hq-onboarding": "^0.1.0",
35
35
  "@sentry/node": "^10.49.0",
36
36
  "@tobilu/qmd": "2.5.3",