@indigoai-us/hq-cloud 5.39.0 → 5.40.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 +46 -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/package.json +1 -1
- package/src/bin/sync-runner.test.ts +222 -1
- package/src/bin/sync-runner.ts +56 -4
|
@@ -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
|
@@ -501,6 +501,16 @@ async function runClaimDance(
|
|
|
501
501
|
interface ParsedArgs {
|
|
502
502
|
companies: boolean;
|
|
503
503
|
company?: string;
|
|
504
|
+
/**
|
|
505
|
+
* Personal-vault-only mode. Mutually exclusive with `--companies` and
|
|
506
|
+
* `--company`. Skips `listMyMemberships` (and therefore the claim-dance);
|
|
507
|
+
* builds a fanout plan containing ONLY the personal target. Designed as
|
|
508
|
+
* the runner-side entry point that replaces Rust's
|
|
509
|
+
* `personal.rs::run_personal_first_push` first-push walker — so the
|
|
510
|
+
* personal-vault scope (`computePersonalVaultPaths`) lives in exactly
|
|
511
|
+
* one place (this TS engine) and not duplicated across engines.
|
|
512
|
+
*/
|
|
513
|
+
personal: boolean;
|
|
504
514
|
onConflict: ConflictStrategy;
|
|
505
515
|
hqRoot: string;
|
|
506
516
|
direction: Direction;
|
|
@@ -529,6 +539,7 @@ interface ParsedArgs {
|
|
|
529
539
|
function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
530
540
|
let companies = false;
|
|
531
541
|
let company: string | undefined;
|
|
542
|
+
let personal = false;
|
|
532
543
|
let onConflict: ConflictStrategy = "abort";
|
|
533
544
|
let hqRoot = DEFAULT_HQ_ROOT;
|
|
534
545
|
let direction: Direction = "pull";
|
|
@@ -547,6 +558,14 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
547
558
|
company = argv[++i];
|
|
548
559
|
if (!company) return { error: "--company requires a value" };
|
|
549
560
|
break;
|
|
561
|
+
case "--personal":
|
|
562
|
+
// Personal-vault-only mode. Skips listMyMemberships + claim-dance
|
|
563
|
+
// entirely; builds a fanout plan containing only the personal target.
|
|
564
|
+
// Replaces Rust's personal.rs::run_personal_first_push walker —
|
|
565
|
+
// the personal-vault scope (computePersonalVaultPaths) is owned
|
|
566
|
+
// by this engine, not duplicated across Rust + TS.
|
|
567
|
+
personal = true;
|
|
568
|
+
break;
|
|
550
569
|
case "--on-conflict": {
|
|
551
570
|
const val = argv[++i];
|
|
552
571
|
if (val !== "abort" && val !== "overwrite" && val !== "keep") {
|
|
@@ -609,8 +628,18 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
609
628
|
if (companies && company) {
|
|
610
629
|
return { error: "Pass --companies OR --company <slug>, not both" };
|
|
611
630
|
}
|
|
612
|
-
if (
|
|
613
|
-
return {
|
|
631
|
+
if (personal && (companies || company)) {
|
|
632
|
+
return {
|
|
633
|
+
error: "--personal is mutually exclusive with --companies / --company",
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
if (personal && skipPersonal) {
|
|
637
|
+
return {
|
|
638
|
+
error: "--personal and --skip-personal are contradictory",
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
if (!companies && !company && !personal) {
|
|
642
|
+
return { error: "Pass --companies, --company <slug>, or --personal" };
|
|
614
643
|
}
|
|
615
644
|
if (pollRemoteMs !== undefined && !watch) {
|
|
616
645
|
return { error: "--poll-remote-ms requires --watch" };
|
|
@@ -622,6 +651,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
622
651
|
return {
|
|
623
652
|
companies,
|
|
624
653
|
company,
|
|
654
|
+
personal,
|
|
625
655
|
onConflict,
|
|
626
656
|
hqRoot,
|
|
627
657
|
direction,
|
|
@@ -774,7 +804,15 @@ export async function runRunner(
|
|
|
774
804
|
// ---- resolve targets --------------------------------------------------
|
|
775
805
|
let memberships: Pick<Membership, "companyUid">[];
|
|
776
806
|
try {
|
|
777
|
-
if (parsed.
|
|
807
|
+
if (parsed.personal) {
|
|
808
|
+
// Personal-vault-only mode: skip listMyMemberships entirely (and
|
|
809
|
+
// therefore the claim-dance). The fanout plan is built solely from
|
|
810
|
+
// the person-entity lookup below — no cloud-company targets, no
|
|
811
|
+
// /membership/me round-trip. setup-needed is deferred to the
|
|
812
|
+
// person-entity check (firing only when the personal entity is
|
|
813
|
+
// also absent, not when memberships are empty by design).
|
|
814
|
+
memberships = [];
|
|
815
|
+
} else if (parsed.companies) {
|
|
778
816
|
// Before giving up on memberships, run the claim-dance: new users signed
|
|
779
817
|
// in via the tray may have email-keyed invites waiting for them. Without
|
|
780
818
|
// this, an invited user would see "setup-needed" on every tray click.
|
|
@@ -840,7 +878,10 @@ export async function runRunner(
|
|
|
840
878
|
plan.push({ uid: m.companyUid, slug, ...(name ? { name } : {}) });
|
|
841
879
|
}
|
|
842
880
|
|
|
843
|
-
if (
|
|
881
|
+
if (
|
|
882
|
+
(parsed.companies || parsed.personal) &&
|
|
883
|
+
!resolveSkipPersonal(parsed.skipPersonal)
|
|
884
|
+
) {
|
|
844
885
|
// Personal-target fanout slot. Skipped entirely when --skip-personal
|
|
845
886
|
// (or HQ_SYNC_SKIP_PERSONAL=1) is set — see resolveSkipPersonal doc for
|
|
846
887
|
// the rationale (menubar opt-out for users who only want company sync).
|
|
@@ -849,6 +890,9 @@ export async function runRunner(
|
|
|
849
890
|
// workspaces row, status surfaces) should already tolerate that
|
|
850
891
|
// shape since pre-5.25 fanout often had it (a user with no person
|
|
851
892
|
// entity yet, or before the canonical-person-entity machinery landed).
|
|
893
|
+
//
|
|
894
|
+
// `--personal` mode reaches this block with an empty `plan` and
|
|
895
|
+
// empty `memberships`; only the personal target gets added below.
|
|
852
896
|
const persons = await client.entity.listByType("person");
|
|
853
897
|
const pick = pickCanonicalPersonEntity(persons);
|
|
854
898
|
if (pick?.bucketName) {
|
|
@@ -864,6 +908,14 @@ export async function runRunner(
|
|
|
864
908
|
// root-cause writeup.
|
|
865
909
|
journalSlug: PERSONAL_VAULT_JOURNAL_SLUG,
|
|
866
910
|
});
|
|
911
|
+
} else if (parsed.personal) {
|
|
912
|
+
// --personal mode with no canonical personal entity → setup-needed.
|
|
913
|
+
// (In --companies mode this state is silent — companies still sync
|
|
914
|
+
// and the missing personal target just shows an empty workspaces row.
|
|
915
|
+
// In --personal mode it's the ONLY signal, so it gets surfaced as
|
|
916
|
+
// setup-needed with the same shape as the empty-memberships case.)
|
|
917
|
+
emit({ type: "setup-needed" });
|
|
918
|
+
return 0;
|
|
867
919
|
}
|
|
868
920
|
}
|
|
869
921
|
|