@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.
- package/.github/workflows/publish.yml +23 -4
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +61 -4
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +193 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/qmd-reindex.d.ts +59 -0
- package/dist/qmd-reindex.d.ts.map +1 -0
- package/dist/qmd-reindex.js +128 -0
- package/dist/qmd-reindex.js.map +1 -0
- package/dist/qmd-reindex.test.d.ts +10 -0
- package/dist/qmd-reindex.test.d.ts.map +1 -0
- package/dist/qmd-reindex.test.js +129 -0
- package/dist/qmd-reindex.test.js.map +1 -0
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +222 -1
- package/src/bin/sync-runner.ts +72 -4
- package/src/qmd-reindex.test.ts +143 -0
- package/src/qmd-reindex.ts +151 -0
|
@@ -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
|
@@ -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("--
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -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 (
|
|
613
|
-
return {
|
|
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.
|
|
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 (
|
|
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
|