@indigoai-us/hq-cloud 5.1.9 → 5.1.11

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 (48) hide show
  1. package/dist/auth.js +2 -2
  2. package/dist/auth.js.map +1 -1
  3. package/dist/bin/sync-runner.d.ts +28 -0
  4. package/dist/bin/sync-runner.d.ts.map +1 -1
  5. package/dist/bin/sync-runner.js +101 -34
  6. package/dist/bin/sync-runner.js.map +1 -1
  7. package/dist/bin/sync-runner.test.js +194 -1
  8. package/dist/bin/sync-runner.test.js.map +1 -1
  9. package/dist/cli/accept.js +2 -2
  10. package/dist/cli/accept.js.map +1 -1
  11. package/dist/cli/share.d.ts +18 -0
  12. package/dist/cli/share.d.ts.map +1 -1
  13. package/dist/cli/share.js +71 -14
  14. package/dist/cli/share.js.map +1 -1
  15. package/dist/cli/share.test.js +167 -13
  16. package/dist/cli/share.test.js.map +1 -1
  17. package/dist/cli/sync.d.ts.map +1 -1
  18. package/dist/cli/sync.js +6 -1
  19. package/dist/cli/sync.js.map +1 -1
  20. package/dist/cli/sync.test.js +31 -12
  21. package/dist/cli/sync.test.js.map +1 -1
  22. package/dist/cognito-auth.d.ts +13 -2
  23. package/dist/cognito-auth.d.ts.map +1 -1
  24. package/dist/cognito-auth.js +18 -9
  25. package/dist/cognito-auth.js.map +1 -1
  26. package/dist/cognito-auth.test.d.ts +3 -3
  27. package/dist/cognito-auth.test.js +21 -10
  28. package/dist/cognito-auth.test.js.map +1 -1
  29. package/dist/vault-client.d.ts +1 -0
  30. package/dist/vault-client.d.ts.map +1 -1
  31. package/dist/vault-client.js +9 -2
  32. package/dist/vault-client.js.map +1 -1
  33. package/dist/vault-client.test.js +46 -0
  34. package/dist/vault-client.test.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/auth.ts +2 -2
  37. package/src/bin/sync-runner.test.ts +256 -1
  38. package/src/bin/sync-runner.ts +138 -34
  39. package/src/cli/accept.ts +2 -2
  40. package/src/cli/share.test.ts +201 -13
  41. package/src/cli/share.ts +91 -15
  42. package/src/cli/sync.test.ts +33 -12
  43. package/src/cli/sync.ts +6 -1
  44. package/src/cognito-auth.test.ts +22 -14
  45. package/src/cognito-auth.ts +31 -11
  46. package/src/vault-client.test.ts +60 -0
  47. package/src/vault-client.ts +8 -1
  48. package/test/invite-flow.integration.test.ts +1 -1
@@ -54,8 +54,24 @@ import type {
54
54
  SyncResult,
55
55
  SyncProgressEvent,
56
56
  } from "../cli/sync.js";
57
+ import { share as defaultShare } from "../cli/share.js";
58
+ import type { ShareOptions, ShareResult } from "../cli/share.js";
57
59
  import type { ConflictStrategy } from "../cli/conflict.js";
58
60
 
61
+ /**
62
+ * Sync direction for a run.
63
+ *
64
+ * - `pull`: download-only (legacy `hq sync` behaviour, and the default for
65
+ * back-compat with pre-5.1.11 callers of the runner).
66
+ * - `push`: upload-only. Walks the company folder and sends every file whose
67
+ * local hash differs from the journal (skipUnchanged).
68
+ * - `both`: push first, then pull. "Sync Now" in the menubar app targets this.
69
+ * Push runs first so the subsequent pull doesn't redownload files we were
70
+ * about to replace; if a company aborts on push conflict, pull is skipped
71
+ * for that company but the fanout continues.
72
+ */
73
+ export type Direction = "pull" | "push" | "both";
74
+
59
75
  // ---------------------------------------------------------------------------
60
76
  // Defaults — mirror `hq-cli/src/utils/cognito-session.ts`. Inlined (not
61
77
  // imported) to avoid a circular dep between hq-cli and hq-cloud. If these
@@ -97,12 +113,27 @@ export type RunnerEvent =
97
113
  }
