@indigoai-us/hq-cli 5.18.1 → 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
@@ -20,13 +20,29 @@ export interface InviteOptions {
20
20
  callerUid: string;
21
21
  token: string;
22
22
  }
23
+ /**
24
+ * Outcome of `hq members invite`. Two shapes depending on server schema:
25
+ *
26
+ * - **schemaVersion ≤ 1** — server returns a random `inviteToken` the
27
+ * invitee redeems via the `hq://accept/{token}` magic link. `magicLink`
28
+ * is populated so the caller can print or paste it.
29
+ * - **schemaVersion 2+** (current production) — membership row is
30
+ * email-keyed and authoritative. There is no token; the invitee
31
+ * accepts by signing into HQ with the same email. `inviteToken` +
32
+ * `magicLink` are both `undefined`; the caller prints sign-in
33
+ * instructions instead.
34
+ *
35
+ * `membership` is always populated when the server returned 2xx.
36
+ */
23
37
  export interface InviteResult {
24
- inviteToken: string;
25
- magicLink: string;
38
+ inviteToken?: string;
39
+ magicLink?: string;
26
40
  membership: {
41
+ membershipKey?: string;
27
42
  role: string;
28
43
  status: string;
29
44
  inviteToken?: string;
45
+ inviteeEmail?: string;
30
46
  };
31
47
  }
32
48
  export interface DetectedTarget {
@@ -50,6 +66,23 @@ export declare class InviteHttpError extends Error {
50
66
  }
51
67
  export declare function formatInviteHttpError(status: number, fallback: string, code?: string): string;
52
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;
53
86
  export declare function revokeInvite(token: string, tokenOrKey: string, companyUid: string): Promise<void>;
54
87
  export declare function registerMembersCommand(program: Command): void;
55
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]="5f8b5b62-a00e-5ca3-b293-23906579a464")}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";
@@ -72,19 +72,26 @@ export async function inviteMember(options) {
72
72
  throw new InviteHttpError(res.status, err.message ?? err.error ?? res.statusText, err.code);
73
73
  }
74
74
  const data = (await res.json());
75
- // The token may arrive at the response root OR nested on the membership row,
76
- // depending on vault-service version. Resolve from either; never emit
77
- // `hq://accept/undefined` (a broken link that looks like success).
78
- const inviteToken = data.inviteToken ?? data.membership?.inviteToken;
79
- if (!inviteToken) {
75
+ if (!data.membership) {
80
76
  const keys = Object.keys(data ?? {}).join(", ") || "<empty>";
81
- throw new Error(`Invite was created but the server response did not include an invite token (response keys: ${keys}). ` +
82
- "Run `hq members list` to retrieve the pending invite, or upgrade hq.");
77
+ throw new Error(`Invite endpoint returned 2xx with no membership row (response keys: ${keys}). ` +
78
+ "This is a server-side regression file an issue.");
83
79
  }
80
+ // Two server schemas in the wild:
81
+ // - Legacy (schemaVersion ≤ 1): response carries a random `inviteToken`
82
+ // the invitee redeems via `hq://accept/{token}`.
83
+ // - Current (schemaVersion 2+): membership row is email-keyed and
84
+ // authoritative — there is no token. The invitee accepts by signing
85
+ // into HQ with the invited email. The CLI must NOT throw here
86
+ // (previously did: "response did not include an invite token") — the
87
+ // invite IS successfully created on the server; the caller just gets
88
+ // undefined for inviteToken/magicLink and prints sign-in instructions.
89
+ const inviteToken = data.inviteToken ?? data.membership.inviteToken;
84
90
  return {
85
- inviteToken,
86
- magicLink: `hq://accept/${inviteToken}`,
87
- membership: data.membership ?? { role: options.role, status: "pending" },
91
+ ...(inviteToken
92
+ ? { inviteToken, magicLink: `hq://accept/${inviteToken}` }
93
+ : {}),
94
+ membership: data.membership,
88
95
  };
89
96
  }
90
97
  export class InviteHttpError extends Error {
@@ -122,8 +129,39 @@ export async function listPendingInvites(token, companyUid) {
122
129
  const err = (await res.json().catch(() => ({})));
123
130
  throw new InviteHttpError(res.status, err.message ?? err.error ?? res.statusText, err.code);
124
131
  }
132
+ // Server schema: `{ pending: [...] }`. Earlier dev branches used
133
+ // `{ invites: [...] }` which the CLI still accepts as a fallback for
134
+ // operators running staging stages that haven't caught up yet.
125
135
  const data = (await res.json());
126
- return data?.invites ?? [];
136
+ return data?.pending ?? data?.invites ?? [];
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;
127
165
  }
