@indigoai-us/hq-cloud 5.2.1 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-runner.d.ts +45 -16
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +76 -13
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +65 -11
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +6 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +25 -2
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +45 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +12 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +27 -2
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +34 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/ignore.d.ts +17 -11
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +30 -12
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.d.ts +10 -0
- package/dist/ignore.test.d.ts.map +1 -0
- package/dist/ignore.test.js +57 -0
- package/dist/ignore.test.js.map +1 -0
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +80 -17
- package/src/bin/sync-runner.ts +91 -17
- package/src/cli/share.test.ts +56 -0
- package/src/cli/share.ts +34 -2
- package/src/cli/sync.test.ts +44 -0
- package/src/cli/sync.ts +43 -4
- package/src/ignore.test.ts +71 -0
- package/src/ignore.ts +29 -12
package/src/bin/sync-runner.ts
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* (ADR-0001).
|
|
5
5
|
*
|
|
6
6
|
* The AppBar Sync menubar (Tauri + Rust) spawns this binary as a subprocess
|
|
7
|
-
* and reads ndjson events from stdout
|
|
8
|
-
* and versioned-by-shape, not
|
|
9
|
-
*
|
|
10
|
-
* `@indigoai-us/hq-cli`.
|
|
7
|
+
* and reads ndjson events from BOTH stdout and stderr (see "Channels"
|
|
8
|
+
* below). The protocol is intentionally narrow and versioned-by-shape, not
|
|
9
|
+
* by tooling — no chalk, no colors, no human prose. If you want to invoke
|
|
10
|
+
* sync as a human, use `hq sync` in `@indigoai-us/hq-cli`.
|
|
11
11
|
*
|
|
12
12
|
* Flags:
|
|
13
13
|
* --companies Fan out across every membership the caller has
|
|
@@ -18,14 +18,23 @@
|
|
|
18
18
|
* only output mode. Accepted for symmetry with the
|
|
19
19
|
* AppBar's argv in case someone passes it.
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
21
|
+
* Channels (one JSON object per line):
|
|
22
|
+
* stdout — protocol stream:
|
|
23
|
+
* setup-needed caller signed in but has no person entity yet
|
|
24
|
+
* fanout-plan list of companies we're about to sync
|
|
25
|
+
* progress per-file download
|
|
26
|
+
* complete per-company summary
|
|
27
|
+
* all-complete aggregate summary after fanout
|
|
28
|
+
* stderr — diagnostic stream:
|
|
29
|
+
* error per-file or per-company error
|
|
30
|
+
* auth-error no valid token available (interactive login disabled)
|
|
31
|
+
*
|
|
32
|
+
* Why the split: error-class events go to stderr so the menubar's Sentry
|
|
33
|
+
* breadcrumb pipeline picks them up automatically (see hq-sync
|
|
34
|
+
* src-tauri/src/commands/sync.rs `ProcessEvent::Stderr` handler). The
|
|
35
|
+
* single Sentry capture at runner-exit then ships one #hq-alerts issue
|
|
36
|
+
* with the full per-file → company → exit error trail attached, instead
|
|
37
|
+
* of requiring per-event capture calls in the menubar.
|
|
29
38
|
*
|
|
30
39
|
* Exit code:
|
|
31
40
|
* 0 — event stream describes the outcome (including setup-needed)
|
|
@@ -100,10 +109,14 @@ const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
|
|
|
100
109
|
// ---------------------------------------------------------------------------
|
|
101
110
|
|
|
102
111
|
/**
|
|
103
|
-
* Every event
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
112
|
+
* Every event the runner emits. Channel routing (stdout vs stderr) is
|
|
113
|
+
* decided inside `runRunner`'s `emit` helper based on the event's `type`
|
|
114
|
+
* — see the doc-block on the file header for the split.
|
|
115
|
+
*
|
|
116
|
+
* The `company` field is present on every event except `setup-needed` /
|
|
117
|
+
* `auth-error` / `fanout-plan` / `all-complete` (which describe the whole
|
|
118
|
+
* run) — consumers should treat its absence as "meta-event, not tied to a
|
|
119
|
+
* specific company".
|
|
107
120
|
*/
|
|
108
121
|
export type RunnerEvent =
|
|
109
122
|
| { type: "setup-needed" }
|
|
@@ -114,6 +127,7 @@ export type RunnerEvent =
|
|
|
114
127
|
}
|
|
115
128
|
| ({ type: "progress"; company: string } & Omit<Extract<SyncProgressEvent, { type: "progress" }>, "type">)
|
|
116
129
|
| ({ type: "error"; company?: string } & Omit<Extract<SyncProgressEvent, { type: "error" }>, "type">)
|
|
130
|
+
| ({ type: "conflict"; company: string } & Omit<Extract<SyncProgressEvent, { type: "conflict" }>, "type">)
|
|
117
131
|
| ({
|
|
118
132
|
type: "complete";
|
|
119
133
|
company: string;
|
|
@@ -135,6 +149,13 @@ export type RunnerEvent =
|
|
|
135
149
|
/** Always emitted; 0 when no push phase ran. */
|
|
136
150
|
filesUploaded: number;
|
|
137
151
|
bytesUploaded: number;
|
|
152
|
+
/**
|
|
153
|
+
* Conflict file paths aggregated across every company in the run.
|
|
154
|
+
* Always emitted; empty array when no conflicts were detected. Lets
|
|
155
|
+
* the menubar UI render a flat list without re-walking per-company
|
|
156
|
+
* `complete` events.
|
|
157
|
+
*/
|
|
158
|
+
conflictPaths: Array<{ company: string; path: string; direction: "pull" | "push" }>;
|
|
138
159
|
errors: Array<{ company: string; message: string }>;
|
|
139
160
|
};
|
|
140
161
|
|
|
@@ -347,8 +368,35 @@ export async function runRunner(
|
|
|
347
368
|
const stdout = deps.stdout ?? process.stdout;
|
|
348
369
|
const stderr = deps.stderr ?? process.stderr;
|
|
349
370
|
|
|
371
|
+
// ---- emit ---------------------------------------------------------------
|
|
372
|
+
// Error-class events go to stderr; everything else to stdout.
|
|
373
|
+
//
|
|
374
|
+
// Why split: the AppBar Sync menubar (Tauri + Rust) feeds runner stderr
|
|
375
|
+
// into Sentry as breadcrumbs and captures one Sentry event when the
|
|
376
|
+
// runner exits non-zero. Routing `error` / `auth-error` events through
|
|
377
|
+
// stderr makes them part of that breadcrumb trail automatically — the
|
|
378
|
+
// menubar doesn't need a per-event capture call, and operators get the
|
|
379
|
+
// full context (per-file errors → company error → exit) in a single
|
|
380
|
+
// Sentry issue alerted to #hq-alerts.
|
|
381
|
+
//
|
|
382
|
+
// Non-error events (progress, complete, fanout-plan, all-complete,
|
|
383
|
+
// setup-needed) stay on stdout. They're the protocol stream the menubar
|
|
384
|
+
// parses for UI updates; mixing them with error events on the same
|
|
385
|
+
// channel was the original design (single ndjson stream, simpler to
|
|
386
|
+
// tee), but error context belongs in the diagnostic channel.
|
|
387
|
+
//
|
|
388
|
+
// Backward compat: older menubar releases (pre-PR-#34) parse only
|
|
389
|
+
// stdout for ndjson; with this change they will NOT receive error
|
|
390
|
+
// events. The menubar's `HQ_CLOUD_VERSION` pin gates which runner
|
|
391
|
+
// they spawn, so old menubars stay on the previous runner version
|
|
392
|
+
// even after this one is published.
|
|
393
|
+
const ERROR_TYPES: ReadonlySet<RunnerEvent["type"]> = new Set([
|
|
394
|
+
"error",
|
|
395
|
+
"auth-error",
|
|
396
|
+
]);
|
|
350
397
|
const emit = (event: RunnerEvent): void => {
|
|
351
|
-
|
|
398
|
+
const stream = ERROR_TYPES.has(event.type) ? stderr : stdout;
|
|
399
|
+
stream.write(`${JSON.stringify(event)}\n`);
|
|
352
400
|
};
|
|
353
401
|
|
|
354
402
|
// ---- argv -------------------------------------------------------------
|
|
@@ -479,6 +527,7 @@ export async function runRunner(
|
|
|
479
527
|
let totalUploaded = 0;
|
|
480
528
|
let totalUploadedBytes = 0;
|
|
481
529
|
const errors: Array<{ company: string; message: string }> = [];
|
|
530
|
+
const allConflicts: Array<{ company: string; path: string; direction: "pull" | "push" }> = [];
|
|
482
531
|
|
|
483
532
|
for (const target of plan) {
|
|
484
533
|
const companyLabel = target.slug;
|
|
@@ -493,6 +542,14 @@ export async function runRunner(
|
|
|
493
542
|
bytes: event.bytes,
|
|
494
543
|
...(event.message ? { message: event.message } : {}),
|
|
495
544
|
});
|
|
545
|
+
} else if (event.type === "conflict") {
|
|
546
|
+
emit({
|
|
547
|
+
type: "conflict",
|
|
548
|
+
company: companyLabel,
|
|
549
|
+
path: event.path,
|
|
550
|
+
direction: event.direction,
|
|
551
|
+
resolution: event.resolution,
|
|
552
|
+
});
|
|
496
553
|
} else {
|
|
497
554
|
emit({
|
|
498
555
|
type: "error",
|
|
@@ -508,6 +565,7 @@ export async function runRunner(
|
|
|
508
565
|
filesUploaded: 0,
|
|
509
566
|
bytesUploaded: 0,
|
|
510
567
|
filesSkipped: 0,
|
|
568
|
+
conflictPaths: [],
|
|
511
569
|
aborted: false,
|
|
512
570
|
};
|
|
513
571
|
let pullResult: SyncResult = {
|
|
@@ -515,6 +573,7 @@ export async function runRunner(
|
|
|
515
573
|
bytesDownloaded: 0,
|
|
516
574
|
filesSkipped: 0,
|
|
517
575
|
conflicts: 0,
|
|
576
|
+
conflictPaths: [],
|
|
518
577
|
aborted: false,
|
|
519
578
|
};
|
|
520
579
|
|
|
@@ -549,6 +608,13 @@ export async function runRunner(
|
|
|
549
608
|
});
|
|
550
609
|
}
|
|
551
610
|
|
|
611
|
+
// Concat push + pull conflict paths into a single per-company list.
|
|
612
|
+
// Both arrays are always present (defaulted to []) so consumers can
|
|
613
|
+
// treat `conflictPaths` as authoritative without a falsy check.
|
|
614
|
+
const mergedConflictPaths = [
|
|
615
|
+
...pullResult.conflictPaths,
|
|
616
|
+
...pushResult.conflictPaths,
|
|
617
|
+
];
|
|
552
618
|
emit({
|
|
553
619
|
type: "complete",
|
|
554
620
|
company: companyLabel,
|
|
@@ -558,10 +624,17 @@ export async function runRunner(
|
|
|
558
624
|
bytesUploaded: pushResult.bytesUploaded,
|
|
559
625
|
filesSkipped: pullResult.filesSkipped + pushResult.filesSkipped,
|
|
560
626
|
conflicts: pullResult.conflicts,
|
|
627
|
+
conflictPaths: mergedConflictPaths,
|
|
561
628
|
// Either phase aborting marks the company aborted — the UI treats
|
|
562
629
|
// `aborted: true` as "sync didn't complete cleanly for this company".
|
|
563
630
|
aborted: pullResult.aborted || pushResult.aborted,
|
|
564
631
|
});
|
|
632
|
+
for (const p of pullResult.conflictPaths) {
|
|
633
|
+
allConflicts.push({ company: companyLabel, path: p, direction: "pull" });
|
|
634
|
+
}
|
|
635
|
+
for (const p of pushResult.conflictPaths) {
|
|
636
|
+
allConflicts.push({ company: companyLabel, path: p, direction: "push" });
|
|
637
|
+
}
|
|
565
638
|
totalDownloaded += pullResult.filesDownloaded;
|
|
566
639
|
totalDownloadedBytes += pullResult.bytesDownloaded;
|
|
567
640
|
totalUploaded += pushResult.filesUploaded;
|
|
@@ -586,6 +659,7 @@ export async function runRunner(
|
|
|
586
659
|
bytesDownloaded: totalDownloadedBytes,
|
|
587
660
|
filesUploaded: totalUploaded,
|
|
588
661
|
bytesUploaded: totalUploadedBytes,
|
|
662
|
+
conflictPaths: allConflicts,
|
|
589
663
|
errors,
|
|
590
664
|
});
|
|
591
665
|
return 0;
|
package/src/cli/share.test.ts
CHANGED
|
@@ -276,6 +276,62 @@ describe("share", () => {
|
|
|
276
276
|
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "changed.md");
|
|
277
277
|
});
|
|
278
278
|
|
|
279
|
+
it("populates conflictPaths and emits a conflict event when remote drifted from journal", async () => {
|
|
280
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
281
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
282
|
+
const testFile = path.join(companyRoot, "drifted.md");
|
|
283
|
+
fs.writeFileSync(testFile, "local edit");
|
|
284
|
+
|
|
285
|
+
// Journal has a stale hash → local diverged. headRemoteFile returning
|
|
286
|
+
// non-null tells share() the remote also exists; combined with the
|
|
287
|
+
// hash mismatch this trips the conflict branch.
|
|
288
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
289
|
+
lastModified: new Date(),
|
|
290
|
+
etag: '"remote-changed"',
|
|
291
|
+
size: 99,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
295
|
+
fs.writeFileSync(
|
|
296
|
+
journalPath,
|
|
297
|
+
JSON.stringify({
|
|
298
|
+
version: "1",
|
|
299
|
+
lastSync: new Date().toISOString(),
|
|
300
|
+
files: {
|
|
301
|
+
"drifted.md": {
|
|
302
|
+
hash: "stale-hash",
|
|
303
|
+
size: 10,
|
|
304
|
+
syncedAt: new Date().toISOString(),
|
|
305
|
+
direction: "up",
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const events: unknown[] = [];
|
|
312
|
+
const result = await share({
|
|
313
|
+
paths: [testFile],
|
|
314
|
+
company: "acme",
|
|
315
|
+
vaultConfig: mockConfig,
|
|
316
|
+
hqRoot: tmpDir,
|
|
317
|
+
onConflict: "keep",
|
|
318
|
+
onEvent: (e) => events.push(e),
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
expect(result.conflictPaths).toEqual(["drifted.md"]);
|
|
322
|
+
const conflicts = events.filter(
|
|
323
|
+
(e): e is { type: "conflict"; path: string; direction: "push"; resolution: string } =>
|
|
324
|
+
typeof e === "object" && e !== null && (e as { type?: string }).type === "conflict",
|
|
325
|
+
);
|
|
326
|
+
expect(conflicts).toHaveLength(1);
|
|
327
|
+
expect(conflicts[0]).toMatchObject({
|
|
328
|
+
type: "conflict",
|
|
329
|
+
path: "drifted.md",
|
|
330
|
+
direction: "push",
|
|
331
|
+
resolution: "keep",
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
279
335
|
it("skipUnchanged=false (default) uploads even when hash matches", async () => {
|
|
280
336
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
281
337
|
fs.mkdirSync(companyRoot, { recursive: true });
|
package/src/cli/share.ts
CHANGED
|
@@ -52,6 +52,12 @@ export interface ShareResult {
|
|
|
52
52
|
filesUploaded: number;
|
|
53
53
|
bytesUploaded: number;
|
|
54
54
|
filesSkipped: number;
|
|
55
|
+
/**
|
|
56
|
+
* Paths (company-relative) that were detected as push conflicts. Mirrors
|
|
57
|
+
* `SyncResult.conflictPaths` so push and pull surface conflicts the same
|
|
58
|
+
* way to runner/UI consumers.
|
|
59
|
+
*/
|
|
60
|
+
conflictPaths: string[];
|
|
55
61
|
aborted: boolean;
|
|
56
62
|
}
|
|
57
63
|
|
|
@@ -83,6 +89,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
83
89
|
let filesUploaded = 0;
|
|
84
90
|
let bytesUploaded = 0;
|
|
85
91
|
let filesSkipped = 0;
|
|
92
|
+
const conflictPaths: string[] = [];
|
|
86
93
|
|
|
87
94
|
// Collect all files to share
|
|
88
95
|
const filesToShare = collectFiles(paths, hqRoot, syncRoot, shouldSync);
|
|
@@ -123,6 +130,8 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
123
130
|
|
|
124
131
|
// If remote has changed since our last sync, it's a conflict
|
|
125
132
|
if (journalEntry && journalEntry.hash !== localHash) {
|
|
133
|
+
conflictPaths.push(relativePath);
|
|
134
|
+
|
|
126
135
|
// Local has changes — check if remote also changed
|
|
127
136
|
const resolution = await resolveConflict(
|
|
128
137
|
{
|
|
@@ -134,8 +143,21 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
134
143
|
onConflict,
|
|
135
144
|
);
|
|
136
145
|
|
|
146
|
+
emit({
|
|
147
|
+
type: "conflict",
|
|
148
|
+
path: relativePath,
|
|
149
|
+
direction: "push",
|
|
150
|
+
resolution,
|
|
151
|
+
});
|
|
152
|
+
|
|
137
153
|
if (resolution === "abort") {
|
|
138
|
-
return {
|
|
154
|
+
return {
|
|
155
|
+
filesUploaded,
|
|
156
|
+
bytesUploaded,
|
|
157
|
+
filesSkipped,
|
|
158
|
+
conflictPaths,
|
|
159
|
+
aborted: true,
|
|
160
|
+
};
|
|
139
161
|
}
|
|
140
162
|
if (resolution === "keep" || resolution === "skip") {
|
|
141
163
|
filesSkipped++;
|
|
@@ -179,7 +201,13 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
179
201
|
|
|
180
202
|
writeJournal(ctx.slug, journal);
|
|
181
203
|
|
|
182
|
-
return {
|
|
204
|
+
return {
|
|
205
|
+
filesUploaded,
|
|
206
|
+
bytesUploaded,
|
|
207
|
+
filesSkipped,
|
|
208
|
+
conflictPaths,
|
|
209
|
+
aborted: false,
|
|
210
|
+
};
|
|
183
211
|
}
|
|
184
212
|
|
|
185
213
|
/**
|
|
@@ -193,6 +221,10 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
193
221
|
} else {
|
|
194
222
|
console.log(` ✓ ${event.path}`);
|
|
195
223
|
}
|
|
224
|
+
} else if (event.type === "conflict") {
|
|
225
|
+
console.error(
|
|
226
|
+
` ⚠ conflict (${event.direction}): ${event.path} — ${event.resolution}`,
|
|
227
|
+
);
|
|
196
228
|
} else {
|
|
197
229
|
console.error(` ✗ ${event.path} — ${event.message}`);
|
|
198
230
|
}
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -176,10 +176,54 @@ describe("sync", () => {
|
|
|
176
176
|
});
|
|
177
177
|
|
|
178
178
|
expect(result.conflicts).toBe(1);
|
|
179
|
+
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
179
180
|
expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
|
|
180
181
|
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("local version");
|
|
181
182
|
});
|
|
182
183
|
|
|
184
|
+
it("emits a conflict event with path + resolution on hash mismatch", async () => {
|
|
185
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
186
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
187
|
+
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
188
|
+
|
|
189
|
+
fs.writeFileSync(
|
|
190
|
+
journalPath,
|
|
191
|
+
JSON.stringify({
|
|
192
|
+
version: "1",
|
|
193
|
+
lastSync: new Date().toISOString(),
|
|
194
|
+
files: {
|
|
195
|
+
"docs/handoff.md": {
|
|
196
|
+
hash: "stale-hash",
|
|
197
|
+
size: 20,
|
|
198
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
199
|
+
direction: "down",
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const events: unknown[] = [];
|
|
206
|
+
await sync({
|
|
207
|
+
company: "acme",
|
|
208
|
+
onConflict: "keep",
|
|
209
|
+
vaultConfig: mockConfig,
|
|
210
|
+
hqRoot: tmpDir,
|
|
211
|
+
onEvent: (e) => events.push(e),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const conflicts = events.filter(
|
|
215
|
+
(e): e is { type: "conflict"; path: string; direction: "pull"; resolution: string } =>
|
|
216
|
+
typeof e === "object" && e !== null && (e as { type?: string }).type === "conflict",
|
|
217
|
+
);
|
|
218
|
+
expect(conflicts).toHaveLength(1);
|
|
219
|
+
expect(conflicts[0]).toMatchObject({
|
|
220
|
+
type: "conflict",
|
|
221
|
+
path: "docs/handoff.md",
|
|
222
|
+
direction: "pull",
|
|
223
|
+
resolution: "keep",
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
183
227
|
it("aborts on --on-conflict abort", async () => {
|
|
184
228
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
185
229
|
fs.mkdirSync(companyDocs, { recursive: true });
|
package/src/cli/sync.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { downloadFile, listRemoteFiles } from "../s3.js";
|
|
|
13
13
|
import { readJournal, writeJournal, hashFile, updateEntry, getEntry } from "../journal.js";
|
|
14
14
|
import { createIgnoreFilter } from "../ignore.js";
|
|
15
15
|
import { resolveConflict } from "./conflict.js";
|
|
16
|
-
import type { ConflictStrategy } from "./conflict.js";
|
|
16
|
+
import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Per-file events emitted by `sync()` as it progresses.
|
|
@@ -28,7 +28,13 @@ import type { ConflictStrategy } from "./conflict.js";
|
|
|
28
28
|
*/
|
|
29
29
|
export type SyncProgressEvent =
|
|
30
30
|
| { type: "progress"; path: string; bytes: number; message?: string }
|
|
31
|
-
| { type: "error"; path: string; message: string }
|
|
31
|
+
| { type: "error"; path: string; message: string }
|
|
32
|
+
| {
|
|
33
|
+
type: "conflict";
|
|
34
|
+
path: string;
|
|
35
|
+
direction: "pull" | "push";
|
|
36
|
+
resolution: ConflictResolution;
|
|
37
|
+
};
|
|
32
38
|
|
|
33
39
|
export interface SyncOptions {
|
|
34
40
|
/** Company slug or UID (defaults to active company from config) */
|
|
@@ -66,6 +72,12 @@ export interface SyncResult {
|
|
|
66
72
|
bytesDownloaded: number;
|
|
67
73
|
filesSkipped: number;
|
|
68
74
|
conflicts: number;
|
|
75
|
+
/**
|
|
76
|
+
* Paths (remote keys) that were detected as conflicts during this run.
|
|
77
|
+
* Always populated when `conflicts > 0` so callers can surface them in UI
|
|
78
|
+
* or logs without re-streaming the per-file events.
|
|
79
|
+
*/
|
|
80
|
+
conflictPaths: string[];
|
|
69
81
|
aborted: boolean;
|
|
70
82
|
}
|
|
71
83
|
|
|
@@ -106,6 +118,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
106
118
|
let bytesDownloaded = 0;
|
|
107
119
|
let filesSkipped = 0;
|
|
108
120
|
let conflicts = 0;
|
|
121
|
+
const conflictPaths: string[] = [];
|
|
109
122
|
|
|
110
123
|
// List all remote files (IAM session policy filters at the AWS layer)
|
|
111
124
|
const remoteFiles = await listRemoteFiles(ctx);
|
|
@@ -138,6 +151,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
138
151
|
// If local file has changed since last sync, it's a conflict
|
|
139
152
|
if (journalEntry && journalEntry.hash !== localHash) {
|
|
140
153
|
conflicts++;
|
|
154
|
+
conflictPaths.push(remoteFile.key);
|
|
141
155
|
|
|
142
156
|
const resolution = await resolveConflict(
|
|
143
157
|
{
|
|
@@ -150,9 +164,23 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
150
164
|
onConflict,
|
|
151
165
|
);
|
|
152
166
|
|
|
167
|
+
emit({
|
|
168
|
+
type: "conflict",
|
|
169
|
+
path: remoteFile.key,
|
|
170
|
+
direction: "pull",
|
|
171
|
+
resolution,
|
|
172
|
+
});
|
|
173
|
+
|
|
153
174
|
if (resolution === "abort") {
|
|
154
175
|
writeJournal(journalSlug, journal);
|
|
155
|
-
return {
|
|
176
|
+
return {
|
|
177
|
+
filesDownloaded,
|
|
178
|
+
bytesDownloaded,
|
|
179
|
+
filesSkipped,
|
|
180
|
+
conflicts,
|
|
181
|
+
conflictPaths,
|
|
182
|
+
aborted: true,
|
|
183
|
+
};
|
|
156
184
|
}
|
|
157
185
|
if (resolution === "keep" || resolution === "skip") {
|
|
158
186
|
filesSkipped++;
|
|
@@ -208,7 +236,14 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
208
236
|
|
|
209
237
|
writeJournal(journalSlug, journal);
|
|
210
238
|
|
|
211
|
-
return {
|
|
239
|
+
return {
|
|
240
|
+
filesDownloaded,
|
|
241
|
+
bytesDownloaded,
|
|
242
|
+
filesSkipped,
|
|
243
|
+
conflicts,
|
|
244
|
+
conflictPaths,
|
|
245
|
+
aborted: false,
|
|
246
|
+
};
|
|
212
247
|
}
|
|
213
248
|
|
|
214
249
|
/**
|
|
@@ -251,5 +286,9 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
251
286
|
}
|
|
252
287
|
} else if (event.type === "error") {
|
|
253
288
|
console.error(` ✗ ${event.path} — ${event.message}`);
|
|
289
|
+
} else if (event.type === "conflict") {
|
|
290
|
+
console.error(
|
|
291
|
+
` ⚠ conflict (${event.direction}): ${event.path} — ${event.resolution}`,
|
|
292
|
+
);
|
|
254
293
|
}
|
|
255
294
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for createIgnoreFilter.
|
|
3
|
+
*
|
|
4
|
+
* Covers both modes: legacy permissive (no .hqinclude) and allowlist mode
|
|
5
|
+
* (.hqinclude present). The allowlist tests guard against accidentally
|
|
6
|
+
* leaking sensitive subtrees like data/ or workers/ to S3 — a regression
|
|
7
|
+
* here would silently push private content on the next sync.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import * as fs from "fs";
|
|
12
|
+
import * as os from "os";
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
import { createIgnoreFilter } from "./ignore.js";
|
|
15
|
+
|
|
16
|
+
describe("createIgnoreFilter", () => {
|
|
17
|
+
let hqRoot: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-ignore-test-"));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("permissive mode: regular files sync, defaults are ignored", () => {
|
|
28
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
29
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/notes.md"))).toBe(true);
|
|
30
|
+
expect(shouldSync(path.join(hqRoot, "node_modules/foo/x.js"))).toBe(false);
|
|
31
|
+
expect(shouldSync(path.join(hqRoot, ".env"))).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("permissive mode: .hqignore patterns are honored", () => {
|
|
35
|
+
fs.writeFileSync(path.join(hqRoot, ".hqignore"), "companies/*/data/\n");
|
|
36
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
37
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/data/x.csv"))).toBe(false);
|
|
38
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/notes.md"))).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("allowlist mode: presence of .hqinclude switches to opt-in", () => {
|
|
42
|
+
fs.writeFileSync(
|
|
43
|
+
path.join(hqRoot, ".hqinclude"),
|
|
44
|
+
"companies/*/knowledge/\ncompanies/*/projects/\n",
|
|
45
|
+
);
|
|
46
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
47
|
+
// Allowlisted paths sync.
|
|
48
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/knowledge/foo.md"))).toBe(true);
|
|
49
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/projects/p1/prd.json"))).toBe(true);
|
|
50
|
+
// Anything else stays local — this is the privacy guarantee.
|
|
51
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/data/leads.csv"))).toBe(false);
|
|
52
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/workers/cmo/skill.md"))).toBe(false);
|
|
53
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/settings/aws.json"))).toBe(false);
|
|
54
|
+
expect(shouldSync(path.join(hqRoot, "personal/journal/2026-04-26.md"))).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("allowlist mode: exclusion layers still subtract on top", () => {
|
|
58
|
+
// Even when a subtree is allowlisted, default ignores like node_modules/
|
|
59
|
+
// and .env must still apply. Otherwise an allowlisted subdir would sync
|
|
60
|
+
// gigabytes of dependency junk or leak secret env files.
|
|
61
|
+
fs.writeFileSync(path.join(hqRoot, ".hqinclude"), "companies/*/projects/\n");
|
|
62
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
63
|
+
expect(
|
|
64
|
+
shouldSync(path.join(hqRoot, "companies/indigo/projects/p1/prd.json")),
|
|
65
|
+
).toBe(true);
|
|
66
|
+
expect(
|
|
67
|
+
shouldSync(path.join(hqRoot, "companies/indigo/projects/p1/node_modules/react/index.js")),
|
|
68
|
+
).toBe(false);
|
|
69
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/projects/p1/.env"))).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/ignore.ts
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Ignore-file parser for cloud sync.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
4
|
+
* Two modes:
|
|
5
|
+
* - **Permissive (default)**: everything syncs except what ignore layers
|
|
6
|
+
* subtract. Three layers stack (later overrides earlier):
|
|
7
|
+
* 1. Built-in defaults — VCS, node_modules, build artifacts, caches,
|
|
8
|
+
* env files. Covers the common stacks so a first-time sync over a
|
|
9
|
+
* random project folder doesn't push `target/` or `.next/` to S3.
|
|
10
|
+
* 2. Repo `.gitignore` at hqRoot — reuses existing exclusions so we
|
|
11
|
+
* don't re-list every build directory. Root-level only.
|
|
12
|
+
* 3. `.hqignore` (preferred) or `.hqsyncignore` (legacy) — sync-specific
|
|
13
|
+
* overrides. Use `!pattern` to re-include something earlier layers
|
|
14
|
+
* excluded.
|
|
15
|
+
*
|
|
16
|
+
* - **Allowlist**: triggered when `.hqinclude` exists at hqRoot. Nothing
|
|
17
|
+
* syncs unless its path matches at least one pattern in `.hqinclude`. The
|
|
18
|
+
* three exclusion layers still subtract on top — so even allowlisted
|
|
19
|
+
* subtrees won't push `node_modules/` or `.env`. Privacy-by-default for
|
|
20
|
+
* HQ trees that contain mixed personal + shareable data.
|
|
15
21
|
*/
|
|
16
22
|
|
|
17
23
|
import * as fs from "fs";
|
|
@@ -102,10 +108,21 @@ export function createIgnoreFilter(hqRoot: string): (filePath: string) => boolea
|
|
|
102
108
|
readIgnoreFile(path.join(hqRoot, ".hqsyncignore"));
|
|
103
109
|
if (hqignore) ig.add(hqignore);
|
|
104
110
|
|
|
111
|
+
// Allowlist mode: when `.hqinclude` exists, sync is opt-in. The matcher
|
|
112
|
+
// here treats include patterns as ignore patterns and inverts the verdict —
|
|
113
|
+
// a path is "allowed" iff its relative path matches at least one entry.
|
|
114
|
+
// Exclusion layers above still subtract, so build artifacts inside an
|
|
115
|
+
// allowlisted subtree (e.g. node_modules/ inside companies/x/repos/y/) are
|
|
116
|
+
// still skipped.
|
|
117
|
+
const hqinclude = readIgnoreFile(path.join(hqRoot, ".hqinclude"));
|
|
118
|
+
const includeMatcher = hqinclude ? ignore().add(hqinclude) : null;
|
|
119
|
+
|
|
105
120
|
return (filePath: string): boolean => {
|
|
106
121
|
const relative = path.relative(hqRoot, filePath);
|
|
107
122
|
if (!relative || relative.startsWith("..")) return true; // outside HQ root
|
|
108
|
-
|
|
123
|
+
if (ig.ignores(relative)) return false;
|
|
124
|
+
if (includeMatcher && !includeMatcher.ignores(relative)) return false;
|
|
125
|
+
return true;
|
|
109
126
|
};
|
|
110
127
|
}
|
|
111
128
|
|