@indigoai-us/hq-cli 5.17.0 → 5.18.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.
@@ -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 () => {
@@ -28,6 +28,7 @@ import {
28
28
  computePersonalVaultPaths,
29
29
  type ConflictStrategy,
30
30
  type EntityContext,
31
+ type MembershipSyncConfig,
31
32
  type SyncProgressEvent,
32
33
  type UploadAuthor,
33
34
  } from "@indigoai-us/hq-cloud";
@@ -37,6 +38,12 @@ import {
37
38
  ensureCognitoToken,
38
39
  buildVaultConfig,
39
40
  } from "../utils/cognito-session.js";
41
+ import {
42
+ emitNarrowHint,
43
+ isStrictRefusal,
44
+ resolveBannerLevel,
45
+ type BannerLevel,
46
+ } from "../lib/narrow-hint-banner.js";
40
47
 
41
48
  interface CommonSyncOptions {
42
49
  hqRoot: string;
@@ -53,7 +60,9 @@ interface CommonSyncOptions {
53
60
  // ─────────────────────────────────────────────────────────────────────────────
54
61
 
55
62
  export interface PullAllVaultClient {
56
- listMyMemberships(): Promise<Array<{ companyUid: string }>>;
63
+ listMyMemberships(): Promise<
64
+ Array<{ companyUid: string; membershipKey?: string }>
65
+ >;
57
66
  listPersonEntities(): Promise<
58
67
  Array<{
59
68
  uid: string;
@@ -64,6 +73,15 @@ export interface PullAllVaultClient {
64
73
  }>
65
74
  >;
66
75
  getEntity(uid: string): Promise<{ slug?: string; name?: string } | null>;
76
+ /**
77
+ * US-011: optional — when present, `pullAll` calls it once per
78
+ * membership to surface the narrow-hint banner for all-mode owners.
79
+ * Absent on legacy adapters (push-all et al.) where the banner is not
80
+ * applicable.
81
+ */
82
+ getMembershipSyncConfig?: (
83
+ membershipId: string,
84
+ ) => Promise<MembershipSyncConfig>;
67
85
  }
68
86
 
69
87
  export interface SyncCallOptions {
@@ -91,6 +109,22 @@ export interface PullAllDeps {
91
109
  export interface PullAllOptions {
92
110
  hqRoot: string;
93
111
  onConflict?: ConflictStrategy;
112
+ /**
113
+ * US-011: banner level for the narrow-hint nudge. Defaults to `'hint'`
114
+ * — see `resolveBannerLevel` for the env-driven override. The
115
+ * `'strict'` level causes `pullAll` to refuse to sync any membership
116
+ * still on `syncMode: 'all'` unless `modeAllOverride` is true.
117
+ *
118
+ * TODO(hq-core-staging release N+2): default flips to 'warning'.
119
+ * TODO(hq-core-staging release N+3): default flips to 'strict'.
120
+ */
121
+ narrowHintLevel?: BannerLevel;
122
+ /**
123
+ * US-011: when `true`, strict-mode does NOT refuse all-mode
124
+ * memberships — the operator has explicitly opted into keeping the
125
+ * legacy behavior for this run via `--mode-all`.
126
+ */
127
+ modeAllOverride?: boolean;
94
128
  }
95
129
 
96
130
  export interface PullAllRow {
@@ -168,6 +202,9 @@ export interface PullAllResult {
168
202
  interface PlanEntry {
169
203
  slug: string;
170
204
  syncOptions: SyncCallOptions;
205
+ /** US-011: tracked so we can resolve sync-config + emit the narrow hint. */
206
+ companyUid?: string;
207
+ membershipKey?: string;
171
208
  }
172
209
 
173
210
  // Oldest-first by createdAt, ties broken by uid lexicographic — matches
@@ -191,6 +228,9 @@ export async function pullAll(
191
228
  const memberships = await deps.vaultClient.listMyMemberships();
192
229
  const persons = await deps.vaultClient.listPersonEntities();
193
230
 
231
+ const narrowHintLevel: BannerLevel = options.narrowHintLevel ?? "hint";
232
+ const getSyncConfig = deps.vaultClient.getMembershipSyncConfig;
233
+
194
234
  const plan: PlanEntry[] = [];
195
235
  for (const m of memberships) {
196
236
  let slug = m.companyUid;
@@ -202,6 +242,8 @@ export async function pullAll(
202
242
  }
203
243
  plan.push({
204
244
  slug,
245
+ companyUid: m.companyUid,
246
+ ...(m.membershipKey ? { membershipKey: m.membershipKey } : {}),
205
247
  syncOptions: {
206
248
  company: m.companyUid,
207
249
  hqRoot: options.hqRoot,
@@ -235,12 +277,63 @@ export async function pullAll(
235
277
 
236
278
  for (const entry of plan) {
237
279
  result.attempted += 1;
280
+
281
+ // US-011: resolve the membership's effective sync mode so we can
282
+ // either nudge an all-mode owner toward `hq sync narrow` OR refuse
283
+ // the leg outright when strict-mode is on and the operator didn't
284
+ // pass `--mode-all`. Sync-config lookup is best-effort — a 404 or
285
+ // network blip should never block the sync itself, so we fall back
286
+ // to syncMode='all' (the legacy default) and skip the banner.
287
+ let resolvedMode: MembershipSyncConfig["syncMode"] | undefined;
288
+ if (entry.membershipKey && getSyncConfig) {
289
+ try {
290
+ const cfg = await getSyncConfig(entry.membershipKey);
291
+ resolvedMode = cfg.syncMode;
292
+ } catch {
293
+ resolvedMode = undefined;
294
+ }
295
+ }
296
+
297
+ if (
298
+ resolvedMode === "all" &&
299
+ isStrictRefusal(resolvedMode, narrowHintLevel) &&
300
+ !options.modeAllOverride &&
301
+ entry.companyUid
302
+ ) {
303
+ // Emit the strict-level banner once, then mark the leg as errored
304
+ // without invoking sync(). The operator either narrows the
305
+ // membership (`hq sync narrow --apply`) or passes `--mode-all` to
306
+ // opt back in.
307
+ emitNarrowHint({
308
+ companyUid: entry.companyUid,
309
+ syncMode: resolvedMode,
310
+ level: narrowHintLevel,
311
+ });
312
+ const message =
313
+ "Refusing to pull all-mode membership in strict mode. " +
314
+ "Run `hq sync narrow --apply` to migrate, or re-run with --mode-all.";
315
+ result.errors.push({ company: entry.slug, message });
316
+ result.perCompany.push({ slug: entry.slug, error: message });
317
+ continue;
318
+ }
319
+
238
320
  try {
239
321
  const r = await deps.sync(entry.syncOptions);
240
322
  result.filesDownloaded += r.filesDownloaded;
241
323
  result.bytesDownloaded += r.bytesDownloaded;
242
324
  result.conflicts += r.conflicts;
243
325
  result.perCompany.push({ slug: entry.slug, result: r });
326
+
327
+ // Banner emitted AFTER the leg succeeds so it appears alongside
328
+ // the per-company summary line and doesn't get scrolled off by
329
+ // sync chatter.
330
+ if (resolvedMode === "all" && entry.companyUid) {
331
+ emitNarrowHint({
332
+ companyUid: entry.companyUid,
333
+ syncMode: resolvedMode,
334
+ level: narrowHintLevel,
335
+ });
336
+ }
244
337
  } catch (err) {
245
338
  const message = err instanceof Error ? err.message : String(err);
246
339
  result.errors.push({ company: entry.slug, message });
@@ -681,12 +774,20 @@ export function registerCloudCommands(program: Command): void {
681
774
  "from the cached Cognito session. Mutually exclusive with --company " +
682
775
  "and --all.",
683
776
  )
777
+ .option(
778
+ "--mode-all",
779
+ "US-011: opt out of the strict narrow-hint refusal for this run. " +
780
+ "Has no effect today (default narrow-hint level is 'hint'); " +
781
+ "wired so future hq-core-staging releases can flip the default to " +
782
+ "'strict' without re-touching this command.",
783
+ )
684
784
  .action(
685
785
  async (
686
786
  options: CommonSyncOptions & {
687
787
  onConflict?: ConflictStrategy;
688
788
  all?: boolean;
689
789
  personal?: boolean;
790
+ modeAll?: boolean;
690
791
  },
691
792
  ) => {
692
793
  try {
@@ -699,7 +800,11 @@ export function registerCloudCommands(program: Command): void {
699
800
  process.exit(1);
700
801
  }
701
802
  if (options.all) {
702
- await runPullAll(options.hqRoot, options.onConflict);
803
+ await runPullAll(
804
+ options.hqRoot,
805
+ options.onConflict,
806
+ options.modeAll === true,
807
+ );
703
808
  return;
704
809
  }
705
810
  if (options.personal) {
@@ -833,6 +938,12 @@ export function registerCloudCommands(program: Command): void {
833
938
  "Sync the caller's canonical personal vault bidirectionally. " +
834
939
  "Mutually exclusive with --company and --all.",
835
940
  )
941
+ .option(
942
+ "--mode-all",
943
+ "US-011: opt out of the strict narrow-hint refusal for this run. " +
944
+ "No-op today; wired so future hq-core-staging releases can flip " +
945
+ "the default narrow-hint level to 'strict'.",
946
+ )
836
947
  .action(
837
948
  async (
838
949
  options: CommonSyncOptions & {
@@ -840,6 +951,7 @@ export function registerCloudCommands(program: Command): void {
840
951
  message?: string;
841
952
  all?: boolean;
842
953
  personal?: boolean;
954
+ modeAll?: boolean;
843
955
  },
844
956
  ) => {
845
957
  try {
@@ -849,6 +961,7 @@ export function registerCloudCommands(program: Command): void {
849
961
  options.hqRoot,
850
962
  options.message,
851
963
  options.onConflict,
964
+ options.modeAll === true,
852
965
  );
853
966
  return;
854
967
  }
@@ -858,6 +971,7 @@ export function registerCloudCommands(program: Command): void {
858
971
  options.personal === true,
859
972
  options.message,
860
973
  options.onConflict,
974
+ options.modeAll === true,
861
975
  );
862
976
  } catch (err) {
863
977
  console.error(
@@ -873,6 +987,7 @@ export function registerCloudCommands(program: Command): void {
873
987
  async function runPullAll(
874
988
  hqRoot: string,
875
989
  onConflict?: ConflictStrategy,
990
+ modeAllOverride?: boolean,
876
991
  ): Promise<void> {
877
992
  console.log(chalk.bold("\nHQ Sync — Pull (all)"));
878
993
  console.log(` HQ root: ${hqRoot}`);
@@ -894,10 +1009,17 @@ async function runPullAll(
894
1009
  return null;
895
1010
  }
896
1011
  },
1012
+ getMembershipSyncConfig: (id: string) =>
1013
+ realClient.getMembershipSyncConfig(id),
897
1014
  };
898
1015
 
899
1016
  result = await pullAll(
900
- { hqRoot, ...(onConflict ? { onConflict } : {}) },
1017
+ {
1018
+ hqRoot,
1019
+ ...(onConflict ? { onConflict } : {}),
1020
+ narrowHintLevel: resolveBannerLevel(),
1021
+ ...(modeAllOverride ? { modeAllOverride: true } : {}),
1022
+ },
901
1023
  {
902
1024
  vaultClient: adapter,
903
1025
  sync: (opts) =>
@@ -1095,6 +1217,7 @@ async function runNowSingle(
1095
1217
  personal: boolean,
1096
1218
  message?: string,
1097
1219
  onConflict?: ConflictStrategy,
1220
+ modeAllOverride?: boolean,
1098
1221
  ): Promise<void> {
1099
1222
  console.log(chalk.bold("\nHQ Sync — Now"));
1100
1223
  console.log(` HQ root: ${hqRoot}`);
@@ -1168,6 +1291,59 @@ async function runNowSingle(
1168
1291
  process.exit(1);
1169
1292
  }
1170
1293
 
1294
+ // US-011: resolve membership sync-config so we can either nudge an
1295
+ // all-mode owner or refuse the pull when strict-mode is on. Skipped
1296
+ // for personal targets (personal vault has no membership row) and
1297
+ // for resolution failures (best-effort — never block sync). The
1298
+ // lookup runs BEFORE the pull leg so strict refusal can short-circuit
1299
+ // without burning a sync.
1300
+ const narrowHintLevel: BannerLevel = resolveBannerLevel();
1301
+ let resolvedMode: MembershipSyncConfig["syncMode"] | undefined;
1302
+ let resolvedCompanyUid: string | undefined;
1303
+ if (!personalMode && targetCompany) {
1304
+ try {
1305
+ const client = new VaultClient(vaultConfig);
1306
+ const memberships = await client.listMyMemberships();
1307
+ const match = memberships.find(
1308
+ (m) => m.companyUid === targetCompany || m.membershipKey === targetCompany,
1309
+ );
1310
+ if (match) {
1311
+ resolvedCompanyUid = match.companyUid;
1312
+ try {
1313
+ const cfg = await client.getMembershipSyncConfig(
1314
+ match.membershipKey,
1315
+ );
1316
+ resolvedMode = cfg.syncMode;
1317
+ } catch {
1318
+ resolvedMode = undefined;
1319
+ }
1320
+ }
1321
+ } catch {
1322
+ resolvedMode = undefined;
1323
+ }
1324
+ }
1325
+
1326
+ if (
1327
+ resolvedMode === "all" &&
1328
+ isStrictRefusal(resolvedMode, narrowHintLevel) &&
1329
+ !modeAllOverride &&
1330
+ resolvedCompanyUid
1331
+ ) {
1332
+ emitNarrowHint({
1333
+ companyUid: resolvedCompanyUid,
1334
+ syncMode: resolvedMode,
1335
+ level: narrowHintLevel,
1336
+ });
1337
+ console.error(
1338
+ chalk.red(
1339
+ "\n✗ Sync now refused: strict narrow-hint mode is on and this " +
1340
+ "membership still pulls everything. Run `hq sync narrow --apply` " +
1341
+ "to migrate, or re-run with --mode-all.",
1342
+ ),
1343
+ );
1344
+ process.exit(1);
1345
+ }
1346
+
1171
1347
  console.log(chalk.dim(" → pull leg"));
1172
1348
  const pullResult = await sync({
1173
1349
  company: targetCompany,
@@ -1188,6 +1364,17 @@ async function runNowSingle(
1188
1364
  console.log(chalk.yellow("\n⚠ Sync now finished with pull leg aborted."));
1189
1365
  process.exit(1);
1190
1366
  }
1367
+
1368
+ // US-011: emit the hint banner after a successful pull so it
1369
+ // appears at the bottom of the summary rather than mid-stream.
1370
+ if (resolvedMode === "all" && resolvedCompanyUid) {
1371
+ emitNarrowHint({
1372
+ companyUid: resolvedCompanyUid,
1373
+ syncMode: resolvedMode,
1374
+ level: narrowHintLevel,
1375
+ });
1376
+ }
1377
+
1191
1378
  console.log(chalk.green("\n✓ Sync now complete"));
1192
1379
  } catch (err) {
1193
1380
  console.error(
@@ -1202,6 +1389,7 @@ async function runNowAll(
1202
1389
  hqRoot: string,
1203
1390
  message?: string,
1204
1391
  onConflict?: ConflictStrategy,
1392
+ modeAllOverride?: boolean,
1205
1393
  ): Promise<void> {
1206
1394
  console.log(chalk.bold("\nHQ Sync — Now (all)"));
1207
1395
  console.log(` HQ root: ${hqRoot}`);
@@ -1213,7 +1401,10 @@ async function runNowAll(
1213
1401
  console.log(chalk.dim("→ push --all"));
1214
1402
  await runPushAll(hqRoot, message, onConflict);
1215
1403
  console.log(chalk.dim("\n→ pull --all"));
1216
- await runPullAll(hqRoot, onConflict);
1404
+ // US-011: forward --mode-all so the strict refusal applies to the
1405
+ // pull leg (push doesn't need a narrow-hint — the narrow ritual is
1406
+ // pull-side).
1407
+ await runPullAll(hqRoot, onConflict, modeAllOverride);
1217
1408
  }
1218
1409
 
1219
1410
  /**