@indigoai-us/hq-cli 5.103.1 → 5.103.2

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,8 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.103.2] — 2026-08-19
6
+
5
7
  ## [5.103.1] — 2026-08-18
6
8
 
7
9
  ### Fixed
@@ -206,6 +206,25 @@ export interface ConnectionAccess {
206
206
  grantedAt: string;
207
207
  }>;
208
208
  }
209
+ export interface PendingApproval {
210
+ queueId: string;
211
+ connectionId: string;
212
+ provider: string;
213
+ /** Gateway tool (…mcp.tools.call). */
214
+ toolName: string;
215
+ /** Concrete app tool the caller named, when carried by the queued call. */
216
+ requestedTool?: string | null;
217
+ requestedBy: string;
218
+ requestedByName?: string | null;
219
+ createdAt: string;
220
+ expiresAt: string;
221
+ }
222
+ /**
223
+ * Live open-approval list, read from hq-pro's confirm-queue state (not the
224
+ * audit feed). Unlike the audit-derived reconstruction this replaces, a call
225
+ * that was approved/rejected/expired no longer appears here.
226
+ */
227
+ export declare function fetchPendingApprovals(token: string, companyUid: string): Promise<PendingApproval[]>;
209
228
  export declare function getConnectionAccess(token: string, companyUid: string, connectionId: string): Promise<ConnectionAccess>;
