@indigoai-us/hq-cli 5.18.0 → 5.18.2

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.
@@ -163,5 +163,42 @@ export declare function assertSingleSelector(opts: {
163
163
  personal?: boolean;
164
164
  company?: string;
165
165
  }, command: string): void;
166
+ /**
167
+ * Per-company pull resolution helper used by `hq sync pull --company <slug>`
168
+ * (US-011 fix, 2026-05-21). Mirrors the inline lookup that `runNowSingle`
169
+ * does for sync-now. Pulled out so the action handler stays thin AND so
170
+ * unit tests can exercise the banner / strict-refusal decision without
171
+ * spinning up commander + a real VaultClient.
172
+ *
173
+ * Input shape:
174
+ * - `targetCompany` — slug or UID the caller passed to `--company`. If
175
+ * undefined, the helper short-circuits to a "no resolution" result
176
+ * (the action handler falls back to .hq/config.json via sync()).
177
+ * - `client` — minimal VaultClient surface: listMyMemberships + entity.get
178
+ * + getMembershipSyncConfig.
179
+ *
180
+ * Output: `{ resolvedCompanyUid, resolvedMode }` — either may be undefined
181
+ * if the membership / sync-config call failed. Both undefined is a clean
182
+ * degradation — the caller pulls without a banner.
183
+ */
184
+ export interface PerCompanyPullResolveClient {
185
+ listMyMemberships(): Promise<Array<{
186
+ companyUid: string;
187
+ membershipKey: string;
188
+ }>>;
189
+ getMembershipSyncConfig(membershipKey: string): Promise<{
190
+ syncMode: MembershipSyncConfig["syncMode"];
191
+ }>;
192
+ entity: {
193
+ get(uid: string): Promise<{
194
+ slug?: string;
195
+ }>;
196
+ };
197
+ }
198
+ export interface PerCompanyPullResolveResult {
199
+ resolvedCompanyUid: string | undefined;
200
+ resolvedMode: MembershipSyncConfig["syncMode"] | undefined;
201
+ }
202
+ export declare function resolvePerCompanyPullPlan(client: PerCompanyPullResolveClient, targetCompany: string | undefined): Promise<PerCompanyPullResolveResult>;
166
203
  export declare function registerCloudCommands(program: Command): void;
167
204
  //# sourceMappingURL=cloud.d.ts.map
@@ -13,7 +13,7 @@
13
13
  * hq sync status — show local journal summary
14
14
  */
15
15
 
16
- !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]="2eb7da1d-e84f-591c-b930-b84c5c96986b")}catch(e){}}();
16
+ !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]="4672d875-1dc8-56a2-bef1-49c7f4ff6c76")}catch(e){}}();
17
17
  import chalk from "chalk";
18
18
  import * as fs from "fs";
19
19
  import * as path from "path";
@@ -252,6 +252,54 @@ export function assertSingleSelector(opts, command) {
252
252
  `--company; got: ${selectors.join(", ")}.`);
253
253
  }
254
254
  }
