@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
|
@@ -138,7 +138,7 @@ describe("argv parsing", () => {
|
|
|
138
138
|
const deps = makeDeps();
|
|
139
139
|
const code = await runRunner([], deps);
|
|
140
140
|
expect(code).toBe(1);
|
|
141
|
-
expect(deps.stderr.raw()).toContain("--
|
|
141
|
+
expect(deps.stderr.raw()).toContain("--personal");
|
|
142
142
|
expect(deps.stdout.events()).toEqual([]);
|
|
143
143
|
});
|
|
144
144
|
it("rejects --companies + --company together", async () => {
|
|
@@ -200,15 +200,23 @@ describe("auth", () => {
|
|
|
200
200
|
]);
|
|
201
201
|
expect(deps.stdout.events()).toEqual([]);
|
|
202
202
|
});
|
|
203
|
-
it("emits error event on stderr and returns 1 on non-auth discovery failure", async () => {
|
|
203
|
+
it("emits error event on stderr and returns 1 on non-auth discovery failure (after 3 attempts)", async () => {
|
|
204
|
+
let calls = 0;
|
|
204
205
|
const deps = makeDeps({
|
|
205
206
|
createVaultClient: () => ({
|
|
206
207
|
...makeVaultStub(),
|
|
207
|
-
listMyMemberships: () =>
|
|
208
|
+
listMyMemberships: () => {
|
|
209
|
+
calls++;
|
|
210
|
+
return Promise.reject(new Error("network down"));
|
|
211
|
+
},
|
|
208
212
|
}),
|
|
209
213
|
});
|
|
210
214
|
const code = await runRunner(["--companies"], deps);
|
|
211
215
|
expect(code).toBe(1);
|
|
216
|
+
// Memberships retry burns all 3 attempts before surfacing the error —
|
|
217
|
+
// see listMembershipsWithRetry. Auth errors short-circuit (no retry);
|
|
218
|
+
// anything else (network, 5xx) retries twice more before giving up.
|
|
219
|
+
expect(calls).toBe(3);
|
|
212
220
|
// error events go to stderr, not stdout
|
|
213
221
|
const events = deps.stderr.events();
|
|
214
222
|
expect(events).toHaveLength(1);
|
|
@@ -221,6 +229,96 @@ describe("auth", () => {
|
|
|
221
229
|
});
|
|
222
230
|
});
|
|
223
231
|
// ---------------------------------------------------------------------------
|
|
232
|
+
// memberships retry (3x with backoff, then abort)
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
//
|
|
235
|
+
// `listMyMemberships()` is the single API call that drives every fanout
|
|
236
|
+
// target in --companies mode. A transient network blip on this one call
|
|
237
|
+
// shouldn't kill the whole sync — retry 3 times with backoff before
|
|
238
|
+
// surfacing the error. Auth failures short-circuit (no retries — the
|
|
239
|
+
// caller needs to re-vend, retries won't help).
|
|
240
|
+
//
|
|
241
|
+
// Single-company mode bypasses memberships entirely (the caller already
|
|
242
|
+
// told us which company), so no retry path is needed there.
|
|
243
|
+
describe("memberships retry", () => {
|
|
244
|
+
it("succeeds on 1st attempt with no retry", async () => {
|
|
245
|
+
let listCalls = 0;
|
|
246
|
+
const stub = makeVaultStub();
|
|
247
|
+
stub.listMyMemberships = () => {
|
|
248
|
+
listCalls++;
|
|
249
|
+
return Promise.resolve([{ companyUid: "cmp_acme" }]);
|
|
250
|
+
};
|
|
251
|
+
const deps = makeDeps({ createVaultClient: () => stub });
|
|
252
|
+
const code = await runRunner(["--companies"], deps);
|
|
253
|
+
expect(code).toBe(0);
|
|
254
|
+
expect(listCalls).toBe(1);
|
|
255
|
+
});
|
|
256
|
+
it("succeeds on 3rd attempt after 2 transient failures", async () => {
|
|
257
|
+
let listCalls = 0;
|
|
258
|
+
const stub = makeVaultStub();
|
|
259
|
+
stub.listMyMemberships = () => {
|
|
260
|
+
listCalls++;
|
|
261
|
+
if (listCalls < 3) {
|
|
262
|
+
return Promise.reject(new Error("ECONNRESET"));
|
|
263
|
+
}
|
|
264
|
+
return Promise.resolve([{ companyUid: "cmp_acme" }]);
|
|
265
|
+
};
|
|
266
|
+
const deps = makeDeps({ createVaultClient: () => stub });
|
|
267
|
+
const code = await runRunner(["--companies"], deps);
|
|
268
|
+
expect(code).toBe(0);
|
|
269
|
+
expect(listCalls).toBe(3);
|
|
270
|
+
// No error event should reach stderr — the retry succeeded.
|
|
271
|
+
expect(deps.stderr.events().some((e) => e.type === "error")).toBe(false);
|
|
272
|
+
});
|
|
273
|
+
it("exhausts 3 retries on persistent failure, then emits error event + exit 1", async () => {
|
|
274
|
+
let listCalls = 0;
|
|
275
|
+
const stub = makeVaultStub();
|
|
276
|
+
stub.listMyMemberships = () => {
|
|
277
|
+
listCalls++;
|
|
278
|
+
return Promise.reject(new Error("network down"));
|
|
279
|
+
};
|
|
280
|
+
const deps = makeDeps({ createVaultClient: () => stub });
|
|
281
|
+
const code = await runRunner(["--companies"], deps);
|
|
282
|
+
expect(code).toBe(1);
|
|
283
|
+
expect(listCalls).toBe(3);
|
|
284
|
+
const events = deps.stderr.events();
|
|
285
|
+
expect(events).toHaveLength(1);
|
|
286
|
+
expect(events[0]).toMatchObject({
|
|
287
|
+
type: "error",
|
|
288
|
+
message: expect.stringContaining("network down"),
|
|
289
|
+
path: "(discovery)",
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
it("VaultAuthError short-circuits retry (no retries on auth failure)", async () => {
|
|
293
|
+
let listCalls = 0;
|
|
294
|
+
const stub = makeVaultStub();
|
|
295
|
+
stub.listMyMemberships = () => {
|
|
296
|
+
listCalls++;
|
|
297
|
+
return Promise.reject(new VaultAuthError("token expired"));
|
|
298
|
+
};
|
|
299
|
+
const deps = makeDeps({ createVaultClient: () => stub });
|
|
300
|
+
const code = await runRunner(["--companies"], deps);
|
|
301
|
+
expect(code).toBe(0);
|
|
302
|
+
// Auth failures don't retry — re-vending creds is the caller's job.
|
|
303
|
+
expect(listCalls).toBe(1);
|
|
304
|
+
expect(deps.stderr.events()).toEqual([
|
|
305
|
+
{ type: "auth-error", message: "token expired" },
|
|
306
|
+
]);
|
|
307
|
+
});
|
|
308
|
+
it("single-company mode bypasses listMyMemberships entirely (no retry path triggered)", async () => {
|
|
309
|
+
let listCalls = 0;
|
|
310
|
+
const stub = makeVaultStub();
|
|
311
|
+
stub.listMyMemberships = () => {
|
|
312
|
+
listCalls++;
|
|
313
|
+
return Promise.reject(new Error("should not be called"));
|
|
314
|
+
};
|
|
315
|
+
const deps = makeDeps({ createVaultClient: () => stub });
|
|
316
|
+
const code = await runRunner(["--company", "cmp_explicit"], deps);
|
|
317
|
+
expect(code).toBe(0);
|
|
318
|
+
expect(listCalls).toBe(0);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
224
322
|
// claim-dance (first sign-in)
|
|
225
323
|
// ---------------------------------------------------------------------------
|
|
226
324
|
describe("claim-dance", () => {
|
|
@@ -380,6 +478,198 @@ describe("target resolution", () => {
|
|
|
380
478
|
});
|
|
381
479
|
});
|
|
382
480
|
// ---------------------------------------------------------------------------
|
|
481
|
+
// --personal mode (personal-vault-only, skip company fanout)
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
//
|
|
484
|
+
// `--personal` is mutually exclusive with `--companies` and `--company`. It
|
|
485
|
+
// runs ONLY the personal-vault target — no listMyMemberships call, no
|
|
486
|
+
// claim-dance, no cloud-company fanout. Designed as the replacement
|
|
487
|
+
// pathway for Rust's `personal.rs::run_personal_first_push` (the menubar's
|
|
488
|
+
// first-push), so the entire personal-vault walker lives in one place
|
|
489
|
+
// (hq-cloud TS) and not in two duplicate engines.
|
|
490
|
+
describe("--personal mode", () => {
|
|
491
|
+
it("argv: --personal + --companies is rejected", async () => {
|
|
492
|
+
const deps = makeDeps();
|
|
493
|
+
const code = await runRunner(["--personal", "--companies"], deps);
|
|
494
|
+
expect(code).toBe(1);
|
|
495
|
+
expect(deps.stderr.raw()).toContain("mutually exclusive");
|
|
496
|
+
});
|
|
497
|
+
it("argv: --personal + --company X is rejected", async () => {
|
|
498
|
+
const deps = makeDeps();
|
|
499
|
+
const code = await runRunner(["--personal", "--company", "cmp_x"], deps);
|
|
500
|
+
expect(code).toBe(1);
|
|
501
|
+
expect(deps.stderr.raw()).toContain("mutually exclusive");
|
|
502
|
+
});
|
|
503
|
+
it("argv: --personal + --skip-personal is rejected (contradictory)", async () => {
|
|
504
|
+
const deps = makeDeps();
|
|
505
|
+
const code = await runRunner(["--personal", "--skip-personal"], deps);
|
|
506
|
+
expect(code).toBe(1);
|
|
507
|
+
expect(deps.stderr.raw()).toContain("contradictory");
|
|
508
|
+
});
|
|
509
|
+
it("does NOT call listMyMemberships (skips company-discovery API entirely)", async () => {
|
|
510
|
+
const listSpy = vi.fn();
|
|
511
|
+
const deps = makeDeps({
|
|
512
|
+
createVaultClient: () => ({
|
|
513
|
+
...makeVaultStub({
|
|
514
|
+
listPersons: () => Promise.resolve([
|
|
515
|
+
{
|
|
516
|
+
uid: "ent_person_1",
|
|
517
|
+
type: "person",
|
|
518
|
+
bucketName: "hq-vault-personal-1",
|
|
519
|
+
status: "active",
|
|
520
|
+
},
|
|
521
|
+
]),
|
|
522
|
+
}),
|
|
523
|
+
listMyMemberships: listSpy,
|
|
524
|
+
}),
|
|
525
|
+
});
|
|
526
|
+
const code = await runRunner(["--personal"], deps);
|
|
527
|
+
expect(code).toBe(0);
|
|
528
|
+
expect(listSpy).not.toHaveBeenCalled();
|
|
529
|
+
});
|
|
530
|
+
it("does NOT run claim-dance (skips pending-invites + ensurePerson)", async () => {
|
|
531
|
+
const claimSpy = vi.fn();
|
|
532
|
+
const ensureSpy = vi.fn();
|
|
533
|
+
const deps = makeDeps({
|
|
534
|
+
createVaultClient: () => ({
|
|
535
|
+
...makeVaultStub({
|
|
536
|
+
listPersons: () => Promise.resolve([
|
|
537
|
+
{
|
|
538
|
+
uid: "ent_person_1",
|
|
539
|
+
type: "person",
|
|
540
|
+
bucketName: "hq-vault-personal-1",
|
|
541
|
+
status: "active",
|
|
542
|
+
},
|
|
543
|
+
]),
|
|
544
|
+
}),
|
|
545
|
+
claimPendingInvitesByEmail: claimSpy,
|
|
546
|
+
ensureMyPersonEntity: ensureSpy,
|
|
547
|
+
}),
|
|
548
|
+
getIdTokenClaims: () => ({
|
|
549
|
+
sub: "sub-abc",
|
|
550
|
+
email: "user@example.com",
|
|
551
|
+
name: "User Name",
|
|
552
|
+
}),
|
|
553
|
+
});
|
|
554
|
+
const code = await runRunner(["--personal"], deps);
|
|
555
|
+
expect(code).toBe(0);
|
|
556
|
+
expect(claimSpy).not.toHaveBeenCalled();
|
|
557
|
+
expect(ensureSpy).not.toHaveBeenCalled();
|
|
558
|
+
});
|
|
559
|
+
it("emits fanout-plan with exactly one personal entry", async () => {
|
|
560
|
+
const deps = makeDeps({
|
|
561
|
+
createVaultClient: () => makeVaultStub({
|
|
562
|
+
listPersons: () => Promise.resolve([
|
|
563
|
+
{
|
|
564
|
+
uid: "ent_person_1",
|
|
565
|
+
type: "person",
|
|
566
|
+
bucketName: "hq-vault-personal-1",
|
|
567
|
+
status: "active",
|
|
568
|
+
},
|
|
569
|
+
]),
|
|
570
|
+
}),
|
|
571
|
+
});
|
|
572
|
+
const code = await runRunner(["--personal"], deps);
|
|
573
|
+
expect(code).toBe(0);
|
|
574
|
+
const plan = deps.stdout
|
|
575
|
+
.events()
|
|
576
|
+
.find((e) => e.type === "fanout-plan");
|
|
577
|
+
expect(plan).toBeDefined();
|
|
578
|
+
expect(plan.companies).toHaveLength(1);
|
|
579
|
+
expect(plan.companies[0]).toMatchObject({ slug: "personal" });
|
|
580
|
+
});
|
|
581
|
+
it("calls syncFn ONCE with personalMode=true (default direction=pull)", async () => {
|
|
582
|
+
const deps = makeDeps({
|
|
583
|
+
createVaultClient: () => makeVaultStub({
|
|
584
|
+
listPersons: () => Promise.resolve([
|
|
585
|
+
{
|
|
586
|
+
uid: "ent_person_1",
|
|
587
|
+
type: "person",
|
|
588
|
+
bucketName: "hq-vault-personal-1",
|
|
589
|
+
status: "active",
|
|
590
|
+
},
|
|
591
|
+
]),
|
|
592
|
+
}),
|
|
593
|
+
});
|
|
594
|
+
const code = await runRunner(["--personal"], deps);
|
|
595
|
+
expect(code).toBe(0);
|
|
596
|
+
expect(deps.sync).toHaveBeenCalledTimes(1);
|
|
597
|
+
const call = deps.sync.mock
|
|
598
|
+
.calls[0][0];
|
|
599
|
+
expect(call.personalMode).toBe(true);
|
|
600
|
+
});
|
|
601
|
+
it("--personal --direction push calls shareFn with personalMode + computePersonalVaultPaths", async () => {
|
|
602
|
+
const shareSpy = vi.fn().mockResolvedValue({
|
|
603
|
+
filesUploaded: 0,
|
|
604
|
+
bytesUploaded: 0,
|
|
605
|
+
filesSkipped: 0,
|
|
606
|
+
filesDeleted: 0,
|
|
607
|
+
conflictPaths: [],
|
|
608
|
+
aborted: false,
|
|
609
|
+
});
|
|
610
|
+
const deps = makeDeps({
|
|
611
|
+
share: shareSpy,
|
|
612
|
+
createVaultClient: () => makeVaultStub({
|
|
613
|
+
listPersons: () => Promise.resolve([
|
|
614
|
+
{
|
|
615
|
+
uid: "ent_person_1",
|
|
616
|
+
type: "person",
|
|
617
|
+
bucketName: "hq-vault-personal-1",
|
|
618
|
+
status: "active",
|
|
619
|
+
},
|
|
620
|
+
]),
|
|
621
|
+
}),
|
|
622
|
+
});
|
|
623
|
+
const code = await runRunner(["--personal", "--direction", "push"], deps);
|
|
624
|
+
expect(code).toBe(0);
|
|
625
|
+
expect(shareSpy).toHaveBeenCalledTimes(1);
|
|
626
|
+
const opts = shareSpy.mock.calls[0][0];
|
|
627
|
+
expect(opts.personalMode).toBe(true);
|
|
628
|
+
// paths come from computePersonalVaultPaths — non-empty array of
|
|
629
|
+
// absolute hqRoot-anchored paths. Don't pin the exact contents
|
|
630
|
+
// (depends on hqRoot's local layout); just confirm the shape.
|
|
631
|
+
expect(Array.isArray(opts.paths)).toBe(true);
|
|
632
|
+
});
|
|
633
|
+
it("no person entity → emits setup-needed event, exit 0 (same shape as --companies)", async () => {
|
|
634
|
+
const deps = makeDeps({
|
|
635
|
+
createVaultClient: () => makeVaultStub({
|
|
636
|
+
listPersons: () => Promise.resolve([]),
|
|
637
|
+
}),
|
|
638
|
+
});
|
|
639
|
+
const code = await runRunner(["--personal"], deps);
|
|
640
|
+
expect(code).toBe(0);
|
|
641
|
+
expect(deps.stdout.events().some((e) => e.type === "setup-needed")).toBe(true);
|
|
642
|
+
expect(deps.sync).not.toHaveBeenCalled();
|
|
643
|
+
});
|
|
644
|
+
it("--personal honors --direction both (calls both shareFn and syncFn)", async () => {
|
|
645
|
+
const shareSpy = vi.fn().mockResolvedValue({
|
|
646
|
+
filesUploaded: 0,
|
|
647
|
+
bytesUploaded: 0,
|
|
648
|
+
filesSkipped: 0,
|
|
649
|
+
filesDeleted: 0,
|
|
650
|
+
conflictPaths: [],
|
|
651
|
+
aborted: false,
|
|
652
|
+
});
|
|
653
|
+
const deps = makeDeps({
|
|
654
|
+
share: shareSpy,
|
|
655
|
+
createVaultClient: () => makeVaultStub({
|
|
656
|
+
listPersons: () => Promise.resolve([
|
|
657
|
+
{
|
|
658
|
+
uid: "ent_person_1",
|
|
659
|
+
type: "person",
|
|
660
|
+
bucketName: "hq-vault-personal-1",
|
|
661
|
+
status: "active",
|
|
662
|
+
},
|
|
663
|
+
]),
|
|
664
|
+
}),
|
|
665
|
+
});
|
|
666
|
+
const code = await runRunner(["--personal", "--direction", "both"], deps);
|
|
667
|
+
expect(code).toBe(0);
|
|
668
|
+
expect(shareSpy).toHaveBeenCalledTimes(1);
|
|
669
|
+
expect(deps.sync).toHaveBeenCalledTimes(1);
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
// ---------------------------------------------------------------------------
|
|
383
673
|
// fanout-plan
|
|
384
674
|
// ---------------------------------------------------------------------------
|
|
385
675
|
describe("fanout-plan", () => {
|