@indigoai-us/hq-cli 5.18.3 → 5.20.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.
@@ -493,4 +493,102 @@ describe("pullAll", () => {
493
493
  expect(call.hqRoot).toBe("/Users/me/scratch/hq");
494
494
  }
495
495
  });
496
+
497
+ // ── 11. skipPersonal — fanout omits the canonical-person leg ──────────────
498
+ //
499
+ // Mirrors the upstream `hq-sync-runner --skip-personal` flag (hq-cloud
500
+ // 5.25.0) plumbed through to the CLI as `hq sync pull --all --no-personal`.
501
+ // When the option is true, `pullAll` must (a) skip the canonical-person
502
+ // bucket even when one exists, and (b) leave companies untouched.
503
+
504
+ it("skipPersonal=true omits the personal leg even when a person entity exists", async () => {
505
+ const vaultClient = makeVaultClient({
506
+ memberships: [
507
+ { companyUid: "cmp_acme" },
508
+ { companyUid: "cmp_globex" },
509
+ ],
510
+ persons: [
511
+ {
512
+ uid: "psn_alice",
513
+ type: "person",
514
+ slug: "alice",
515
+ bucketName: "hq-vault-psn-alice",
516
+ createdAt: "2026-01-01T00:00:00Z",
517
+ },
518
+ ],
519
+ entitiesBySlug: {
520
+ cmp_acme: { slug: "acme" },
521
+ cmp_globex: { slug: "globex" },
522
+ },
523
+ });
524
+ const sync = makeSyncSpy();
525
+
526
+ const result = await pullAll(
527
+ { hqRoot: "/tmp/hq", skipPersonal: true },
528
+ { vaultClient, sync: sync.fn },
529
+ );
530
+
531
+ expect(sync.calls.map((c) => c.company)).toEqual([
532
+ "cmp_acme",
533
+ "cmp_globex",
534
+ ]);
535
+ for (const call of sync.calls) {
536
+ expect(call.personalMode).toBeUndefined();
537
+ expect(call.journalSlug).toBeUndefined();
538
+ }
539
+ expect(result.attempted).toBe(2);
540
+ expect(result.perCompany.map((r) => r.slug)).toEqual(["acme", "globex"]);
541
+ });
542
+
543
+ it("skipPersonal omitted/false keeps the personal leg in the fanout (baseline)", async () => {
544
+ const vaultClient = makeVaultClient({
545
+ memberships: [{ companyUid: "cmp_acme" }],
546
+ persons: [
547
+ {
548
+ uid: "psn_alice",
549
+ type: "person",
550
+ slug: "alice",
551
+ createdAt: "2026-01-01T00:00:00Z",
552
+ },
553
+ ],
554
+ entitiesBySlug: { cmp_acme: { slug: "acme" } },
555
+ });
556
+ const sync = makeSyncSpy();
557
+
558
+ await pullAll(
559
+ { hqRoot: "/tmp/hq" },
560
+ { vaultClient, sync: sync.fn },
561
+ );
562
+
563
+ expect(sync.calls.map((c) => c.company)).toEqual([
564
+ "cmp_acme",
565
+ "psn_alice",
566
+ ]);
567
+ expect(sync.calls[1].personalMode).toBe(true);
568
+ expect(sync.calls[1].journalSlug).toBe("personal");
569
+ });
570
+
571
+ it("skipPersonal=true with empty memberships produces zero sync calls", async () => {
572
+ const vaultClient = makeVaultClient({
573
+ memberships: [],
574
+ persons: [
575
+ {
576
+ uid: "psn_alice",
577
+ type: "person",
578
+ slug: "alice",
579
+ createdAt: "2026-01-01T00:00:00Z",
580
+ },
581
+ ],
582
+ });
583
+ const sync = makeSyncSpy();
584
+
585
+ const result = await pullAll(
586
+ { hqRoot: "/tmp/hq", skipPersonal: true },
587
+ { vaultClient, sync: sync.fn },
588
+ );
589
+
590
+ expect(sync.calls).toEqual([]);
591
+ expect(result.attempted).toBe(0);
592
+ expect(result.perCompany).toEqual([]);
593
+ });
496
594
  });
