@indigoai-us/hq-cli 5.103.21 → 5.103.22

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,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.103.22] — 2026-08-25
6
+
7
+ ### Fixed
8
+
9
+ - `hq auth login` now points account signup guidance at the canonical `onboarding.hq.computer` host instead of the retired Indigo onboarding domain.
10
+
5
11
  ## [5.103.21] — 2026-08-24
6
12
 
7
13
  ### Fixed
@@ -8,7 +8,7 @@
8
8
  * hq auth status — show whether a valid session is cached + expiry
9
9
  *
10
10
  * Sign-up is owned by the onboarding web app at
11
- * https://onboarding.indigo-hq.com. `hq auth login` signs an existing account
11
+ * https://onboarding.hq.computer. `hq auth login` signs an existing account
12
12
  * into this machine by writing ~/.hq/cognito-tokens.json; once cached, the
13
13
  * session is kept valid by `hq auth refresh` / `hq-auth-refresh` and consumed
14
14
  * by the deploy + sync skills.
@@ -8,7 +8,7 @@
8
8
  * hq auth status — show whether a valid session is cached + expiry
9
9
  *
10
10
  * Sign-up is owned by the onboarding web app at
11
- * https://onboarding.indigo-hq.com. `hq auth login` signs an existing account
11
+ * https://onboarding.hq.computer. `hq auth login` signs an existing account
12
12
  * into this machine by writing ~/.hq/cognito-tokens.json; once cached, the
13
13
  * session is kept valid by `hq auth refresh` / `hq-auth-refresh` and consumed
14
14
  * by the deploy + sync skills.
@@ -96,7 +96,7 @@ export function registerAuthCommands(program) {
96
96
  console.error(chalk.dim(callbackPortCollisionGuidance(DEFAULT_COGNITO.port ?? 8765)));
97
97
  }
98
98
  else {
99
- console.error(chalk.dim(" If you do not have an account, sign up at https://onboarding.indigo-hq.com"));
99
+ console.error(chalk.dim(" If you do not have an account, sign up at https://onboarding.hq.computer"));
100
100
  }
101
101
  process.exit(1);
102
102
  }
@@ -156,6 +156,14 @@ export declare function uninstallIntegration(token: string, companyUid: string,
156
156
  installationId: string;
157
157
  connectionId: string;
158
158
  }>;
