@indigoai-us/hq-cli 5.103.20 → 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 +26 -0
- package/dist/commands/agents.d.ts +23 -0
- package/dist/commands/agents.js +95 -9
- package/dist/commands/auth.d.ts +1 -1
- package/dist/commands/auth.js +2 -2
- package/dist/commands/integrations-api.d.ts +29 -0
- package/dist/commands/integrations-api.js +35 -0
- package/dist/commands/integrations-connect.js +89 -43
- package/dist/commands/integrations-core.d.ts +24 -2
- package/dist/commands/integrations-core.js +88 -12
- package/dist/commands/integrations-import.d.ts +24 -0
- package/dist/commands/integrations-import.js +364 -0
- package/dist/commands/integrations-manage.d.ts +1 -1
- package/dist/commands/integrations-manage.js +159 -4
- package/dist/commands/integrations.d.ts +3 -0
- package/dist/commands/integrations.js +143 -9
- package/dist/commands/mcp-registration.d.ts +2 -2
- package/dist/commands/mcp-registration.js +8 -6
- package/dist/utils/vault-api.d.ts +12 -0
- package/dist/utils/vault-api.js +13 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
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
|
+
|
|
11
|
+
## [5.103.21] — 2026-08-24
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- `hq agents jobs list|pause|cancel` now fails closed on an agent box instead of
|
|
16
|
+
provoking a cross-agent rejection on the server. These off-box operator
|
|
17
|
+
commands are reachable on agent boxes, where the CLI authenticates with the
|
|
18
|
+
box's own agent machine identity; forwarding another agent's identifier into
|
|
19
|
+
`GET /v1/agents/{uid}/jobs` was refused server-side by hq-pro's self-only jobs
|
|
20
|
+
guard and logged as an unexpected warning, while the CLI mislabeled the 403 as
|
|
21
|
+
"You need owner/admin on this company." — a role problem a box can never
|
|
22
|
+
satisfy. The commands now resolve the agent reference through the company
|
|
23
|
+
roster (a slug or name becomes an `agt_` uid, or fails locally), and refuse a
|
|
24
|
+
cross-agent target before any request leaves the box, naming the on-box
|
|
25
|
+
`hq-agent-jobs` tool and the person session required to manage another agent's
|
|
26
|
+
jobs. A server-side `CROSS_AGENT_JOB` denial is now rendered as identity-scoped
|
|
27
|
+
guidance rather than the owner/admin copy. Separately, every authenticated
|
|
28
|
+
vault-API request now carries an `x-hq-client-name` header so its origin is
|
|
29
|
+
attributable in telemetry and error reports. (Sentry 7617401436)
|
|
30
|
+
|
|
5
31
|
## [5.103.20] — 2026-08-24
|
|
6
32
|
|
|
7
33
|
### Changed
|
|
@@ -267,6 +267,29 @@ export interface DmThreadMessage {
|
|
|
267
267
|
* membership).
|
|
268
268
|
*/
|
|
269
269
|
export declare function resolveAgentUid(token: string, ref: string, companySlug: string | undefined): Promise<string>;
|
|
270
|
+
/**
|
|
271
|
+
* Fail-closed local guard for the off-box `hq agents jobs` operator commands.
|
|
272
|
+
*
|
|
273
|
+
* hq-pro's jobs endpoints are self-only for an agent machine identity: an
|
|
274
|
+
* agent-authenticated list/pause/cancel against `/v1/agents/{uid}/jobs` whose
|
|
275
|
+
* `{uid}` is not the caller's OWN agt_ uid is refused server-side as 403
|
|
276
|
+
* `CROSS_AGENT_JOB` and logged loud (`caller_uid_mismatch`, Sentry hq-pro
|
|
277
|
+
* 7617401436). That happens when these commands are run ON an agent box, where
|
|
278
|
+
* {@link resolveVaultCredential} silently returns the box's own agent ID token —
|
|
279
|
+
* the operator meant to act as a person with owner/admin, not as the box.
|
|
280
|
+
*
|
|
281
|
+
* Catch it locally so the call never leaves the box: decode the (already
|
|
282
|
+
* locally trusted, signature-unverified) bearer token and, ONLY when it proves
|
|
283
|
+
* an agent identity acting on a DIFFERENT agent, return a refusal string for the
|
|
284
|
+
* command to print before any HTTP request. Every other shape fails OPEN
|
|
285
|
+
* (returns `null`) and proceeds to the server exactly as today: a person session
|
|
286
|
+
* (no `custom:entityType` claim), an opaque / non-JWT credential (an `hqk_` API
|
|
287
|
+
* key), a token with a missing or malformed uid claim, or an agent acting on its
|
|
288
|
+
* OWN jobs.
|
|
289
|
+
*
|
|
290
|
+
* Pure: no network, no logging; never prints or returns the token itself.
|
|
291
|
+
*/
|
|
292
|
+
export declare function crossAgentJobsRefusal(token: string, resolvedAgentUid: string): string | null;
|
|
270
293
|
/** Send a DM to an agent. Returns nothing meaningful beyond success. */
|
|
271
294
|
export declare function sendAgentDm(token: string, agentUid: string, message: string): Promise<void>;
|
|
272
295
|
/** Read the two-way DM conversation with an agent (most recent `limit`). */
|
package/dist/commands/agents.js
CHANGED
|
@@ -30,6 +30,7 @@ import * as readline from "node:readline";
|
|
|
30
30
|
import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
|
|
31
31
|
import { gateApiKeyCapabilities } from "../utils/api-key-command-gate.js";
|
|
32
32
|
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
33
|
+
import { peekIdToken } from "../utils/id-token.js";
|
|
33
34
|
import { isPlanGateError } from "../utils/plan-gate-error.js";
|
|
34
35
|
import { confirmChargeOrExit, formatUsd, parseBillingPayload, surfaceBillingBlocked, } from "../utils/billing-gate.js";
|
|
35
36
|
/** Reasoning-effort values hq-pro accepts on `runtime-config`. */
|
|
@@ -345,6 +346,17 @@ export async function cancelAgentJob(token, agentUid, jobId) {
|
|
|
345
346
|
export function formatJobsHttpError(err, jobId) {
|
|
346
347
|
if (err.status === 401)
|
|
347
348
|
return "Not authenticated — run `hq login`.";
|
|
349
|
+
// A 403 CROSS_AGENT_JOB is hq-pro's self-only jobs guard, not a role problem:
|
|
350
|
+
// the caller authenticated as one agent and asked about another. Naming it as
|
|
351
|
+
// "owner/admin required" (below) is misleading copy an agent box can never
|
|
352
|
+
// satisfy, so distinguish it here (defence in depth — the CLI now also fails
|
|
353
|
+
// closed locally before the request, see crossAgentJobsRefusal).
|
|
354
|
+
if (err.status === 403 && err.code === "CROSS_AGENT_JOB") {
|
|
355
|
+
return ("Jobs are scoped to the calling agent's own identity — an agent may only " +
|
|
356
|
+
"list, pause, or cancel its OWN jobs (use the on-box `hq-agent-jobs` tool). " +
|
|
357
|
+
"Managing another agent's jobs needs a person session with owner/admin on " +
|
|
358
|
+
"the company.");
|
|
359
|
+
}
|
|
348
360
|
if (err.status === 403)
|
|
349
361
|
return "You need owner/admin on this company.";
|
|
350
362
|
if (err.status === 404 && err.code === "JOB_NOT_FOUND") {
|
|
@@ -420,6 +432,77 @@ export async function resolveAgentUid(token, ref, companySlug) {
|
|
|
420
432
|
}
|
|
421
433
|
return match.uid;
|
|
422
434
|
}
|
|
435
|
+
/**
|
|
436
|
+
* Fail-closed local guard for the off-box `hq agents jobs` operator commands.
|
|
437
|
+
*
|
|
438
|
+
* hq-pro's jobs endpoints are self-only for an agent machine identity: an
|
|
439
|
+
* agent-authenticated list/pause/cancel against `/v1/agents/{uid}/jobs` whose
|
|
440
|
+
* `{uid}` is not the caller's OWN agt_ uid is refused server-side as 403
|
|
441
|
+
* `CROSS_AGENT_JOB` and logged loud (`caller_uid_mismatch`, Sentry hq-pro
|
|
442
|
+
* 7617401436). That happens when these commands are run ON an agent box, where
|
|
443
|
+
* {@link resolveVaultCredential} silently returns the box's own agent ID token —
|
|
444
|
+
* the operator meant to act as a person with owner/admin, not as the box.
|
|
445
|
+
*
|
|
446
|
+
* Catch it locally so the call never leaves the box: decode the (already
|
|
447
|
+
* locally trusted, signature-unverified) bearer token and, ONLY when it proves
|
|
448
|
+
* an agent identity acting on a DIFFERENT agent, return a refusal string for the
|
|
449
|
+
* command to print before any HTTP request. Every other shape fails OPEN
|
|
450
|
+
* (returns `null`) and proceeds to the server exactly as today: a person session
|
|
451
|
+
* (no `custom:entityType` claim), an opaque / non-JWT credential (an `hqk_` API
|
|
452
|
+
* key), a token with a missing or malformed uid claim, or an agent acting on its
|
|
453
|
+
* OWN jobs.
|
|
454
|
+
*
|
|
455
|
+
* Pure: no network, no logging; never prints or returns the token itself.
|
|
456
|
+
*/
|
|
457
|
+
export function crossAgentJobsRefusal(token, resolvedAgentUid) {
|
|
458
|
+
const claims = peekIdToken(token);
|
|
459
|
+
const entityType = typeof claims["custom:entityType"] === "string"
|
|
460
|
+
? claims["custom:entityType"]
|
|
461
|
+
: undefined;
|
|
462
|
+
const entityUid = typeof claims["custom:entityUid"] === "string"
|
|
463
|
+
? claims["custom:entityUid"].trim()
|
|
464
|
+
: "";
|
|
465
|
+
// Positive proof required before refusing: an agent identity, a well-formed
|
|
466
|
+
// agt_ own-uid, and a target that differs from it. Anything else proceeds.
|
|
467
|
+
if (entityType !== "agent")
|
|
468
|
+
return null;
|
|
469
|
+
if (!AGENT_UID_PATTERN.test(entityUid))
|
|
470
|
+
return null;
|
|
471
|
+
if (entityUid === resolvedAgentUid.trim())
|
|
472
|
+
return null;
|
|
473
|
+
return (`Refusing to manage ${resolvedAgentUid}'s scheduled jobs using this agent's ` +
|
|
474
|
+
`own identity (${entityUid}). On an agent box, \`hq agents jobs\` authenticates ` +
|
|
475
|
+
`as the box itself, and hq-pro scopes an agent to its OWN jobs only — inspect ` +
|
|
476
|
+
`those with the on-box \`hq-agent-jobs\` tool. To manage another agent's jobs, ` +
|
|
477
|
+
`run this from a person session with owner/admin on the company (\`hq login\`), ` +
|
|
478
|
+
`not on the agent box.`);
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Shared preamble for the three `hq agents jobs` actions: resolve the caller's
|
|
482
|
+
* credential, turn the (possibly slug/name) reference into an `agt_` uid via the
|
|
483
|
+
* roster, and fail closed BEFORE any jobs request when an agent identity targets
|
|
484
|
+
* another agent ({@link crossAgentJobsRefusal}). Returns the token + resolved
|
|
485
|
+
* uid for the caller's single jobs request. Never returns on a refusal or a
|
|
486
|
+
* resolution error — it prints and exits non-zero. The refusal exit lives
|
|
487
|
+
* OUTSIDE the resolution try so it is not re-wrapped as a generic failure.
|
|
488
|
+
*/
|
|
489
|
+
async function resolveJobsTargetOrExit(ref, company) {
|
|
490
|
+
let token;
|
|
491
|
+
let targetUid;
|
|
492
|
+
try {
|
|
493
|
+
token = (await resolveVaultCredential()).token;
|
|
494
|
+
targetUid = await resolveAgentUid(token, ref, company);
|
|
495
|
+
}
|
|
496
|
+
catch (err) {
|
|
497
|
+
return failJobs(err);
|
|
498
|
+
}
|
|
499
|
+
const refusal = crossAgentJobsRefusal(token, targetUid);
|
|
500
|
+
if (refusal) {
|
|
501
|
+
console.error(chalk.red(refusal));
|
|
502
|
+
process.exit(1);
|
|
503
|
+
}
|
|
504
|
+
return { token, targetUid };
|
|
505
|
+
}
|
|
423
506
|
/** Send a DM to an agent. Returns nothing meaningful beyond success. */
|
|
424
507
|
export async function sendAgentDm(token, agentUid, message) {
|
|
425
508
|
await agentsRequest({
|
|
@@ -934,11 +1017,12 @@ export function registerAgentsCommand(program) {
|
|
|
934
1017
|
jobs
|
|
935
1018
|
.command("list <agentUid>")
|
|
936
1019
|
.description("List an agent's scheduled jobs")
|
|
1020
|
+
.option("--company <slug>", "Company slug (to resolve an agent by name/slug)")
|
|
937
1021
|
.option("--json", "Emit raw JSON")
|
|
938
|
-
.action(async (agentUid, opts)
|
|
1022
|
+
.action(async function (agentUid, opts) {
|
|
1023
|
+
const { token, targetUid } = await resolveJobsTargetOrExit(agentUid, companyOf(this));
|
|
939
1024
|
try {
|
|
940
|
-
const
|
|
941
|
-
const roster = await listAgentJobs(token, agentUid);
|
|
1025
|
+
const roster = await listAgentJobs(token, targetUid);
|
|
942
1026
|
if (opts.json) {
|
|
943
1027
|
process.stdout.write(JSON.stringify(roster, null, 2) + "\n");
|
|
944
1028
|
return;
|
|
@@ -956,10 +1040,11 @@ export function registerAgentsCommand(program) {
|
|
|
956
1040
|
jobs
|
|
957
1041
|
.command("pause <agentUid> <jobId>")
|
|
958
1042
|
.description("Pause a job's schedule (reversible)")
|
|
959
|
-
.
|
|
1043
|
+
.option("--company <slug>", "Company slug (to resolve an agent by name/slug)")
|
|
1044
|
+
.action(async function (agentUid, jobId) {
|
|
1045
|
+
const { token, targetUid } = await resolveJobsTargetOrExit(agentUid, companyOf(this));
|
|
960
1046
|
try {
|
|
961
|
-
const
|
|
962
|
-
const result = await pauseAgentJob(token, agentUid, jobId);
|
|
1047
|
+
const result = await pauseAgentJob(token, targetUid, jobId);
|
|
963
1048
|
console.log(chalk.green(formatPauseResult(result)));
|
|
964
1049
|
}
|
|
965
1050
|
catch (err) {
|
|
@@ -969,10 +1054,11 @@ export function registerAgentsCommand(program) {
|
|
|
969
1054
|
jobs
|
|
970
1055
|
.command("cancel <agentUid> <jobId>")
|
|
971
1056
|
.description("Cancel a job and delete its schedule")
|
|
972
|
-
.
|
|
1057
|
+
.option("--company <slug>", "Company slug (to resolve an agent by name/slug)")
|
|
1058
|
+
.action(async function (agentUid, jobId) {
|
|
1059
|
+
const { token, targetUid } = await resolveJobsTargetOrExit(agentUid, companyOf(this));
|
|
973
1060
|
try {
|
|
974
|
-
const
|
|
975
|
-
const result = await cancelAgentJob(token, agentUid, jobId);
|
|
1061
|
+
const result = await cancelAgentJob(token, targetUid, jobId);
|
|
976
1062
|
console.log(chalk.green(`Cancelled ${result.jobId}.`));
|
|
977
1063
|
}
|
|
978
1064
|
catch (err) {
|
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
|
}
|
|
@@ -134,12 +134,36 @@ export interface InstallInput {
|
|
|
134
134
|
docsUrl?: string;
|
|
135
135
|
authMode?: "none" | "bearer";
|
|
136
136
|
bearerToken?: string;
|
|
137
|
+
/** Preserve a non-default placement for a pasted key (for example X-API-Key). */
|
|
138
|
+
authScheme?: {
|
|
139
|
+
placement: "authorization";
|
|
140
|
+
format: "bearer";
|
|
141
|
+
} | {
|
|
142
|
+
placement: "authorization";
|
|
143
|
+
format: "prefix";
|
|
144
|
+
prefix: string;
|
|
145
|
+
} | {
|
|
146
|
+
placement: "authorization";
|
|
147
|
+
format: "basic";
|
|
148
|
+
username?: string;
|
|
149
|
+
} | {
|
|
150
|
+
placement: "header";
|
|
151
|
+
header: string;
|
|
152
|
+
};
|
|
137
153
|
}
|
|
138
154
|
export declare function installIntegration(token: string, companyUid: string, input: InstallInput): Promise<InstallResult>;
|
|
139
155
|
export declare function uninstallIntegration(token: string, companyUid: string, installationId: string): Promise<{
|
|
140
156
|
installationId: string;
|
|
141
157
|
connectionId: string;
|
|
142
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
|
+
}>;
|
|
143
167
|
export interface OAuthStartResult {
|
|
144
168
|
provider: string;
|
|
145
169
|
displayName: string;
|
|
@@ -181,6 +205,11 @@ export declare function updateGovernance(token: string, companyUid: string, inpu
|
|
|
181
205
|
writePolicy?: WritePolicy;
|
|
182
206
|
writeAllowlist: WriteAllowlistGrant[];
|
|
183
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>;
|
|
184
213
|
export interface ConnectionAccess {
|
|
185
214
|
connectionId: string;
|
|
186
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 —
|
|
@@ -278,6 +289,14 @@ async function findCatalogEntry(token, companyUid, domain) {
|
|
|
278
289
|
function domainLabel(domain) {
|
|
279
290
|
return domain.trim().toLowerCase().split(".")[0] ?? "";
|
|
280
291
|
}
|
|
292
|
+
/** A catalog display name's copy-pasteable command-line slug. */
|
|
293
|
+
function displayNameSlug(name) {
|
|
294
|
+
return name
|
|
295
|
+
.trim()
|
|
296
|
+
.toLowerCase()
|
|
297
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
298
|
+
.replace(/^-+|-+$/g, "");
|
|
299
|
+
}
|
|
281
300
|
/**
|
|
282
301
|
* Best-effort resolution of a bare name to a single catalog entry. Matches the
|
|
283
302
|
* entry's registrable domain label (`notion` → `notion.com`) or an exact
|
|
@@ -293,7 +312,8 @@ async function findCatalogEntryByName(token, companyUid, name) {
|
|
|
293
312
|
return null;
|
|
294
313
|
const entries = await listCatalog(token, companyUid, { query: name, limit: 20 });
|
|
295
314
|
const matches = entries.filter((entry) => domainLabel(entry.domain) === want ||
|
|
296
|
-
entry.name?.trim().toLowerCase() === want
|
|
315
|
+
entry.name?.trim().toLowerCase() === want ||
|
|
316
|
+
(entry.name !== undefined && displayNameSlug(entry.name) === displayNameSlug(want)));
|
|
297
317
|
if (matches.length === 0)
|
|
298
318
|
return null;
|
|
299
319
|
// Collapse rows that point at the same app (same domain) before deciding
|
|
@@ -415,14 +435,15 @@ async function completeCredentialIfNeeded(token, companyUid, target, opts, resul
|
|
|
415
435
|
});
|
|
416
436
|
}
|
|
417
437
|
/** Print the outcome of a successful connect. */
|
|
418
|
-
function reportInstall(result, opts) {
|
|
438
|
+
function reportInstall(result, opts, expectedRevivedConnectionId) {
|
|
419
439
|
if (opts.json) {
|
|
420
440
|
printJson(result);
|
|
421
441
|
return;
|
|
422
442
|
}
|
|
423
443
|
const { installation, connection } = result;
|
|
424
444
|
const toolCount = result.mcp?.tools?.length;
|
|
425
|
-
|
|
445
|
+
const revived = connection.id === expectedRevivedConnectionId;
|
|
446
|
+
console.log(chalk.green(`${revived ? "Revived" : "Connected"} ${chalk.bold(installation.displayName)}`) +
|
|
426
447
|
(typeof toolCount === "number" ? chalk.dim(` — ${toolCount} tools available`) : ""));
|
|
427
448
|
console.log(chalk.dim(` connection: ${connection.id}`));
|
|
428
449
|
if (installation.status === "needs_credentials") {
|
|
@@ -430,6 +451,49 @@ function reportInstall(result, opts) {
|
|
|
430
451
|
}
|
|
431
452
|
console.log(chalk.dim(` Try it: hq integrations tools --provider ${bareProvider(connection.provider)}`));
|
|
432
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
|
+
}
|
|
433
497
|
export function registerConnectCommands(integrations) {
|
|
434
498
|
integrations
|
|
435
499
|
.command("catalog [query]")
|
|
@@ -552,46 +616,11 @@ export function registerConnectCommands(integrations) {
|
|
|
552
616
|
assertAuthMode(opts.auth);
|
|
553
617
|
const token = await ensureCognitoIdToken();
|
|
554
618
|
const companyUid = await getCompanyUid(token, opts.company);
|
|
555
|
-
|
|
556
|
-
const authMode = opts.auth ?? target.authClass;
|
|
557
|
-
if (authMode === "oauth") {
|
|
558
|
-
const result = await connectViaOAuth(token, companyUid, target, opts);
|
|
559
|
-
if (result)
|
|
560
|
-
reportInstall(result, opts);
|
|
561
|
-
return;
|
|
562
|
-
}
|
|
563
|
-
// A key is only collected when something already says one is needed, or
|
|
564
|
-
// the caller supplied one — otherwise a no-auth app would pointlessly
|
|
565
|
-
// prompt.
|
|
566
|
-
const wantsKey = authMode === "key" || Boolean(opts.token || opts.tokenStdin);
|
|
567
|
-
const bearerToken = wantsKey ? await resolveKey(opts, target.label) : undefined;
|
|
568
|
-
try {
|
|
569
|
-
const result = await installIntegration(token, companyUid, {
|
|
570
|
-
...target.ref,
|
|
571
|
-
...(bearerToken
|
|
572
|
-
? { authMode: "bearer", bearerToken }
|
|
573
|
-
: authMode === "none"
|
|
574
|
-
? { authMode: "none" }
|
|
575
|
-
: {}),
|
|
576
|
-
});
|
|
577
|
-
reportInstall(await completeCredentialIfNeeded(token, companyUid, target, opts, result), opts);
|
|
578
|
-
}
|
|
579
|
-
catch (err) {
|
|
580
|
-
// Server-authoritative detection: the endpoint turned out to be
|
|
581
|
-
// OAuth-protected, so run the browser flow instead of making the
|
|
582
|
-
// caller re-issue the command with --auth oauth.
|
|
583
|
-
if (isOAuthRequiredError(err)) {
|
|
584
|
-
const result = await connectViaOAuth(token, companyUid, target, opts);
|
|
585
|
-
if (result)
|
|
586
|
-
reportInstall(result, opts);
|
|
587
|
-
return;
|
|
588
|
-
}
|
|
589
|
-
throw err;
|
|
590
|
-
}
|
|
619
|
+
await connectApp(token, companyUid, app, opts);
|
|
591
620
|
});
|
|
592
621
|
integrations
|
|
593
622
|
.command("reconnect [app]")
|
|
594
|
-
.description("Re-authenticate a connected app
|
|
623
|
+
.description("Re-authenticate a connected app; use --connect to re-add a revoked app")
|
|
595
624
|
.option("--company <slug>", "Company slug, e.g. indigo")
|
|
596
625
|
.option("--provider <slug>", "Connected app (e.g. linear)")
|
|
597
626
|
.option("--connection <id>", "Connection id (acct_…)")
|
|
@@ -600,6 +629,7 @@ export function registerConnectCommands(integrations) {
|
|
|
600
629
|
.option("--auth <mode>", "Force the auth mode: none, key, or oauth (default: detect)")
|
|
601
630
|
.option("--no-browser", "Print the sign-in URL instead of opening a browser")
|
|
602
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")
|
|
603
633
|
.option("--json", "Machine-readable output")
|
|
604
634
|
.action(async (app, opts) => {
|
|
605
635
|
// Same validation as `connect`. Without it a typo like `--auth oauth2`
|
|
@@ -609,7 +639,23 @@ export function registerConnectCommands(integrations) {
|
|
|
609
639
|
assertAuthMode(opts.auth);
|
|
610
640
|
const token = await ensureCognitoIdToken();
|
|
611
641
|
const companyUid = await getCompanyUid(token, opts.company);
|
|
612
|
-
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
|
+
}
|
|
613
659
|
const url = connection.installation?.surface?.url;
|
|
614
660
|
if (!url) {
|
|
615
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;
|
|
@@ -201,13 +211,23 @@ export declare function fetchAdminSurface(token: string, companyUid: string): Pr
|
|
|
201
211
|
export declare function fetchConnections(token: string, companyUid: string): Promise<AdminConnection[]>;
|
|
202
212
|
/**
|
|
203
213
|
* Resolve one connection by `--connection acct_…` or `--provider linear`
|
|
204
|
-
* (matches `factory:<slug
|
|
205
|
-
*
|
|
214
|
+
* (matches `factory:<slug>`, bare provider ids, and an installation's human
|
|
215
|
+
* display-name slug, case-insensitive). The legacy provider id remains a
|
|
216
|
+
* first-class match, so scripts that saved opaque historical slugs keep
|
|
217
|
+
* working. Errors list what IS connected so the fix is one command away.
|
|
206
218
|
*/
|
|
207
219
|
export declare function selectConnection(connections: AdminConnection[], opts: {
|
|
208
220
|
connection?: string;
|
|
209
221
|
provider?: string;
|
|
210
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;
|
|
211
231
|
/**
|
|
212
232
|
* Resolve a connection the caller named positionally OR through the
|
|
213
233
|
* `--provider` / `--connection` flags. Every management verb takes an optional
|
|
@@ -217,6 +237,8 @@ export declare function selectConnection(connections: AdminConnection[], opts: {
|
|
|
217
237
|
export declare function resolveConnection(token: string, companyUid: string, app: string | undefined, opts: {
|
|
218
238
|
provider?: string;
|
|
219
239
|
connection?: string;
|
|
240
|
+
}, recoveryOpts?: {
|
|
241
|
+
allowSingleRevoked?: boolean;
|
|
220
242
|
}): Promise<AdminConnection>;
|
|
221
243
|
export declare function callGateway(token: string, params: Record<string, unknown>): Promise<GatewayMessage>;
|
|
222
244
|
/**
|