@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 +6 -0
- package/dist/commands/auth.d.ts +1 -1
- package/dist/commands/auth.js +2 -2
- package/dist/commands/integrations-api.d.ts +13 -0
- package/dist/commands/integrations-api.js +35 -0
- package/dist/commands/integrations-connect.js +79 -42
- package/dist/commands/integrations-core.d.ts +20 -0
- package/dist/commands/integrations-core.js +59 -5
- package/dist/commands/integrations-manage.d.ts +1 -1
- package/dist/commands/integrations-manage.js +159 -4
- package/dist/commands/integrations.d.ts +2 -0
- package/dist/commands/integrations.js +84 -5
- package/package.json +1 -1
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
|
package/dist/commands/auth.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
package/dist/commands/auth.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
?
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|