98
114
  | ({ type: "progress"; company: string } & Omit<Extract<SyncProgressEvent, { type: "progress" }>, "type">)
99
115
  | ({ type: "error"; company?: string } & Omit<Extract<SyncProgressEvent, { type: "error" }>, "type">)
100
- | ({ type: "complete"; company: string } & SyncResult)
116
+ | ({
117
+ type: "complete";
118
+ company: string;
119
+ /**
120
+ * Upload counters. Always emitted (0 when the run was pull-only) so
121
+ * downstream consumers don't need to conditionally read the field.
122
+ * Tauri's `SyncCompleteEvent` ignores extra fields today; adding them
123
+ * to the Rust struct is a follow-up when the UI needs to surface push
124
+ * totals.
125
+ */
126
+ filesUploaded: number;
127
+ bytesUploaded: number;
128
+ } & SyncResult)
101
129
  | {
102
130
  type: "all-complete";
103
131
  companiesAttempted: number;
104
132
  filesDownloaded: number;
105
133
  bytesDownloaded: number;
134
+ /** Always emitted; 0 when no push phase ran. */
135
+ filesUploaded: number;
136
+ bytesUploaded: number;
106
137
  errors: Array<{ company: string; message: string }>;
107
138
  };
108
139
 
@@ -158,6 +189,8 @@ export interface RunnerDeps {
158
189
  createVaultClient?: (config: VaultServiceConfig) => VaultClientSurface;
159
190
  /** Sync function. Defaults to `cli/sync.sync`. */
160
191
  sync?: (options: SyncOptions) => Promise<SyncResult>;
192
+ /** Share function (push phase). Defaults to `cli/share.share`. */
193
+ share?: (options: ShareOptions) => Promise<ShareResult>;
161
194
  }
162
195
 
163
196
  // ---------------------------------------------------------------------------
@@ -239,6 +272,7 @@ interface ParsedArgs {
239
272
  company?: string;
240
273
  onConflict: ConflictStrategy;
241
274
  hqRoot: string;
275
+ direction: Direction;
242
276
  }
243
277
 
244
278
  function parseArgs(argv: string[]): ParsedArgs | { error: string } {
@@ -246,6 +280,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
246
280
  let company: string | undefined;
247
281
  let onConflict: ConflictStrategy = "abort";
248
282
  let hqRoot = DEFAULT_HQ_ROOT;
283
+ let direction: Direction = "pull";
249
284
 
250
285
  for (let i = 0; i < argv.length; i++) {
251
286
  const arg = argv[i];
@@ -267,6 +302,16 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
267
302
  onConflict = val;
268
303
  break;
269
304
  }
305
+ case "--direction": {
306
+ const val = argv[++i];
307
+ if (val !== "pull" && val !== "push" && val !== "both") {
308
+ return {
309
+ error: `--direction must be one of pull|push|both, got: ${val ?? "(missing)"}`,
310
+ };
311
+ }
312
+ direction = val;
313
+ break;
314
+ }
270
315
  case "--hq-root":
271
316
  hqRoot = argv[++i];
272
317
  if (!hqRoot) return { error: "--hq-root requires a value" };
@@ -286,7 +331,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
286
331
  return { error: "Pass --companies or --company <slug>" };
287
332
  }
288
333
 
289
- return { companies, company, onConflict, hqRoot };
334
+ return { companies, company, onConflict, hqRoot, direction };
290
335
  }
291
336
 
292
337
  // ---------------------------------------------------------------------------
