@indigoai-us/hq-cloud 5.39.0 → 5.41.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.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Unit tests for reindexAfterSync.
3
+ *
4
+ * The function shells out to the global `qmd` binary, so every test injects a
5
+ * fake `exec` that records the calls and returns canned results — no real qmd
6
+ * is invoked. We assert on the *sequence of qmd commands* the function would
7
+ * run, which is the contract that keeps a teammate's index fresh after sync.
8
+ */
9
+ export {};
10
+ //# sourceMappingURL=qmd-reindex.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"qmd-reindex.test.d.ts","sourceRoot":"","sources":["../src/qmd-reindex.test.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG"}
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Unit tests for reindexAfterSync.
3
+ *
4
+ * The function shells out to the global `qmd` binary, so every test injects a
5
+ * fake `exec` that records the calls and returns canned results — no real qmd
6
+ * is invoked. We assert on the *sequence of qmd commands* the function would
7
+ * run, which is the contract that keeps a teammate's index fresh after sync.
8
+ */
9
+ import { describe, it, expect } from "vitest";
10
+ import * as fs from "fs";
11
+ import * as os from "os";
12
+ import * as path from "path";
13
+ import { reindexAfterSync } from "./qmd-reindex.js";
14
+ /** Build a fake exec that returns status 0 for everything and logs calls. */
15
+ function fakeExec(opts) {
16
+ const calls = [];
17
+ const exec = (args) => {
18
+ calls.push(args);
19
+ if (opts?.failOn && args[0] === opts.failOn)
20
+ return { status: 1, stdout: "" };
21
+ if (args[0] === "collection" && args[1] === "list") {
22
+ return { status: 0, stdout: opts?.listStdout ?? "" };
23
+ }
24
+ return { status: 0, stdout: "" };
25
+ };
26
+ return { exec, calls };
27
+ }
28
+ describe("reindexAfterSync", () => {
29
+ it("no-ops when the path is not an HQ root", () => {
30
+ const { exec, calls } = fakeExec();
31
+ const r = reindexAfterSync("/tmp/not-hq", {
32
+ exec,
33
+ existsSync: () => false, // core/core.yaml absent
34
+ });
35
+ expect(r.qmdAvailable).toBe(false);
36
+ expect(calls).toHaveLength(0);
37
+ });
38
+ it("no-ops when qmd is unavailable (collection list errors)", () => {
39
+ const { exec, calls } = fakeExec({ failOn: "collection" });
40
+ const r = reindexAfterSync("/hq", {
41
+ exec,
42
+ existsSync: () => true, // core.yaml present
43
+ });
44
+ expect(r.qmdAvailable).toBe(false);
45
+ expect(r.updated).toBe(false);
46
+ // Only the availability probe ran, then bailed.
47
+ expect(calls).toEqual([["collection", "list"]]);
48
+ });
49
+ it("registers a missing company collection then runs an incremental update", () => {
50
+ const { exec, calls } = fakeExec({ listStdout: "Collections (1):\n\nHQ (qmd://HQ/)\n" });
51
+ const r = reindexAfterSync("/hq", {
52
+ exec,
53
+ existsSync: () => true,
54
+ readCompanies: () => ["acme"],
55
+ hasIndexableMarkdown: () => true,
56
+ });
57
+ expect(r.qmdAvailable).toBe(true);
58
+ expect(r.collectionsAdded).toEqual(["acme"]);
59
+ expect(r.updated).toBe(true);
60
+ expect(r.embedded).toBe(false);
61
+ // Expected command sequence: probe, add, context, update.
62
+ expect(calls).toEqual([
63
+ ["collection", "list"],
64
+ ["collection", "add", path.join("/hq", "companies", "acme", "knowledge"), "--name", "acme", "--mask", "**/*.md"],
65
+ ["context", "add", "qmd://acme", "Knowledge base for acme."],
66
+ ["update"],
67
+ ]);
68
+ });
69
+ it("skips collections that already exist", () => {
70
+ const { exec, calls } = fakeExec({ listStdout: "acme (qmd://acme/)\n" });
71
+ const r = reindexAfterSync("/hq", {
72
+ exec,
73
+ existsSync: () => true,
74
+ readCompanies: () => ["acme"],
75
+ hasIndexableMarkdown: () => true,
76
+ });
77
+ expect(r.collectionsAdded).toEqual([]);
78
+ // No `collection add` — straight to update.
79
+ expect(calls).toEqual([["collection", "list"], ["update"]]);
80
+ });
81
+ it("skips company dirs with no indexable markdown", () => {
82
+ const { exec, calls } = fakeExec({ listStdout: "" });
83
+ const r = reindexAfterSync("/hq", {
84
+ exec,
85
+ existsSync: () => true,
86
+ readCompanies: () => ["empty"],
87
+ hasIndexableMarkdown: () => false,
88
+ });
89
+ expect(r.collectionsAdded).toEqual([]);
90
+ expect(calls).toEqual([["collection", "list"], ["update"]]);
91
+ });
92
+ it("runs embed only when embed:true", () => {
93
+ const { exec, calls } = fakeExec({ listStdout: "" });
94
+ const r = reindexAfterSync("/hq", {
95
+ exec,
96
+ existsSync: () => true,
97
+ readCompanies: () => [],
98
+ embed: true,
99
+ });
100
+ expect(r.embedded).toBe(true);
101
+ expect(calls).toEqual([["collection", "list"], ["update"], ["embed"]]);
102
+ });
103
+ it("never throws even if exec misbehaves", () => {
104
+ const exec = () => {
105
+ throw new Error("boom");
106
+ };
107
+ expect(() => reindexAfterSync("/hq", { exec, existsSync: () => true })).not.toThrow();
108
+ });
109
+ it("hasIndexableMarkdown finds a real .md and ignores INDEX.md", () => {
110
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-test-"));
111
+ try {
112
+ const hq = path.join(dir, "hq");
113
+ const kdir = path.join(hq, "companies", "acme", "knowledge");
114
+ fs.mkdirSync(kdir, { recursive: true });
115
+ fs.mkdirSync(path.join(hq, "core"), { recursive: true });
116
+ fs.writeFileSync(path.join(hq, "core", "core.yaml"), "hqVersion: 1\n");
117
+ fs.writeFileSync(path.join(kdir, "INDEX.md"), "# index\n");
118
+ fs.writeFileSync(path.join(kdir, "note.md"), "# real\n");
119
+ const { exec, calls } = fakeExec({ listStdout: "" });
120
+ const r = reindexAfterSync(hq, { exec }); // real fs walk for md detection
121
+ expect(r.collectionsAdded).toEqual(["acme"]);
122
+ expect(calls.some((c) => c[0] === "collection" && c[1] === "add")).toBe(true);
123
+ }
124
+ finally {
125
+ fs.rmSync(dir, { recursive: true, force: true });
126
+ }
127
+ });
128
+ });
129
+ //# sourceMappingURL=qmd-reindex.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"qmd-reindex.test.js","sourceRoot":"","sources":["../src/qmd-reindex.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "5.39.0",
3
+ "version": "5.41.0",
4
4
  "description": "HQ by Indigo cloud sync engine — bidirectional S3 sync for mobile access",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -205,7 +205,7 @@ describe("argv parsing", () => {
205
205
  const deps = makeDeps();
206
206
  const code = await runRunner([], deps);
207
207
  expect(code).toBe(1);
208
- expect(deps.stderr.raw()).toContain("--companies or --company");
208
+ expect(deps.stderr.raw()).toContain("--personal");
209
209
  expect(deps.stdout.events()).toEqual([]);
210
210
  });
