@indigoai-us/hq-cli 5.15.0 → 5.17.0-sources-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.github/workflows/ci.yml +8 -4
  2. package/.github/workflows/publish.yml +8 -3
  3. package/dist/cli-version.d.ts +1 -0
  4. package/dist/cli-version.js +3 -2
  5. package/dist/commands/cloud-provision.d.ts +25 -0
  6. package/dist/commands/cloud-provision.js +75 -9
  7. package/dist/commands/groups.js +6 -6
  8. package/dist/commands/meetings.js +8 -8
  9. package/dist/commands/members.d.ts +4 -2
  10. package/dist/commands/members.js +30 -14
  11. package/dist/commands/signals.d.ts +24 -0
  12. package/dist/commands/signals.js +240 -0
  13. package/dist/commands/sources.d.ts +22 -0
  14. package/dist/commands/sources.js +250 -0
  15. package/dist/index.js +8 -2
  16. package/dist/utils/cognito-session.d.ts +10 -1
  17. package/dist/utils/cognito-session.js +18 -3
  18. package/package.json +5 -4
  19. package/scripts/smoke-sources-signals.sh +103 -0
  20. package/src/cli-version.ts +5 -1
  21. package/src/commands/cloud-provision.test.ts +27 -2
  22. package/src/commands/cloud-provision.ts +122 -7
  23. package/src/commands/groups.ts +4 -4
  24. package/src/commands/meetings.ts +6 -6
  25. package/src/commands/members.test.ts +56 -0
  26. package/src/commands/members.ts +44 -13
  27. package/src/commands/signals.ts +345 -0
  28. package/src/commands/sources.ts +356 -0
  29. package/src/index.ts +8 -0
  30. package/src/utils/cognito-session.test.ts +24 -1
  31. package/src/utils/cognito-session.ts +18 -0
  32. package/test/commands/signals.test.ts +200 -0
  33. package/test/commands/sources.test.ts +225 -0
  34. package/test/fixtures/signals/action_item/sample.md +16 -0
  35. package/test/fixtures/signals/summary/sample.md +12 -0
  36. package/test/fixtures/sources/meetings/sample.md +25 -0
  37. package/test/helpers/cli-runner.ts +150 -0
  38. package/test/helpers/s3-list-mock.ts +79 -0
  39. package/test/helpers/vault-service-mock.ts +160 -0
  40. package/test/sources-signals/smoke.test.ts +226 -0
  41. package/vitest.config.ts +11 -0
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # smoke-sources-signals.sh — end-to-end verification of `hq sources` /
4
+ # `hq signals` against a real vault bucket. Owned by US-008 of the
5
+ # hq-cli-sources-signals PRD.
6
+ #
7
+ # Usage:
8
+ # # 1. Make sure you have a working Cognito session OR export a token:
9
+ # hq whoami # interactive Cognito login if needed
10
+ # # or: export HQ_ACCESS_TOKEN="$(...)" # service-account JWT bypass
11
+ #
12
+ # # 2. Run the smoke against an entity slug you have access to (default: indigo):
13
+ # ./scripts/smoke-sources-signals.sh # uses ENTITY=indigo
14
+ # ENTITY=personal ./scripts/smoke-sources-signals.sh
15
+ #
16
+ # Behavior:
17
+ # Runs four CLI invocations, each asserted to exit 0 AND produce parseable
18
+ # JSON (where --format json is used). Stdout from each invocation is
19
+ # appended to a temp file; the script prints a summary table at the end.
20
+ #
21
+ # Exit codes:
22
+ # 0 all four invocations succeeded and produced valid JSON
23
+ # 1 one or more invocations failed (script aborts on first failure with set -e)
24
+ #
25
+ # Dependencies:
26
+ # - hq (built locally via `pnpm --filter @indigoai-us/hq-cli build`, or
27
+ # `npx @indigoai-us/hq-cli@rc ...` against a published rc)
28
+ # - jq (for JSON validation)
29
+ #
30
+
31
+ set -euo pipefail
32
+
33
+ ENTITY="${ENTITY:-indigo}"
34
+ LIMIT="${LIMIT:-5}"
35
+ HQ_BIN="${HQ_BIN:-hq}"
36
+
37
+ # Resolve workspace-local journal location for capturing output. The
38
+ # operator running this script may redirect the script's stdout to a journal
39
+ # file under companies/indigo/projects/hq-cli-sources-signals/journal/.
40
+ SMOKE_TMP="$(mktemp -d -t hq-smoke-XXXXXX)"
41
+ trap 'rm -rf "$SMOKE_TMP"' EXIT
42
+
43
+ echo "==> hq sources/signals smoke for entity=$ENTITY limit=$LIMIT"
44
+ echo " binary: $HQ_BIN"
45
+ echo " tmpdir: $SMOKE_TMP"
46
+ echo
47
+
48
+ require_cmd() {
49
+ if ! command -v "$1" >/dev/null 2>&1; then
50
+ echo "ERROR: required command '$1' not found in PATH" >&2
51
+ exit 1
52
+ fi
53
+ }
54
+
55
+ require_cmd "$HQ_BIN"
56
+ require_cmd jq
57
+
58
+ # --- 1. entities -----------------------------------------------------------
59
+ echo "[1/4] hq sources entities"
60
+ "$HQ_BIN" sources entities --format json > "$SMOKE_TMP/entities.json"
61
+ jq -e 'type == "array"' "$SMOKE_TMP/entities.json" > /dev/null
62
+ ENTITY_COUNT=$(jq 'length' "$SMOKE_TMP/entities.json")
63
+ echo " ok — $ENTITY_COUNT entity/entities visible"
64
+ echo
65
+
66
+ # --- 2. sources channels (no auth required) --------------------------------
67
+ echo "[2/4] hq sources channels"
68
+ "$HQ_BIN" sources channels > "$SMOKE_TMP/channels.txt"
69
+ grep -qx 'meeting' "$SMOKE_TMP/channels.txt"
70
+ grep -qx 'email' "$SMOKE_TMP/channels.txt"
71
+ grep -qx 'slack' "$SMOKE_TMP/channels.txt"
72
+ grep -qx 'linear' "$SMOKE_TMP/channels.txt"
73
+ grep -qx 'notion' "$SMOKE_TMP/channels.txt"
74
+ echo " ok — all five canonical channels present"
75
+ echo
76
+
77
+ # --- 3. sources list --type meeting (the headline acceptance criterion) ---
78
+ echo "[3/4] hq sources list --entity $ENTITY --type meeting --limit $LIMIT --format json"
79
+ "$HQ_BIN" sources list \
80
+ --entity "$ENTITY" \
81
+ --type meeting \
82
+ --limit "$LIMIT" \
83
+ --format json > "$SMOKE_TMP/sources-list.json"
84
+ jq -e '.entries | type == "array"' "$SMOKE_TMP/sources-list.json" > /dev/null
85
+ SOURCE_COUNT=$(jq '.entries | length' "$SMOKE_TMP/sources-list.json")
86
+ echo " ok — $SOURCE_COUNT meeting source(s) listed"
87
+ echo
88
+
89
+ # --- 4. signals list --type action_item (the other headline) --------------
90
+ echo "[4/4] hq signals list --entity $ENTITY --type action_item --limit $LIMIT --format json"
91
+ "$HQ_BIN" signals list \
92
+ --entity "$ENTITY" \
93
+ --type action_item \
94
+ --limit "$LIMIT" \
95
+ --format json > "$SMOKE_TMP/signals-list.json"
96
+ jq -e '.entries | type == "array"' "$SMOKE_TMP/signals-list.json" > /dev/null
97
+ SIGNAL_COUNT=$(jq '.entries | length' "$SMOKE_TMP/signals-list.json")
98
+ echo " ok — $SIGNAL_COUNT action_item signal(s) listed"
99
+ echo
100
+
101
+ echo "==> SMOKE PASSED"
102
+ echo " entities=$ENTITY_COUNT meeting_sources=$SOURCE_COUNT action_item_signals=$SIGNAL_COUNT"
103
+ echo " raw outputs preserved in $SMOKE_TMP until process exit"
@@ -4,6 +4,10 @@ import path from "node:path";
4
4
 