255
+ export async function resolvePerCompanyPullPlan(client, targetCompany) {
256
+ if (!targetCompany)
257
+ return { resolvedCompanyUid: undefined, resolvedMode: undefined };
258
+ try {
259
+ const memberships = await client.listMyMemberships();
260
+ // Direct UID / membershipKey match first (cheapest).
261
+ const direct = memberships.find((m) => m.companyUid === targetCompany || m.membershipKey === targetCompany);
262
+ if (direct) {
263
+ let mode;
264
+ try {
265
+ const cfg = await client.getMembershipSyncConfig(direct.membershipKey);
266
+ mode = cfg.syncMode;
267
+ }
268
+ catch {
269
+ mode = undefined;
270
+ }
271
+ return { resolvedCompanyUid: direct.companyUid, resolvedMode: mode };
272
+ }
273
+ // Slug match — listMyMemberships returns companyUid only, so fan out
274
+ // entity.get to find the row whose slug matches the caller's input.
275
+ for (const m of memberships) {
276
+ try {
277
+ const entity = await client.entity.get(m.companyUid);
278
+ if (entity.slug === targetCompany) {
279
+ let mode;
280
+ try {
281
+ const cfg = await client.getMembershipSyncConfig(m.membershipKey);
282
+ mode = cfg.syncMode;
283
+ }
284
+ catch {
285
+ mode = undefined;
286
+ }
287
+ return { resolvedCompanyUid: m.companyUid, resolvedMode: mode };
288
+ }
289
+ }
290
+ catch {
291
+ // Entity not visible — skip and continue. Worst case the loop ends
292
+ // with no match and we return undefined for both — the pull still
293
+ // proceeds, banner just stays quiet.
294
+ }
295
+ }
296
+ }
297
+ catch {
298
+ // listMyMemberships failed — degrade silently. Sync still works without
299
+ // the banner; this matches the runPullAll catch behavior.
300
+ }
301
+ return { resolvedCompanyUid: undefined, resolvedMode: undefined };
302
+ }
255
303
  export function registerCloudCommands(program) {
256
304
  program
257
305
  .command("push")
@@ -482,10 +530,37 @@ export function registerCloudCommands(program) {
482
530
  console.log(` HQ root: ${options.hqRoot}`);
483
531
  console.log(` Company: ${options.company ?? "(from .hq/config.json)"}\n`);
484
532
  const accessToken = await ensureCognitoToken();
533
+ const vaultConfig = buildVaultConfig(accessToken);
534
+ // US-011 (2026-05-21 fix): resolve the caller's sync-config for
535
+ // the targeted membership BEFORE the pull, so we can (a) emit the
536
+ // narrow-hint banner after success if still on all-mode and
537
+ // (b) respect strict-mode refusal mirror of the --all + sync-now
538
+ // paths. Failure to resolve degrades silently — pull still works,
539
+ // banner just stays quiet (same as the catch in runPullAll).
540
+ const narrowHintLevel = resolveBannerLevel();
541
+ const { resolvedCompanyUid, resolvedMode } = await resolvePerCompanyPullPlan(new VaultClient(vaultConfig), options.company);
542
+ // Strict-mode refusal: matches runPullAll + runNowSingle behavior.
543
+ // Default banner level is 'hint' which never triggers refusal —
544
+ // wired now so future hq-core-staging releases can flip the
545
+ // default to 'strict' without re-touching this command.
546
+ if (resolvedMode === "all" &&
547
+ isStrictRefusal(resolvedMode, narrowHintLevel) &&
548
+ options.modeAll !== true &&
549
+ resolvedCompanyUid) {
550
+ emitNarrowHint({
551
+ companyUid: resolvedCompanyUid,
552
+ syncMode: resolvedMode,
553
+ level: narrowHintLevel,
554
+ });
555
+ console.error(chalk.red("\n✗ Pull refused: strict narrow-hint mode is on and this " +
556
+ "membership still pulls everything. Run `hq sync narrow --apply` " +
557
+ "to migrate, or re-run with --mode-all."));
558
+ process.exit(1);
559
+ }
485
560
  const result = await sync({
486
561
  company: options.company,
487
562
  onConflict: options.onConflict,
488
- vaultConfig: buildVaultConfig(accessToken),
563
+ vaultConfig,
489
564
  hqRoot: options.hqRoot,
490
565
  });
491
566
  if (result.aborted) {
@@ -493,6 +568,16 @@ export function registerCloudCommands(program) {
493
568
  process.exit(1);
494
569
  }
495
570
  console.log(chalk.green(`\n✓ Pulled ${result.filesDownloaded} file(s) (${formatBytes(result.bytesDownloaded)}, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`));
571
+ // US-011 (2026-05-21 fix): emit the hint banner after success
572
+ // so it appears alongside the summary line. Mirrors the wiring
573
+ // in runPullAll (cloud.ts:331) and runNowSingle (cloud.ts:1371).
574
+ if (resolvedMode === "all" && resolvedCompanyUid) {
575
+ emitNarrowHint({
576
+ companyUid: resolvedCompanyUid,
577
+ syncMode: resolvedMode,
578
+ level: narrowHintLevel,
579
+ });
580
+ }
496
581
  }
497
582
  catch (err) {
498
583
  console.error(chalk.red("\n✗ Pull failed:"), err instanceof Error ? err.message : String(err));
@@ -978,4 +1063,4 @@ function resolveUploadAuthorFromCache() {
978
1063
  }
979
1064
  }
980
1065
  //# sourceMappingURL=cloud.js.map
981
- //# debugId=2eb7da1d-e84f-591c-b930-b84c5c96986b
1066
+ //# debugId=4672d875-1dc8-56a2-bef1-49c7f4ff6c76
@@ -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 {
@@ -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]="20c76490-0485-592f-856b-3b20e403a392")}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,11 @@ 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 ?? [];
127
137
  }
