@indigoai-us/hq-cli 5.17.0 → 5.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,9 +14,12 @@
14
14
  * 5. Empty memberships AND no person entity → no sync calls, clean result.
15
15
  * 6. Multiple person entities → canonical pick (oldest createdAt, uid tiebreak).
16
16
  * 7. hqRoot is forwarded verbatim to every sync() call.
17
+ * 8. US-011 — narrow-hint banner emits per all-mode membership.
18
+ * 9. US-011 — strict-mode refuses all-mode legs without --mode-all.
19
+ * 10. US-011 — strict-mode + --mode-all proceeds.
17
20
  */
18
21
 
19
- import { describe, expect, it, vi } from "vitest";
22
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
20
23
 
21
24
  import {
22
25
  pullAll,
@@ -25,6 +28,7 @@ import {
25
28
  type SyncCallOptions,
26
29
  type SyncCallResult,
27
30
  } from "./cloud.js";
31
+ import { _resetShownForTests } from "../lib/narrow-hint-banner.js";
28
32
 
29
33
  // ── Helpers ─────────────────────────────────────────────────────────────────
30
34
 
@@ -299,6 +303,171 @@ describe("pullAll", () => {
299
303
  expect(sync.calls[0].journalSlug).toBe("personal");
300
304
  });
301
305
 
306
+ // ── 8/9/10. US-011 narrow-hint banner ─────────────────────────────────────
307
+
308
+ describe("narrow-hint banner (US-011)", () => {
309
+ let stderrSpy: ReturnType<typeof vi.spyOn>;
310
+ let captured: string[];
311
+
312
+ beforeEach(() => {
313
+ _resetShownForTests();
314
+ captured = [];
315
+ stderrSpy = vi
316
+ .spyOn(process.stderr, "write")
317
+ .mockImplementation((chunk: string | Uint8Array) => {
318
+ captured.push(typeof chunk === "string" ? chunk : chunk.toString());
319
+ return true;
320
+ });
321
+ });
322
+
323
+ afterEach(() => {
324
+ stderrSpy.mockRestore();
325
+ delete process.env.HQ_SYNC_NARROW_HINT;
326
+ delete process.env.HQ_SYNC_NARROW_HINT_LEVEL;
327
+ });
328
+
329
+ function makeVaultClientWithSyncConfig(opts: {
330
+ memberships: Array<{ companyUid: string; membershipKey: string }>;
331
+ syncModes: Record<string, "shared" | "all" | "custom">;
332
+ entitiesBySlug?: Record<string, { slug: string; name?: string }>;
333
+ }): PullAllVaultClient {
334
+ return {
335
+ listMyMemberships: vi.fn().mockResolvedValue(opts.memberships),
336
+ listPersonEntities: vi.fn().mockResolvedValue([]),
337
+ getEntity: vi
338
+ .fn()
339
+ .mockImplementation(async (uid: string) =>
340
+ opts.entitiesBySlug?.[uid] ?? null,
341
+ ),
342
+ getMembershipSyncConfig: vi
343
+ .fn()
344
+ .mockImplementation(async (membershipKey: string) => {
345
+ const m = opts.memberships.find(
346
+ (m) => m.membershipKey === membershipKey,
347
+ );
348
+ const companyUid = m?.companyUid ?? "";
349
+ return {
350
+ membershipId: membershipKey,
351
+ syncMode: opts.syncModes[companyUid] ?? "all",
352
+ };
353
+ }),
354
+ };
355
+ }
356
+
357
+ it("emits one hint per all-mode membership and skips shared/custom", async () => {
358
+ const vaultClient = makeVaultClientWithSyncConfig({
359
+ memberships: [
360
+ { companyUid: "cmp_acme", membershipKey: "psn_1#cmp_acme" },
361
+ { companyUid: "cmp_globex", membershipKey: "psn_1#cmp_globex" },
362
+ { companyUid: "cmp_init", membershipKey: "psn_1#cmp_init" },
363
+ ],
364
+ syncModes: {
365
+ cmp_acme: "all",
366
+ cmp_globex: "shared",
367
+ cmp_init: "custom",
368
+ },
369
+ entitiesBySlug: {
370
+ cmp_acme: { slug: "acme" },
371
+ cmp_globex: { slug: "globex" },
372
+ cmp_init: { slug: "init" },
373
+ },
374
+ });
375
+ const sync = makeSyncSpy();
376
+
377
+ const result = await pullAll(
378
+ { hqRoot: "/tmp/hq", narrowHintLevel: "hint" },
379
+ { vaultClient, sync: sync.fn },
380
+ );
381
+
382
+ expect(result.errors).toEqual([]);
383
+ expect(result.attempted).toBe(3);
384
+ // sync ran for all three (no strict refusal at 'hint' level)
385
+ expect(sync.calls).toHaveLength(3);
386
+
387
+ const joined = captured.join("");
388
+ expect(joined).toMatch(/switch to shared-mode sync/i);
389
+ // exactly one banner line — only cmp_acme is on 'all'
390
+ const bannerLines = captured.filter((c) => /shared-mode sync/i.test(c));
391
+ expect(bannerLines).toHaveLength(1);
392
+ });
393
+
394
+ it("strict-mode refuses all-mode legs and short-circuits sync()", async () => {
395
+ const vaultClient = makeVaultClientWithSyncConfig({
396
+ memberships: [
397
+ { companyUid: "cmp_acme", membershipKey: "psn_1#cmp_acme" },
398
+ { companyUid: "cmp_globex", membershipKey: "psn_1#cmp_globex" },
399
+ ],
400
+ syncModes: {
401
+ cmp_acme: "all",
402
+ cmp_globex: "shared",
403
+ },
404
+ entitiesBySlug: {
405
+ cmp_acme: { slug: "acme" },
406
+ cmp_globex: { slug: "globex" },
407
+ },
408
+ });
409
+ const sync = makeSyncSpy();
410
+
411
+ const result = await pullAll(
412
+ { hqRoot: "/tmp/hq", narrowHintLevel: "strict" },
413
+ { vaultClient, sync: sync.fn },
414
+ );
415
+
416
+ // acme refused (no sync() call), globex still synced
417
+ expect(sync.calls.map((c) => c.company)).toEqual(["cmp_globex"]);
418
+ expect(result.errors).toEqual([
419
+ {
420
+ company: "acme",
421
+ message: expect.stringContaining("Refusing to pull all-mode"),
422
+ },
423
+ ]);
424
+ });
425
+
426
+ it("strict-mode + modeAllOverride lets all-mode legs proceed", async () => {
427
+ const vaultClient = makeVaultClientWithSyncConfig({
428
+ memberships: [
429
+ { companyUid: "cmp_acme", membershipKey: "psn_1#cmp_acme" },
430
+ ],
431
+ syncModes: { cmp_acme: "all" },
432
+ entitiesBySlug: { cmp_acme: { slug: "acme" } },
433
+ });
434
+ const sync = makeSyncSpy();
435
+
436
+ const result = await pullAll(
437
+ {
438
+ hqRoot: "/tmp/hq",
439
+ narrowHintLevel: "strict",
440
+ modeAllOverride: true,
441
+ },
442
+ { vaultClient, sync: sync.fn },
443
+ );
444
+
445
+ expect(sync.calls).toHaveLength(1);
446
+ expect(result.errors).toEqual([]);
447
+ });
448
+
449
+ it("HQ_SYNC_NARROW_HINT=off suppresses the banner", async () => {
450
+ process.env.HQ_SYNC_NARROW_HINT = "off";
451
+ const vaultClient = makeVaultClientWithSyncConfig({
452
+ memberships: [
453
+ { companyUid: "cmp_acme", membershipKey: "psn_1#cmp_acme" },
454
+ ],
455
+ syncModes: { cmp_acme: "all" },
456
+ entitiesBySlug: { cmp_acme: { slug: "acme" } },
457
+ });
458
+ const sync = makeSyncSpy();
459
+
460
+ await pullAll(
461
+ { hqRoot: "/tmp/hq", narrowHintLevel: "hint" },
462
+ { vaultClient, sync: sync.fn },
463
+ );
464
+
465
+ const joined = captured.join("");
466
+ expect(joined).not.toMatch(/switch to shared-mode sync/i);
467
+ expect(sync.calls).toHaveLength(1);
468
+ });
469
+ });
470
+
302
471
  // ── 7. hqRoot passthrough ─────────────────────────────────────────────────
303
472
 
304
473
  it("forwards hqRoot verbatim to every sync() call", async () => {
@@ -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
+ });