5
5
  const here = path.dirname(fileURLToPath(import.meta.url));
6
6
  const pkgPath = path.resolve(here, "..", "package.json");
7
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version: string };
7
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
8
+ name: string;
9
+ version: string;
10
+ };
8
11
 
12
+ export const CLI_NAME: string = pkg.name;
9
13
  export const CLI_VERSION: string = pkg.version;
@@ -582,6 +582,20 @@ describe("provisionCompany", () => {
582
582
  listMyPersonEntities: vi.fn().mockResolvedValue([
583
583
  { uid: "prs_01H", type: "person", slug: "test-user", name: "Test User" },
584
584
  ]),
585
+ // Default to "slug available in caller's namespace" so happy-path
586
+ // tests fall through to createCompanyEntity. Reuse-path tests
587
+ // override with {available: false, conflictingCompanyUid: ...}
588
+ // and supply a getCompanyByUid that returns the existing entity.
589
+ checkSlugInMyNamespace: vi
590
+ .fn()
591
+ .mockResolvedValue({ available: true }),
592
+ getCompanyByUid: vi
593
+ .fn()
594
+ .mockRejectedValue(
595
+ new Error(
596
+ "getCompanyByUid called without an explicit per-test mock — happy path should never hit it",
597
+ ),
598
+ ),
585
599
  findCompanyBySlug: vi.fn().mockResolvedValue(null),
586
600
  createCompanyEntity: vi.fn(),
587
601
  ...overrides,
@@ -716,7 +730,13 @@ describe("provisionCompany", () => {
716
730
  kmsKeyId: null,
717
731
  };
718
732
  const vaultClient = makeVaultClient({
719
- findCompanyBySlug: vi.fn().mockResolvedValue(entity),
733
+ // Reuse path under the per-user-namespace model: checkSlugInMyNamespace
734
+ // reports `available: false` with the existing entity's uid, and
735
+ // getCompanyByUid materializes the full entity for downstream use.
736
+ checkSlugInMyNamespace: vi
737
+ .fn()
738
+ .mockResolvedValue({ available: false, conflictingCompanyUid: entity.uid }),
739
+ getCompanyByUid: vi.fn().mockResolvedValue(entity),
720
740
  createCompanyEntity: vi.fn(),
721
741
  });
722
742
  const result = await provisionCompany({
@@ -731,6 +751,7 @@ describe("provisionCompany", () => {
731
751
  expect(result.created_entity).toBe(false);
732
752
  expect(result.kms_key_id).toBeNull();
733
753
  expect(vaultClient.createCompanyEntity).not.toHaveBeenCalled();
754
+ expect(vaultClient.getCompanyByUid).toHaveBeenCalledWith(entity.uid);
734
755
  });
735
756
 
736
757
  it("throws code 1 when entity has no bucketName (incomplete provisioning)", async () => {
@@ -743,7 +764,11 @@ describe("provisionCompany", () => {
743
764
  // bucketName intentionally absent
744
765
  };
745
766
  const vaultClient = makeVaultClient({
746
- findCompanyBySlug: vi.fn().mockResolvedValue(entity),
767
+ // Same reuse-path mock shape as the idempotent-path test above.
768
+ checkSlugInMyNamespace: vi
769
+ .fn()
770
+ .mockResolvedValue({ available: false, conflictingCompanyUid: entity.uid }),
771
+ getCompanyByUid: vi.fn().mockResolvedValue(entity),
747
772
  });
748
773
  try {
749
774
  await provisionCompany({
@@ -133,7 +133,32 @@ export interface VaultClient {
133
133
  * entity.
134
134
  */
135
135
  listMyPersonEntities(): Promise<VaultEntity[]>;
136
+ /**
137
+ * Legacy global-uniqueness lookup. Under the per-user-namespace model
138
+ * (hq-pro 2026-05-15) this can return any tenant's entity when more
139
+ * than one user holds the same slug, OR `null` when the caller doesn't
140
+ * have it but a different user does. Kept on the interface for any
141
+ * remaining callers, but `provisionCompany` now uses
142
+ * `checkSlugInMyNamespace` instead — same-slug-different-owner is
143
+ * legitimate and should NOT trigger reuse of the stranger's entity.
144
+ */
136
145
  findCompanyBySlug(slug: string): Promise<VaultEntity | null>;
146
+ /**
147
+ * Caller-scoped slug availability check via
148
+ * `GET /entity/check-slug/me?type=company&slug=...`. Returns
149
+ * `{available: true}` when the caller's namespace
150
+ * (owned ∪ active-member-of, soft-deleted excluded) doesn't hold the
151
+ * slug, or `{available: false, conflictingCompanyUid}` when it does
152
+ * — `provisionCompany` reuses the `conflictingCompanyUid` as the
153
+ * idempotent entity instead of creating a duplicate.
154
+ */
155
+ checkSlugInMyNamespace(slug: string): Promise<{
156
+ available: boolean;
157
+ conflictingCompanyUid?: string;
158
+ }>;
159
+ /** Fetch a company entity by uid. Used to materialize the entity
160
+ * after `checkSlugInMyNamespace` reports a same-namespace collision. */
161
+ getCompanyByUid(uid: string): Promise<VaultEntity>;
137
162
  createCompanyEntity(input: {
138
163
  slug: string;
139
164
  name: string;
@@ -475,6 +500,45 @@ export function createDefaultVaultClient(
475
500
  }
476
501
  return data.entity;
477
502
  },
503
+ async checkSlugInMyNamespace(slug: string): Promise<{
504
+ available: boolean;
505
+ conflictingCompanyUid?: string;
506
+ }> {
507
+ const url = `${apiUrl.replace(/\/$/, "")}/entity/check-slug/me?type=company&slug=${encodeURIComponent(
508
+ slug,
509
+ )}`;
510
+ const res = await fetch(url, { method: "GET", headers });
511
+ if (!res.ok) {
512
+ const body = await safeBody(res);
513
+ throw new ProvisionError(
514
+ 1,
515
+ `Vault GET /entity/check-slug/me failed: ${res.status} ${res.statusText} — ${body}`,
516
+ );
517
+ }
518
+ return (await res.json()) as {
519
+ available: boolean;
520
+ conflictingCompanyUid?: string;
521
+ };
522
+ },
523
+ async getCompanyByUid(uid: string): Promise<VaultEntity> {
524
+ const url = `${apiUrl.replace(/\/$/, "")}/entity/${encodeURIComponent(uid)}`;
525
+ const res = await fetch(url, { method: "GET", headers });
526
+ if (!res.ok) {
527
+ const body = await safeBody(res);
528
+ throw new ProvisionError(
529
+ 1,
530
+ `Vault GET /entity/${uid} failed: ${res.status} ${res.statusText} — ${body}`,
531
+ );
532
+ }
533
+ const data = (await res.json()) as { entity?: VaultEntity };
534
+ if (!data.entity) {
535
+ throw new ProvisionError(
536
+ 1,
537
+ `Vault GET /entity/${uid} returned 200 with no entity body`,
538
+ );
539
+ }
540
+ return data.entity;
541
+ },
478
542
  async createCompanyEntity(input: {
479
543
  slug: string;
480
544
  name: string;
@@ -494,9 +558,15 @@ export function createDefaultVaultClient(
494
558
  });
495
559
  if (!res.ok) {
496
560
  const text = await safeBody(res);
497
- // 409 means a concurrent client created it between our GET and POST —
498
- // surface it as a vault error. The orchestrator is responsible for
499
- // retrying GET if it wants idempotency on collisions.
561
+ // 409 SLUG_IN_USE_FOR_PERSON: the caller already has the slug
562
+ // in their namespace (owned ∪ active-member-of). Under the
563
+ // per-user-namespace model this is the new same-user-collision
564
+ // signal — distinct from the legacy global EntityAlreadyExists.
565
+ // The CLI normally reaches `createCompanyEntity` only after
566
+ // `checkSlugInMyNamespace` reported `available: true`, so a
567
+ // 409 here means a race between the pre-check and the POST.
568
+ // Surface the response body verbatim so the caller can see the
569
+ // `code` + `conflictingCompanyUid` and resolve / retry.
500
570
  throw new ProvisionError(
501
571
  1,
502
572
  `Vault POST /entity failed: ${res.status} ${res.statusText} — ${text}`,
@@ -600,12 +670,57 @@ export async function provisionCompany(
600
670
  }
601
671
  log(`pre-flight ok — caller has ${persons.length} person entity(ies)`);
602
672
 
603
- let entity = await vaultClient.findCompanyBySlug(options.slug);
673
+ // Per-user-namespace-aware reuse-or-create. Replaces the legacy
674
+ // global `findCompanyBySlug` lookup, which under the per-user model
675
+ // (hq-pro 2026-05-15) returns ANY tenant's entity when more than one
676
+ // user holds the same slug, OR null when a different user has it —
677
+ // both wrong for the CLI's "reuse mine, or create" intent.
678
+ //
679
+ // `--owner` override: `options.ownerUid`, when set, lets a caller
680
+ // create the entity under a DIFFERENT person's ownership (e.g. an
681
+ // admin provisioning on behalf of someone). `/entity/check-slug/me`
682
+ // answers about the CALLER's namespace, not the target owner's, so
683
+ // the pre-check is meaningless in that case. Codex P2 on PR 7
684
+ // flagged this. The gate: only run the namespace check when the
685
+ // owner is the caller (or defaulted to the caller — i.e. no
686
+ // --owner supplied). On override, fall through to
687
+ // `createCompanyEntity` and let the server's authoritative 409
688
+ // (which IS scoped to the target's namespace, per the
689
+ // callerIsOwner gate on POST /entity in hq-pro PR 67) surface any
690
+ // real conflict.
691
+ //
692
+ // `callerIsOwner` is `true` whenever `options.ownerUid` is unset
693
+ // (defaults to caller server-side) OR — when set — happens to
694
+ // match the caller's own person UID(s) from `listMyPersonEntities`.
695
+ const callerOwnedUids = new Set(persons.map((p) => p.uid));
696
+ const callerIsOwner =
697
+ !options.ownerUid || callerOwnedUids.has(options.ownerUid);
698
+
699
+ let entity: VaultEntity;
604
700
  let createdEntity = false;
605
- if (entity) {
606
- log(`reusing existing vault entity uid=${entity.uid}`);
701
+ if (callerIsOwner) {
702
+ const slugCheck = await vaultClient.checkSlugInMyNamespace(options.slug);
703
+ if (!slugCheck.available && slugCheck.conflictingCompanyUid) {
704
+ log(
705
+ `reusing existing vault entity uid=${slugCheck.conflictingCompanyUid} (slug already in caller's namespace)`,
706
+ );
707
+ entity = await vaultClient.getCompanyByUid(
708
+ slugCheck.conflictingCompanyUid,
709
+ );
710
+ } else {
711
+ log(`slug available in caller's namespace — creating vault entity`);
712
+ entity = await vaultClient.createCompanyEntity({
713
+ slug: options.slug,
714
+ name: options.name ?? options.slug,
715
+ ownerUid: options.ownerUid,
716
+ });
717
+ createdEntity = true;
718
+ log(`created vault entity uid=${entity.uid}`);
719
+ }
607
720
  } else {
608
- log(`vault entity not found — creating`);
721
+ log(
722
+ `--owner ${options.ownerUid} differs from caller's person(s); skipping namespace pre-check (server authoritatively gates per-target-namespace)`,
723
+ );
609
724
  entity = await vaultClient.createCompanyEntity({
610
725
  slug: options.slug,
611
726
  name: options.name ?? options.slug,
@@ -66,7 +66,7 @@ export function registerGroupsCommand(program: Command): void {
66
66
  if (res.status === 401) {
67
67
  console.error(chalk.red("Not authenticated — please run `hq login`"));
68
68
  } else if (res.status === 403) {
69
- console.error(chalk.red("Not authorized — owner or admin role required"));
69
+ console.error(chalk.red("Not authorized — owner role required"));
70
70
  } else if (res.status === 409) {
71
71
  console.error(chalk.red(`Group already exists: ${groupId}`));
72
72
  } else if (res.status >= 500) {
@@ -110,7 +110,7 @@ export function registerGroupsCommand(program: Command): void {
110
110
  if (res.status === 401) {
111
111
  console.error(chalk.red("Not authenticated — please run `hq login`"));
112
112
  } else if (res.status === 403) {
113
- console.error(chalk.red("Not authorized — owner or admin role required"));
113
+ console.error(chalk.red("Not authorized — owner role required"));
114
114
  } else if (res.status === 404) {
115
115
  console.error(chalk.red(`Group not found: ${groupId}`));
116
116
  } else if (res.status >= 500) {
@@ -160,7 +160,7 @@ export function registerGroupsCommand(program: Command): void {
160
160
  if (res.status === 401) {
161
161
  console.error(chalk.red("Not authenticated — please run `hq login`"));
162
162
  } else if (res.status === 403) {
163
- console.error(chalk.red("Not authorized — owner/admin role or group creator required"));
163
+ console.error(chalk.red("Not authorized — owner role or group creator required"));
164
164
  } else if (res.status === 404) {
165
165
  // Server provides a helpful message (email not found vs group not found)
166
166
  console.error(chalk.red(err.error ?? `Not found`));
@@ -215,7 +215,7 @@ export function registerGroupsCommand(program: Command): void {
215
215
  if (res.status === 401) {
216
216
  console.error(chalk.red("Not authenticated — please run `hq login`"));
217
217
  } else if (res.status === 403) {
218
- console.error(chalk.red("Not authorized — owner/admin role or group creator required"));
218
+ console.error(chalk.red("Not authorized — owner role or group creator required"));
219
219
  } else if (res.status === 404) {
220
220
  console.error(chalk.red(err.error ?? `Not found`));
221
221
  } else if (res.status >= 500) {
@@ -1,7 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import chalk from "chalk";
3
3
  import { ensureCognitoToken } from "../utils/cognito-session.js";
4
- import { vaultApiFetch } from "../utils/vault-api.js";
4
+ import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
5
5
 
6
6
  interface MeetingListItem {
7
7
  meetingId: string;
@@ -210,7 +210,7 @@ export function registerMeetingsCommand(program: Command): void {
210
210
 
211
211
  if (opts.limit) query.limit = opts.limit;
212
212
  if (opts.next) query.nextToken = opts.next;
213
- if (companySlug) query.companyId = companySlug;
213
+ if (companySlug) query.companyId = await getCompanyUid(token, companySlug);
214
214
 
215
215
  const res = await vaultApiFetch({ token, path: "/v1/meetings", query });
216
216
  if (!res.ok) await handleApiError(res);
@@ -250,7 +250,7 @@ export function registerMeetingsCommand(program: Command): void {
250
250
  const token = await ensureCognitoToken();
251
251
  const query: Record<string, string> = {};
252
252
  const companySlug = meetings.opts().company as string | undefined;
253
- if (companySlug) query.companyId = companySlug;
253
+ if (companySlug) query.companyId = await getCompanyUid(token, companySlug);
254
254
 
255
255
  const meetingId = await resolveShortId(token, rawId, query);
256
256
  const res = await vaultApiFetch({
@@ -309,7 +309,7 @@ export function registerMeetingsCommand(program: Command): void {
309
309
  const token = await ensureCognitoToken();
310
310
  const params: Record<string, string> = { q: query };
311
311
  const companySlug = meetings.opts().company as string | undefined;
312
- if (companySlug) params.companyId = companySlug;
312
+ if (companySlug) params.companyId = await getCompanyUid(token, companySlug);
313
313
 
314
314
  const res = await vaultApiFetch({
315
315
  token,
@@ -347,7 +347,7 @@ export function registerMeetingsCommand(program: Command): void {
347
347
  const token = await ensureCognitoToken();
348
348
  const query: Record<string, string> = {};
349
349
  const companySlug = meetings.opts().company as string | undefined;
350
- if (companySlug) query.companyId = companySlug;
350
+ if (companySlug) query.companyId = await getCompanyUid(token, companySlug);
351
351
 
352
352
  const meetingId = await resolveShortId(token, rawId, query);
353
353
  const res = await vaultApiFetch({
@@ -403,7 +403,7 @@ export function registerMeetingsCommand(program: Command): void {
403
403
  const token = await ensureCognitoToken();
404
404
  const query: Record<string, string> = {};
405
405
  const companySlug = meetings.opts().company as string | undefined;
406
- if (companySlug) query.companyId = companySlug;
406
+ if (companySlug) query.companyId = await getCompanyUid(token, companySlug);
407
407
 
408
408
  const meetingId = await resolveShortId(token, rawId, query);
409
409
  const res = await vaultApiFetch({
@@ -222,6 +222,47 @@ describe("inviteMember", () => {
222
222
  }),
223
223
  ).rejects.toBeInstanceOf(InviteHttpError);
224
224
  });
225
+
226
+ it("resolves the token when it is nested on the membership row", async () => {
227
+ fetchSpy.mockResolvedValueOnce(
228
+ jsonResponse(200, {
229
+ membership: {
230
+ role: "admin",
231
+ status: "pending",
232
+ inviteToken: "tok_nested",
233
+ },
234
+ }),
235
+ );
236
+
237
+ const result = await inviteMember({
238
+ target: "alice@example.com",
239
+ role: "admin",
240
+ companyUid: "cmp_acme",
241
+ callerUid: "prs_admin",
242
+ token: "test-token",
243
+ });
244
+
245
+ expect(result.inviteToken).toBe("tok_nested");
246
+ expect(result.magicLink).toBe("hq://accept/tok_nested");
247
+ });
248
+
249
+ it("throws instead of emitting hq://accept/undefined when no token is present", async () => {
250
+ fetchSpy.mockResolvedValueOnce(
251
+ jsonResponse(200, {
252
+ membership: { role: "admin", status: "pending" },
253
+ }),
254
+ );
255
+
256
+ await expect(
257
+ inviteMember({
258
+ target: "alice@example.com",
259
+ role: "admin",
260
+ companyUid: "cmp_acme",
261
+ callerUid: "prs_admin",
262
+ token: "test-token",
263
+ }),
264
+ ).rejects.toThrow(/did not include an invite token/);
265
+ });
225
266
  });
226
267
 
227
268
  // ---------------------------------------------------------------------------
@@ -257,6 +298,21 @@ describe("listPendingInvites", () => {
257
298
  InviteHttpError,
258
299
  );
259
300
  });
301
+
302
+ it("returns [] for an empty invites array", async () => {
303
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { invites: [] }));
304
+ await expect(listPendingInvites("test-token", "cmp_acme")).resolves.toEqual([]);
305
+ });
306
+
307
+ it("returns [] when invites is null", async () => {
308
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { invites: null }));
309
+ await expect(listPendingInvites("test-token", "cmp_acme")).resolves.toEqual([]);
310
+ });
311
+
312
+ it("returns [] when the invites key is missing", async () => {
313
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
314
+ await expect(listPendingInvites("test-token", "cmp_acme")).resolves.toEqual([]);
315
+ });
260
316
  });
261
317
 
262
318
  // ---------------------------------------------------------------------------
@@ -41,7 +41,7 @@ export interface InviteOptions {
41
41
  export interface InviteResult {
42
42
  inviteToken: string;
43
43
  magicLink: string;
44
- membership: { role: string; status: string };
44
+ membership: { role: string; status: string; inviteToken?: string };
45
45
  }
46
46
 
47
47
  export interface DetectedTarget {
@@ -132,30 +132,53 @@ export async function inviteMember(
132
132
  throw new InviteHttpError(
133
133
  res.status,
134
134
  err.message ?? err.error ?? res.statusText,
135
+ err.code,
135
136
  );
136
137
  }
137
138
 
138
139
  const data = (await res.json()) as {
139
- membership: { role: string; status: string };
140
- inviteToken: string;
140
+ membership?: { role: string; status: string; inviteToken?: string };
141
+ inviteToken?: string;
141
142
  };
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) {
148
+ const keys = Object.keys(data ?? {}).join(", ") || "<empty>";
149
+ 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.",
152
+ );
153
+ }
142
154
  return {
143
- inviteToken: data.inviteToken,
144
- magicLink: `hq://accept/${data.inviteToken}`,
145
- membership: data.membership,
155
+ inviteToken,
156
+ magicLink: `hq://accept/${inviteToken}`,
157
+ membership: data.membership ?? { role: options.role, status: "pending" },
146
158
  };
147
159
  }
148
160
 
149
161
  export class InviteHttpError extends Error {
150
- constructor(public status: number, message: string) {
162
+ constructor(
163
+ public status: number,
164
+ message: string,
165
+ public code?: string,
166
+ ) {
151
167
  super(message);
152
168
  this.name = "InviteHttpError";
153
169
  }
154
170
  }
155
171
 
156
- export function formatInviteHttpError(status: number, fallback: string): string {
172
+ export function formatInviteHttpError(
173
+ status: number,
174
+ fallback: string,
175
+ code?: string,
176
+ ): string {
157
177
  if (status === 401) return "Not authenticated — please run `hq login`";
158
178
  if (status === 403) {
179
+ if (code === "ADMIN_ROLE_TARGET_RESTRICTED") {
180
+ return "Admin can only invite at role=member; ask an owner to invite at this role";
181
+ }
159
182
  return "Not authorized — only admins and owners can invite members";
160
183
  }
161
184
  if (status === 409) {
@@ -178,10 +201,11 @@ export async function listPendingInvites(
178
201
  throw new InviteHttpError(
179
202
  res.status,
180
203
  err.message ?? err.error ?? res.statusText,
204
+ err.code,
181
205
  );
182
206
  }
183
- const data = (await res.json()) as { invites: PendingInvite[] };
184
- return data.invites;
207
+ const data = (await res.json()) as { invites?: PendingInvite[] | null };
208
+ return data?.invites ?? [];
185
209
  }
186
210
 
187
211
  export async function revokeInvite(
@@ -200,6 +224,7 @@ export async function revokeInvite(
200
224
  throw new InviteHttpError(
201
225
  res.status,
202
226
  err.message ?? err.error ?? res.statusText,
227
+ err.code,
203
228
  );
204
229
  }
205
230
  }
@@ -260,7 +285,11 @@ export function registerMembersCommand(program: Command): void {
260
285
  );
261
286
  } catch (err) {
262
287
  if (err instanceof InviteHttpError) {
263
- console.error(chalk.red(formatInviteHttpError(err.status, err.message)));
288
+ console.error(
289
+ chalk.red(
290
+ formatInviteHttpError(err.status, err.message, err.code),
291
+ ),
292
+ );
264
293
  process.exit(1);
265
294
  }
266
295
  console.error(
@@ -350,10 +379,12 @@ export function registerMembersCommand(program: Command): void {
350
379
  if (err instanceof InviteHttpError) {
351
380
  const msg =
352
381
  err.status === 403
353
- ? "Not authorized — only admins and owners can revoke invites"
382
+ ? err.code === "ADMIN_ROLE_TARGET_RESTRICTED"
383
+ ? "Admin can only revoke role=member; ask an owner to revoke this membership"
384
+ : "Not authorized — only admins and owners can revoke invites"
354
385
  : err.status === 404
355
386
  ? "Invite not found — it may have already been accepted or revoked"
356
- : formatInviteHttpError(err.status, err.message);
387
+ : formatInviteHttpError(err.status, err.message, err.code);
357
388
  console.error(chalk.red(msg));
358
389
  process.exit(1);
359
390
  }