128
166
  export async function revokeInvite(token, tokenOrKey, companyUid) {
129
167
  const res = await vaultApiFetch({
@@ -163,10 +201,41 @@ export function registerMembersCommand(program) {
163
201
  });
164
202
  console.log(chalk.green(`Invited ${target} as ${result.membership.role} (status: ${result.membership.status})`));
165
203
  console.log();
166
- console.log(chalk.bold("Magic link:"));
167
- console.log(` ${result.magicLink}`);
168
- console.log();
169
- console.log(chalk.dim("Share this link with the invitee. They can run `hq onboard join --invite-token <token>` to accept."));
204
+ if (result.magicLink) {
205
+ // Legacy server schema — magic-link redemption.
206
+ console.log(chalk.bold("Magic link:"));
207
+ console.log(` ${result.magicLink}`);
208
+ console.log();
209
+ console.log(chalk.dim("Share this link with the invitee. They can run `hq onboard join --invite-token <token>` to accept."));
210
+ }
211
+ else {
212
+ // schemaVersion 2+ — email-keyed authoritative membership row.
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.
221
+ const inviteeEmail = result.membership.inviteeEmail ??
222
+ (typeof target === "string" && target.includes("@")
223
+ ? target
224
+ : undefined);
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."));
234
+ if (result.membership.membershipKey) {
235
+ console.log();
236
+ console.log(chalk.dim(`Membership key: ${result.membership.membershipKey}`));
237
+ }
238
+ }
170
239
  }
171
240
  catch (err) {
172
241
  if (err instanceof InviteHttpError) {
@@ -225,15 +294,16 @@ export function registerMembersCommand(program) {
225
294
  }
226
295
  });
227
296
  members
228
- .command("revoke <tokenOrKey>")
229
- .description("Revoke a pending invite (accepts the inviteToken or membershipKey)")
230
- .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) => {
231
300
  try {
232
301
  const token = await ensureCognitoToken();
233
302
  const companySlug = members.opts().company;
234
303
  const companyUid = await getCompanyUid(token, companySlug);
235
- await revokeInvite(token, tokenOrKey, companyUid);
236
- 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}'`));
237
307
  }
238
308
  catch (err) {
239
309
  if (err instanceof InviteHttpError) {
@@ -253,4 +323,4 @@ export function registerMembersCommand(program) {
253
323
  });
254
324
  }
255
325
  //# sourceMappingURL=members.js.map
256
- //# debugId=5f8b5b62-a00e-5ca3-b293-23906579a464
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.1",
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
 
@@ -246,13 +247,49 @@ describe("inviteMember", () => {
246
247
  expect(result.magicLink).toBe("hq://accept/tok_nested");
247
248
  });
248
249
 
