@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.
@@ -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
  // ---------------------------------------------------------------------------
@@ -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 (!companies && !company) {
613
- return { error: "Pass --companies or --company <slug>" };
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.companies) {
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 (parsed.companies && !resolveSkipPersonal(parsed.skipPersonal)) {
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