@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,583 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq integrations catalog | inspect | discover | connect | reconnect`.
|
|
3
|
+
*
|
|
4
|
+
* The add half of the lifecycle: find an app, look at what it exposes, and
|
|
5
|
+
* connect it. Connecting is the interesting one — an app authenticates in one
|
|
6
|
+
* of three ways and hq-cli should not make the caller work out which:
|
|
7
|
+
*
|
|
8
|
+
* none the MCP endpoint is anonymously callable → install directly
|
|
9
|
+
* key the endpoint wants a bearer token → take it from --token /
|
|
10
|
+
* --token-stdin / a hidden prompt, never from argv history
|
|
11
|
+
* oauth the endpoint speaks OAuth 2.1 → run the RFC 8252 loopback flow
|
|
12
|
+
*
|
|
13
|
+
* Detection is server-authoritative and lazy. Rather than guessing from
|
|
14
|
+
* catalog metadata (which can be stale), the command attempts the direct
|
|
15
|
+
* install and treats hq-pro's `INTEGRATION_FACTORY_OAUTH_REQUIRED` as the
|
|
16
|
+
* signal to switch into the browser flow. That way a catalog row that says
|
|
17
|
+
* "key" but is really OAuth-protected still connects on the first try.
|
|
18
|
+
*/
|
|
19
|
+
import chalk from "chalk";
|
|
20
|
+
import open from "open";
|
|
21
|
+
import { ensureCognitoIdToken } from "../utils/cognito-session.js";
|
|
22
|
+
import { getCompanyUid } from "../utils/vault-api.js";
|
|
23
|
+
import { IntegrationsCliError, bareProvider, printJson, resolveConnection, } from "./integrations-core.js";
|
|
24
|
+
import { completeOAuth, discoverDocs, installIntegration, listCatalog, pullBlueprint, startOAuth, } from "./integrations-api.js";
|
|
25
|
+
import { startLoopbackListener } from "./integrations-oauth.js";
|
|
26
|
+
/** hq-pro's machine code for "this endpoint needs a browser sign-in". */
|
|
27
|
+
const OAUTH_REQUIRED_CODE = "INTEGRATION_FACTORY_OAUTH_REQUIRED";
|
|
28
|
+
/**
|
|
29
|
+
* hq-pro will not take our loopback callback, for either of the two reasons it
|
|
30
|
+
* can refuse one:
|
|
31
|
+
*
|
|
32
|
+
* OAUTH_REDIRECT_URI_NOT_ALLOWED the backend's loopback flag is off, or it
|
|
33
|
+
* predates loopback support entirely
|
|
34
|
+
* OAUTH_LOOPBACK_NOT_SUPPORTED this app signs in through a client HQ
|
|
35
|
+
* pre-registered with the vendor against the
|
|
36
|
+
* fixed console callback, so an ephemeral
|
|
37
|
+
* port can never be added to it
|
|
38
|
+
*
|
|
39
|
+
* Both mean the same thing to the caller — finish in the browser, the console
|
|
40
|
+
* completes the install — so both take the handoff path.
|
|
41
|
+
*/
|
|
42
|
+
const CONSOLE_HANDOFF_CODES = new Set([
|
|
43
|
+
"OAUTH_REDIRECT_URI_NOT_ALLOWED",
|
|
44
|
+
"OAUTH_LOOPBACK_NOT_SUPPORTED",
|
|
45
|
+
]);
|
|
46
|
+
/**
|
|
47
|
+
* Looks-like-a-domain test for the positional `<app>` argument. Deliberately
|
|
48
|
+
* loose — hq-pro does the real resolution — but tight enough that `linear.app`
|
|
49
|
+
* routes to the domain path while a bare word routes to catalog search.
|
|
50
|
+
*/
|
|
51
|
+
function looksLikeDomain(value) {
|
|
52
|
+
return /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(value) && !value.includes(" ");
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* True when this shell is almost certainly on a different machine from the
|
|
56
|
+
* person's browser. Not a security boundary — just the signal that a loopback
|
|
57
|
+
* callback cannot reach us.
|
|
58
|
+
*/
|
|
59
|
+
function isRemoteShell() {
|
|
60
|
+
return Boolean(process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Validate `--auth` against the supported enum, rejecting an unknown value
|
|
64
|
+
* before anything is installed. A finite provider/runtime option must never be
|
|
65
|
+
* narrowed by a truthy cast — an unrecognised mode has to fail loudly rather
|
|
66
|
+
* than fall through to a default.
|
|
67
|
+
*/
|
|
68
|
+
export function assertAuthMode(value) {
|
|
69
|
+
if (value !== undefined && !["none", "key", "oauth"].includes(value)) {
|
|
70
|
+
throw new IntegrationsCliError("--auth must be none, key, or oauth.", { expected: true });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Single-quote a value for a copy-pasteable shell command. */
|
|
74
|
+
export function shellQuote(value) {
|
|
75
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
76
|
+
}
|
|
77
|
+
/** Read a secret from piped stdin, without echoing or trailing-newline noise. */
|
|
78
|
+
function readStdin() {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
let data = "";
|
|
81
|
+
process.stdin.setEncoding("utf8");
|
|
82
|
+
process.stdin.on("data", (chunk) => {
|
|
83
|
+
data += chunk;
|
|
84
|
+
});
|
|
85
|
+
process.stdin.on("end", () => resolve(data.replace(/\r?\n$/, "")));
|
|
86
|
+
process.stdin.on("error", reject);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Hidden TTY prompt for an API key. Mirrors `hq secrets`' input handling:
|
|
91
|
+
* raw mode with no echo, Ctrl-C aborts, Ctrl-D/Enter submits. Keys must never
|
|
92
|
+
* reach argv (shell history, `ps` output) unless the caller insists via
|
|
93
|
+
* `--token`.
|
|
94
|
+
*/
|
|
95
|
+
function promptForKey(label) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
process.stdout.write(`${label}: `);
|
|
98
|
+
if (process.stdin.isTTY)
|
|
99
|
+
process.stdin.setRawMode(true);
|
|
100
|
+
process.stdin.setEncoding("utf8");
|
|
101
|
+
let value = "";
|
|
102
|
+
const cleanup = () => {
|
|
103
|
+
if (process.stdin.isTTY)
|
|
104
|
+
process.stdin.setRawMode(false);
|
|
105
|
+
process.stdin.removeListener("data", onData);
|
|
106
|
+
process.stdin.pause();
|
|
107
|
+
};
|
|
108
|
+
const onData = (chunk) => {
|
|
109
|
+
for (const ch of chunk) {
|
|
110
|
+
const codePoint = ch.codePointAt(0);
|
|
111
|
+
if (ch === "\n" || ch === "\r" || codePoint === 4) {
|
|
112
|
+
cleanup();
|
|
113
|
+
process.stdout.write("\n");
|
|
114
|
+
resolve(value);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (codePoint === 3) {
|
|
118
|
+
cleanup();
|
|
119
|
+
process.stdout.write("\n");
|
|
120
|
+
reject(new IntegrationsCliError("Cancelled.", { expected: true }));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (codePoint === 127 || codePoint === 8) {
|
|
124
|
+
value = value.slice(0, -1);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
value += ch;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
process.stdin.on("data", onData);
|
|
131
|
+
process.stdin.resume();
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Resolve the API key for a key-authenticated app, in descending order of how
|
|
136
|
+
* safe the source is: piped stdin, a hidden prompt, then the explicit
|
|
137
|
+
* `--token` flag. Refuses rather than connecting credential-less when there is
|
|
138
|
+
* nobody to ask.
|
|
139
|
+
*/
|
|
140
|
+
async function resolveKey(opts, appLabel) {
|
|
141
|
+
if (opts.tokenStdin) {
|
|
142
|
+
const piped = (await readStdin()).trim();
|
|
143
|
+
if (!piped) {
|
|
144
|
+
throw new IntegrationsCliError("--token-stdin was set but stdin was empty.", {
|
|
145
|
+
expected: true,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return piped;
|
|
149
|
+
}
|
|
150
|
+
if (opts.token)
|
|
151
|
+
return opts.token;
|
|
152
|
+
if (process.stdin.isTTY) {
|
|
153
|
+
const value = (await promptForKey(`API key for ${appLabel}`)).trim();
|
|
154
|
+
if (!value) {
|
|
155
|
+
throw new IntegrationsCliError("No API key entered — nothing was connected.", {
|
|
156
|
+
expected: true,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
throw new IntegrationsCliError(`${appLabel} needs an API key. Pass it with --token-stdin (recommended, keeps it out of shell history) or --token <key>.`, { expected: true });
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Turn the caller's arguments into one unambiguous target. Precedence is
|
|
165
|
+
* most-specific-first so an explicit flag always wins over the positional
|
|
166
|
+
* argument's heuristics.
|
|
167
|
+
*/
|
|
168
|
+
async function resolveTarget(token, companyUid, app, opts) {
|
|
169
|
+
if (opts.docsUrl) {
|
|
170
|
+
const found = await discoverDocs(token, companyUid, opts.docsUrl);
|
|
171
|
+
if (!found.discovery || !found.discoveryReceiptId) {
|
|
172
|
+
throw new IntegrationsCliError("No connectable server was found on that documentation page.", { expected: true });
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
// The receipt is the trusted continuation: hq-pro re-reads the verified
|
|
176
|
+
// endpoint from its own row instead of trusting a URL echoed back here.
|
|
177
|
+
ref: { discoveryReceiptId: found.discoveryReceiptId },
|
|
178
|
+
authClass: found.discovery.authMode === "bearer"
|
|
179
|
+
? "key"
|
|
180
|
+
: found.discovery.authMode === "oauth"
|
|
181
|
+
? "oauth"
|
|
182
|
+
: "none",
|
|
183
|
+
label: found.discovery.displayName,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (opts.mcpUrl) {
|
|
187
|
+
return {
|
|
188
|
+
ref: {
|
|
189
|
+
mcpUrl: opts.mcpUrl,
|
|
190
|
+
...(opts.provider ? { provider: opts.provider } : {}),
|
|
191
|
+
...(opts.displayName ? { displayName: opts.displayName } : {}),
|
|
192
|
+
},
|
|
193
|
+
label: opts.displayName ?? opts.provider ?? opts.mcpUrl,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (opts.entryId) {
|
|
197
|
+
return { ref: { catalogEntryId: opts.entryId }, label: opts.entryId };
|
|
198
|
+
}
|
|
199
|
+
if (!app) {
|
|
200
|
+
throw new IntegrationsCliError("Name an app to connect — a domain (`hq integrations connect linear.app`), or use --mcp-url / --docs-url / --entry-id.", { expected: true });
|
|
201
|
+
}
|
|
202
|
+
if (looksLikeDomain(app)) {
|
|
203
|
+
// A catalog row for the same domain carries the opaque entryId and the
|
|
204
|
+
// authClass, both of which make the connect cleaner than a raw domain
|
|
205
|
+
// lookup. Missing it is fine — the domain path still works.
|
|
206
|
+
const match = await findCatalogEntry(token, companyUid, app);
|
|
207
|
+
if (match?.entryId) {
|
|
208
|
+
return {
|
|
209
|
+
ref: { catalogEntryId: match.entryId },
|
|
210
|
+
...(match.authClass ? { authClass: match.authClass } : {}),
|
|
211
|
+
label: match.name || app,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
ref: { domain: app },
|
|
216
|
+
...(match?.authClass ? { authClass: match.authClass } : {}),
|
|
217
|
+
label: match?.name || app,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return { ref: { query: app }, label: app };
|
|
221
|
+
}
|
|
222
|
+
/** Best-effort catalog lookup by domain; never fails the connect. */
|
|
223
|
+
async function findCatalogEntry(token, companyUid, domain) {
|
|
224
|
+
try {
|
|
225
|
+
const entries = await listCatalog(token, companyUid, { query: domain, limit: 20 });
|
|
226
|
+
const want = domain.trim().toLowerCase();
|
|
227
|
+
return entries.find((entry) => entry.domain.trim().toLowerCase() === want) ?? null;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// The catalog is an optimization here, not the source of truth: an
|
|
231
|
+
// unreachable or flag-disabled catalog must not block a direct connect.
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Run the browser sign-in and finish the install.
|
|
237
|
+
*
|
|
238
|
+
* The loopback listener is bound BEFORE `/oauth/start` because its port is
|
|
239
|
+
* part of the redirect URI hq-pro registers with the remote authorization
|
|
240
|
+
* server. If hq-pro refuses that URI (its loopback flag is off, or it predates
|
|
241
|
+
* the feature), the flow degrades to the console handoff rather than failing:
|
|
242
|
+
* `/oauth/start` is retried with no `redirectUri`, and the caller is told to
|
|
243
|
+
* finish in the browser where the console callback completes it.
|
|
244
|
+
*/
|
|
245
|
+
async function connectViaOAuth(token, companyUid, target, opts) {
|
|
246
|
+
const timeoutMs = opts.timeout ? Number(opts.timeout) * 1000 : undefined;
|
|
247
|
+
if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
|
|
248
|
+
throw new IntegrationsCliError("--timeout must be a positive number of seconds.", {
|
|
249
|
+
expected: true,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
const startInput = { ...target.ref };
|
|
253
|
+
// A loopback listener only works if the browser runs on THIS machine. Over
|
|
254
|
+
// SSH the person opens the printed URL on their workstation, so the provider
|
|
255
|
+
// redirects to the workstation's 127.0.0.1 while the listener sits on the
|
|
256
|
+
// remote host — the callback can never arrive and the command just times
|
|
257
|
+
// out. Go straight to the console handoff instead of failing slowly.
|
|
258
|
+
if (opts.browser === false && isRemoteShell()) {
|
|
259
|
+
return await consoleHandoff(token, companyUid, startInput, target, opts);
|
|
260
|
+
}
|
|
261
|
+
const listener = await startLoopbackListener(timeoutMs === undefined ? {} : { timeoutMs });
|
|
262
|
+
try {
|
|
263
|
+
let started;
|
|
264
|
+
try {
|
|
265
|
+
started = await startOAuth(token, companyUid, {
|
|
266
|
+
...startInput,
|
|
267
|
+
redirectUri: listener.redirectUri,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
if (!(err instanceof IntegrationsCliError) ||
|
|
272
|
+
!err.code ||
|
|
273
|
+
!CONSOLE_HANDOFF_CODES.has(err.code)) {
|
|
274
|
+
throw err;
|
|
275
|
+
}
|
|
276
|
+
listener.close();
|
|
277
|
+
return await consoleHandoff(token, companyUid, startInput, target, opts);
|
|
278
|
+
}
|
|
279
|
+
// Arm the waiter BEFORE the browser opens. The listener buffers a redirect
|
|
280
|
+
// that beats it, but ordering it this way means the happy path never
|
|
281
|
+
// depends on that buffer.
|
|
282
|
+
const pendingCode = listener.waitForCode(started.state);
|
|
283
|
+
console.error(chalk.dim(`Waiting for you to sign in to ${started.displayName || target.label}…`));
|
|
284
|
+
if (opts.browser === false) {
|
|
285
|
+
console.error(`Open this URL to sign in:\n ${started.authorizationUrl}`);
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
// A failed browser launch (headless box, no handler) is recoverable —
|
|
289
|
+
// print the URL and keep waiting rather than aborting the sign-in.
|
|
290
|
+
await open(started.authorizationUrl).catch(() => {
|
|
291
|
+
console.error(`Open this URL to sign in:\n ${started.authorizationUrl}`);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
const code = await pendingCode;
|
|
295
|
+
return await completeOAuth(token, companyUid, { state: started.state, code });
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
listener.close();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Fallback for a backend that will not accept a loopback callback: start the
|
|
303
|
+
* sign-in against hq-pro's own console callback and hand the URL over. The
|
|
304
|
+
* console route finishes the install, so this command cannot report the
|
|
305
|
+
* result — it reports the handoff truthfully instead of pretending.
|
|
306
|
+
*/
|
|
307
|
+
async function consoleHandoff(token, companyUid, startInput, target, opts) {
|
|
308
|
+
const started = await startOAuth(token, companyUid, startInput);
|
|
309
|
+
const name = started.displayName || target.label;
|
|
310
|
+
console.error(chalk.yellow(`${name} signs in through the browser, and this HQ backend finishes those sign-ins in the console.`));
|
|
311
|
+
console.error(`Open this URL to sign in:\n ${started.authorizationUrl}`);
|
|
312
|
+
if (opts.browser !== false)
|
|
313
|
+
await open(started.authorizationUrl).catch(() => { });
|
|
314
|
+
console.error(chalk.dim("When the browser says it connected, run `hq integrations list` to confirm."));
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Second chance at a credential when the target's auth mode wasn't known up
|
|
319
|
+
* front.
|
|
320
|
+
*
|
|
321
|
+
* A key-authenticated app selected by bare query, `--entry-id`, or an unknown
|
|
322
|
+
* `--mcp-url` carries no `authClass`, so nothing prompts for a key and the
|
|
323
|
+
* install lands as `needs_credentials`. Unlike OAuth there is no error code to
|
|
324
|
+
* branch on — the signal is in the SUCCESS payload. Treat it as the detection
|
|
325
|
+
* point: prompt and re-install once, so the common case finishes in one
|
|
326
|
+
* command instead of leaving a half-connected app behind.
|
|
327
|
+
*/
|
|
328
|
+
async function completeCredentialIfNeeded(token, companyUid, target, opts, result) {
|
|
329
|
+
const needsKey = result.installation.status === "needs_credentials" ||
|
|
330
|
+
(result.credential?.required === true && result.credential.configured === false);
|
|
331
|
+
// Only worth retrying when a key could actually be collected: a token was
|
|
332
|
+
// never supplied (or it was and still failed — don't loop on it) and there
|
|
333
|
+
// is somebody to ask.
|
|
334
|
+
if (!needsKey || opts.token || opts.tokenStdin || !process.stdin.isTTY)
|
|
335
|
+
return result;
|
|
336
|
+
console.error(chalk.dim(`${target.label} needs an API key to finish connecting.`));
|
|
337
|
+
const bearerToken = await resolveKey(opts, target.label);
|
|
338
|
+
return await installIntegration(token, companyUid, {
|
|
339
|
+
...target.ref,
|
|
340
|
+
authMode: "bearer",
|
|
341
|
+
bearerToken,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
/** Print the outcome of a successful connect. */
|
|
345
|
+
function reportInstall(result, opts) {
|
|
346
|
+
if (opts.json) {
|
|
347
|
+
printJson(result);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const { installation, connection } = result;
|
|
351
|
+
const toolCount = result.mcp?.tools?.length;
|
|
352
|
+
console.log(chalk.green(`Connected ${chalk.bold(installation.displayName)}`) +
|
|
353
|
+
(typeof toolCount === "number" ? chalk.dim(` — ${toolCount} tools available`) : ""));
|
|
354
|
+
console.log(chalk.dim(` connection: ${connection.id}`));
|
|
355
|
+
if (installation.status === "needs_credentials") {
|
|
356
|
+
console.log(chalk.yellow(" This app still needs credentials — re-run with --token, or reconnect it."));
|
|
357
|
+
}
|
|
358
|
+
console.log(chalk.dim(` Try it: hq integrations tools --provider ${bareProvider(connection.provider)}`));
|
|
359
|
+
}
|
|
360
|
+
export function registerConnectCommands(integrations) {
|
|
361
|
+
integrations
|
|
362
|
+
.command("catalog [query]")
|
|
363
|
+
.alias("search")
|
|
364
|
+
.description("Browse or search apps you can connect")
|
|
365
|
+
.option("--company <slug>", "Company slug")
|
|
366
|
+
.option("--limit <n>", "How many entries to show (max 100)", "60")
|
|
367
|
+
.option("--json", "Machine-readable output")
|
|
368
|
+
.action(async (query, opts) => {
|
|
369
|
+
const token = await ensureCognitoIdToken();
|
|
370
|
+
const companyUid = await getCompanyUid(token, opts.company);
|
|
371
|
+
const limit = Number(opts.limit);
|
|
372
|
+
if (!Number.isFinite(limit) || limit < 1) {
|
|
373
|
+
throw new IntegrationsCliError("--limit must be a positive number.", { expected: true });
|
|
374
|
+
}
|
|
375
|
+
const entries = await listCatalog(token, companyUid, {
|
|
376
|
+
limit,
|
|
377
|
+
...(query ? { query } : {}),
|
|
378
|
+
});
|
|
379
|
+
if (opts.json) {
|
|
380
|
+
printJson(entries);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (entries.length === 0) {
|
|
384
|
+
console.log(query
|
|
385
|
+
? `Nothing matched '${query}'. Try a domain, e.g. hq integrations catalog linear.app`
|
|
386
|
+
: "The catalog is empty right now.");
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
for (const entry of entries) {
|
|
390
|
+
const auth = entry.authClass === "oauth"
|
|
391
|
+
? "sign-in"
|
|
392
|
+
: entry.authClass === "key"
|
|
393
|
+
? "api key"
|
|
394
|
+
: entry.authClass === "none"
|
|
395
|
+
? "no auth"
|
|
396
|
+
: null;
|
|
397
|
+
const tags = [
|
|
398
|
+
auth,
|
|
399
|
+
entry.source === "hq-recommended" ? "recommended" : null,
|
|
400
|
+
entry.source === "hq-discovered" ? "community" : null,
|
|
401
|
+
entry.mcpReady ? null : "not one-click",
|
|
402
|
+
]
|
|
403
|
+
.filter(Boolean)
|
|
404
|
+
.join(" · ");
|
|
405
|
+
console.log(`${chalk.bold(entry.name)} ${chalk.dim(entry.domain)} ${chalk.dim(tags)}`);
|
|
406
|
+
if (entry.description)
|
|
407
|
+
console.log(chalk.dim(` ${entry.description}`));
|
|
408
|
+
}
|
|
409
|
+
console.log(chalk.dim(`\n${entries.length} apps. Connect one with: hq integrations connect <domain>`));
|
|
410
|
+
});
|
|
411
|
+
integrations
|
|
412
|
+
.command("inspect <app>")
|
|
413
|
+
.description("Show what an app exposes before you connect it")
|
|
414
|
+
.option("--company <slug>", "Company slug")
|
|
415
|
+
.option("--json", "Machine-readable output")
|
|
416
|
+
.action(async (app, opts) => {
|
|
417
|
+
const token = await ensureCognitoIdToken();
|
|
418
|
+
const companyUid = await getCompanyUid(token, opts.company);
|
|
419
|
+
const blueprint = await pullBlueprint(token, companyUid, looksLikeDomain(app) ? { domain: app } : { query: app });
|
|
420
|
+
if (opts.json) {
|
|
421
|
+
printJson(blueprint);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
console.log(`${chalk.bold(blueprint.displayName)} ${chalk.dim(blueprint.domain)}`);
|
|
425
|
+
if (blueprint.summary)
|
|
426
|
+
console.log(chalk.dim(blueprint.summary));
|
|
427
|
+
for (const surface of blueprint.surfaces) {
|
|
428
|
+
const auth = surface.authStatus === "none" ? "no auth" : `auth: ${surface.authStatus}`;
|
|
429
|
+
console.log(` ${chalk.bold(surface.kind)} ${surface.name} ${chalk.dim(`${auth} · ${surface.readiness.strategy}`)}`);
|
|
430
|
+
}
|
|
431
|
+
for (const credential of blueprint.credentials) {
|
|
432
|
+
console.log(chalk.dim(` needs: ${credential.label}${credential.generateUrl ? ` (${credential.generateUrl})` : ""}`));
|
|
433
|
+
}
|
|
434
|
+
for (const warning of blueprint.warnings) {
|
|
435
|
+
console.log(chalk.yellow(` ! ${warning.message}`));
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
integrations
|
|
439
|
+
.command("discover <docsUrl>")
|
|
440
|
+
.description("Find a connectable server from an app's documentation page")
|
|
441
|
+
.option("--company <slug>", "Company slug")
|
|
442
|
+
.option("--json", "Machine-readable output")
|
|
443
|
+
.action(async (docsUrl, opts) => {
|
|
444
|
+
const token = await ensureCognitoIdToken();
|
|
445
|
+
const companyUid = await getCompanyUid(token, opts.company);
|
|
446
|
+
const found = await discoverDocs(token, companyUid, docsUrl);
|
|
447
|
+
if (opts.json) {
|
|
448
|
+
printJson(found);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (!found.discovery) {
|
|
452
|
+
console.log("No connectable server was found on that page.");
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const { discovery } = found;
|
|
456
|
+
console.log(`${chalk.bold(discovery.displayName)} ${chalk.dim(discovery.mcpUrl)}`);
|
|
457
|
+
console.log(chalk.dim(` sign-in: ${discovery.authMode} · ${discovery.verification} · confidence ${discovery.confidence}`));
|
|
458
|
+
// Quoted: a docs URL routinely carries `&`, which would background the
|
|
459
|
+
// command when pasted, and `;` would run whatever follows it.
|
|
460
|
+
console.log(chalk.dim(`\nConnect it with: hq integrations connect --docs-url ${shellQuote(docsUrl)}`));
|
|
461
|
+
});
|
|
462
|
+
integrations
|
|
463
|
+
.command("connect [app]")
|
|
464
|
+
.alias("add")
|
|
465
|
+
.description("Connect an app — by domain, catalog entry, docs page, or MCP URL")
|
|
466
|
+
.option("--company <slug>", "Company slug")
|
|
467
|
+
.option("--entry-id <id>", "Catalog entry id (from `hq integrations catalog --json`)")
|
|
468
|
+
.option("--mcp-url <url>", "Connect a specific MCP endpoint directly")
|
|
469
|
+
.option("--docs-url <url>", "Find the endpoint from a documentation page first")
|
|
470
|
+
.option("--provider <slug>", "Provider slug for a custom --mcp-url")
|
|
471
|
+
.option("--display-name <name>", "Display name for a custom --mcp-url")
|
|
472
|
+
.option("--token <key>", "API key (prefer --token-stdin: --token lands in shell history)")
|
|
473
|
+
.option("--token-stdin", "Read the API key from stdin")
|
|
474
|
+
.option("--auth <mode>", "Force the auth mode: none, key, or oauth (default: detect)")
|
|
475
|
+
.option("--no-browser", "Print the sign-in URL instead of opening a browser")
|
|
476
|
+
.option("--timeout <seconds>", "How long to wait for a browser sign-in (default 300)")
|
|
477
|
+
.option("--json", "Machine-readable output")
|
|
478
|
+
.action(async (app, opts) => {
|
|
479
|
+
assertAuthMode(opts.auth);
|
|
480
|
+
const token = await ensureCognitoIdToken();
|
|
481
|
+
const companyUid = await getCompanyUid(token, opts.company);
|
|
482
|
+
const target = await resolveTarget(token, companyUid, app, opts);
|
|
483
|
+
const authMode = opts.auth ?? target.authClass;
|
|
484
|
+
if (authMode === "oauth") {
|
|
485
|
+
const result = await connectViaOAuth(token, companyUid, target, opts);
|
|
486
|
+
if (result)
|
|
487
|
+
reportInstall(result, opts);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
// A key is only collected when something already says one is needed, or
|
|
491
|
+
// the caller supplied one — otherwise a no-auth app would pointlessly
|
|
492
|
+
// prompt.
|
|
493
|
+
const wantsKey = authMode === "key" || Boolean(opts.token || opts.tokenStdin);
|
|
494
|
+
const bearerToken = wantsKey ? await resolveKey(opts, target.label) : undefined;
|
|
495
|
+
try {
|
|
496
|
+
const result = await installIntegration(token, companyUid, {
|
|
497
|
+
...target.ref,
|
|
498
|
+
...(bearerToken
|
|
499
|
+
? { authMode: "bearer", bearerToken }
|
|
500
|
+
: authMode === "none"
|
|
501
|
+
? { authMode: "none" }
|
|
502
|
+
: {}),
|
|
503
|
+
});
|
|
504
|
+
reportInstall(await completeCredentialIfNeeded(token, companyUid, target, opts, result), opts);
|
|
505
|
+
}
|
|
506
|
+
catch (err) {
|
|
507
|
+
// Server-authoritative detection: the endpoint turned out to be
|
|
508
|
+
// OAuth-protected, so run the browser flow instead of making the
|
|
509
|
+
// caller re-issue the command with --auth oauth.
|
|
510
|
+
if (err instanceof IntegrationsCliError && err.code === OAUTH_REQUIRED_CODE) {
|
|
511
|
+
const result = await connectViaOAuth(token, companyUid, target, opts);
|
|
512
|
+
if (result)
|
|
513
|
+
reportInstall(result, opts);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
throw err;
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
integrations
|
|
520
|
+
.command("reconnect [app]")
|
|
521
|
+
.description("Re-authenticate a connected app whose credentials stopped working")
|
|
522
|
+
.option("--company <slug>", "Company slug")
|
|
523
|
+
.option("--provider <slug>", "Connected app (e.g. linear)")
|
|
524
|
+
.option("--connection <id>", "Connection id (acct_…)")
|
|
525
|
+
.option("--token <key>", "API key (prefer --token-stdin)")
|
|
526
|
+
.option("--token-stdin", "Read the API key from stdin")
|
|
527
|
+
.option("--auth <mode>", "Force the auth mode: none, key, or oauth (default: detect)")
|
|
528
|
+
.option("--no-browser", "Print the sign-in URL instead of opening a browser")
|
|
529
|
+
.option("--timeout <seconds>", "How long to wait for a browser sign-in (default 300)")
|
|
530
|
+
.option("--json", "Machine-readable output")
|
|
531
|
+
.action(async (app, opts) => {
|
|
532
|
+
// Same validation as `connect`. Without it a typo like `--auth oauth2`
|
|
533
|
+
// is cast straight into `authClass`, matches neither branch, and
|
|
534
|
+
// silently reinstalls with auto-detection — the caller believes they
|
|
535
|
+
// forced a mode that was never applied.
|
|
536
|
+
assertAuthMode(opts.auth);
|
|
537
|
+
const token = await ensureCognitoIdToken();
|
|
538
|
+
const companyUid = await getCompanyUid(token, opts.company);
|
|
539
|
+
const connection = await resolveConnection(token, companyUid, app, opts);
|
|
540
|
+
const url = connection.installation?.surface?.url;
|
|
541
|
+
if (!url) {
|
|
542
|
+
throw new IntegrationsCliError(`${bareProvider(connection.provider)} was not installed through the app catalog, so it cannot be reconnected from here.`, { expected: true });
|
|
543
|
+
}
|
|
544
|
+
// Re-installing the same endpoint is the reconnect: hq-pro serializes it
|
|
545
|
+
// on the connection's install lease and replaces the stored credential.
|
|
546
|
+
const target = {
|
|
547
|
+
ref: {
|
|
548
|
+
mcpUrl: url,
|
|
549
|
+
provider: bareProvider(connection.provider),
|
|
550
|
+
...(connection.installation?.displayName
|
|
551
|
+
? { displayName: connection.installation.displayName }
|
|
552
|
+
: {}),
|
|
553
|
+
},
|
|
554
|
+
...(opts.auth ? { authClass: opts.auth } : {}),
|
|
555
|
+
label: connection.installation?.displayName ?? bareProvider(connection.provider),
|
|
556
|
+
};
|
|
557
|
+
if (target.authClass === "oauth") {
|
|
558
|
+
const result = await connectViaOAuth(token, companyUid, target, opts);
|
|
559
|
+
if (result)
|
|
560
|
+
reportInstall(result, opts);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const wantsKey = target.authClass === "key" || Boolean(opts.token || opts.tokenStdin);
|
|
564
|
+
const bearerToken = wantsKey ? await resolveKey(opts, target.label) : undefined;
|
|
565
|
+
try {
|
|
566
|
+
const result = await installIntegration(token, companyUid, {
|
|
567
|
+
...target.ref,
|
|
568
|
+
...(bearerToken ? { authMode: "bearer", bearerToken } : {}),
|
|
569
|
+
});
|
|
570
|
+
reportInstall(await completeCredentialIfNeeded(token, companyUid, target, opts, result), opts);
|
|
571
|
+
}
|
|
572
|
+
catch (err) {
|
|
573
|
+
if (err instanceof IntegrationsCliError && err.code === OAUTH_REQUIRED_CODE) {
|
|
574
|
+
const result = await connectViaOAuth(token, companyUid, target, opts);
|
|
575
|
+
if (result)
|
|
576
|
+
reportInstall(result, opts);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
throw err;
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
//# sourceMappingURL=integrations-connect.js.map
|