@indigoai-us/hq-cli 5.101.7 → 5.102.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 +44 -0
- 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/package.json +1 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native-client OAuth loopback listener for `hq integrations connect`.
|
|
3
|
+
*
|
|
4
|
+
* An OAuth-protected MCP server signs the admin in through a browser, so the
|
|
5
|
+
* authorization code comes back as an HTTP redirect. RFC 8252 §7.3 defines the
|
|
6
|
+
* native-app answer: bind an ephemeral port on the loopback interface and use
|
|
7
|
+
* `http://127.0.0.1:<port>/…` as the redirect URI. hq-pro validates that shape
|
|
8
|
+
* on `/oauth/start` (see `isCliLoopbackRedirectUri`) and keeps everything that
|
|
9
|
+
* matters server-side — the PKCE verifier, the single-use state row, and the
|
|
10
|
+
* code exchange — so a code captured here is useless on its own.
|
|
11
|
+
*
|
|
12
|
+
* Ordering matters: the listener must be bound BEFORE `/oauth/start` is called,
|
|
13
|
+
* because the port is part of the redirect URI hq-pro registers with the remote
|
|
14
|
+
* authorization server. Bind → start → open browser → await → exchange.
|
|
15
|
+
*/
|
|
16
|
+
import { createServer } from "node:http";
|
|
17
|
+
import { IntegrationsCliError } from "./integrations-core.js";
|
|
18
|
+
/**
|
|
19
|
+
* The one callback path hq-pro admits for a loopback redirect. Must stay
|
|
20
|
+
* byte-identical to hq-pro's `CLI_LOOPBACK_REDIRECT_PATH`; a drift here reads
|
|
21
|
+
* as `OAUTH_REDIRECT_URI_NOT_ALLOWED` at connect time.
|
|
22
|
+
*/
|
|
23
|
+
export const LOOPBACK_CALLBACK_PATH = "/hq/integrations/oauth/callback";
|
|
24
|
+
/** How long to wait for the browser round trip before giving the port back. */
|
|
25
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
26
|
+
function respond(res, status, title, detail) {
|
|
27
|
+
const body = `<!doctype html><meta charset="utf-8"><title>${title}</title>
|
|
28
|
+
<body style="font:15px system-ui;margin:4rem auto;max-width:32rem;color:#111">
|
|
29
|
+
<h1 style="font-size:1.1rem;font-weight:600">${title}</h1><p>${detail}</p></body>`;
|
|
30
|
+
res.writeHead(status, {
|
|
31
|
+
"content-type": "text/html; charset=utf-8",
|
|
32
|
+
// The page is a terminal handoff, never something to keep or re-fetch.
|
|
33
|
+
"cache-control": "no-store",
|
|
34
|
+
});
|
|
35
|
+
res.end(body);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Bind a loopback listener on an ephemeral port and return its redirect URI.
|
|
39
|
+
*
|
|
40
|
+
* Binds to `127.0.0.1` explicitly rather than the default wildcard: a wildcard
|
|
41
|
+
* bind would expose the callback to the local network for the life of the
|
|
42
|
+
* sign-in, and the whole reason hq-pro admits this URI is that it cannot leave
|
|
43
|
+
* the machine.
|
|
44
|
+
*/
|
|
45
|
+
export async function startLoopbackListener(opts = {}) {
|
|
46
|
+
let onCallback = null;
|
|
47
|
+
/**
|
|
48
|
+
* A callback that arrived before anything was waiting for it. The browser is
|
|
49
|
+
* opened before `waitForCode` is awaited, and a redirect can complete inside
|
|
50
|
+
* that window — dropping it would hang the sign-in until the timeout for no
|
|
51
|
+
* reason. Buffering makes the listener correct regardless of ordering.
|
|
52
|
+
*/
|
|
53
|
+
let buffered = null;
|
|
54
|
+
/** Latched once a result is delivered OR buffered, so only the first wins. */
|
|
55
|
+
let settled = false;
|
|
56
|
+
const server = createServer((req, res) => {
|
|
57
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
58
|
+
if (url.pathname !== LOOPBACK_CALLBACK_PATH) {
|
|
59
|
+
respond(res, 404, "Not found", "This address only handles HQ sign-in callbacks.");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const error = url.searchParams.get("error") ?? undefined;
|
|
63
|
+
const code = url.searchParams.get("code") ?? undefined;
|
|
64
|
+
const state = url.searchParams.get("state") ?? undefined;
|
|
65
|
+
if (error) {
|
|
66
|
+
respond(res, 400, "Sign-in cancelled", "You can close this tab and return to your terminal.");
|
|
67
|
+
}
|
|
68
|
+
else if (!code || !state) {
|
|
69
|
+
respond(res, 400, "Sign-in incomplete", "The provider did not send a code. Try again from your terminal.");
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
// NOT "Connected": at this point the state has not been checked and the
|
|
73
|
+
// code has not been exchanged, either of which can still fail. The
|
|
74
|
+
// terminal is the only place that knows the real outcome, so the page
|
|
75
|
+
// reports receipt and sends the person back there.
|
|
76
|
+
respond(res, 200, "Sign-in received", "You can close this tab — check your terminal for the result.");
|
|
77
|
+
}
|
|
78
|
+
// Deliver at most once: a refresh or a duplicate redirect must not race a
|
|
79
|
+
// second exchange against a state row that is already consumed.
|
|
80
|
+
if (settled)
|
|
81
|
+
return;
|
|
82
|
+
settled = true;
|
|
83
|
+
const deliver = onCallback;
|
|
84
|
+
onCallback = null;
|
|
85
|
+
if (deliver)
|
|
86
|
+
deliver({ code, state, error });
|
|
87
|
+
else
|
|
88
|
+
buffered = { code, state, error };
|
|
89
|
+
});
|
|
90
|
+
await new Promise((resolve, reject) => {
|
|
91
|
+
server.once("error", reject);
|
|
92
|
+
server.listen(0, "127.0.0.1", () => {
|
|
93
|
+
server.removeListener("error", reject);
|
|
94
|
+
resolve();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
const address = server.address();
|
|
98
|
+
if (!address || typeof address !== "object") {
|
|
99
|
+
server.close();
|
|
100
|
+
throw new IntegrationsCliError("Could not open a local port to finish sign-in. Retry, or use --no-browser to connect from the console.", { expected: true });
|
|
101
|
+
}
|
|
102
|
+
const redirectUri = `http://127.0.0.1:${address.port}${LOOPBACK_CALLBACK_PATH}`;
|
|
103
|
+
let timer = null;
|
|
104
|
+
const close = () => {
|
|
105
|
+
if (timer)
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
timer = null;
|
|
108
|
+
onCallback = null;
|
|
109
|
+
server.close();
|
|
110
|
+
// The socket keeps the event loop alive; hq-cli exits by draining it.
|
|
111
|
+
server.unref();
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
redirectUri,
|
|
115
|
+
close,
|
|
116
|
+
waitForCode(expectedState) {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const deliver = (result) => {
|
|
119
|
+
if (timer)
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
timer = null;
|
|
122
|
+
const { code, state, error } = result;
|
|
123
|
+
if (error) {
|
|
124
|
+
reject(new IntegrationsCliError(`Sign-in was declined (${error}).`, { expected: true }));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!code || !state) {
|
|
128
|
+
reject(new IntegrationsCliError("The provider redirected back without an authorization code. Try connecting again.", { expected: true }));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// The state hq-pro minted is the anti-CSRF binding. A mismatch means
|
|
132
|
+
// this callback belongs to a different sign-in (or was injected), so
|
|
133
|
+
// refuse rather than forwarding someone else's code for exchange.
|
|
134
|
+
if (state !== expectedState) {
|
|
135
|
+
reject(new IntegrationsCliError("The sign-in response did not match the request. Nothing was connected — try again.", { expected: true }));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
resolve(code);
|
|
139
|
+
};
|
|
140
|
+
// The redirect may already have landed while the browser was opening.
|
|
141
|
+
if (buffered) {
|
|
142
|
+
const result = buffered;
|
|
143
|
+
buffered = null;
|
|
144
|
+
deliver(result);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
timer = setTimeout(() => {
|
|
148
|
+
close();
|
|
149
|
+
reject(new IntegrationsCliError("Timed out waiting for the browser sign-in. Run the connect command again.", { expected: true }));
|
|
150
|
+
}, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
151
|
+
// Don't let a pending sign-in wait keep an otherwise-finished process
|
|
152
|
+
// alive: the explicit timeout above is the bound, not the event loop.
|
|
153
|
+
timer.unref?.();
|
|
154
|
+
onCallback = deliver;
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=integrations-oauth.js.map
|
|
@@ -1,21 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `hq integrations` subcommand group —
|
|
3
|
-
*
|
|
2
|
+
* `hq integrations` subcommand group — the full lifecycle of a company's
|
|
3
|
+
* connected apps, from any HQ-authenticated session.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* (
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Cloud agents get connected apps natively through hq-pro's integration MCP
|
|
6
|
+
* gateway (POST /v1/integrations/mcp). This command group gives LOCAL sessions
|
|
7
|
+
* (Claude Code / Codex chats, scripts) the same first-class path — no
|
|
8
|
+
* hand-crafted authenticated curl calls, and no bouncing to the console for
|
|
9
|
+
* things a terminal can do.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* Find and add:
|
|
12
|
+
* hq integrations catalog [query] Apps you can connect.
|
|
13
|
+
* hq integrations inspect <app> What an app exposes.
|
|
14
|
+
* hq integrations discover <docsUrl> Find a server from its docs page.
|
|
15
|
+
* hq integrations connect <app> Connect it (no-auth, key, OAuth).
|
|
16
|
+
* hq integrations reconnect [app] Re-authenticate a broken app.
|
|
17
|
+
*
|
|
18
|
+
* Use:
|
|
12
19
|
* hq integrations list Connected apps for the company.
|
|
20
|
+
* hq integrations show [app] One app in full.
|
|
13
21
|
* hq integrations tools --provider <p> What the connected app can do.
|
|
14
22
|
* hq integrations call <tool> --provider <p> --args '<json>'
|
|
15
|
-
*
|
|
16
|
-
* hq integrations approve|reject <queueId>
|
|
17
|
-
*
|
|
18
|
-
*
|
|
23
|
+
* hq integrations pending Calls awaiting approval.
|
|
24
|
+
* hq integrations approve|reject <queueId> Decide a queued call.
|
|
25
|
+
*
|
|
26
|
+
* Govern and remove:
|
|
27
|
+
* hq integrations policy [app] --set <m> Approval setting for changes.
|
|
28
|
+
* hq integrations grants|grant|ungrant Per-tool approval exceptions.
|
|
29
|
+
* hq integrations access|share|unshare Who may use the app.
|
|
30
|
+
* hq integrations audit Recent activity.
|
|
31
|
+
* hq integrations disconnect [app] Remove it and its credentials.
|
|
19
32
|
*
|
|
20
33
|
* Governance: reads flow freely; calls that can change the app are subject to
|
|
21
34
|
* the connection's write policy (default: a person approves first). A queued
|
|
@@ -24,64 +37,14 @@
|
|
|
24
37
|
* Auth: Cognito ID token via the shared session cache (`hq auth refresh`
|
|
25
38
|
* semantics); company resolves like every other command — `--company <slug>`
|
|
26
39
|
* or the caller's single active membership. Never hardcoded.
|
|
40
|
+
*
|
|
41
|
+
* Shared primitives (error taxonomy, HTTP guards, connection resolution) live
|
|
42
|
+
* in `integrations-core.ts` and are re-exported below so existing importers
|
|
43
|
+
* keep working; the verb implementations live in `integrations-connect.ts` and
|
|
44
|
+
* `integrations-manage.ts`.
|
|
27
45
|
*/
|
|
28
46
|
import { Command } from "commander";
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
provider: string;
|
|
32
|
-
status: string;
|
|
33
|
-
writePolicy?: string;
|
|
34
|
-
installation?: {
|
|
35
|
-
displayName?: string;
|
|
36
|
-
domain?: string;
|
|
37
|
-
status?: string;
|
|
38
|
-
} | null;
|
|
39
|
-
}
|
|
40
|
-
interface GatewayMessage {
|
|
41
|
-
result?: unknown;
|
|
42
|
-
error?: {
|
|
43
|
-
code?: number;
|
|
44
|
-
message?: string;
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
export declare class IntegrationsCliError extends Error {
|
|
48
|
-
/**
|
|
49
|
-
* True when the error is the caller's request/state/permission (a client 4xx
|
|
50
|
-
* or a local input/usage error) rather than an hq-cli defect. Expected errors
|
|
51
|
-
* are printed to the user but skipped for Sentry capture (HQ-CLI-6). Defaults
|
|
52
|
-
* to false so an unclassified error still reaches Sentry.
|
|
53
|
-
*/
|
|
54
|
-
readonly expected: boolean;
|
|
55
|
-
constructor(message: string, opts?: {
|
|
56
|
-
expected?: boolean;
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
/** "factory:linear" → "linear"; mirrors hq-pro's factoryToolPrefix. */
|
|
60
|
-
export declare function toolPrefixForProvider(provider: string): string;
|
|
61
|
-
export declare function fetchConnections(token: string, companyUid: string): Promise<AdminConnection[]>;
|
|
62
|
-
/**
|
|
63
|
-
* Resolve one connection by `--connection acct_…` or `--provider linear`
|
|
64
|
-
* (matches `factory:<slug>` and bare provider ids, case-insensitive). Errors
|
|
65
|
-
* list what IS connected so the fix is one command away.
|
|
66
|
-
*/
|
|
67
|
-
export declare function selectConnection(connections: AdminConnection[], opts: {
|
|
68
|
-
connection?: string;
|
|
69
|
-
provider?: string;
|
|
70
|
-
}): AdminConnection;
|
|
71
|
-
export declare function callGateway(token: string, params: Record<string, unknown>): Promise<GatewayMessage>;
|
|
72
|
-
/**
|
|
73
|
-
* Gateway results arrive MCP-style: `{ content: [{ type: "text", text }] }`
|
|
74
|
-
* where `text` is the provider's JSON. Unwrap to the inner payload; fall back
|
|
75
|
-
* to the raw result when the shape differs.
|
|
76
|
-
*/
|
|
77
|
-
export declare function unwrapGatewayResult(result: unknown): unknown;
|
|
78
|
-
interface QueuedOutcome {
|
|
79
|
-
queuedForApproval: true;
|
|
80
|
-
queueId: string;
|
|
81
|
-
connectionId: string;
|
|
82
|
-
expiresAt?: string;
|
|
83
|
-
}
|
|
84
|
-
export declare function queuedOutcome(payload: unknown): QueuedOutcome | null;
|
|
47
|
+
export { IntegrationsCliError, callGateway, fetchConnections, queuedOutcome, selectConnection, toolPrefixForProvider, unwrapGatewayResult, } from "./integrations-core.js";
|
|
48
|
+
export type { AdminConnection } from "./integrations-core.js";
|
|
85
49
|
export declare function registerIntegrationsCommand(program: Command): void;
|
|
86
|
-
export {};
|
|
87
50
|
//# sourceMappingURL=integrations.d.ts.map
|
|
@@ -1,21 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `hq integrations` subcommand group —
|
|
3
|
-
*
|
|
2
|
+
* `hq integrations` subcommand group — the full lifecycle of a company's
|
|
3
|
+
* connected apps, from any HQ-authenticated session.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* (
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Cloud agents get connected apps natively through hq-pro's integration MCP
|
|
6
|
+
* gateway (POST /v1/integrations/mcp). This command group gives LOCAL sessions
|
|
7
|
+
* (Claude Code / Codex chats, scripts) the same first-class path — no
|
|
8
|
+
* hand-crafted authenticated curl calls, and no bouncing to the console for
|
|
9
|
+
* things a terminal can do.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* Find and add:
|
|
12
|
+
* hq integrations catalog [query] Apps you can connect.
|
|
13
|
+
* hq integrations inspect <app> What an app exposes.
|
|
14
|
+
* hq integrations discover <docsUrl> Find a server from its docs page.
|
|
15
|
+
* hq integrations connect <app> Connect it (no-auth, key, OAuth).
|
|
16
|
+
* hq integrations reconnect [app] Re-authenticate a broken app.
|
|
17
|
+
*
|
|
18
|
+
* Use:
|
|
12
19
|
* hq integrations list Connected apps for the company.
|
|
20
|
+
* hq integrations show [app] One app in full.
|
|
13
21
|
* hq integrations tools --provider <p> What the connected app can do.
|
|
14
22
|
* hq integrations call <tool> --provider <p> --args '<json>'
|
|
15
|
-
*
|
|
16
|
-
* hq integrations approve|reject <queueId>
|
|
17
|
-
*
|
|
18
|
-
*
|
|
23
|
+
* hq integrations pending Calls awaiting approval.
|
|
24
|
+
* hq integrations approve|reject <queueId> Decide a queued call.
|
|
25
|
+
*
|
|
26
|
+
* Govern and remove:
|
|
27
|
+
* hq integrations policy [app] --set <m> Approval setting for changes.
|
|
28
|
+
* hq integrations grants|grant|ungrant Per-tool approval exceptions.
|
|
29
|
+
* hq integrations access|share|unshare Who may use the app.
|
|
30
|
+
* hq integrations audit Recent activity.
|
|
31
|
+
* hq integrations disconnect [app] Remove it and its credentials.
|
|
19
32
|
*
|
|
20
33
|
* Governance: reads flow freely; calls that can change the app are subject to
|
|
21
34
|
* the connection's write policy (default: a person approves first). A queued
|
|
@@ -24,259 +37,24 @@
|
|
|
24
37
|
* Auth: Cognito ID token via the shared session cache (`hq auth refresh`
|
|
25
38
|
* semantics); company resolves like every other command — `--company <slug>`
|
|
26
39
|
* or the caller's single active membership. Never hardcoded.
|
|
40
|
+
*
|
|
41
|
+
* Shared primitives (error taxonomy, HTTP guards, connection resolution) live
|
|
42
|
+
* in `integrations-core.ts` and are re-exported below so existing importers
|
|
43
|
+
* keep working; the verb implementations live in `integrations-connect.ts` and
|
|
44
|
+
* `integrations-manage.ts`.
|
|
27
45
|
*/
|
|
28
46
|
import { randomUUID } from "node:crypto";
|
|
29
47
|
import chalk from "chalk";
|
|
30
48
|
import { ensureCognitoIdToken } from "../utils/cognito-session.js";
|
|
31
49
|
import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
* True when the error is the caller's request/state/permission (a client 4xx
|
|
37
|
-
* or a local input/usage error) rather than an hq-cli defect. Expected errors
|
|
38
|
-
* are printed to the user but skipped for Sentry capture (HQ-CLI-6). Defaults
|
|
39
|
-
* to false so an unclassified error still reaches Sentry.
|
|
40
|
-
*/
|
|
41
|
-
expected;
|
|
42
|
-
constructor(message, opts = {}) {
|
|
43
|
-
super(message);
|
|
44
|
-
this.name = "IntegrationsCliError";
|
|
45
|
-
this.expected = opts.expected ?? false;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* A client 4xx is the caller's request/state/permission (bad params, stale
|
|
50
|
-
* queueId, a non-owner approving) — expected and user-facing, not a bug. A 5xx
|
|
51
|
-
* (or a 2xx protocol violation) is a genuine server/unknown fault worth a Sentry
|
|
52
|
-
* crash report.
|
|
53
|
-
*/
|
|
54
|
-
function isClientError(status) {
|
|
55
|
-
return status >= 400 && status < 500;
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* Statuses that mean HQ's integration gateway (or the third-party provider
|
|
59
|
-
* behind it) could not serve this request right now — an UPSTREAM AVAILABILITY
|
|
60
|
-
* event, not an hq-cli defect and not something the caller did wrong.
|
|
61
|
-
*
|
|
62
|
-
* HQ-CLI-F: the 500/503s in Sentry correlate request-for-request with the
|
|
63
|
-
* hq-pro `IntegrationMcpFunction` Lambda hitting its 30s timeout while waiting
|
|
64
|
-
* on a provider (CloudWatch `integration_mcp_audit event=provider_error`, and
|
|
65
|
-
* the same spikes counted in the AWS/Lambda `Errors` metric). The event is
|
|
66
|
-
* already recorded first-party, in the project that owns the fix; mirroring it
|
|
67
|
-
* into hq-cli's tracker is duplicate, unactionable noise. 429 is included
|
|
68
|
-
* because a rate-limited call is the same "retry in a moment" outcome (it was
|
|
69
|
-
* already `expected` via `isClientError`; only its wording changes here).
|
|
70
|
-
*/
|
|
71
|
-
function isUpstreamUnavailable(status) {
|
|
72
|
-
return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
73
|
-
}
|
|
74
|
-
/** Actionable wording for an upstream-availability status. */
|
|
75
|
-
function upstreamUnavailableMessage(status) {
|
|
76
|
-
return status === 429
|
|
77
|
-
? `HQ's integration gateway is rate-limiting this request (HTTP 429). Wait a moment and retry.`
|
|
78
|
-
: `HQ's integration gateway is temporarily unavailable (HTTP ${status}). ` +
|
|
79
|
-
`This is a service-side hiccup, not a problem with your command — retry in a moment.`;
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* Shared non-2xx guard for every integration-gateway call site. Raises the
|
|
83
|
-
* expected, actionable upstream-availability error when the status says the
|
|
84
|
-
* service is down or throttling; returns otherwise so the caller keeps its own
|
|
85
|
-
* status-specific message and `expected` classification unchanged.
|
|
86
|
-
*/
|
|
87
|
-
function raiseIfUpstreamUnavailable(res) {
|
|
88
|
-
if (isUpstreamUnavailable(res.status)) {
|
|
89
|
-
throw new IntegrationsCliError(upstreamUnavailableMessage(res.status), {
|
|
90
|
-
expected: true,
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
// The integration gateway answers `POST /v1/integrations/mcp` JSON-RPC-style: a
|
|
95
|
-
// transport failure is a non-2xx HTTP status, but a GOVERNED refusal arrives as
|
|
96
|
-
// HTTP 200 carrying a JSON-RPC `error` object (mirrors hq-pro's
|
|
97
|
-
// integration-mcp/server.ts error mapping). These caller-side codes are the
|
|
98
|
-
// JSON-RPC analog of a client 4xx — the caller's request/state/permission,
|
|
99
|
-
// expected and actionable, not an hq-cli defect — so they are printed to the
|
|
100
|
-
// user and skipped for Sentry capture (HQ-CLI-B):
|
|
101
|
-
// -32003 UNAUTHORIZED — connection-level access denial, e.g. the
|
|
102
|
-
// "You do not have access to this integration. Ask its
|
|
103
|
-
// owner to share it with you." (IntegrationAccessDenied)
|
|
104
|
-
// that flooded Sentry, plus ConnectionNotFound / a
|
|
105
|
-
// read-only share rejecting a write.
|
|
106
|
-
// -32602 INVALID_PARAMS — an unknown tool or unsupported provider for the
|
|
107
|
-
// connection (a bad request the caller can correct).
|
|
108
|
-
// -32050 PROVIDER_ERROR — a THIRD-PARTY provider fault, surfaced verbatim.
|
|
109
|
-
// hq-pro mints this code for EVERY provider fault
|
|
110
|
-
// and for nothing else: `integration-mcp/server.ts`
|
|
111
|
-
// maps an `IntegrationMcpError` with status
|
|
112
|
-
// 'provider_error' to -32050, raised by
|
|
113
|
-
// `integration-mcp/dispatch.ts` for ProviderTimeout,
|
|
114
|
-
// ProviderRateLimited, ProviderParseError,
|
|
115
|
-
// ProviderWriteUnknown and TokenRefreshFailed.
|
|
116
|
-
// NOTE the remote server's OWN JSON-RPC code is
|
|
117
|
-
// embedded in the message TEXT ("Remote MCP request
|
|
118
|
-
// failed (-32602): …"); the wire code hq-cli sees is
|
|
119
|
-
// always -32050, so the codes above never match it.
|
|
120
|
-
// Every occurrence is already recorded first-party
|
|
121
|
-
// in hq-pro — `integration_mcp_audit
|
|
122
|
-
// event=provider_error` with reason/provider/tool,
|
|
123
|
-
// an `integration_mcp_health_signal` metric, and
|
|
124
|
-
// hq-pro's own Sentry project — so hq-cli reporting
|
|
125
|
-
// it again is duplicate noise in the wrong tracker,
|
|
126
|
-
// filed against a codebase that cannot fix it
|
|
127
|
-
// (HQ-CLI-F). Should hq-pro ever reuse -32050 for
|
|
128
|
-
// an hq-pro-side fault, the mapping site named above
|
|
129
|
-
// is where that change is traceable; -32603 below
|
|
130
|
-
// remains the code for hq-pro's own faults.
|
|
131
|
-
// Everything else stays unexpected so a genuine fault still reaches Sentry:
|
|
132
|
-
// INTERNAL_ERROR (-32603), CONFLICT (-32009, which the gateway also raises for
|
|
133
|
-
// a confirm queue being unavailable or an owner notification failing — real
|
|
134
|
-
// backend faults worth a report), METHOD_NOT_FOUND / PARSE_ERROR, and any
|
|
135
|
-
// absent or unrecognized code.
|
|
136
|
-
const EXPECTED_GATEWAY_ERROR_CODES = new Set([-32003, -32602, -32050]);
|
|
137
|
-
function isExpectedGatewayError(code) {
|
|
138
|
-
return code != null && EXPECTED_GATEWAY_ERROR_CODES.has(code);
|
|
139
|
-
}
|
|
140
|
-
// A 401 from ANY integration-gateway vault call means the caller's HQ session
|
|
141
|
-
// is expired or missing — an expected auth state fixed by `hq login`, not an
|
|
142
|
-
// hq-cli defect. Raise the same typed AuthError the vault company-resolution
|
|
143
|
-
// paths use (HQ-CLI-8) so the top-level handler prints one actionable message
|
|
144
|
-
// and skips Sentry, instead of surfacing the opaque, unactionable
|
|
145
|
-
// "Integration gateway request failed (HTTP 401)" that shipped as a fatal from
|
|
146
|
-
// `callGateway` (HQ-CLI-9). Non-401 statuses keep their existing behavior:
|
|
147
|
-
// other 4xx stay expected client errors, 5xx still report.
|
|
148
|
-
function raiseIfUnauthorized(res) {
|
|
149
|
-
if (res.status === 401)
|
|
150
|
-
throw new AuthError();
|
|
151
|
-
}
|
|
152
|
-
/** "factory:linear" → "linear"; mirrors hq-pro's factoryToolPrefix. */
|
|
153
|
-
export function toolPrefixForProvider(provider) {
|
|
154
|
-
return provider
|
|
155
|
-
.replace(/^factory:/, "")
|
|
156
|
-
.trim()
|
|
157
|
-
.toLowerCase()
|
|
158
|
-
.replace(/[^a-z0-9]+/g, ".");
|
|
159
|
-
}
|
|
160
|
-
export async function fetchConnections(token, companyUid) {
|
|
161
|
-
const res = await vaultApiFetch({
|
|
162
|
-
token,
|
|
163
|
-
path: "/v1/integrations/admin",
|
|
164
|
-
query: { companyUid },
|
|
165
|
-
});
|
|
166
|
-
if (!res.ok) {
|
|
167
|
-
raiseIfUnauthorized(res);
|
|
168
|
-
raiseIfUpstreamUnavailable(res);
|
|
169
|
-
const body = (await res.json().catch(() => ({})));
|
|
170
|
-
throw new IntegrationsCliError(body.error ?? `Failed to list integrations (HTTP ${res.status})`, { expected: isClientError(res.status) });
|
|
171
|
-
}
|
|
172
|
-
const data = (await res.json());
|
|
173
|
-
return data.connections ?? [];
|
|
174
|
-
}
|
|
175
|
-
/**
|
|
176
|
-
* Resolve one connection by `--connection acct_…` or `--provider linear`
|
|
177
|
-
* (matches `factory:<slug>` and bare provider ids, case-insensitive). Errors
|
|
178
|
-
* list what IS connected so the fix is one command away.
|
|
179
|
-
*/
|
|
180
|
-
export function selectConnection(connections, opts) {
|
|
181
|
-
const active = connections.filter((c) => c.status !== "revoked");
|
|
182
|
-
if (opts.connection) {
|
|
183
|
-
const match = connections.find((c) => c.id === opts.connection);
|
|
184
|
-
if (!match) {
|
|
185
|
-
throw new IntegrationsCliError(`No connection '${opts.connection}'. Run \`hq integrations list\` to see connected apps.`, { expected: true });
|
|
186
|
-
}
|
|
187
|
-
return match;
|
|
188
|
-
}
|
|
189
|
-
if (opts.provider) {
|
|
190
|
-
const want = opts.provider.trim().toLowerCase();
|
|
191
|
-
const match = active.find((c) => {
|
|
192
|
-
const bare = c.provider.replace(/^factory:/, "").toLowerCase();
|
|
193
|
-
return bare === want || c.provider.toLowerCase() === want;
|
|
194
|
-
});
|
|
195
|
-
if (!match) {
|
|
196
|
-
const available = active
|
|
197
|
-
.map((c) => c.provider.replace(/^factory:/, ""))
|
|
198
|
-
.join(", ");
|
|
199
|
-
throw new IntegrationsCliError(`No connected app matches '${opts.provider}'.` +
|
|
200
|
-
(available ? ` Connected: ${available}.` : " Nothing is connected yet — connect apps on the console Integrations page."), { expected: true });
|
|
201
|
-
}
|
|
202
|
-
return match;
|
|
203
|
-
}
|
|
204
|
-
if (active.length === 1)
|
|
205
|
-
return active[0];
|
|
206
|
-
if (active.length === 0) {
|
|
207
|
-
throw new IntegrationsCliError("No connected apps yet. Connect one on the console Integrations page, then retry.", { expected: true });
|
|
208
|
-
}
|
|
209
|
-
throw new IntegrationsCliError(`Multiple apps are connected — pick one with --provider:\n` +
|
|
210
|
-
active.map((c) => ` --provider ${c.provider.replace(/^factory:/, "")}`).join("\n"), { expected: true });
|
|
211
|
-
}
|
|
212
|
-
export async function callGateway(token, params) {
|
|
213
|
-
const res = await vaultApiFetch({
|
|
214
|
-
token,
|
|
215
|
-
path: "/v1/integrations/mcp",
|
|
216
|
-
method: "POST",
|
|
217
|
-
body: {
|
|
218
|
-
jsonrpc: "2.0",
|
|
219
|
-
id: `hq-cli-${randomUUID()}`,
|
|
220
|
-
method: "tools/call",
|
|
221
|
-
params,
|
|
222
|
-
},
|
|
223
|
-
});
|
|
224
|
-
const message = (await res.json().catch(() => null));
|
|
225
|
-
if (!res.ok || !message) {
|
|
226
|
-
raiseIfUnauthorized(res);
|
|
227
|
-
raiseIfUpstreamUnavailable(res);
|
|
228
|
-
throw new IntegrationsCliError(`Integration gateway request failed (HTTP ${res.status}).`, { expected: isClientError(res.status) });
|
|
229
|
-
}
|
|
230
|
-
if (message.error) {
|
|
231
|
-
// The gateway's error text is minted UPSTREAM (hq-pro, the integration
|
|
232
|
-
// factory, and beyond it the third-party provider), so it is untrusted:
|
|
233
|
-
// scrub credentials and bound the length here, at the throw site, because
|
|
234
|
-
// an `expected` error is printed straight from `err.message` by the
|
|
235
|
-
// top-level handler and never passes through `unexpectedCliErrorMessage`.
|
|
236
|
-
// The chain is idempotent, so the unexpected path scrubbing again is a
|
|
237
|
-
// no-op. This preserves PR #298's user-visible diagnostic; it only makes
|
|
238
|
-
// it safe on the newly-expected path.
|
|
239
|
-
throw new IntegrationsCliError(redactErrorText(message.error.message ?? "") ||
|
|
240
|
-
"Integration gateway returned an error.", { expected: isExpectedGatewayError(message.error.code) });
|
|
241
|
-
}
|
|
242
|
-
return message;
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* Gateway results arrive MCP-style: `{ content: [{ type: "text", text }] }`
|
|
246
|
-
* where `text` is the provider's JSON. Unwrap to the inner payload; fall back
|
|
247
|
-
* to the raw result when the shape differs.
|
|
248
|
-
*/
|
|
249
|
-
export function unwrapGatewayResult(result) {
|
|
250
|
-
if (result && typeof result === "object" && Array.isArray(result.content)) {
|
|
251
|
-
const content = result.content;
|
|
252
|
-
const text = content.find((c) => c.type === "text")?.text;
|
|
253
|
-
if (typeof text === "string") {
|
|
254
|
-
try {
|
|
255
|
-
return JSON.parse(text);
|
|
256
|
-
}
|
|
257
|
-
catch {
|
|
258
|
-
return text;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
return result;
|
|
263
|
-
}
|
|
264
|
-
export function queuedOutcome(payload) {
|
|
265
|
-
if (payload &&
|
|
266
|
-
typeof payload === "object" &&
|
|
267
|
-
payload.queuedForApproval === true &&
|
|
268
|
-
typeof payload.queueId === "string") {
|
|
269
|
-
return payload;
|
|
270
|
-
}
|
|
271
|
-
return null;
|
|
272
|
-
}
|
|
273
|
-
function printJson(value) {
|
|
274
|
-
console.log(JSON.stringify(value, null, 2));
|
|
275
|
-
}
|
|
50
|
+
import { IntegrationsCliError, bareProvider, callGateway, fetchConnections, isClientError, printJson, raiseIfUnauthorized, raiseIfUpstreamUnavailable, selectConnection, toolPrefixForProvider, unwrapGatewayResult, queuedOutcome, } from "./integrations-core.js";
|
|
51
|
+
import { registerConnectCommands } from "./integrations-connect.js";
|
|
52
|
+
import { registerManageCommands } from "./integrations-manage.js";
|
|
53
|
+
export { IntegrationsCliError, callGateway, fetchConnections, queuedOutcome, selectConnection, toolPrefixForProvider, unwrapGatewayResult, } from "./integrations-core.js";
|
|
276
54
|
export function registerIntegrationsCommand(program) {
|
|
277
55
|
const integrations = program
|
|
278
56
|
.command("integrations")
|
|
279
|
-
.description("
|
|
57
|
+
.description("Connect, govern, and use company apps (Linear, Notion, …) through HQ's governed integration gateway");
|
|
280
58
|
integrations
|
|
281
59
|
.command("list")
|
|
282
60
|
.description("List the company's connected apps")
|
|
@@ -291,11 +69,11 @@ export function registerIntegrationsCommand(program) {
|
|
|
291
69
|
return;
|
|
292
70
|
}
|
|
293
71
|
if (connections.length === 0) {
|
|
294
|
-
console.log("No apps connected yet.
|
|
72
|
+
console.log("No apps connected yet. Browse them with `hq integrations catalog`, then connect one with `hq integrations connect <app>`.");
|
|
295
73
|
return;
|
|
296
74
|
}
|
|
297
75
|
for (const c of connections) {
|
|
298
|
-
const name = c.installation?.displayName ?? c.provider
|
|
76
|
+
const name = c.installation?.displayName ?? bareProvider(c.provider);
|
|
299
77
|
const flags = [
|
|
300
78
|
c.status,
|
|
301
79
|
c.writePolicy ? `writes: ${c.writePolicy}` : null,
|
|
@@ -303,7 +81,7 @@ export function registerIntegrationsCommand(program) {
|
|
|
303
81
|
]
|
|
304
82
|
.filter(Boolean)
|
|
305
83
|
.join(" · ");
|
|
306
|
-
console.log(`${chalk.bold(name)} (${c.provider
|
|
84
|
+
console.log(`${chalk.bold(name)} (${bareProvider(c.provider)}) ${chalk.dim(flags)}`);
|
|
307
85
|
console.log(chalk.dim(` connection: ${c.id}`));
|
|
308
86
|
}
|
|
309
87
|
});
|
|
@@ -338,7 +116,7 @@ export function registerIntegrationsCommand(program) {
|
|
|
338
116
|
const label = tool.title && tool.title !== tool.name ? ` ${chalk.dim(tool.title)}` : "";
|
|
339
117
|
console.log(`${chalk.bold(tool.name)}${label}`);
|
|
340
118
|
}
|
|
341
|
-
console.log(chalk.dim(`\n${tools.length} tools. Call one with: hq integrations call <tool> --provider ${connection.provider
|
|
119
|
+
console.log(chalk.dim(`\n${tools.length} tools. Call one with: hq integrations call <tool> --provider ${bareProvider(connection.provider)} --args '<json>'`));
|
|
342
120
|
});
|
|
343
121
|
integrations
|
|
344
122
|
.command("call <tool>")
|
|
@@ -385,7 +163,7 @@ export function registerIntegrationsCommand(program) {
|
|
|
385
163
|
return;
|
|
386
164
|
}
|
|
387
165
|
console.log(chalk.yellow("Queued for approval — this call can change the app, so a company owner decides first."));
|
|
388
|
-
console.log(`Approve with:\n hq integrations approve ${queued.queueId} --provider ${connection.provider
|
|
166
|
+
console.log(`Approve with:\n hq integrations approve ${queued.queueId} --provider ${bareProvider(connection.provider)}`);
|
|
389
167
|
if (queued.expiresAt) {
|
|
390
168
|
console.log(chalk.dim(`Expires ${queued.expiresAt}`));
|
|
391
169
|
}
|
|
@@ -431,5 +209,7 @@ export function registerIntegrationsCommand(program) {
|
|
|
431
209
|
}
|
|
432
210
|
});
|
|
433
211
|
}
|
|
212
|
+
registerConnectCommands(integrations);
|
|
213
|
+
registerManageCommands(integrations);
|
|
434
214
|
}
|
|
435
215
|
//# sourceMappingURL=integrations.js.map
|
package/dist/commands/reindex.js
CHANGED
|
@@ -229,7 +229,7 @@ export function registerReindexCommand(program) {
|
|
|
229
229
|
program
|
|
230
230
|
.command('reindex')
|
|
231
231
|
.alias('master-sync')
|
|
232
|
-
.description('Surface namespaced skills, regenerate the workers registry, and trust HQ hooks for Codex and Grok')
|
|
232
|
+
.description('Surface namespaced skills, materialize legacy knowledge repos, regenerate the workers registry, and trust HQ hooks for Codex and Grok')
|
|
233
233
|
.option('--repo-root <path>', 'HQ root to operate on (defaults to the current directory)')
|
|
234
234
|
.option('--from-hook', 'Invoked from a Claude/Codex lifecycle hook: never wait on the per-root operation lock — if a sync/rescue holds it, skip this reindex instead of blocking the session (equivalent to --lock-timeout 0)')
|
|
235
235
|
.option('--lock-timeout <seconds>', 'Bound the wait for the per-root operation lock (seconds). 0 = refuse immediately; omitted = wait indefinitely (the interactive default). --from-hook implies 0; an explicit --lock-timeout wins.')
|