@indigoai-us/hq-cli 5.24.0 → 5.25.1

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.
@@ -33,6 +33,17 @@ export interface InviteOptions {
33
33
  target: string;
34
34
  role: string;
35
35
  paths?: string;
36
+ /** Email-keyed invites only — secret-group ids to attach on claim. */
37
+ groupIds?: string[];
38
+ /**
39
+ * Opt the server-side Resend send in/out. When `true`, hq-pro renders +
40
+ * sends the invitation email server-side and returns `emailSent` /
41
+ * `emailSkipped` / `emailError`. When `false` or unset, the legacy
42
+ * no-email path runs and the CLI prints the manual sign-in instructions.
43
+ * Requires hq-pro that supports `sendEmail` (older servers ignore the
44
+ * field; CLI falls back to the legacy printout in that case).
45
+ */
46
+ sendEmail?: boolean;
36
47
  companyUid: string;
37
48
  callerUid: string;
38
49
  token: string;
@@ -51,6 +62,10 @@ export interface InviteOptions {
51
62
  * instructions instead.
52
63
  *
53
64
  * `membership` is always populated when the server returned 2xx.
65
+ *
66
+ * Email-related fields are populated only when hq-pro performed a
67
+ * server-side Resend send (caller passed `sendEmail: true` AND the
68
+ * server supports it). Pre-resend servers omit them entirely.
54
69
  */
55
70
  export interface InviteResult {
56
71
  inviteToken?: string;
@@ -62,6 +77,31 @@ export interface InviteResult {
62
77
  inviteToken?: string;
63
78
  inviteeEmail?: string;
64
79
  };
80
+ /** Resend send actually fired and accepted by Resend. */
81
+ emailSent?: boolean;
82
+ /** Resend skipped because the server has no RESEND_API_KEY configured. */
83
+ emailSkipped?: boolean;
84
+ /** Resend was attempted but failed — human-readable reason. */
85
+ emailError?: string;
86
+ }
87
+
88
+ export interface ResendInviteOptions {
89
+ inviteeEmail: string;
90
+ companyUid: string;
91
+ callerUid: string;
92
+ token: string;
93
+ }
94
+
95
+ /**
96
+ * Outcome of `hq members invite --resend`. No new membership row was
97
+ * created — the server's `resend: true` short-circuit just re-fires the
98
+ * Resend email against the existing pending row.
99
+ */
100
+ export interface ResendInviteResult {
101
+ resent: true;
102
+ emailSent: boolean;
103
+ emailSkipped: boolean;
104
+ emailError?: string;
65
105
  }
66
106
 
67
107
  export interface DetectedTarget {
@@ -127,6 +167,12 @@ export async function inviteMember(
127
167
  );
128
168
  }
129
169
 
170
+ if (options.groupIds && options.groupIds.length > 0 && detected.type === "person") {
171
+ throw new Error(
172
+ "--groups is only valid on email-keyed invites (server rejects personUid + groupIds with 400)",
173
+ );
174
+ }
175
+
130
176
  const allowedPrefixes = options.paths
131
177
  ? options.paths.split(",").map((p) => p.trim()).filter(Boolean)
132
178
  : undefined;
@@ -139,6 +185,10 @@ export async function inviteMember(
139
185
  if (detected.type === "email") body.inviteeEmail = detected.value;
140
186
  else body.personUid = detected.value;
141
187
  if (allowedPrefixes) body.allowedPrefixes = allowedPrefixes;
188
+ if (options.groupIds && options.groupIds.length > 0) {
189
+ body.groupIds = options.groupIds;
190
+ }
191
+ if (options.sendEmail === true) body.sendEmail = true;
142
192
 
143
193
  const res = await vaultApiFetch({
144
194
  token: options.token,
@@ -166,6 +216,9 @@ export async function inviteMember(
166
216
  schemaVersion?: number;
167
217
  };
168
218
  inviteToken?: string;
219
+ emailSent?: boolean;
220
+ emailSkipped?: boolean;
221
+ emailError?: string;
169
222
  };
170
223
  if (!data.membership) {
171
224
  const keys = Object.keys(data ?? {}).join(", ") || "<empty>";
@@ -189,6 +242,77 @@ export async function inviteMember(
189
242
  ? { inviteToken, magicLink: `hq://accept/${inviteToken}` }
190
243
  : {}),
191
244
  membership: data.membership,
245
+ // Pre-resend hq-pro versions omit these fields entirely — leave the
246
+ // result fields undefined so the caller's "is server-side email
247
+ // supported?" check works via `typeof result.emailSent === 'boolean'`.
248
+ ...(typeof data.emailSent === "boolean" ? { emailSent: data.emailSent } : {}),
249
+ ...(typeof data.emailSkipped === "boolean"
250
+ ? { emailSkipped: data.emailSkipped }
251
+ : {}),
252
+ ...(data.emailError ? { emailError: data.emailError } : {}),
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Re-fire the invite email against an existing pending row without
258
+ * creating a new membership row. Maps to hq-pro `/membership/invite`
259
+ * with `{ resend: true }` (see hq-pro PR #140). Server gates on the
260
+ * caller's admin/owner role.
261
+ *
262
+ * Returns the email-send status. 404 here means "no pending row exists
263
+ * for this email+company pair — re-invite without --resend first."
264
+ */
265
+ export async function resendInvite(
266
+ options: ResendInviteOptions,
267
+ ): Promise<ResendInviteResult> {
268
+ const detected = detectTarget(options.inviteeEmail);
269
+ if (!detected || detected.type !== "email") {
270
+ throw new Error(
271
+ "--resend requires an email target (the resend path is email-keyed-row only)",
272
+ );
273
+ }
274
+
275
+ const res = await vaultApiFetch({
276
+ token: options.token,
277
+ path: "/membership/invite",
278
+ method: "POST",
279
+ body: {
280
+ companyUid: options.companyUid,
281
+ // Server's invite handler still requires `role` even on the resend
282
+ // short-circuit. Pass the safe default — server ignores it on this
283
+ // path (the pending row already has its real role).
284
+ role: "member",
285
+ inviteeEmail: detected.value,
286
+ invitedBy: options.callerUid,
287
+ resend: true,
288
+ },
289
+ });
290
+
291
+ if (!res.ok) {
292
+ const err = (await res.json().catch(() => ({}))) as Record<string, string>;
293
+ throw new InviteHttpError(
294
+ res.status,
295
+ err.message ?? err.error ?? res.statusText,
296
+ err.code,
297
+ );
298
+ }
299
+
300
+ const data = (await res.json()) as {
301
+ resent?: boolean;
302
+ emailSent?: boolean;
303
+ emailSkipped?: boolean;
304
+ emailError?: string;
305
+ };
306
+ if (data.resent !== true) {
307
+ throw new Error(
308
+ "Resend endpoint returned 2xx without `resent: true` — the connected hq-pro likely doesn't support `resend: true` yet. Upgrade hq-pro or re-invite without --resend.",
309
+ );
310
+ }
311
+ return {
312
+ resent: true,
313
+ emailSent: data.emailSent === true,
314
+ emailSkipped: data.emailSkipped === true,
315
+ ...(data.emailError ? { emailError: data.emailError } : {}),
192
316
  };
193
317
  }
194
318
 
@@ -279,6 +403,37 @@ export function resolveRevokeTargetToMembershipKey(
279
403
  return arg;
280
404
  }
281
405
 
406
+ function printEmailStatus(opts: {
407
+ recipient: string;
408
+ sent: boolean;
409
+ skipped: boolean;
410
+ error?: string;
411
+ }): void {
412
+ if (opts.sent) {
413
+ console.log(
414
+ chalk.green(`✓ Invitation email sent to ${opts.recipient.toLowerCase()}`),
415
+ );
416
+ return;
417
+ }
418
+ if (opts.skipped) {
419
+ console.log(
420
+ chalk.yellow(
421
+ `⚠ Email send skipped — hq-pro has no RESEND_API_KEY configured for this stage.`,
422
+ ),
423
+ );
424
+ return;
425
+ }
426
+ if (opts.error) {
427
+ console.log(chalk.yellow(`⚠ Email send failed: ${opts.error}`));
428
+ return;
429
+ }
430
+ console.log(
431
+ chalk.yellow(
432
+ `⚠ Email status unknown — hq-pro returned no email fields (likely a pre-resend deploy).`,
433
+ ),
434
+ );
435
+ }
436
+
282
437
  export async function revokeInvite(
283
438
  token: string,
284
439
  tokenOrKey: string,
@@ -309,7 +464,7 @@ export function registerMembersCommand(program: Command): void {
309
464
  members
310
465
  .command("invite <target>")
311
466
  .description(
312
- "Invite a person to the company by email or personUid (prints a magic link)",
467
+ "Invite a person to the company by email or personUid (sends an invitation email by default)",
313
468
  )
314
469
  .option(
315
470
  "--role <role>",
@@ -320,10 +475,33 @@ export function registerMembersCommand(program: Command): void {
320
475
  "--paths <prefixes>",
321
476
  "Comma-separated allowed prefixes (only valid with --role guest)",
322
477
  )
478
+ .option(
479
+ "--groups <ids>",
480
+ "Comma-separated secret-group ids the invitee will be added to on claim (email-keyed invites only)",
481
+ )
482
+ .option(
483
+ "--send-email",
484
+ "Have hq-pro send an invitation email via Resend (default true for email-keyed invites). Pre-resend hq-pro versions ignore this and the legacy 'no email sent' instructions print instead.",
485
+ true,
486
+ )
487
+ .option(
488
+ "--no-send-email",
489
+ "Skip the server-side Resend send — only create the pending DDB row. Useful when you're scripting bulk invites and will send the announcement out-of-band.",
490
+ )
491
+ .option(
492
+ "--resend",
493
+ "Re-fire the invitation email against an existing pending row without creating a new row. Maps to hq-pro `resend: true` short-circuit. Email-keyed invites only.",
494
+ )
323
495
  .action(
324
496
  async (
325
497
  target: string,
326
- opts: { role: string; paths?: string },
498
+ opts: {
499
+ role: string;
500
+ paths?: string;
501
+ groups?: string;
502
+ sendEmail: boolean;
503
+ resend?: boolean;
504
+ },
327
505
  ) => {
328
506
  try {
329
507
  const token = await ensureCognitoToken();
@@ -331,10 +509,39 @@ export function registerMembersCommand(program: Command): void {
331
509
  const companyUid = await getCompanyUid(token, companySlug);
332
510
  const callerUid = await getCallerPersonUid(token);
333
511
 
512
+ // --resend short-circuits to the server's re-fire path. No new row.
513
+ if (opts.resend === true) {
514
+ const resendResult = await resendInvite({
515
+ inviteeEmail: target,
516
+ companyUid,
517
+ callerUid,
518
+ token,
519
+ });
520
+ console.log(
521
+ chalk.green(
522
+ `Re-fired invite email for ${target.toLowerCase()} (existing pending row left untouched)`,
523
+ ),
524
+ );
525
+ console.log();
526
+ printEmailStatus({
527
+ recipient: target,
528
+ sent: resendResult.emailSent,
529
+ skipped: resendResult.emailSkipped,
530
+ error: resendResult.emailError,
531
+ });
532
+ return;
533
+ }
534
+
535
+ const groupIds = opts.groups
536
+ ? opts.groups.split(",").map((g) => g.trim()).filter(Boolean)
537
+ : undefined;
538
+
334
539
  const result = await inviteMember({
335
540
  target,
336
541
  role: opts.role,
337
542
  paths: opts.paths,
543
+ groupIds,
544
+ sendEmail: opts.sendEmail,
338
545
  companyUid,
339
546
  callerUid,
340
547
  token,
@@ -356,52 +563,91 @@ export function registerMembersCommand(program: Command): void {
356
563
  "Share this link with the invitee. They can run `hq onboard join --invite-token <token>` to accept.",
357
564
  ),
358
565
  );
359
- } else {
360
- // schemaVersion 2+ — email-keyed authoritative membership row.
361
- // No magic link to share; invitee accepts by signing into HQ.
362
- //
363
- // CRITICAL UX NOTE: this CLI command does NOT send any email.
364
- // hq-pro only writes the DDB pending row; only the hq-console UI
365
- // path triggers Resend. Operators who run `hq members invite`
366
- // expecting an email to fly out get silently broken flows. The
367
- // output below uses chalk.yellow + an explicit "no email sent"
368
- // line so this never sneaks past again.
369
- const inviteeEmail =
370
- result.membership.inviteeEmail ??
371
- (typeof target === "string" && target.includes("@")
372
- ? target
373
- : undefined);
566
+ return;
567
+ }
568
+
569
+ // schemaVersion 2+ — email-keyed authoritative membership row.
570
+ // Three output branches depending on whether the server-side
571
+ // Resend send fired:
572
+ // (a) emailSent === true → "email sent" success line
573
+ // (b) emailSkipped === true server has no RESEND_API_KEY;
574
+ // fall back to the manual-notify instructions
575
+ // (c) emailSent/emailSkipped both undefined pre-resend
576
+ // hq-pro, doesn't understand `sendEmail` at all; fall
577
+ // back to the manual-notify instructions
578
+ const inviteeEmail =
579
+ result.membership.inviteeEmail ??
580
+ (typeof target === "string" && target.includes("@")
581
+ ? target
582
+ : undefined);
583
+ const emailDelivered = result.emailSent === true;
584
+ if (emailDelivered) {
585
+ printEmailStatus({
586
+ recipient: inviteeEmail ?? target,
587
+ sent: true,
588
+ skipped: false,
589
+ error: undefined,
590
+ });
591
+ if (result.membership.membershipKey) {
592
+ console.log();
593
+ console.log(
594
+ chalk.dim(`Membership key: ${result.membership.membershipKey}`),
595
+ );
596
+ }
597
+ return;
598
+ }
599
+ // Email-send failed or wasn't attempted — surface the manual path.
600
+ if (result.emailError) {
374
601
  console.log(
375
- chalk.yellow(
376
- "⚠ No email was sent. `hq members invite` only creates the pending membership row.",
377
- ),
602
+ chalk.yellow(`⚠ Email send failed: ${result.emailError}`),
378
603
  );
379
604
  console.log();
380
- console.log(chalk.bold("To complete the invite, do ONE of:"));
381
- console.log(
382
- ` 1. Manually notify ${inviteeEmail ?? "the invitee"}: ask them to sign into HQ`,
383
- );
384
- console.log(
385
- ` at https://hq.getindigo.ai with that email address.`,
386
- );
387
- console.log(
388
- ` 2. Or use the hq-console UI at https://hq.getindigo.ai to issue the`,
389
- );
605
+ }
606
+ console.log(
607
+ chalk.yellow(
608
+ "⚠ No email was sent. The pending membership row exists; the invitee needs to sign in manually to claim it.",
609
+ ),
610
+ );
611
+ console.log();
612
+ console.log(chalk.bold("To complete the invite, do ONE of:"));
613
+ console.log(
614
+ ` 1. Manually notify ${inviteeEmail ?? "the invitee"}: ask them to sign into HQ`,
615
+ );
616
+ console.log(
617
+ ` at https://hq.getindigo.ai with that email address.`,
618
+ );
619
+ console.log(
620
+ ` 2. Or use the hq-console UI at https://hq.getindigo.ai to issue the`,
621
+ );
622
+ console.log(
623
+ ` invite instead — the UI path triggers an automated email via Resend.`,
624
+ );
625
+ if (result.emailSkipped !== true && result.emailSent === undefined) {
626
+ console.log();
390
627
  console.log(
391
- ` invite instead — the UI path triggers an automated email via Resend.`,
628
+ chalk.dim(
629
+ "Tip: this hq-pro deploy doesn't support server-side email yet (`sendEmail` ignored). Ask an operator to ship hq-pro PR #140 + provision the ResendApiKey SST secret.",
630
+ ),
392
631
  );
632
+ } else if (result.emailSkipped === true) {
393
633
  console.log();
394
634
  console.log(
395
635
  chalk.dim(
396
- "The pending membership row claims itself on the invitee's first sign-in.",
636
+ "Tip: hq-pro reported `emailSkipped: true` its `RESEND_API_KEY` SST secret isn't set for this stage.",
397
637
  ),
398
638
  );
399
- if (result.membership.membershipKey) {
400
- console.log();
401
- console.log(
402
- chalk.dim(`Membership key: ${result.membership.membershipKey}`),
403
- );
404
- }
639
+ }
640
+ console.log();
641
+ console.log(
642
+ chalk.dim(
643
+ "The pending membership row claims itself on the invitee's first sign-in.",
644
+ ),
645
+ );
646
+ if (result.membership.membershipKey) {
647
+ console.log();
648
+ console.log(
649
+ chalk.dim(`Membership key: ${result.membership.membershipKey}`),
650
+ );
405
651
  }
406
652
  } catch (err) {
407
653
  if (err instanceof InviteHttpError) {
@@ -21,7 +21,7 @@ import { execSync } from 'child_process';
21
21
  import { Command } from 'commander';
22
22
  import chalk from 'chalk';
23
23
  import { ensureCognitoToken } from '../utils/cognito-session.js';
24
- import { findHqRoot } from '../utils/hq-root.js';
24
+ import { resolveDefaultHqRoot } from '../utils/cognito-session.js';
25
25
  import {
26
26
  getRegistryUrl,
27
27
  RegistryClient,
@@ -124,7 +124,7 @@ async function installPackage(
124
124
  }
125
125
 
126
126
  // 7. Extract
127
- const hqRoot = findHqRoot();
127
+ const hqRoot = resolveDefaultHqRoot({ onMissing: 'throw' });
128
128
  const installDir = path.resolve(hqRoot, 'packages', 'installed', slug);
129
129
 
130
130
  // Clean existing installation
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { Command } from 'commander';
8
8
  import chalk from 'chalk';
9
- import { findHqRoot } from '../utils/hq-root.js';
9
+ import { resolveDefaultHqRoot } from '../utils/cognito-session.js';
10
10
  import { readRegistry } from '../utils/registry.js';
11
11
  import { loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
12
12
  import {
@@ -34,7 +34,7 @@ export function registerPackageListCommand(parent: Command): void {
34
34
  }
35
35
 
36
36
  async function listPackages(): Promise<void> {
37
- const hqRoot = findHqRoot();
37
+ const hqRoot = resolveDefaultHqRoot({ onMissing: 'throw' });
38
38
  const installed = readRegistry(hqRoot);
39
39
 
40
40
  // Print installed packages
@@ -10,7 +10,7 @@ import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import { Command } from 'commander';
12
12
  import chalk from 'chalk';
13
- import { findHqRoot } from '../utils/hq-root.js';
13
+ import { resolveDefaultHqRoot } from '../utils/cognito-session.js';
14
14
  import { removeFromRegistry, readRegistry } from '../utils/registry.js';
15
15
 
16
16
  export function registerPackageRemoveCommand(parent: Command): void {
@@ -31,7 +31,7 @@ export function registerPackageRemoveCommand(parent: Command): void {
31
31
  }
32
32
 
33
33
  async function removePackage(slug: string): Promise<void> {
34
- const hqRoot = findHqRoot();
34
+ const hqRoot = resolveDefaultHqRoot({ onMissing: 'throw' });
35
35
  const installDir = path.resolve(hqRoot, 'packages', 'installed', slug);
36
36
 
37
37
  // Verify it is actually installed
@@ -13,7 +13,7 @@ import { execSync } from 'child_process';
13
13
  import { Command } from 'commander';
14
14
  import chalk from 'chalk';
15
15
  import { ensureCognitoToken } from '../utils/cognito-session.js';
16
- import { findHqRoot } from '../utils/hq-root.js';
16
+ import { resolveDefaultHqRoot } from '../utils/cognito-session.js';
17
17
  import {
18
18
  getRegistryUrl,
19
19
  RegistryClient,
@@ -43,7 +43,7 @@ export function registerPackageUpdateCommand(parent: Command): void {
43
43
  }
44
44
 
45
45
  async function updatePackages(slug?: string): Promise<void> {
46
- const hqRoot = findHqRoot();
46
+ const hqRoot = resolveDefaultHqRoot({ onMissing: 'throw' });
47
47
  const entries = readRegistry(hqRoot);
48
48
 
49
49
  if (entries.length === 0) {
@@ -16,7 +16,7 @@ import { execSync } from 'child_process';
16
16
  import { Command } from 'commander';
17
17
  import chalk from 'chalk';
18
18
  import simpleGit from 'simple-git';
19
- import { findHqRoot } from '../utils/hq-root.js';
19
+ import { resolveDefaultHqRoot } from '../utils/cognito-session.js';
20
20
  import { ensureCognitoToken } from '../utils/cognito-session.js';
21
21
 
22
22
  // ─── Types ──────────────────────────────────────────────────────────────────
@@ -433,7 +433,7 @@ export function registerTeamSyncCommand(program: Command): void {
433
433
  .action(
434
434
  async (options: { team?: string; dryRun?: boolean }) => {
435
435
  try {
436
- const hqRoot = findHqRoot();
436
+ const hqRoot = resolveDefaultHqRoot({ onMissing: 'throw' });
437
437
 
438
438
  // 1. Discover team directories
439
439
  let teamDirs = discoverTeamDirs(hqRoot);
package/src/index.ts CHANGED
@@ -40,6 +40,10 @@ import {
40
40
  maybeWarnNewVersion,
41
41
  refreshVersionCache,
42
42
  } from "./utils/version-check.js";
43
+ import {
44
+ enforceVersionGate,
45
+ shouldSkipGate,
46
+ } from "./utils/version-gate.js";
43
47
  import { CLI_VERSION } from "./cli-version.js";
44
48
 
45
49
  // Swallow EPIPE when a downstream reader (e.g. `source <(…)`, `| head`) closes the pipe early.
@@ -157,6 +161,14 @@ registerSignalsCommand(program);
157
161
  message: sanitizeArgv(process.argv.slice(2)).join(" "),
158
162
  level: "info",
159
163
  });
164
+ // Hard version gate: ask hq-pro whether this CLI is below the floor and
165
+ // auto-update if so (exits the process on update). Skipped for inspection
166
+ // flags (`--version`, `--help`) so users debugging a broken install can
167
+ // still introspect what they have. Silent on any failure — never blocks
168
+ // the CLI on a flaky network or hq-pro hiccup. See `utils/version-gate.ts`.
169
+ if (!shouldSkipGate(process.argv)) {
170
+ await enforceVersionGate();
171
+ }
160
172
  await program.parseAsync();
161
173
  } catch (err) {
162
174
  Sentry.captureException(err);
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { describe, it, expect, beforeEach, afterEach } from "vitest";
7
- import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
7
+ import { mkdtempSync, realpathSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
10
 
@@ -114,6 +114,61 @@ describe("resolveDefaultHqRoot", () => {
114
114
 
115
115
  expect(resolveDefaultHqRoot()).toBe(explicit);
116
116
  });
117
+
118
+ // ── onMissing: throw vs fallback ──────────────────────────────────────
119
+ //
120
+ // Default behavior (no opts) is fallback to ~/hq — preserves the
121
+ // pre-dedup module-load contract of `DEFAULT_HQ_ROOT = resolveDefaultHqRoot()`.
122
+ // Module-management commands (pkg-*, team-sync) opt into throw mode so a
123
+ // user invoking the command from outside an HQ gets a clear error instead
124
+ // of a silent default-path miss.
125
+ it("onMissing: 'throw' throws when no HQ root is found above cwd", () => {
126
+ const stranded = join(tmpRoot, "stranded");
127
+ mkdirSync(stranded, { recursive: true });
128
+ process.chdir(stranded);
129
+
130
+ expect(() => resolveDefaultHqRoot({ onMissing: "throw" })).toThrow(
131
+ /Could not find HQ root/i,
132
+ );
133
+ });
134
+
135
+ it("onMissing: 'throw' still honors $HQ_ROOT even though dir wouldn't otherwise be detected", () => {
136
+ // HQ_ROOT is treated as an explicit assertion by the caller — no
137
+ // walk-and-throw applies. Module-management commands run with
138
+ // HQ_ROOT=/abs/path should succeed regardless of cwd.
139
+ const explicit = join(tmpRoot, "explicit");
140
+ mkdirSync(explicit, { recursive: true });
141
+ process.env.HQ_ROOT = explicit;
142
+ process.chdir(tmpRoot);
143
+
144
+ expect(resolveDefaultHqRoot({ onMissing: "throw" })).toBe(explicit);
145
+ });
146
+
147
+ it("onMissing: 'throw' still resolves when cwd IS an HQ root", () => {
148
+ const hqDir = join(tmpRoot, "hq");
149
+ mkdirSync(join(hqDir, "companies"), { recursive: true });
150
+ writeFileSync(join(hqDir, "core.yaml"), "version: 14.1.1\n");
151
+
152
+ process.chdir(hqDir);
153
+ // realpath both sides: macOS /tmp -> /private/var/folders symlink would
154
+ // otherwise produce a spurious mismatch (same root cause as the two
155
+ // pre-existing failing `priority 2:` tests above).
156
+ expect(realpathSync(resolveDefaultHqRoot({ onMissing: "throw" }))).toBe(
157
+ realpathSync(hqDir),
158
+ );
159
+ });
160
+
161
+ it("default (no opts) falls back to ~/hq — back-compat with module-load DEFAULT_HQ_ROOT", () => {
162
+ // Regression pin: any code that imports `DEFAULT_HQ_ROOT` (resolved at
163
+ // module load) MUST NOT throw if the loader's cwd is outside an HQ.
164
+ // This contract pre-dates the onMissing parameter and several commands
165
+ // depend on it (commander.js .option() registration time).
166
+ const stranded = join(tmpRoot, "stranded");
167
+ mkdirSync(stranded, { recursive: true });
168
+ process.chdir(stranded);
169
+
170
+ expect(() => resolveDefaultHqRoot()).not.toThrow();
171
+ });
117
172
  });
118
173
 
119
174
  describe("CLI_CLIENT_INFO + buildVaultConfig", () => {
@@ -80,13 +80,40 @@ export const DEFAULT_VAULT_API_URL =
80
80
  * value at registration time, which matches the user's actual cwd at process
81
81
  * start. Re-importable as a function for tests and command-time resolution.
82
82
  */
83
- export function resolveDefaultHqRoot(): string {
83
+ /**
84
+ * Resolve the HQ root directory.
85
+ *
86
+ * Resolution order:
87
+ * 1. $HQ_ROOT env var (treated as an explicit assertion by the caller)
88
+ * 2. Walk up from cwd looking for `core.yaml` AND `companies/` siblings
89
+ * 3. Fall back to `~/hq` (or throw, per `opts.onMissing`)
90
+ *
91
+ * `opts.onMissing` controls the third arm:
92
+ * - `'fallback'` (default) — return `~/hq` if no HQ root is found above cwd.
93
+ * This preserves the module-load contract of `DEFAULT_HQ_ROOT`, which
94
+ * several commander.js `.option()` callers pin at registration time.
95
+ * - `'throw'` — throw with a user-actionable error. Used by module-management
96
+ * commands (pkg-install, pkg-remove, pkg-list, pkg-update, team-sync) where
97
+ * a silent default-path miss would silently target the wrong directory.
98
+ *
99
+ * `$HQ_ROOT` short-circuits both arms — if the env var is set, it's used
100
+ * as-is regardless of `onMissing`.
101
+ */
102
+ export function resolveDefaultHqRoot(opts: {
103
+ onMissing?: "throw" | "fallback";
104
+ } = {}): string {
84
105
  if (process.env.HQ_ROOT) return path.resolve(process.env.HQ_ROOT);
85
106
  let cur = path.resolve(process.cwd());
86
107
  while (cur !== path.dirname(cur)) {
87
108
  if (isHqRoot(cur)) return cur;
88
109
  cur = path.dirname(cur);
89
110
  }
111
+ if (opts.onMissing === "throw") {
112
+ throw new Error(
113
+ "Could not find HQ root. Run this command from within your HQ directory " +
114
+ "(must contain core.yaml AND a companies/ subdirectory), or set $HQ_ROOT.",
115
+ );
116
+ }
90
117
  return path.join(os.homedir(), "hq");
91
118
  }
92
119
 
@@ -5,7 +5,7 @@
5
5
  import * as crypto from 'crypto';
6
6
  import * as fs from 'fs';
7
7
  import * as path from 'path';
8
- import { findHqRoot } from './hq-root.js';
8
+ import { resolveDefaultHqRoot } from './cognito-session.js';
9
9
 
10
10
  /**
11
11
  * Verify a file's SHA256 hash matches the expected value.
@@ -39,7 +39,7 @@ export function verifyRsaSignature(
39
39
  ): boolean {
40
40
  const keyPath =
41
41
  publicKeyPath ??
42
- path.resolve(findHqRoot(), 'packages', '.keys', 'registry-public.pem');
42
+ path.resolve(resolveDefaultHqRoot({ onMissing: 'throw' }), 'packages', '.keys', 'registry-public.pem');
43
43
 
44
44
  if (!fs.existsSync(keyPath)) {
45
45
  return false;