211
211
 
@@ -582,6 +582,227 @@ describe("target resolution", () => {
582
582
  });
583
583
  });
584
584
 
585
+ // ---------------------------------------------------------------------------
586
+ // --personal mode (personal-vault-only, skip company fanout)
587
+ // ---------------------------------------------------------------------------
588
+ //
589
+ // `--personal` is mutually exclusive with `--companies` and `--company`. It
590
+ // runs ONLY the personal-vault target — no listMyMemberships call, no
591
+ // claim-dance, no cloud-company fanout. Designed as the replacement
592
+ // pathway for Rust's `personal.rs::run_personal_first_push` (the menubar's
593
+ // first-push), so the entire personal-vault walker lives in one place
594
+ // (hq-cloud TS) and not in two duplicate engines.
595
+
596
+ describe("--personal mode", () => {
597
+ it("argv: --personal + --companies is rejected", async () => {
598
+ const deps = makeDeps();
599
+ const code = await runRunner(["--personal", "--companies"], deps);
600
+ expect(code).toBe(1);
601
+ expect(deps.stderr.raw()).toContain("mutually exclusive");
602
+ });
603
+
604
+ it("argv: --personal + --company X is rejected", async () => {
605
+ const deps = makeDeps();
606
+ const code = await runRunner(["--personal", "--company", "cmp_x"], deps);
607
+ expect(code).toBe(1);
608
+ expect(deps.stderr.raw()).toContain("mutually exclusive");
609
+ });
610
+
611
+ it("argv: --personal + --skip-personal is rejected (contradictory)", async () => {
612
+ const deps = makeDeps();
613
+ const code = await runRunner(["--personal", "--skip-personal"], deps);
614
+ expect(code).toBe(1);
615
+ expect(deps.stderr.raw()).toContain("contradictory");
616
+ });
617
+
618
+ it("does NOT call listMyMemberships (skips company-discovery API entirely)", async () => {
619
+ const listSpy = vi.fn();
620
+ const deps = makeDeps({
621
+ createVaultClient: () => ({
622
+ ...makeVaultStub({
623
+ listPersons: () =>
624
+ Promise.resolve([
625
+ {
626
+ uid: "ent_person_1",
627
+ type: "person",
628
+ bucketName: "hq-vault-personal-1",
629
+ status: "active",
630
+ } as unknown as EntityInfo,
631
+ ]),
632
+ }),
633
+ listMyMemberships: listSpy as unknown as () => Promise<Membership[]>,
634
+ }),
635
+ });
636
+ const code = await runRunner(["--personal"], deps);
637
+ expect(code).toBe(0);
638
+ expect(listSpy).not.toHaveBeenCalled();
639
+ });
640
+
641
+ it("does NOT run claim-dance (skips pending-invites + ensurePerson)", async () => {
642
+ const claimSpy = vi.fn();
643
+ const ensureSpy = vi.fn();
644
+ const deps = makeDeps({
645
+ createVaultClient: () => ({
646
+ ...makeVaultStub({
647
+ listPersons: () =>
648
+ Promise.resolve([
649
+ {
650
+ uid: "ent_person_1",
651
+ type: "person",
652
+ bucketName: "hq-vault-personal-1",
653
+ status: "active",
654
+ } as unknown as EntityInfo,
655
+ ]),
656
+ }),
657
+ claimPendingInvitesByEmail:
658
+ claimSpy as unknown as (uid: string) => Promise<void>,
659
+ ensureMyPersonEntity:
660
+ ensureSpy as unknown as VaultClientSurface["ensureMyPersonEntity"],
661
+ }),
662
+ getIdTokenClaims: () => ({
663
+ sub: "sub-abc",
664
+ email: "user@example.com",
665
+ name: "User Name",
666
+ }),
667
+ });
668
+ const code = await runRunner(["--personal"], deps);
669
+ expect(code).toBe(0);
670
+ expect(claimSpy).not.toHaveBeenCalled();
671
+ expect(ensureSpy).not.toHaveBeenCalled();
672
+ });
673
+
674
+ it("emits fanout-plan with exactly one personal entry", async () => {
675
+ const deps = makeDeps({
676
+ createVaultClient: () =>
677
+ makeVaultStub({
678
+ listPersons: () =>
679
+ Promise.resolve([
680
+ {
681
+ uid: "ent_person_1",
682
+ type: "person",
683
+ bucketName: "hq-vault-personal-1",
684
+ status: "active",
685
+ } as unknown as EntityInfo,
686
+ ]),
687
+ }),
688
+ });
689
+ const code = await runRunner(["--personal"], deps);
690
+ expect(code).toBe(0);
691
+ const plan = deps.stdout
692
+ .events()
693
+ .find((e) => e.type === "fanout-plan") as Extract<
694
+ RunnerEvent,
695
+ { type: "fanout-plan" }
696
+ >;
697
+ expect(plan).toBeDefined();
698
+ expect(plan.companies).toHaveLength(1);
699
+ expect(plan.companies[0]).toMatchObject({ slug: "personal" });
700
+ });
701
+
702
+ it("calls syncFn ONCE with personalMode=true (default direction=pull)", async () => {
703
+ const deps = makeDeps({
704
+ createVaultClient: () =>
705
+ makeVaultStub({
706
+ listPersons: () =>
707
+ Promise.resolve([
708
+ {
709
+ uid: "ent_person_1",
710
+ type: "person",
711
+ bucketName: "hq-vault-personal-1",
712
+ status: "active",
713
+ } as unknown as EntityInfo,
714
+ ]),
715
+ }),
716
+ });
717
+ const code = await runRunner(["--personal"], deps);
718
+ expect(code).toBe(0);
719
+ expect(deps.sync).toHaveBeenCalledTimes(1);
720
+ const call = (deps.sync as ReturnType<typeof vi.fn>).mock
721
+ .calls[0][0] as SyncOptions;
722
+ expect(call.personalMode).toBe(true);
723
+ });
724
+
725
+ it("--personal --direction push calls shareFn with personalMode + computePersonalVaultPaths", async () => {
726
+ const shareSpy = vi.fn().mockResolvedValue({
727
+ filesUploaded: 0,
728
+ bytesUploaded: 0,
729
+ filesSkipped: 0,
730
+ filesDeleted: 0,
731
+ conflictPaths: [],
732
+ aborted: false,
733
+ });
734
+ const deps = makeDeps({
735
+ share: shareSpy as unknown as RunnerDeps["share"],
736
+ createVaultClient: () =>
737
+ makeVaultStub({
738
+ listPersons: () =>
739
+ Promise.resolve([
740
+ {
741
+ uid: "ent_person_1",
742
+ type: "person",
743
+ bucketName: "hq-vault-personal-1",
744
+ status: "active",
745
+ } as unknown as EntityInfo,
746
+ ]),
747
+ }),
748
+ });
749
+ const code = await runRunner(["--personal", "--direction", "push"], deps);
750
+ expect(code).toBe(0);
751
+ expect(shareSpy).toHaveBeenCalledTimes(1);
752
+ const opts = shareSpy.mock.calls[0][0];
753
+ expect(opts.personalMode).toBe(true);
754
+ // paths come from computePersonalVaultPaths — non-empty array of
755
+ // absolute hqRoot-anchored paths. Don't pin the exact contents
756
+ // (depends on hqRoot's local layout); just confirm the shape.
757
+ expect(Array.isArray(opts.paths)).toBe(true);
758
+ });
759
+
760
+ it("no person entity → emits setup-needed event, exit 0 (same shape as --companies)", async () => {
761
+ const deps = makeDeps({
762
+ createVaultClient: () =>
763
+ makeVaultStub({
764
+ listPersons: () => Promise.resolve([]),
765
+ }),
766
+ });
767
+ const code = await runRunner(["--personal"], deps);
768
+ expect(code).toBe(0);
769
+ expect(
770
+ deps.stdout.events().some((e) => e.type === "setup-needed"),
771
+ ).toBe(true);
772
+ expect(deps.sync).not.toHaveBeenCalled();
773
+ });
774
+
775
+ it("--personal honors --direction both (calls both shareFn and syncFn)", async () => {
776
+ const shareSpy = vi.fn().mockResolvedValue({
777
+ filesUploaded: 0,
778
+ bytesUploaded: 0,
779
+ filesSkipped: 0,
780
+ filesDeleted: 0,
781
+ conflictPaths: [],
782
+ aborted: false,
783
+ });
784
+ const deps = makeDeps({
785
+ share: shareSpy as unknown as RunnerDeps["share"],
786
+ createVaultClient: () =>
787
+ makeVaultStub({
788
+ listPersons: () =>
789
+ Promise.resolve([
790
+ {
791
+ uid: "ent_person_1",
792
+ type: "person",
793
+ bucketName: "hq-vault-personal-1",
794
+ status: "active",
795
+ } as unknown as EntityInfo,
796
+ ]),
797
+ }),
798
+ });
799
+ const code = await runRunner(["--personal", "--direction", "both"], deps);
800
+ expect(code).toBe(0);
801
+ expect(shareSpy).toHaveBeenCalledTimes(1);
802
+ expect(deps.sync).toHaveBeenCalledTimes(1);
803
+ });
804
+ });
805
+
585
806
  // ---------------------------------------------------------------------------
