@indigoai-us/hq-cloud 5.1.0 → 5.1.9

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.
Files changed (100) hide show
  1. package/dist/bin/sync-runner.d.ts +134 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -0
  3. package/dist/bin/sync-runner.js +360 -0
  4. package/dist/bin/sync-runner.js.map +1 -0
  5. package/dist/bin/sync-runner.test.d.ts +10 -0
  6. package/dist/bin/sync-runner.test.d.ts.map +1 -0
  7. package/dist/bin/sync-runner.test.js +648 -0
  8. package/dist/bin/sync-runner.test.js.map +1 -0
  9. package/dist/cli/index.d.ts +1 -1
  10. package/dist/cli/index.d.ts.map +1 -1
  11. package/dist/cli/share.js +2 -2
  12. package/dist/cli/share.js.map +1 -1
  13. package/dist/cli/share.test.js +9 -1
  14. package/dist/cli/share.test.js.map +1 -1
  15. package/dist/cli/sync.d.ts +28 -0
  16. package/dist/cli/sync.d.ts.map +1 -1
  17. package/dist/cli/sync.js +33 -10
  18. package/dist/cli/sync.js.map +1 -1
  19. package/dist/cli/sync.test.js +15 -4
  20. package/dist/cli/sync.test.js.map +1 -1
  21. package/dist/cognito-auth.d.ts.map +1 -1
  22. package/dist/cognito-auth.js +19 -1
  23. package/dist/cognito-auth.js.map +1 -1
  24. package/dist/cognito-auth.test.d.ts +9 -0
  25. package/dist/cognito-auth.test.d.ts.map +1 -0
  26. package/dist/cognito-auth.test.js +113 -0
  27. package/dist/cognito-auth.test.js.map +1 -0
  28. package/dist/context.d.ts.map +1 -1
  29. package/dist/context.js +1 -0
  30. package/dist/context.js.map +1 -1
  31. package/dist/daemon-worker.d.ts +6 -1
  32. package/dist/daemon-worker.d.ts.map +1 -1
  33. package/dist/daemon-worker.js +12 -16
  34. package/dist/daemon-worker.js.map +1 -1
  35. package/dist/daemon.d.ts +2 -0
  36. package/dist/daemon.d.ts.map +1 -1
  37. package/dist/daemon.js +2 -0
  38. package/dist/daemon.js.map +1 -1
  39. package/dist/ignore.d.ts +13 -2
  40. package/dist/ignore.d.ts.map +1 -1
  41. package/dist/ignore.js +69 -12
  42. package/dist/ignore.js.map +1 -1
  43. package/dist/index.d.ts +24 -28
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +19 -134
  46. package/dist/index.js.map +1 -1
  47. package/dist/journal.d.ts +20 -4
  48. package/dist/journal.d.ts.map +1 -1
  49. package/dist/journal.js +45 -8
  50. package/dist/journal.js.map +1 -1
  51. package/dist/journal.test.d.ts +9 -0
  52. package/dist/journal.test.d.ts.map +1 -0
  53. package/dist/journal.test.js +114 -0
  54. package/dist/journal.test.js.map +1 -0
  55. package/dist/s3.d.ts +18 -6
  56. package/dist/s3.d.ts.map +1 -1
  57. package/dist/s3.js +57 -56
  58. package/dist/s3.js.map +1 -1
  59. package/dist/types.d.ts +34 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/dist/vault-client.d.ts +59 -0
  62. package/dist/vault-client.d.ts.map +1 -1
  63. package/dist/vault-client.js +72 -0
  64. package/dist/vault-client.js.map +1 -1
  65. package/dist/vault-client.test.js +160 -0
  66. package/dist/vault-client.test.js.map +1 -1
  67. package/dist/watcher.d.ts +7 -1
  68. package/dist/watcher.d.ts.map +1 -1
  69. package/dist/watcher.js +11 -5
  70. package/dist/watcher.js.map +1 -1
  71. package/package.json +15 -3
  72. package/src/bin/sync-runner.test.ts +804 -0
  73. package/src/bin/sync-runner.ts +499 -0
  74. package/src/cli/accept.ts +97 -0
  75. package/src/cli/conflict.ts +119 -0
  76. package/src/cli/index.ts +25 -0
  77. package/src/cli/invite.test.ts +247 -0
  78. package/src/cli/invite.ts +180 -0
  79. package/src/cli/promote.ts +123 -0
  80. package/src/cli/share.test.ts +155 -0
  81. package/src/cli/share.ts +212 -0
  82. package/src/cli/sync.test.ts +225 -0
  83. package/src/cli/sync.ts +225 -0
  84. package/src/cognito-auth.test.ts +156 -0
  85. package/src/cognito-auth.ts +18 -1
  86. package/src/context.test.ts +202 -0
  87. package/src/context.ts +178 -0
  88. package/src/daemon-worker.ts +13 -19
  89. package/src/daemon.ts +2 -0
  90. package/src/ignore.ts +76 -12
  91. package/src/index.ts +94 -165
  92. package/src/journal.test.ts +146 -0
  93. package/src/journal.ts +53 -11
  94. package/src/s3.ts +76 -66
  95. package/src/types.ts +37 -0
  96. package/src/vault-client.test.ts +563 -0
  97. package/src/vault-client.ts +478 -0
  98. package/src/watcher.ts +12 -5
  99. package/test/invite-flow.integration.test.ts +244 -0
  100. package/test/share-sync.integration.test.ts +210 -0
@@ -0,0 +1,804 @@
1
+ /**
2
+ * Unit tests for hq-sync-runner (ADR-0001).
3
+ *
4
+ * The runner is designed around `RunnerDeps` — every side effect is
5
+ * injectable, so tests assert on captured ndjson output rather than mocking
6
+ * modules. That keeps each test honest about what the runner does vs what
7
+ * its collaborators do.
8
+ */
9
+
10
+ import { describe, it, expect, vi, beforeEach } from "vitest";
11
+ import { runRunner } from "./sync-runner.js";
12
+ import type {
13
+ RunnerEvent,
14
+ RunnerDeps,
15
+ VaultClientSurface,
16
+ } from "./sync-runner.js";
17
+ import type { SyncResult, SyncOptions } from "../cli/sync.js";
18
+ import type {
19
+ Membership,
20
+ EntityInfo,
21
+ PendingInviteByEmail,
22
+ } from "../vault-client.js";
23
+ import { VaultAuthError } from "../vault-client.js";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Capturing writer — collects writes so we can assert on the ndjson stream
27
+ // ---------------------------------------------------------------------------
28
+
29
+ interface CapturingWriter {
30
+ write: (chunk: string) => boolean;
31
+ lines: () => string[];
32
+ events: () => RunnerEvent[];
33
+ raw: () => string;
34
+ }
35
+
36
+ function makeWriter(): CapturingWriter {
37
+ let buf = "";
38
+ return {
39
+ write: (chunk: string) => {
40
+ buf += chunk;
41
+ return true;
42
+ },
43
+ lines: () => buf.split("\n").filter((l) => l.length > 0),
44
+ events: () =>
45
+ buf
46
+ .split("\n")
47
+ .filter((l) => l.length > 0)
48
+ .map((l) => JSON.parse(l) as RunnerEvent),
49
+ raw: () => buf,
50
+ };
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Default stub factory — tests override individual fields
55
+ // ---------------------------------------------------------------------------
56
+
57
+ function defaultSyncResult(overrides: Partial<SyncResult> = {}): SyncResult {
58
+ return {
59
+ filesDownloaded: 0,
60
+ bytesDownloaded: 0,
61
+ filesSkipped: 0,
62
+ conflicts: 0,
63
+ aborted: false,
64
+ ...overrides,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Produce a minimal VaultClientSurface stub. Tests pass in the memberships
70
+ * they want `listMyMemberships` to return, plus the sequence of `entity.get`
71
+ * resolutions. Defaults cover the "no memberships" path.
72
+ */
73
+ function makeVaultStub(
74
+ opts: {
75
+ memberships?: Array<Pick<Membership, "companyUid">>;
76
+ entityGet?: (uid: string) => Promise<EntityInfo>;
77
+ pendingInvites?: Array<Record<string, unknown>>;
78
+ ensurePerson?: (hints: {
79
+ ownerSub: string;
80
+ displayName: string;
81
+ }) => Promise<EntityInfo>;
82
+ claim?: (personUid: string) => Promise<void>;
83
+ } = {},
84
+ ): VaultClientSurface {
85
+ const memberships = opts.memberships ?? [];
86
+ const pending = opts.pendingInvites ?? [];
87
+ return {
88
+ listMyMemberships: () => Promise.resolve(memberships as Membership[]),
89
+ listMyPendingInvitesByEmail: () =>
90
+ Promise.resolve(pending as unknown as PendingInviteByEmail[]),
91
+ claimPendingInvitesByEmail:
92
+ opts.claim ?? (() => Promise.resolve(undefined)),
93
+ ensureMyPersonEntity:
94
+ opts.ensurePerson ??
95
+ (() =>
96
+ Promise.resolve({
97
+ uid: "ent_person_default",
98
+ type: "person",
99
+ slug: "default-person",
100
+ status: "active",
101
+ } as unknown as EntityInfo)),
102
+ entity: {
103
+ get:
104
+ opts.entityGet ??
105
+ ((uid: string) =>
106
+ Promise.resolve({
107
+ uid,
108
+ type: "company",
109
+ slug: uid,
110
+ bucketName: `bucket-${uid}`,
111
+ status: "active",
112
+ } as unknown as EntityInfo)),
113
+ },
114
+ };
115
+ }
116
+
117
+ interface TestDeps extends RunnerDeps {
118
+ stdout: CapturingWriter;
119
+ stderr: CapturingWriter;
120
+ }
121
+
122
+ function makeDeps(overrides: Partial<RunnerDeps> = {}): TestDeps {
123
+ const stdout = makeWriter();
124
+ const stderr = makeWriter();
125
+ // Spread overrides first so our CapturingWriter stdout/stderr always
126
+ // survive in the returned shape. Tests cannot override those — capturing
127
+ // is the whole point of the helper. vi.fn() wraps defaults so tests can
128
+ // still call .toHaveBeenCalled() / .toHaveBeenCalledTimes() on the returned
129
+ // deps without each override re-wrapping.
130
+ return {
131
+ getAccessToken: vi.fn().mockResolvedValue("test-access-token"),
132
+ createVaultClient: vi.fn().mockImplementation(() => makeVaultStub()),
133
+ sync: vi.fn().mockResolvedValue(defaultSyncResult()),
134
+ ...overrides,
135
+ stdout,
136
+ stderr,
137
+ };
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // argv parsing
142
+ // ---------------------------------------------------------------------------
143
+
144
+ describe("argv parsing", () => {
145
+ it("rejects missing mode with exit 1", async () => {
146
+ const deps = makeDeps();
147
+ const code = await runRunner([], deps);
148
+ expect(code).toBe(1);
149
+ expect(deps.stderr.raw()).toContain("--companies or --company");
150
+ expect(deps.stdout.events()).toEqual([]);
151
+ });
152
+
153
+ it("rejects --companies + --company together", async () => {
154
+ const deps = makeDeps();
155
+ const code = await runRunner(["--companies", "--company", "acme"], deps);
156
+ expect(code).toBe(1);
157
+ expect(deps.stderr.raw()).toContain("not both");
158
+ expect(deps.stdout.events()).toEqual([]);
159
+ });
160
+
161
+ it("rejects unknown flags", async () => {
162
+ const deps = makeDeps();
163
+ const code = await runRunner(["--companies", "--wat"], deps);
164
+ expect(code).toBe(1);
165
+ expect(deps.stderr.raw()).toContain("Unknown argument: --wat");
166
+ });
167
+
168
+ it("rejects invalid --on-conflict value", async () => {
169
+ const deps = makeDeps();
170
+ const code = await runRunner(
171
+ ["--companies", "--on-conflict", "nuke"],
172
+ deps,
173
+ );
174
+ expect(code).toBe(1);
175
+ expect(deps.stderr.raw()).toContain("abort|overwrite|keep");
176
+ });
177
+
178
+ it("accepts --json as a silent no-op (ndjson is the only mode)", async () => {
179
+ const deps = makeDeps({
180
+ createVaultClient: () => makeVaultStub({ memberships: [] }),
181
+ });
182
+ const code = await runRunner(["--companies", "--json"], deps);
183
+ expect(code).toBe(0);
184
+ // Empty memberships → setup-needed, not a parse error
185
+ expect(deps.stdout.events()).toEqual([{ type: "setup-needed" }]);
186
+ });
187
+ });
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // auth
191
+ // ---------------------------------------------------------------------------
192
+
193
+ describe("auth", () => {
194
+ it("emits auth-error and returns 0 when token fetch fails", async () => {
195
+ const deps = makeDeps({
196
+ getAccessToken: vi.fn().mockRejectedValue(new Error("no cached tokens")),
197
+ });
198
+ const code = await runRunner(["--companies"], deps);
199
+ expect(code).toBe(0);
200
+ expect(deps.stdout.events()).toEqual([
201
+ { type: "auth-error", message: "no cached tokens" },
202
+ ]);
203
+ });
204
+
205
+ it("emits auth-error when VaultAuthError thrown during discovery", async () => {
206
+ const deps = makeDeps({
207
+ createVaultClient: () => ({
208
+ ...makeVaultStub(),
209
+ listMyMemberships: () =>
210
+ Promise.reject(new VaultAuthError("token expired")),
211
+ }),
212
+ });
213
+ const code = await runRunner(["--companies"], deps);
214
+ expect(code).toBe(0);
215
+ expect(deps.stdout.events()).toEqual([
216
+ { type: "auth-error", message: "token expired" },
217
+ ]);
218
+ });
219
+
220
+ it("emits error event and returns 1 on non-auth discovery failure", async () => {
221
+ const deps = makeDeps({
222
+ createVaultClient: () => ({
223
+ ...makeVaultStub(),
224
+ listMyMemberships: () => Promise.reject(new Error("network down")),
225
+ }),
226
+ });
227
+ const code = await runRunner(["--companies"], deps);
228
+ expect(code).toBe(1);
229
+ const events = deps.stdout.events();
230
+ expect(events).toHaveLength(1);
231
+ expect(events[0]).toMatchObject({
232
+ type: "error",
233
+ message: "network down",
234
+ path: "(discovery)",
235
+ });
236
+ });
237
+ });
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // claim-dance (first sign-in)
241
+ // ---------------------------------------------------------------------------
242
+
243
+ describe("claim-dance", () => {
244
+ const claims = {
245
+ sub: "sub-abc",
246
+ email: "stefan@getindigo.ai",
247
+ name: "Stefan Johnson",
248
+ };
249
+
250
+ it("claims pending invites + ensures person before listing memberships", async () => {
251
+ const ensureSpy = vi.fn().mockResolvedValue({
252
+ uid: "ent_person_stefan",
253
+ type: "person",
254
+ slug: "stefan-johnson",
255
+ status: "active",
256
+ });
257
+ const claimSpy = vi.fn().mockResolvedValue(undefined);
258
+ // First listMyMemberships returns the just-claimed row.
259
+ let listCalls = 0;
260
+ const stub = makeVaultStub({
261
+ pendingInvites: [
262
+ {
263
+ membershipKey: "inv_1",
264
+ companyUid: "cmp_indigo",
265
+ role: "owner",
266
+ invitedBy: "sub-admin",
267
+ invitedAt: "2026-04-20T00:00:00Z",
268
+ },
269
+ ],
270
+ ensurePerson: ensureSpy as unknown as VaultClientSurface["ensureMyPersonEntity"],
271
+ claim: claimSpy as unknown as VaultClientSurface["claimPendingInvitesByEmail"],
272
+ });
273
+ stub.listMyMemberships = () => {
274
+ listCalls++;
275
+ return Promise.resolve([{ companyUid: "cmp_indigo" }] as Membership[]);
276
+ };
277
+
278
+ const deps = makeDeps({
279
+ createVaultClient: () => stub,
280
+ getIdTokenClaims: () => claims,
281
+ });
282
+ const code = await runRunner(["--companies"], deps);
283
+ expect(code).toBe(0);
284
+ expect(ensureSpy).toHaveBeenCalledWith({
285
+ ownerSub: "sub-abc",
286
+ displayName: "Stefan Johnson",
287
+ });
288
+ expect(claimSpy).toHaveBeenCalledWith("ent_person_stefan");
289
+ expect(listCalls).toBe(1);
290
+ // setup-needed must NOT fire — the user has memberships after the claim.
291
+ expect(deps.stdout.events().some((e) => e.type === "setup-needed")).toBe(
292
+ false,
293
+ );
294
+ });
295
+
296
+ it("skips ensurePerson + claim when no pending invites exist", async () => {
297
+ const ensureSpy = vi.fn();
298
+ const claimSpy = vi.fn();
299
+ const deps = makeDeps({
300
+ createVaultClient: () =>
301
+ makeVaultStub({
302
+ pendingInvites: [],
303
+ ensurePerson:
304
+ ensureSpy as unknown as VaultClientSurface["ensureMyPersonEntity"],
305
+ claim: claimSpy as unknown as VaultClientSurface["claimPendingInvitesByEmail"],
306
+ }),
307
+ getIdTokenClaims: () => claims,
308
+ });
309
+ const code = await runRunner(["--companies"], deps);
310
+ expect(code).toBe(0);
311
+ expect(ensureSpy).not.toHaveBeenCalled();
312
+ expect(claimSpy).not.toHaveBeenCalled();
313
+ // No memberships, no invites — truly empty → setup-needed is correct here.
314
+ expect(deps.stdout.events()).toEqual([{ type: "setup-needed" }]);
315
+ });
316
+
317
+ it("skips claim-dance entirely when no idToken claims are available", async () => {
318
+ const pendingSpy = vi.fn().mockResolvedValue([]);
319
+ const stub = makeVaultStub();
320
+ stub.listMyPendingInvitesByEmail =
321
+ pendingSpy as unknown as VaultClientSurface["listMyPendingInvitesByEmail"];
322
+ const deps = makeDeps({
323
+ createVaultClient: () => stub,
324
+ getIdTokenClaims: () => null,
325
+ });
326
+ await runRunner(["--companies"], deps);
327
+ expect(pendingSpy).not.toHaveBeenCalled();
328
+ });
329
+
330
+ it("does not fail the run when claim-dance throws (best-effort)", async () => {
331
+ const stub = makeVaultStub({
332
+ memberships: [{ companyUid: "cmp_a" }],
333
+ });
334
+ stub.listMyPendingInvitesByEmail = () =>
335
+ Promise.reject(new Error("vault 500"));
336
+ const deps = makeDeps({
337
+ createVaultClient: () => stub,
338
+ getIdTokenClaims: () => claims,
339
+ });
340
+ const code = await runRunner(["--companies"], deps);
341
+ expect(code).toBe(0);
342
+ // Sync proceeds as usual for the existing membership.
343
+ expect(deps.sync).toHaveBeenCalledTimes(1);
344
+ expect(deps.stderr.raw()).toContain("claim-dance skipped");
345
+ });
346
+
347
+ it("falls back to given_name + family_name when name claim is absent", async () => {
348
+ const ensureSpy = vi.fn().mockResolvedValue({
349
+ uid: "ent_person_x",
350
+ type: "person",
351
+ slug: "x",
352
+ status: "active",
353
+ });
354
+ const deps = makeDeps({
355
+ createVaultClient: () =>
356
+ makeVaultStub({
357
+ pendingInvites: [
358
+ {
359
+ membershipKey: "inv_1",
360
+ companyUid: "cmp_x",
361
+ role: "owner",
362
+ invitedBy: "sub-admin",
363
+ invitedAt: "2026-04-20T00:00:00Z",
364
+ },
365
+ ],
366
+ ensurePerson:
367
+ ensureSpy as unknown as VaultClientSurface["ensureMyPersonEntity"],
368
+ }),
369
+ getIdTokenClaims: () => ({
370
+ sub: "sub-xyz",
371
+ given_name: "Ada",
372
+ family_name: "Lovelace",
373
+ }),
374
+ });
375
+ await runRunner(["--companies"], deps);
376
+ expect(ensureSpy).toHaveBeenCalledWith({
377
+ ownerSub: "sub-xyz",
378
+ displayName: "Ada Lovelace",
379
+ });
380
+ });
381
+ });
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // target resolution
385
+ // ---------------------------------------------------------------------------
386
+
387
+ describe("target resolution", () => {
388
+ it("emits setup-needed when --companies returns no memberships", async () => {
389
+ const deps = makeDeps();
390
+ const code = await runRunner(["--companies"], deps);
391
+ expect(code).toBe(0);
392
+ expect(deps.stdout.events()).toEqual([{ type: "setup-needed" }]);
393
+ // sync should NOT have been called — no targets
394
+ expect(deps.sync).not.toHaveBeenCalled();
395
+ });
396
+
397
+ it("single-company mode skips listMyMemberships and syncs the named UID", async () => {
398
+ const listSpy = vi.fn();
399
+ const deps = makeDeps({
400
+ createVaultClient: () => ({
401
+ ...makeVaultStub({
402
+ entityGet: (uid: string) =>
403
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
404
+ }),
405
+ listMyMemberships: listSpy as unknown as () => Promise<Membership[]>,
406
+ }),
407
+ });
408
+ const code = await runRunner(["--company", "cmp_abc"], deps);
409
+ expect(code).toBe(0);
410
+ expect(listSpy).not.toHaveBeenCalled();
411
+ expect(deps.sync).toHaveBeenCalledTimes(1);
412
+ const call = (deps.sync as ReturnType<typeof vi.fn>).mock.calls[0][0] as SyncOptions;
413
+ expect(call.company).toBe("cmp_abc");
414
+ });
415
+ });
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // fanout-plan
419
+ // ---------------------------------------------------------------------------
420
+
421
+ describe("fanout-plan", () => {
422
+ it("resolves slugs from entity.get before fanning out", async () => {
423
+ const slugByUid: Record<string, string> = {
424
+ cmp_a: "acme",
425
+ cmp_b: "beta",
426
+ };
427
+ const deps = makeDeps({
428
+ createVaultClient: () =>
429
+ makeVaultStub({
430
+ memberships: [{ companyUid: "cmp_a" }, { companyUid: "cmp_b" }],
431
+ entityGet: (uid: string) =>
432
+ Promise.resolve({
433
+ uid,
434
+ slug: slugByUid[uid] ?? uid,
435
+ } as unknown as EntityInfo),
436
+ }),
437
+ });
438
+
439
+ const code = await runRunner(["--companies"], deps);
440
+ expect(code).toBe(0);
441
+ const plan = deps.stdout
442
+ .events()
443
+ .find((e) => e.type === "fanout-plan") as Extract<RunnerEvent, { type: "fanout-plan" }>;
444
+ expect(plan).toBeDefined();
445
+ expect(plan.companies).toEqual([
446
+ { uid: "cmp_a", slug: "acme" },
447
+ { uid: "cmp_b", slug: "beta" },
448
+ ]);
449
+ });
450
+
451
+ it("degrades to UID when entity.get throws (best-effort slug resolution)", async () => {
452
+ const deps = makeDeps({
453
+ createVaultClient: () =>
454
+ makeVaultStub({
455
+ memberships: [{ companyUid: "cmp_ghost" }],
456
+ entityGet: () => Promise.reject(new Error("entity deleted")),
457
+ }),
458
+ });
459
+
460
+ const code = await runRunner(["--companies"], deps);
461
+ expect(code).toBe(0);
462
+ const plan = deps.stdout
463
+ .events()
464
+ .find((e) => e.type === "fanout-plan") as Extract<RunnerEvent, { type: "fanout-plan" }>;
465
+ expect(plan.companies).toEqual([{ uid: "cmp_ghost", slug: "cmp_ghost" }]);
466
+ });
467
+
468
+ it("includes entity.name on plan entries when available", async () => {
469
+ const deps = makeDeps({
470
+ createVaultClient: () =>
471
+ makeVaultStub({
472
+ memberships: [{ companyUid: "cmp_a" }, { companyUid: "cmp_b" }],
473
+ entityGet: (uid: string) =>
474
+ Promise.resolve({
475
+ uid,
476
+ slug: uid === "cmp_a" ? "acme" : "beta",
477
+ name: uid === "cmp_a" ? "Acme Corp" : undefined,
478
+ } as unknown as EntityInfo),
479
+ }),
480
+ });
481
+
482
+ const code = await runRunner(["--companies"], deps);
483
+ expect(code).toBe(0);
484
+ const plan = deps.stdout
485
+ .events()
486
+ .find((e) => e.type === "fanout-plan") as Extract<RunnerEvent, { type: "fanout-plan" }>;
487
+ expect(plan.companies).toEqual([
488
+ { uid: "cmp_a", slug: "acme", name: "Acme Corp" },
489
+ { uid: "cmp_b", slug: "beta" },
490
+ ]);
491
+ });
492
+
493
+ it("degrades to UID when entity.get returns falsy slug", async () => {
494
+ const deps = makeDeps({
495
+ createVaultClient: () =>
496
+ makeVaultStub({
497
+ memberships: [{ companyUid: "cmp_empty" }],
498
+ entityGet: (uid: string) =>
499
+ Promise.resolve({ uid, slug: "" } as unknown as EntityInfo),
500
+ }),
501
+ });
502
+
503
+ const code = await runRunner(["--companies"], deps);
504
+ expect(code).toBe(0);
505
+ const plan = deps.stdout
506
+ .events()
507
+ .find((e) => e.type === "fanout-plan") as Extract<RunnerEvent, { type: "fanout-plan" }>;
508
+ expect(plan.companies).toEqual([{ uid: "cmp_empty", slug: "cmp_empty" }]);
509
+ });
510
+ });
511
+
512
+ // ---------------------------------------------------------------------------
513
+ // per-company event tagging
514
+ // ---------------------------------------------------------------------------
515
+
516
+ describe("per-company fanout", () => {
517
+ it("tags per-file progress events with the company slug", async () => {
518
+ const deps = makeDeps({
519
+ createVaultClient: () =>
520
+ makeVaultStub({
521
+ memberships: [{ companyUid: "cmp_a" }],
522
+ entityGet: (uid: string) =>
523
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
524
+ }),
525
+ sync: vi.fn().mockImplementation(async (opts: SyncOptions) => {
526
+ opts.onEvent?.({ type: "progress", path: "notes.md", bytes: 42 });
527
+ opts.onEvent?.({
528
+ type: "progress",
529
+ path: "shared/doc.md",
530
+ bytes: 1024,
531
+ message: "draft update",
532
+ });
533
+ return defaultSyncResult({ filesDownloaded: 2, bytesDownloaded: 1066 });
534
+ }),
535
+ });
536
+
537
+ const code = await runRunner(["--companies"], deps);
538
+ expect(code).toBe(0);
539
+ const progressEvents = deps.stdout
540
+ .events()
541
+ .filter((e): e is Extract<RunnerEvent, { type: "progress" }> =>
542
+ e.type === "progress",
543
+ );
544
+ expect(progressEvents).toEqual([
545
+ { type: "progress", company: "acme", path: "notes.md", bytes: 42 },
546
+ {
547
+ type: "progress",
548
+ company: "acme",
549
+ path: "shared/doc.md",
550
+ bytes: 1024,
551
+ message: "draft update",
552
+ },
553
+ ]);
554
+ });
555
+
556
+ it("tags per-file error events with the company slug", async () => {
557
+ const deps = makeDeps({
558
+ createVaultClient: () =>
559
+ makeVaultStub({
560
+ memberships: [{ companyUid: "cmp_a" }],
561
+ entityGet: (uid: string) =>
562
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
563
+ }),
564
+ sync: vi.fn().mockImplementation(async (opts: SyncOptions) => {
565
+ opts.onEvent?.({
566
+ type: "error",
567
+ path: "locked.md",
568
+ message: "access denied",
569
+ });
570
+ return defaultSyncResult({ filesSkipped: 1 });
571
+ }),
572
+ });
573
+
574
+ const code = await runRunner(["--companies"], deps);
575
+ expect(code).toBe(0);
576
+ const errs = deps.stdout
577
+ .events()
578
+ .filter((e): e is Extract<RunnerEvent, { type: "error" }> =>
579
+ e.type === "error",
580
+ );
581
+ expect(errs).toEqual([
582
+ {
583
+ type: "error",
584
+ company: "acme",
585
+ path: "locked.md",
586
+ message: "access denied",
587
+ },
588
+ ]);
589
+ });
590
+
591
+ it("emits complete event per company with the SyncResult spread", async () => {
592
+ const result = defaultSyncResult({
593
+ filesDownloaded: 3,
594
+ bytesDownloaded: 999,
595
+ filesSkipped: 1,
596
+ conflicts: 0,
597
+ aborted: false,
598
+ });
599
+ const deps = makeDeps({
600
+ createVaultClient: () =>
601
+ makeVaultStub({
602
+ memberships: [{ companyUid: "cmp_a" }],
603
+ entityGet: (uid: string) =>
604
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
605
+ }),
606
+ sync: vi.fn().mockResolvedValue(result),
607
+ });
608
+
609
+ const code = await runRunner(["--companies"], deps);
610
+ expect(code).toBe(0);
611
+ const complete = deps.stdout
612
+ .events()
613
+ .find((e) => e.type === "complete") as Extract<RunnerEvent, { type: "complete" }>;
614
+ expect(complete).toEqual({
615
+ type: "complete",
616
+ company: "acme",
617
+ ...result,
618
+ });
619
+ });
620
+
621
+ it("passes --on-conflict and --hq-root through to sync()", async () => {
622
+ const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
623
+ const deps = makeDeps({
624
+ createVaultClient: () =>
625
+ makeVaultStub({
626
+ memberships: [{ companyUid: "cmp_a" }],
627
+ entityGet: (uid: string) =>
628
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
629
+ }),
630
+ sync: syncSpy,
631
+ });
632
+
633
+ const code = await runRunner(
634
+ [
635
+ "--companies",
636
+ "--on-conflict",
637
+ "overwrite",
638
+ "--hq-root",
639
+ "/tmp/fake-hq",
640
+ ],
641
+ deps,
642
+ );
643
+ expect(code).toBe(0);
644
+ expect(syncSpy).toHaveBeenCalledTimes(1);
645
+ const opts = syncSpy.mock.calls[0][0] as SyncOptions;
646
+ expect(opts.onConflict).toBe("overwrite");
647
+ expect(opts.hqRoot).toBe("/tmp/fake-hq");
648
+ });
649
+
650
+ it("continues the fanout when one company's sync throws", async () => {
651
+ const slugs: Record<string, string> = { cmp_a: "acme", cmp_b: "beta" };
652
+ const deps = makeDeps({
653
+ createVaultClient: () =>
654
+ makeVaultStub({
655
+ memberships: [{ companyUid: "cmp_a" }, { companyUid: "cmp_b" }],
656
+ entityGet: (uid: string) =>
657
+ Promise.resolve({ uid, slug: slugs[uid] ?? uid } as unknown as EntityInfo),
658
+ }),
659
+ sync: vi
660
+ .fn<(opts: SyncOptions) => Promise<SyncResult>>()
661
+ .mockImplementationOnce(async () => {
662
+ throw new Error("acme blew up");
663
+ })
664
+ .mockImplementationOnce(async () =>
665
+ defaultSyncResult({ filesDownloaded: 1, bytesDownloaded: 500 }),
666
+ ),
667
+ });
668
+
669
+ const code = await runRunner(["--companies"], deps);
670
+ expect(code).toBe(0); // whole fanout still returns 0
671
+
672
+ const events = deps.stdout.events();
673
+ // Error event for acme (company-level) with path sentinel "(company)"
674
+ const companyErr = events.find(
675
+ (e): e is Extract<RunnerEvent, { type: "error" }> =>
676
+ e.type === "error" && e.company === "acme",
677
+ );
678
+ expect(companyErr).toMatchObject({
679
+ type: "error",
680
+ company: "acme",
681
+ path: "(company)",
682
+ message: "acme blew up",
683
+ });
684
+ // But beta still completed
685
+ const betaComplete = events.find(
686
+ (e): e is Extract<RunnerEvent, { type: "complete" }> =>
687
+ e.type === "complete" && e.company === "beta",
688
+ );
689
+ expect(betaComplete).toBeDefined();
690
+ expect(betaComplete?.filesDownloaded).toBe(1);
691
+ });
692
+ });
693
+
694
+ // ---------------------------------------------------------------------------
695
+ // all-complete aggregate
696
+ // ---------------------------------------------------------------------------
697
+
698
+ describe("all-complete aggregate", () => {
699
+ it("sums filesDownloaded and bytesDownloaded across all companies", async () => {
700
+ const slugs: Record<string, string> = { cmp_a: "acme", cmp_b: "beta" };
701
+ const deps = makeDeps({
702
+ createVaultClient: () =>
703
+ makeVaultStub({
704
+ memberships: [{ companyUid: "cmp_a" }, { companyUid: "cmp_b" }],
705
+ entityGet: (uid: string) =>
706
+ Promise.resolve({ uid, slug: slugs[uid] ?? uid } as unknown as EntityInfo),
707
+ }),
708
+ sync: vi
709
+ .fn<(opts: SyncOptions) => Promise<SyncResult>>()
710
+ .mockResolvedValueOnce(
711
+ defaultSyncResult({ filesDownloaded: 3, bytesDownloaded: 100 }),
712
+ )
713
+ .mockResolvedValueOnce(
714
+ defaultSyncResult({ filesDownloaded: 4, bytesDownloaded: 250 }),
715
+ ),
716
+ });
717
+
718
+ const code = await runRunner(["--companies"], deps);
719
+ expect(code).toBe(0);
720
+ const all = deps.stdout
721
+ .events()
722
+ .find((e) => e.type === "all-complete") as Extract<RunnerEvent, { type: "all-complete" }>;
723
+ expect(all).toEqual({
724
+ type: "all-complete",
725
+ companiesAttempted: 2,
726
+ filesDownloaded: 7,
727
+ bytesDownloaded: 350,
728
+ errors: [],
729
+ });
730
+ });
731
+
732
+ it("collects company-level errors into the all-complete errors array", async () => {
733
+ const slugs: Record<string, string> = { cmp_a: "acme", cmp_b: "beta" };
734
+ const deps = makeDeps({
735
+ createVaultClient: () =>
736
+ makeVaultStub({
737
+ memberships: [{ companyUid: "cmp_a" }, { companyUid: "cmp_b" }],
738
+ entityGet: (uid: string) =>
739
+ Promise.resolve({ uid, slug: slugs[uid] ?? uid } as unknown as EntityInfo),
740
+ }),
741
+ sync: vi
742
+ .fn<(opts: SyncOptions) => Promise<SyncResult>>()
743
+ .mockRejectedValueOnce(new Error("acme failed"))
744
+ .mockResolvedValueOnce(defaultSyncResult()),
745
+ });
746
+
747
+ const code = await runRunner(["--companies"], deps);
748
+ expect(code).toBe(0);
749
+ const all = deps.stdout
750
+ .events()
751
+ .find((e) => e.type === "all-complete") as Extract<RunnerEvent, { type: "all-complete" }>;
752
+ expect(all.companiesAttempted).toBe(2);
753
+ expect(all.errors).toEqual([
754
+ { company: "acme", message: "acme failed" },
755
+ ]);
756
+ });
757
+ });
758
+
759
+ // ---------------------------------------------------------------------------
760
+ // ndjson stream shape (belt-and-suspenders)
761
+ // ---------------------------------------------------------------------------
762
+
763
+ describe("ndjson stream shape", () => {
764
+ it("emits one JSON object per line, terminated by newline", async () => {
765
+ const deps = makeDeps({
766
+ createVaultClient: () =>
767
+ makeVaultStub({
768
+ memberships: [{ companyUid: "cmp_a" }],
769
+ entityGet: (uid: string) =>
770
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
771
+ }),
772
+ sync: vi.fn().mockImplementation(async (opts: SyncOptions) => {
773
+ opts.onEvent?.({ type: "progress", path: "x.md", bytes: 1 });
774
+ return defaultSyncResult({ filesDownloaded: 1, bytesDownloaded: 1 });
775
+ }),
776
+ });
777
+
778
+ await runRunner(["--companies"], deps);
779
+ const raw = deps.stdout.raw();
780
+ expect(raw.endsWith("\n")).toBe(true);
781
+ // Every line must parse as JSON
782
+ const lines = raw.split("\n").filter((l) => l.length > 0);
783
+ for (const line of lines) {
784
+ expect(() => JSON.parse(line)).not.toThrow();
785
+ }
786
+ // Expected shape: fanout-plan, progress, complete, all-complete
787
+ expect(lines).toHaveLength(4);
788
+ const types = lines.map((l) => (JSON.parse(l) as RunnerEvent).type);
789
+ expect(types).toEqual([
790
+ "fanout-plan",
791
+ "progress",
792
+ "complete",
793
+ "all-complete",
794
+ ]);
795
+ });
796
+ });
797
+
798
+ // ---------------------------------------------------------------------------
799
+ // Re-initialize for each test (mock state hygiene)
800
+ // ---------------------------------------------------------------------------
801
+
802
+ beforeEach(() => {
803
+ vi.clearAllMocks();
804
+ });