@@ -402,42 +447,99 @@ export async function runRunner(
402
447
 
403
448
  // ---- fanout -----------------------------------------------------------
404
449
  const syncFn = deps.sync ?? defaultSync;
405
- let totalFiles = 0;
406
- let totalBytes = 0;
450
+ const shareFn = deps.share ?? defaultShare;
451
+ const doPush = parsed.direction === "push" || parsed.direction === "both";
452
+ const doPull = parsed.direction === "pull" || parsed.direction === "both";
453
+ let totalDownloaded = 0;
454
+ let totalDownloadedBytes = 0;
455
+ let totalUploaded = 0;
456
+ let totalUploadedBytes = 0;
407
457
  const errors: Array<{ company: string; message: string }> = [];
408
458
 
409
459
  for (const target of plan) {
410
460
  const companyLabel = target.slug;
461
+ // Per-company event tagger — shared by push and pull phases so progress
462
+ // rows land on the right company regardless of which phase emitted them.
463
+ const tagAndEmit = (event: SyncProgressEvent): void => {
464
+ if (event.type === "progress") {
465
+ emit({
466
+ type: "progress",
467
+ company: companyLabel,
468
+ path: event.path,
469
+ bytes: event.bytes,
470
+ ...(event.message ? { message: event.message } : {}),
471
+ });
472
+ } else {
473
+ emit({
474
+ type: "error",
475
+ company: companyLabel,
476
+ path: event.path,
477
+ message: event.message,
478
+ });
479
+ }
480
+ };
481
+
411
482
  try {
412
- const result = await syncFn({
413
- company: target.uid,
414
- vaultConfig,
415
- hqRoot: parsed.hqRoot,
416
- onConflict: parsed.onConflict,
417
- onEvent: (event) => {
418
- // Tag per-file events with the company they belong to so the
419
- // menubar can route them to the right company's progress bar.
420
- if (event.type === "progress") {
421
- emit({
422
- type: "progress",
423
- company: companyLabel,
424
- path: event.path,
425
- bytes: event.bytes,
426
- ...(event.message ? { message: event.message } : {}),
427
- });
428
- } else {
429
- emit({
430
- type: "error",
431
- company: companyLabel,
432
- path: event.path,
433
- message: event.message,
434
- });
435
- }
436
- },
483
+ let pushResult: ShareResult = {
484
+ filesUploaded: 0,
485
+ bytesUploaded: 0,
486
+ filesSkipped: 0,
487
+ aborted: false,
488
+ };
489
+ let pullResult: SyncResult = {
490
+ filesDownloaded: 0,
491
+ bytesDownloaded: 0,
492
+ filesSkipped: 0,
493
+ conflicts: 0,
494
+ aborted: false,
495
+ };
496
+
497
+ // Push first so a subsequent pull doesn't overwrite files we were about
498
+ // to broadcast. Uses the walk-everything-under-companies/{slug}/ entry
499
+ // point with `skipUnchanged` so we don't re-upload files that haven't
500
+ // changed since the last sync.
501
+ if (doPush) {
502
+ pushResult = await shareFn({
503
+ paths: [path.join(parsed.hqRoot, "companies", target.slug)],
504
+ company: target.uid,
505
+ vaultConfig,
506
+ hqRoot: parsed.hqRoot,
507
+ onConflict: parsed.onConflict,
508
+ skipUnchanged: true,
509
+ onEvent: tagAndEmit,
510
+ });
511
+ }
512
+
513
+ // Pull runs unless the push phase aborted on conflict — aborted means
514
+ // the user has local edits + remote drift; blindly pulling would erase
515
+ // whichever side `--on-conflict abort` just protected.
516
+ if (doPull && !pushResult.aborted) {
517
+ pullResult = await syncFn({
518
+ company: target.uid,
519
+ vaultConfig,
520
+ hqRoot: parsed.hqRoot,
521
+ onConflict: parsed.onConflict,
522
+ onEvent: tagAndEmit,
523
+ });
524
+ }
525
+
526
+ emit({
527
+ type: "complete",
528
+ company: companyLabel,
529
+ filesDownloaded: pullResult.filesDownloaded,
530
+ bytesDownloaded: pullResult.bytesDownloaded,
531
+ filesUploaded: pushResult.filesUploaded,
532
+ bytesUploaded: pushResult.bytesUploaded,
533
+ filesSkipped: pullResult.filesSkipped + pushResult.filesSkipped,
534
+ conflicts: pullResult.conflicts,
535
+ // Either phase aborting marks the company aborted — the UI treats
536
+ // `aborted: true` as "sync didn't complete cleanly for this company".
537
+ aborted: pullResult.aborted || pushResult.aborted,
437
538
  });
438
- emit({ type: "complete", company: companyLabel, ...result });
439
- totalFiles += result.filesDownloaded;
440
- totalBytes += result.bytesDownloaded;
539
+ totalDownloaded += pullResult.filesDownloaded;
540
+ totalDownloadedBytes += pullResult.bytesDownloaded;
541
+ totalUploaded += pushResult.filesUploaded;
542
+ totalUploadedBytes += pushResult.bytesUploaded;
441
543
  } catch (err) {
442
544
  const message = err instanceof Error ? err.message : String(err);
443
545
  errors.push({ company: companyLabel, message });
@@ -454,8 +556,10 @@ export async function runRunner(
454
556
  emit({
455
557
  type: "all-complete",
456
558
  companiesAttempted: plan.length,
457
- filesDownloaded: totalFiles,
458
- bytesDownloaded: totalBytes,
559
+ filesDownloaded: totalDownloaded,
560
+ bytesDownloaded: totalDownloadedBytes,
561
+ filesUploaded: totalUploaded,
562
+ bytesUploaded: totalUploadedBytes,
459
563
  errors,
460
564
  });
461
565
  return 0;
package/src/cli/accept.ts CHANGED
@@ -40,8 +40,8 @@ export function parseToken(tokenOrLink: string): string {
40
40
  return trimmed.slice("hq://accept/".length);
41
41
  }
42
42
 
43
- // https://hq.indigoai.com/accept/<token> (future web route)
44
- const httpsPrefix = "https://hq.indigoai.com/accept/";
43
+ // https://example.com/accept/<token> (future web route)
44
+ const httpsPrefix = "https://example.com/accept/";
45
45
  if (trimmed.startsWith(httpsPrefix)) {
46
46
  return trimmed.slice(httpsPrefix.length);
47
47
  }
@@ -19,7 +19,7 @@ vi.mock("../s3.js", () => ({
19
19
  }));
20
20
 
21
21
  import { share } from "./share.js";
22
- import { headRemoteFile } from "../s3.js";
22
+ import { headRemoteFile, uploadFile } from "../s3.js";
23
23
 
24
24
  const mockConfig: VaultServiceConfig = {
25
25
  apiUrl: "https://vault-api.test",
@@ -82,8 +82,10 @@ describe("share", () => {
82
82
  delete process.env.HQ_STATE_DIR;
83
83
  });
84
84
 
85
- it("shares a single file", async () => {
86
- const testFile = path.join(tmpDir, "test.md");
85
+ it("shares a single file keyed relative to the company root", async () => {
86
+ const companyRoot = path.join(tmpDir, "companies", "acme");
87
+ fs.mkdirSync(companyRoot, { recursive: true });
88
+ const testFile = path.join(companyRoot, "test.md");
87
89
  fs.writeFileSync(testFile, "# Hello World");
88
90
 
89
91
  const result = await share({
@@ -95,15 +97,18 @@ describe("share", () => {
95
97
 
96
98
  expect(result.filesUploaded).toBe(1);
97
99
  expect(result.aborted).toBe(false);
100
+ // Remote key must be company-relative, not hqRoot-relative
101
+ expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "test.md");
98
102
  });
99
103
 
100
104
  it("respects ignore rules", async () => {
101
- fs.mkdirSync(path.join(tmpDir, ".git"));
102
- fs.writeFileSync(path.join(tmpDir, ".git", "config"), "git config");
103
- fs.writeFileSync(path.join(tmpDir, "readme.md"), "readme");
105
+ const companyRoot = path.join(tmpDir, "companies", "acme");
106
+ fs.mkdirSync(path.join(companyRoot, ".git"), { recursive: true });
107
+ fs.writeFileSync(path.join(companyRoot, ".git", "config"), "git config");
108
+ fs.writeFileSync(path.join(companyRoot, "readme.md"), "readme");
104
109
 
105
110
  const result = await share({
106
- paths: [tmpDir],
111
+ paths: [companyRoot],
107
112
  company: "acme",
108
113
  vaultConfig: mockConfig,
109
114
  hqRoot: tmpDir,
@@ -113,12 +118,13 @@ describe("share", () => {
113
118
  });
114
119
 
115
120
  it("shares a directory of files", async () => {
116
- fs.mkdirSync(path.join(tmpDir, "docs"));
117
- fs.writeFileSync(path.join(tmpDir, "docs", "a.md"), "doc a");
118
- fs.writeFileSync(path.join(tmpDir, "docs", "b.md"), "doc b");
121
+ const companyRoot = path.join(tmpDir, "companies", "acme");
122
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
123
+ fs.writeFileSync(path.join(companyRoot, "docs", "a.md"), "doc a");
124
+ fs.writeFileSync(path.join(companyRoot, "docs", "b.md"), "doc b");
119
125
 
120
126
  const result = await share({
121
- paths: [path.join(tmpDir, "docs")],
127
+ paths: [path.join(companyRoot, "docs")],
122
128
  company: "acme",
123
129
  vaultConfig: mockConfig,
124
130
  hqRoot: tmpDir,
@@ -127,6 +133,44 @@ describe("share", () => {
127
133
  expect(result.filesUploaded).toBe(2);
128
134
  });
129
135
 
136
+ it("keys nested paths relative to the company root, not hqRoot", async () => {
137
+ const companyRoot = path.join(tmpDir, "companies", "acme");
138
+ fs.mkdirSync(path.join(companyRoot, "knowledge"), { recursive: true });
139
+ const nested = path.join(companyRoot, "knowledge", "crawl.json");
140
+ fs.writeFileSync(nested, "{}");
141
+
142
+ await share({
143
+ paths: [nested],
144
+ company: "acme",
145
+ vaultConfig: mockConfig,
146
+ hqRoot: tmpDir,
147
+ });
148
+
149
+ // Key is "knowledge/crawl.json", not "companies/acme/knowledge/crawl.json"
150
+ expect(uploadFile).toHaveBeenCalledWith(expect.anything(), nested, "knowledge/crawl.json");
151
+ });
152
+
153
+ it("skips files outside the company folder with a warning", async () => {
154
+ const warnSpy = vi.spyOn(console, "error").mockImplementation(() => {});
155
+ // File at hqRoot, outside companies/acme/
156
+ const outsideFile = path.join(tmpDir, "stray.md");
157
+ fs.writeFileSync(outsideFile, "stray");
158
+
159
+ const result = await share({
160
+ paths: [outsideFile],
161
+ company: "acme",
162
+ vaultConfig: mockConfig,
163
+ hqRoot: tmpDir,
164
+ });
165
+
166
+ expect(result.filesUploaded).toBe(0);
167
+ expect(uploadFile).not.toHaveBeenCalled();
168
+ expect(warnSpy).toHaveBeenCalledWith(
169
+ expect.stringMatching(/outside company folder/i),
170
+ );
171
+ warnSpy.mockRestore();
172
+ });
173
+
130
174
  it("throws when no company specified and no active company", async () => {
131
175
  fs.writeFileSync(path.join(tmpDir, "test.md"), "test");
132
176
 
@@ -140,16 +184,160 @@ describe("share", () => {
140
184
  });
141
185
 
142
186
  it("resolves active company from .hq/config.json", async () => {
187
+ const companyRoot = path.join(tmpDir, "companies", "acme");
188
+ fs.mkdirSync(companyRoot, { recursive: true });
143
189
  fs.mkdirSync(path.join(tmpDir, ".hq"), { recursive: true });
144
190
  fs.writeFileSync(path.join(tmpDir, ".hq", "config.json"), JSON.stringify({ activeCompany: "acme" }));
145
- fs.writeFileSync(path.join(tmpDir, "test.md"), "test");
191
+ fs.writeFileSync(path.join(companyRoot, "test.md"), "test");
192
+
193
+ const result = await share({
194
+ paths: [path.join(companyRoot, "test.md")],
195
+ vaultConfig: mockConfig,
196
+ hqRoot: tmpDir,
197
+ });
198
+
199
+ expect(result.filesUploaded).toBe(1);
200
+ });
201
+
202
+ it("skipUnchanged=true skips files whose local hash matches the journal", async () => {
203
+ const companyRoot = path.join(tmpDir, "companies", "acme");
204
+ fs.mkdirSync(companyRoot, { recursive: true });
205
+ const testFile = path.join(companyRoot, "unchanged.md");
206
+ fs.writeFileSync(testFile, "stable content");
207
+
208
+ // Precompute the hash of the file so the journal matches exactly.
209
+ const { hashFile } = await import("../journal.js");
210
+ const hash = hashFile(testFile);
211
+
212
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
213
+ fs.writeFileSync(
214
+ journalPath,
215
+ JSON.stringify({
216
+ version: "1",
217
+ lastSync: new Date().toISOString(),
218
+ files: {
219
+ "unchanged.md": {
220
+ hash,
221
+ size: 15,
222
+ syncedAt: new Date().toISOString(),
223
+ direction: "up",
224
+ },
225
+ },
226
+ }),
227
+ );
228
+
229
+ const result = await share({
230
+ paths: [testFile],
231
+ company: "acme",
232
+ vaultConfig: mockConfig,
233
+ hqRoot: tmpDir,
234
+ skipUnchanged: true,
235
+ });
236
+
237
+ expect(result.filesUploaded).toBe(0);
238
+ expect(result.filesSkipped).toBe(1);
239
+ expect(uploadFile).not.toHaveBeenCalled();
240
+ });
241
+
242
+ it("skipUnchanged=true still uploads files whose hash differs from the journal", async () => {
243
+ const companyRoot = path.join(tmpDir, "companies", "acme");
244
+ fs.mkdirSync(companyRoot, { recursive: true });
245
+ const testFile = path.join(companyRoot, "changed.md");
246
+ fs.writeFileSync(testFile, "new content");
247
+
248
+ // Journal has a stale hash for this path — simulating "local has been
249
+ // edited since the last push".
250
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
251
+ fs.writeFileSync(
252
+ journalPath,
253
+ JSON.stringify({
254
+ version: "1",
255
+ lastSync: new Date().toISOString(),
256
+ files: {
257
+ "changed.md": {
258
+ hash: "stale-hash-from-previous-sync",
259
+ size: 10,
260
+ syncedAt: new Date().toISOString(),
261
+ direction: "up",
262
+ },
263
+ },
264
+ }),
265
+ );
266
+
267
+ const result = await share({
268
+ paths: [testFile],
269
+ company: "acme",
270
+ vaultConfig: mockConfig,
271
+ hqRoot: tmpDir,
272
+ skipUnchanged: true,
273
+ });
274
+
275
+ expect(result.filesUploaded).toBe(1);
276
+ expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "changed.md");
277
+ });
278
+
279
+ it("skipUnchanged=false (default) uploads even when hash matches", async () => {
280
+ const companyRoot = path.join(tmpDir, "companies", "acme");
281
+ fs.mkdirSync(companyRoot, { recursive: true });
282
+ const testFile = path.join(companyRoot, "unchanged.md");
283
+ fs.writeFileSync(testFile, "stable content");
284
+
285
+ const { hashFile } = await import("../journal.js");
286
+ const hash = hashFile(testFile);
287
+
288
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
289
+ fs.writeFileSync(
290
+ journalPath,
291
+ JSON.stringify({
292
+ version: "1",
293
+ lastSync: new Date().toISOString(),
294
+ files: {
295
+ "unchanged.md": {
296
+ hash,
297
+ size: 15,
298
+ syncedAt: new Date().toISOString(),
299
+ direction: "up",
300
+ },
301
+ },
302
+ }),
303
+ );
146
304
 
147
305
  const result = await share({
148
- paths: [path.join(tmpDir, "test.md")],
306
+ paths: [testFile],
307
+ company: "acme",
149
308
  vaultConfig: mockConfig,
150
309
  hqRoot: tmpDir,
310
+ // skipUnchanged omitted — preserves `hq share <file>` semantics
151
311
  });
152
312
 
153
313
  expect(result.filesUploaded).toBe(1);
314
+ expect(uploadFile).toHaveBeenCalled();
315
+ });
316
+
317
+ it("onEvent receives progress events instead of console output", async () => {
318
+ const companyRoot = path.join(tmpDir, "companies", "acme");
319
+ fs.mkdirSync(companyRoot, { recursive: true });
320
+ fs.writeFileSync(path.join(companyRoot, "a.md"), "aaa");
321
+ fs.writeFileSync(path.join(companyRoot, "b.md"), "bbb");
322
+
323
+ const events: Array<{ type: string; path: string; bytes?: number }> = [];
324
+ const result = await share({
325
+ paths: [companyRoot],
326
+ company: "acme",
327
+ vaultConfig: mockConfig,
328
+ hqRoot: tmpDir,
329
+ onEvent: (e) => {
330
+ events.push({
331
+ type: e.type,
332
+ path: e.path,
333
+ ...(e.type === "progress" ? { bytes: e.bytes } : {}),
334
+ });
335
+ },
336
+ });
337
+
338
+ expect(result.filesUploaded).toBe(2);
339
+ expect(events).toHaveLength(2);
340
+ expect(events.every((e) => e.type === "progress")).toBe(true);
341
+ expect(events.map((e) => e.path).sort()).toEqual(["a.md", "b.md"]);
154
342
  });
155
343
  });