@indigoai-us/hq-cli 5.101.7 → 5.103.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.
@@ -0,0 +1,556 @@
1
+ /**
2
+ * `hq integrations show | policy | grants | grant | ungrant | access | share |
3
+ * unshare | audit | pending | disconnect`.
4
+ *
5
+ * The govern-and-remove half of the lifecycle. Two different permission
6
+ * surfaces live here and are easy to confuse, so they get separate verbs:
7
+ *
8
+ * ACCESS (`access` / `share` / `unshare`) — who inside the company may use
9
+ * the connection at all. Managed by the connection's creator or a
10
+ * company admin.
11
+ * GRANTS (`grants` / `grant` / `ungrant`) — which of those people may call
12
+ * a specific WRITE tool without an approval round trip. Owner-only,
13
+ * and layered under the connection's write policy.
14
+ */
15
+ import readline from "node:readline";
16
+ import chalk from "chalk";
17
+ import { ensureCognitoIdToken } from "../utils/cognito-session.js";
18
+ import { getCompanyUid } from "../utils/vault-api.js";
19
+ import { IntegrationsCliError, bareProvider, fetchAdminSurface, printJson, resolveConnection, selectConnection, } from "./integrations-core.js";
20
+ import { getConnectionAccess, mutateConnectionAccess, uninstallIntegration, updateGovernance, } from "./integrations-api.js";
21
+ const WRITE_POLICIES = ["auto-allow", "confirm", "deny"];
22
+ const PERMISSIONS = ["read", "write", "admin"];
23
+ /** Plain-English gloss for each write policy, used in every policy readout. */
24
+ const POLICY_BLURB = {
25
+ "auto-allow": "changes run immediately, no approval",
26
+ confirm: "a company owner approves each change first",
27
+ deny: "changes are refused",
28
+ };
29
+ /**
30
+ * Parse a grantee from the one string a person would actually type.
31
+ *
32
+ * Accepts an explicit `person:`/`group:`/`email:` prefix, the word `everyone`,
33
+ * a bare email address, or a bare uid. Refuses anything ambiguous rather than
34
+ * guessing — a misparsed principal silently grants access to the wrong party.
35
+ */
36
+ export function parsePrincipal(raw) {
37
+ const value = raw.trim();
38
+ if (!value) {
39
+ throw new IntegrationsCliError("Name who to share with.", { expected: true });
40
+ }
41
+ if (/^(everyone|company-wide|all)$/i.test(value)) {
42
+ return { granteeType: "company-wide" };
43
+ }
44
+ const prefixed = /^(person|group|email):(.+)$/i.exec(value);
45
+ if (prefixed) {
46
+ return {
47
+ granteeType: prefixed[1].toLowerCase(),
48
+ granteeId: prefixed[2].trim(),
49
+ };
50
+ }
51
+ if (value.includes("@"))
52
+ return { granteeType: "email", granteeId: value };
53
+ if (/^grp_[A-Za-z0-9_-]+$/i.test(value))
54
+ return { granteeType: "group", granteeId: value };
55
+ // Agents are people to the ACL: they hold an identity and a membership, so a
56
+ // grant to `agt_…` is a person grant, not a separate grantee kind.
57
+ if (/^(prs|psn|agt)_[A-Za-z0-9_-]+$/i.test(value)) {
58
+ return { granteeType: "person", granteeId: value };
59
+ }
60
+ throw new IntegrationsCliError(`Could not tell what '${raw}' refers to. Use an email address, a uid (prs_… / agt_… / grp_…), \`everyone\`, or an explicit \`person:<id>\` / \`group:<id>\` form.`, { expected: true });
61
+ }
62
+ /** How a principal reads back in output, without leaking a raw uid as a name. */
63
+ function describePrincipal(entry) {
64
+ if (entry.granteeType === "company-wide")
65
+ return "everyone in the company";
66
+ return entry.granteeName ? `${entry.granteeName} (${entry.granteeId})` : entry.granteeId;
67
+ }
68
+ /** Interactive yes/no. Refuses (never assumes yes) when there is nobody to ask. */
69
+ function confirm(message) {
70
+ if (!process.stdin.isTTY)
71
+ return Promise.resolve(false);
72
+ // stderr, so `--json` stdout stays machine-readable.
73
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
74
+ return new Promise((resolve) => {
75
+ rl.question(`${message} [y/N] `, (answer) => {
76
+ rl.close();
77
+ resolve(/^y(es)?$/i.test(answer.trim()));
78
+ });
79
+ });
80
+ }
81
+ function connectionLabel(connection) {
82
+ return connection.installation?.displayName ?? bareProvider(connection.provider);
83
+ }
84
+ /**
85
+ * Governance writes are owner-only on hq-pro AND the allowlist PATCH is a
86
+ * whole-list REPLACE. A non-owner reads an identity-redacted grant list, so
87
+ * merging into it and writing it back would erase real grantees. Refuse before
88
+ * reading rather than after — the redacted list must never reach a mutation.
89
+ */
90
+ function requireGovernanceManager(viewer) {
91
+ if (!viewer.canManageGovernance) {
92
+ throw new IntegrationsCliError("Only a company owner can change an app's approval settings. Ask an owner to run this.", { expected: true });
93
+ }
94
+ }
95
+ function printAccess(access) {
96
+ const mode = access.access.mode === "legacy-open"
97
+ ? "everyone (not yet restricted)"
98
+ : access.access.mode;
99
+ console.log(`${chalk.bold(access.provider)} ${chalk.dim(`shared: ${mode}`)}`);
100
+ console.log(chalk.dim(` connected by ${access.creator.name ?? access.creator.uid}`));
101
+ if (access.entries.length === 0) {
102
+ console.log(chalk.dim(" No individual grants."));
103
+ }
104
+ for (const entry of access.entries) {
105
+ console.log(` ${describePrincipal(entry)} ${chalk.dim(entry.permission)}`);
106
+ }
107
+ if (!access.canManage) {
108
+ console.log(chalk.dim("\n You cannot change this — ask its owner or a company admin."));
109
+ }
110
+ }
111
+ export function registerManageCommands(integrations) {
112
+ integrations
113
+ .command("show [app]")
114
+ .description("Show one connected app in full")
115
+ .option("--company <slug>", "Company slug")
116
+ .option("--provider <slug>", "Connected app (e.g. linear)")
117
+ .option("--connection <id>", "Connection id (acct_…)")
118
+ .option("--json", "Machine-readable output")
119
+ .action(async (app, opts) => {
120
+ const token = await ensureCognitoIdToken();
121
+ const companyUid = await getCompanyUid(token, opts.company);
122
+ const surface = await fetchAdminSurface(token, companyUid);
123
+ const connection = app && !opts.provider && !opts.connection
124
+ ? selectConnection(surface.connections, app.startsWith("acct_") ? { connection: app } : { provider: app })
125
+ : selectConnection(surface.connections, opts);
126
+ if (opts.json) {
127
+ printJson(connection);
128
+ return;
129
+ }
130
+ const install = connection.installation;
131
+ console.log(`${chalk.bold(connectionLabel(connection))} ${chalk.dim(bareProvider(connection.provider))}`);
132
+ console.log(chalk.dim(` connection: ${connection.id}`));
133
+ if (install?.id)
134
+ console.log(chalk.dim(` installation: ${install.id}`));
135
+ console.log(chalk.dim(` status: ${connection.status}`));
136
+ if (install?.domain)
137
+ console.log(chalk.dim(` domain: ${install.domain}`));
138
+ if (install?.surface?.url)
139
+ console.log(chalk.dim(` endpoint: ${install.surface.url}`));
140
+ const policy = connection.writePolicy;
141
+ if (policy) {
142
+ console.log(chalk.dim(` changes: ${policy} — ${POLICY_BLURB[policy] ?? ""}`));
143
+ }
144
+ if (connection.access?.mode) {
145
+ console.log(chalk.dim(` shared: ${connection.access.mode}${connection.access.grantCount ? ` (${connection.access.grantCount} grants)` : ""}`));
146
+ }
147
+ if (connection.createdByName || connection.createdBy) {
148
+ console.log(chalk.dim(` connected by ${connection.createdByName ?? connection.createdBy}`));
149
+ }
150
+ if (install?.status === "needs_credentials") {
151
+ console.log(chalk.yellow(" Needs sign-in — run `hq integrations reconnect` to fix it."));
152
+ }
153
+ });
154
+ integrations
155
+ .command("policy [app]")
156
+ .description("Show or change whether an app's changes need approval")
157
+ .option("--company <slug>", "Company slug")
158
+ .option("--provider <slug>", "Connected app (e.g. linear)")
159
+ .option("--connection <id>", "Connection id (acct_…)")
160
+ .option("--set <mode>", "auto-allow, confirm, or deny")
161
+ .option("--json", "Machine-readable output")
162
+ .action(async (app, opts) => {
163
+ const token = await ensureCognitoIdToken();
164
+ const companyUid = await getCompanyUid(token, opts.company);
165
+ const surface = await fetchAdminSurface(token, companyUid);
166
+ const connection = app && !opts.provider && !opts.connection
167
+ ? selectConnection(surface.connections, app.startsWith("acct_") ? { connection: app } : { provider: app })
168
+ : selectConnection(surface.connections, opts);
169
+ if (!opts.set) {
170
+ // An absent writePolicy is UNKNOWN, not `confirm`. Defaulting to the
171
+ // safe-sounding value would tell someone their writes are gated when
172
+ // the response simply never said so.
173
+ const current = connection.writePolicy;
174
+ if (opts.json) {
175
+ printJson({ connectionId: connection.id, writePolicy: current ?? null });
176
+ return;
177
+ }
178
+ if (!current) {
179
+ console.log(`${connectionLabel(connection)}: approval setting not reported by this HQ backend.`);
180
+ return;
181
+ }
182
+ console.log(`${connectionLabel(connection)}: ${chalk.bold(current)} — ${POLICY_BLURB[current]}`);
183
+ console.log(chalk.dim(`\nChange it with: hq integrations policy ${bareProvider(connection.provider)} --set <${WRITE_POLICIES.join("|")}>`));
184
+ return;
185
+ }
186
+ if (!WRITE_POLICIES.includes(opts.set)) {
187
+ throw new IntegrationsCliError(`--set must be one of: ${WRITE_POLICIES.join(", ")}.`, { expected: true });
188
+ }
189
+ requireGovernanceManager(surface.viewer);
190
+ const writePolicy = opts.set;
191
+ const result = await updateGovernance(token, companyUid, {
192
+ connectionId: connection.id,
193
+ writePolicy,
194
+ });
195
+ if (opts.json) {
196
+ printJson(result);
197
+ return;
198
+ }
199
+ console.log(chalk.green(`${connectionLabel(connection)}: ${writePolicy} — ${POLICY_BLURB[writePolicy]}`));
200
+ });
201
+ integrations
202
+ .command("grants [app]")
203
+ .description("Show who may run an app's change-making tools without approval")
204
+ .option("--company <slug>", "Company slug")
205
+ .option("--provider <slug>", "Connected app (e.g. linear)")
206
+ .option("--connection <id>", "Connection id (acct_…)")
207
+ .option("--json", "Machine-readable output")
208
+ .action(async (app, opts) => {
209
+ const token = await ensureCognitoIdToken();
210
+ const companyUid = await getCompanyUid(token, opts.company);
211
+ const connection = await resolveConnection(token, companyUid, app, opts);
212
+ const grants = connection.writeAllowlist ?? [];
213
+ if (opts.json) {
214
+ printJson(grants);
215
+ return;
216
+ }
217
+ if (grants.length === 0) {
218
+ console.log("No per-tool exceptions — this app follows its overall approval setting.");
219
+ return;
220
+ }
221
+ for (const grant of grants) {
222
+ console.log(chalk.bold(grant.toolName));
223
+ for (const entry of grant.entries) {
224
+ console.log(` ${describePrincipal(entry)} ${chalk.dim(entry.permission)}`);
225
+ }
226
+ }
227
+ });
228
+ /*
229
+ * grant / ungrant are read-modify-write: hq-pro's PATCH REPLACES the whole
230
+ * per-tool allowlist, so both verbs must send the merged list, never a delta.
231
+ */
232
+ integrations
233
+ .command("grant [app]")
234
+ .description("Let someone run one of an app's change-making tools without approval")
235
+ .option("--company <slug>", "Company slug")
236
+ .option("--provider <slug>", "Connected app (e.g. linear)")
237
+ .option("--connection <id>", "Connection id (acct_…)")
238
+ .requiredOption("--tool <name>", "The tool to allow")
239
+ .requiredOption("--to <principal>", "Email, uid, or `everyone`")
240
+ .option("--permission <level>", "read, write, or admin", "write")
241
+ .option("--json", "Machine-readable output")
242
+ .action(async (app, opts) => {
243
+ if (!PERMISSIONS.includes(opts.permission)) {
244
+ throw new IntegrationsCliError(`--permission must be one of: ${PERMISSIONS.join(", ")}.`, { expected: true });
245
+ }
246
+ const toolName = opts.tool.trim();
247
+ if (!toolName) {
248
+ throw new IntegrationsCliError("--tool cannot be empty.", { expected: true });
249
+ }
250
+ const principal = parsePrincipal(opts.to);
251
+ const token = await ensureCognitoIdToken();
252
+ const companyUid = await getCompanyUid(token, opts.company);
253
+ const surface = await fetchAdminSurface(token, companyUid);
254
+ requireGovernanceManager(surface.viewer);
255
+ const connection = app && !opts.provider && !opts.connection
256
+ ? selectConnection(surface.connections, app.startsWith("acct_") ? { connection: app } : { provider: app })
257
+ : selectConnection(surface.connections, opts);
258
+ const snapshot = connection.writeAllowlist ?? [];
259
+ const merged = mergeGrant(snapshot, toolName, {
260
+ granteeType: principal.granteeType,
261
+ granteeId: principal.granteeId ?? "",
262
+ permission: opts.permission,
263
+ });
264
+ const result = await patchAllowlistIfUnchanged(token, companyUid, connection.id, snapshot, merged);
265
+ if (opts.json) {
266
+ printJson(result);
267
+ return;
268
+ }
269
+ console.log(chalk.green(`${describePrincipal({ ...principal, granteeId: principal.granteeId ?? "" })} can now run ${toolName} on ${connectionLabel(connection)} without approval.`));
270
+ });
271
+ integrations
272
+ .command("ungrant [app]")
273
+ .description("Remove a per-tool exception, so the tool needs approval again")
274
+ .option("--company <slug>", "Company slug")
275
+ .option("--provider <slug>", "Connected app (e.g. linear)")
276
+ .option("--connection <id>", "Connection id (acct_…)")
277
+ .requiredOption("--tool <name>", "The tool to stop allowing")
278
+ .requiredOption("--from <principal>", "Email, uid, or `everyone`")
279
+ .option("--json", "Machine-readable output")
280
+ .action(async (app, opts) => {
281
+ const principal = parsePrincipal(opts.from);
282
+ const token = await ensureCognitoIdToken();
283
+ const companyUid = await getCompanyUid(token, opts.company);
284
+ const surface = await fetchAdminSurface(token, companyUid);
285
+ requireGovernanceManager(surface.viewer);
286
+ const connection = app && !opts.provider && !opts.connection
287
+ ? selectConnection(surface.connections, app.startsWith("acct_") ? { connection: app } : { provider: app })
288
+ : selectConnection(surface.connections, opts);
289
+ const current = connection.writeAllowlist ?? [];
290
+ const merged = removeGrant(current, opts.tool.trim(), principal);
291
+ if (merged === null) {
292
+ throw new IntegrationsCliError(`No exception for ${opts.from} on ${opts.tool} — nothing to remove.`, { expected: true });
293
+ }
294
+ const result = await patchAllowlistIfUnchanged(token, companyUid, connection.id, current, merged);
295
+ if (opts.json) {
296
+ printJson(result);
297
+ return;
298
+ }
299
+ console.log(chalk.green(`${opts.tool} on ${connectionLabel(connection)} needs approval again.`));
300
+ });
301
+ integrations
302
+ .command("access [app]")
303
+ .description("Show who in the company can use a connected app")
304
+ .option("--company <slug>", "Company slug")
305
+ .option("--provider <slug>", "Connected app (e.g. linear)")
306
+ .option("--connection <id>", "Connection id (acct_…)")
307
+ .option("--json", "Machine-readable output")
308
+ .action(async (app, opts) => {
309
+ const token = await ensureCognitoIdToken();
310
+ const companyUid = await getCompanyUid(token, opts.company);
311
+ const connection = await resolveConnection(token, companyUid, app, opts);
312
+ const access = await getConnectionAccess(token, companyUid, connection.id);
313
+ if (opts.json) {
314
+ printJson(access);
315
+ return;
316
+ }
317
+ printAccess(access);
318
+ });
319
+ integrations
320
+ .command("share [app]")
321
+ .description("Let someone in the company use a connected app")
322
+ .option("--company <slug>", "Company slug")
323
+ .option("--provider <slug>", "Connected app (e.g. linear)")
324
+ .option("--connection <id>", "Connection id (acct_…)")
325
+ .requiredOption("--with <principal>", "Email, uid, or `everyone`")
326
+ .option("--permission <level>", "read, write, or admin", "write")
327
+ .option("--json", "Machine-readable output")
328
+ .action(async (app, opts) => {
329
+ if (!PERMISSIONS.includes(opts.permission)) {
330
+ throw new IntegrationsCliError(`--permission must be one of: ${PERMISSIONS.join(", ")}.`, { expected: true });
331
+ }
332
+ const principal = parsePrincipal(opts.with);
333
+ const token = await ensureCognitoIdToken();
334
+ const companyUid = await getCompanyUid(token, opts.company);
335
+ const connection = await resolveConnection(token, companyUid, app, opts);
336
+ const access = await mutateConnectionAccess(token, companyUid, "grant", {
337
+ connectionId: connection.id,
338
+ granteeType: principal.granteeType,
339
+ ...(principal.granteeId ? { granteeId: principal.granteeId } : {}),
340
+ permission: opts.permission,
341
+ });
342
+ if (opts.json) {
343
+ printJson(access);
344
+ return;
345
+ }
346
+ console.log(chalk.green(`Shared ${connectionLabel(connection)}.`));
347
+ printAccess(access);
348
+ });
349
+ integrations
350
+ .command("unshare [app]")
351
+ .description("Stop someone from using a connected app")
352
+ .option("--company <slug>", "Company slug")
353
+ .option("--provider <slug>", "Connected app (e.g. linear)")
354
+ .option("--connection <id>", "Connection id (acct_…)")
355
+ .requiredOption("--from <principal>", "Email, uid, or `everyone`")
356
+ .option("--json", "Machine-readable output")
357
+ .action(async (app, opts) => {
358
+ const principal = parsePrincipal(opts.from);
359
+ const token = await ensureCognitoIdToken();
360
+ const companyUid = await getCompanyUid(token, opts.company);
361
+ const connection = await resolveConnection(token, companyUid, app, opts);
362
+ const access = await mutateConnectionAccess(token, companyUid, "revoke", {
363
+ connectionId: connection.id,
364
+ granteeType: principal.granteeType,
365
+ ...(principal.granteeId ? { granteeId: principal.granteeId } : {}),
366
+ });
367
+ if (opts.json) {
368
+ printJson(access);
369
+ return;
370
+ }
371
+ console.log(chalk.green(`Removed access to ${connectionLabel(connection)}.`));
372
+ printAccess(access);
373
+ });
374
+ integrations
375
+ .command("audit")
376
+ .description("Recent activity across the company's connected apps")
377
+ .option("--company <slug>", "Company slug")
378
+ .option("--provider <slug>", "Only this app")
379
+ .option("--limit <n>", "How many events to show", "20")
380
+ .option("--json", "Machine-readable output")
381
+ .action(async (opts) => {
382
+ const token = await ensureCognitoIdToken();
383
+ const companyUid = await getCompanyUid(token, opts.company);
384
+ const surface = await fetchAdminSurface(token, companyUid);
385
+ const limit = Number(opts.limit);
386
+ if (!Number.isFinite(limit) || limit < 1) {
387
+ throw new IntegrationsCliError("--limit must be a positive number.", { expected: true });
388
+ }
389
+ // Normalize BOTH sides: `--provider` is routinely copied from JSON
390
+ // output, which carries the qualified `factory:linear` form, while every
391
+ // audit row is stored bare. Comparing them raw silently reports "no
392
+ // activity" for an app that has plenty.
393
+ const want = opts.provider ? bareProvider(opts.provider.trim()).toLowerCase() : undefined;
394
+ const rows = surface.audit
395
+ .filter((row) => !want || bareProvider(row.provider ?? "").toLowerCase() === want)
396
+ .slice(0, limit);
397
+ if (opts.json) {
398
+ printJson(rows);
399
+ return;
400
+ }
401
+ if (rows.length === 0) {
402
+ console.log("No recorded activity yet.");
403
+ return;
404
+ }
405
+ for (const row of rows) {
406
+ const who = row.memberOrAgentName ?? row.memberOrAgent;
407
+ console.log(`${chalk.dim(row.timestamp)} ${chalk.bold(row.outcome)} ${row.toolName} ${chalk.dim(who)}`);
408
+ if (row.reason)
409
+ console.log(chalk.dim(` ${row.reason}`));
410
+ }
411
+ // hq-pro caps its own feed at 50 events; say so rather than letting a
412
+ // truncated list read as "that is everything that ever happened".
413
+ if (surface.audit.length > rows.length) {
414
+ console.log(chalk.dim(`\nShowing ${rows.length} of ${surface.audit.length} recent events.`));
415
+ }
416
+ });
417
+ integrations
418
+ .command("pending")
419
+ .description("Calls that were queued for an owner's approval")
420
+ .option("--company <slug>", "Company slug")
421
+ .option("--json", "Machine-readable output")
422
+ .action(async (opts) => {
423
+ const token = await ensureCognitoIdToken();
424
+ 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);
427
+ if (opts.json) {
428
+ printJson(queued);
429
+ return;
430
+ }
431
+ if (queued.length === 0) {
432
+ console.log("Nothing is waiting for approval.");
433
+ return;
434
+ }
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}`)}`);
438
+ console.log(chalk.dim(` hq integrations approve ${row.queueId}${row.provider ? ` --provider ${bareProvider(row.provider)}` : ""}`));
439
+ }
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
+ });
446
+ integrations
447
+ .command("disconnect [app]")
448
+ .alias("remove")
449
+ .description("Disconnect an app and delete its stored credentials")
450
+ .option("--company <slug>", "Company slug")
451
+ .option("--provider <slug>", "Connected app (e.g. linear)")
452
+ .option("--connection <id>", "Connection id (acct_…)")
453
+ .option("--yes", "Skip the confirmation prompt")
454
+ .option("--json", "Machine-readable output")
455
+ .action(async (app, opts) => {
456
+ const token = await ensureCognitoIdToken();
457
+ const companyUid = await getCompanyUid(token, opts.company);
458
+ const connection = await resolveConnection(token, companyUid, app, opts);
459
+ const installationId = connection.installation?.id;
460
+ if (!installationId) {
461
+ throw new IntegrationsCliError(`${connectionLabel(connection)} was not installed through the app catalog, so it cannot be disconnected from here.`, { expected: true });
462
+ }
463
+ if (!opts.yes) {
464
+ const label = connectionLabel(connection);
465
+ // Warning + prompt go to stderr: with --json, stdout must stay parseable,
466
+ // and prose ahead of the result would make it invalid.
467
+ console.error(`Disconnecting ${chalk.bold(label)} deletes its stored credentials, revokes the connection, and clears its approval exceptions. Anything relying on it stops working. Reconnecting requires signing in again.`);
468
+ const ok = await confirm(`Disconnect ${label}?`);
469
+ if (!ok) {
470
+ // A non-TTY run lands here too: refusing is the only safe default
471
+ // when there is nobody to ask about an irreversible removal.
472
+ throw new IntegrationsCliError("Not disconnected. Re-run with --yes if you are sure.", { expected: true });
473
+ }
474
+ }
475
+ const result = await uninstallIntegration(token, companyUid, installationId);
476
+ if (opts.json) {
477
+ printJson(result);
478
+ return;
479
+ }
480
+ console.log(chalk.green(`Disconnected ${connectionLabel(connection)}.`));
481
+ });
482
+ }
483
+ /** Order-insensitive identity of an allowlist, for change detection. */
484
+ export function allowlistFingerprint(grants) {
485
+ return JSON.stringify([...grants]
486
+ .map((grant) => ({
487
+ toolName: grant.toolName,
488
+ entries: [...grant.entries]
489
+ .map((e) => `${e.granteeType}:${e.granteeId}:${e.permission}`)
490
+ .sort(),
491
+ }))
492
+ .sort((a, b) => a.toolName.localeCompare(b.toolName)));
493
+ }
494
+ /**
495
+ * Send a merged allowlist, but only if nobody changed it in between.
496
+ *
497
+ * hq-pro's PATCH replaces the whole list and offers no compare-and-set, so two
498
+ * owners running `grant`/`ungrant` at once would each merge into the same
499
+ * snapshot and the later write would silently discard the earlier one. Re-read
500
+ * immediately before writing and refuse on drift: this cannot close the window
501
+ * entirely (there is no server-side CAS to close it with), but it turns a
502
+ * silent lost update into a visible, retryable error.
503
+ */
504
+ async function patchAllowlistIfUnchanged(token, companyUid, connectionId, snapshot, merged) {
505
+ const fresh = await fetchAdminSurface(token, companyUid);
506
+ const current = fresh.connections.find((c) => c.id === connectionId)?.writeAllowlist ?? [];
507
+ if (allowlistFingerprint(current) !== allowlistFingerprint(snapshot)) {
508
+ throw new IntegrationsCliError("Someone else changed this app's approval exceptions while this command was running. Nothing was written — re-run to apply your change on top of theirs.", { expected: true });
509
+ }
510
+ return await updateGovernance(token, companyUid, { connectionId, writeAllowlist: merged });
511
+ }
512
+ /**
513
+ * Add (or upgrade) one grantee's permission on one tool, returning the FULL
514
+ * allowlist to send back. Exported for tests: the merge is the part that would
515
+ * silently destroy other people's grants if it drifted.
516
+ */
517
+ export function mergeGrant(current, toolName, entry) {
518
+ const next = current.map((grant) => ({ ...grant, entries: [...grant.entries] }));
519
+ const existing = next.find((grant) => grant.toolName === toolName);
520
+ const target = existing ?? { toolName, entries: [] };
521
+ if (!existing)
522
+ next.push(target);
523
+ const at = target.entries.findIndex((candidate) => candidate.granteeType === entry.granteeType && candidate.granteeId === entry.granteeId);
524
+ if (at >= 0)
525
+ target.entries[at] = { ...target.entries[at], permission: entry.permission };
526
+ else
527
+ target.entries.push(entry);
528
+ return next;
529
+ }
530
+ /**
531
+ * Remove one grantee from one tool, returning the FULL allowlist to send back,
532
+ * or `null` when the grant was not there (so the caller can say "nothing to
533
+ * remove" instead of issuing a no-op write). A tool left with no entries is
534
+ * dropped entirely rather than persisted as an empty rule.
535
+ */
536
+ export function removeGrant(current, toolName, principal) {
537
+ const granteeId = principal.granteeId ?? "";
538
+ let removed = false;
539
+ const next = [];
540
+ for (const grant of current) {
541
+ if (grant.toolName !== toolName) {
542
+ next.push(grant);
543
+ continue;
544
+ }
545
+ const entries = grant.entries.filter((entry) => {
546
+ const match = entry.granteeType === principal.granteeType && entry.granteeId === granteeId;
547
+ if (match)
548
+ removed = true;
549
+ return !match;
550
+ });
551
+ if (entries.length > 0)
552
+ next.push({ ...grant, entries });
553
+ }
554
+ return removed ? next : null;
555
+ }
556
+ //# sourceMappingURL=integrations-manage.js.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Native-client OAuth loopback listener for `hq integrations connect`.
3
+ *
4
+ * An OAuth-protected MCP server signs the admin in through a browser, so the
5
+ * authorization code comes back as an HTTP redirect. RFC 8252 §7.3 defines the
6
+ * native-app answer: bind an ephemeral port on the loopback interface and use
7
+ * `http://127.0.0.1:<port>/…` as the redirect URI. hq-pro validates that shape
8
+ * on `/oauth/start` (see `isCliLoopbackRedirectUri`) and keeps everything that
9
+ * matters server-side — the PKCE verifier, the single-use state row, and the
10
+ * code exchange — so a code captured here is useless on its own.
11
+ *
12
+ * Ordering matters: the listener must be bound BEFORE `/oauth/start` is called,
13
+ * because the port is part of the redirect URI hq-pro registers with the remote
14
+ * authorization server. Bind → start → open browser → await → exchange.
15
+ */
16
+ /**
17
+ * The one callback path hq-pro admits for a loopback redirect. Must stay
18
+ * byte-identical to hq-pro's `CLI_LOOPBACK_REDIRECT_PATH`; a drift here reads
19
+ * as `OAUTH_REDIRECT_URI_NOT_ALLOWED` at connect time.
20
+ */
21
+ export declare const LOOPBACK_CALLBACK_PATH = "/hq/integrations/oauth/callback";
22
+ export interface LoopbackListener {
23
+ /** The redirect URI to hand hq-pro — includes the OS-assigned port. */
24
+ redirectUri: string;
25
+ /**
26
+ * Resolves once the authorization server redirects back. Rejects on timeout,
27
+ * on an `?error=` response (the person clicked Deny), or on a state mismatch.
28
+ */
29
+ waitForCode(expectedState: string): Promise<string>;
30
+ close(): void;
31
+ }
32
+ /**
33
+ * Bind a loopback listener on an ephemeral port and return its redirect URI.
34
+ *
35
+ * Binds to `127.0.0.1` explicitly rather than the default wildcard: a wildcard
36
+ * bind would expose the callback to the local network for the life of the
37
+ * sign-in, and the whole reason hq-pro admits this URI is that it cannot leave
38
+ * the machine.
39
+ */
40
+ export declare function startLoopbackListener(opts?: {
41
+ timeoutMs?: number;
42
+ }): Promise<LoopbackListener>;
43
+ //# sourceMappingURL=integrations-oauth.d.ts.map