@indigoai-us/hq-cli 5.103.1 → 5.103.2
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 +2 -0
- package/dist/commands/integrations-api.d.ts +19 -0
- package/dist/commands/integrations-api.js +16 -0
- package/dist/commands/integrations-connect.js +86 -13
- package/dist/commands/integrations-core.d.ts +19 -0
- package/dist/commands/integrations-core.js +25 -0
- package/dist/commands/integrations-manage.js +46 -23
- package/dist/commands/integrations.d.ts +1 -1
- package/dist/commands/integrations.js +10 -2
- package/dist/utils/self-update.d.ts +38 -1
- package/dist/utils/self-update.js +66 -9
- package/dist/utils/version-gate.d.ts +51 -0
- package/dist/utils/version-gate.js +81 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -206,6 +206,25 @@ export interface ConnectionAccess {
|
|
|
206
206
|
grantedAt: string;
|
|
207
207
|
}>;
|
|
208
208
|
}
|
|
209
|
+
export interface PendingApproval {
|
|
210
|
+
queueId: string;
|
|
211
|
+
connectionId: string;
|
|
212
|
+
provider: string;
|
|
213
|
+
/** Gateway tool (…mcp.tools.call). */
|
|
214
|
+
toolName: string;
|
|
215
|
+
/** Concrete app tool the caller named, when carried by the queued call. */
|
|
216
|
+
requestedTool?: string | null;
|
|
217
|
+
requestedBy: string;
|
|
218
|
+
requestedByName?: string | null;
|
|
219
|
+
createdAt: string;
|
|
220
|
+
expiresAt: string;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Live open-approval list, read from hq-pro's confirm-queue state (not the
|
|
224
|
+
* audit feed). Unlike the audit-derived reconstruction this replaces, a call
|
|
225
|
+
* that was approved/rejected/expired no longer appears here.
|
|
226
|
+
*/
|
|
227
|
+
export declare function fetchPendingApprovals(token: string, companyUid: string): Promise<PendingApproval[]>;
|
|
209
228
|
export declare function getConnectionAccess(token: string, companyUid: string, connectionId: string): Promise<ConnectionAccess>;
|
|
210
229
|
export declare function mutateConnectionAccess(token: string, companyUid: string, action: "grant" | "revoke", input: {
|
|
211
230
|
connectionId: string;
|
|
@@ -110,6 +110,22 @@ export async function updateGovernance(token, companyUid, input) {
|
|
|
110
110
|
await raiseForResponse(res, "Failed to update the app's settings");
|
|
111
111
|
return (await res.json());
|
|
112
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Live open-approval list, read from hq-pro's confirm-queue state (not the
|
|
115
|
+
* audit feed). Unlike the audit-derived reconstruction this replaces, a call
|
|
116
|
+
* that was approved/rejected/expired no longer appears here.
|
|
117
|
+
*/
|
|
118
|
+
export async function fetchPendingApprovals(token, companyUid) {
|
|
119
|
+
const res = await vaultApiFetch({
|
|
120
|
+
token,
|
|
121
|
+
path: "/v1/integrations/factory/pending",
|
|
122
|
+
query: { companyUid },
|
|
123
|
+
});
|
|
124
|
+
if (!res.ok)
|
|
125
|
+
await raiseForResponse(res, "Failed to list pending approvals");
|
|
126
|
+
const body = (await res.json());
|
|
127
|
+
return body.pending ?? [];
|
|
128
|
+
}
|
|
113
129
|
export async function getConnectionAccess(token, companyUid, connectionId) {
|
|
114
130
|
const res = await vaultApiFetch({
|
|
115
131
|
token,
|
|
@@ -25,6 +25,24 @@ import { completeOAuth, discoverDocs, installIntegration, listCatalog, pullBluep
|
|
|
25
25
|
import { startLoopbackListener } from "./integrations-oauth.js";
|
|
26
26
|
/** hq-pro's machine code for "this endpoint needs a browser sign-in". */
|
|
27
27
|
const OAUTH_REQUIRED_CODE = "INTEGRATION_FACTORY_OAUTH_REQUIRED";
|
|
28
|
+
/**
|
|
29
|
+
* A server-authoritative "this endpoint is OAuth-protected, run the browser
|
|
30
|
+
* sign-in instead" signal, in EITHER of the two shapes hq-pro emits it:
|
|
31
|
+
*
|
|
32
|
+
* - the catalog/discovery install path returns the
|
|
33
|
+
* `INTEGRATION_FACTORY_OAUTH_REQUIRED` code, and
|
|
34
|
+
* - the direct-MCP install path returns `DIRECT_MCP_AUTH_REJECTED` with
|
|
35
|
+
* `oauthProtected: true`.
|
|
36
|
+
*
|
|
37
|
+
* Keying off only the code left the direct-MCP path (e.g. `--mcp-url` against
|
|
38
|
+
* a sign-in server, or a bare-domain OAuth app) surfacing the raw
|
|
39
|
+
* "uses OAuth sign-in rather than a pasted access key" error instead of
|
|
40
|
+
* transparently switching to the sign-in flow.
|
|
41
|
+
*/
|
|
42
|
+
function isOAuthRequiredError(err) {
|
|
43
|
+
return (err instanceof IntegrationsCliError &&
|
|
44
|
+
(err.code === OAUTH_REQUIRED_CODE || err.oauthProtected === true));
|
|
45
|
+
}
|
|
28
46
|
/**
|
|
29
47
|
* hq-pro will not take our loopback callback, for either of the two reasons it
|
|
30
48
|
* can refuse one:
|
|
@@ -204,20 +222,40 @@ async function resolveTarget(token, companyUid, app, opts) {
|
|
|
204
222
|
// authClass, both of which make the connect cleaner than a raw domain
|
|
205
223
|
// lookup. Missing it is fine — the domain path still works.
|
|
206
224
|
const match = await findCatalogEntry(token, companyUid, app);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
225
|
+
return catalogEntryToTarget(match, { ref: { domain: app }, label: app });
|
|
226
|
+
}
|
|
227
|
+
// A bare name (`notion`, `atlassian`) is how the catalog reads to a person —
|
|
228
|
+
// the rows are titled `notion.com`, `atlassian.com`. Resolve it to that row
|
|
229
|
+
// (by its registrable label / provider / display name) so `connect notion`
|
|
230
|
+
// is `connect notion.com`. The match also carries the authClass, so an OAuth
|
|
231
|
+
// app routes to sign-in instead of a doomed key install. Ambiguous or
|
|
232
|
+
// unmatched → fall back to the server-side query resolver.
|
|
233
|
+
const named = await findCatalogEntryByName(token, companyUid, app);
|
|
234
|
+
if (named) {
|
|
235
|
+
return catalogEntryToTarget(named, { ref: { query: app }, label: app });
|
|
236
|
+
}
|
|
237
|
+
return { ref: { query: app }, label: app };
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Turn a matched catalog entry into a connect target, preferring its opaque
|
|
241
|
+
* entryId (the cleanest handle) and otherwise its domain. `fallback` is used
|
|
242
|
+
* verbatim when there was no catalog match at all.
|
|
243
|
+
*/
|
|
244
|
+
function catalogEntryToTarget(match, fallback) {
|
|
245
|
+
if (!match)
|
|
246
|
+
return fallback;
|
|
247
|
+
if (match.entryId) {
|
|
214
248
|
return {
|
|
215
|
-
ref: {
|
|
216
|
-
...(match
|
|
217
|
-
label: match
|
|
249
|
+
ref: { catalogEntryId: match.entryId },
|
|
250
|
+
...(match.authClass ? { authClass: match.authClass } : {}),
|
|
251
|
+
label: match.name || fallback.label,
|
|
218
252
|
};
|
|
219
253
|
}
|
|
220
|
-
return {
|
|
254
|
+
return {
|
|
255
|
+
ref: { domain: match.domain },
|
|
256
|
+
...(match.authClass ? { authClass: match.authClass } : {}),
|
|
257
|
+
label: match.name || fallback.label,
|
|
258
|
+
};
|
|
221
259
|
}
|
|
222
260
|
/** Best-effort catalog lookup by domain; never fails the connect. */
|
|
223
261
|
async function findCatalogEntry(token, companyUid, domain) {
|
|
@@ -232,6 +270,41 @@ async function findCatalogEntry(token, companyUid, domain) {
|
|
|
232
270
|
return null;
|
|
233
271
|
}
|
|
234
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* The registrable label of a domain — its first dot-separated segment
|
|
275
|
+
* (`notion.com` → `notion`, `linear.app` → `linear`). This is the short name a
|
|
276
|
+
* person types for a catalog app.
|
|
277
|
+
*/
|
|
278
|
+
function domainLabel(domain) {
|
|
279
|
+
return domain.trim().toLowerCase().split(".")[0] ?? "";
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Best-effort resolution of a bare name to a single catalog entry. Matches the
|
|
283
|
+
* entry's registrable domain label (`notion` → `notion.com`) or an exact
|
|
284
|
+
* display-name. Returns null when nothing matches OR when more than one DISTINCT
|
|
285
|
+
* app matches — an ambiguous short name must not silently connect the wrong app,
|
|
286
|
+
* so it falls back to the server-side query resolver. Never throws: the catalog
|
|
287
|
+
* is an optimization, not the source of truth.
|
|
288
|
+
*/
|
|
289
|
+
async function findCatalogEntryByName(token, companyUid, name) {
|
|
290
|
+
try {
|
|
291
|
+
const want = name.trim().toLowerCase();
|
|
292
|
+
if (!want)
|
|
293
|
+
return null;
|
|
294
|
+
const entries = await listCatalog(token, companyUid, { query: name, limit: 20 });
|
|
295
|
+
const matches = entries.filter((entry) => domainLabel(entry.domain) === want ||
|
|
296
|
+
entry.name?.trim().toLowerCase() === want);
|
|
297
|
+
if (matches.length === 0)
|
|
298
|
+
return null;
|
|
299
|
+
// Collapse rows that point at the same app (same domain) before deciding
|
|
300
|
+
// ambiguity, so one app surfaced twice is still an unambiguous match.
|
|
301
|
+
const distinct = new Map(matches.map((m) => [m.domain.trim().toLowerCase(), m]));
|
|
302
|
+
return distinct.size === 1 ? [...distinct.values()][0] : null;
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
235
308
|
/**
|
|
236
309
|
* Run the browser sign-in and finish the install.
|
|
237
310
|
*
|
|
@@ -507,7 +580,7 @@ export function registerConnectCommands(integrations) {
|
|
|
507
580
|
// Server-authoritative detection: the endpoint turned out to be
|
|
508
581
|
// OAuth-protected, so run the browser flow instead of making the
|
|
509
582
|
// caller re-issue the command with --auth oauth.
|
|
510
|
-
if (err
|
|
583
|
+
if (isOAuthRequiredError(err)) {
|
|
511
584
|
const result = await connectViaOAuth(token, companyUid, target, opts);
|
|
512
585
|
if (result)
|
|
513
586
|
reportInstall(result, opts);
|
|
@@ -570,7 +643,7 @@ export function registerConnectCommands(integrations) {
|
|
|
570
643
|
reportInstall(await completeCredentialIfNeeded(token, companyUid, target, opts, result), opts);
|
|
571
644
|
}
|
|
572
645
|
catch (err) {
|
|
573
|
-
if (err
|
|
646
|
+
if (isOAuthRequiredError(err)) {
|
|
574
647
|
const result = await connectViaOAuth(token, companyUid, target, opts);
|
|
575
648
|
if (result)
|
|
576
649
|
reportInstall(result, opts);
|
|
@@ -120,10 +120,20 @@ export declare class IntegrationsCliError extends Error {
|
|
|
120
120
|
readonly code?: string;
|
|
121
121
|
/** HTTP status the failure came back with, when it came from a response. */
|
|
122
122
|
readonly status?: number;
|
|
123
|
+
/**
|
|
124
|
+
* hq-pro's signal that the endpoint is OAuth-protected, carried on some
|
|
125
|
+
* auth-rejection bodies (e.g. `DIRECT_MCP_AUTH_REJECTED`). It is a SECOND,
|
|
126
|
+
* code-independent way to reach the sign-in flow: the direct-MCP install
|
|
127
|
+
* path reports OAuth via this flag rather than the `INTEGRATION_FACTORY_
|
|
128
|
+
* OAUTH_REQUIRED` code, so a connect that keys off only the code would
|
|
129
|
+
* surface the error instead of switching to the browser sign-in.
|
|
130
|
+
*/
|
|
131
|
+
readonly oauthProtected?: boolean;
|
|
123
132
|
constructor(message: string, opts?: {
|
|
124
133
|
expected?: boolean;
|
|
125
134
|
code?: string;
|
|
126
135
|
status?: number;
|
|
136
|
+
oauthProtected?: boolean;
|
|
127
137
|
});
|
|
128
138
|
}
|
|
129
139
|
/**
|
|
@@ -204,6 +214,15 @@ export declare function callGateway(token: string, params: Record<string, unknow
|
|
|
204
214
|
* to the raw result when the shape differs.
|
|
205
215
|
*/
|
|
206
216
|
export declare function unwrapGatewayResult(result: unknown): unknown;
|
|
217
|
+
/**
|
|
218
|
+
* True when a tool result is an MCP *tool error* (`isError: true`). The flag
|
|
219
|
+
* lives on the OUTER gateway result alongside `content`, so it must be read
|
|
220
|
+
* from the raw `message.result` BEFORE `unwrapGatewayResult` strips the
|
|
221
|
+
* envelope down to the inner text. A failed tool call otherwise prints its
|
|
222
|
+
* error payload and still exits 0 — indistinguishable from success to any
|
|
223
|
+
* script (or agent) driving the gateway.
|
|
224
|
+
*/
|
|
225
|
+
export declare function gatewayResultIsError(result: unknown): boolean;
|
|
207
226
|
interface QueuedOutcome {
|
|
208
227
|
queuedForApproval: true;
|
|
209
228
|
queueId: string;
|
|
@@ -28,6 +28,15 @@ export class IntegrationsCliError extends Error {
|
|
|
28
28
|
code;
|
|
29
29
|
/** HTTP status the failure came back with, when it came from a response. */
|
|
30
30
|
status;
|
|
31
|
+
/**
|
|
32
|
+
* hq-pro's signal that the endpoint is OAuth-protected, carried on some
|
|
33
|
+
* auth-rejection bodies (e.g. `DIRECT_MCP_AUTH_REJECTED`). It is a SECOND,
|
|
34
|
+
* code-independent way to reach the sign-in flow: the direct-MCP install
|
|
35
|
+
* path reports OAuth via this flag rather than the `INTEGRATION_FACTORY_
|
|
36
|
+
* OAUTH_REQUIRED` code, so a connect that keys off only the code would
|
|
37
|
+
* surface the error instead of switching to the browser sign-in.
|
|
38
|
+
*/
|
|
39
|
+
oauthProtected;
|
|
31
40
|
constructor(message, opts = {}) {
|
|
32
41
|
super(message);
|
|
33
42
|
this.name = "IntegrationsCliError";
|
|
@@ -36,6 +45,8 @@ export class IntegrationsCliError extends Error {
|
|
|
36
45
|
this.code = opts.code;
|
|
37
46
|
if (opts.status !== undefined)
|
|
38
47
|
this.status = opts.status;
|
|
48
|
+
if (opts.oauthProtected !== undefined)
|
|
49
|
+
this.oauthProtected = opts.oauthProtected;
|
|
39
50
|
}
|
|
40
51
|
}
|
|
41
52
|
/**
|
|
@@ -161,6 +172,7 @@ export async function raiseForResponse(res, fallback) {
|
|
|
161
172
|
expected: isClientError(res.status),
|
|
162
173
|
status: res.status,
|
|
163
174
|
...(body.code ? { code: body.code } : {}),
|
|
175
|
+
...(body.oauthProtected === true ? { oauthProtected: true } : {}),
|
|
164
176
|
});
|
|
165
177
|
}
|
|
166
178
|
/** "factory:linear" → "linear"; mirrors hq-pro's factoryToolPrefix. */
|
|
@@ -305,6 +317,19 @@ export function unwrapGatewayResult(result) {
|
|
|
305
317
|
}
|
|
306
318
|
return result;
|
|
307
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* True when a tool result is an MCP *tool error* (`isError: true`). The flag
|
|
322
|
+
* lives on the OUTER gateway result alongside `content`, so it must be read
|
|
323
|
+
* from the raw `message.result` BEFORE `unwrapGatewayResult` strips the
|
|
324
|
+
* envelope down to the inner text. A failed tool call otherwise prints its
|
|
325
|
+
* error payload and still exits 0 — indistinguishable from success to any
|
|
326
|
+
* script (or agent) driving the gateway.
|
|
327
|
+
*/
|
|
328
|
+
export function gatewayResultIsError(result) {
|
|
329
|
+
return (!!result &&
|
|
330
|
+
typeof result === "object" &&
|
|
331
|
+
result.isError === true);
|
|
332
|
+
}
|
|
308
333
|
export function queuedOutcome(payload) {
|
|
309
334
|
if (payload &&
|
|
310
335
|
typeof payload === "object" &&
|
|
@@ -17,7 +17,7 @@ import chalk from "chalk";
|
|
|
17
17
|
import { ensureCognitoIdToken } from "../utils/cognito-session.js";
|
|
18
18
|
import { getCompanyUid } from "../utils/vault-api.js";
|
|
19
19
|
import { IntegrationsCliError, bareProvider, fetchAdminSurface, printJson, resolveConnection, selectConnection, } from "./integrations-core.js";
|
|
20
|
-
import { getConnectionAccess, mutateConnectionAccess, uninstallIntegration, updateGovernance, } from "./integrations-api.js";
|
|
20
|
+
import { fetchPendingApprovals, getConnectionAccess, mutateConnectionAccess, uninstallIntegration, updateGovernance, } from "./integrations-api.js";
|
|
21
21
|
const WRITE_POLICIES = ["auto-allow", "confirm", "deny"];
|
|
22
22
|
const PERMISSIONS = ["read", "write", "admin"];
|
|
23
23
|
/** Plain-English gloss for each write policy, used in every policy readout. */
|
|
@@ -99,7 +99,11 @@ function printAccess(access) {
|
|
|
99
99
|
console.log(`${chalk.bold(access.provider)} ${chalk.dim(`shared: ${mode}`)}`);
|
|
100
100
|
console.log(chalk.dim(` connected by ${access.creator.name ?? access.creator.uid}`));
|
|
101
101
|
if (access.entries.length === 0) {
|
|
102
|
-
|
|
102
|
+
// "Individual shares" — not "grants" — so this never reads as contradicting
|
|
103
|
+
// `hq integrations grants`. This list is who the connection is SHARED with
|
|
104
|
+
// (who may use the app at all); `grants` is the separate per-tool
|
|
105
|
+
// authorization for change-making tools.
|
|
106
|
+
console.log(chalk.dim(" No individual shares (per-tool authorization is `hq integrations grants`)."));
|
|
103
107
|
}
|
|
104
108
|
for (const entry of access.entries) {
|
|
105
109
|
console.log(` ${describePrincipal(entry)} ${chalk.dim(entry.permission)}`);
|
|
@@ -200,7 +204,7 @@ export function registerManageCommands(integrations) {
|
|
|
200
204
|
});
|
|
201
205
|
integrations
|
|
202
206
|
.command("grants [app]")
|
|
203
|
-
.description("Show who
|
|
207
|
+
.description("Show who is authorized to run an app's change-making tools")
|
|
204
208
|
.option("--company <slug>", "Company slug")
|
|
205
209
|
.option("--provider <slug>", "Connected app (e.g. linear)")
|
|
206
210
|
.option("--connection <id>", "Connection id (acct_…)")
|
|
@@ -215,7 +219,7 @@ export function registerManageCommands(integrations) {
|
|
|
215
219
|
return;
|
|
216
220
|
}
|
|
217
221
|
if (grants.length === 0) {
|
|
218
|
-
console.log("No per-tool
|
|
222
|
+
console.log("No per-tool authorization set — the app's overall sharing decides who may run its tools.");
|
|
219
223
|
return;
|
|
220
224
|
}
|
|
221
225
|
for (const grant of grants) {
|
|
@@ -224,6 +228,15 @@ export function registerManageCommands(integrations) {
|
|
|
224
228
|
console.log(` ${describePrincipal(entry)} ${chalk.dim(entry.permission)}`);
|
|
225
229
|
}
|
|
226
230
|
}
|
|
231
|
+
// Authorization is not approval. A grant here says WHO may invoke the
|
|
232
|
+
// tool; whether that invocation runs immediately or waits for an owner is
|
|
233
|
+
// the connection's `policy` (confirm vs auto-allow). Saying so stops this
|
|
234
|
+
// list from reading as "these people bypass approval" — they don't unless
|
|
235
|
+
// the policy is auto-allow.
|
|
236
|
+
const policy = connection.writePolicy;
|
|
237
|
+
console.log(chalk.dim(policy === "auto-allow"
|
|
238
|
+
? "\nApproval: this app is set to auto-allow, so authorized calls run immediately (`hq integrations policy`)."
|
|
239
|
+
: "\nApproval is separate: authorized change-making calls still follow this app's policy (`hq integrations policy`)."));
|
|
227
240
|
});
|
|
228
241
|
/*
|
|
229
242
|
* grant / ungrant are read-modify-write: hq-pro's PATCH REPLACES the whole
|
|
@@ -231,11 +244,11 @@ export function registerManageCommands(integrations) {
|
|
|
231
244
|
*/
|
|
232
245
|
integrations
|
|
233
246
|
.command("grant [app]")
|
|
234
|
-
.description("
|
|
247
|
+
.description("Authorize someone to run one of an app's change-making tools")
|
|
235
248
|
.option("--company <slug>", "Company slug")
|
|
236
249
|
.option("--provider <slug>", "Connected app (e.g. linear)")
|
|
237
250
|
.option("--connection <id>", "Connection id (acct_…)")
|
|
238
|
-
.requiredOption("--tool <name>", "The tool to
|
|
251
|
+
.requiredOption("--tool <name>", "The tool to authorize")
|
|
239
252
|
.requiredOption("--to <principal>", "Email, uid, or `everyone`")
|
|
240
253
|
.option("--permission <level>", "read, write, or admin", "write")
|
|
241
254
|
.option("--json", "Machine-readable output")
|
|
@@ -266,15 +279,15 @@ export function registerManageCommands(integrations) {
|
|
|
266
279
|
printJson(result);
|
|
267
280
|
return;
|
|
268
281
|
}
|
|
269
|
-
console.log(chalk.green(`${describePrincipal({ ...principal, granteeId: principal.granteeId ?? "" })}
|
|
282
|
+
console.log(chalk.green(`${describePrincipal({ ...principal, granteeId: principal.granteeId ?? "" })} is now authorized to run ${toolName} on ${connectionLabel(connection)}. Whether it needs approval still follows the app's policy.`));
|
|
270
283
|
});
|
|
271
284
|
integrations
|
|
272
285
|
.command("ungrant [app]")
|
|
273
|
-
.description("Remove a per-tool
|
|
286
|
+
.description("Remove a per-tool authorization, leaving only the app's default access")
|
|
274
287
|
.option("--company <slug>", "Company slug")
|
|
275
288
|
.option("--provider <slug>", "Connected app (e.g. linear)")
|
|
276
289
|
.option("--connection <id>", "Connection id (acct_…)")
|
|
277
|
-
.requiredOption("--tool <name>", "The tool to stop
|
|
290
|
+
.requiredOption("--tool <name>", "The tool to stop authorizing")
|
|
278
291
|
.requiredOption("--from <principal>", "Email, uid, or `everyone`")
|
|
279
292
|
.option("--json", "Machine-readable output")
|
|
280
293
|
.action(async (app, opts) => {
|
|
@@ -289,14 +302,14 @@ export function registerManageCommands(integrations) {
|
|
|
289
302
|
const current = connection.writeAllowlist ?? [];
|
|
290
303
|
const merged = removeGrant(current, opts.tool.trim(), principal);
|
|
291
304
|
if (merged === null) {
|
|
292
|
-
throw new IntegrationsCliError(`No
|
|
305
|
+
throw new IntegrationsCliError(`No per-tool authorization for ${opts.from} on ${opts.tool} — nothing to remove.`, { expected: true });
|
|
293
306
|
}
|
|
294
307
|
const result = await patchAllowlistIfUnchanged(token, companyUid, connection.id, current, merged);
|
|
295
308
|
if (opts.json) {
|
|
296
309
|
printJson(result);
|
|
297
310
|
return;
|
|
298
311
|
}
|
|
299
|
-
console.log(chalk.green(`${opts.tool} on ${connectionLabel(connection)}
|
|
312
|
+
console.log(chalk.green(`${opts.tool} on ${connectionLabel(connection)} no longer has a per-tool authorization.`));
|
|
300
313
|
});
|
|
301
314
|
integrations
|
|
302
315
|
.command("access [app]")
|
|
@@ -422,26 +435,36 @@ export function registerManageCommands(integrations) {
|
|
|
422
435
|
.action(async (opts) => {
|
|
423
436
|
const token = await ensureCognitoIdToken();
|
|
424
437
|
const companyUid = await getCompanyUid(token, opts.company);
|
|
425
|
-
|
|
426
|
-
|
|
438
|
+
// Live confirm-queue state — a call already approved, rejected, or expired
|
|
439
|
+
// is gone from this list, unlike the old audit-feed reconstruction which
|
|
440
|
+
// could only show that a call was once queued.
|
|
441
|
+
let pending;
|
|
442
|
+
try {
|
|
443
|
+
pending = await fetchPendingApprovals(token, companyUid);
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
// This CLI can ship ahead of the hq-pro deploy that adds the pending
|
|
447
|
+
// route (the two land as separate PRs). A 404 means "backend too old",
|
|
448
|
+
// not a real failure — say so plainly instead of a stack trace.
|
|
449
|
+
if (err instanceof IntegrationsCliError && err.status === 404) {
|
|
450
|
+
throw new IntegrationsCliError("This HQ backend doesn't support the live pending list yet. Approvals still work — an owner gets the approve command when a call is queued.", { expected: true });
|
|
451
|
+
}
|
|
452
|
+
throw err;
|
|
453
|
+
}
|
|
427
454
|
if (opts.json) {
|
|
428
|
-
printJson(
|
|
455
|
+
printJson(pending);
|
|
429
456
|
return;
|
|
430
457
|
}
|
|
431
|
-
if (
|
|
458
|
+
if (pending.length === 0) {
|
|
432
459
|
console.log("Nothing is waiting for approval.");
|
|
433
460
|
return;
|
|
434
461
|
}
|
|
435
|
-
for (const row of
|
|
436
|
-
const who = row.
|
|
437
|
-
|
|
462
|
+
for (const row of pending) {
|
|
463
|
+
const who = row.requestedByName ?? row.requestedBy;
|
|
464
|
+
const label = row.requestedTool ?? row.toolName;
|
|
465
|
+
console.log(`${chalk.bold(label)} ${chalk.dim(`${who} · ${row.createdAt}`)}`);
|
|
438
466
|
console.log(chalk.dim(` hq integrations approve ${row.queueId}${row.provider ? ` --provider ${bareProvider(row.provider)}` : ""}`));
|
|
439
467
|
}
|
|
440
|
-
// hq-pro exposes no "list open queue entries" route, so this is derived
|
|
441
|
-
// from the activity feed — and the feed records the QUEUING, never the
|
|
442
|
-
// later decision. Saying so is the difference between a useful list and
|
|
443
|
-
// a misleading one.
|
|
444
|
-
console.log(chalk.dim("\nDerived from recent activity — an entry already approved, rejected, or expired may still appear here."));
|
|
445
468
|
});
|
|
446
469
|
integrations
|
|
447
470
|
.command("disconnect [app]")
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*
|
|
26
26
|
* Govern and remove:
|
|
27
27
|
* hq integrations policy [app] --set <m> Approval setting for changes.
|
|
28
|
-
* hq integrations grants|grant|ungrant Per-tool
|
|
28
|
+
* hq integrations grants|grant|ungrant Per-tool authorization to run change-making tools.
|
|
29
29
|
* hq integrations access|share|unshare Who may use the app.
|
|
30
30
|
* hq integrations audit Recent activity.
|
|
31
31
|
* hq integrations disconnect [app] Remove it and its credentials.
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*
|
|
26
26
|
* Govern and remove:
|
|
27
27
|
* hq integrations policy [app] --set <m> Approval setting for changes.
|
|
28
|
-
* hq integrations grants|grant|ungrant Per-tool
|
|
28
|
+
* hq integrations grants|grant|ungrant Per-tool authorization to run change-making tools.
|
|
29
29
|
* hq integrations access|share|unshare Who may use the app.
|
|
30
30
|
* hq integrations audit Recent activity.
|
|
31
31
|
* hq integrations disconnect [app] Remove it and its credentials.
|
|
@@ -47,7 +47,7 @@ import { randomUUID } from "node:crypto";
|
|
|
47
47
|
import chalk from "chalk";
|
|
48
48
|
import { ensureCognitoIdToken } from "../utils/cognito-session.js";
|
|
49
49
|
import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
|
|
50
|
-
import { IntegrationsCliError, bareProvider, callGateway, fetchConnections, isClientError, printJson, raiseIfUnauthorized, raiseIfUpstreamUnavailable, selectConnection, toolPrefixForProvider, unwrapGatewayResult, queuedOutcome, } from "./integrations-core.js";
|
|
50
|
+
import { IntegrationsCliError, bareProvider, callGateway, fetchConnections, isClientError, printJson, raiseIfUnauthorized, raiseIfUpstreamUnavailable, gatewayResultIsError, selectConnection, toolPrefixForProvider, unwrapGatewayResult, queuedOutcome, } from "./integrations-core.js";
|
|
51
51
|
import { registerConnectCommands } from "./integrations-connect.js";
|
|
52
52
|
import { registerManageCommands } from "./integrations-manage.js";
|
|
53
53
|
export { IntegrationsCliError, callGateway, fetchConnections, queuedOutcome, selectConnection, toolPrefixForProvider, unwrapGatewayResult, } from "./integrations-core.js";
|
|
@@ -169,7 +169,15 @@ export function registerIntegrationsCommand(program) {
|
|
|
169
169
|
}
|
|
170
170
|
return;
|
|
171
171
|
}
|
|
172
|
+
// A tool that ran but FAILED (`isError: true`) still has its payload
|
|
173
|
+
// printed — the error text is the useful part — but the command exits
|
|
174
|
+
// non-zero so a script or agent can tell failure from success without
|
|
175
|
+
// parsing output. The flag is on the raw gateway result, not the
|
|
176
|
+
// unwrapped payload.
|
|
172
177
|
printJson(payload);
|
|
178
|
+
if (gatewayResultIsError(message.result)) {
|
|
179
|
+
process.exitCode = 1;
|
|
180
|
+
}
|
|
173
181
|
});
|
|
174
182
|
for (const decision of ["approve", "reject"]) {
|
|
175
183
|
integrations
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* performs the update and re-runs the command, and only warns when the
|
|
10
10
|
* update itself fails. Installs quietly (the package manager's stdout is
|
|
11
11
|
* captured, never forwarded) because `--json` consumers parse ours.
|
|
12
|
+
* Attended callers wait for the install and get the re-exec; unattended
|
|
13
|
+
* ones (hooks, scripts, agents, cron) do not update at all — see below.
|
|
12
14
|
*
|
|
13
15
|
* - `selfUpdateAndReexec` — `hq rescue`. The rescue script ships inside this
|
|
14
16
|
* install's `@indigoai-us/hq-cloud` dependency, so a stale CLI runs a stale
|
|
@@ -28,6 +30,29 @@
|
|
|
28
30
|
* command on the current version. Self-updating must never make a command less
|
|
29
31
|
* available than it was before.
|
|
30
32
|
*
|
|
33
|
+
* Surviving a killed parent — the reason this file spawns the way it does.
|
|
34
|
+
* A global install rewrites the one copy of the CLI on the machine and is NOT
|
|
35
|
+
* atomic: killed part-way through, it leaves a store the `hq` shim resolves
|
|
36
|
+
* into but cannot load, and every later invocation dies with MODULE_NOT_FOUND
|
|
37
|
+
* until a human reinstalls. Three properties prevent that:
|
|
38
|
+
*
|
|
39
|
+
* 1. Unattended callers do not update at all. They are the ones carrying
|
|
40
|
+
* deadlines they cannot control, their own command may still need files
|
|
41
|
+
* from the package an install would replace, and with no terminal to
|
|
42
|
+
* return to the re-exec is pointless. `onlyWhenAttended` on the flavor.
|
|
43
|
+
* 2. Every install spawn is `detached`, so it runs in its own process group
|
|
44
|
+
* and a signal aimed at the CLI's group — Ctrl-C, a hangup, a deadline
|
|
45
|
+
* runner — cannot reach the package manager mid-write.
|
|
46
|
+
* 3. Install output goes to descriptors that outlive this process, never to
|
|
47
|
+
* pipes we own, so a killed CLI cannot take the installer down by EPIPE.
|
|
48
|
+
* `openInstallOutput` in version-gate.ts.
|
|
49
|
+
*
|
|
50
|
+
* (2) and (3) are separate holes and both must be closed: the process group
|
|
51
|
+
* governs signals, the descriptors govern I/O, and either one alone still
|
|
52
|
+
* leaves a way to kill an install half-applied.
|
|
53
|
+
*
|
|
54
|
+
* Covered by `self-update-kill-resistance.test.ts`.
|
|
55
|
+
*
|
|
31
56
|
* Opt-outs: `HQ_NO_UPDATE_CHECK=1` (the shared knob that also silences
|
|
32
57
|
* version-check), `hq rescue --no-self-update`, and the re-exec guard env.
|
|
33
58
|
*/
|
|
@@ -47,7 +72,13 @@ export type SelfUpdateAction =
|
|
|
47
72
|
/** Updated, but the re-exec couldn't start; continue on the current (in-memory) version. */
|
|
48
73
|
| "updated-no-reexec"
|
|
49
74
|
/** Updated and the command re-ran on the new version; exit with `reexecStatus`. */
|
|
50
|
-
| "reexec"
|
|
75
|
+
| "reexec"
|
|
76
|
+
/**
|
|
77
|
+
* Unattended caller: replacing the CLI in place is unsafe here, so nothing
|
|
78
|
+
* was attempted and the command runs on the current version. An attended
|
|
79
|
+
* session, the desktop app's installer, or the hard version gate updates it.
|
|
80
|
+
*/
|
|
81
|
+
| "deferred";
|
|
51
82
|
export interface SelfUpdateOutcome {
|
|
52
83
|
action: SelfUpdateAction;
|
|
53
84
|
/** Exit status of the re-exec'd `hq …` (action === "reexec"). */
|
|
@@ -93,6 +124,12 @@ export interface SelfUpdateDeps {
|
|
|
93
124
|
runner?: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
|
|
94
125
|
reexec?: (argv: string[], env: NodeJS.ProcessEnv) => number | null;
|
|
95
126
|
acquireLock?: () => (() => void) | null;
|
|
127
|
+
/**
|
|
128
|
+
* Whether a human is watching this invocation. Defaults to "stderr is a TTY",
|
|
129
|
+
* which is false for exactly the callers that must not replace the CLI
|
|
130
|
+
* underneath themselves: hooks, scripts, agents, cron, CI.
|
|
131
|
+
*/
|
|
132
|
+
interactive?: boolean;
|
|
96
133
|
}
|
|
97
134
|
/**
|
|
98
135
|
* Startup path: the running CLI is behind npm `latest`, so update in place and
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* performs the update and re-runs the command, and only warns when the
|
|
10
10
|
* update itself fails. Installs quietly (the package manager's stdout is
|
|
11
11
|
* captured, never forwarded) because `--json` consumers parse ours.
|
|
12
|
+
* Attended callers wait for the install and get the re-exec; unattended
|
|
13
|
+
* ones (hooks, scripts, agents, cron) do not update at all — see below.
|
|
12
14
|
*
|
|
13
15
|
* - `selfUpdateAndReexec` — `hq rescue`. The rescue script ships inside this
|
|
14
16
|
* install's `@indigoai-us/hq-cloud` dependency, so a stale CLI runs a stale
|
|
@@ -28,6 +30,29 @@
|
|
|
28
30
|
* command on the current version. Self-updating must never make a command less
|
|
29
31
|
* available than it was before.
|
|
30
32
|
*
|
|
33
|
+
* Surviving a killed parent — the reason this file spawns the way it does.
|
|
34
|
+
* A global install rewrites the one copy of the CLI on the machine and is NOT
|
|
35
|
+
* atomic: killed part-way through, it leaves a store the `hq` shim resolves
|
|
36
|
+
* into but cannot load, and every later invocation dies with MODULE_NOT_FOUND
|
|
37
|
+
* until a human reinstalls. Three properties prevent that:
|
|
38
|
+
*
|
|
39
|
+
* 1. Unattended callers do not update at all. They are the ones carrying
|
|
40
|
+
* deadlines they cannot control, their own command may still need files
|
|
41
|
+
* from the package an install would replace, and with no terminal to
|
|
42
|
+
* return to the re-exec is pointless. `onlyWhenAttended` on the flavor.
|
|
43
|
+
* 2. Every install spawn is `detached`, so it runs in its own process group
|
|
44
|
+
* and a signal aimed at the CLI's group — Ctrl-C, a hangup, a deadline
|
|
45
|
+
* runner — cannot reach the package manager mid-write.
|
|
46
|
+
* 3. Install output goes to descriptors that outlive this process, never to
|
|
47
|
+
* pipes we own, so a killed CLI cannot take the installer down by EPIPE.
|
|
48
|
+
* `openInstallOutput` in version-gate.ts.
|
|
49
|
+
*
|
|
50
|
+
* (2) and (3) are separate holes and both must be closed: the process group
|
|
51
|
+
* governs signals, the descriptors govern I/O, and either one alone still
|
|
52
|
+
* leaves a way to kill an install half-applied.
|
|
53
|
+
*
|
|
54
|
+
* Covered by `self-update-kill-resistance.test.ts`.
|
|
55
|
+
*
|
|
31
56
|
* Opt-outs: `HQ_NO_UPDATE_CHECK=1` (the shared knob that also silences
|
|
32
57
|
* version-check), `hq rescue --no-self-update`, and the re-exec guard env.
|
|
33
58
|
*/
|
|
@@ -38,7 +63,7 @@ import * as path from "node:path";
|
|
|
38
63
|
import semver from "semver";
|
|
39
64
|
import chalk from "chalk";
|
|
40
65
|
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
41
|
-
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
|
|
66
|
+
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, inOwnProcessGroup, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
|
|
42
67
|
/**
|
|
43
68
|
* Set on the re-exec'd child so it can never self-update (and re-exec) again.
|
|
44
69
|
* One update + one re-exec per user invocation, ever.
|
|
@@ -89,21 +114,24 @@ export function buildSelfUpdatePlan(install) {
|
|
|
89
114
|
* stderr is kept only to explain a failure.
|
|
90
115
|
*/
|
|
91
116
|
export function runUpdateQuiet(cmd, args, env) {
|
|
117
|
+
// File sinks rather than pipes: a pipe dies with this process and would take
|
|
118
|
+
// the installer down with it mid-write. See `openInstallOutput`.
|
|
119
|
+
const output = openInstallOutput(false);
|
|
92
120
|
try {
|
|
93
121
|
const plan = buildSpawnPlan(cmd, args);
|
|
94
|
-
|
|
95
|
-
|
|
122
|
+
// `inOwnProcessGroup`: the install must survive a signal aimed at this
|
|
123
|
+
// process, or a killed `hq` leaves a corrupted global install behind.
|
|
124
|
+
const result = spawnSync(plan.cmd, plan.args, inOwnProcessGroup({
|
|
125
|
+
stdio: output.stdio,
|
|
96
126
|
shell: plan.shell,
|
|
97
|
-
encoding: "utf-8",
|
|
98
127
|
...(env ? { env } : {}),
|
|
99
|
-
});
|
|
128
|
+
}));
|
|
100
129
|
if (result.error) {
|
|
101
130
|
const code = result.error.code;
|
|
102
131
|
return { ok: false, code, detail: result.error.message };
|
|
103
132
|
}
|
|
104
133
|
if (result.status !== 0) {
|
|
105
|
-
const
|
|
106
|
-
const tail = stderr ? stderr.slice(-DETAIL_MAX_CHARS) : "";
|
|
134
|
+
const tail = output.stderrTail().slice(-DETAIL_MAX_CHARS);
|
|
107
135
|
return {
|
|
108
136
|
ok: false,
|
|
109
137
|
detail: tail || `exit ${result.status ?? "signal"}`,
|
|
@@ -118,6 +146,9 @@ export function runUpdateQuiet(cmd, args, env) {
|
|
|
118
146
|
detail: err instanceof Error ? err.message : String(err),
|
|
119
147
|
};
|
|
120
148
|
}
|
|
149
|
+
finally {
|
|
150
|
+
output.dispose();
|
|
151
|
+
}
|
|
121
152
|
}
|
|
122
153
|
function lockDir() {
|
|
123
154
|
return path.join(os.homedir(), ".hq", "self-update.lock");
|
|
@@ -195,6 +226,32 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
195
226
|
return { action: "skipped" };
|
|
196
227
|
if (!semver.gt(latestValid, current))
|
|
197
228
|
return { action: "current", latest };
|
|
229
|
+
// Replacing the CLI in place is only safe when someone is at a terminal.
|
|
230
|
+
//
|
|
231
|
+
// A global install rewrites the very package this process is running from,
|
|
232
|
+
// and an unattended `hq` is the worst possible moment for that:
|
|
233
|
+
//
|
|
234
|
+
// - It carries a deadline it does not control (a Claude Code hook capped at
|
|
235
|
+
// `"timeout": 5`, a harness Bash deadline, cron), so waiting out a
|
|
236
|
+
// tens-of-seconds install means being killed part-way through one.
|
|
237
|
+
// - Its own command may still need files from the installed package.
|
|
238
|
+
// `resolveBundledAsset` reads `assets/scaffold/**` from the package root
|
|
239
|
+
// at call time, so an install overlapping the command can make it fail
|
|
240
|
+
// with "Bundled asset is missing" or mix new assets with old in-memory
|
|
241
|
+
// code. Running the install alongside the command is not an option, and
|
|
242
|
+
// nothing here can know when the command will finish.
|
|
243
|
+
// - Nobody benefits: with no terminal to return to, the re-exec is pointless
|
|
244
|
+
// and the "Updating…" line is noise in output someone else is parsing.
|
|
245
|
+
//
|
|
246
|
+
// So an unattended caller does not update; it runs the command it was asked
|
|
247
|
+
// to run. Updates land from an attended session, from the desktop app's
|
|
248
|
+
// background installer (which owns this for unattended machines, retries, and
|
|
249
|
+
// can repair a CLI that no longer loads), or from the hq-pro hard gate when a
|
|
250
|
+
// version is genuinely not allowed to run.
|
|
251
|
+
const interactive = deps.interactive ?? process.stderr.isTTY === true;
|
|
252
|
+
if (!interactive && flavor.onlyWhenAttended) {
|
|
253
|
+
return { action: "deferred", latest };
|
|
254
|
+
}
|
|
198
255
|
const releaseLock = flavor.lock ? (deps.acquireLock ?? acquireUpdateLock)() : () => { };
|
|
199
256
|
if (!releaseLock)
|
|
200
257
|
return { action: "skipped", latest };
|
|
@@ -251,7 +308,7 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
251
308
|
* this resolve it from the registry.
|
|
252
309
|
*/
|
|
253
310
|
export async function autoUpdateAndReexec(argv, latest, deps = {}) {
|
|
254
|
-
return updateAndReexec(argv, { noun: "command", verbose: false, lock: true }, latest, deps);
|
|
311
|
+
return updateAndReexec(argv, { noun: "command", verbose: false, lock: true, onlyWhenAttended: true }, latest, deps);
|
|
255
312
|
}
|
|
256
313
|
/**
|
|
257
314
|
* Rescue path: update to npm latest and re-exec the rescue on the new version.
|
|
@@ -260,6 +317,6 @@ export async function autoUpdateAndReexec(argv, latest, deps = {}) {
|
|
|
260
317
|
* are preserved.
|
|
261
318
|
*/
|
|
262
319
|
export async function selfUpdateAndReexec(argv, deps = {}) {
|
|
263
|
-
return updateAndReexec(argv, { noun: "rescue", verbose: true, lock: true }, null, deps);
|
|
320
|
+
return updateAndReexec(argv, { noun: "rescue", verbose: true, lock: true, onlyWhenAttended: false }, null, deps);
|
|
264
321
|
}
|
|
265
322
|
//# sourceMappingURL=self-update.js.map
|
|
@@ -181,6 +181,57 @@ export type UpdateResult = {
|
|
|
181
181
|
};
|
|
182
182
|
type UpdateRunner = (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
|
|
183
183
|
export { buildSpawnPlan, quoteForWindowsShell };
|
|
184
|
+
/**
|
|
185
|
+
* Add `detached` to a **synchronous** spawn's options.
|
|
186
|
+
*
|
|
187
|
+
* Why a helper rather than the property inline: Node implements `detached` in
|
|
188
|
+
* its sync spawn path (`spawn_sync.cc` maps it onto libuv's
|
|
189
|
+
* `UV_PROCESS_DETACHED`, exactly as the async path does), but `@types/node`
|
|
190
|
+
* declares the flag only on the async `SpawnOptions`. This function is that
|
|
191
|
+
* one type gap, isolated and explained in a single place instead of a cast
|
|
192
|
+
* repeated at each call site.
|
|
193
|
+
*
|
|
194
|
+
* It is load-bearing rather than cosmetic. A global install rewrites the only
|
|
195
|
+
* copy of the CLI on the machine and is not atomic, so a package manager that
|
|
196
|
+
* shares the CLI's process group is destroyed mid-write whenever something
|
|
197
|
+
* signals that group — a Claude Code hook with `"timeout": 5`, a harness
|
|
198
|
+
* deadline, cron, or a plain Ctrl-C — leaving an install that resolves but
|
|
199
|
+
* cannot load (MODULE_NOT_FOUND) until a human reinstalls by hand.
|
|
200
|
+
*
|
|
201
|
+
* Because the guarantee rests on behaviour the types do not describe, it is
|
|
202
|
+
* pinned by a runtime test (`self-update-kill-resistance.test.ts`) that asserts
|
|
203
|
+
* the spawned child really is its own process-group leader. If a future Node
|
|
204
|
+
* stops honouring the flag here, that test fails loudly instead of the
|
|
205
|
+
* protection silently disappearing.
|
|
206
|
+
*/
|
|
207
|
+
export declare function inOwnProcessGroup<T extends object>(options: T): T;
|
|
208
|
+
/**
|
|
209
|
+
* Where an install's output goes — the other half of surviving a killed parent.
|
|
210
|
+
*
|
|
211
|
+
* `inOwnProcessGroup` stops a signal from reaching the package manager, but it
|
|
212
|
+
* does nothing about file descriptors, and a PIPE is owned by this process. If
|
|
213
|
+
* we are killed mid-install the read ends close, and the package manager takes
|
|
214
|
+
* `EPIPE`/`SIGPIPE` on its next write — dying part-way through rewriting the
|
|
215
|
+
* global store, which is exactly the corruption the process group was meant to
|
|
216
|
+
* prevent. Pipe lifetime is not a process-group property, so both fixes are
|
|
217
|
+
* needed.
|
|
218
|
+
*
|
|
219
|
+
* A file descriptor onto a temp file has no such coupling: it stays valid after
|
|
220
|
+
* we are gone, and the installer runs to completion regardless.
|
|
221
|
+
*
|
|
222
|
+
* `inherit` is safe ONLY when our own streams are a terminal, because a
|
|
223
|
+
* terminal outlives us too. Inheriting a *pipe* (a hook, a captured harness
|
|
224
|
+
* invocation, cron) has the identical failure as a pipe we opened ourselves, so
|
|
225
|
+
* the verbose path takes the file sink there and gives up live output that
|
|
226
|
+
* nothing was reading anyway.
|
|
227
|
+
*/
|
|
228
|
+
export interface InstallOutput {
|
|
229
|
+
stdio: ("ignore" | "inherit" | number)[];
|
|
230
|
+
/** Captured stderr tail, or "" when output was inherited by a terminal. */
|
|
231
|
+
stderrTail: () => string;
|
|
232
|
+
dispose: () => void;
|
|
233
|
+
}
|
|
234
|
+
export declare function openInstallOutput(verbose: boolean, isTty?: boolean): InstallOutput;
|
|
184
235
|
export declare function runUpdateCommand(cmd: string, args: string[], env?: NodeJS.ProcessEnv): UpdateResult;
|
|
185
236
|
declare function performUpdateCommand(cmd: string, args: string[], runner?: UpdateRunner, env?: NodeJS.ProcessEnv): UpdateResult;
|
|
186
237
|
declare function performUpdate(command: string, runner?: UpdateRunner): UpdateResult;
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { spawnSync } from "node:child_process";
|
|
31
31
|
import { buildSpawnPlan, quoteForWindowsShell } from "./windows-spawn.js";
|
|
32
|
-
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
32
|
+
import { closeSync, existsSync, mkdtempSync, openSync, readdirSync, readFileSync, rmSync, } from "node:fs";
|
|
33
|
+
import os from "node:os";
|
|
33
34
|
import path from "node:path";
|
|
34
35
|
import { fileURLToPath } from "node:url";
|
|
35
36
|
import chalk from "chalk";
|
|
@@ -352,23 +353,95 @@ async function fetchVersionDecision() {
|
|
|
352
353
|
// public surface (and the `__test__` block below) stable for existing callers
|
|
353
354
|
// and tests.
|
|
354
355
|
export { buildSpawnPlan, quoteForWindowsShell };
|
|
356
|
+
/**
|
|
357
|
+
* Add `detached` to a **synchronous** spawn's options.
|
|
358
|
+
*
|
|
359
|
+
* Why a helper rather than the property inline: Node implements `detached` in
|
|
360
|
+
* its sync spawn path (`spawn_sync.cc` maps it onto libuv's
|
|
361
|
+
* `UV_PROCESS_DETACHED`, exactly as the async path does), but `@types/node`
|
|
362
|
+
* declares the flag only on the async `SpawnOptions`. This function is that
|
|
363
|
+
* one type gap, isolated and explained in a single place instead of a cast
|
|
364
|
+
* repeated at each call site.
|
|
365
|
+
*
|
|
366
|
+
* It is load-bearing rather than cosmetic. A global install rewrites the only
|
|
367
|
+
* copy of the CLI on the machine and is not atomic, so a package manager that
|
|
368
|
+
* shares the CLI's process group is destroyed mid-write whenever something
|
|
369
|
+
* signals that group — a Claude Code hook with `"timeout": 5`, a harness
|
|
370
|
+
* deadline, cron, or a plain Ctrl-C — leaving an install that resolves but
|
|
371
|
+
* cannot load (MODULE_NOT_FOUND) until a human reinstalls by hand.
|
|
372
|
+
*
|
|
373
|
+
* Because the guarantee rests on behaviour the types do not describe, it is
|
|
374
|
+
* pinned by a runtime test (`self-update-kill-resistance.test.ts`) that asserts
|
|
375
|
+
* the spawned child really is its own process-group leader. If a future Node
|
|
376
|
+
* stops honouring the flag here, that test fails loudly instead of the
|
|
377
|
+
* protection silently disappearing.
|
|
378
|
+
*/
|
|
379
|
+
export function inOwnProcessGroup(options) {
|
|
380
|
+
return { ...options, detached: true };
|
|
381
|
+
}
|
|
382
|
+
/** Tail of captured installer stderr kept to explain a failure. */
|
|
383
|
+
const INSTALL_DETAIL_MAX_CHARS = 400;
|
|
384
|
+
export function openInstallOutput(verbose, isTty = process.stdout.isTTY === true && process.stderr.isTTY === true) {
|
|
385
|
+
if (verbose && isTty) {
|
|
386
|
+
return {
|
|
387
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
388
|
+
stderrTail: () => "",
|
|
389
|
+
dispose: () => { },
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "hq-cli-install-"));
|
|
393
|
+
const errPath = path.join(dir, "stderr.log");
|
|
394
|
+
const out = openSync(path.join(dir, "stdout.log"), "a");
|
|
395
|
+
const err = openSync(errPath, "a");
|
|
396
|
+
return {
|
|
397
|
+
stdio: ["ignore", out, err],
|
|
398
|
+
stderrTail: () => {
|
|
399
|
+
try {
|
|
400
|
+
return readFileSync(errPath, "utf-8").trim().slice(-INSTALL_DETAIL_MAX_CHARS);
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
return "";
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
dispose: () => {
|
|
407
|
+
for (const fd of [out, err]) {
|
|
408
|
+
try {
|
|
409
|
+
closeSync(fd);
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
// Already closed; nothing to recover.
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
rmSync(dir, { recursive: true, force: true });
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
// Best-effort cleanup of a temp dir.
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|
|
355
424
|
export function runUpdateCommand(cmd, args, env) {
|
|
425
|
+
const output = openInstallOutput(true);
|
|
356
426
|
try {
|
|
357
427
|
const plan = buildSpawnPlan(cmd, args);
|
|
358
|
-
const result = spawnSync(plan.cmd, plan.args, {
|
|
359
|
-
stdio:
|
|
428
|
+
const result = spawnSync(plan.cmd, plan.args, inOwnProcessGroup({
|
|
429
|
+
stdio: output.stdio,
|
|
360
430
|
shell: plan.shell,
|
|
361
431
|
...(env ? { env } : {}),
|
|
362
|
-
});
|
|
432
|
+
}));
|
|
363
433
|
// spawnSync reports a missing executable via `error`, not a throw.
|
|
364
434
|
if (result.error) {
|
|
365
435
|
const code = result.error.code;
|
|
366
436
|
return { ok: false, code, detail: result.error.message };
|
|
367
437
|
}
|
|
368
438
|
if (result.status !== 0) {
|
|
439
|
+
// When output went to a file rather than the user's terminal, its tail is
|
|
440
|
+
// the only explanation anyone will ever see for this failure.
|
|
441
|
+
const captured = output.stderrTail();
|
|
369
442
|
return {
|
|
370
443
|
ok: false,
|
|
371
|
-
detail: `exit ${result.status ?? "signal"}`,
|
|
444
|
+
detail: captured || `exit ${result.status ?? "signal"}`,
|
|
372
445
|
};
|
|
373
446
|
}
|
|
374
447
|
return { ok: true };
|
|
@@ -380,6 +453,9 @@ export function runUpdateCommand(cmd, args, env) {
|
|
|
380
453
|
detail: err instanceof Error ? err.message : String(err),
|
|
381
454
|
};
|
|
382
455
|
}
|
|
456
|
+
finally {
|
|
457
|
+
output.dispose();
|
|
458
|
+
}
|
|
383
459
|
}
|
|
384
460
|
function performUpdateCommand(cmd, args, runner = runUpdateCommand, env) {
|
|
385
461
|
// Forward env only when there is one to forward, so the common path keeps the
|