@@ -378,4 +378,79 @@ describe("pushAll", () => {
378
378
  expect(result.filesUploaded).toBe(3);
379
379
  expect(result.filesDeleted).toBe(4);
380
380
  });
381
+
382
+ // ── skipPersonal — fanout omits the canonical-person leg ──────────────────
383
+ //
384
+ // Symmetric with the `pullAll` skipPersonal coverage in
385
+ // `cloud.pull-all.test.ts`. Wired through from `hq sync push --all
386
+ // --no-personal` (Commander's `--no-personal` is the auto-negation of the
387
+ // `--personal` single-target selector). Verifies (a) the personal leg is
388
+ // dropped even when a canonical person exists, and (b) the baseline
389
+ // behavior — no flag → personal stays in the plan.
390
+
391
+ it("skipPersonal=true omits the personal leg even when a person entity exists", async () => {
392
+ const hqRoot = makeHqRoot([".git", "companies", "repos", "workspace"]);
393
+ const vaultClient = makeVaultClient({
394
+ memberships: [
395
+ { companyUid: "cmp_acme" },
396
+ { companyUid: "cmp_globex" },
397
+ ],
398
+ persons: [
399
+ {
400
+ uid: "psn_alice",
401
+ type: "person",
402
+ slug: "alice",
403
+ bucketName: "hq-vault-psn-alice",
404
+ createdAt: "2026-01-01T00:00:00Z",
405
+ },
406
+ ],
407
+ entitiesBySlug: {
408
+ cmp_acme: { slug: "acme" },
409
+ cmp_globex: { slug: "globex" },
410
+ },
411
+ });
412
+ const share = makeShareSpy();
413
+
414
+ const result = await pushAll(
415
+ { hqRoot, skipPersonal: true },
416
+ { vaultClient, share: share.fn },
417
+ );
418
+
419
+ expect(share.calls.map((c) => c.company)).toEqual([
420
+ "cmp_acme",
421
+ "cmp_globex",
422
+ ]);
423
+ for (const call of share.calls) {
424
+ expect(call.personalMode).toBeUndefined();
425
+ expect(call.journalSlug).toBeUndefined();
426
+ }
427
+ expect(result.attempted).toBe(2);
428
+ expect(result.perCompany.map((r) => r.slug)).toEqual(["acme", "globex"]);
429
+ });
430
+
431
+ it("skipPersonal omitted keeps the personal leg in the plan (baseline)", async () => {
432
+ const hqRoot = makeHqRoot([".git", "companies", "repos", "workspace"]);
433
+ const vaultClient = makeVaultClient({
434
+ memberships: [{ companyUid: "cmp_acme" }],
435
+ persons: [
436
+ {
437
+ uid: "psn_alice",
438
+ type: "person",
439
+ slug: "alice",
440
+ createdAt: "2026-01-01T00:00:00Z",
441
+ },
442
+ ],
443
+ entitiesBySlug: { cmp_acme: { slug: "acme" } },
444
+ });
445
+ const share = makeShareSpy();
446
+
447
+ await pushAll({ hqRoot }, { vaultClient, share: share.fn });
448
+
449
+ expect(share.calls.map((c) => c.company)).toEqual([
450
+ "cmp_acme",
451
+ "psn_alice",
452
+ ]);
453
+ expect(share.calls[1].personalMode).toBe(true);
454
+ expect(share.calls[1].journalSlug).toBe("personal");
455
+ });
381
456
  });
@@ -11,6 +11,7 @@
11
11
  import { describe, expect, it, vi } from "vitest";
12
12
 
13
13
  import {
14
+ assertNoPersonalPositionalPaths,
14
15
  assertSingleSelector,
15
16
  resolveCanonicalPersonUid,
16
17
  type PullAllVaultClient,
@@ -76,6 +77,58 @@ describe("assertSingleSelector", () => {
76
77
  });
77
78
  });
78
79
 
