@indigoai-us/hq-cli 5.18.2 → 5.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,49 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.18.3] — 2026-05-21
6
+
7
+ ### Fixed
8
+
9
+ - **`hq members invite`: loud no-email warning + clearer next-steps.**
10
+ schemaVersion-2 invites only create the email-keyed pending DDB row —
11
+ the CLI does NOT send email. New output leads with a `chalk.yellow
12
+ ⚠ No email was sent` warning + a two-option "do ONE of: manually
13
+ notify / use hq-console UI" block. Previous wording read like passive
14
+ next-step copy; admins mistook it for "email sent." (#22)
15
+ - **`hq members revoke <bare-email>`: no more 404.** Accepts bare email
16
+ and bare `prs_*` personUids, wrapping them into the canonical
17
+ `email:<email>#<companyUid>` / `<personUid>#<companyUid>` membership
18
+ key shape before posting to the server. (#22)
19
+
20
+ ## [5.18.2] — 2026-05-21
21
+
22
+ ### Fixed
23
+
24
+ - **`hq members invite` + `hq members list` under schemaVersion 2 server.**
25
+ Aligns CLI with hq-pro membership schema-v2 (live-smoke surfaced regression). (#20, #21)
26
+
27
+ ## [5.18.1] — 2026-05-21
28
+
29
+ ### Fixed
30
+
31
+ - **Wire narrow-hint banner into per-company pull (US-011).** Banner now renders
32
+ on the per-company `hq sync pull` path, not just full-sync. (#18, #19)
33
+
34
+ ## [5.18.0] — 2026-05-21
35
+
36
+ ### Added — sync-browse-vs-sync
37
+
38
+ - **`hq sync mode <shared|narrow>`** — set per-membership sync mode (US-006).
39
+ - **`hq sync narrow --dry-run | --apply`** — preview/execute ACL narrowing for
40
+ the active company (US-007).
41
+ - **`hq files browse <prefix>` + `hq files cat <path>`** — read-only vault
42
+ browse via purpose=browse STS scope, distinct from sync (US-008).
43
+ - **Narrow-hint banner** — surfaces on `hq sync` when local membership is
44
+ shared-mode but cloud signals narrowing is recommended (US-011).
45
+ - Bumps `@indigoai-us/hq-cloud` to 5.23.0 (VaultClient SDK + sync engine
46
+ narrowing + journal v2). (#16, #17)
47
+
5
48
  ## [5.14.1] — 2026-05-14
6
49
 
7
50
  ### Fixed
@@ -66,6 +66,23 @@ export declare class InviteHttpError extends Error {
66
66
  }
67
67
  export declare function formatInviteHttpError(status: number, fallback: string, code?: string): string;
68
68
  export declare function listPendingInvites(token: string, companyUid: string): Promise<PendingInvite[]>;
69
+ /**
70
+ * Resolve a `revoke` CLI argument into the canonical `membershipKey` shape
71
+ * the server requires. Accepts three input forms:
72
+ *
73
+ * 1. Full membership key — already has `#<companyUid>`; passed through.
74
+ * Examples: `email:alice@example.com#cmp_abc`, `prs_abc#cmp_abc`.
75
+ * 2. Bare email — wrap as `email:<email>#<companyUid>`.
76
+ * 3. Bare personUid (`prs_*`) — wrap as `<personUid>#<companyUid>`.
77
+ * 4. Anything else (e.g. legacy schemaVersion-1 inviteToken) — pass through
78
+ * so the server can decide. Server may 404 if the token doesn't resolve.
79
+ *
80
+ * Pure function — no I/O — so it's trivially unit-testable. The previous
81
+ * shape sent the user's raw arg straight through, which meant `hq members
82
+ * revoke alice@example.com` always 404'd ("Invite not found") even when
83
+ * that exact email was just shown by `hq members list`.
84
+ */
85
+ export declare function resolveRevokeTargetToMembershipKey(arg: string, companyUid: string): string;
69
86
  export declare function revokeInvite(token: string, tokenOrKey: string, companyUid: string): Promise<void>;
70
87
  export declare function registerMembersCommand(program: Command): void;
71
88
  //# sourceMappingURL=members.d.ts.map
@@ -1,5 +1,5 @@
1
1
 
2
- !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]="20c76490-0485-592f-856b-3b20e403a392")}catch(e){}}();
2
+ !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]="c06b812a-ee4a-5701-a64b-cee2fcfef523")}catch(e){}}();
3
3
  import chalk from "chalk";
