@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.
@@ -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
- console.log(
619
- chalk.dim(
620
- ` (core/scripts/scan-packages.sh not present — skipping auto-wire; ` +
621
- `will run on next session start)`
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
- console.log(chalk.dim(`→ transport: ${transport}; source: ${source}`));
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
- console.log(
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
- console.log(
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
- console.log(chalk.red('Install aborted (hooks denied).'));
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
- console.log(
818
+ say(
711
819
  chalk.green(
712
- `\n✓ Installed ${pkg.name}@${pkg.version} ${path.relative(hqRoot, destDir)}/`
820
+ `\nOK Installed ${pkg.name}@${pkg.version} -> ${path.relative(hqRoot, destDir)}/`
713
821
  )
714
822
  );
715
- console.log(
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.`