249
- it("throws instead of emitting hq://accept/undefined when no token is present", async () => {
250
+ it("schemaVersion 2: no inviteToken in response success with undefined magicLink", async () => {
251
+ // 2026-05-21 fix: the server moved to email-keyed authoritative
252
+ // membership rows (schemaVersion 2) — the invite IS created, but
253
+ // there is no token to redeem. The CLI must NOT throw here; instead
254
+ // the action handler prints "sign in with the invited email"
255
+ // instructions. Previously this case threw "did not include an
256
+ // invite token" which produced false-failure UX on a working invite.
250
257
  fetchSpy.mockResolvedValueOnce(
251
- jsonResponse(200, {
252
- membership: { role: "admin", status: "pending" },
258
+ jsonResponse(201, {
259
+ membership: {
260
+ membershipKey: "email:alice@example.com#cmp_acme",
261
+ role: "member",
262
+ status: "pending",
263
+ inviteeEmail: "alice@example.com",
264
+ schemaVersion: 2,
265
+ },
253
266
  }),
254
267
  );
255
268
 
269
+ const result = await inviteMember({
270
+ target: "alice@example.com",
271
+ role: "member",
272
+ companyUid: "cmp_acme",
273
+ callerUid: "prs_admin",
274
+ token: "test-token",
275
+ });
276
+
277
+ expect(result.inviteToken).toBeUndefined();
278
+ expect(result.magicLink).toBeUndefined();
279
+ expect(result.membership.role).toBe("member");
280
+ expect(result.membership.status).toBe("pending");
281
+ expect(result.membership.membershipKey).toBe(
282
+ "email:alice@example.com#cmp_acme",
283
+ );
284
+ expect(result.membership.inviteeEmail).toBe("alice@example.com");
285
+ });
286
+
287
+ it("throws when the response has no membership row at all (server bug)", async () => {
288
+ // Belt-and-suspenders: a 2xx response with NO membership row is a
289
+ // server-side regression — surface it loudly so it doesn't silently
290
+ // succeed-but-do-nothing.
291
+ fetchSpy.mockResolvedValueOnce(jsonResponse(201, {}));
292
+
256
293
  await expect(
257
294
  inviteMember({
258
295
  target: "alice@example.com",
@@ -261,7 +298,7 @@ describe("inviteMember", () => {
261
298
  callerUid: "prs_admin",
262
299
  token: "test-token",
263
300
  }),
264
- ).rejects.toThrow(/did not include an invite token/);
301
+ ).rejects.toThrow(/no membership row/);
265
302
  });
266
303
  });
267
304
 
@@ -313,6 +350,47 @@ describe("listPendingInvites", () => {
313
350
  fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
314
351
  await expect(listPendingInvites("test-token", "cmp_acme")).resolves.toEqual([]);
315
352
  });
353
+
354
+ it("schemaVersion 2: reads `pending` key (the canonical server response)", async () => {
355
+ // 2026-05-21 fix: live server returns `{ pending: [...] }`, not
356
+ // `{ invites: [...] }`. The CLI must read `pending` as the primary
357
+ // and fall back to `invites` for older stages.
358
+ fetchSpy.mockResolvedValueOnce(
359
+ jsonResponse(200, {
360
+ pending: [
361
+ {
362
+ membershipKey: "email:alice@example.com#cmp_acme",
363
+ inviteeEmail: "alice@example.com",
364
+ companyUid: "cmp_acme",
365
+ role: "member",
366
+ status: "pending",
367
+ invitedBy: "prs_admin",
368
+ invitedAt: "2026-05-21T12:00:00Z",
369
+ schemaVersion: 2,
370
+ },
371
+ ],
372
+ }),
373
+ );
374
+
375
+ const invites = await listPendingInvites("test-token", "cmp_acme");
376
+ expect(invites).toHaveLength(1);
377
+ expect(invites[0].membershipKey).toBe("email:alice@example.com#cmp_acme");
378
+ expect(invites[0].inviteeEmail).toBe("alice@example.com");
379
+ });
380
+
381
+ it("prefers `pending` over legacy `invites` when both present (server transition)", async () => {
382
+ // Defensive: an in-flight server deploy could briefly return BOTH
383
+ // fields. CLI takes the canonical `pending` key.
384
+ fetchSpy.mockResolvedValueOnce(
385
+ jsonResponse(200, {
386
+ pending: [{ membershipKey: "k_pending" } as never],
387
+ invites: [{ membershipKey: "k_legacy" } as never],
388
+ }),
389
+ );
390
+ const invites = await listPendingInvites("test-token", "cmp_acme");
391
+ expect(invites).toHaveLength(1);
392
+ expect(invites[0].membershipKey).toBe("k_pending");
393
+ });
316
394
  });
317
395
 
318
396
  // ---------------------------------------------------------------------------
@@ -337,6 +415,58 @@ describe("revokeInvite", () => {
337
415
  });
338
416
  });
339
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
+
340
470
  // ---------------------------------------------------------------------------
341
471
  // formatInviteHttpError
342
472
  // ---------------------------------------------------------------------------
@@ -38,10 +38,30 @@ export interface InviteOptions {
38
38
  token: string;
39
39
  }
40
40
 
41
+ /**
42
+ * Outcome of `hq members invite`. Two shapes depending on server schema:
43
+ *
44
+ * - **schemaVersion ≤ 1** — server returns a random `inviteToken` the
45
+ * invitee redeems via the `hq://accept/{token}` magic link. `magicLink`
46
+ * is populated so the caller can print or paste it.
47
+ * - **schemaVersion 2+** (current production) — membership row is
48
+ * email-keyed and authoritative. There is no token; the invitee
49
+ * accepts by signing into HQ with the same email. `inviteToken` +
50
+ * `magicLink` are both `undefined`; the caller prints sign-in
51
+ * instructions instead.
52
+ *
53
+ * `membership` is always populated when the server returned 2xx.
54
+ */
41
55
  export interface InviteResult {
42
- inviteToken: string;
43
- magicLink: string;
44
- membership: { role: string; status: string; inviteToken?: string };
56
+ inviteToken?: string;
57
+ magicLink?: string;
58
+ membership: {
59
+ membershipKey?: string;
60
+ role: string;
61
+ status: string;
62
+ inviteToken?: string;
63
+ inviteeEmail?: string;
64
+ };
45
65
  }
46
66
 