80
+ // ── assertNoPersonalPositionalPaths (hq-cli#25) ─────────────────────────────
81
+ //
82
+ // The combination `--personal <path>` silently bypasses
83
+ // PERSONAL_VAULT_EXCLUDED_TOP_LEVEL (which is only applied by
84
+ // computePersonalVaultPaths). Real incident: 196 companies/{slug}/** objects
85
+ // uploaded to a personal vault. Refusal forces the operator to drop one of
86
+ // the two — either `--personal` (full vault scope) or the positional paths
87
+ // (specific subset against the active company).
88
+
89
+ describe("assertNoPersonalPositionalPaths", () => {
90
+ it("accepts --personal alone (no positional paths)", () => {
91
+ expect(() => assertNoPersonalPositionalPaths({ personal: true }, undefined)).not.toThrow();
92
+ expect(() => assertNoPersonalPositionalPaths({ personal: true }, [])).not.toThrow();
93
+ });
94
+
95
+ it("accepts positional paths without --personal", () => {
96
+ expect(() => assertNoPersonalPositionalPaths({ personal: false }, ["./scratch"])).not.toThrow();
97
+ expect(() => assertNoPersonalPositionalPaths({}, ["./scratch"])).not.toThrow();
98
+ });
99
+
100
+ it("accepts neither --personal nor positional paths", () => {
101
+ expect(() => assertNoPersonalPositionalPaths({}, undefined)).not.toThrow();
102
+ expect(() => assertNoPersonalPositionalPaths({}, [])).not.toThrow();
103
+ });
104
+
105
+ it("throws when --personal combines with one positional path", () => {
106
+ expect(() => assertNoPersonalPositionalPaths({ personal: true }, ["./scratch"])).toThrow(
107
+ /--personal.*cannot be combined with explicit \[paths\]/,
108
+ );
109
+ });
110
+
111
+ it("throws when --personal combines with multiple positional paths", () => {
112
+ expect(() =>
113
+ assertNoPersonalPositionalPaths({ personal: true }, ["./a", "./b", "./c"]),
114
+ ).toThrow(/PERSONAL_VAULT_EXCLUDED_TOP_LEVEL/);
115
+ });
116
+
117
+ it("error message names the dangerous prefixes so the operator understands the risk", () => {
118
+ let caught: Error | null = null;
119
+ try {
120
+ assertNoPersonalPositionalPaths({ personal: true }, ["/Users/corey/Documents/HQ"]);
121
+ } catch (e) {
122
+ caught = e as Error;
123
+ }
124
+ expect(caught).not.toBeNull();
125
+ expect(caught!.message).toContain(".git/");
126
+ expect(caught!.message).toContain("companies/");
127
+ expect(caught!.message).toContain("repos/");
128
+ expect(caught!.message).toContain("workspace/");
129
+ });
130
+ });
131
+
79
132
  // ── resolveCanonicalPersonUid ──────────────────────────────────────────────
80
133
 