586
807
  // fanout-plan
587
808
  // ---------------------------------------------------------------------------
@@ -88,6 +88,7 @@ import type { ShareOptions, ShareResult } from "../cli/share.js";
88
88
  import type { ConflictStrategy } from "../cli/conflict.js";
89
89
  import type { UploadAuthor } from "../s3.js";
90
90
  import { collectAndSendTelemetry } from "../telemetry.js";
91
+ import { reindexAfterSync } from "../qmd-reindex.js";
91
92
  import { describeError } from "../lib/describe-error.js";
92
93
  import { getOrCreateMachineId } from "../lib/machine-id.js";
93
94
  import {
@@ -501,6 +502,16 @@ async function runClaimDance(
501
502
  interface ParsedArgs {
502
503
  companies: boolean;
503
504
  company?: string;
505
+ /**
506
+ * Personal-vault-only mode. Mutually exclusive with `--companies` and
507
+ * `--company`. Skips `listMyMemberships` (and therefore the claim-dance);
508
+ * builds a fanout plan containing ONLY the personal target. Designed as
509
+ * the runner-side entry point that replaces Rust's
510
+ * `personal.rs::run_personal_first_push` first-push walker — so the
511
+ * personal-vault scope (`computePersonalVaultPaths`) lives in exactly
512
+ * one place (this TS engine) and not duplicated across engines.
513
+ */
514
+ personal: boolean;
504
515
  onConflict: ConflictStrategy;
505
516
  hqRoot: string;
506
517
  direction: Direction;
@@ -529,6 +540,7 @@ interface ParsedArgs {
529
540
  function parseArgs(argv: string[]): ParsedArgs | { error: string } {
530
541
  let companies = false;
531
542
  let company: string | undefined;
543
+ let personal = false;
532
544
  let onConflict: ConflictStrategy = "abort";
533
545
  let hqRoot = DEFAULT_HQ_ROOT;
534
546
  let direction: Direction = "pull";
@@ -547,6 +559,14 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
547
559
  company = argv[++i];
548
560
  if (!company) return { error: "--company requires a value" };
549
561
  break;
562
+ case "--personal":
563
+ // Personal-vault-only mode. Skips listMyMemberships + claim-dance
564
+ // entirely; builds a fanout plan containing only the personal target.
565
+ // Replaces Rust's personal.rs::run_personal_first_push walker —
566
+ // the personal-vault scope (computePersonalVaultPaths) is owned
567
+ // by this engine, not duplicated across Rust + TS.
568
+ personal = true;
569
+ break;
550
570
  case "--on-conflict": {
551
571
  const val = argv[++i];
552
572
  if (val !== "abort" && val !== "overwrite" && val !== "keep") {
@@ -609,8 +629,18 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
609
629
  if (companies && company) {
610
630
  return { error: "Pass --companies OR --company <slug>, not both" };
611
631
  }
612
- if (!companies && !company) {
613
- return { error: "Pass --companies or --company <slug>" };
632
+ if (personal && (companies || company)) {
633
+ return {
634
+ error: "--personal is mutually exclusive with --companies / --company",
635
+ };
636
+ }
637
+ if (personal && skipPersonal) {
638
+ return {
639
+ error: "--personal and --skip-personal are contradictory",
640
+ };
641
+ }
642
+ if (!companies && !company && !personal) {
643
+ return { error: "Pass --companies, --company <slug>, or --personal" };
614
644
  }
615
645
  if (pollRemoteMs !== undefined && !watch) {
616
646
  return { error: "--poll-remote-ms requires --watch" };
@@ -622,6 +652,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
622
652
  return {
623
653
  companies,
624
654
  company,
655
+ personal,
625
656
  onConflict,
626
657
  hqRoot,
627
658
  direction,
@@ -774,7 +805,15 @@ export async function runRunner(
774
805
  // ---- resolve targets --------------------------------------------------
775
806
  let memberships: Pick<Membership, "companyUid">[];
776
807
  try {
777
- if (parsed.companies) {
808
+ if (parsed.personal) {
809
+ // Personal-vault-only mode: skip listMyMemberships entirely (and
810
+ // therefore the claim-dance). The fanout plan is built solely from
811
+ // the person-entity lookup below — no cloud-company targets, no
812
+ // /membership/me round-trip. setup-needed is deferred to the
813
+ // person-entity check (firing only when the personal entity is
814
+ // also absent, not when memberships are empty by design).
815
+ memberships = [];
816
+ } else if (parsed.companies) {
778
817
  // Before giving up on memberships, run the claim-dance: new users signed
779
818
  // in via the tray may have email-keyed invites waiting for them. Without
780
819
  // this, an invited user would see "setup-needed" on every tray click.
@@ -840,7 +879,10 @@ export async function runRunner(
840
879
  plan.push({ uid: m.companyUid, slug, ...(name ? { name } : {}) });
841
880
  }
842
881
 
843
- if (parsed.companies && !resolveSkipPersonal(parsed.skipPersonal)) {
882
+ if (
883
+ (parsed.companies || parsed.personal) &&
884
+ !resolveSkipPersonal(parsed.skipPersonal)
885
+ ) {
844
886
  // Personal-target fanout slot. Skipped entirely when --skip-personal
845
887
  // (or HQ_SYNC_SKIP_PERSONAL=1) is set — see resolveSkipPersonal doc for
846
888
  // the rationale (menubar opt-out for users who only want company sync).
@@ -849,6 +891,9 @@ export async function runRunner(
849
891
  // workspaces row, status surfaces) should already tolerate that
850
892
  // shape since pre-5.25 fanout often had it (a user with no person
851
893
  // entity yet, or before the canonical-person-entity machinery landed).
894
+ //
895
+ // `--personal` mode reaches this block with an empty `plan` and
896
+ // empty `memberships`; only the personal target gets added below.
852
897
  const persons = await client.entity.listByType("person");
853
898
  const pick = pickCanonicalPersonEntity(persons);
854
899
  if (pick?.bucketName) {
@@ -864,6 +909,14 @@ export async function runRunner(
864
909
  // root-cause writeup.
865
910
  journalSlug: PERSONAL_VAULT_JOURNAL_SLUG,
866
911
  });
912
+ } else if (parsed.personal) {
913
+ // --personal mode with no canonical personal entity → setup-needed.
914
+ // (In --companies mode this state is silent — companies still sync
915
+ // and the missing personal target just shows an empty workspaces row.
916
+ // In --personal mode it's the ONLY signal, so it gets surfaced as
917
+ // setup-needed with the same shape as the empty-memberships case.)
918
+ emit({ type: "setup-needed" });
919
+ return 0;
867
920
  }
868
921
  }
869
922
 
@@ -1337,6 +1390,21 @@ export async function runRunner(
1337
1390
  partial,
1338
1391
  companies,
1339
1392
  });
1393
+
1394
+ // Post-sync qmd reindex — runs AFTER `all-complete` is emitted so the
1395
+ // menubar/CLI already shows the sync as done; this is a best-effort tail
1396
+ // step that never affects the exit code. Only when files were actually
1397
+ // pulled in (nothing to reindex otherwise) and not explicitly disabled.
1398
+ // Self-contained: shells out to the global `qmd` binary, no dependency on
1399
+ // any (possibly stale) script inside the synced HQ tree. See qmd-reindex.ts.
1400
+ if (totalDownloaded > 0 && process.env.HQ_QMD_REINDEX_ON_SYNC !== "0") {
1401
+ try {
1402
+ reindexAfterSync(parsed.hqRoot);
1403
+ } catch {
1404
+ // Defensive: reindexAfterSync already swallows internally.
1405
+ }
1406
+ }
1407
+
1340
1408
  // Exit 2 only when something actually threw (`errors.length > 0`). A clean
1341
1409
  // conflict-abort sets `partial: true` in the JSON but exits 0 — the Tauri
1342
1410
  // menubar's non-zero-exit Sentry capture would otherwise fire for normal