@indigoai-us/hq-cli 5.31.0 → 5.32.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/dist/commands/group-grants.d.ts +76 -0
- package/dist/commands/group-grants.js +296 -0
- package/dist/commands/pack-install.d.ts +46 -1
- package/dist/commands/pack-install.js +96 -17
- package/dist/commands/packs.d.ts +22 -0
- package/dist/commands/packs.js +406 -0
- package/dist/commands/pkg-list.js +31 -5
- package/dist/index.js +12 -2
- package/dist/utils/pack-contributions.d.ts +87 -0
- package/dist/utils/pack-contributions.js +239 -0
- package/package.json +1 -1
- package/src/commands/group-grants.test.ts +291 -0
- package/src/commands/group-grants.ts +452 -0
- package/src/commands/pack-install.ts +129 -21
- package/src/commands/packs.ts +524 -0
- package/src/commands/pkg-list.ts +34 -3
- package/src/index.ts +12 -0
- package/src/utils/pack-contributions.test.ts +208 -0
- package/src/utils/pack-contributions.ts +303 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
4
|
+
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
5
|
+
import { GROUP_ID_PATTERN } from "./_patterns.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `hq group-grants` — manage cross-company group access grants.
|
|
9
|
+
*
|
|
10
|
+
* A *group grant* lets a group defined in a SOURCE company be granted a role
|
|
11
|
+
* on a TARGET company. The hq-pro API authorizes the operation against the
|
|
12
|
+
* TARGET company (the caller must be able to invite into it). The CLI is a
|
|
13
|
+
* thin client — it passes the right params and surfaces the server's 403 as an
|
|
14
|
+
* actionable cross-tenant permission error. NO authorization is reimplemented
|
|
15
|
+
* here.
|
|
16
|
+
*
|
|
17
|
+
* Backend contract (hq-pro feature/agency-cross-company-group-grants):
|
|
18
|
+
* - POST /group-grants { groupId, sourceCompanyUid, targetCompanyUid, role }
|
|
19
|
+
* - POST /group-grants/revoke { groupId, sourceCompanyUid, targetCompanyUid }
|
|
20
|
+
* - GET /group-grants/outbound?sourceCompanyUid&groupId
|
|
21
|
+
* - GET /group-grants/inbound?companyUid
|
|
22
|
+
*
|
|
23
|
+
* All require a Cognito idToken bearer; 403 (code FORBIDDEN) on authz failure.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const VALID_GRANT_ROLES = new Set(["owner", "admin", "member", "guest"]);
|
|
27
|
+
|
|
28
|
+
export type GrantRole = "owner" | "admin" | "member" | "guest";
|
|
29
|
+
|
|
30
|
+
export interface GroupGrant {
|
|
31
|
+
groupId: string;
|
|
32
|
+
sourceCompanyUid: string;
|
|
33
|
+
targetCompanyUid: string;
|
|
34
|
+
role: string;
|
|
35
|
+
grantedBy?: string;
|
|
36
|
+
grantedAt?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface GrantGroupOptions {
|
|
40
|
+
groupId: string;
|
|
41
|
+
sourceCompanyUid: string;
|
|
42
|
+
targetCompanyUid: string;
|
|
43
|
+
role: string;
|
|
44
|
+
token: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RevokeGroupGrantOptions {
|
|
48
|
+
groupId: string;
|
|
49
|
+
sourceCompanyUid: string;
|
|
50
|
+
targetCompanyUid: string;
|
|
51
|
+
token: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Typed HTTP error mirroring members.ts's `InviteHttpError`. Carries the
|
|
56
|
+
* server status + optional error `code` (e.g. `FORBIDDEN`) so the command
|
|
57
|
+
* layer can render an actionable message and exit non-zero.
|
|
58
|
+
*/
|
|
59
|
+
export class GrantHttpError extends Error {
|
|
60
|
+
constructor(
|
|
61
|
+
public status: number,
|
|
62
|
+
message: string,
|
|
63
|
+
public code?: string,
|
|
64
|
+
) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = "GrantHttpError";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Map a `GrantHttpError` onto an actionable, human-readable message. The 403
|
|
72
|
+
* branch is the story's key cross-tenant case: authorization is enforced
|
|
73
|
+
* against the TARGET company, so the message names the target the caller
|
|
74
|
+
* lacks rights on.
|
|
75
|
+
*/
|
|
76
|
+
export function formatGrantHttpError(
|
|
77
|
+
status: number,
|
|
78
|
+
fallback: string,
|
|
79
|
+
ctx: { targetCompany?: string; code?: string } = {},
|
|
80
|
+
): string {
|
|
81
|
+
if (status === 401) return "Not authenticated — please run `hq login`";
|
|
82
|
+
if (status === 403) {
|
|
83
|
+
const target = ctx.targetCompany ? ` '${ctx.targetCompany}'` : "";
|
|
84
|
+
return (
|
|
85
|
+
`Permission denied: you must be an owner or admin of the target company${target} to grant a group into it. ` +
|
|
86
|
+
"The grant is authorized against the target company — ask an owner/admin there, or have them enable adminCanInvite."
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (status === 404) {
|
|
90
|
+
return `Not found: ${fallback}. Check the group id and that both companies exist.`;
|
|
91
|
+
}
|
|
92
|
+
if (status === 409) {
|
|
93
|
+
return `That grant already exists: ${fallback}`;
|
|
94
|
+
}
|
|
95
|
+
if (status >= 500) return `Server error: ${fallback}`;
|
|
96
|
+
return fallback;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function shortDate(iso: string): string {
|
|
100
|
+
return iso.slice(0, 10);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* POST /group-grants — grant a group's access to a target company at a role.
|
|
105
|
+
* Pure-ish: validates inputs, then calls the API client. Throws
|
|
106
|
+
* `GrantHttpError` on non-2xx so the command layer surfaces it.
|
|
107
|
+
*/
|
|
108
|
+
export async function grantGroup(options: GrantGroupOptions): Promise<GroupGrant> {
|
|
109
|
+
if (!GROUP_ID_PATTERN.test(options.groupId)) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Invalid group id '${options.groupId}': must match grp_<alphanumeric, underscore, hyphen>`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (!VALID_GRANT_ROLES.has(options.role)) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Invalid role '${options.role}': must be one of owner, admin, member, guest`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (options.sourceCompanyUid === options.targetCompanyUid) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
"Source and target companies are the same — a group grant must cross company boundaries.",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const res = await vaultApiFetch({
|
|
126
|
+
token: options.token,
|
|
127
|
+
path: "/group-grants",
|
|
128
|
+
method: "POST",
|
|
129
|
+
body: {
|
|
130
|
+
groupId: options.groupId,
|
|
131
|
+
sourceCompanyUid: options.sourceCompanyUid,
|
|
132
|
+
targetCompanyUid: options.targetCompanyUid,
|
|
133
|
+
role: options.role,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (!res.ok) {
|
|
138
|
+
const err = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
139
|
+
throw new GrantHttpError(
|
|
140
|
+
res.status,
|
|
141
|
+
err.message ?? err.error ?? res.statusText,
|
|
142
|
+
err.code,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const data = (await res.json()) as { grant?: GroupGrant };
|
|
147
|
+
return (
|
|
148
|
+
data.grant ?? {
|
|
149
|
+
groupId: options.groupId,
|
|
150
|
+
sourceCompanyUid: options.sourceCompanyUid,
|
|
151
|
+
targetCompanyUid: options.targetCompanyUid,
|
|
152
|
+
role: options.role,
|
|
153
|
+
}
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** POST /group-grants/revoke — remove a group's grant on a target company. */
|
|
158
|
+
export async function revokeGroupGrant(
|
|
159
|
+
options: RevokeGroupGrantOptions,
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
if (!GROUP_ID_PATTERN.test(options.groupId)) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`Invalid group id '${options.groupId}': must match grp_<alphanumeric, underscore, hyphen>`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const res = await vaultApiFetch({
|
|
168
|
+
token: options.token,
|
|
169
|
+
path: "/group-grants/revoke",
|
|
170
|
+
method: "POST",
|
|
171
|
+
body: {
|
|
172
|
+
groupId: options.groupId,
|
|
173
|
+
sourceCompanyUid: options.sourceCompanyUid,
|
|
174
|
+
targetCompanyUid: options.targetCompanyUid,
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
const err = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
180
|
+
throw new GrantHttpError(
|
|
181
|
+
res.status,
|
|
182
|
+
err.message ?? err.error ?? res.statusText,
|
|
183
|
+
err.code,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** GET /group-grants/outbound — grants a source company's group(s) hold. */
|
|
189
|
+
export async function listOutboundGrants(
|
|
190
|
+
token: string,
|
|
191
|
+
sourceCompanyUid: string,
|
|
192
|
+
groupId?: string,
|
|
193
|
+
): Promise<GroupGrant[]> {
|
|
194
|
+
const query: Record<string, string> = { sourceCompanyUid };
|
|
195
|
+
if (groupId) query.groupId = groupId;
|
|
196
|
+
|
|
197
|
+
const res = await vaultApiFetch({
|
|
198
|
+
token,
|
|
199
|
+
path: "/group-grants/outbound",
|
|
200
|
+
query,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (!res.ok) {
|
|
204
|
+
const err = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
205
|
+
throw new GrantHttpError(
|
|
206
|
+
res.status,
|
|
207
|
+
err.message ?? err.error ?? res.statusText,
|
|
208
|
+
err.code,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const data = (await res.json()) as { grants?: GroupGrant[] | null };
|
|
213
|
+
return data.grants ?? [];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** GET /group-grants/inbound — grants other companies' groups hold on us. */
|
|
217
|
+
export async function listInboundGrants(
|
|
218
|
+
token: string,
|
|
219
|
+
companyUid: string,
|
|
220
|
+
): Promise<GroupGrant[]> {
|
|
221
|
+
const res = await vaultApiFetch({
|
|
222
|
+
token,
|
|
223
|
+
path: "/group-grants/inbound",
|
|
224
|
+
query: { companyUid },
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
if (!res.ok) {
|
|
228
|
+
const err = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
229
|
+
throw new GrantHttpError(
|
|
230
|
+
res.status,
|
|
231
|
+
err.message ?? err.error ?? res.statusText,
|
|
232
|
+
err.code,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const data = (await res.json()) as { grants?: GroupGrant[] | null };
|
|
237
|
+
return data.grants ?? [];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function printGrantsTable(grants: GroupGrant[]): void {
|
|
241
|
+
const idW = Math.max(8, ...grants.map((g) => g.groupId.length));
|
|
242
|
+
const srcW = Math.max(6, ...grants.map((g) => g.sourceCompanyUid.length));
|
|
243
|
+
const tgtW = Math.max(6, ...grants.map((g) => g.targetCompanyUid.length));
|
|
244
|
+
const roleW = Math.max(4, ...grants.map((g) => g.role.length));
|
|
245
|
+
console.log(
|
|
246
|
+
chalk.bold(
|
|
247
|
+
[
|
|
248
|
+
"GROUP_ID".padEnd(idW),
|
|
249
|
+
"SOURCE".padEnd(srcW),
|
|
250
|
+
"TARGET".padEnd(tgtW),
|
|
251
|
+
"ROLE".padEnd(roleW),
|
|
252
|
+
"GRANTED_AT",
|
|
253
|
+
].join(" "),
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
for (const g of grants) {
|
|
257
|
+
console.log(
|
|
258
|
+
[
|
|
259
|
+
g.groupId.padEnd(idW),
|
|
260
|
+
g.sourceCompanyUid.padEnd(srcW),
|
|
261
|
+
g.targetCompanyUid.padEnd(tgtW),
|
|
262
|
+
g.role.padEnd(roleW),
|
|
263
|
+
g.grantedAt ? shortDate(g.grantedAt) : "",
|
|
264
|
+
].join(" "),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function registerGroupGrantsCommand(program: Command): void {
|
|
270
|
+
const grants = program
|
|
271
|
+
.command("group-grants")
|
|
272
|
+
.description(
|
|
273
|
+
"Grant a group's access to another company (cross-company), and revoke or inspect those grants",
|
|
274
|
+
)
|
|
275
|
+
.option(
|
|
276
|
+
"--company <slug>",
|
|
277
|
+
"Source company slug (the company that owns the group; resolves to sourceCompanyUid)",
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
grants
|
|
281
|
+
.command("grant <groupId> <targetCompany>")
|
|
282
|
+
.description(
|
|
283
|
+
"Grant a group (from the source company) access to <targetCompany> at a role",
|
|
284
|
+
)
|
|
285
|
+
.option(
|
|
286
|
+
"--role <role>",
|
|
287
|
+
"Role the group receives on the target company: owner, admin, member, or guest",
|
|
288
|
+
"member",
|
|
289
|
+
)
|
|
290
|
+
.action(
|
|
291
|
+
async (
|
|
292
|
+
groupId: string,
|
|
293
|
+
targetCompany: string,
|
|
294
|
+
opts: { role: string },
|
|
295
|
+
) => {
|
|
296
|
+
try {
|
|
297
|
+
const token = await ensureCognitoToken();
|
|
298
|
+
const sourceSlug = grants.opts().company as string | undefined;
|
|
299
|
+
const sourceCompanyUid = await getCompanyUid(token, sourceSlug);
|
|
300
|
+
const targetCompanyUid = await getCompanyUid(token, targetCompany);
|
|
301
|
+
|
|
302
|
+
const grant = await grantGroup({
|
|
303
|
+
groupId,
|
|
304
|
+
sourceCompanyUid,
|
|
305
|
+
targetCompanyUid,
|
|
306
|
+
role: opts.role,
|
|
307
|
+
token,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
console.log(
|
|
311
|
+
chalk.green(
|
|
312
|
+
`Granted group '${grant.groupId}' access to '${targetCompany}' as ${grant.role}`,
|
|
313
|
+
),
|
|
314
|
+
);
|
|
315
|
+
console.log(
|
|
316
|
+
chalk.dim(
|
|
317
|
+
` source: ${grant.sourceCompanyUid} → target: ${grant.targetCompanyUid}`,
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
if (err instanceof GrantHttpError) {
|
|
322
|
+
console.error(
|
|
323
|
+
chalk.red(
|
|
324
|
+
formatGrantHttpError(err.status, err.message, {
|
|
325
|
+
targetCompany,
|
|
326
|
+
code: err.code,
|
|
327
|
+
}),
|
|
328
|
+
),
|
|
329
|
+
);
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
console.error(
|
|
333
|
+
chalk.red("Error:"),
|
|
334
|
+
err instanceof Error ? err.message : String(err),
|
|
335
|
+
);
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
grants
|
|
342
|
+
.command("revoke <groupId> <targetCompany>")
|
|
343
|
+
.description(
|
|
344
|
+
"Revoke a group's grant on <targetCompany>",
|
|
345
|
+
)
|
|
346
|
+
.action(async (groupId: string, targetCompany: string) => {
|
|
347
|
+
try {
|
|
348
|
+
const token = await ensureCognitoToken();
|
|
349
|
+
const sourceSlug = grants.opts().company as string | undefined;
|
|
350
|
+
const sourceCompanyUid = await getCompanyUid(token, sourceSlug);
|
|
351
|
+
const targetCompanyUid = await getCompanyUid(token, targetCompany);
|
|
352
|
+
|
|
353
|
+
await revokeGroupGrant({
|
|
354
|
+
groupId,
|
|
355
|
+
sourceCompanyUid,
|
|
356
|
+
targetCompanyUid,
|
|
357
|
+
token,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
console.log(
|
|
361
|
+
chalk.green(
|
|
362
|
+
`Revoked group '${groupId}' grant on '${targetCompany}'`,
|
|
363
|
+
),
|
|
364
|
+
);
|
|
365
|
+
} catch (err) {
|
|
366
|
+
if (err instanceof GrantHttpError) {
|
|
367
|
+
console.error(
|
|
368
|
+
chalk.red(
|
|
369
|
+
formatGrantHttpError(err.status, err.message, {
|
|
370
|
+
targetCompany,
|
|
371
|
+
code: err.code,
|
|
372
|
+
}),
|
|
373
|
+
),
|
|
374
|
+
);
|
|
375
|
+
process.exit(1);
|
|
376
|
+
}
|
|
377
|
+
console.error(
|
|
378
|
+
chalk.red("Error:"),
|
|
379
|
+
err instanceof Error ? err.message : String(err),
|
|
380
|
+
);
|
|
381
|
+
process.exit(1);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
grants
|
|
386
|
+
.command("outbound")
|
|
387
|
+
.description(
|
|
388
|
+
"List grants the source company's groups hold on other companies",
|
|
389
|
+
)
|
|
390
|
+
.option(
|
|
391
|
+
"--group <groupId>",
|
|
392
|
+
"Filter to a single group id",
|
|
393
|
+
)
|
|
394
|
+
.action(async (opts: { group?: string }) => {
|
|
395
|
+
try {
|
|
396
|
+
const token = await ensureCognitoToken();
|
|
397
|
+
const sourceSlug = grants.opts().company as string | undefined;
|
|
398
|
+
const sourceCompanyUid = await getCompanyUid(token, sourceSlug);
|
|
399
|
+
|
|
400
|
+
const list = await listOutboundGrants(token, sourceCompanyUid, opts.group);
|
|
401
|
+
if (list.length === 0) {
|
|
402
|
+
console.log(chalk.gray("No outbound group grants."));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
printGrantsTable(list);
|
|
406
|
+
} catch (err) {
|
|
407
|
+
if (err instanceof GrantHttpError) {
|
|
408
|
+
console.error(
|
|
409
|
+
chalk.red(formatGrantHttpError(err.status, err.message)),
|
|
410
|
+
);
|
|
411
|
+
process.exit(1);
|
|
412
|
+
}
|
|
413
|
+
console.error(
|
|
414
|
+
chalk.red("Error:"),
|
|
415
|
+
err instanceof Error ? err.message : String(err),
|
|
416
|
+
);
|
|
417
|
+
process.exit(1);
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
grants
|
|
422
|
+
.command("inbound")
|
|
423
|
+
.description(
|
|
424
|
+
"List grants other companies' groups hold on this company",
|
|
425
|
+
)
|
|
426
|
+
.action(async () => {
|
|
427
|
+
try {
|
|
428
|
+
const token = await ensureCognitoToken();
|
|
429
|
+
const companySlug = grants.opts().company as string | undefined;
|
|
430
|
+
const companyUid = await getCompanyUid(token, companySlug);
|
|
431
|
+
|
|
432
|
+
const list = await listInboundGrants(token, companyUid);
|
|
433
|
+
if (list.length === 0) {
|
|
434
|
+
console.log(chalk.gray("No inbound group grants."));
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
printGrantsTable(list);
|
|
438
|
+
} catch (err) {
|
|
439
|
+
if (err instanceof GrantHttpError) {
|
|
440
|
+
console.error(
|
|
441
|
+
chalk.red(formatGrantHttpError(err.status, err.message)),
|
|
442
|
+
);
|
|
443
|
+
process.exit(1);
|
|
444
|
+
}
|
|
445
|
+
console.error(
|
|
446
|
+
chalk.red("Error:"),
|
|
447
|
+
err instanceof Error ? err.message : String(err),
|
|
448
|
+
);
|
|
449
|
+
process.exit(1);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
}
|
|
@@ -45,6 +45,7 @@ import chalk from 'chalk';
|
|
|
45
45
|
import semverSatisfies from 'semver/functions/satisfies.js';
|
|
46
46
|
import semverValid from 'semver/functions/valid.js';
|
|
47
47
|
import semverValidRange from 'semver/ranges/valid.js';
|
|
48
|
+
import semverGt from 'semver/functions/gt.js';
|
|
48
49
|
import { findHqRoot } from '../utils/manifest.js';
|
|
49
50
|
import type { PackManifest, PackContributeKey } from '../types.js';
|
|
50
51
|
|
|
@@ -52,9 +53,9 @@ import type { PackManifest, PackContributeKey } from '../types.js';
|
|
|
52
53
|
// Source classification
|
|
53
54
|
// ---------------------------------------------------------------------------
|
|
54
55
|
|
|
55
|
-
type Transport = 'npm' | 'git' | 'local';
|
|
56
|
+
export type Transport = 'npm' | 'git' | 'local';
|
|
56
57
|
|
|
57
|
-
function classify(source: string): Transport {
|
|
58
|
+
export function classify(source: string): Transport {
|
|
58
59
|
if (source.startsWith('@')) return 'npm';
|
|
59
60
|
if (
|
|
60
61
|
source.startsWith('http://') ||
|
|
@@ -109,7 +110,7 @@ function expandGithubShorthand(url: string): string {
|
|
|
109
110
|
* Disambiguation: fragment containing '/' is a subpath (optionally with
|
|
110
111
|
* '@<ref>' suffix); fragment without '/' is a ref.
|
|
111
112
|
*/
|
|
112
|
-
function parseGitFragment(source: string): {
|
|
113
|
+
export function parseGitFragment(source: string): {
|
|
113
114
|
url: string;
|
|
114
115
|
subpath?: string;
|
|
115
116
|
ref?: string;
|
|
@@ -335,6 +336,97 @@ function isNamedRef(url: string, ref: string): boolean {
|
|
|
335
336
|
}
|
|
336
337
|
}
|
|
337
338
|
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// Update-availability probe (no install) — used by `hq packs update --check-only`
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
export interface LatestResult {
|
|
344
|
+
transport: Transport;
|
|
345
|
+
/** Identifier of the currently-installed pack (sha for git, version for npm). */
|
|
346
|
+
current?: string;
|
|
347
|
+
/** Latest available identifier from the remote. */
|
|
348
|
+
latest?: string;
|
|
349
|
+
/** true/false when a comparison was possible; null when undeterminable (local). */
|
|
350
|
+
updateAvailable: boolean | null;
|
|
351
|
+
error?: string;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Extract the ref (sha or named ref) recorded in a stamped git source. */
|
|
355
|
+
function gitRefFromSource(source: string): string | undefined {
|
|
356
|
+
const { subpath, ref } = parseGitFragment(source);
|
|
357
|
+
// For 'url#subpath@ref' parseGitFragment returns ref; for 'url#ref' likewise.
|
|
358
|
+
// A bare 'url#subpath' (no @ref) has no ref.
|
|
359
|
+
void subpath;
|
|
360
|
+
return ref;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Probe whether a newer version of an already-installed pack is available,
|
|
365
|
+
* WITHOUT fetching or installing. Reuses the same git/npm primitives as the
|
|
366
|
+
* install path. Never throws — network/parse failures return
|
|
367
|
+
* `{ updateAvailable: null, error }` so callers (the menubar) stay resilient.
|
|
368
|
+
*
|
|
369
|
+
* @param source the stamped `source:` from the installed package.yaml
|
|
370
|
+
* @param installedVersion the installed pack's manifest `version` (npm compare)
|
|
371
|
+
*/
|
|
372
|
+
export function resolveLatest(
|
|
373
|
+
source: string,
|
|
374
|
+
installedVersion?: string,
|
|
375
|
+
): LatestResult {
|
|
376
|
+
let transport: Transport;
|
|
377
|
+
try {
|
|
378
|
+
transport = classify(source);
|
|
379
|
+
} catch (e) {
|
|
380
|
+
return { transport: 'local', updateAvailable: null, error: (e as Error).message };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (transport === 'local') {
|
|
384
|
+
return { transport, updateAvailable: null, error: 'local source — re-run to re-sync' };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (transport === 'npm') {
|
|
388
|
+
const pkg = stripVersion(source);
|
|
389
|
+
const current =
|
|
390
|
+
installedVersion ?? (source.lastIndexOf('@') > 0 ? source.slice(source.lastIndexOf('@') + 1) : undefined);
|
|
391
|
+
try {
|
|
392
|
+
const latest = execFileSync('npm', ['view', pkg, 'version'], {
|
|
393
|
+
encoding: 'utf-8',
|
|
394
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
395
|
+
}).trim();
|
|
396
|
+
const updateAvailable =
|
|
397
|
+
current && latest ? semverGt(latest, current) : null;
|
|
398
|
+
return { transport, current, latest, updateAvailable };
|
|
399
|
+
} catch (e) {
|
|
400
|
+
return { transport, current, updateAvailable: null, error: `npm view failed: ${(e as Error).message}` };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// git
|
|
405
|
+
const parsed = parseGitFragment(source);
|
|
406
|
+
let url: string;
|
|
407
|
+
try {
|
|
408
|
+
url = expandGithubShorthand(parsed.url);
|
|
409
|
+
} catch (e) {
|
|
410
|
+
return { transport, updateAvailable: null, error: (e as Error).message };
|
|
411
|
+
}
|
|
412
|
+
const current = gitRefFromSource(source);
|
|
413
|
+
// If install followed a named ref (branch/tag), compare that ref's tip;
|
|
414
|
+
// otherwise (default SHA-pin) compare the default branch HEAD.
|
|
415
|
+
const refArg = current && isNamedRef(url, current) ? current : 'HEAD';
|
|
416
|
+
try {
|
|
417
|
+
const out = execFileSync('git', ['ls-remote', url, refArg], {
|
|
418
|
+
encoding: 'utf-8',
|
|
419
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
420
|
+
}).trim();
|
|
421
|
+
const latest = out.split(/\s+/)[0] || undefined;
|
|
422
|
+
const updateAvailable =
|
|
423
|
+
current && latest ? !latest.startsWith(current) && !current.startsWith(latest) : null;
|
|
424
|
+
return { transport, current, latest, updateAvailable };
|
|
425
|
+
} catch (e) {
|
|
426
|
+
return { transport, current, updateAvailable: null, error: `git ls-remote failed: ${(e as Error).message}` };
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
338
430
|
// ---------------------------------------------------------------------------
|
|
339
431
|
// Manifest validation (spec §Validation, 10 checks)
|
|
340
432
|
// ---------------------------------------------------------------------------
|
|
@@ -611,24 +703,30 @@ export function stampInstallSource(destDir: string, source: string): void {
|
|
|
611
703
|
* own scan.
|
|
612
704
|
*
|
|
613
705
|
* Exported for tests.
|
|
706
|
+
*
|
|
707
|
+
* `quiet` keeps the script's stdout off our stdout (it routes only stderr
|
|
708
|
+
* through, and sets HQ_SCAN_QUIET=1) so callers emitting machine-readable
|
|
709
|
+
* JSON — e.g. `hq packs uninstall --json` — produce clean output.
|
|
614
710
|
*/
|
|
615
|
-
export function runScanPackages(hqRoot: string): void {
|
|
711
|
+
export function runScanPackages(hqRoot: string, opts: { quiet?: boolean } = {}): void {
|
|
616
712
|
const script = path.join(hqRoot, 'core', 'scripts', 'scan-packages.sh');
|
|
617
713
|
if (!fs.existsSync(script)) {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
`
|
|
622
|
-
|
|
623
|
-
|
|
714
|
+
if (!opts.quiet) {
|
|
715
|
+
console.log(
|
|
716
|
+
chalk.dim(
|
|
717
|
+
` (core/scripts/scan-packages.sh not present — skipping auto-wire; ` +
|
|
718
|
+
`will run on next session start)`
|
|
719
|
+
)
|
|
720
|
+
);
|
|
721
|
+
}
|
|
624
722
|
return;
|
|
625
723
|
}
|
|
626
724
|
const r = spawnSync('bash', [script], {
|
|
627
725
|
cwd: hqRoot,
|
|
628
|
-
env: { ...process.env, HQ_ROOT: hqRoot },
|
|
629
|
-
stdio: 'inherit',
|
|
726
|
+
env: { ...process.env, HQ_ROOT: hqRoot, ...(opts.quiet ? { HQ_SCAN_QUIET: '1' } : {}) },
|
|
727
|
+
stdio: opts.quiet ? ['ignore', 'ignore', 'inherit'] : 'inherit',
|
|
630
728
|
});
|
|
631
|
-
if (r.status !== 0) {
|
|
729
|
+
if (r.status !== 0 && !opts.quiet) {
|
|
632
730
|
console.log(chalk.yellow(' scan-packages.sh exited non-zero; see output above.'));
|
|
633
731
|
}
|
|
634
732
|
}
|
|
@@ -640,6 +738,13 @@ export function runScanPackages(hqRoot: string): void {
|
|
|
640
738
|
export interface InstallPackOptions {
|
|
641
739
|
allowHooks?: boolean;
|
|
642
740
|
followBranch?: boolean;
|
|
741
|
+
/**
|
|
742
|
+
* Route this function's human output to stderr (and silence scan-packages
|
|
743
|
+
* stdout) so a caller emitting machine-readable JSON keeps stdout clean.
|
|
744
|
+
* The fetch tools are already quiet/stderr-only (`npm pack --silent`,
|
|
745
|
+
* `rsync -a`, `git clone` progress -> stderr), so this is sufficient.
|
|
746
|
+
*/
|
|
747
|
+
quiet?: boolean;
|
|
643
748
|
}
|
|
644
749
|
|
|
645
750
|
export async function installPack(
|
|
@@ -649,8 +754,11 @@ export async function installPack(
|
|
|
649
754
|
const transport = classify(source);
|
|
650
755
|
const hqRoot = findHqRoot();
|
|
651
756
|
const hqVersion = readHqVersion(hqRoot);
|
|
757
|
+
const say: (...a: unknown[]) => void = opts.quiet
|
|
758
|
+
? (...a) => console.error(...a)
|
|
759
|
+
: (...a) => console.log(...a);
|
|
652
760
|
|
|
653
|
-
|
|
761
|
+
say(chalk.dim(`-> transport: ${transport}; source: ${source}`));
|
|
654
762
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-pack-'));
|
|
655
763
|
try {
|
|
656
764
|
let fetched: FetchResult;
|
|
@@ -671,7 +779,7 @@ export async function installPack(
|
|
|
671
779
|
if (pkg.conditional) {
|
|
672
780
|
const allowed = await confirmConditional(pkg, opts.allowHooks ?? false);
|
|
673
781
|
if (!allowed) {
|
|
674
|
-
|
|
782
|
+
say(
|
|
675
783
|
chalk.red(
|
|
676
784
|
'Install aborted (conditional predicate not approved).',
|
|
677
785
|
),
|
|
@@ -680,7 +788,7 @@ export async function installPack(
|
|
|
680
788
|
}
|
|
681
789
|
const ok = evalConditional(pkg.conditional);
|
|
682
790
|
if (!ok) {
|
|
683
|
-
|
|
791
|
+
say(
|
|
684
792
|
chalk.yellow(
|
|
685
793
|
`Skipping ${pkg.name}: conditional "${pkg.conditional}" returned non-zero.`
|
|
686
794
|
)
|
|
@@ -691,7 +799,7 @@ export async function installPack(
|
|
|
691
799
|
|
|
692
800
|
const confirmed = await confirmHooks(pkg, opts.allowHooks ?? false);
|
|
693
801
|
if (!confirmed) {
|
|
694
|
-
|
|
802
|
+
say(chalk.red('Install aborted (hooks denied).'));
|
|
695
803
|
return;
|
|
696
804
|
}
|
|
697
805
|
|
|
@@ -705,14 +813,14 @@ export async function installPack(
|
|
|
705
813
|
// re-runs. Stamping the literal input (not the resolved SHA/version)
|
|
706
814
|
// matches the verbatim equality check in setup.sh.
|
|
707
815
|
stampInstallSource(destDir, source);
|
|
708
|
-
runScanPackages(hqRoot);
|
|
816
|
+
runScanPackages(hqRoot, { quiet: opts.quiet });
|
|
709
817
|
|
|
710
|
-
|
|
818
|
+
say(
|
|
711
819
|
chalk.green(
|
|
712
|
-
`\
|
|
820
|
+
`\nOK Installed ${pkg.name}@${pkg.version} -> ${path.relative(hqRoot, destDir)}/`
|
|
713
821
|
)
|
|
714
822
|
);
|
|
715
|
-
|
|
823
|
+
say(
|
|
716
824
|
chalk.dim(
|
|
717
825
|
` Wired ${Object.values(pkg.contributes).flat().filter(Boolean).length} ` +
|
|
718
826
|
`contribution(s) into host-side paths.`
|