81
134
  function makeClient(persons: Array<{
@@ -125,6 +125,17 @@ export interface PullAllOptions {
125
125
  * legacy behavior for this run via `--mode-all`.
126
126
  */
127
127
  modeAllOverride?: boolean;
128
+ /**
129
+ * When `true`, skip the canonical-person-entity leg entirely — the
130
+ * fanout only visits the caller's company memberships. Mirrors the
131
+ * `--skip-personal` flag and `HQ_SYNC_SKIP_PERSONAL` env var that
132
+ * `@indigoai-us/hq-cloud`'s `sync-runner` exposes in `--companies`
133
+ * mode (see hq-cloud 5.25.0 `resolveSkipPersonal`). Surfaced on the
134
+ * CLI as `hq sync pull --all --no-personal` so the AppBar HQ Sync
135
+ * menubar toggle (and CLI users opting out one-off) can drop the
136
+ * personal vault from the run without touching the rest of the plan.
137
+ */
138
+ skipPersonal?: boolean;
128
139
  }
129
140
 
130
141
  export interface PullAllRow {
@@ -173,6 +184,13 @@ export interface PushAllOptions {
173
184
  hqRoot: string;
174
185
  onConflict?: ConflictStrategy;
175
186
  message?: string;
187
+ /**
188
+ * When `true`, skip the canonical-person-entity leg entirely — the
189
+ * fanout only visits the caller's company memberships. Symmetric with
190
+ * `PullAllOptions.skipPersonal`; surfaced on the CLI as
191
+ * `hq sync push --all --no-personal`.
192
+ */
193
+ skipPersonal?: boolean;
176
194
  }
177
195
 
178
196
  export interface PushAllRow {
@@ -252,7 +270,7 @@ export async function pullAll(
252
270
  });
253
271
  }
254
272
 
255
- const personal = pickCanonicalPerson(persons);
273
+ const personal = options.skipPersonal ? null : pickCanonicalPerson(persons);
256
274
  if (personal) {
257
275
  plan.push({
258
276
  slug: "personal",
@@ -385,7 +403,7 @@ export async function pushAll(
385
403
  });
386
404
  }
387
405
 
388
- const personal = pickCanonicalPerson(persons);
406
+ const personal = options.skipPersonal ? null : pickCanonicalPerson(persons);
389
407
  if (personal) {
390
408
  plan.push({
391
409
  slug: "personal",
@@ -451,6 +469,35 @@ export async function resolveCanonicalPersonUid(
451
469
  return pick.uid;
452
470
  }
453
471
 
472
+ /**
473
+ * Refuse `hq sync push --personal <path>` — the combination silently
474
+ * bypasses `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL` (which is only applied by
475
+ * `computePersonalVaultPaths`), risking cross-scope upload of `companies/`,
476
+ * `repos/`, `workspace/`, or `.git/` content to the personal vault. Real
477
+ * incident (2026-05-21): a single command uploaded 196 `companies/{slug}/**`
478
+ * objects to a personal vault before being killed. Cleanup required a
479
+ * hand-rolled S3 sweep. Closes hq-cli#25.
480
+ *
481
+ * Refusal — not silent filtering — is intentional: explicit is better than
482
+ * implicit guesswork, and the legitimate "I want to push a subset of my
483
+ * personal vault" use case has a clean workaround (drop `--personal`, the
484
+ * subset upload targets the active company via standard semantics).
485
+ */
486
+ export function assertNoPersonalPositionalPaths(opts: {
487
+ personal?: boolean;
488
+ }, paths: string[] | undefined): void {
489
+ if (opts.personal && paths && paths.length > 0) {
490
+ throw new Error(
491
+ "`--personal` cannot be combined with explicit [paths]: " +
492
+ "positional paths bypass the PERSONAL_VAULT_EXCLUDED_TOP_LEVEL " +
493
+ "guard (skips .git/, companies/, repos/, workspace/), risking " +
494
+ "cross-scope upload of company data to the personal vault. " +
495
+ "Use bare `--personal` to push the whole personal scope, OR " +
496
+ "drop `--personal` to push specific paths to the active company.",
497
+ );
498
+ }
499
+ }
500
+
454
501
  /**
455
502
  * Refuse ambiguous selector combinations. `--all`, `--personal`, and
456
503
  * `--company` are mutually exclusive — at most one may be set per
@@ -608,6 +655,15 @@ export function registerCloudCommands(program: Command): void {
608
655
  "(.git, companies, repos, workspace) — same scope as `--all`'s " +
609
656
  "personal slot. Mutually exclusive with --company and --all.",
610
657
  )
658
+ .option(
659
+ "--no-personal",
660
+ "In `--all` mode, skip the canonical-person leg of the fanout — " +
661
+ "only push to the caller's company memberships. Mirrors the " +
662
+ "upstream `hq-sync-runner --skip-personal` flag (hq-cloud 5.25.0); " +
663
+ "wired so the AppBar HQ Sync menubar can drop personal sync via " +
664
+ "a toggle, and so CLI users can opt out one-off. Ignored outside " +
665
+ "`--all`.",
666
+ )
611
667
  .action(
612
668
  async (
613
669
  paths: string[],
@@ -649,10 +705,17 @@ export function registerCloudCommands(program: Command): void {
649
705
  );
650
706
  process.exit(1);
651
707
  }
708
+ // `options.personal === false` happens when the user passed
709
+ // `--no-personal` (Commander's auto-negation of the `--personal`
710
+ // selector). In `--all` mode that means "skip the personal leg of
711
+ // the fanout" — wired through to `pushAll.skipPersonal`. Outside
712
+ // `--all` the flag has no effect (logged above as part of the
713
+ // option's help text).
652
714
  await runPushAll(
653
715
  options.hqRoot,
654
716
  options.message,
655
717
  options.onConflict,
718
+ options.personal === false,
656
719
  );
657
720
  return;
658
721
  }
@@ -675,6 +738,8 @@ export function registerCloudCommands(program: Command): void {
675
738
  "to have already resolved entity + credentials. Pick one.",
676
739
  );
677
740
  }
741
+ // Closes hq-cli#25 — see `assertNoPersonalPositionalPaths` doc-block.
742
+ assertNoPersonalPositionalPaths(options, paths);
678
743
 
679
744
  log(chalk.bold("\nHQ Sync — Push"));
680
745
  log(` HQ root: ${options.hqRoot}`);
@@ -854,6 +919,13 @@ export function registerCloudCommands(program: Command): void {
854
919
  "from the cached Cognito session. Mutually exclusive with --company " +
855
920
  "and --all.",
856
921
  )
922
+ .option(
923
+ "--no-personal",
924
+ "In `--all` mode, skip the canonical-person leg of the fanout — " +
925
+ "only pull the caller's company memberships. Mirrors the upstream " +
926
+ "`hq-sync-runner --skip-personal` flag (hq-cloud 5.25.0). Ignored " +
927
+ "outside `--all`.",
928
+ )
857
929
  .option(
858
930
  "--mode-all",
859
931
  "US-011: opt out of the strict narrow-hint refusal for this run. " +
@@ -880,10 +952,14 @@ export function registerCloudCommands(program: Command): void {
880
952
  process.exit(1);
881
953
  }
882
954
  if (options.all) {
955
+ // `options.personal === false` is Commander's auto-negation of
956
+ // `--personal`; in `--all` mode that means "drop the personal
957
+ // leg from the fanout" (see `--no-personal` option above).
883
958
  await runPullAll(
884
959
  options.hqRoot,
885
960
  options.onConflict,
886
961
  options.modeAll === true,
962
+ options.personal === false,
887
963
  );
888
964
  return;
889
965
  }
@@ -1069,6 +1145,13 @@ export function registerCloudCommands(program: Command): void {
1069
1145
  "Sync the caller's canonical personal vault bidirectionally. " +
1070
1146
  "Mutually exclusive with --company and --all.",
1071
1147
  )
1148
+ .option(
1149
+ "--no-personal",
1150
+ "In `--all` mode, skip the canonical-person leg of both the push " +
1151
+ "and pull fanouts — only sync the caller's company memberships. " +
1152
+ "Mirrors the upstream `hq-sync-runner --skip-personal` flag " +
1153
+ "(hq-cloud 5.25.0). Ignored outside `--all`.",
1154
+ )
1072
1155
  .option(
1073
1156
  "--mode-all",
1074
1157
  "US-011: opt out of the strict narrow-hint refusal for this run. " +
@@ -1088,11 +1171,16 @@ export function registerCloudCommands(program: Command): void {
1088
1171
  try {
1089
1172
  assertSingleSelector(options, "now");
1090
1173
  if (options.all) {
1174
+ // `options.personal === false` is Commander's auto-negation
1175
+ // of `--personal`; in `--all` mode that means "drop the
1176
+ // personal leg from both legs of the bidirectional fanout"
1177
+ // (see `--no-personal` option above).
1091
1178
  await runNowAll(
1092
1179
  options.hqRoot,
1093
1180
  options.message,
1094
1181
  options.onConflict,
1095
1182
  options.modeAll === true,
1183
+ options.personal === false,
1096
1184
  );
1097
1185
  return;
1098
1186
  }
@@ -1119,10 +1207,15 @@ async function runPullAll(
1119
1207
  hqRoot: string,
1120
1208
  onConflict?: ConflictStrategy,
1121
1209
  modeAllOverride?: boolean,
1210
+ skipPersonal?: boolean,
1122
1211
  ): Promise<void> {
1123
1212
  console.log(chalk.bold("\nHQ Sync — Pull (all)"));
1124
1213
  console.log(` HQ root: ${hqRoot}`);
1125
- console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
1214
+ console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
1215
+ if (skipPersonal) {
1216
+ console.log(` Personal: skipped (--no-personal)`);
1217
+ }
1218
+ console.log("");
1126
1219
 
1127
1220
  let result: PullAllResult;
1128
1221
  try {
@@ -1150,6 +1243,7 @@ async function runPullAll(
1150
1243
  ...(onConflict ? { onConflict } : {}),
1151
1244
  narrowHintLevel: resolveBannerLevel(),
1152
1245
  ...(modeAllOverride ? { modeAllOverride: true } : {}),
1246
+ ...(skipPersonal ? { skipPersonal: true } : {}),
1153
1247
  },
1154
1248
  {
1155
1249
  vaultClient: adapter,
@@ -1254,10 +1348,15 @@ async function runPushAll(
1254
1348
  hqRoot: string,
1255
1349
  message?: string,
1256
1350
  onConflict?: ConflictStrategy,
1351
+ skipPersonal?: boolean,
1257
1352
  ): Promise<void> {
1258
1353
  console.log(chalk.bold("\nHQ Sync — Push (all)"));
1259
1354
  console.log(` HQ root: ${hqRoot}`);
1260
- console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
1355
+ console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
1356
+ if (skipPersonal) {
1357
+ console.log(` Personal: skipped (--no-personal)`);
1358
+ }
1359
+ console.log("");
1261
1360
 
1262
1361
  let result: PushAllResult;
1263
1362
  try {
@@ -1283,6 +1382,7 @@ async function runPushAll(
1283
1382
  hqRoot,
1284
1383
  ...(onConflict ? { onConflict } : {}),
1285
1384
  ...(message ? { message } : {}),
1385
+ ...(skipPersonal ? { skipPersonal: true } : {}),
1286
1386
  },
1287
1387
  {
1288
1388
  vaultClient: adapter,
@@ -1521,21 +1621,26 @@ async function runNowAll(
1521
1621
  message?: string,
1522
1622
  onConflict?: ConflictStrategy,
1523
1623
  modeAllOverride?: boolean,
1624
+ skipPersonal?: boolean,
1524
1625
  ): Promise<void> {
1525
1626
  console.log(chalk.bold("\nHQ Sync — Now (all)"));
1526
1627
  console.log(` HQ root: ${hqRoot}`);
1527
- console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
1628
+ console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
1629
+ if (skipPersonal) {
1630
+ console.log(` Personal: skipped (--no-personal)`);
1631
+ }
1632
+ console.log("");
1528
1633
 
1529
1634
  // Push first (matches runner), then pull. Re-uses the per-leg orchestrators
1530
1635
  // so the per-target rendering, error isolation, and exit codes are
1531
1636
  // identical to running `push --all` then `pull --all` back-to-back.
1532
1637
  console.log(chalk.dim("→ push --all"));
1533
- await runPushAll(hqRoot, message, onConflict);
1638
+ await runPushAll(hqRoot, message, onConflict, skipPersonal);
1534
1639
  console.log(chalk.dim("\n→ pull --all"));
1535
1640
  // US-011: forward --mode-all so the strict refusal applies to the
1536
1641
  // pull leg (push doesn't need a narrow-hint — the narrow ritual is
1537
1642
  // pull-side).
1538
- await runPullAll(hqRoot, onConflict, modeAllOverride);
1643
+ await runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal);
1539
1644
  }
1540
1645
 
1541
1646
  /**