47
67
  export interface DetectedTarget {
@@ -137,24 +157,38 @@ export async function inviteMember(
137
157
  }
138
158
 
139
159
  const data = (await res.json()) as {
140
- membership?: { role: string; status: string; inviteToken?: string };
160
+ membership?: {
161
+ membershipKey?: string;
162
+ role: string;
163
+ status: string;
164
+ inviteToken?: string;
165
+ inviteeEmail?: string;
166
+ schemaVersion?: number;
167
+ };
141
168
  inviteToken?: string;
142
169
  };
143
- // The token may arrive at the response root OR nested on the membership row,
144
- // depending on vault-service version. Resolve from either; never emit
145
- // `hq://accept/undefined` (a broken link that looks like success).
146
- const inviteToken = data.inviteToken ?? data.membership?.inviteToken;
147
- if (!inviteToken) {
170
+ if (!data.membership) {
148
171
  const keys = Object.keys(data ?? {}).join(", ") || "<empty>";
149
172
  throw new Error(
150
- `Invite was created but the server response did not include an invite token (response keys: ${keys}). ` +
151
- "Run `hq members list` to retrieve the pending invite, or upgrade hq.",
173
+ `Invite endpoint returned 2xx with no membership row (response keys: ${keys}). ` +
174
+ "This is a server-side regression file an issue.",
152
175
  );
153
176
  }
177
+ // Two server schemas in the wild:
178
+ // - Legacy (schemaVersion ≤ 1): response carries a random `inviteToken`
179
+ // the invitee redeems via `hq://accept/{token}`.
180
+ // - Current (schemaVersion 2+): membership row is email-keyed and
181
+ // authoritative — there is no token. The invitee accepts by signing
182
+ // into HQ with the invited email. The CLI must NOT throw here
183
+ // (previously did: "response did not include an invite token") — the
184
+ // invite IS successfully created on the server; the caller just gets
185
+ // undefined for inviteToken/magicLink and prints sign-in instructions.
186
+ const inviteToken = data.inviteToken ?? data.membership.inviteToken;
154
187
  return {
155
- inviteToken,
156
- magicLink: `hq://accept/${inviteToken}`,
157
- membership: data.membership ?? { role: options.role, status: "pending" },
188
+ ...(inviteToken
189
+ ? { inviteToken, magicLink: `hq://accept/${inviteToken}` }
190
+ : {}),
191
+ membership: data.membership,
158
192
  };
159
193
  }
160
194
 
@@ -204,8 +238,45 @@ export async function listPendingInvites(
204
238
  err.code,
205
239
  );
206
240
  }
207
- const data = (await res.json()) as { invites?: PendingInvite[] | null };
208
- return data?.invites ?? [];
241
+ // Server schema: `{ pending: [...] }`. Earlier dev branches used
242
+ // `{ invites: [...] }` which the CLI still accepts as a fallback for
243
+ // operators running staging stages that haven't caught up yet.
244
+ const data = (await res.json()) as {
245
+ pending?: PendingInvite[] | null;
246
+ invites?: PendingInvite[] | null;
247
+ };
248
+ return data?.pending ?? data?.invites ?? [];
249
+ }
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;
209
280
  }
210
281
 
211
282
  export async function revokeInvite(
@@ -275,14 +346,63 @@ export function registerMembersCommand(program: Command): void {
275
346
  ),
276
347
  );
277
348
  console.log();
278
- console.log(chalk.bold("Magic link:"));
279
- console.log(` ${result.magicLink}`);
280
- console.log();
281
- console.log(
282
- chalk.dim(
283
- "Share this link with the invitee. They can run `hq onboard join --invite-token <token>` to accept.",
284
- ),
285
- );
349
+ if (result.magicLink) {
350
+ // Legacy server schema — magic-link redemption.
351
+ console.log(chalk.bold("Magic link:"));
352
+ console.log(` ${result.magicLink}`);
353
+ console.log();
354
+ console.log(
355
+ chalk.dim(
356
+ "Share this link with the invitee. They can run `hq onboard join --invite-token <token>` to accept.",
357
+ ),
358
+ );
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);
374
+ console.log(
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`,
389
+ );
390
+ console.log(
391
+ ` invite instead — the UI path triggers an automated email via Resend.`,
392
+ );
393
+ console.log();
394
+ console.log(
395
+ chalk.dim(
396
+ "The pending membership row claims itself on the invitee's first sign-in.",
397
+ ),
398
+ );
399
+ if (result.membership.membershipKey) {
400
+ console.log();
401
+ console.log(
402
+ chalk.dim(`Membership key: ${result.membership.membershipKey}`),
403
+ );
404
+ }
405
+ }
286
406
  } catch (err) {
287
407
  if (err instanceof InviteHttpError) {
288
408
  console.error(
@@ -365,16 +485,22 @@ export function registerMembersCommand(program: Command): void {
365
485
  });
366
486
 
367
487
  members
368
- .command("revoke <tokenOrKey>")
369
- .description("Revoke a pending invite (accepts the inviteToken or membershipKey)")
370
- .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) => {
371
493
  try {
372
494
  const token = await ensureCognitoToken();
373
495
  const companySlug = members.opts().company as string | undefined;
374
496
  const companyUid = await getCompanyUid(token, companySlug);
375
497
 
376
- await revokeInvite(token, tokenOrKey, companyUid);
377
- 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}'`));
378
504
  } catch (err) {
379
505
  if (err instanceof InviteHttpError) {
380
506
  const msg =