@indigoai-us/hq-cloud 5.38.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 +85 -5
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +293 -3
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +17 -1
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +61 -0
- package/dist/personal-vault.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +328 -3
- package/src/bin/sync-runner.ts +97 -5
- package/src/personal-vault.test.ts +91 -0
- package/src/personal-vault.ts +16 -1
|
@@ -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
|
|
|
@@ -279,15 +279,23 @@ describe("auth", () => {
|
|
|
279
279
|
expect(deps.stdout.events()).toEqual([]);
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
-
it("emits error event on stderr and returns 1 on non-auth discovery failure", async () => {
|
|
282
|
+
it("emits error event on stderr and returns 1 on non-auth discovery failure (after 3 attempts)", async () => {
|
|
283
|
+
let calls = 0;
|
|
283
284
|
const deps = makeDeps({
|
|
284
285
|
createVaultClient: () => ({
|
|
285
286
|
...makeVaultStub(),
|
|
286
|
-
listMyMemberships: () =>
|
|
287
|
+
listMyMemberships: () => {
|
|
288
|
+
calls++;
|
|
289
|
+
return Promise.reject(new Error("network down"));
|
|
290
|
+
},
|
|
287
291
|
}),
|
|
288
292
|
});
|
|
289
293
|
const code = await runRunner(["--companies"], deps);
|
|
290
294
|
expect(code).toBe(1);
|
|
295
|
+
// Memberships retry burns all 3 attempts before surfacing the error —
|
|
296
|
+
// see listMembershipsWithRetry. Auth errors short-circuit (no retry);
|
|
297
|
+
// anything else (network, 5xx) retries twice more before giving up.
|
|
298
|
+
expect(calls).toBe(3);
|
|
291
299
|
// error events go to stderr, not stdout
|
|
292
300
|
const events = deps.stderr.events();
|
|
293
301
|
expect(events).toHaveLength(1);
|
|
@@ -300,6 +308,102 @@ describe("auth", () => {
|
|
|
300
308
|
});
|
|
301
309
|
});
|
|
302
310
|
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// memberships retry (3x with backoff, then abort)
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
//
|
|
315
|
+
// `listMyMemberships()` is the single API call that drives every fanout
|
|
316
|
+
// target in --companies mode. A transient network blip on this one call
|
|
317
|
+
// shouldn't kill the whole sync — retry 3 times with backoff before
|
|
318
|
+
// surfacing the error. Auth failures short-circuit (no retries — the
|
|
319
|
+
// caller needs to re-vend, retries won't help).
|
|
320
|
+
//
|
|
321
|
+
// Single-company mode bypasses memberships entirely (the caller already
|
|
322
|
+
// told us which company), so no retry path is needed there.
|
|
323
|
+
|
|
324
|
+
describe("memberships retry", () => {
|
|
325
|
+
it("succeeds on 1st attempt with no retry", async () => {
|
|
326
|
+
let listCalls = 0;
|
|
327
|
+
const stub = makeVaultStub();
|
|
328
|
+
stub.listMyMemberships = () => {
|
|
329
|
+
listCalls++;
|
|
330
|
+
return Promise.resolve([{ companyUid: "cmp_acme" }] as Membership[]);
|
|
331
|
+
};
|
|
332
|
+
const deps = makeDeps({ createVaultClient: () => stub });
|
|
333
|
+
const code = await runRunner(["--companies"], deps);
|
|
334
|
+
expect(code).toBe(0);
|
|
335
|
+
expect(listCalls).toBe(1);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("succeeds on 3rd attempt after 2 transient failures", async () => {
|
|
339
|
+
let listCalls = 0;
|
|
340
|
+
const stub = makeVaultStub();
|
|
341
|
+
stub.listMyMemberships = () => {
|
|
342
|
+
listCalls++;
|
|
343
|
+
if (listCalls < 3) {
|
|
344
|
+
return Promise.reject(new Error("ECONNRESET"));
|
|
345
|
+
}
|
|
346
|
+
return Promise.resolve([{ companyUid: "cmp_acme" }] as Membership[]);
|
|
347
|
+
};
|
|
348
|
+
const deps = makeDeps({ createVaultClient: () => stub });
|
|
349
|
+
const code = await runRunner(["--companies"], deps);
|
|
350
|
+
expect(code).toBe(0);
|
|
351
|
+
expect(listCalls).toBe(3);
|
|
352
|
+
// No error event should reach stderr — the retry succeeded.
|
|
353
|
+
expect(deps.stderr.events().some((e) => e.type === "error")).toBe(false);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("exhausts 3 retries on persistent failure, then emits error event + exit 1", async () => {
|
|
357
|
+
let listCalls = 0;
|
|
358
|
+
const stub = makeVaultStub();
|
|
359
|
+
stub.listMyMemberships = () => {
|
|
360
|
+
listCalls++;
|
|
361
|
+
return Promise.reject(new Error("network down"));
|
|
362
|
+
};
|
|
363
|
+
const deps = makeDeps({ createVaultClient: () => stub });
|
|
364
|
+
const code = await runRunner(["--companies"], deps);
|
|
365
|
+
expect(code).toBe(1);
|
|
366
|
+
expect(listCalls).toBe(3);
|
|
367
|
+
const events = deps.stderr.events();
|
|
368
|
+
expect(events).toHaveLength(1);
|
|
369
|
+
expect(events[0]).toMatchObject({
|
|
370
|
+
type: "error",
|
|
371
|
+
message: expect.stringContaining("network down"),
|
|
372
|
+
path: "(discovery)",
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("VaultAuthError short-circuits retry (no retries on auth failure)", async () => {
|
|
377
|
+
let listCalls = 0;
|
|
378
|
+
const stub = makeVaultStub();
|
|
379
|
+
stub.listMyMemberships = () => {
|
|
380
|
+
listCalls++;
|
|
381
|
+
return Promise.reject(new VaultAuthError("token expired"));
|
|
382
|
+
};
|
|
383
|
+
const deps = makeDeps({ createVaultClient: () => stub });
|
|
384
|
+
const code = await runRunner(["--companies"], deps);
|
|
385
|
+
expect(code).toBe(0);
|
|
386
|
+
// Auth failures don't retry — re-vending creds is the caller's job.
|
|
387
|
+
expect(listCalls).toBe(1);
|
|
388
|
+
expect(deps.stderr.events()).toEqual([
|
|
389
|
+
{ type: "auth-error", message: "token expired" },
|
|
390
|
+
]);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("single-company mode bypasses listMyMemberships entirely (no retry path triggered)", async () => {
|
|
394
|
+
let listCalls = 0;
|
|
395
|
+
const stub = makeVaultStub();
|
|
396
|
+
stub.listMyMemberships = () => {
|
|
397
|
+
listCalls++;
|
|
398
|
+
return Promise.reject(new Error("should not be called"));
|
|
399
|
+
};
|
|
400
|
+
const deps = makeDeps({ createVaultClient: () => stub });
|
|
401
|
+
const code = await runRunner(["--company", "cmp_explicit"], deps);
|
|
402
|
+
expect(code).toBe(0);
|
|
403
|
+
expect(listCalls).toBe(0);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
303
407
|
// ---------------------------------------------------------------------------
|
|
304
408
|
// claim-dance (first sign-in)
|
|
305
409
|
// ---------------------------------------------------------------------------
|
|
@@ -478,6 +582,227 @@ describe("target resolution", () => {
|
|
|
478
582
|
});
|
|
479
583
|
});
|
|
480
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
|
+
|
|
481
806
|
// ---------------------------------------------------------------------------
|
|
482
807
|
// fanout-plan
|
|
483
808
|
// ---------------------------------------------------------------------------
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -340,6 +340,46 @@ export interface VaultClientSurface {
|
|
|
340
340
|
};
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Backoff schedule (in ms) between attempts 2 and 3 of
|
|
345
|
+
* `listMembershipsWithRetry`. Short on purpose — memberships is a single
|
|
346
|
+
* API call gating the whole runner, and a transient blip (DNS hiccup,
|
|
347
|
+
* idle ALB connection reset) usually clears in <50ms. If the network is
|
|
348
|
+
* genuinely down, three attempts in <200ms total fail fast enough that
|
|
349
|
+
* the tray can show its error banner before the user notices a delay.
|
|
350
|
+
*/
|
|
351
|
+
const MEMBERSHIPS_RETRY_BACKOFFS_MS: readonly number[] = [50, 100];
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Call `listMyMemberships()` with up to 3 attempts and a small linear
|
|
355
|
+
* backoff between them. The single network call that drives every cloud
|
|
356
|
+
* company target plus the personal-vault slot — a one-off network blip
|
|
357
|
+
* shouldn't kill the whole sync run.
|
|
358
|
+
*
|
|
359
|
+
* Auth failures (VaultAuthError) bypass retry entirely: re-vending creds
|
|
360
|
+
* is the caller's job, not retryable in-process. Re-throwing immediately
|
|
361
|
+
* preserves the existing auth-error event semantics in the outer
|
|
362
|
+
* try/catch.
|
|
363
|
+
*/
|
|
364
|
+
async function listMembershipsWithRetry(
|
|
365
|
+
client: VaultClientSurface,
|
|
366
|
+
): Promise<Membership[]> {
|
|
367
|
+
let lastErr: unknown;
|
|
368
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
369
|
+
if (attempt > 0) {
|
|
370
|
+
const delayMs = MEMBERSHIPS_RETRY_BACKOFFS_MS[attempt - 1] ?? 100;
|
|
371
|
+
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
return await client.listMyMemberships();
|
|
375
|
+
} catch (err) {
|
|
376
|
+
if (err instanceof VaultAuthError) throw err;
|
|
377
|
+
lastErr = err;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
throw lastErr;
|
|
381
|
+
}
|
|
382
|
+
|
|
343
383
|
/** Minimal shape of the claims we read off the Cognito idToken. */
|
|
344
384
|
interface IdTokenClaims {
|
|
345
385
|
sub?: string;
|
|
@@ -461,6 +501,16 @@ async function runClaimDance(
|
|
|
461
501
|
interface ParsedArgs {
|
|
462
502
|
companies: boolean;
|
|
463
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;
|
|
464
514
|
onConflict: ConflictStrategy;
|
|
465
515
|
hqRoot: string;
|
|
466
516
|
direction: Direction;
|
|
@@ -489,6 +539,7 @@ interface ParsedArgs {
|
|
|
489
539
|
function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
490
540
|
let companies = false;
|
|
491
541
|
let company: string | undefined;
|
|
542
|
+
let personal = false;
|
|
492
543
|
let onConflict: ConflictStrategy = "abort";
|
|
493
544
|
let hqRoot = DEFAULT_HQ_ROOT;
|
|
494
545
|
let direction: Direction = "pull";
|
|
@@ -507,6 +558,14 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
507
558
|
company = argv[++i];
|
|
508
559
|
if (!company) return { error: "--company requires a value" };
|
|
509
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;
|
|
510
569
|
case "--on-conflict": {
|
|
511
570
|
const val = argv[++i];
|
|
512
571
|
if (val !== "abort" && val !== "overwrite" && val !== "keep") {
|
|
@@ -569,8 +628,18 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
569
628
|
if (companies && company) {
|
|
570
629
|
return { error: "Pass --companies OR --company <slug>, not both" };
|
|
571
630
|
}
|
|
572
|
-
if (
|
|
573
|
-
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" };
|
|
574
643
|
}
|
|
575
644
|
if (pollRemoteMs !== undefined && !watch) {
|
|
576
645
|
return { error: "--poll-remote-ms requires --watch" };
|
|
@@ -582,6 +651,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
582
651
|
return {
|
|
583
652
|
companies,
|
|
584
653
|
company,
|
|
654
|
+
personal,
|
|
585
655
|
onConflict,
|
|
586
656
|
hqRoot,
|
|
587
657
|
direction,
|
|
@@ -734,7 +804,15 @@ export async function runRunner(
|
|
|
734
804
|
// ---- resolve targets --------------------------------------------------
|
|
735
805
|
let memberships: Pick<Membership, "companyUid">[];
|
|
736
806
|
try {
|
|
737
|
-
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) {
|
|
738
816
|
// Before giving up on memberships, run the claim-dance: new users signed
|
|
739
817
|
// in via the tray may have email-keyed invites waiting for them. Without
|
|
740
818
|
// this, an invited user would see "setup-needed" on every tray click.
|
|
@@ -742,7 +820,7 @@ export async function runRunner(
|
|
|
742
820
|
await runClaimDance(client, claims, stderr);
|
|
743
821
|
}
|
|
744
822
|
|
|
745
|
-
memberships = await client
|
|
823
|
+
memberships = await listMembershipsWithRetry(client);
|
|
746
824
|
if (memberships.length === 0) {
|
|
747
825
|
// Truly empty — still a valid state (no memberships = nothing to
|
|
748
826
|
// sync). The tray will show a friendly "create your first company"
|
|
@@ -800,7 +878,10 @@ export async function runRunner(
|
|
|
800
878
|
plan.push({ uid: m.companyUid, slug, ...(name ? { name } : {}) });
|
|
801
879
|
}
|
|
802
880
|
|
|
803
|
-
if (
|
|
881
|
+
if (
|
|
882
|
+
(parsed.companies || parsed.personal) &&
|
|
883
|
+
!resolveSkipPersonal(parsed.skipPersonal)
|
|
884
|
+
) {
|
|
804
885
|
// Personal-target fanout slot. Skipped entirely when --skip-personal
|
|
805
886
|
// (or HQ_SYNC_SKIP_PERSONAL=1) is set — see resolveSkipPersonal doc for
|
|
806
887
|
// the rationale (menubar opt-out for users who only want company sync).
|
|
@@ -809,6 +890,9 @@ export async function runRunner(
|
|
|
809
890
|
// workspaces row, status surfaces) should already tolerate that
|
|
810
891
|
// shape since pre-5.25 fanout often had it (a user with no person
|
|
811
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.
|
|
812
896
|
const persons = await client.entity.listByType("person");
|
|
813
897
|
const pick = pickCanonicalPersonEntity(persons);
|
|
814
898
|
if (pick?.bucketName) {
|
|
@@ -824,6 +908,14 @@ export async function runRunner(
|
|
|
824
908
|
// root-cause writeup.
|
|
825
909
|
journalSlug: PERSONAL_VAULT_JOURNAL_SLUG,
|
|
826
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;
|
|
827
919
|
}
|
|
828
920
|
}
|
|
829
921
|
|
|
@@ -228,4 +228,95 @@ describe("personal-vault helpers", () => {
|
|
|
228
228
|
fs.rmSync(hqRoot, { recursive: true });
|
|
229
229
|
expect(computePersonalVaultPaths(hqRoot, { includeLocalCompanies: true })).toEqual([]);
|
|
230
230
|
});
|
|
231
|
+
|
|
232
|
+
// ── companies/manifest.yaml inclusion ─────────────────────────────────
|
|
233
|
+
//
|
|
234
|
+
// The manifest is the routing source-of-truth (which slugs exist, which
|
|
235
|
+
// are cloud-backed, what their company UIDs are). The vault needs a
|
|
236
|
+
// single canonical copy: without it, a fresh machine joining the
|
|
237
|
+
// personal vault has no way to enumerate which non-cloud companies it
|
|
238
|
+
// expects to pull. Special-cased because companies/ itself is in
|
|
239
|
+
// PERSONAL_VAULT_EXCLUDED_TOP_LEVEL.
|
|
240
|
+
it("manifest: includes companies/manifest.yaml when present (default opts)", () => {
|
|
241
|
+
fs.mkdirSync(path.join(hqRoot, "companies"));
|
|
242
|
+
fs.writeFileSync(
|
|
243
|
+
path.join(hqRoot, "companies", "manifest.yaml"),
|
|
244
|
+
"companies:\n foo: {}\n",
|
|
245
|
+
);
|
|
246
|
+
fs.mkdirSync(path.join(hqRoot, ".claude"));
|
|
247
|
+
|
|
248
|
+
const out = rel(computePersonalVaultPaths(hqRoot));
|
|
249
|
+
expect(out).toContain(path.join("companies", "manifest.yaml"));
|
|
250
|
+
expect(out).toContain(".claude");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("manifest: included even when includeLocalCompanies=false (manifest is unconditional)", () => {
|
|
254
|
+
fs.mkdirSync(path.join(hqRoot, "companies"));
|
|
255
|
+
fs.writeFileSync(
|
|
256
|
+
path.join(hqRoot, "companies", "manifest.yaml"),
|
|
257
|
+
"companies: {}\n",
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const out = rel(
|
|
261
|
+
computePersonalVaultPaths(hqRoot, { includeLocalCompanies: false }),
|
|
262
|
+
);
|
|
263
|
+
expect(out).toContain(path.join("companies", "manifest.yaml"));
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("manifest: absent file is not included (no crash, no phantom entry)", () => {
|
|
267
|
+
fs.mkdirSync(path.join(hqRoot, "companies"));
|
|
268
|
+
// No manifest.yaml written.
|
|
269
|
+
|
|
270
|
+
const out = rel(computePersonalVaultPaths(hqRoot));
|
|
271
|
+
expect(out).not.toContain(path.join("companies", "manifest.yaml"));
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("manifest: missing companies/ directory entirely is not an error", () => {
|
|
275
|
+
// Fresh install or atypical layout: no companies/ dir at all.
|
|
276
|
+
fs.mkdirSync(path.join(hqRoot, ".claude"));
|
|
277
|
+
expect(() => computePersonalVaultPaths(hqRoot)).not.toThrow();
|
|
278
|
+
const out = rel(computePersonalVaultPaths(hqRoot));
|
|
279
|
+
expect(out).toContain(".claude");
|
|
280
|
+
expect(out).not.toContain(path.join("companies", "manifest.yaml"));
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("manifest: ONLY manifest.yaml is special-cased — other companies/ root files stay excluded", () => {
|
|
284
|
+
// Anti-test: only manifest.yaml gets the bypass. A stray README.md at
|
|
285
|
+
// companies/ root or a directory called `_template` must still be
|
|
286
|
+
// filtered (the latter by company-eligibility, the former silently).
|
|
287
|
+
fs.mkdirSync(path.join(hqRoot, "companies"));
|
|
288
|
+
fs.writeFileSync(
|
|
289
|
+
path.join(hqRoot, "companies", "manifest.yaml"),
|
|
290
|
+
"companies: {}\n",
|
|
291
|
+
);
|
|
292
|
+
fs.writeFileSync(
|
|
293
|
+
path.join(hqRoot, "companies", "README.md"),
|
|
294
|
+
"# companies tree",
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const out = rel(computePersonalVaultPaths(hqRoot));
|
|
298
|
+
expect(out).toContain(path.join("companies", "manifest.yaml"));
|
|
299
|
+
expect(out).not.toContain(path.join("companies", "README.md"));
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("manifest: composes with includeLocalCompanies=true + local company subdirs", () => {
|
|
303
|
+
fs.mkdirSync(path.join(hqRoot, "companies"));
|
|
304
|
+
fs.writeFileSync(
|
|
305
|
+
path.join(hqRoot, "companies", "manifest.yaml"),
|
|
306
|
+
"companies:\n foo: {cloud_uid: null}\n team-acme: {cloud_uid: cmp_01XYZ}\n",
|
|
307
|
+
);
|
|
308
|
+
writeCompany("foo", "cloud: false\n");
|
|
309
|
+
writeCompany("team-acme", "cloud: true\n");
|
|
310
|
+
|
|
311
|
+
const out = rel(
|
|
312
|
+
computePersonalVaultPaths(hqRoot, {
|
|
313
|
+
includeLocalCompanies: true,
|
|
314
|
+
teamSyncedSlugs: new Set(["team-acme"]),
|
|
315
|
+
}),
|
|
316
|
+
);
|
|
317
|
+
// Manifest + non-cloud subdir, NOT team-acme.
|
|
318
|
+
expect(out).toContain(path.join("companies", "manifest.yaml"));
|
|
319
|
+
expect(out).toContain(path.join("companies", "foo"));
|
|
320
|
+
expect(out).not.toContain(path.join("companies", "team-acme"));
|
|
321
|
+
});
|
|
231
322
|
});
|
package/src/personal-vault.ts
CHANGED
|
@@ -105,10 +105,25 @@ export function computePersonalVaultPaths(
|
|
|
105
105
|
const topLevel = entries
|
|
106
106
|
.filter((name) => !PERSONAL_VAULT_EXCLUDED_TOP_LEVEL.includes(name))
|
|
107
107
|
.map((name) => path.join(hqRoot, name));
|
|
108
|
+
// companies/manifest.yaml is the routing source-of-truth (which slugs
|
|
109
|
+
// exist, which are cloud-backed, what their UIDs are). It must travel
|
|
110
|
+
// with every machine's personal vault so a fresh install can enumerate
|
|
111
|
+
// expected non-cloud companies before its first pull. Special-cased
|
|
112
|
+
// because the parent `companies/` is in PERSONAL_VAULT_EXCLUDED_TOP_LEVEL
|
|
113
|
+
// (we never enumerate the whole companies tree wholesale).
|
|
114
|
+
const manifest: string[] = [];
|
|
115
|
+
const manifestPath = path.join(hqRoot, "companies", "manifest.yaml");
|
|
116
|
+
try {
|
|
117
|
+
if (fs.statSync(manifestPath).isFile()) {
|
|
118
|
+
manifest.push(manifestPath);
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// Missing or unreadable — silently omit. Callers tolerate empty arrays.
|
|
122
|
+
}
|
|
108
123
|
const companySubdirs = opts.includeLocalCompanies === true
|
|
109
124
|
? computePersonalCompanySubdirs(hqRoot, opts.teamSyncedSlugs)
|
|
110
125
|
: [];
|
|
111
|
-
return [...topLevel, ...companySubdirs];
|
|
126
|
+
return [...topLevel, ...manifest, ...companySubdirs];
|
|
112
127
|
}
|
|
113
128
|
|
|
114
129
|
/**
|