@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.
- package/dist/auth.js +2 -2
- package/dist/auth.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +28 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +101 -34
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +194 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/accept.js +2 -2
- package/dist/cli/accept.js.map +1 -1
- package/dist/cli/share.d.ts +18 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +71 -14
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +167 -13
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +6 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +31 -12
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts +13 -2
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +18 -9
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.d.ts +3 -3
- package/dist/cognito-auth.test.js +21 -10
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/vault-client.d.ts +1 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +9 -2
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +46 -0
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +1 -1
- package/src/auth.ts +2 -2
- package/src/bin/sync-runner.test.ts +256 -1
- package/src/bin/sync-runner.ts +138 -34
- package/src/cli/accept.ts +2 -2
- package/src/cli/share.test.ts +201 -13
- package/src/cli/share.ts +91 -15
- package/src/cli/sync.test.ts +33 -12
- package/src/cli/sync.ts +6 -1
- package/src/cognito-auth.test.ts +22 -14
- package/src/cognito-auth.ts +31 -11
- package/src/vault-client.test.ts +60 -0
- package/src/vault-client.ts +8 -1
- package/test/invite-flow.integration.test.ts +1 -1
package/src/bin/sync-runner.ts
CHANGED
|
@@ -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
|
-
| ({
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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:
|
|
458
|
-
bytesDownloaded:
|
|
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://
|
|
44
|
-
const httpsPrefix = "https://
|
|
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
|
}
|
package/src/cli/share.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
102
|
-
fs.
|
|
103
|
-
fs.writeFileSync(path.join(
|
|
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: [
|
|
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
|
-
|
|
117
|
-
fs.
|
|
118
|
-
fs.writeFileSync(path.join(
|
|
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(
|
|
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(
|
|
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: [
|
|
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
|
});
|