4
4
  import { ensureCognitoToken } from "../utils/cognito-session.js";
5
5
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
@@ -135,6 +135,34 @@ export async function listPendingInvites(token, companyUid) {
135
135
  const data = (await res.json());
136
136
  return data?.pending ?? data?.invites ?? [];
137
137
  }
138
+ /**
139
+ * Resolve a `revoke` CLI argument into the canonical `membershipKey` shape
140
+ * the server requires. Accepts three input forms:
141
+ *
142
+ * 1. Full membership key — already has `#<companyUid>`; passed through.
143
+ * Examples: `email:alice@example.com#cmp_abc`, `prs_abc#cmp_abc`.
144
+ * 2. Bare email — wrap as `email:<email>#<companyUid>`.
145
+ * 3. Bare personUid (`prs_*`) — wrap as `<personUid>#<companyUid>`.
146
+ * 4. Anything else (e.g. legacy schemaVersion-1 inviteToken) — pass through
147
+ * so the server can decide. Server may 404 if the token doesn't resolve.
148
+ *
149
+ * Pure function — no I/O — so it's trivially unit-testable. The previous
150
+ * shape sent the user's raw arg straight through, which meant `hq members
151
+ * revoke alice@example.com` always 404'd ("Invite not found") even when
152
+ * that exact email was just shown by `hq members list`.
153
+ */
154
+ export function resolveRevokeTargetToMembershipKey(arg, companyUid) {
155
+ if (arg.includes("#"))
156
+ return arg;
157
+ const detected = detectTarget(arg);
158
+ if (detected?.type === "email") {
159
+ return `email:${detected.value}#${companyUid}`;
160
+ }
161
+ if (detected?.type === "person") {
162
+ return `${detected.value}#${companyUid}`;
163
+ }
164
+ return arg;
165
+ }
138
166
  export async function revokeInvite(token, tokenOrKey, companyUid) {
139
167
  const res = await vaultApiFetch({
140
168
  token,
@@ -183,16 +211,29 @@ export function registerMembersCommand(program) {
183
211
  else {
184
212
  // schemaVersion 2+ — email-keyed authoritative membership row.
185
213
  // No magic link to share; invitee accepts by signing into HQ.
214
+ //
215
+ // CRITICAL UX NOTE: this CLI command does NOT send any email.
216
+ // hq-pro only writes the DDB pending row; only the hq-console UI
217
+ // path triggers Resend. Operators who run `hq members invite`
218
+ // expecting an email to fly out get silently broken flows. The
219
+ // output below uses chalk.yellow + an explicit "no email sent"
220
+ // line so this never sneaks past again.
186
221
  const inviteeEmail = result.membership.inviteeEmail ??
187
222
  (typeof target === "string" && target.includes("@")
188
223
  ? target
189
224
  : undefined);
190
- console.log(chalk.bold("Next step:"));
191
- console.log(` Tell ${inviteeEmail ?? "the invitee"} to sign into HQ at https://hq.getindigo.ai with that email.`);
192
- console.log(chalk.dim(" The pending membership row claims itself on first sign-in — no separate token redemption."));
225
+ console.log(chalk.yellow(" No email was sent. `hq members invite` only creates the pending membership row."));
226
+ console.log();
227
+ console.log(chalk.bold("To complete the invite, do ONE of:"));
228
+ console.log(` 1. Manually notify ${inviteeEmail ?? "the invitee"}: ask them to sign into HQ`);
229
+ console.log(` at https://hq.getindigo.ai with that email address.`);
230
+ console.log(` 2. Or use the hq-console UI at https://hq.getindigo.ai to issue the`);
231
+ console.log(` invite instead — the UI path triggers an automated email via Resend.`);
232
+ console.log();
233
+ console.log(chalk.dim("The pending membership row claims itself on the invitee's first sign-in."));
193
234
  if (result.membership.membershipKey) {
194
235
  console.log();
195
- console.log(chalk.dim(` Membership key: ${result.membership.membershipKey}`));
236
+ console.log(chalk.dim(`Membership key: ${result.membership.membershipKey}`));
196
237
  }
197
238
  }
198
239
  }
@@ -253,15 +294,16 @@ export function registerMembersCommand(program) {
253
294
  }
254
295
  });
255
296
  members
256
- .command("revoke <tokenOrKey>")
257
- .description("Revoke a pending invite (accepts the inviteToken or membershipKey)")
258
- .action(async (tokenOrKey) => {
297
+ .command("revoke <target>")
298
+ .description("Revoke a pending invite. Accepts an email, personUid, full membershipKey, or legacy inviteToken.")
299
+ .action(async (target) => {
259
300
  try {
260
301
  const token = await ensureCognitoToken();
261
302
  const companySlug = members.opts().company;
262
303
  const companyUid = await getCompanyUid(token, companySlug);
263
- await revokeInvite(token, tokenOrKey, companyUid);
264
- console.log(chalk.green(`Revoked invite '${tokenOrKey}'`));
304
+ const membershipKey = resolveRevokeTargetToMembershipKey(target, companyUid);
305
+ await revokeInvite(token, membershipKey, companyUid);
306
+ console.log(chalk.green(`Revoked invite '${membershipKey}'`));
265
307
  }
266
308
  catch (err) {
267
309
  if (err instanceof InviteHttpError) {
@@ -281,4 +323,4 @@ export function registerMembersCommand(program) {
281
323
  });
282
324
  }
283
325
  //# sourceMappingURL=members.js.map
284
- //# debugId=20c76490-0485-592f-856b-3b20e403a392
326
+ //# debugId=c06b812a-ee4a-5701-a64b-cee2fcfef523
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.18.2",
3
+ "version": "5.18.3",
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
+ resolveRevokeTargetToMembershipKey,
21
22
  revokeInvite,
22
23
  } from "./members.js";
23
24
 
@@ -414,6 +415,58 @@ describe("revokeInvite", () => {
414
415
  });
415
416
  });
416
417
 
418
+ // ---------------------------------------------------------------------------
419
+ // resolveRevokeTargetToMembershipKey
420
+ // ---------------------------------------------------------------------------
421
+
422
+ describe("resolveRevokeTargetToMembershipKey", () => {
423
+ // Regression: `hq members revoke alice@example.com` used to send the raw
424
+ // email straight to /membership/revoke, which the server rejects with 404
425
+ // "Invite not found" because it keys on `email:<email>#<companyUid>`. Live
426
+ // smoke 2026-05-21 reproduced this against indigo.
427
+ it("wraps a bare email into the email-keyed membership shape", () => {
428
+ expect(
429
+ resolveRevokeTargetToMembershipKey("alice@example.com", "cmp_abc"),
430
+ ).toBe("email:alice@example.com#cmp_abc");
431
+ });
432
+
433
+ it("lowercases the email when wrapping", () => {
434
+ expect(
435
+ resolveRevokeTargetToMembershipKey("Alice@Example.COM", "cmp_abc"),
436
+ ).toBe("email:alice@example.com#cmp_abc");
437
+ });
438
+
439
+ it("wraps a bare personUid into the personUid-keyed membership shape", () => {
440
+ expect(resolveRevokeTargetToMembershipKey("prs_bob123", "cmp_abc")).toBe(
441
+ "prs_bob123#cmp_abc",
442
+ );
443
+ });
444
+
445
+ it("passes through a full email-keyed membership key unchanged", () => {
446
+ expect(
447
+ resolveRevokeTargetToMembershipKey(
448
+ "email:alice@example.com#cmp_abc",
449
+ "cmp_other",
450
+ ),
451
+ ).toBe("email:alice@example.com#cmp_abc");
452
+ });
453
+
454
+ it("passes through a full personUid-keyed membership key unchanged", () => {
455
+ expect(
456
+ resolveRevokeTargetToMembershipKey("prs_bob#cmp_abc", "cmp_other"),
457
+ ).toBe("prs_bob#cmp_abc");
458
+ });
459
+
460
+ it("passes through unrecognized strings (legacy inviteToken, garbage)", () => {
461
+ // A schemaVersion-1 inviteToken is opaque base64url; we can't tell it
462
+ // apart from garbage. Send it to the server and let the server 404 if
463
+ // it doesn't resolve.
464
+ expect(
465
+ resolveRevokeTargetToMembershipKey("some-opaque-token", "cmp_abc"),
466
+ ).toBe("some-opaque-token");
467
+ });
468
+ });
469
+
417
470
  // ---------------------------------------------------------------------------
418
471
  // formatInviteHttpError
419
472
  // ---------------------------------------------------------------------------
@@ -248,6 +248,37 @@ export async function listPendingInvites(
248
248
  return data?.pending ?? data?.invites ?? [];
249
249
  }
250
250
 
251
+ /**
252
+ * Resolve a `revoke` CLI argument into the canonical `membershipKey` shape
253
+ * the server requires. Accepts three input forms:
254
+ *
255
+ * 1. Full membership key — already has `#<companyUid>`; passed through.
256
+ * Examples: `email:alice@example.com#cmp_abc`, `prs_abc#cmp_abc`.
257
+ * 2. Bare email — wrap as `email:<email>#<companyUid>`.
258
+ * 3. Bare personUid (`prs_*`) — wrap as `<personUid>#<companyUid>`.
259
+ * 4. Anything else (e.g. legacy schemaVersion-1 inviteToken) — pass through
260
+ * so the server can decide. Server may 404 if the token doesn't resolve.
261
+ *
262
+ * Pure function — no I/O — so it's trivially unit-testable. The previous
263
+ * shape sent the user's raw arg straight through, which meant `hq members
264
+ * revoke alice@example.com` always 404'd ("Invite not found") even when
265
+ * that exact email was just shown by `hq members list`.
266
+ */
267
+ export function resolveRevokeTargetToMembershipKey(
268
+ arg: string,
269
+ companyUid: string,
270
+ ): string {
271
+ if (arg.includes("#")) return arg;
272
+ const detected = detectTarget(arg);
273
+ if (detected?.type === "email") {
274
+ return `email:${detected.value}#${companyUid}`;
275
+ }
276
+ if (detected?.type === "person") {
277
+ return `${detected.value}#${companyUid}`;
278
+ }
279
+ return arg;
280
+ }
281
+
251
282
  export async function revokeInvite(
252
283
  token: string,
253
284
  tokenOrKey: string,
@@ -328,24 +359,47 @@ export function registerMembersCommand(program: Command): void {
328
359
  } else {
329
360
  // schemaVersion 2+ — email-keyed authoritative membership row.
330
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.
331
369
  const inviteeEmail =
332
370
  result.membership.inviteeEmail ??
333
371
  (typeof target === "string" && target.includes("@")
334
372
  ? target
335
373
  : undefined);
336
- console.log(chalk.bold("Next step:"));
337
374
  console.log(
338
- ` Tell ${inviteeEmail ?? "the invitee"} to sign into HQ at https://hq.getindigo.ai with that email.`,
375
+ chalk.yellow(
376
+ "⚠ No email was sent. `hq members invite` only creates the pending membership row.",
377
+ ),
378
+ );
379
+ 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`,
339
389
  );
390
+ console.log(
391
+ ` invite instead — the UI path triggers an automated email via Resend.`,
392
+ );
393
+ console.log();
340
394
  console.log(
341
395
  chalk.dim(
342
- " The pending membership row claims itself on first sign-in — no separate token redemption.",
396
+ "The pending membership row claims itself on the invitee's first sign-in.",
343
397
  ),
344
398
  );
345
399
  if (result.membership.membershipKey) {
346
400
  console.log();
347
401
  console.log(
348
- chalk.dim(` Membership key: ${result.membership.membershipKey}`),
402
+ chalk.dim(`Membership key: ${result.membership.membershipKey}`),
349
403
  );
350
404
  }
351
405
  }
@@ -431,16 +485,22 @@ export function registerMembersCommand(program: Command): void {
431
485
  });
432
486
 
433
487
  members
434
- .command("revoke <tokenOrKey>")
435
- .description("Revoke a pending invite (accepts the inviteToken or membershipKey)")
436
- .action(async (tokenOrKey: string) => {
488
+ .command("revoke <target>")
489
+ .description(
490
+ "Revoke a pending invite. Accepts an email, personUid, full membershipKey, or legacy inviteToken.",
491
+ )
492
+ .action(async (target: string) => {
437
493
  try {
438
494
  const token = await ensureCognitoToken();
439
495
  const companySlug = members.opts().company as string | undefined;
440
496
  const companyUid = await getCompanyUid(token, companySlug);
441
497
 
442
- await revokeInvite(token, tokenOrKey, companyUid);
443
- console.log(chalk.green(`Revoked invite '${tokenOrKey}'`));
498
+ const membershipKey = resolveRevokeTargetToMembershipKey(
499
+ target,
500
+ companyUid,
501
+ );
502
+ await revokeInvite(token, membershipKey, companyUid);
503
+ console.log(chalk.green(`Revoked invite '${membershipKey}'`));
444
504
  } catch (err) {
445
505
  if (err instanceof InviteHttpError) {
446
506
  const msg =