159
+ /**
160
+ * Permanently remove a revoked connection tombstone. The admin purge endpoint
161
+ * deliberately accepts only the connection id: it derives the company and
162
+ * enforces owner authorization from the authenticated connection record.
163
+ */
164
+ export declare function purgeConnection(token: string, companyUid: string, connectionId: string): Promise<{
165
+ connectionId: string;
166
+ }>;
159
167
  export interface OAuthStartResult {
160
168
  provider: string;
161
169
  displayName: string;
@@ -197,6 +205,11 @@ export declare function updateGovernance(token: string, companyUid: string, inpu
197
205
  writePolicy?: WritePolicy;
198
206
  writeAllowlist: WriteAllowlistGrant[];
199
207
  }>;
208
+ /**
209
+ * Mark or unmark one tool as read-safe. The service owns tool classification:
210
+ * it refuses attempts to mark write/destructive tools read-safe.
211
+ */
212
+ export declare function setReadSafe(token: string, companyUid: string, connectionId: string, toolName: string, readSafe: boolean): Promise<void>;
200
213
  export interface ConnectionAccess {
201
214
  connectionId: string;
202
215
  provider: string;
@@ -74,6 +74,27 @@ export async function uninstallIntegration(token, companyUid, installationId) {
74
74
  await raiseForResponse(res, "Failed to disconnect the app");
75
75
  return (await res.json());
76
76
  }
77
+ /**
78
+ * Permanently remove a revoked connection tombstone. The admin purge endpoint
79
+ * deliberately accepts only the connection id: it derives the company and
80
+ * enforces owner authorization from the authenticated connection record.
81
+ */
82
+ export async function purgeConnection(token, companyUid, connectionId) {
83
+ // Keep the company in this client's call signature with the other
84
+ // connection mutations. It is resolved before the target connection so a
85
+ // caller cannot use a slug from a different company, but the server's purge
86
+ // contract intentionally takes only connectionId in its body.
87
+ void companyUid;
88
+ const res = await vaultApiFetch({
89
+ token,
90
+ path: "/v1/integrations/admin/purge",
91
+ method: "POST",
92
+ body: { connectionId },
93
+ });
94
+ if (!res.ok)
95
+ await raiseForResponse(res, "Failed to purge the revoked connection");
96
+ return (await res.json());
97
+ }
77
98
  export async function startOAuth(token, companyUid, input) {
78
99
  const res = await vaultApiFetch({
79
100
  token,
@@ -110,6 +131,20 @@ export async function updateGovernance(token, companyUid, input) {
110
131
  await raiseForResponse(res, "Failed to update the app's settings");
111
132
  return (await res.json());
112
133
  }
134
+ /**
135
+ * Mark or unmark one tool as read-safe. The service owns tool classification:
136
+ * it refuses attempts to mark write/destructive tools read-safe.
137
+ */
138
+ export async function setReadSafe(token, companyUid, connectionId, toolName, readSafe) {
139
+ const res = await vaultApiFetch({
140
+ token,
141
+ path: "/v1/integrations/admin/read-safe",
142
+ method: "POST",
143
+ body: { companyUid, connectionId, toolName, readSafe },
144
+ });
145
+ if (!res.ok)
146
+ await raiseForResponse(res, "Failed to update the tool's read-safe setting");
147
+ }
113
148
  /**
114
149
  * Live open-approval list, read from hq-pro's confirm-queue state (not the
115
150
  * audit feed). Unlike the audit-derived reconstruction this replaces, a call
@@ -20,7 +20,7 @@ import chalk from "chalk";
20
20
  import open from "open";
21
21
  import { ensureCognitoIdToken } from "../utils/cognito-session.js";
22
22
  import { getCompanyUid } from "../utils/vault-api.js";
23
- import { IntegrationsCliError, bareProvider, printJson, resolveConnection, } from "./integrations-core.js";
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";
25
25
  import { startLoopbackListener } from "./integrations-oauth.js";
26
26
  /** hq-pro's machine code for "this endpoint needs a browser sign-in". */
@@ -183,7 +183,7 @@ async function resolveKey(opts, appLabel) {
183
183
  * most-specific-first so an explicit flag always wins over the positional
184
184
  * argument's heuristics.
185
185
  */
186
- async function resolveTarget(token, companyUid, app, opts) {
186
+ async function resolveTarget(token, companyUid, app, opts, preserveDomain = false) {
187
187
  if (opts.docsUrl) {
188
188
  const found = await discoverDocs(token, companyUid, opts.docsUrl);
189
189
  if (!found.discovery || !found.discoveryReceiptId) {
@@ -222,6 +222,17 @@ async function resolveTarget(token, companyUid, app, opts) {
222
222
  // authClass, both of which make the connect cleaner than a raw domain
223
223
  // lookup. Missing it is fine — the domain path still works.
224
224
  const match = await findCatalogEntry(token, companyUid, app);
225
+ if (preserveDomain) {
226
+ // Reviving a revoked connection is keyed by its canonical domain in
227
+ // hq-pro. A catalog entry id is useful auth metadata, but replacing the
228
+ // domain with it can create a distinct connection instead of reviving
229
+ // the original acct_ row.
230
+ return {
231
+ ref: { domain: app },
232
+ ...(match?.authClass ? { authClass: match.authClass } : {}),
233
+ label: match?.name || app,
234
+ };
235
+ }
225
236
  return catalogEntryToTarget(match, { ref: { domain: app }, label: app });
226
237
  }
227
238
  // A bare name (`notion`, `atlassian`) is how the catalog reads to a person —
@@ -424,14 +435,15 @@ async function completeCredentialIfNeeded(token, companyUid, target, opts, resul
424
435
  });
425
436
  }
426
437
  /** Print the outcome of a successful connect. */
427
- function reportInstall(result, opts) {
438
+ function reportInstall(result, opts, expectedRevivedConnectionId) {
428
439
  if (opts.json) {
429
440
  printJson(result);
430
441
  return;
431
442
  }
432
443
  const { installation, connection } = result;
433
444
  const toolCount = result.mcp?.tools?.length;
434
- console.log(chalk.green(`Connected ${chalk.bold(installation.displayName)}`) +
445
+ const revived = connection.id === expectedRevivedConnectionId;
446
+ console.log(chalk.green(`${revived ? "Revived" : "Connected"} ${chalk.bold(installation.displayName)}`) +
435
447
  (typeof toolCount === "number" ? chalk.dim(` — ${toolCount} tools available`) : ""));
436
448
  console.log(chalk.dim(` connection: ${connection.id}`));
437
449
  if (installation.status === "needs_credentials") {
@@ -439,6 +451,49 @@ function reportInstall(result, opts) {
439
451
  }
440
452
  console.log(chalk.dim(` Try it: hq integrations tools --provider ${bareProvider(connection.provider)}`));
441
453
  }
454
+ /**
455
+ * The shared `connect <domain>` execution path. Reconnect's revoked fallback
456
+ * intentionally comes through here rather than re-installing its saved MCP
457
+ * URL: hq-pro recognizes the domain and revives the revoked row in place.
458
+ */
459
+ async function connectApp(token, companyUid, app, opts, expectedRevivedConnectionId) {
460
+ const target = await resolveTarget(token, companyUid, app, opts, expectedRevivedConnectionId !== undefined);
461
+ const authMode = opts.auth ?? target.authClass;
462
+ if (authMode === "oauth") {
463
+ const result = await connectViaOAuth(token, companyUid, target, opts);
464
+ if (result)
465
+ reportInstall(result, opts, expectedRevivedConnectionId);
466
+ return;
467
+ }
468
+ // A key is only collected when something already says one is needed, or
469
+ // the caller supplied one — otherwise a no-auth app would pointlessly
470
+ // prompt.
471
+ const wantsKey = authMode === "key" || Boolean(opts.token || opts.tokenStdin);
472
+ const bearerToken = wantsKey ? await resolveKey(opts, target.label) : undefined;
473
+ try {
474
+ const result = await installIntegration(token, companyUid, {
475
+ ...target.ref,
476
+ ...(bearerToken
477
+ ? { authMode: "bearer", bearerToken }
478
+ : authMode === "none"
479
+ ? { authMode: "none" }
480
+ : {}),
481
+ });
482
+ reportInstall(await completeCredentialIfNeeded(token, companyUid, target, opts, result), opts, expectedRevivedConnectionId);
483
+ }
484
+ catch (err) {
485
+ // Server-authoritative detection: the endpoint turned out to be
486
+ // OAuth-protected, so run the browser flow instead of making the
487
+ // caller re-issue the command with --auth oauth.
488
+ if (isOAuthRequiredError(err)) {
489
+ const result = await connectViaOAuth(token, companyUid, target, opts);
490
+ if (result)
491
+ reportInstall(result, opts, expectedRevivedConnectionId);
492
+ return;
493
+ }
494
+ throw err;
495
+ }
496
+ }
442
497
  export function registerConnectCommands(integrations) {
443
498
  integrations
444
499
  .command("catalog [query]")
@@ -561,46 +616,11 @@ export function registerConnectCommands(integrations) {
561
616
  assertAuthMode(opts.auth);
562
617
  const token = await ensureCognitoIdToken();
563
618
  const companyUid = await getCompanyUid(token, opts.company);
564
- const target = await resolveTarget(token, companyUid, app, opts);
565
- const authMode = opts.auth ?? target.authClass;
566
- if (authMode === "oauth") {
567
- const result = await connectViaOAuth(token, companyUid, target, opts);
568
- if (result)
569
- reportInstall(result, opts);
570
- return;
571
- }
572
- // A key is only collected when something already says one is needed, or
573
- // the caller supplied one — otherwise a no-auth app would pointlessly
574
- // prompt.
575
- const wantsKey = authMode === "key" || Boolean(opts.token || opts.tokenStdin);
576
- const bearerToken = wantsKey ? await resolveKey(opts, target.label) : undefined;
577
- try {
578
- const result = await installIntegration(token, companyUid, {
579
- ...target.ref,
580
- ...(bearerToken
581
- ? { authMode: "bearer", bearerToken }
582
- : authMode === "none"
583
- ? { authMode: "none" }
584
- : {}),
585
- });
586
- reportInstall(await completeCredentialIfNeeded(token, companyUid, target, opts, result), opts);
587
- }
588
- catch (err) {
589
- // Server-authoritative detection: the endpoint turned out to be
590
- // OAuth-protected, so run the browser flow instead of making the
591
- // caller re-issue the command with --auth oauth.
592
- if (isOAuthRequiredError(err)) {
593
- const result = await connectViaOAuth(token, companyUid, target, opts);
594
- if (result)
595
- reportInstall(result, opts);
596
- return;
597
- }
598
- throw err;
599
- }
619
+ await connectApp(token, companyUid, app, opts);
600
620
  });
601
621
  integrations
602
622
  .command("reconnect [app]")
603
- .description("Re-authenticate a connected app whose credentials stopped working")
623
+ .description("Re-authenticate a connected app; use --connect to re-add a revoked app")
604
624
  .option("--company <slug>", "Company slug, e.g. indigo")
605
625
  .option("--provider <slug>", "Connected app (e.g. linear)")
606
626
  .option("--connection <id>", "Connection id (acct_…)")
@@ -609,6 +629,7 @@ export function registerConnectCommands(integrations) {
609
629
  .option("--auth <mode>", "Force the auth mode: none, key, or oauth (default: detect)")
610
630
  .option("--no-browser", "Print the sign-in URL instead of opening a browser")
611
631
  .option("--timeout <seconds>", "How long to wait for a browser sign-in (default 300)")
632
+ .option("--connect", "For a revoked row, run `connect <domain>` to re-add and revive it")
612
633
  .option("--json", "Machine-readable output")
613
634
  .action(async (app, opts) => {
614
635
  // Same validation as `connect`. Without it a typo like `--auth oauth2`
@@ -618,7 +639,23 @@ export function registerConnectCommands(integrations) {
618
639
  assertAuthMode(opts.auth);
619
640
  const token = await ensureCognitoIdToken();
620
641
  const companyUid = await getCompanyUid(token, opts.company);
621
- const connection = await resolveConnection(token, companyUid, app, opts);
642
+ const connection = await resolveConnection(token, companyUid, app, opts, {
643
+ allowSingleRevoked: Boolean(opts.connect),
644
+ });
645
+ if (connection.status === "revoked") {
646
+ const details = revokedConnectionDetails(connection, opts.company);
647
+ if (!opts.connect) {
648
+ if (opts.json)
649
+ printJson(details);
650
+ else {
651
+ console.log(chalk.yellow(details.reason));
652
+ console.log(chalk.yellow(`Re-add it with: ${details.fixPath}`));
653
+ }
654
+ return;
655
+ }
656
+ await connectApp(token, companyUid, connectionDomain(connection), opts, connection.id);
657
+ return;
658
+ }
622
659
  const url = connection.installation?.surface?.url;
623
660
  if (!url) {
624
661
  throw new IntegrationsCliError(`${bareProvider(connection.provider)} was not installed through the app catalog, so it cannot be reconnected from here.`, { expected: true });
@@ -74,6 +74,16 @@ export interface AdminConnection {
74
74
  };
75
75
  installation?: FactoryInstallation | null;
76
76
  }
77
+ /**
78
+ * The actionable state returned instead of attempting to use a revoked
79
+ * connection. Keep this machine-readable so commands and scripts get the same
80
+ * recovery path instead of treating a listed row as absent.
81
+ */
82
+ export interface RevokedConnectionDetails {
83
+ status: "revoked";
84
+ reason: string;
85
+ fixPath: string;
86
+ }
77
87
  export interface AdminAuditEvent {
78
88
  timestamp: string;
79
89
  memberOrAgent: string;
@@ -210,6 +220,14 @@ export declare function selectConnection(connections: AdminConnection[], opts: {
210
220
  connection?: string;
211
221
  provider?: string;
212
222
  }): AdminConnection;
223
+ /**
224
+ * The hostname re-add needs. Prefer the server's canonical installation domain;
225
+ * an older row may only retain its MCP URL, and provider is the last-resort
226
+ * human-safe query when neither was stored.
227
+ */
228
+ export declare function connectionDomain(connection: AdminConnection): string;
229
+ /** A revoked row is still addressable, but it cannot make a live MCP call. */
230
+ export declare function revokedConnectionDetails(connection: AdminConnection, companySlug?: string): RevokedConnectionDetails;
213
231
  /**
214
232
  * Resolve a connection the caller named positionally OR through the
215
233
  * `--provider` / `--connection` flags. Every management verb takes an optional
@@ -219,6 +237,8 @@ export declare function selectConnection(connections: AdminConnection[], opts: {
219
237
  export declare function resolveConnection(token: string, companyUid: string, app: string | undefined, opts: {
220
238
  provider?: string;
221
239
  connection?: string;
240
+ }, recoveryOpts?: {
241
+ allowSingleRevoked?: boolean;
222
242
  }): Promise<AdminConnection>;
223
243
  export declare function callGateway(token: string, params: Record<string, unknown>): Promise<GatewayMessage>;
224
244
  /**
@@ -256,14 +256,26 @@ export function selectConnection(connections, opts) {
256
256
  if (opts.provider) {
257
257
  const want = opts.provider.trim().toLowerCase();
258
258
  const wantHumanSlug = humanSlug(opts.provider);
259
- const providerMatch = active.find((c) => bareProvider(c.provider).toLowerCase() === want || c.provider.toLowerCase() === want);
259
+ // A reconnect normally leaves a revoked historical row alongside its new
260
+ // active connection. Prefer the active inventory before applying the
261
+ // provider/name/alias precedence below; fall back to history only when no
262
+ // active row matches this selector at all. `--connection` above remains
263
+ // the explicit way to inspect or act on a particular historical row.
264
+ const activeMatches = connections.filter((c) => c.status !== "revoked" &&
265
+ (bareProvider(c.provider).toLowerCase() === want ||
266
+ c.provider.toLowerCase() === want ||
267
+ c.installation?.displayName?.trim().toLowerCase() === want ||
268
+ (wantHumanSlug !== "" &&
269
+ humanSlug(c.installation?.displayName ?? "") === wantHumanSlug)));
270
+ const candidates = activeMatches.length > 0 ? activeMatches : connections;
271
+ const providerMatch = candidates.find((c) => bareProvider(c.provider).toLowerCase() === want || c.provider.toLowerCase() === want);
260
272
  if (providerMatch)
261
273
  return providerMatch;
262
- const displayNameMatch = active.find((c) => c.installation?.displayName?.trim().toLowerCase() === want);
274
+ const displayNameMatch = candidates.find((c) => c.installation?.displayName?.trim().toLowerCase() === want);
263
275
  if (displayNameMatch)
264
276
  return displayNameMatch;
265
277
  const aliasMatches = wantHumanSlug
266
- ? active.filter((c) => {
278
+ ? candidates.filter((c) => {
267
279
  const displayName = c.installation?.displayName;
268
280
  const displayNameSlug = displayName ? humanSlug(displayName) : "";
269
281
  return displayNameSlug !== "" && displayNameSlug === wantHumanSlug;
@@ -274,7 +286,7 @@ export function selectConnection(connections, opts) {
274
286
  if (aliasMatches.length > 1) {
275
287
  throw new IntegrationsCliError(`Display-name alias '${opts.provider}' matches multiple connected apps. Use --connection to choose one.`, { expected: true });
276
288
  }
277
- const available = active.map((c) => bareProvider(c.provider)).join(", ");
289
+ const available = connections.map((c) => bareProvider(c.provider)).join(", ");
278
290
  throw new IntegrationsCliError(`No connected app matches '${opts.provider}'.` +
279
291
  (available ? ` Connected: ${available}.` : " Nothing is connected yet — connect apps with `hq integrations connect <app>`."), { expected: true });
280
292
  }
@@ -286,17 +298,59 @@ export function selectConnection(connections, opts) {
286
298
  throw new IntegrationsCliError(`Multiple apps are connected — pick one with --provider:\n` +
287
299
  active.map((c) => ` --provider ${bareProvider(c.provider)}`).join("\n"), { expected: true });
288
300
  }
301
+ /**
302
+ * The hostname re-add needs. Prefer the server's canonical installation domain;
303
+ * an older row may only retain its MCP URL, and provider is the last-resort
304
+ * human-safe query when neither was stored.
305
+ */
306
+ export function connectionDomain(connection) {
307
+ const domain = connection.installation?.domain?.trim();
308
+ if (domain)
309
+ return domain;
310
+ const url = connection.installation?.surface?.url;
311
+ if (url) {
312
+ try {
313
+ const host = new URL(url).hostname;
314
+ if (host)
315
+ return host;
316
+ }
317
+ catch {
318
+ // The saved endpoint is advisory here. A malformed legacy URL must not
319
+ // prevent recovery when the provider slug can still be re-added.
320
+ }
321
+ }
322
+ return bareProvider(connection.provider);
323
+ }
324
+ /** A revoked row is still addressable, but it cannot make a live MCP call. */
325
+ export function revokedConnectionDetails(connection, companySlug) {
326
+ const domain = connectionDomain(connection);
327
+ return {
328
+ status: "revoked",
329
+ reason: "This connection was revoked and cannot be used until it is re-added.",
330
+ fixPath: `hq integrations connect ${domain}` +
331
+ (companySlug ? ` --company ${companySlug}` : ""),
332
+ };
333
+ }
289
334
  /**
290
335
  * Resolve a connection the caller named positionally OR through the
291
336
  * `--provider` / `--connection` flags. Every management verb takes an optional
292
337
  * `<app>` argument for ergonomics (`hq integrations policy linear …`), which is
293
338
  * matched exactly like `--provider` unless it looks like a connection id.
294
339
  */
295
- export async function resolveConnection(token, companyUid, app, opts) {
340
+ export async function resolveConnection(token, companyUid, app, opts, recoveryOpts = {}) {
296
341
  const connections = await fetchConnections(token, companyUid);
297
342
  if (app && !opts.provider && !opts.connection) {
298
343
  return selectConnection(connections, app.startsWith("acct_") ? { connection: app } : { provider: app });
299
344
  }
345
+ // Normal management verbs deliberately ignore revoked rows for implicit
346
+ // selection. Reconnect's explicit --connect recovery is the one exception:
347
+ // a sole revoked row is unambiguous and needs its saved domain to revive.
348
+ if (recoveryOpts.allowSingleRevoked &&
349
+ !opts.provider &&
350
+ !opts.connection &&
351
+ connections.length === 1) {
352
+ return connections[0];
353
+ }
300
354
  return selectConnection(connections, opts);
301
355
  }
302
356
  export async function callGateway(token, params) {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `hq integrations show | policy | grants | grant | ungrant | access | share |
3
- * unshare | audit | pending | disconnect`.
3
+ * unshare | audit | pending | disconnect | purge`.
4
4
  *
5
5
  * The govern-and-remove half of the lifecycle. Two different permission
6
6
  * surfaces live here and are easy to confuse, so they get separate verbs:
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `hq integrations show | policy | grants | grant | ungrant | access | share |
3
- * unshare | audit | pending | disconnect`.
3
+ * unshare | audit | pending | disconnect | purge`.
4
4
  *
5
5
  * The govern-and-remove half of the lifecycle. Two different permission
6
6
  * surfaces live here and are easy to confuse, so they get separate verbs:
@@ -16,8 +16,8 @@ import readline from "node:readline";
16
16
  import chalk from "chalk";
17
17
  import { ensureCognitoIdToken } from "../utils/cognito-session.js";
18
18
  import { getCompanyUid } from "../utils/vault-api.js";
19
- import { IntegrationsCliError, bareProvider, fetchAdminSurface, printJson, resolveConnection, selectConnection, } from "./integrations-core.js";
20
- import { fetchPendingApprovals, getConnectionAccess, mutateConnectionAccess, uninstallIntegration, updateGovernance, } from "./integrations-api.js";
19
+ import { IntegrationsCliError, bareProvider, fetchConnections, fetchAdminSurface, printJson, revokedConnectionDetails, resolveConnection, selectConnection, } from "./integrations-core.js";
20
+ import { fetchPendingApprovals, getConnectionAccess, mutateConnectionAccess, purgeConnection, setReadSafe, 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. */
@@ -97,6 +97,67 @@ function confirm(message) {
97
97
  function connectionLabel(connection) {
98
98
  return connection.installation?.displayName ?? bareProvider(connection.provider);
99
99
  }
100
+ /**
101
+ * The category of match must mirror `selectConnection`: an exact provider id
102
+ * takes precedence over a display name, which takes precedence over its human
103
+ * slug. Unlike ordinary verbs, purge deliberately operates on historical
104
+ * revoked rows, so it must not inherit selectConnection's active-first policy.
105
+ */
106
+ function purgeSelectorMatches(connections, selector) {
107
+ if (selector.connection) {
108
+ return connections.filter((connection) => connection.id === selector.connection);
109
+ }
110
+ if (selector.provider) {
111
+ const want = selector.provider.trim().toLowerCase();
112
+ const wantHumanSlug = humanSlug(selector.provider);
113
+ const providerMatches = connections.filter((connection) => bareProvider(connection.provider).toLowerCase() === want ||
114
+ connection.provider.toLowerCase() === want);
115
+ if (providerMatches.length > 0)
116
+ return providerMatches;
117
+ const displayNameMatches = connections.filter((connection) => connection.installation?.displayName?.trim().toLowerCase() === want);
118
+ if (displayNameMatches.length > 0)
119
+ return displayNameMatches;
120
+ if (wantHumanSlug) {
121
+ return connections.filter((connection) => {
122
+ const displayName = connection.installation?.displayName;
123
+ return displayName !== undefined && humanSlug(displayName) === wantHumanSlug;
124
+ });
125
+ }
126
+ return [];
127
+ }
128
+ return connections;
129
+ }
130
+ function humanSlug(value) {
131
+ return value
132
+ .trim()
133
+ .toLowerCase()
134
+ .replace(/[^a-z0-9]+/g, "-")
135
+ .replace(/^-+|-+$/g, "");
136
+ }
137
+ function purgeSelector(app, opts) {
138
+ if (app && !opts.provider && !opts.connection) {
139
+ return app.startsWith("acct_") ? { connection: app } : { provider: app };
140
+ }
141
+ return opts;
142
+ }
143
+ async function resolveRevokedPurgeConnection(token, companyUid, app, opts) {
144
+ const selector = purgeSelector(app, opts);
145
+ const connections = await fetchConnections(token, companyUid);
146
+ const matches = purgeSelectorMatches(connections, selector);
147
+ const revoked = matches.filter((connection) => connection.status === "revoked");
148
+ if (revoked.length === 1)
149
+ return revoked[0];
150
+ if (revoked.length > 1) {
151
+ const selected = selector.connection ?? selector.provider ?? "the supplied selector";
152
+ throw new IntegrationsCliError(`Multiple revoked connections match '${selected}'. Use --connection <acct_id> to choose one:\n` +
153
+ revoked.map((connection) => ` --connection ${connection.id} (${connectionLabel(connection)})`).join("\n"), { expected: true });
154
+ }
155
+ if (matches.length > 0) {
156
+ throw new IntegrationsCliError(`${connectionLabel(matches[0])} is still connected. Disconnect it first, then purge it.`, { expected: true });
157
+ }
158
+ // Keep the existing command group's not-found wording and error taxonomy.
159
+ return selectConnection(connections, selector);
160
+ }
100
161
  /**
101
162
  * Governance writes are owner-only on hq-pro AND the allowlist PATCH is a
102
163
  * whole-list REPLACE. A non-owner reads an identity-redacted grant list, so
@@ -144,7 +205,9 @@ export function registerManageCommands(integrations) {
144
205
  ? selectConnection(surface.connections, app.startsWith("acct_") ? { connection: app } : { provider: app })
145
206
  : selectConnection(surface.connections, opts);
146
207
  if (opts.json) {
147
- printJson(connection);
208
+ printJson(connection.status === "revoked"
209
+ ? { ...connection, ...revokedConnectionDetails(connection, opts.company) }
210
+ : connection);
148
211
  return;
149
212
  }
150
213
  const install = connection.installation;
@@ -153,6 +216,11 @@ export function registerManageCommands(integrations) {
153
216
  if (install?.id)
154
217
  console.log(chalk.dim(` installation: ${install.id}`));
155
218
  console.log(chalk.dim(` status: ${connection.status}`));
219
+ if (connection.status === "revoked") {
220
+ const details = revokedConnectionDetails(connection, opts.company);
221
+ console.log(chalk.yellow(` Revoked — ${details.reason}`));
222
+ console.log(chalk.yellow(` Re-add: ${details.fixPath}`));
223
+ }
156
224
  if (install?.domain)
157
225
  console.log(chalk.dim(` domain: ${install.domain}`));
158
226
  if (install?.surface?.url)
@@ -218,6 +286,47 @@ export function registerManageCommands(integrations) {
218
286
  }
219
287
  console.log(chalk.green(`${connectionLabel(connection)}: ${writePolicy} — ${POLICY_BLURB[writePolicy]}`));
220
288
  });
289
+ integrations
290
+ .command("read-safe <tool>")
291
+ .description("Mark a read-only tool as safe to run without approval (owner only)")
292
+ .option("--company <slug>", "Company slug, e.g. indigo")
293
+ .option("--provider <slug>", "Connected app (e.g. linear)")
294
+ .option("--connection <id>", "Connection id (acct_…)")
295
+ .option("--unset", "Remove the read-safe override")
296
+ .option("--off", "Alias for --unset")
297
+ .option("--json", "Machine-readable output")
298
+ .action(async (tool, opts) => {
299
+ const toolName = tool.trim();
300
+ if (!toolName) {
301
+ throw new IntegrationsCliError("Tool name cannot be empty.", { expected: true });
302
+ }
303
+ const token = await ensureCognitoIdToken();
304
+ const companyUid = await getCompanyUid(token, opts.company);
305
+ const connection = await resolveConnection(token, companyUid, undefined, opts);
306
+ const readSafe = !(opts.unset || opts.off);
307
+ try {
308
+ await setReadSafe(token, companyUid, connection.id, toolName, readSafe);
309
+ }
310
+ catch (error) {
311
+ if (error instanceof IntegrationsCliError) {
312
+ if (error.status === 403) {
313
+ throw new IntegrationsCliError("This command is owner only. Ask a company owner to manage read-safe tools.", { expected: true });
314
+ }
315
+ if (readSafe && (error.status === 409 || error.status === 422)) {
316
+ throw new IntegrationsCliError(`${toolName} is a write tool and can't be marked read-safe (its changes always need approval).`, { expected: true });
317
+ }
318
+ }
319
+ throw error;
320
+ }
321
+ const outcome = { connectionId: connection.id, toolName, readSafe };
322
+ if (opts.json) {
323
+ printJson(outcome);
324
+ return;
325
+ }
326
+ console.log(chalk.green(readSafe
327
+ ? `${toolName} on ${connectionLabel(connection)} is now read-safe and skips approval.`
328
+ : `${toolName} on ${connectionLabel(connection)} is no longer read-safe; its override was removed.`));
329
+ });
221
330
  integrations
222
331
  .command("grants [app]")
223
332
  .description("Show who is authorized to run an app's change-making tools")
@@ -518,6 +627,52 @@ export function registerManageCommands(integrations) {
518
627
  }
519
628
  console.log(chalk.green(`Disconnected ${connectionLabel(connection)}.`));
520
629
  });
630
+ integrations
631
+ .command("purge [app]")
632
+ .description("Permanently remove a revoked connection from the list (owner only)")
633
+ .option("--company <slug>", "Company slug, e.g. indigo")
634
+ .option("--provider <slug>", "Connected app (e.g. linear)")
635
+ .option("--connection <id>", "Connection id (acct_…)")
636
+ .option("--yes", "Skip the permanent-removal confirmation prompt")
637
+ .option("--json", "Machine-readable output")
638
+ .action(async (app, opts) => {
639
+ const token = await ensureCognitoIdToken();
640
+ const companyUid = await getCompanyUid(token, opts.company);
641
+ const connection = await resolveRevokedPurgeConnection(token, companyUid, app, opts);
642
+ const label = connectionLabel(connection);
643
+ if (!opts.yes) {
644
+ // Warning + prompt go to stderr: with --json, stdout must stay parseable.
645
+ console.error(`Purging ${chalk.bold(label)} permanently removes its revoked connection record. It cannot be restored.`);
646
+ const ok = await confirm(`Permanently purge ${label}?`);
647
+ if (!ok) {
648
+ // A non-TTY run lands here too: refusing is the only safe default
649
+ // when nobody can confirm a permanent deletion.
650
+ throw new IntegrationsCliError("Not purged. Re-run with --yes if you are sure.", { expected: true });
651
+ }
652
+ }
653
+ try {
654
+ await purgeConnection(token, companyUid, connection.id);
655
+ }
656
+ catch (error) {
657
+ if (error instanceof IntegrationsCliError && error.status === 409) {
658
+ throw new IntegrationsCliError(`${label} is still connected. Disconnect it first, then purge it.`, { expected: true, status: 409, code: error.code });
659
+ }
660
+ if (error instanceof IntegrationsCliError && error.status === 403) {
661
+ throw new IntegrationsCliError("Purging a connection is owner only. Ask a company owner to run this.", { expected: true, status: 403, code: error.code });
662
+ }
663
+ throw error;
664
+ }
665
+ const outcome = {
666
+ connectionId: connection.id,
667
+ provider: bareProvider(connection.provider),
668
+ status: "purged",
669
+ };
670
+ if (opts.json) {
671
+ printJson(outcome);
672
+ return;
673
+ }
674
+ console.log(chalk.green(`Purged ${label}. It will no longer appear in \`hq integrations list\`.`));
675
+ });
521
676
  }
522
677
  /** Order-insensitive identity of an allowlist, for change detection. */
523
678
  export function allowlistFingerprint(grants) {
@@ -26,10 +26,12 @@
26
26
  *
27
27
  * Govern and remove:
28
28
  * hq integrations policy [app] --set <m> Approval setting for changes.
29
+ * hq integrations read-safe <tool> Mark/unmark a trusted read-only tool.
29
30
  * hq integrations grants|grant|ungrant Per-tool authorization to run change-making tools.
30
31
  * hq integrations access|share|unshare Who may use the app.
31
32
  * hq integrations audit Recent activity.
32
33
  * hq integrations disconnect [app] Remove it and its credentials.
34
+ * hq integrations purge [app] Permanently remove a revoked connection row.
33
35
  *
34
36
  * Governance: reads flow freely; calls that can change the app are subject to
35
37
  * the connection's write policy (default: a person approves first). A queued
@@ -26,10 +26,12 @@
26
26
  *
27
27
  * Govern and remove:
28
28
  * hq integrations policy [app] --set <m> Approval setting for changes.
29
+ * hq integrations read-safe <tool> Mark/unmark a trusted read-only tool.
29
30
  * hq integrations grants|grant|ungrant Per-tool authorization to run change-making tools.
30
31
  * hq integrations access|share|unshare Who may use the app.
31
32
  * hq integrations audit Recent activity.
32
33
  * hq integrations disconnect [app] Remove it and its credentials.
34
+ * hq integrations purge [app] Permanently remove a revoked connection row.
33
35
  *
34
36
  * Governance: reads flow freely; calls that can change the app are subject to
35
37
  * the connection's write policy (default: a person approves first). A queued
@@ -50,11 +52,65 @@ import { randomUUID } from "node:crypto";
50
52
  import chalk from "chalk";
51
53
  import { ensureCognitoIdToken } from "../utils/cognito-session.js";
52
54
  import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
53
- import { IntegrationsCliError, bareProvider, callGateway, fetchConnections, isClientError, printJson, raiseIfUnauthorized, raiseIfUpstreamUnavailable, gatewayResultIsError, selectConnection, toolPrefixForProvider, unwrapGatewayResult, queuedOutcome, } from "./integrations-core.js";
55
+ import { IntegrationsCliError, bareProvider, callGateway, fetchConnections, isClientError, printJson, raiseIfUnauthorized, raiseIfUpstreamUnavailable, gatewayResultIsError, selectConnection, revokedConnectionDetails, toolPrefixForProvider, unwrapGatewayResult, queuedOutcome, } from "./integrations-core.js";
54
56
  import { registerConnectCommands } from "./integrations-connect.js";
55
57
  import { registerImportCommands } from "./integrations-import.js";
56
58
  import { registerManageCommands } from "./integrations-manage.js";
57
59
  export { IntegrationsCliError, callGateway, fetchConnections, queuedOutcome, selectConnection, toolPrefixForProvider, unwrapGatewayResult, } from "./integrations-core.js";
60
+ /**
61
+ * Gateway metadata is additive and may be absent when talking to an older
62
+ * gateway. Parse it defensively so an unknown or malformed value never breaks
63
+ * tool discovery.
64
+ */
65
+ function effectiveToolMode(tool) {
66
+ const raw = tool._meta?.["hq/effective-mode"];
67
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
68
+ return null;
69
+ const mode = raw.mode;
70
+ const approvalRequired = raw.approvalRequired;
71
+ if ((mode !== "read" && mode !== "write") || typeof approvalRequired !== "boolean") {
72
+ return null;
73
+ }
74
+ const denied = raw.denied;
75
+ const policy = raw.policy;
76
+ return {
77
+ mode,
78
+ approvalRequired,
79
+ ...(denied === true ? { denied: true } : {}),
80
+ ...(policy === "deny" ? { policy: "deny" } : {}),
81
+ };
82
+ }
83
+ function toolBadge(tool) {
84
+ const effectiveMode = effectiveToolMode(tool);
85
+ if (!effectiveMode)
86
+ return null;
87
+ if (effectiveMode.mode === "read")
88
+ return "read";
89
+ if (effectiveMode.denied)
90
+ return "denied";
91
+ return effectiveMode.approvalRequired ? "approval-required" : "write";
92
+ }
93
+ function formatToolBadge(tool) {
94
+ const badge = toolBadge(tool);
95
+ if (!badge)
96
+ return "";
97
+ const label = `[${badge}]`;
98
+ switch (badge) {
99
+ case "read":
100
+ return chalk.green(label);
101
+ case "approval-required":
102
+ return chalk.yellow(label);
103
+ case "denied":
104
+ return chalk.red(label);
105
+ case "write":
106
+ return chalk.blue(label);
107
+ }
108
+ }
109
+ /** Add gateway policy metadata where scripts can consume it directly. */
110
+ function toolJsonView(tool) {
111
+ const effectiveMode = effectiveToolMode(tool);
112
+ return effectiveMode ? { ...tool, effectiveMode } : tool;
113
+ }
58
114
  /**
59
115
  * The gateway relays MCP tool schemas verbatim. Keep the default listing
60
116
  * compact, but make required inputs visible before a caller has to discover
@@ -86,6 +142,23 @@ function printToolSchema(tool) {
86
142
  console.log(chalk.dim(" Input schema:"));
87
143
  console.log(chalk.dim(schema.split("\n").map((line) => ` ${line}`).join("\n")));
88
144
  }
145
+ function reportRevokedConnection(connection, opts) {
146
+ if (connection.status !== "revoked")
147
+ return false;
148
+ const details = revokedConnectionDetails(connection, opts.company);
149
+ if (opts.json) {
150
+ printJson(details);
151
+ }
152
+ else {
153
+ console.log(chalk.yellow(`${connectionLabel(connection)}: status=revoked`));
154
+ console.log(chalk.yellow(` ${details.reason}`));
155
+ console.log(` Re-add: ${details.fixPath}`);
156
+ }
157
+ if (opts.fail) {
158
+ throw new IntegrationsCliError(`status=revoked. ${details.reason} Re-add with: ${details.fixPath}`, { expected: true });
159
+ }
160
+ return true;
161
+ }
89
162
  export function registerIntegrationsCommand(program) {
90
163
  const integrations = program
91
164
  .command("integrations")
@@ -132,6 +205,8 @@ export function registerIntegrationsCommand(program) {
132
205
  const token = await ensureCognitoIdToken();
133
206
  const companyUid = await getCompanyUid(token, opts.company);
134
207
  const connection = selectConnection(await fetchConnections(token, companyUid), opts);
208
+ if (reportRevokedConnection(connection, opts))
209
+ return;
135
210
  const prefix = toolPrefixForProvider(connection.provider);
136
211
  const message = await callGateway(token, {
137
212
  companyUid,
@@ -154,24 +229,26 @@ export function registerIntegrationsCommand(program) {
154
229
  throw new IntegrationsCliError(`No tool named '${opts.describe}' on ${connectionLabel(connection)}.`, { expected: true });
155
230
  }
156
231
  if (opts.json) {
157
- printJson(tool);
232
+ printJson(toolJsonView(tool));
158
233
  return;
159
234
  }
160
- console.log(`${chalk.bold(tool.name)} ${chalk.dim(`on ${connectionLabel(connection)}`)}`);
235
+ const badge = formatToolBadge(tool);
236
+ console.log(`${chalk.bold(tool.name)}${badge ? ` ${badge}` : ""} ${chalk.dim(`on ${connectionLabel(connection)}`)}`);
161
237
  if (tool.description)
162
238
  console.log(chalk.dim(` ${tool.description}`));
163
239
  printToolSchema(tool);
164
240
  return;
165
241
  }
166
242
  if (opts.json) {
167
- printJson(payload);
243
+ printJson({ ...payload, tools: tools.map(toolJsonView) });
168
244
  return;
169
245
  }
170
246
  console.log(chalk.dim(`App: ${connectionLabel(connection)}`));
171
247
  for (const tool of tools) {
172
248
  const label = tool.title && tool.title !== tool.name ? ` ${chalk.dim(tool.title)}` : "";
173
249
  const required = requiredInputHint(tool.inputSchema);
174
- console.log(`${chalk.bold(tool.name)}${label}${required ? ` ${chalk.dim(required)}` : ""}`);
250
+ const badge = formatToolBadge(tool);
251
+ console.log(`${chalk.bold(tool.name)}${badge ? ` ${badge}` : ""}${label}${required ? ` ${chalk.dim(required)}` : ""}`);
175
252
  }
176
253
  console.log(chalk.dim(`\n${tools.length} tools. Describe inputs: hq integrations tools --provider ${bareProvider(connection.provider)} --describe <tool>\nCall one with: hq integrations call <tool> --provider ${bareProvider(connection.provider)} --args '<json>'`));
177
254
  });
@@ -199,6 +276,8 @@ export function registerIntegrationsCommand(program) {
199
276
  const token = await ensureCognitoIdToken();
200
277
  const companyUid = await getCompanyUid(token, opts.company);
201
278
  const connection = selectConnection(await fetchConnections(token, companyUid), opts);
279
+ if (reportRevokedConnection(connection, { ...opts, fail: true }))
280
+ return;
202
281
  const prefix = toolPrefixForProvider(connection.provider);
203
282
  const idempotencyKey = opts.idempotencyKey ?? `hq-cli-${randomUUID()}`;
204
283
  const message = await callGateway(token, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.103.21",
3
+ "version": "5.103.22",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {