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