210
229
  export declare function mutateConnectionAccess(token: string, companyUid: string, action: "grant" | "revoke", input: {
211
230
  connectionId: string;
@@ -110,6 +110,22 @@ export async function updateGovernance(token, companyUid, input) {
110
110
  await raiseForResponse(res, "Failed to update the app's settings");
111
111
  return (await res.json());
112
112
  }
113
+ /**
114
+ * Live open-approval list, read from hq-pro's confirm-queue state (not the
115
+ * audit feed). Unlike the audit-derived reconstruction this replaces, a call
116
+ * that was approved/rejected/expired no longer appears here.
117
+ */
118
+ export async function fetchPendingApprovals(token, companyUid) {
119
+ const res = await vaultApiFetch({
120
+ token,
121
+ path: "/v1/integrations/factory/pending",
122
+ query: { companyUid },
123
+ });
124
+ if (!res.ok)
125
+ await raiseForResponse(res, "Failed to list pending approvals");
126
+ const body = (await res.json());
127
+ return body.pending ?? [];
128
+ }
113
129
  export async function getConnectionAccess(token, companyUid, connectionId) {
114
130
  const res = await vaultApiFetch({
115
131
  token,
@@ -25,6 +25,24 @@ import { completeOAuth, discoverDocs, installIntegration, listCatalog, pullBluep
25
25
  import { 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
+ /**
29
+ * A server-authoritative "this endpoint is OAuth-protected, run the browser
30
+ * sign-in instead" signal, in EITHER of the two shapes hq-pro emits it:
31
+ *
32
+ * - the catalog/discovery install path returns the
33
+ * `INTEGRATION_FACTORY_OAUTH_REQUIRED` code, and
34
+ * - the direct-MCP install path returns `DIRECT_MCP_AUTH_REJECTED` with
35
+ * `oauthProtected: true`.
36
+ *
37
+ * Keying off only the code left the direct-MCP path (e.g. `--mcp-url` against
38
+ * a sign-in server, or a bare-domain OAuth app) surfacing the raw
39
+ * "uses OAuth sign-in rather than a pasted access key" error instead of
40
+ * transparently switching to the sign-in flow.
41
+ */
42
+ function isOAuthRequiredError(err) {
43
+ return (err instanceof IntegrationsCliError &&
44
+ (err.code === OAUTH_REQUIRED_CODE || err.oauthProtected === true));
45
+ }
28
46
  /**
29
47
  * hq-pro will not take our loopback callback, for either of the two reasons it
30
48
  * can refuse one:
@@ -204,20 +222,40 @@ async function resolveTarget(token, companyUid, app, opts) {
204
222
  // authClass, both of which make the connect cleaner than a raw domain
205
223
  // lookup. Missing it is fine — the domain path still works.
206
224
  const match = await findCatalogEntry(token, companyUid, app);
207
- if (match?.entryId) {
208
- return {
209
- ref: { catalogEntryId: match.entryId },
210
- ...(match.authClass ? { authClass: match.authClass } : {}),
211
- label: match.name || app,
212
- };
213
- }
225
+ return catalogEntryToTarget(match, { ref: { domain: app }, label: app });
226
+ }
227
+ // A bare name (`notion`, `atlassian`) is how the catalog reads to a person —
228
+ // the rows are titled `notion.com`, `atlassian.com`. Resolve it to that row
229
+ // (by its registrable label / provider / display name) so `connect notion`
230
+ // is `connect notion.com`. The match also carries the authClass, so an OAuth
231
+ // app routes to sign-in instead of a doomed key install. Ambiguous or
232
+ // unmatched → fall back to the server-side query resolver.
233
+ const named = await findCatalogEntryByName(token, companyUid, app);
234
+ if (named) {
235
+ return catalogEntryToTarget(named, { ref: { query: app }, label: app });
236
+ }
237
+ return { ref: { query: app }, label: app };
238
+ }
239
+ /**
240
+ * Turn a matched catalog entry into a connect target, preferring its opaque
241
+ * entryId (the cleanest handle) and otherwise its domain. `fallback` is used
242
+ * verbatim when there was no catalog match at all.
243
+ */
244
+ function catalogEntryToTarget(match, fallback) {
245
+ if (!match)
246
+ return fallback;
247
+ if (match.entryId) {
214
248
  return {
215
- ref: { domain: app },
216
- ...(match?.authClass ? { authClass: match.authClass } : {}),
217
- label: match?.name || app,
249
+ ref: { catalogEntryId: match.entryId },
250
+ ...(match.authClass ? { authClass: match.authClass } : {}),
251
+ label: match.name || fallback.label,
218
252
  };
219
253
  }
220
- return { ref: { query: app }, label: app };
254
+ return {
255
+ ref: { domain: match.domain },
256
+ ...(match.authClass ? { authClass: match.authClass } : {}),
257
+ label: match.name || fallback.label,
258
+ };
221
259
  }
222
260
  /** Best-effort catalog lookup by domain; never fails the connect. */
223
261
  async function findCatalogEntry(token, companyUid, domain) {
@@ -232,6 +270,41 @@ async function findCatalogEntry(token, companyUid, domain) {
232
270
  return null;
233
271
  }
234
272
  }
273
+ /**
274
+ * The registrable label of a domain — its first dot-separated segment
275
+ * (`notion.com` → `notion`, `linear.app` → `linear`). This is the short name a
276
+ * person types for a catalog app.
277
+ */
278
+ function domainLabel(domain) {
279
+ return domain.trim().toLowerCase().split(".")[0] ?? "";
280
+ }
281
+ /**
282
+ * Best-effort resolution of a bare name to a single catalog entry. Matches the
283
+ * entry's registrable domain label (`notion` → `notion.com`) or an exact
284
+ * display-name. Returns null when nothing matches OR when more than one DISTINCT
285
+ * app matches — an ambiguous short name must not silently connect the wrong app,
286
+ * so it falls back to the server-side query resolver. Never throws: the catalog
287
+ * is an optimization, not the source of truth.
288
+ */
289
+ async function findCatalogEntryByName(token, companyUid, name) {
290
+ try {
291
+ const want = name.trim().toLowerCase();
292
+ if (!want)
293
+ return null;
294
+ const entries = await listCatalog(token, companyUid, { query: name, limit: 20 });
295
+ const matches = entries.filter((entry) => domainLabel(entry.domain) === want ||
296
+ entry.name?.trim().toLowerCase() === want);
297
+ if (matches.length === 0)
298
+ return null;
299
+ // Collapse rows that point at the same app (same domain) before deciding
300
+ // ambiguity, so one app surfaced twice is still an unambiguous match.
301
+ const distinct = new Map(matches.map((m) => [m.domain.trim().toLowerCase(), m]));
302
+ return distinct.size === 1 ? [...distinct.values()][0] : null;
303
+ }
304
+ catch {
305
+ return null;
306
+ }
307
+ }
235
308
  /**
236
309
  * Run the browser sign-in and finish the install.
237
310
  *
@@ -507,7 +580,7 @@ export function registerConnectCommands(integrations) {
507
580
  // Server-authoritative detection: the endpoint turned out to be
508
581
  // OAuth-protected, so run the browser flow instead of making the
509
582
  // caller re-issue the command with --auth oauth.
510
- if (err instanceof IntegrationsCliError && err.code === OAUTH_REQUIRED_CODE) {
583
+ if (isOAuthRequiredError(err)) {
511
584
  const result = await connectViaOAuth(token, companyUid, target, opts);
512
585
  if (result)
513
586
  reportInstall(result, opts);
@@ -570,7 +643,7 @@ export function registerConnectCommands(integrations) {
570
643
  reportInstall(await completeCredentialIfNeeded(token, companyUid, target, opts, result), opts);
571
644
  }
572
645
  catch (err) {
573
- if (err instanceof IntegrationsCliError && err.code === OAUTH_REQUIRED_CODE) {
646
+ if (isOAuthRequiredError(err)) {
574
647
  const result = await connectViaOAuth(token, companyUid, target, opts);
575
648
  if (result)
576
649
  reportInstall(result, opts);
@@ -120,10 +120,20 @@ export declare class IntegrationsCliError extends Error {
120
120
  readonly code?: string;
121
121
  /** HTTP status the failure came back with, when it came from a response. */
122
122
  readonly status?: number;
123
+ /**
124
+ * hq-pro's signal that the endpoint is OAuth-protected, carried on some
125
+ * auth-rejection bodies (e.g. `DIRECT_MCP_AUTH_REJECTED`). It is a SECOND,
126
+ * code-independent way to reach the sign-in flow: the direct-MCP install
127
+ * path reports OAuth via this flag rather than the `INTEGRATION_FACTORY_
128
+ * OAUTH_REQUIRED` code, so a connect that keys off only the code would
129
+ * surface the error instead of switching to the browser sign-in.
130
+ */
131
+ readonly oauthProtected?: boolean;
123
132
  constructor(message: string, opts?: {
124
133
  expected?: boolean;
125
134
  code?: string;
126
135
  status?: number;
136
+ oauthProtected?: boolean;
127
137
  });
128
138
  }
129
139
  /**
@@ -204,6 +214,15 @@ export declare function callGateway(token: string, params: Record<string, unknow
204
214
  * to the raw result when the shape differs.
205
215
  */
206
216
  export declare function unwrapGatewayResult(result: unknown): unknown;
217
+ /**
218
+ * True when a tool result is an MCP *tool error* (`isError: true`). The flag
219
+ * lives on the OUTER gateway result alongside `content`, so it must be read
220
+ * from the raw `message.result` BEFORE `unwrapGatewayResult` strips the
221
+ * envelope down to the inner text. A failed tool call otherwise prints its
222
+ * error payload and still exits 0 — indistinguishable from success to any
223
+ * script (or agent) driving the gateway.
224
+ */
225
+ export declare function gatewayResultIsError(result: unknown): boolean;
207
226
  interface QueuedOutcome {
208
227
  queuedForApproval: true;
209
228
  queueId: string;
@@ -28,6 +28,15 @@ export class IntegrationsCliError extends Error {
28
28
  code;
29
29
  /** HTTP status the failure came back with, when it came from a response. */
30
30
  status;
31
+ /**
32
+ * hq-pro's signal that the endpoint is OAuth-protected, carried on some
33
+ * auth-rejection bodies (e.g. `DIRECT_MCP_AUTH_REJECTED`). It is a SECOND,
34
+ * code-independent way to reach the sign-in flow: the direct-MCP install
35
+ * path reports OAuth via this flag rather than the `INTEGRATION_FACTORY_
36
+ * OAUTH_REQUIRED` code, so a connect that keys off only the code would
37
+ * surface the error instead of switching to the browser sign-in.
38
+ */
39
+ oauthProtected;
31
40
  constructor(message, opts = {}) {
32
41
  super(message);
33
42
  this.name = "IntegrationsCliError";
@@ -36,6 +45,8 @@ export class IntegrationsCliError extends Error {
36
45
  this.code = opts.code;
37
46
  if (opts.status !== undefined)
38
47
  this.status = opts.status;
48
+ if (opts.oauthProtected !== undefined)
49
+ this.oauthProtected = opts.oauthProtected;
39
50
  }
40
51
  }
41
52
  /**
@@ -161,6 +172,7 @@ export async function raiseForResponse(res, fallback) {
161
172
  expected: isClientError(res.status),
162
173
  status: res.status,
163
174
  ...(body.code ? { code: body.code } : {}),
175
+ ...(body.oauthProtected === true ? { oauthProtected: true } : {}),
164
176
  });
165
177
  }
166
178
  /** "factory:linear" → "linear"; mirrors hq-pro's factoryToolPrefix. */
@@ -305,6 +317,19 @@ export function unwrapGatewayResult(result) {
305
317
  }
306
318
  return result;
307
319
  }
320
+ /**
321
+ * True when a tool result is an MCP *tool error* (`isError: true`). The flag
322
+ * lives on the OUTER gateway result alongside `content`, so it must be read
323
+ * from the raw `message.result` BEFORE `unwrapGatewayResult` strips the
324
+ * envelope down to the inner text. A failed tool call otherwise prints its
325
+ * error payload and still exits 0 — indistinguishable from success to any
326
+ * script (or agent) driving the gateway.
327
+ */
328
+ export function gatewayResultIsError(result) {
329
+ return (!!result &&
330
+ typeof result === "object" &&
331
+ result.isError === true);
332
+ }
308
333
  export function queuedOutcome(payload) {
309
334
  if (payload &&
310
335
  typeof payload === "object" &&
@@ -17,7 +17,7 @@ import chalk from "chalk";
17
17
  import { ensureCognitoIdToken } from "../utils/cognito-session.js";
18
18
  import { getCompanyUid } from "../utils/vault-api.js";
19
19
  import { IntegrationsCliError, bareProvider, fetchAdminSurface, printJson, resolveConnection, selectConnection, } from "./integrations-core.js";
20
- import { getConnectionAccess, mutateConnectionAccess, uninstallIntegration, updateGovernance, } from "./integrations-api.js";
20
+ import { fetchPendingApprovals, getConnectionAccess, mutateConnectionAccess, uninstallIntegration, updateGovernance, } from "./integrations-api.js";
21
21
  const WRITE_POLICIES = ["auto-allow", "confirm", "deny"];
22
22
  const PERMISSIONS = ["read", "write", "admin"];
23
23
  /** Plain-English gloss for each write policy, used in every policy readout. */
@@ -99,7 +99,11 @@ function printAccess(access) {
99
99
  console.log(`${chalk.bold(access.provider)} ${chalk.dim(`shared: ${mode}`)}`);
100
100
  console.log(chalk.dim(` connected by ${access.creator.name ?? access.creator.uid}`));
101
101
  if (access.entries.length === 0) {
102
- console.log(chalk.dim(" No individual grants."));
102
+ // "Individual shares" — not "grants" — so this never reads as contradicting
103
+ // `hq integrations grants`. This list is who the connection is SHARED with
104
+ // (who may use the app at all); `grants` is the separate per-tool
105
+ // authorization for change-making tools.
106
+ console.log(chalk.dim(" No individual shares (per-tool authorization is `hq integrations grants`)."));
103
107
  }
104
108
  for (const entry of access.entries) {
105
109
  console.log(` ${describePrincipal(entry)} ${chalk.dim(entry.permission)}`);
@@ -200,7 +204,7 @@ export function registerManageCommands(integrations) {
200
204
  });
201
205
  integrations
202
206
  .command("grants [app]")
203
- .description("Show who may run an app's change-making tools without approval")
207
+ .description("Show who is authorized to run an app's change-making tools")
204
208
  .option("--company <slug>", "Company slug")
205
209
  .option("--provider <slug>", "Connected app (e.g. linear)")
206
210
  .option("--connection <id>", "Connection id (acct_…)")
@@ -215,7 +219,7 @@ export function registerManageCommands(integrations) {
215
219
  return;
216
220
  }
217
221
  if (grants.length === 0) {
218
- console.log("No per-tool exceptionsthis app follows its overall approval setting.");
222
+ console.log("No per-tool authorization set the app's overall sharing decides who may run its tools.");
219
223
  return;
220
224
  }
221
225
  for (const grant of grants) {
@@ -224,6 +228,15 @@ export function registerManageCommands(integrations) {
224
228
  console.log(` ${describePrincipal(entry)} ${chalk.dim(entry.permission)}`);
225
229
  }
226
230
  }
231
+ // Authorization is not approval. A grant here says WHO may invoke the
232
+ // tool; whether that invocation runs immediately or waits for an owner is
233
+ // the connection's `policy` (confirm vs auto-allow). Saying so stops this
234
+ // list from reading as "these people bypass approval" — they don't unless
235
+ // the policy is auto-allow.
236
+ const policy = connection.writePolicy;
237
+ console.log(chalk.dim(policy === "auto-allow"
238
+ ? "\nApproval: this app is set to auto-allow, so authorized calls run immediately (`hq integrations policy`)."
239
+ : "\nApproval is separate: authorized change-making calls still follow this app's policy (`hq integrations policy`)."));
227
240
  });
228
241
  /*
229
242
  * grant / ungrant are read-modify-write: hq-pro's PATCH REPLACES the whole
@@ -231,11 +244,11 @@ export function registerManageCommands(integrations) {
231
244
  */
232
245
  integrations
233
246
  .command("grant [app]")
234
- .description("Let someone run one of an app's change-making tools without approval")
247
+ .description("Authorize someone to run one of an app's change-making tools")
235
248
  .option("--company <slug>", "Company slug")
236
249
  .option("--provider <slug>", "Connected app (e.g. linear)")
237
250
  .option("--connection <id>", "Connection id (acct_…)")
238
- .requiredOption("--tool <name>", "The tool to allow")
251
+ .requiredOption("--tool <name>", "The tool to authorize")
239
252
  .requiredOption("--to <principal>", "Email, uid, or `everyone`")
240
253
  .option("--permission <level>", "read, write, or admin", "write")
241
254
  .option("--json", "Machine-readable output")
@@ -266,15 +279,15 @@ export function registerManageCommands(integrations) {
266
279
  printJson(result);
267
280
  return;
268
281
  }
269
- console.log(chalk.green(`${describePrincipal({ ...principal, granteeId: principal.granteeId ?? "" })} can now run ${toolName} on ${connectionLabel(connection)} without approval.`));
282
+ console.log(chalk.green(`${describePrincipal({ ...principal, granteeId: principal.granteeId ?? "" })} is now authorized to run ${toolName} on ${connectionLabel(connection)}. Whether it needs approval still follows the app's policy.`));
270
283
  });
271
284
  integrations
272
285
  .command("ungrant [app]")
273
- .description("Remove a per-tool exception, so the tool needs approval again")
286
+ .description("Remove a per-tool authorization, leaving only the app's default access")
274
287
  .option("--company <slug>", "Company slug")
275
288
  .option("--provider <slug>", "Connected app (e.g. linear)")
276
289
  .option("--connection <id>", "Connection id (acct_…)")
277
- .requiredOption("--tool <name>", "The tool to stop allowing")
290
+ .requiredOption("--tool <name>", "The tool to stop authorizing")
278
291
  .requiredOption("--from <principal>", "Email, uid, or `everyone`")
279
292
  .option("--json", "Machine-readable output")
280
293
  .action(async (app, opts) => {
@@ -289,14 +302,14 @@ export function registerManageCommands(integrations) {
289
302
  const current = connection.writeAllowlist ?? [];
290
303
  const merged = removeGrant(current, opts.tool.trim(), principal);
291
304
  if (merged === null) {
292
- throw new IntegrationsCliError(`No exception for ${opts.from} on ${opts.tool} — nothing to remove.`, { expected: true });
305
+ throw new IntegrationsCliError(`No per-tool authorization for ${opts.from} on ${opts.tool} — nothing to remove.`, { expected: true });
293
306
  }
294
307
  const result = await patchAllowlistIfUnchanged(token, companyUid, connection.id, current, merged);
295
308
  if (opts.json) {
296
309
  printJson(result);
297
310
  return;
298
311
  }
299
- console.log(chalk.green(`${opts.tool} on ${connectionLabel(connection)} needs approval again.`));
312
+ console.log(chalk.green(`${opts.tool} on ${connectionLabel(connection)} no longer has a per-tool authorization.`));
300
313
  });
301
314
  integrations
302
315
  .command("access [app]")
@@ -422,26 +435,36 @@ export function registerManageCommands(integrations) {
422
435
  .action(async (opts) => {
423
436
  const token = await ensureCognitoIdToken();
424
437
  const companyUid = await getCompanyUid(token, opts.company);
425
- const surface = await fetchAdminSurface(token, companyUid);
426
- const queued = surface.audit.filter((row) => row.outcome === "queued" && row.queueId);
438
+ // Live confirm-queue state — a call already approved, rejected, or expired
439
+ // is gone from this list, unlike the old audit-feed reconstruction which
440
+ // could only show that a call was once queued.
441
+ let pending;
442
+ try {
443
+ pending = await fetchPendingApprovals(token, companyUid);
444
+ }
445
+ catch (err) {
446
+ // This CLI can ship ahead of the hq-pro deploy that adds the pending
447
+ // route (the two land as separate PRs). A 404 means "backend too old",
448
+ // not a real failure — say so plainly instead of a stack trace.
449
+ if (err instanceof IntegrationsCliError && err.status === 404) {
450
+ throw new IntegrationsCliError("This HQ backend doesn't support the live pending list yet. Approvals still work — an owner gets the approve command when a call is queued.", { expected: true });
451
+ }
452
+ throw err;
453
+ }
427
454
  if (opts.json) {
428
- printJson(queued);
455
+ printJson(pending);
429
456
  return;
430
457
  }
431
- if (queued.length === 0) {
458
+ if (pending.length === 0) {
432
459
  console.log("Nothing is waiting for approval.");
433
460
  return;
434
461
  }
435
- for (const row of queued) {
436
- const who = row.memberOrAgentName ?? row.memberOrAgent;
437
- console.log(`${chalk.bold(row.toolName)} ${chalk.dim(`${who} · ${row.timestamp}`)}`);
462
+ for (const row of pending) {
463
+ const who = row.requestedByName ?? row.requestedBy;
464
+ const label = row.requestedTool ?? row.toolName;
465
+ console.log(`${chalk.bold(label)} ${chalk.dim(`${who} · ${row.createdAt}`)}`);
438
466
  console.log(chalk.dim(` hq integrations approve ${row.queueId}${row.provider ? ` --provider ${bareProvider(row.provider)}` : ""}`));
439
467
  }
440
- // hq-pro exposes no "list open queue entries" route, so this is derived
441
- // from the activity feed — and the feed records the QUEUING, never the
442
- // later decision. Saying so is the difference between a useful list and
443
- // a misleading one.
444
- console.log(chalk.dim("\nDerived from recent activity — an entry already approved, rejected, or expired may still appear here."));
445
468
  });
446
469
  integrations
447
470
  .command("disconnect [app]")
@@ -25,7 +25,7 @@
25
25
  *
26
26
  * Govern and remove:
27
27
  * hq integrations policy [app] --set <m> Approval setting for changes.
28
- * hq integrations grants|grant|ungrant Per-tool approval exceptions.
28
+ * hq integrations grants|grant|ungrant Per-tool authorization to run change-making tools.
29
29
  * hq integrations access|share|unshare Who may use the app.
30
30
  * hq integrations audit Recent activity.
31
31
  * hq integrations disconnect [app] Remove it and its credentials.
@@ -25,7 +25,7 @@
25
25
  *
26
26
  * Govern and remove:
27
27
  * hq integrations policy [app] --set <m> Approval setting for changes.
28
- * hq integrations grants|grant|ungrant Per-tool approval exceptions.
28
+ * hq integrations grants|grant|ungrant Per-tool authorization to run change-making tools.
29
29
  * hq integrations access|share|unshare Who may use the app.
30
30
  * hq integrations audit Recent activity.
31
31
  * hq integrations disconnect [app] Remove it and its credentials.
@@ -47,7 +47,7 @@ import { randomUUID } from "node:crypto";
47
47
  import chalk from "chalk";
48
48
  import { ensureCognitoIdToken } from "../utils/cognito-session.js";
49
49
  import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
50
- import { IntegrationsCliError, bareProvider, callGateway, fetchConnections, isClientError, printJson, raiseIfUnauthorized, raiseIfUpstreamUnavailable, selectConnection, toolPrefixForProvider, unwrapGatewayResult, queuedOutcome, } from "./integrations-core.js";
50
+ import { IntegrationsCliError, bareProvider, callGateway, fetchConnections, isClientError, printJson, raiseIfUnauthorized, raiseIfUpstreamUnavailable, gatewayResultIsError, selectConnection, toolPrefixForProvider, unwrapGatewayResult, queuedOutcome, } from "./integrations-core.js";
51
51
  import { registerConnectCommands } from "./integrations-connect.js";
52
52
  import { registerManageCommands } from "./integrations-manage.js";
53
53
  export { IntegrationsCliError, callGateway, fetchConnections, queuedOutcome, selectConnection, toolPrefixForProvider, unwrapGatewayResult, } from "./integrations-core.js";
@@ -169,7 +169,15 @@ export function registerIntegrationsCommand(program) {
169
169
  }
170
170
  return;
171
171
  }
172
+ // A tool that ran but FAILED (`isError: true`) still has its payload
173
+ // printed — the error text is the useful part — but the command exits
174
+ // non-zero so a script or agent can tell failure from success without
175
+ // parsing output. The flag is on the raw gateway result, not the
176
+ // unwrapped payload.
172
177
  printJson(payload);
178
+ if (gatewayResultIsError(message.result)) {
179
+ process.exitCode = 1;
180
+ }
173
181
  });
174
182
  for (const decision of ["approve", "reject"]) {
175
183
  integrations
@@ -9,6 +9,8 @@
9
9
  * performs the update and re-runs the command, and only warns when the
10
10
  * update itself fails. Installs quietly (the package manager's stdout is
11
11
  * captured, never forwarded) because `--json` consumers parse ours.
12
+ * Attended callers wait for the install and get the re-exec; unattended
13
+ * ones (hooks, scripts, agents, cron) do not update at all — see below.
12
14
  *
13
15
  * - `selfUpdateAndReexec` — `hq rescue`. The rescue script ships inside this
14
16
  * install's `@indigoai-us/hq-cloud` dependency, so a stale CLI runs a stale
@@ -28,6 +30,29 @@
28
30
  * command on the current version. Self-updating must never make a command less
29
31
  * available than it was before.
30
32
  *
33
+ * Surviving a killed parent — the reason this file spawns the way it does.
34
+ * A global install rewrites the one copy of the CLI on the machine and is NOT
35
+ * atomic: killed part-way through, it leaves a store the `hq` shim resolves
36
+ * into but cannot load, and every later invocation dies with MODULE_NOT_FOUND
37
+ * until a human reinstalls. Three properties prevent that:
38
+ *
39
+ * 1. Unattended callers do not update at all. They are the ones carrying
40
+ * deadlines they cannot control, their own command may still need files
41
+ * from the package an install would replace, and with no terminal to
42
+ * return to the re-exec is pointless. `onlyWhenAttended` on the flavor.
43
+ * 2. Every install spawn is `detached`, so it runs in its own process group
44
+ * and a signal aimed at the CLI's group — Ctrl-C, a hangup, a deadline
45
+ * runner — cannot reach the package manager mid-write.
46
+ * 3. Install output goes to descriptors that outlive this process, never to
47
+ * pipes we own, so a killed CLI cannot take the installer down by EPIPE.
48
+ * `openInstallOutput` in version-gate.ts.
49
+ *
50
+ * (2) and (3) are separate holes and both must be closed: the process group
51
+ * governs signals, the descriptors govern I/O, and either one alone still
52
+ * leaves a way to kill an install half-applied.
53
+ *
54
+ * Covered by `self-update-kill-resistance.test.ts`.
55
+ *
31
56
  * Opt-outs: `HQ_NO_UPDATE_CHECK=1` (the shared knob that also silences
32
57
  * version-check), `hq rescue --no-self-update`, and the re-exec guard env.
33
58
  */
@@ -47,7 +72,13 @@ export type SelfUpdateAction =
47
72
  /** Updated, but the re-exec couldn't start; continue on the current (in-memory) version. */
48
73
  | "updated-no-reexec"
49
74
  /** Updated and the command re-ran on the new version; exit with `reexecStatus`. */
50
- | "reexec";
75
+ | "reexec"
76
+ /**
77
+ * Unattended caller: replacing the CLI in place is unsafe here, so nothing
78
+ * was attempted and the command runs on the current version. An attended
79
+ * session, the desktop app's installer, or the hard version gate updates it.
80
+ */
81
+ | "deferred";
51
82
  export interface SelfUpdateOutcome {
52
83
  action: SelfUpdateAction;
53
84
  /** Exit status of the re-exec'd `hq …` (action === "reexec"). */
@@ -93,6 +124,12 @@ export interface SelfUpdateDeps {
93
124
  runner?: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
94
125
  reexec?: (argv: string[], env: NodeJS.ProcessEnv) => number | null;
95
126
  acquireLock?: () => (() => void) | null;
127
+ /**
128
+ * Whether a human is watching this invocation. Defaults to "stderr is a TTY",
129
+ * which is false for exactly the callers that must not replace the CLI
130
+ * underneath themselves: hooks, scripts, agents, cron, CI.
131
+ */
132
+ interactive?: boolean;
96
133
  }
97
134
  /**
98
135
  * Startup path: the running CLI is behind npm `latest`, so update in place and
@@ -9,6 +9,8 @@
9
9
  * performs the update and re-runs the command, and only warns when the
10
10
  * update itself fails. Installs quietly (the package manager's stdout is
11
11
  * captured, never forwarded) because `--json` consumers parse ours.
12
+ * Attended callers wait for the install and get the re-exec; unattended
13
+ * ones (hooks, scripts, agents, cron) do not update at all — see below.
12
14
  *
13
15
  * - `selfUpdateAndReexec` — `hq rescue`. The rescue script ships inside this
14
16
  * install's `@indigoai-us/hq-cloud` dependency, so a stale CLI runs a stale
@@ -28,6 +30,29 @@
28
30
  * command on the current version. Self-updating must never make a command less
29
31
  * available than it was before.
30
32
  *
33
+ * Surviving a killed parent — the reason this file spawns the way it does.
34
+ * A global install rewrites the one copy of the CLI on the machine and is NOT
35
+ * atomic: killed part-way through, it leaves a store the `hq` shim resolves
36
+ * into but cannot load, and every later invocation dies with MODULE_NOT_FOUND
37
+ * until a human reinstalls. Three properties prevent that:
38
+ *
39
+ * 1. Unattended callers do not update at all. They are the ones carrying
40
+ * deadlines they cannot control, their own command may still need files
41
+ * from the package an install would replace, and with no terminal to
42
+ * return to the re-exec is pointless. `onlyWhenAttended` on the flavor.
43
+ * 2. Every install spawn is `detached`, so it runs in its own process group
44
+ * and a signal aimed at the CLI's group — Ctrl-C, a hangup, a deadline
45
+ * runner — cannot reach the package manager mid-write.
46
+ * 3. Install output goes to descriptors that outlive this process, never to
47
+ * pipes we own, so a killed CLI cannot take the installer down by EPIPE.
48
+ * `openInstallOutput` in version-gate.ts.
49
+ *
50
+ * (2) and (3) are separate holes and both must be closed: the process group
51
+ * governs signals, the descriptors govern I/O, and either one alone still
52
+ * leaves a way to kill an install half-applied.
53
+ *
54
+ * Covered by `self-update-kill-resistance.test.ts`.
55
+ *
31
56
  * Opt-outs: `HQ_NO_UPDATE_CHECK=1` (the shared knob that also silences
32
57
  * version-check), `hq rescue --no-self-update`, and the re-exec guard env.
33
58
  */
@@ -38,7 +63,7 @@ import * as path from "node:path";
38
63
  import semver from "semver";
39
64
  import chalk from "chalk";
40
65
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
41
- import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
66
+ import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, inOwnProcessGroup, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
42
67
  /**
43
68
  * Set on the re-exec'd child so it can never self-update (and re-exec) again.
44
69
  * One update + one re-exec per user invocation, ever.
@@ -89,21 +114,24 @@ export function buildSelfUpdatePlan(install) {
89
114
  * stderr is kept only to explain a failure.
90
115
  */
91
116
  export function runUpdateQuiet(cmd, args, env) {
117
+ // File sinks rather than pipes: a pipe dies with this process and would take
118
+ // the installer down with it mid-write. See `openInstallOutput`.
119
+ const output = openInstallOutput(false);
92
120
  try {
93
121
  const plan = buildSpawnPlan(cmd, args);
94
- const result = spawnSync(plan.cmd, plan.args, {
95
- stdio: ["ignore", "pipe", "pipe"],
122
+ // `inOwnProcessGroup`: the install must survive a signal aimed at this
123
+ // process, or a killed `hq` leaves a corrupted global install behind.
124
+ const result = spawnSync(plan.cmd, plan.args, inOwnProcessGroup({
125
+ stdio: output.stdio,
96
126
  shell: plan.shell,
97
- encoding: "utf-8",
98
127
  ...(env ? { env } : {}),
99
- });
128
+ }));
100
129
  if (result.error) {
101
130
  const code = result.error.code;
102
131
  return { ok: false, code, detail: result.error.message };
103
132
  }
104
133
  if (result.status !== 0) {
105
- const stderr = (result.stderr ?? "").trim();
106
- const tail = stderr ? stderr.slice(-DETAIL_MAX_CHARS) : "";
134
+ const tail = output.stderrTail().slice(-DETAIL_MAX_CHARS);
107
135
  return {
108
136
  ok: false,
109
137
  detail: tail || `exit ${result.status ?? "signal"}`,
@@ -118,6 +146,9 @@ export function runUpdateQuiet(cmd, args, env) {
118
146
  detail: err instanceof Error ? err.message : String(err),
119
147
  };
120
148
  }
149
+ finally {
150
+ output.dispose();
151
+ }
121
152
  }
122
153
  function lockDir() {
123
154
  return path.join(os.homedir(), ".hq", "self-update.lock");
@@ -195,6 +226,32 @@ async function updateAndReexec(argv, flavor, known, deps) {
195
226
  return { action: "skipped" };
196
227
  if (!semver.gt(latestValid, current))
197
228
  return { action: "current", latest };
229
+ // Replacing the CLI in place is only safe when someone is at a terminal.
230
+ //
231
+ // A global install rewrites the very package this process is running from,
232
+ // and an unattended `hq` is the worst possible moment for that:
233
+ //
234
+ // - It carries a deadline it does not control (a Claude Code hook capped at
235
+ // `"timeout": 5`, a harness Bash deadline, cron), so waiting out a
236
+ // tens-of-seconds install means being killed part-way through one.
237
+ // - Its own command may still need files from the installed package.
238
+ // `resolveBundledAsset` reads `assets/scaffold/**` from the package root
239
+ // at call time, so an install overlapping the command can make it fail
240
+ // with "Bundled asset is missing" or mix new assets with old in-memory
241
+ // code. Running the install alongside the command is not an option, and
242
+ // nothing here can know when the command will finish.
243
+ // - Nobody benefits: with no terminal to return to, the re-exec is pointless
244
+ // and the "Updating…" line is noise in output someone else is parsing.
245
+ //
246
+ // So an unattended caller does not update; it runs the command it was asked
247
+ // to run. Updates land from an attended session, from the desktop app's
248
+ // background installer (which owns this for unattended machines, retries, and
249
+ // can repair a CLI that no longer loads), or from the hq-pro hard gate when a
250
+ // version is genuinely not allowed to run.
251
+ const interactive = deps.interactive ?? process.stderr.isTTY === true;
252
+ if (!interactive && flavor.onlyWhenAttended) {
253
+ return { action: "deferred", latest };
254
+ }
198
255
  const releaseLock = flavor.lock ? (deps.acquireLock ?? acquireUpdateLock)() : () => { };
199
256
  if (!releaseLock)
200
257
  return { action: "skipped", latest };
@@ -251,7 +308,7 @@ async function updateAndReexec(argv, flavor, known, deps) {
251
308
  * this resolve it from the registry.
252
309
  */
253
310
  export async function autoUpdateAndReexec(argv, latest, deps = {}) {
254
- return updateAndReexec(argv, { noun: "command", verbose: false, lock: true }, latest, deps);
311
+ return updateAndReexec(argv, { noun: "command", verbose: false, lock: true, onlyWhenAttended: true }, latest, deps);
255
312
  }
256
313
  /**
257
314
  * Rescue path: update to npm latest and re-exec the rescue on the new version.
@@ -260,6 +317,6 @@ export async function autoUpdateAndReexec(argv, latest, deps = {}) {
260
317
  * are preserved.
261
318
  */
262
319
  export async function selfUpdateAndReexec(argv, deps = {}) {
263
- return updateAndReexec(argv, { noun: "rescue", verbose: true, lock: true }, null, deps);
320
+ return updateAndReexec(argv, { noun: "rescue", verbose: true, lock: true, onlyWhenAttended: false }, null, deps);
264
321
  }
265
322
  //# sourceMappingURL=self-update.js.map
@@ -181,6 +181,57 @@ export type UpdateResult = {
181
181
  };
182
182
  type UpdateRunner = (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
183
183
  export { buildSpawnPlan, quoteForWindowsShell };
184
+ /**
185
+ * Add `detached` to a **synchronous** spawn's options.
186
+ *
187
+ * Why a helper rather than the property inline: Node implements `detached` in
188
+ * its sync spawn path (`spawn_sync.cc` maps it onto libuv's
189
+ * `UV_PROCESS_DETACHED`, exactly as the async path does), but `@types/node`
190
+ * declares the flag only on the async `SpawnOptions`. This function is that
191
+ * one type gap, isolated and explained in a single place instead of a cast
192
+ * repeated at each call site.
193
+ *
194
+ * It is load-bearing rather than cosmetic. A global install rewrites the only
195
+ * copy of the CLI on the machine and is not atomic, so a package manager that
196
+ * shares the CLI's process group is destroyed mid-write whenever something
197
+ * signals that group — a Claude Code hook with `"timeout": 5`, a harness
198
+ * deadline, cron, or a plain Ctrl-C — leaving an install that resolves but
199
+ * cannot load (MODULE_NOT_FOUND) until a human reinstalls by hand.
200
+ *
201
+ * Because the guarantee rests on behaviour the types do not describe, it is
202
+ * pinned by a runtime test (`self-update-kill-resistance.test.ts`) that asserts
203
+ * the spawned child really is its own process-group leader. If a future Node
204
+ * stops honouring the flag here, that test fails loudly instead of the
205
+ * protection silently disappearing.
206
+ */
207
+ export declare function inOwnProcessGroup<T extends object>(options: T): T;
208
+ /**
209
+ * Where an install's output goes — the other half of surviving a killed parent.
210
+ *
211
+ * `inOwnProcessGroup` stops a signal from reaching the package manager, but it
212
+ * does nothing about file descriptors, and a PIPE is owned by this process. If
213
+ * we are killed mid-install the read ends close, and the package manager takes
214
+ * `EPIPE`/`SIGPIPE` on its next write — dying part-way through rewriting the
215
+ * global store, which is exactly the corruption the process group was meant to
216
+ * prevent. Pipe lifetime is not a process-group property, so both fixes are
217
+ * needed.
218
+ *
219
+ * A file descriptor onto a temp file has no such coupling: it stays valid after
220
+ * we are gone, and the installer runs to completion regardless.
221
+ *
222
+ * `inherit` is safe ONLY when our own streams are a terminal, because a
223
+ * terminal outlives us too. Inheriting a *pipe* (a hook, a captured harness
224
+ * invocation, cron) has the identical failure as a pipe we opened ourselves, so
225
+ * the verbose path takes the file sink there and gives up live output that
226
+ * nothing was reading anyway.
227
+ */
228
+ export interface InstallOutput {
229
+ stdio: ("ignore" | "inherit" | number)[];
230
+ /** Captured stderr tail, or "" when output was inherited by a terminal. */
231
+ stderrTail: () => string;
232
+ dispose: () => void;
233
+ }
234
+ export declare function openInstallOutput(verbose: boolean, isTty?: boolean): InstallOutput;
184
235
  export declare function runUpdateCommand(cmd: string, args: string[], env?: NodeJS.ProcessEnv): UpdateResult;
185
236
  declare function performUpdateCommand(cmd: string, args: string[], runner?: UpdateRunner, env?: NodeJS.ProcessEnv): UpdateResult;
186
237
  declare function performUpdate(command: string, runner?: UpdateRunner): UpdateResult;
@@ -29,7 +29,8 @@
29
29
  */
30
30
  import { spawnSync } from "node:child_process";
31
31
  import { buildSpawnPlan, quoteForWindowsShell } from "./windows-spawn.js";
32
- import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
32
+ import { closeSync, existsSync, mkdtempSync, openSync, readdirSync, readFileSync, rmSync, } from "node:fs";
33
+ import os from "node:os";
33
34
  import path from "node:path";
34
35
  import { fileURLToPath } from "node:url";
35
36
  import chalk from "chalk";
@@ -352,23 +353,95 @@ async function fetchVersionDecision() {
352
353
  // public surface (and the `__test__` block below) stable for existing callers
353
354
  // and tests.
354
355
  export { buildSpawnPlan, quoteForWindowsShell };
356
+ /**
357
+ * Add `detached` to a **synchronous** spawn's options.
358
+ *
359
+ * Why a helper rather than the property inline: Node implements `detached` in
360
+ * its sync spawn path (`spawn_sync.cc` maps it onto libuv's
361
+ * `UV_PROCESS_DETACHED`, exactly as the async path does), but `@types/node`
362
+ * declares the flag only on the async `SpawnOptions`. This function is that
363
+ * one type gap, isolated and explained in a single place instead of a cast
364
+ * repeated at each call site.
365
+ *
366
+ * It is load-bearing rather than cosmetic. A global install rewrites the only
367
+ * copy of the CLI on the machine and is not atomic, so a package manager that
368
+ * shares the CLI's process group is destroyed mid-write whenever something
369
+ * signals that group — a Claude Code hook with `"timeout": 5`, a harness
370
+ * deadline, cron, or a plain Ctrl-C — leaving an install that resolves but
371
+ * cannot load (MODULE_NOT_FOUND) until a human reinstalls by hand.
372
+ *
373
+ * Because the guarantee rests on behaviour the types do not describe, it is
374
+ * pinned by a runtime test (`self-update-kill-resistance.test.ts`) that asserts
375
+ * the spawned child really is its own process-group leader. If a future Node
376
+ * stops honouring the flag here, that test fails loudly instead of the
377
+ * protection silently disappearing.
378
+ */
379
+ export function inOwnProcessGroup(options) {
380
+ return { ...options, detached: true };
381
+ }
382
+ /** Tail of captured installer stderr kept to explain a failure. */
383
+ const INSTALL_DETAIL_MAX_CHARS = 400;
384
+ export function openInstallOutput(verbose, isTty = process.stdout.isTTY === true && process.stderr.isTTY === true) {
385
+ if (verbose && isTty) {
386
+ return {
387
+ stdio: ["ignore", "inherit", "inherit"],
388
+ stderrTail: () => "",
389
+ dispose: () => { },
390
+ };
391
+ }
392
+ const dir = mkdtempSync(path.join(os.tmpdir(), "hq-cli-install-"));
393
+ const errPath = path.join(dir, "stderr.log");
394
+ const out = openSync(path.join(dir, "stdout.log"), "a");
395
+ const err = openSync(errPath, "a");
396
+ return {
397
+ stdio: ["ignore", out, err],
398
+ stderrTail: () => {
399
+ try {
400
+ return readFileSync(errPath, "utf-8").trim().slice(-INSTALL_DETAIL_MAX_CHARS);
401
+ }
402
+ catch {
403
+ return "";
404
+ }
405
+ },
406
+ dispose: () => {
407
+ for (const fd of [out, err]) {
408
+ try {
409
+ closeSync(fd);
410
+ }
411
+ catch {
412
+ // Already closed; nothing to recover.
413
+ }
414
+ }
415
+ try {
416
+ rmSync(dir, { recursive: true, force: true });
417
+ }
418
+ catch {
419
+ // Best-effort cleanup of a temp dir.
420
+ }
421
+ },
422
+ };
423
+ }
355
424
  export function runUpdateCommand(cmd, args, env) {
425
+ const output = openInstallOutput(true);
356
426
  try {
357
427
  const plan = buildSpawnPlan(cmd, args);
358
- const result = spawnSync(plan.cmd, plan.args, {
359
- stdio: "inherit",
428
+ const result = spawnSync(plan.cmd, plan.args, inOwnProcessGroup({
429
+ stdio: output.stdio,
360
430
  shell: plan.shell,
361
431
  ...(env ? { env } : {}),
362
- });
432
+ }));
363
433
  // spawnSync reports a missing executable via `error`, not a throw.
364
434
  if (result.error) {
365
435
  const code = result.error.code;
366
436
  return { ok: false, code, detail: result.error.message };
367
437
  }
368
438
  if (result.status !== 0) {
439
+ // When output went to a file rather than the user's terminal, its tail is
440
+ // the only explanation anyone will ever see for this failure.
441
+ const captured = output.stderrTail();
369
442
  return {
370
443
  ok: false,
371
- detail: `exit ${result.status ?? "signal"}`,
444
+ detail: captured || `exit ${result.status ?? "signal"}`,
372
445
  };
373
446
  }
374
447
  return { ok: true };
@@ -380,6 +453,9 @@ export function runUpdateCommand(cmd, args, env) {
380
453
  detail: err instanceof Error ? err.message : String(err),
381
454
  };
382
455
  }
456
+ finally {
457
+ output.dispose();
458
+ }
383
459
  }
384
460
  function performUpdateCommand(cmd, args, runner = runUpdateCommand, env) {
385
461
  // Forward env only when there is one to forward, so the common path keeps the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.103.1",
3
+ "version": "5.103.2",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {