@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.
@@ -19,7 +19,7 @@
19
19
  * HQ_VAULT_API_URL — vault-service API Gateway URL
20
20
  */
21
21
 
22
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="d16ef3c7-eead-5307-aaa7-ca5fc828102b")}catch(e){}}();
22
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="cd9690e0-c7b0-5e26-a3ee-7f7bc98a0fe2")}catch(e){}}();
23
23
  import * as fs from "fs";
24
24
  import * as os from "os";
25
25
  import * as path from "path";
@@ -67,7 +67,26 @@ export const DEFAULT_VAULT_API_URL = process.env.HQ_VAULT_API_URL ?? "https://hq
67
67
  * value at registration time, which matches the user's actual cwd at process
68
68
  * start. Re-importable as a function for tests and command-time resolution.
69
69
  */
70
- export function resolveDefaultHqRoot() {
70
+ /**
71
+ * Resolve the HQ root directory.
72
+ *
73
+ * Resolution order:
74
+ * 1. $HQ_ROOT env var (treated as an explicit assertion by the caller)
75
+ * 2. Walk up from cwd looking for `core.yaml` AND `companies/` siblings
76
+ * 3. Fall back to `~/hq` (or throw, per `opts.onMissing`)
77
+ *
78
+ * `opts.onMissing` controls the third arm:
79
+ * - `'fallback'` (default) — return `~/hq` if no HQ root is found above cwd.
80
+ * This preserves the module-load contract of `DEFAULT_HQ_ROOT`, which
81
+ * several commander.js `.option()` callers pin at registration time.
82
+ * - `'throw'` — throw with a user-actionable error. Used by module-management
83
+ * commands (pkg-install, pkg-remove, pkg-list, pkg-update, team-sync) where
84
+ * a silent default-path miss would silently target the wrong directory.
85
+ *
86
+ * `$HQ_ROOT` short-circuits both arms — if the env var is set, it's used
87
+ * as-is regardless of `onMissing`.
88
+ */
89
+ export function resolveDefaultHqRoot(opts = {}) {
71
90
  if (process.env.HQ_ROOT)
72
91
  return path.resolve(process.env.HQ_ROOT);
73
92
  let cur = path.resolve(process.cwd());
@@ -76,6 +95,10 @@ export function resolveDefaultHqRoot() {
76
95
  return cur;
77
96
  cur = path.dirname(cur);
78
97
  }
98
+ if (opts.onMissing === "throw") {
99
+ throw new Error("Could not find HQ root. Run this command from within your HQ directory " +
100
+ "(must contain core.yaml AND a companies/ subdirectory), or set $HQ_ROOT.");
101
+ }
79
102
  return path.join(os.homedir(), "hq");
80
103
  }
81
104
  /** True iff `dir` looks like an HQ root (has core.yaml + companies/ dir). */
@@ -170,4 +193,4 @@ export async function refreshCachedSession() {
170
193
  }
171
194
  }
172
195
  //# sourceMappingURL=cognito-session.js.map
173
- //# debugId=d16ef3c7-eead-5307-aaa7-ca5fc828102b
196
+ //# debugId=cd9690e0-c7b0-5e26-a3ee-7f7bc98a0fe2
@@ -2,11 +2,11 @@
2
2
  * Integrity verification — SHA256 hash and RSA signature checks (US-005)
3
3
  */
4
4
 
5
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="19765c32-6c3d-5477-bf80-0f7d08197d29")}catch(e){}}();
5
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="820f12b3-4caf-5f08-9add-621628715744")}catch(e){}}();
6
6
  import * as crypto from 'crypto';
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
- import { findHqRoot } from './hq-root.js';
9
+ import { resolveDefaultHqRoot } from './cognito-session.js';
10
10
  /**
11
11
  * Verify a file's SHA256 hash matches the expected value.
12
12
  */
@@ -29,7 +29,7 @@ export async function verifySha256(filePath, expectedHash) {
29
29
  */
30
30
  export function verifyRsaSignature(sha256Hash, signature, publicKeyPath) {
31
31
  const keyPath = publicKeyPath ??
32
- path.resolve(findHqRoot(), 'packages', '.keys', 'registry-public.pem');
32
+ path.resolve(resolveDefaultHqRoot({ onMissing: 'throw' }), 'packages', '.keys', 'registry-public.pem');
33
33
  if (!fs.existsSync(keyPath)) {
34
34
  return false;
35
35
  }
@@ -40,4 +40,4 @@ export function verifyRsaSignature(sha256Hash, signature, publicKeyPath) {
40
40
  return verifier.verify(publicKey, Buffer.from(signature, 'base64'));
41
41
  }
42
42
  //# sourceMappingURL=integrity.js.map
43
- //# debugId=19765c32-6c3d-5477-bf80-0f7d08197d29
43
+ //# debugId=820f12b3-4caf-5f08-9add-621628715744
@@ -5,11 +5,11 @@
5
5
  * Auth tokens are NEVER written to stdout or logs.
6
6
  */
7
7
 
8
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="ffe2dc58-fb20-5566-a56c-1b1c26999bba")}catch(e){}}();
8
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="43d1600f-1bf7-5a52-819b-83e71ba30c73")}catch(e){}}();
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import * as yaml from 'js-yaml';
12
- import { findHqRoot } from './hq-root.js';
12
+ import { resolveDefaultHqRoot } from './cognito-session.js';
13
13
  // ---------------------------------------------------------------------------
14
14
  // URL helper (unchanged from US-004)
15
15
  // ---------------------------------------------------------------------------
@@ -19,7 +19,7 @@ import { findHqRoot } from './hq-root.js';
19
19
  * Throws if sources.yaml is missing or has no sources.
20
20
  */
21
21
  export function getRegistryUrl() {
22
- const hqRoot = findHqRoot();
22
+ const hqRoot = resolveDefaultHqRoot({ onMissing: 'throw' });
23
23
  const sourcesPath = path.join(hqRoot, 'packages', 'sources.yaml');
24
24
  if (!fs.existsSync(sourcesPath)) {
25
25
  throw new Error(`No packages/sources.yaml found at ${sourcesPath}. Is your HQ packages directory set up?`);
@@ -102,4 +102,4 @@ export class RegistryClient {
102
102
  }
103
103
  }
104
104
  //# sourceMappingURL=registry-client.js.map
105
- //# debugId=ffe2dc58-fb20-5566-a56c-1b1c26999bba
105
+ //# debugId=43d1600f-1bf7-5a52-819b-83e71ba30c73
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Hard version-gate: ask hq-pro whether the current CLI is below the minimum
3
+ * acceptable version and, if so, run `npm install -g …@latest` synchronously
4
+ * before any commander parsing happens. Distinct from the existing
5
+ * `version-check.ts` which is a passive (cached, opt-in) stderr nag against
6
+ * the npm registry.
7
+ *
8
+ * Why both?
9
+ * - `version-check.ts` answers "is there something newer?" by polling npm
10
+ * directly. It's a soft hint, lives on a 24h cache, and never blocks.
11
+ * - `version-gate.ts` answers "is the team currently allowing your version
12
+ * to run?" via an authoritative hq-pro endpoint. The server can yank a
13
+ * known-bad release without waiting for the npm `latest` dist-tag move.
14
+ *
15
+ * The endpoint is reusable across clients (hq-sync, hq-installer, create-hq).
16
+ * See `apps/hq-pro/src/vault-service/handlers/client-version-check.ts` for the
17
+ * source-of-truth table.
18
+ *
19
+ * Trust model: anonymous. The CLI may be running pre-login (e.g. fresh
20
+ * install) so we never send credentials. The endpoint identifies the client
21
+ * by `clientId` + `currentVersion`.
22
+ *
23
+ * Failure mode: silent. Network down, hq-pro returning 5xx, malformed body —
24
+ * the gate must never break the CLI for a user who's otherwise fine. We log
25
+ * to Sentry as a breadcrumb (best-effort) and return.
26
+ *
27
+ * Opt-out: `HQ_NO_UPDATE_CHECK=1` (same env as `version-check.ts` — one knob
28
+ * to silence both check + gate).
29
+ */
30
+ /**
31
+ * Run the upgrade command in a blocking subprocess. Inherits stdio so the
32
+ * user sees the npm progress. We do NOT auto-rerun the CLI on completion —
33
+ * forcing a re-invocation would run twice on the same process and feel
34
+ * janky; instead we print a clear "rerun your command" message and exit.
35
+ */
36
+ declare function performUpdate(command: string): {
37
+ ok: boolean;
38
+ detail?: string;
39
+ };
40
+ /**
41
+ * Public entry point. Call before commander parses argv. Blocks the CLI on
42
+ * network IO for up to FETCH_TIMEOUT_MS — acceptable because the alternative
43
+ * (a fire-and-forget background check) gives the user no chance to bail out
44
+ * of a known-bad version before it does damage.
45
+ *
46
+ * `--version` / `-v` callers MUST skip the gate (the user is debugging a
47
+ * broken install and shouldn't be force-upgraded mid-investigation). Caller
48
+ * is responsible for checking argv before invoking us — see index.ts.
49
+ */
50
+ export declare function enforceVersionGate(): Promise<void>;
51
+ /**
52
+ * Cheap argv pre-check: skip the gate for `--version` / `-V` so users
53
+ * inspecting a broken install can still see what they have without being
54
+ * force-upgraded.
55
+ */
56
+ export declare function shouldSkipGate(argv: readonly string[]): boolean;
57
+ export declare const __test__: {
58
+ CLIENT_ID: string;
59
+ ENDPOINT_PATH: string;
60
+ FETCH_TIMEOUT_MS: number;
61
+ performUpdate: typeof performUpdate;
62
+ };
63
+ export {};
64
+ //# sourceMappingURL=version-gate.d.ts.map
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Hard version-gate: ask hq-pro whether the current CLI is below the minimum
3
+ * acceptable version and, if so, run `npm install -g …@latest` synchronously
4
+ * before any commander parsing happens. Distinct from the existing
5
+ * `version-check.ts` which is a passive (cached, opt-in) stderr nag against
6
+ * the npm registry.
7
+ *
8
+ * Why both?
9
+ * - `version-check.ts` answers "is there something newer?" by polling npm
10
+ * directly. It's a soft hint, lives on a 24h cache, and never blocks.
11
+ * - `version-gate.ts` answers "is the team currently allowing your version
12
+ * to run?" via an authoritative hq-pro endpoint. The server can yank a
13
+ * known-bad release without waiting for the npm `latest` dist-tag move.
14
+ *
15
+ * The endpoint is reusable across clients (hq-sync, hq-installer, create-hq).
16
+ * See `apps/hq-pro/src/vault-service/handlers/client-version-check.ts` for the
17
+ * source-of-truth table.
18
+ *
19
+ * Trust model: anonymous. The CLI may be running pre-login (e.g. fresh
20
+ * install) so we never send credentials. The endpoint identifies the client
21
+ * by `clientId` + `currentVersion`.
22
+ *
23
+ * Failure mode: silent. Network down, hq-pro returning 5xx, malformed body —
24
+ * the gate must never break the CLI for a user who's otherwise fine. We log
25
+ * to Sentry as a breadcrumb (best-effort) and return.
26
+ *
27
+ * Opt-out: `HQ_NO_UPDATE_CHECK=1` (same env as `version-check.ts` — one knob
28
+ * to silence both check + gate).
29
+ */
30
+
31
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="d7d58261-f0e4-56bc-b6eb-4117731744f9")}catch(e){}}();
32
+ import { spawnSync } from "node:child_process";
33
+ import chalk from "chalk";
34
+ import { CLI_VERSION } from "../cli-version.js";
35
+ import { DEFAULT_VAULT_API_URL } from "./cognito-session.js";
36
+ const CLIENT_ID = "hq-cli";
37
+ const ENDPOINT_PATH = "/v1/client-version/check";
38
+ const FETCH_TIMEOUT_MS = 3_000;
39
+ function isOptedOut() {
40
+ return process.env.HQ_NO_UPDATE_CHECK === "1";
41
+ }
42
+ /**
43
+ * Hit POST /v1/client-version/check. Returns the parsed body on 200, or
44
+ * `null` on any failure (caller treats as "no gate"). Tight 3s timeout —
45
+ * a hung server must not delay CLI startup.
46
+ */
47
+ async function fetchVersionDecision() {
48
+ try {
49
+ const url = `${DEFAULT_VAULT_API_URL}${ENDPOINT_PATH}`;
50
+ const res = await fetch(url, {
51
+ method: "POST",
52
+ headers: {
53
+ "Content-Type": "application/json",
54
+ Accept: "application/json",
55
+ },
56
+ body: JSON.stringify({
57
+ clientId: CLIENT_ID,
58
+ currentVersion: CLI_VERSION,
59
+ platform: `${process.platform}-${process.arch}`,
60
+ }),
61
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
62
+ });
63
+ if (!res.ok)
64
+ return null;
65
+ const body = (await res.json());
66
+ if (typeof body.minVersion !== "string" ||
67
+ typeof body.latestVersion !== "string" ||
68
+ typeof body.updateRequired !== "boolean") {
69
+ return null;
70
+ }
71
+ return body;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ /**
78
+ * Run the upgrade command in a blocking subprocess. Inherits stdio so the
79
+ * user sees the npm progress. We do NOT auto-rerun the CLI on completion —
80
+ * forcing a re-invocation would run twice on the same process and feel
81
+ * janky; instead we print a clear "rerun your command" message and exit.
82
+ */
83
+ function performUpdate(command) {
84
+ const parts = command.split(/\s+/).filter(Boolean);
85
+ if (parts.length === 0)
86
+ return { ok: false, detail: "empty command" };
87
+ const cmd = parts[0];
88
+ const args = parts.slice(1);
89
+ try {
90
+ const result = spawnSync(cmd, args, { stdio: "inherit" });
91
+ if (result.status !== 0) {
92
+ return {
93
+ ok: false,
94
+ detail: `exit ${result.status ?? "signal"}`,
95
+ };
96
+ }
97
+ return { ok: true };
98
+ }
99
+ catch (err) {
100
+ return { ok: false, detail: err instanceof Error ? err.message : String(err) };
101
+ }
102
+ }
103
+ /**
104
+ * Soft notify when the server says we're below `latestVersion` but still ≥
105
+ * `minVersion`. Single chalk-yellow line on stderr; never blocks.
106
+ */
107
+ function nudgeUpdateRecommended(decision) {
108
+ const msg = chalk.yellow(`⚠ A new version of hq-cli is available: ${decision.latestVersion} (current: ${decision.currentVersion}).`);
109
+ console.error(msg);
110
+ if (decision.updateCommand) {
111
+ console.error(chalk.dim(` Update: ${decision.updateCommand}`));
112
+ }
113
+ }
114
+ /**
115
+ * Hard enforcement when the server says we're below `minVersion`. Print a
116
+ * red banner, attempt the update, then exit so the user reruns against the
117
+ * fresh binary. Sequence chosen so a user with a broken `npm` global prefix
118
+ * still gets a clear error rather than an opaque silent failure.
119
+ *
120
+ * Exit codes:
121
+ * 0 — update succeeded; user must rerun their command
122
+ * 75 — update failed (EX_TEMPFAIL; common for sudo/EACCES on system npm)
123
+ */
124
+ function enforceUpdateRequired(decision) {
125
+ const banner = chalk.red.bold(`✗ hq-cli ${decision.currentVersion} is below the minimum required version (${decision.minVersion}).`);
126
+ console.error(banner);
127
+ if (decision.message)
128
+ console.error(chalk.dim(` ${decision.message}`));
129
+ const command = decision.updateCommand;
130
+ if (!command) {
131
+ console.error(chalk.red(" No updateCommand provided by hq-pro — see https://hq.indigo.ai/docs/cli-update for manual steps."));
132
+ if (decision.downloadUrl) {
133
+ console.error(chalk.dim(` Download: ${decision.downloadUrl}`));
134
+ }
135
+ process.exit(75);
136
+ }
137
+ console.error(chalk.dim(` Running: ${command}`));
138
+ const result = performUpdate(command);
139
+ if (!result.ok) {
140
+ console.error(chalk.red(`✗ Update failed${result.detail ? `: ${result.detail}` : ""}.`));
141
+ console.error(chalk.dim(` Try manually: ${command}`));
142
+ process.exit(75);
143
+ }
144
+ console.error(chalk.green(`✓ Updated to hq-cli ${decision.latestVersion}. Rerun your command.`));
145
+ process.exit(0);
146
+ }
147
+ /**
148
+ * Public entry point. Call before commander parses argv. Blocks the CLI on
149
+ * network IO for up to FETCH_TIMEOUT_MS — acceptable because the alternative
150
+ * (a fire-and-forget background check) gives the user no chance to bail out
151
+ * of a known-bad version before it does damage.
152
+ *
153
+ * `--version` / `-v` callers MUST skip the gate (the user is debugging a
154
+ * broken install and shouldn't be force-upgraded mid-investigation). Caller
155
+ * is responsible for checking argv before invoking us — see index.ts.
156
+ */
157
+ export async function enforceVersionGate() {
158
+ if (isOptedOut())
159
+ return;
160
+ const decision = await fetchVersionDecision();
161
+ if (!decision)
162
+ return; // best-effort: silent on any failure
163
+ if (decision.updateRequired) {
164
+ enforceUpdateRequired(decision); // exits process
165
+ }
166
+ if (decision.updateRecommended) {
167
+ nudgeUpdateRecommended(decision);
168
+ }
169
+ }
170
+ /**
171
+ * Cheap argv pre-check: skip the gate for `--version` / `-V` so users
172
+ * inspecting a broken install can still see what they have without being
173
+ * force-upgraded.
174
+ */
175
+ export function shouldSkipGate(argv) {
176
+ return argv.some((a) => a === "--version" || a === "-V" || a === "-v" || a === "--help" || a === "-h");
177
+ }
178
+ export const __test__ = {
179
+ CLIENT_ID,
180
+ ENDPOINT_PATH,
181
+ FETCH_TIMEOUT_MS,
182
+ performUpdate,
183
+ };
184
+ //# sourceMappingURL=version-gate.js.map
185
+ //# debugId=d7d58261-f0e4-56bc-b6eb-4117731744f9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.24.0",
3
+ "version": "5.25.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -18,6 +18,7 @@ import {
18
18
  getCallerPersonUid,
19
19
  inviteMember,
20
20
  listPendingInvites,
21
+ resendInvite,
21
22
  resolveRevokeTargetToMembershipKey,
22
23
  revokeInvite,
23
24
  } from "./members.js";
@@ -300,6 +301,296 @@ describe("inviteMember", () => {
300
301
  }),
301
302
  ).rejects.toThrow(/no membership row/);
302
303
  });
304
+
305
+ // ---- sendEmail flag (hq-pro PR #140 contract) ---------------------------
306
+
307
+ it("forwards `sendEmail: true` in the request body when the option is set", async () => {
308
+ fetchSpy.mockResolvedValueOnce(
309
+ jsonResponse(201, {
310
+ membership: {
311
+ role: "member",
312
+ status: "pending",
313
+ inviteeEmail: "alice@example.com",
314
+ schemaVersion: 2,
315
+ },
316
+ resent: false,
317
+ emailSent: true,
318
+ emailSkipped: false,
319
+ }),
320
+ );
321
+
322
+ const result = await inviteMember({
323
+ target: "alice@example.com",
324
+ role: "member",
325
+ sendEmail: true,
326
+ companyUid: "cmp_acme",
327
+ callerUid: "prs_admin",
328
+ token: "test-token",
329
+ });
330
+
331
+ const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
332
+ expect(body.sendEmail).toBe(true);
333
+ expect(result.emailSent).toBe(true);
334
+ expect(result.emailSkipped).toBe(false);
335
+ expect(result.emailError).toBeUndefined();
336
+ });
337
+
338
+ it("omits sendEmail from the body when the option is unset (backward-compat with pre-resend hq-pro)", async () => {
339
+ fetchSpy.mockResolvedValueOnce(
340
+ jsonResponse(201, {
341
+ membership: {
342
+ role: "member",
343
+ status: "pending",
344
+ inviteeEmail: "alice@example.com",
345
+ schemaVersion: 2,
346
+ },
347
+ }),
348
+ );
349
+
350
+ const result = await inviteMember({
351
+ target: "alice@example.com",
352
+ role: "member",
353
+ companyUid: "cmp_acme",
354
+ callerUid: "prs_admin",
355
+ token: "test-token",
356
+ });
357
+
358
+ const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
359
+ expect("sendEmail" in body).toBe(false);
360
+ // Pre-resend hq-pro doesn't send the email fields at all — the CLI
361
+ // result fields stay undefined so the caller can detect "server has no
362
+ // opinion" vs "server explicitly skipped".
363
+ expect(result.emailSent).toBeUndefined();
364
+ expect(result.emailSkipped).toBeUndefined();
365
+ });
366
+
367
+ it("surfaces emailSkipped: true when hq-pro has no RESEND_API_KEY", async () => {
368
+ fetchSpy.mockResolvedValueOnce(
369
+ jsonResponse(201, {
370
+ membership: {
371
+ role: "member",
372
+ status: "pending",
373
+ inviteeEmail: "alice@example.com",
374
+ schemaVersion: 2,
375
+ },
376
+ resent: false,
377
+ emailSent: false,
378
+ emailSkipped: true,
379
+ }),
380
+ );
381
+
382
+ const result = await inviteMember({
383
+ target: "alice@example.com",
384
+ role: "member",
385
+ sendEmail: true,
386
+ companyUid: "cmp_acme",
387
+ callerUid: "prs_admin",
388
+ token: "test-token",
389
+ });
390
+
391
+ expect(result.emailSent).toBe(false);
392
+ expect(result.emailSkipped).toBe(true);
393
+ });
394
+
395
+ it("surfaces emailError when the server-side Resend send failed", async () => {
396
+ fetchSpy.mockResolvedValueOnce(
397
+ jsonResponse(201, {
398
+ membership: {
399
+ role: "member",
400
+ status: "pending",
401
+ inviteeEmail: "alice@example.com",
402
+ schemaVersion: 2,
403
+ },
404
+ resent: false,
405
+ emailSent: false,
406
+ emailSkipped: false,
407
+ emailError: "Resend 429: rate limited",
408
+ }),
409
+ );
410
+
411
+ const result = await inviteMember({
412
+ target: "alice@example.com",
413
+ role: "member",
414
+ sendEmail: true,
415
+ companyUid: "cmp_acme",
416
+ callerUid: "prs_admin",
417
+ token: "test-token",
418
+ });
419
+
420
+ expect(result.emailSent).toBe(false);
421
+ expect(result.emailError).toMatch(/Resend 429/);
422
+ });
423
+
424
+ // ---- groupIds (hq-pro PR #140 contract) ---------------------------------
425
+
426
+ it("forwards groupIds in the body when set on an email-keyed invite", async () => {
427
+ fetchSpy.mockResolvedValueOnce(
428
+ jsonResponse(201, {
429
+ membership: {
430
+ role: "member",
431
+ status: "pending",
432
+ inviteeEmail: "alice@example.com",
433
+ schemaVersion: 2,
434
+ },
435
+ }),
436
+ );
437
+
438
+ await inviteMember({
439
+ target: "alice@example.com",
440
+ role: "member",
441
+ groupIds: ["grp-1", "grp-2"],
442
+ companyUid: "cmp_acme",
443
+ callerUid: "prs_admin",
444
+ token: "test-token",
445
+ });
446
+
447
+ const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
448
+ expect(body.groupIds).toEqual(["grp-1", "grp-2"]);
449
+ });
450
+
451
+ it("rejects --groups combined with a personUid target before hitting the server", async () => {
452
+ await expect(
453
+ inviteMember({
454
+ target: "prs_bob",
455
+ role: "member",
456
+ groupIds: ["grp-1"],
457
+ companyUid: "cmp_acme",
458
+ callerUid: "prs_admin",
459
+ token: "test-token",
460
+ }),
461
+ ).rejects.toThrow(/email-keyed invites/);
462
+ expect(fetchSpy).not.toHaveBeenCalled();
463
+ });
464
+
465
+ it("omits groupIds from the body when the array is empty", async () => {
466
+ fetchSpy.mockResolvedValueOnce(
467
+ jsonResponse(201, {
468
+ membership: {
469
+ role: "member",
470
+ status: "pending",
471
+ inviteeEmail: "alice@example.com",
472
+ schemaVersion: 2,
473
+ },
474
+ }),
475
+ );
476
+
477
+ await inviteMember({
478
+ target: "alice@example.com",
479
+ role: "member",
480
+ groupIds: [],
481
+ companyUid: "cmp_acme",
482
+ callerUid: "prs_admin",
483
+ token: "test-token",
484
+ });
485
+
486
+ const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
487
+ expect("groupIds" in body).toBe(false);
488
+ });
489
+ });
490
+
491
+ // ---------------------------------------------------------------------------
492
+ // resendInvite (hq-pro PR #140 resend: true short-circuit)
493
+ // ---------------------------------------------------------------------------
494
+
495
+ describe("resendInvite", () => {
496
+ it("posts { resend: true, inviteeEmail, companyUid, role: 'member' } and returns the resent result", async () => {
497
+ fetchSpy.mockResolvedValueOnce(
498
+ jsonResponse(200, {
499
+ resent: true,
500
+ emailSent: true,
501
+ emailSkipped: false,
502
+ }),
503
+ );
504
+
505
+ const result = await resendInvite({
506
+ inviteeEmail: "Alice@Example.com",
507
+ companyUid: "cmp_acme",
508
+ callerUid: "prs_admin",
509
+ token: "test-token",
510
+ });
511
+
512
+ expect(result).toEqual({
513
+ resent: true,
514
+ emailSent: true,
515
+ emailSkipped: false,
516
+ });
517
+ const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
518
+ expect(body).toEqual({
519
+ companyUid: "cmp_acme",
520
+ // Server still requires `role` even on the resend short-circuit; the
521
+ // CLI passes the safe default. Server ignores it on this path.
522
+ role: "member",
523
+ // Email normalized to lowercase before send.
524
+ inviteeEmail: "alice@example.com",
525
+ invitedBy: "prs_admin",
526
+ resend: true,
527
+ });
528
+ });
529
+
530
+ it("surfaces emailSkipped: true when hq-pro has no RESEND_API_KEY", async () => {
531
+ fetchSpy.mockResolvedValueOnce(
532
+ jsonResponse(200, {
533
+ resent: true,
534
+ emailSent: false,
535
+ emailSkipped: true,
536
+ }),
537
+ );
538
+
539
+ const result = await resendInvite({
540
+ inviteeEmail: "alice@example.com",
541
+ companyUid: "cmp_acme",
542
+ callerUid: "prs_admin",
543
+ token: "test-token",
544
+ });
545
+
546
+ expect(result.emailSent).toBe(false);
547
+ expect(result.emailSkipped).toBe(true);
548
+ });
549
+
550
+ it("throws on a non-email target — the resend path is email-keyed-row only", async () => {
551
+ await expect(
552
+ resendInvite({
553
+ inviteeEmail: "prs_bob",
554
+ companyUid: "cmp_acme",
555
+ callerUid: "prs_admin",
556
+ token: "test-token",
557
+ }),
558
+ ).rejects.toThrow(/email target/);
559
+ expect(fetchSpy).not.toHaveBeenCalled();
560
+ });
561
+
562
+ it("wraps a 404 INVITE_NOT_PENDING in InviteHttpError so the action handler can format it", async () => {
563
+ fetchSpy.mockResolvedValueOnce(
564
+ jsonResponse(404, {
565
+ error: "no pending row",
566
+ code: "INVITE_NOT_PENDING",
567
+ }),
568
+ );
569
+
570
+ await expect(
571
+ resendInvite({
572
+ inviteeEmail: "alice@example.com",
573
+ companyUid: "cmp_acme",
574
+ callerUid: "prs_admin",
575
+ token: "test-token",
576
+ }),
577
+ ).rejects.toBeInstanceOf(InviteHttpError);
578
+ });
579
+
580
+ it("rejects a 2xx response that lacks `resent: true` — guard against a pre-resend hq-pro silently dropping the flag", async () => {
581
+ fetchSpy.mockResolvedValueOnce(
582
+ jsonResponse(200, { membership: { role: "member", status: "pending" } }),
583
+ );
584
+
585
+ await expect(
586
+ resendInvite({
587
+ inviteeEmail: "alice@example.com",
588
+ companyUid: "cmp_acme",
589
+ callerUid: "prs_admin",
590
+ token: "test-token",
591
+ }),
592
+ ).rejects.toThrow(/doesn't support `resend: true` yet/);
593
+ });
303
594
  });
304
595
 
305
596
  // ---------------------------------------------------------------------------