@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.
- package/CHANGELOG.md +57 -0
- package/dist/commands/agents.d.ts +43 -0
- package/dist/commands/agents.js +137 -0
- package/dist/commands/doctor.d.ts +10 -1
- package/dist/commands/doctor.js +7 -2
- package/dist/commands/integrations-api.d.ts +216 -0
- package/dist/commands/integrations-api.js +135 -0
- package/dist/commands/integrations-connect.d.ts +30 -0
- package/dist/commands/integrations-connect.js +583 -0
- package/dist/commands/integrations-core.d.ts +216 -0
- package/dist/commands/integrations-core.js +320 -0
- package/dist/commands/integrations-manage.d.ts +50 -0
- package/dist/commands/integrations-manage.js +556 -0
- package/dist/commands/integrations-oauth.d.ts +43 -0
- package/dist/commands/integrations-oauth.js +159 -0
- package/dist/commands/integrations.d.ts +32 -69
- package/dist/commands/integrations.js +42 -262
- package/dist/commands/reindex.js +1 -1
- package/dist/lib/doctor/checks/runtime-health.d.ts +100 -0
- package/dist/lib/doctor/checks/runtime-health.js +336 -0
- package/dist/lib/doctor/registry.js +6 -0
- package/dist/lib/doctor/types.d.ts +7 -0
- package/dist/utils/self-update.d.ts +2 -2
- package/dist/utils/self-update.js +19 -3
- package/dist/utils/version-gate.d.ts +34 -3
- package/dist/utils/version-gate.js +61 -4
- package/package.json +1 -1
|
@@ -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
|