128
138
  export async function revokeInvite(token, tokenOrKey, companyUid) {
129
139
  const res = await vaultApiFetch({
@@ -163,10 +173,28 @@ export function registerMembersCommand(program) {
163
173
  });
164
174
  console.log(chalk.green(`Invited ${target} as ${result.membership.role} (status: ${result.membership.status})`));
165
175
  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."));
176
+ if (result.magicLink) {
177
+ // Legacy server schema — magic-link redemption.
178
+ console.log(chalk.bold("Magic link:"));
179
+ console.log(` ${result.magicLink}`);
180
+ console.log();
181
+ console.log(chalk.dim("Share this link with the invitee. They can run `hq onboard join --invite-token <token>` to accept."));
182
+ }
183
+ else {
184
+ // schemaVersion 2+ — email-keyed authoritative membership row.
185
+ // No magic link to share; invitee accepts by signing into HQ.
186
+ const inviteeEmail = result.membership.inviteeEmail ??
187
+ (typeof target === "string" && target.includes("@")
188
+ ? target
189
+ : 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."));
193
+ if (result.membership.membershipKey) {
194
+ console.log();
195
+ console.log(chalk.dim(` Membership key: ${result.membership.membershipKey}`));
196
+ }
197
+ }
170
198
  }
171
199
  catch (err) {
172
200
  if (err instanceof InviteHttpError) {
@@ -253,4 +281,4 @@ export function registerMembersCommand(program) {
253
281
  });
254
282
  }
255
283
  //# sourceMappingURL=members.js.map
256
- //# debugId=5f8b5b62-a00e-5ca3-b293-23906579a464
284
+ //# debugId=20c76490-0485-592f-856b-3b20e403a392
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.18.0",
3
+ "version": "5.18.2",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Unit tests for `resolvePerCompanyPullPlan` — the helper extracted from the
3
+ * `hq sync pull --company <slug>` action handler so the US-011 banner +
4
+ * strict-refusal wiring can be exercised without commander / a real
5
+ * VaultClient (US-011 fix, 2026-05-21).
6
+ *
7
+ * The action handler itself stays a thin orchestrator. These tests cover
8
+ * the membership-resolution decision tree, which is the part that broke
9
+ * on the pre-fix code path (banner silently never fired because the
10
+ * handler never looked up the sync-config).
11
+ *
12
+ * Coverage:
13
+ * 1. Undefined targetCompany short-circuits to no-op.
14
+ * 2. Direct UID match returns mode + companyUid.
15
+ * 3. Direct membershipKey match returns mode + companyUid.
16
+ * 4. Slug match via entity.get returns mode + companyUid.
17
+ * 5. listMyMemberships error degrades silently to no resolution.
18
+ * 6. getMembershipSyncConfig error degrades to undefined mode but keeps companyUid.
19
+ * 7. Slug iteration stops at first match (doesn't fan out to all entities).
20
+ * 8. No matching membership returns no resolution.
21
+ */
22
+
23
+ import { describe, expect, it, vi } from "vitest";
24
+ import {
25
+ resolvePerCompanyPullPlan,
26
+ type PerCompanyPullResolveClient,
27
+ } from "./cloud.js";
28
+
29
+ function makeClient(opts: {
30
+ memberships?: Array<{ companyUid: string; membershipKey: string }>;
31
+ syncConfigs?: Record<string, { syncMode: "shared" | "all" | "custom" }>;
32
+ entities?: Record<string, { slug?: string }>;
33
+ failListMemberships?: boolean;
34
+ failSyncConfigFor?: Set<string>;
35
+ failEntityFor?: Set<string>;
36
+ }): PerCompanyPullResolveClient & {
37
+ _entityCalls: () => string[];
38
+ } {
39
+ const entityCalls: string[] = [];
40
+ return {
41
+ listMyMemberships: vi.fn(async () => {
42
+ if (opts.failListMemberships) throw new Error("listMyMemberships boom");
43
+ return opts.memberships ?? [];
44
+ }),
45
+ getMembershipSyncConfig: vi.fn(async (key: string) => {
46
+ if (opts.failSyncConfigFor?.has(key))
47
+ throw new Error(`getMembershipSyncConfig boom for ${key}`);
48
+ const cfg = (opts.syncConfigs ?? {})[key];
49
+ if (!cfg) throw new Error(`no fake config for ${key}`);
50
+ return cfg;
51
+ }),
52
+ entity: {
53
+ get: vi.fn(async (uid: string) => {
54
+ entityCalls.push(uid);
55
+ if (opts.failEntityFor?.has(uid))
56
+ throw new Error(`entity.get boom for ${uid}`);
57
+ return (opts.entities ?? {})[uid] ?? {};
58
+ }),
59
+ },
60
+ _entityCalls: () => entityCalls,
61
+ };
62
+ }
63
+
64
+ describe("resolvePerCompanyPullPlan (US-011 per-company pull fix)", () => {
65
+ it("returns undefined+undefined when targetCompany is undefined", async () => {
66
+ const client = makeClient({});
67
+ const result = await resolvePerCompanyPullPlan(client, undefined);
68
+ expect(result.resolvedCompanyUid).toBeUndefined();
69
+ expect(result.resolvedMode).toBeUndefined();
70
+ // Should not call any API — short-circuit.
71
+ expect(client.listMyMemberships).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it("matches by direct companyUid and returns the live syncMode", async () => {
75
+ const client = makeClient({
76
+ memberships: [
77
+ { companyUid: "cmp_personal", membershipKey: "mbr_personal" },
78
+ { companyUid: "cmp_indigo", membershipKey: "mbr_indigo" },
79
+ ],
80
+ syncConfigs: {
81
+ mbr_personal: { syncMode: "all" },
82
+ mbr_indigo: { syncMode: "shared" },
83
+ },
84
+ });
85
+ const result = await resolvePerCompanyPullPlan(client, "cmp_indigo");
86
+ expect(result.resolvedCompanyUid).toBe("cmp_indigo");
87
+ expect(result.resolvedMode).toBe("shared");
88
+ // Slug-fallback loop must not fire — no entity calls.
89
+ expect(client._entityCalls()).toEqual([]);
90
+ });
91
+
92
+ it("matches by direct membershipKey", async () => {
93
+ const client = makeClient({
94
+ memberships: [
95
+ { companyUid: "cmp_x", membershipKey: "mbr_special" },
96
+ ],
97
+ syncConfigs: { mbr_special: { syncMode: "custom" } },
98
+ });
99
+ const result = await resolvePerCompanyPullPlan(client, "mbr_special");
100
+ expect(result.resolvedCompanyUid).toBe("cmp_x");
101
+ expect(result.resolvedMode).toBe("custom");
102
+ });
103
+
104
+ it("matches by slug via entity.get fan-out", async () => {
105
+ const client = makeClient({
106
+ memberships: [
107
+ { companyUid: "cmp_one", membershipKey: "mbr_one" },
108
+ { companyUid: "cmp_two", membershipKey: "mbr_two" },
109
+ ],
110
+ syncConfigs: { mbr_two: { syncMode: "all" } },
111
+ entities: {
112
+ cmp_one: { slug: "foo" },
113
+ cmp_two: { slug: "personal" },
114
+ },
115
+ });
116
+ const result = await resolvePerCompanyPullPlan(client, "personal");
117
+ expect(result.resolvedCompanyUid).toBe("cmp_two");
118
+ expect(result.resolvedMode).toBe("all");
119
+ });
120
+
121
+ it("stops slug iteration at the first match — does not fan out", async () => {
122
+ const client = makeClient({
123
+ memberships: [
124
+ { companyUid: "cmp_a", membershipKey: "mbr_a" },
125
+ { companyUid: "cmp_b", membershipKey: "mbr_b" },
126
+ { companyUid: "cmp_c", membershipKey: "mbr_c" },
127
+ ],
128
+ syncConfigs: { mbr_b: { syncMode: "shared" } },
129
+ entities: {
130
+ cmp_a: { slug: "alpha" },
131
+ cmp_b: { slug: "beta" },
132
+ cmp_c: { slug: "gamma" },
133
+ },
134
+ });
135
+ const result = await resolvePerCompanyPullPlan(client, "beta");
136
+ expect(result.resolvedCompanyUid).toBe("cmp_b");
137
+ expect(result.resolvedMode).toBe("shared");
138
+ // cmp_a was probed, cmp_b matched, cmp_c never touched.
139
+ expect(client._entityCalls()).toEqual(["cmp_a", "cmp_b"]);
140
+ });
141
+
142
+ it("returns undefined for both when no matching membership/slug", async () => {
143
+ const client = makeClient({
144
+ memberships: [
145
+ { companyUid: "cmp_x", membershipKey: "mbr_x" },
146
+ ],
147
+ entities: { cmp_x: { slug: "foo" } },
148
+ });
149
+ const result = await resolvePerCompanyPullPlan(client, "doesnotexist");
150
+ expect(result.resolvedCompanyUid).toBeUndefined();
151
+ expect(result.resolvedMode).toBeUndefined();
152
+ });
153
+
154
+ it("degrades silently when listMyMemberships fails — no banner, pull continues", async () => {
155
+ const client = makeClient({ failListMemberships: true });
156
+ const result = await resolvePerCompanyPullPlan(client, "personal");
157
+ expect(result.resolvedCompanyUid).toBeUndefined();
158
+ expect(result.resolvedMode).toBeUndefined();
159
+ // Sync-config never reached.
160
+ expect(client.getMembershipSyncConfig).not.toHaveBeenCalled();
161
+ });
162
+
163
+ it("on direct match, keeps companyUid even when getMembershipSyncConfig throws", async () => {
164
+ const client = makeClient({
165
+ memberships: [{ companyUid: "cmp_p", membershipKey: "mbr_p" }],
166
+ failSyncConfigFor: new Set(["mbr_p"]),
167
+ });
168
+ const result = await resolvePerCompanyPullPlan(client, "cmp_p");
169
+ expect(result.resolvedCompanyUid).toBe("cmp_p");
170
+ // Mode unknown — caller will skip banner emit (it only fires when mode='all').
171
+ expect(result.resolvedMode).toBeUndefined();
172
+ });
173
+
174
+ it("on slug-fallback match, skips broken entity rows and keeps looking", async () => {
175
+ const client = makeClient({
176
+ memberships: [
177
+ { companyUid: "cmp_broken", membershipKey: "mbr_broken" },
178
+ { companyUid: "cmp_good", membershipKey: "mbr_good" },
179
+ ],
180
+ syncConfigs: { mbr_good: { syncMode: "all" } },
181
+ entities: { cmp_good: { slug: "wanted" } },
182
+ failEntityFor: new Set(["cmp_broken"]),
183
+ });
184
+ const result = await resolvePerCompanyPullPlan(client, "wanted");
185
+ expect(result.resolvedCompanyUid).toBe("cmp_good");
186
+ expect(result.resolvedMode).toBe("all");
187
+ });
188
+ });
@@ -473,6 +473,86 @@ export function assertSingleSelector(opts: {
473
473
  }
474
474
  }
475
475
 
476
+ /**
477
+ * Per-company pull resolution helper used by `hq sync pull --company <slug>`
478
+ * (US-011 fix, 2026-05-21). Mirrors the inline lookup that `runNowSingle`
479
+ * does for sync-now. Pulled out so the action handler stays thin AND so
480
+ * unit tests can exercise the banner / strict-refusal decision without
481
+ * spinning up commander + a real VaultClient.
482
+ *
483
+ * Input shape:
484
+ * - `targetCompany` — slug or UID the caller passed to `--company`. If
485
+ * undefined, the helper short-circuits to a "no resolution" result
486
+ * (the action handler falls back to .hq/config.json via sync()).
487
+ * - `client` — minimal VaultClient surface: listMyMemberships + entity.get
488
+ * + getMembershipSyncConfig.
489
+ *
490
+ * Output: `{ resolvedCompanyUid, resolvedMode }` — either may be undefined
491
+ * if the membership / sync-config call failed. Both undefined is a clean
492
+ * degradation — the caller pulls without a banner.
493
+ */
494
+ export interface PerCompanyPullResolveClient {
495
+ listMyMemberships(): Promise<Array<{ companyUid: string; membershipKey: string }>>;
496
+ getMembershipSyncConfig(
497
+ membershipKey: string,
498
+ ): Promise<{ syncMode: MembershipSyncConfig["syncMode"] }>;
499
+ entity: { get(uid: string): Promise<{ slug?: string }> };
500
+ }
501
+
502
+ export interface PerCompanyPullResolveResult {
503
+ resolvedCompanyUid: string | undefined;
504
+ resolvedMode: MembershipSyncConfig["syncMode"] | undefined;
505
+ }
506
+
507
+ export async function resolvePerCompanyPullPlan(
508
+ client: PerCompanyPullResolveClient,
509
+ targetCompany: string | undefined,
510
+ ): Promise<PerCompanyPullResolveResult> {
511
+ if (!targetCompany) return { resolvedCompanyUid: undefined, resolvedMode: undefined };
512
+ try {
513
+ const memberships = await client.listMyMemberships();
514
+ // Direct UID / membershipKey match first (cheapest).
515
+ const direct = memberships.find(
516
+ (m) => m.companyUid === targetCompany || m.membershipKey === targetCompany,
517
+ );
518
+ if (direct) {
519
+ let mode: MembershipSyncConfig["syncMode"] | undefined;
520
+ try {
521
+ const cfg = await client.getMembershipSyncConfig(direct.membershipKey);
522
+ mode = cfg.syncMode;
523
+ } catch {
524
+ mode = undefined;
525
+ }
526
+ return { resolvedCompanyUid: direct.companyUid, resolvedMode: mode };
527
+ }
528
+ // Slug match — listMyMemberships returns companyUid only, so fan out
529
+ // entity.get to find the row whose slug matches the caller's input.
530
+ for (const m of memberships) {
531
+ try {
532
+ const entity = await client.entity.get(m.companyUid);
533
+ if (entity.slug === targetCompany) {
534
+ let mode: MembershipSyncConfig["syncMode"] | undefined;
535
+ try {
536
+ const cfg = await client.getMembershipSyncConfig(m.membershipKey);
537
+ mode = cfg.syncMode;
538
+ } catch {
539
+ mode = undefined;
540
+ }
541
+ return { resolvedCompanyUid: m.companyUid, resolvedMode: mode };
542
+ }
543
+ } catch {
544
+ // Entity not visible — skip and continue. Worst case the loop ends
545
+ // with no match and we return undefined for both — the pull still
546
+ // proceeds, banner just stays quiet.
547
+ }
548
+ }
549
+ } catch {
550
+ // listMyMemberships failed — degrade silently. Sync still works without
551
+ // the banner; this matches the runPullAll catch behavior.
552
+ }
553
+ return { resolvedCompanyUid: undefined, resolvedMode: undefined };
554
+ }
555
+
476
556
  export function registerCloudCommands(program: Command): void {
477
557
  program
478
558
  .command("push")
@@ -817,10 +897,50 @@ export function registerCloudCommands(program: Command): void {
817
897
  console.log(` Company: ${options.company ?? "(from .hq/config.json)"}\n`);
818
898
 
819
899
  const accessToken = await ensureCognitoToken();
900
+ const vaultConfig = buildVaultConfig(accessToken);
901
+
902
+ // US-011 (2026-05-21 fix): resolve the caller's sync-config for
903
+ // the targeted membership BEFORE the pull, so we can (a) emit the
904
+ // narrow-hint banner after success if still on all-mode and
905
+ // (b) respect strict-mode refusal mirror of the --all + sync-now
906
+ // paths. Failure to resolve degrades silently — pull still works,
907
+ // banner just stays quiet (same as the catch in runPullAll).
908
+ const narrowHintLevel: BannerLevel = resolveBannerLevel();
909
+ const { resolvedCompanyUid, resolvedMode } =
910
+ await resolvePerCompanyPullPlan(
911
+ new VaultClient(vaultConfig),
912
+ options.company,
913
+ );
914
+
915
+ // Strict-mode refusal: matches runPullAll + runNowSingle behavior.
916
+ // Default banner level is 'hint' which never triggers refusal —
917
+ // wired now so future hq-core-staging releases can flip the
918
+ // default to 'strict' without re-touching this command.
919
+ if (
920
+ resolvedMode === "all" &&
921
+ isStrictRefusal(resolvedMode, narrowHintLevel) &&
922
+ options.modeAll !== true &&
923
+ resolvedCompanyUid
924
+ ) {
925
+ emitNarrowHint({
926
+ companyUid: resolvedCompanyUid,
927
+ syncMode: resolvedMode,
928
+ level: narrowHintLevel,
929
+ });
930
+ console.error(
931
+ chalk.red(
932
+ "\n✗ Pull refused: strict narrow-hint mode is on and this " +
933
+ "membership still pulls everything. Run `hq sync narrow --apply` " +
934
+ "to migrate, or re-run with --mode-all.",
935
+ ),
936
+ );
937
+ process.exit(1);
938
+ }
939
+
820
940
  const result = await sync({
821
941
  company: options.company,
822
942
  onConflict: options.onConflict,
823
- vaultConfig: buildVaultConfig(accessToken),
943
+ vaultConfig,
824
944
  hqRoot: options.hqRoot,
825
945
  });
826
946
 
@@ -838,6 +958,17 @@ export function registerCloudCommands(program: Command): void {
838
958
  `\n✓ Pulled ${result.filesDownloaded} file(s) (${formatBytes(result.bytesDownloaded)}, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`,
839
959
  ),
840
960
  );
961
+
962
+ // US-011 (2026-05-21 fix): emit the hint banner after success
963
+ // so it appears alongside the summary line. Mirrors the wiring
964
+ // in runPullAll (cloud.ts:331) and runNowSingle (cloud.ts:1371).
965
+ if (resolvedMode === "all" && resolvedCompanyUid) {
966
+ emitNarrowHint({
967
+ companyUid: resolvedCompanyUid,
968
+ syncMode: resolvedMode,
969
+ level: narrowHintLevel,
970
+ });
971
+ }
841
972
  } catch (err) {
842
973
  console.error(
843
974
  chalk.red("\n✗ Pull failed:"),
@@ -246,13 +246,49 @@ describe("inviteMember", () => {
246
246
  expect(result.magicLink).toBe("hq://accept/tok_nested");
247
247
  });
248
248
 
249
- it("throws instead of emitting hq://accept/undefined when no token is present", async () => {
249
+ it("schemaVersion 2: no inviteToken in response success with undefined magicLink", async () => {
250
+ // 2026-05-21 fix: the server moved to email-keyed authoritative
251
+ // membership rows (schemaVersion 2) — the invite IS created, but
252
+ // there is no token to redeem. The CLI must NOT throw here; instead
253
+ // the action handler prints "sign in with the invited email"
254
+ // instructions. Previously this case threw "did not include an
255
+ // invite token" which produced false-failure UX on a working invite.
250
256
  fetchSpy.mockResolvedValueOnce(
251
- jsonResponse(200, {
252
- membership: { role: "admin", status: "pending" },
257
+ jsonResponse(201, {
258
+ membership: {
259
+ membershipKey: "email:alice@example.com#cmp_acme",
260
+ role: "member",
261
+ status: "pending",
262
+ inviteeEmail: "alice@example.com",
263
+ schemaVersion: 2,
264
+ },
253
265
  }),
254
266
  );
255
267
 
268
+ const result = await inviteMember({
269
+ target: "alice@example.com",
270
+ role: "member",
271
+ companyUid: "cmp_acme",
272
+ callerUid: "prs_admin",
273
+ token: "test-token",
274
+ });
275
+
276
+ expect(result.inviteToken).toBeUndefined();
277
+ expect(result.magicLink).toBeUndefined();
278
+ expect(result.membership.role).toBe("member");
279
+ expect(result.membership.status).toBe("pending");
280
+ expect(result.membership.membershipKey).toBe(
281
+ "email:alice@example.com#cmp_acme",
282
+ );
283
+ expect(result.membership.inviteeEmail).toBe("alice@example.com");
284
+ });
285
+
286
+ it("throws when the response has no membership row at all (server bug)", async () => {
287
+ // Belt-and-suspenders: a 2xx response with NO membership row is a
288
+ // server-side regression — surface it loudly so it doesn't silently
289
+ // succeed-but-do-nothing.
290
+ fetchSpy.mockResolvedValueOnce(jsonResponse(201, {}));
291
+
256
292
  await expect(
257
293
  inviteMember({
258
294
  target: "alice@example.com",
@@ -261,7 +297,7 @@ describe("inviteMember", () => {
261
297
  callerUid: "prs_admin",
262
298
  token: "test-token",
263
299
  }),
264
- ).rejects.toThrow(/did not include an invite token/);
300
+ ).rejects.toThrow(/no membership row/);
265
301
  });
266
302
  });
267
303
 
@@ -313,6 +349,47 @@ describe("listPendingInvites", () => {
313
349
  fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
314
350
  await expect(listPendingInvites("test-token", "cmp_acme")).resolves.toEqual([]);
315
351
  });
352
+
353
+ it("schemaVersion 2: reads `pending` key (the canonical server response)", async () => {
354
+ // 2026-05-21 fix: live server returns `{ pending: [...] }`, not
355
+ // `{ invites: [...] }`. The CLI must read `pending` as the primary
356
+ // and fall back to `invites` for older stages.
357
+ fetchSpy.mockResolvedValueOnce(
358
+ jsonResponse(200, {
359
+ pending: [
360
+ {
361
+ membershipKey: "email:alice@example.com#cmp_acme",
362
+ inviteeEmail: "alice@example.com",
363
+ companyUid: "cmp_acme",
364
+ role: "member",
365
+ status: "pending",
366
+ invitedBy: "prs_admin",
367
+ invitedAt: "2026-05-21T12:00:00Z",
368
+ schemaVersion: 2,
369
+ },
370
+ ],
371
+ }),
372
+ );
373
+
374
+ const invites = await listPendingInvites("test-token", "cmp_acme");
375
+ expect(invites).toHaveLength(1);
376
+ expect(invites[0].membershipKey).toBe("email:alice@example.com#cmp_acme");
377
+ expect(invites[0].inviteeEmail).toBe("alice@example.com");
378
+ });
379
+
380
+ it("prefers `pending` over legacy `invites` when both present (server transition)", async () => {
381
+ // Defensive: an in-flight server deploy could briefly return BOTH
382
+ // fields. CLI takes the canonical `pending` key.
383
+ fetchSpy.mockResolvedValueOnce(
384
+ jsonResponse(200, {
385
+ pending: [{ membershipKey: "k_pending" } as never],
386
+ invites: [{ membershipKey: "k_legacy" } as never],
387
+ }),
388
+ );
389
+ const invites = await listPendingInvites("test-token", "cmp_acme");
390
+ expect(invites).toHaveLength(1);
391
+ expect(invites[0].membershipKey).toBe("k_pending");
392
+ });
316
393
  });
317
394
 
318
395
  // ---------------------------------------------------------------------------
@@ -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,14 @@ 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 ?? [];
209
249
  }
210
250
 
211
251
  export async function revokeInvite(
@@ -275,14 +315,40 @@ export function registerMembersCommand(program: Command): void {
275
315
  ),
276
316
  );
277
317
  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
- );
318
+ if (result.magicLink) {
319
+ // Legacy server schema — magic-link redemption.
320
+ console.log(chalk.bold("Magic link:"));
321
+ console.log(` ${result.magicLink}`);
322
+ console.log();
323
+ console.log(
324
+ chalk.dim(
325
+ "Share this link with the invitee. They can run `hq onboard join --invite-token <token>` to accept.",
326
+ ),
327
+ );
328
+ } else {
329
+ // schemaVersion 2+ — email-keyed authoritative membership row.
330
+ // No magic link to share; invitee accepts by signing into HQ.
331
+ const inviteeEmail =
332
+ result.membership.inviteeEmail ??
333
+ (typeof target === "string" && target.includes("@")
334
+ ? target
335
+ : undefined);
336
+ console.log(chalk.bold("Next step:"));
337
+ console.log(
338
+ ` Tell ${inviteeEmail ?? "the invitee"} to sign into HQ at https://hq.getindigo.ai with that email.`,
339
+ );
340
+ console.log(
341
+ chalk.dim(
342
+ " The pending membership row claims itself on first sign-in — no separate token redemption.",
343
+ ),
344
+ );
345
+ if (result.membership.membershipKey) {
346
+ console.log();
347
+ console.log(
348
+ chalk.dim(` Membership key: ${result.membership.membershipKey}`),
349
+ );
350
+ }
351
+ }
286
352
  } catch (err) {
287
353
  if (err instanceof InviteHttpError) {
288
354
  console.error(