@indigoai-us/hq-cloud 5.4.6 → 5.7.1
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 +13 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +14 -2
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +37 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +82 -16
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +102 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +22 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +187 -62
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +81 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/lib/conflict-file.d.ts +46 -0
- package/dist/lib/conflict-file.d.ts.map +1 -0
- package/dist/lib/conflict-file.js +86 -0
- package/dist/lib/conflict-file.js.map +1 -0
- package/dist/lib/conflict-index.d.ts +66 -0
- package/dist/lib/conflict-index.d.ts.map +1 -0
- package/dist/lib/conflict-index.js +112 -0
- package/dist/lib/conflict-index.js.map +1 -0
- package/dist/lib/conflict.test.d.ts +7 -0
- package/dist/lib/conflict.test.d.ts.map +1 -0
- package/dist/lib/conflict.test.js +136 -0
- package/dist/lib/conflict.test.js.map +1 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +43 -0
- package/src/bin/sync-runner.ts +25 -2
- package/src/cli/share.test.ts +125 -0
- package/src/cli/share.ts +133 -18
- package/src/cli/sync.test.ts +97 -0
- package/src/cli/sync.ts +277 -68
- package/src/lib/conflict-file.ts +101 -0
- package/src/lib/conflict-index.ts +127 -0
- package/src/lib/conflict.test.ts +180 -0
- package/src/types.ts +27 -0
package/src/cli/sync.ts
CHANGED
|
@@ -7,13 +7,20 @@
|
|
|
7
7
|
|
|
8
8
|
import * as fs from "fs";
|
|
9
9
|
import * as path from "path";
|
|
10
|
-
import type { VaultServiceConfig } from "../types.js";
|
|
10
|
+
import type { VaultServiceConfig, SyncJournal } from "../types.js";
|
|
11
11
|
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
12
12
|
import { downloadFile, listRemoteFiles } from "../s3.js";
|
|
13
|
+
import type { RemoteFile } from "../s3.js";
|
|
13
14
|
import { readJournal, writeJournal, hashFile, updateEntry, getEntry, normalizeEtag } from "../journal.js";
|
|
14
15
|
import { createIgnoreFilter } from "../ignore.js";
|
|
15
16
|
import { resolveConflict } from "./conflict.js";
|
|
16
17
|
import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
|
|
18
|
+
import {
|
|
19
|
+
buildConflictId,
|
|
20
|
+
buildConflictPath,
|
|
21
|
+
readShortMachineId,
|
|
22
|
+
} from "../lib/conflict-file.js";
|
|
23
|
+
import { appendConflictEntry } from "../lib/conflict-index.js";
|
|
17
24
|
|
|
18
25
|
/**
|
|
19
26
|
* Per-file events emitted by `sync()` as it progresses.
|
|
@@ -25,8 +32,31 @@ import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
|
|
|
25
32
|
*
|
|
26
33
|
* The human CLI (`hq sync`) leaves `onEvent` undefined and falls through to
|
|
27
34
|
* `defaultConsoleLogger` below, which preserves the existing tty output.
|
|
35
|
+
*
|
|
36
|
+
* A single `plan` event is emitted once at the start of every run, before
|
|
37
|
+
* any `progress`/`conflict`/`error` events. It carries the totals derived
|
|
38
|
+
* from a Stage-1 classification pass so consumers can render an accurate
|
|
39
|
+
* progress denominator before transfers begin (the menubar's "Preparing
|
|
40
|
+
* sync…" pre-pass becomes obsolete once the runner forwards this).
|
|
28
41
|
*/
|
|
29
42
|
export type SyncProgressEvent =
|
|
43
|
+
| {
|
|
44
|
+
type: "plan";
|
|
45
|
+
/** Files this run intends to download (pull-only; 0 from share). */
|
|
46
|
+
filesToDownload: number;
|
|
47
|
+
bytesToDownload: number;
|
|
48
|
+
/** Files this run intends to upload (push-only; 0 from sync). */
|
|
49
|
+
filesToUpload: number;
|
|
50
|
+
bytesToUpload: number;
|
|
51
|
+
/** Files classified as no-op (ignored, unchanged, local-only on pull). */
|
|
52
|
+
filesToSkip: number;
|
|
53
|
+
/**
|
|
54
|
+
* Files known up-front to be conflicts. Pull-side fills this from the
|
|
55
|
+
* 3-way merge against the journal; push-side leaves it 0 because
|
|
56
|
+
* conflict detection requires a remote HEAD that runs in Stage 2.
|
|
57
|
+
*/
|
|
58
|
+
filesToConflict: number;
|
|
59
|
+
}
|
|
30
60
|
| { type: "progress"; path: string; bytes: number; message?: string }
|
|
31
61
|
| { type: "error"; path: string; message: string }
|
|
32
62
|
| {
|
|
@@ -123,90 +153,130 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
123
153
|
// List all remote files (IAM session policy filters at the AWS layer)
|
|
124
154
|
const remoteFiles = await listRemoteFiles(ctx);
|
|
125
155
|
|
|
126
|
-
|
|
127
|
-
|
|
156
|
+
// Stage 1: classify every remote file against the journal + local disk.
|
|
157
|
+
// Hashing happens here (not in the transfer loop) so the plan event below
|
|
158
|
+
// carries an accurate denominator before any progress events fire.
|
|
159
|
+
const plan = computePullPlan(
|
|
160
|
+
remoteFiles,
|
|
161
|
+
journal,
|
|
162
|
+
companyRoot,
|
|
163
|
+
shouldSync,
|
|
164
|
+
options.personalMode === true,
|
|
165
|
+
);
|
|
128
166
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
167
|
+
emit({
|
|
168
|
+
type: "plan",
|
|
169
|
+
filesToDownload: plan.filesToDownload,
|
|
170
|
+
bytesToDownload: plan.bytesToDownload,
|
|
171
|
+
// sync() is pull-only; push counts are sourced from share()'s plan event.
|
|
172
|
+
filesToUpload: 0,
|
|
173
|
+
bytesToUpload: 0,
|
|
174
|
+
filesToSkip: plan.filesToSkip,
|
|
175
|
+
filesToConflict: plan.filesToConflict,
|
|
176
|
+
});
|
|
133
177
|
|
|
134
|
-
|
|
135
|
-
|
|
178
|
+
// Stage 2: execute the plan. Per-item branching mirrors the pre-refactor
|
|
179
|
+
// inline loop; the only structural change is that classification has
|
|
180
|
+
// already happened (so `localHash` is reused instead of re-hashing).
|
|
181
|
+
for (const item of plan.items) {
|
|
182
|
+
if (
|
|
183
|
+
item.action === "skip-ignored" ||
|
|
184
|
+
item.action === "skip-personal-mode" ||
|
|
185
|
+
item.action === "skip-unchanged" ||
|
|
186
|
+
item.action === "skip-local-only"
|
|
187
|
+
) {
|
|
136
188
|
filesSkipped++;
|
|
137
189
|
continue;
|
|
138
190
|
}
|
|
139
191
|
|
|
140
|
-
|
|
192
|
+
const { remoteFile, localPath } = item;
|
|
193
|
+
|
|
194
|
+
// Auto-refresh context if credentials expiring (kept in execute phase
|
|
195
|
+
// because Stage 1 is fast — no need to refresh just to classify).
|
|
141
196
|
if (isExpiringSoon(ctx.expiresAt)) {
|
|
142
197
|
ctx = await refreshEntityContext(companyRef, vaultConfig);
|
|
143
198
|
}
|
|
144
199
|
|
|
145
|
-
|
|
146
|
-
|
|
200
|
+
if (item.action === "conflict") {
|
|
201
|
+
conflicts++;
|
|
202
|
+
conflictPaths.push(remoteFile.key);
|
|
147
203
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const localChanged = !!journalEntry && journalEntry.hash !== localHash;
|
|
151
|
-
const remoteChanged = !!journalEntry && hasRemoteChanged(remoteFile, journalEntry);
|
|
152
|
-
|
|
153
|
-
// A real conflict requires BOTH sides to have moved since the last
|
|
154
|
-
// sync. If only local changed, push will handle it; pulling here would
|
|
155
|
-
// clobber the local edit. If only remote changed, fall through to
|
|
156
|
-
// download. If neither moved, skip.
|
|
157
|
-
if (localChanged && remoteChanged) {
|
|
158
|
-
conflicts++;
|
|
159
|
-
conflictPaths.push(remoteFile.key);
|
|
160
|
-
|
|
161
|
-
const resolution = await resolveConflict(
|
|
162
|
-
{
|
|
163
|
-
path: remoteFile.key,
|
|
164
|
-
localHash,
|
|
165
|
-
remoteModified: remoteFile.lastModified,
|
|
166
|
-
localModified: fs.statSync(localPath).mtime,
|
|
167
|
-
direction: "pull",
|
|
168
|
-
},
|
|
169
|
-
onConflict,
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
emit({
|
|
173
|
-
type: "conflict",
|
|
204
|
+
const resolution = await resolveConflict(
|
|
205
|
+
{
|
|
174
206
|
path: remoteFile.key,
|
|
207
|
+
localHash: item.localHash,
|
|
208
|
+
remoteModified: remoteFile.lastModified,
|
|
209
|
+
localModified: fs.statSync(localPath).mtime,
|
|
175
210
|
direction: "pull",
|
|
176
|
-
|
|
177
|
-
|
|
211
|
+
},
|
|
212
|
+
onConflict,
|
|
213
|
+
);
|
|
178
214
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
215
|
+
emit({
|
|
216
|
+
type: "conflict",
|
|
217
|
+
path: remoteFile.key,
|
|
218
|
+
direction: "pull",
|
|
219
|
+
resolution,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Write `<original>.conflict-<ts>-<machine>.<ext>` mirror + append to
|
|
223
|
+
// `<hqRoot>/.hq-conflicts/index.json` so the user can later run
|
|
224
|
+
// `/resolve-conflicts` to walk pending conflicts. Skipped for "abort"
|
|
225
|
+
// (user gave up) and "overwrite" (cloud bytes are about to replace
|
|
226
|
+
// local — mirror would be redundant). Best-effort: failure here only
|
|
227
|
+
// emits an error, doesn't break the sync.
|
|
228
|
+
if (resolution !== "abort" && resolution !== "overwrite") {
|
|
229
|
+
try {
|
|
230
|
+
const detectedAt = new Date().toISOString();
|
|
231
|
+
const machineId = readShortMachineId();
|
|
232
|
+
const originalRelative = path.relative(hqRoot, localPath);
|
|
233
|
+
const conflictRelative = buildConflictPath(
|
|
234
|
+
originalRelative,
|
|
235
|
+
detectedAt,
|
|
236
|
+
machineId,
|
|
237
|
+
);
|
|
238
|
+
const conflictAbs = path.join(hqRoot, conflictRelative);
|
|
239
|
+
await downloadFile(ctx, remoteFile.key, conflictAbs);
|
|
240
|
+
appendConflictEntry(hqRoot, {
|
|
241
|
+
id: buildConflictId(originalRelative, detectedAt),
|
|
242
|
+
originalPath: originalRelative,
|
|
243
|
+
conflictPath: conflictRelative,
|
|
244
|
+
detectedAt,
|
|
245
|
+
side: "pull",
|
|
246
|
+
machineId,
|
|
247
|
+
localHash: item.localHash,
|
|
248
|
+
remoteHash: remoteFile.etag ? normalizeEtag(remoteFile.etag) : "",
|
|
249
|
+
});
|
|
250
|
+
} catch (mirrorErr) {
|
|
251
|
+
emit({
|
|
252
|
+
type: "error",
|
|
253
|
+
path: remoteFile.key,
|
|
254
|
+
message: `conflict mirror write failed: ${
|
|
255
|
+
mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)
|
|
256
|
+
}`,
|
|
257
|
+
});
|
|
193
258
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (resolution === "abort") {
|
|
262
|
+
writeJournal(journalSlug, journal);
|
|
263
|
+
return {
|
|
264
|
+
filesDownloaded,
|
|
265
|
+
bytesDownloaded,
|
|
266
|
+
filesSkipped,
|
|
267
|
+
conflicts,
|
|
268
|
+
conflictPaths,
|
|
269
|
+
aborted: true,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
if (resolution === "keep" || resolution === "skip") {
|
|
202
273
|
filesSkipped++;
|
|
203
274
|
continue;
|
|
204
275
|
}
|
|
205
|
-
//
|
|
206
|
-
// to download.
|
|
276
|
+
// "overwrite" falls through to download
|
|
207
277
|
}
|
|
208
278
|
|
|
209
|
-
// Download
|
|
279
|
+
// Download (action === "download" or conflict resolved to "overwrite")
|
|
210
280
|
try {
|
|
211
281
|
await downloadFile(ctx, remoteFile.key, localPath);
|
|
212
282
|
|
|
@@ -216,8 +286,10 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
216
286
|
// drift independently of mtime drift.
|
|
217
287
|
updateEntry(journal, remoteFile.key, hash, stat.size, "down", remoteFile.etag);
|
|
218
288
|
|
|
219
|
-
// Attach message from journal entry if present
|
|
220
|
-
|
|
289
|
+
// Attach message from the prior journal entry if present (set by a
|
|
290
|
+
// previous `share` operation that included a --message).
|
|
291
|
+
const priorEntry = getEntry(journal, remoteFile.key);
|
|
292
|
+
const remoteJournalMessage = (priorEntry as { message?: string } | undefined)?.message;
|
|
221
293
|
emit({
|
|
222
294
|
type: "progress",
|
|
223
295
|
path: remoteFile.key,
|
|
@@ -287,6 +359,124 @@ function hasRemoteChanged(
|
|
|
287
359
|
return remote.lastModified.getTime() > syncedAt;
|
|
288
360
|
}
|
|
289
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Stage-1 classification for a single remote object. Each remote file falls
|
|
364
|
+
* into exactly one bucket; the executor in `sync()` switches on `action` to
|
|
365
|
+
* decide what to do. `localHash` is carried on `conflict` items so the
|
|
366
|
+
* executor can hand it to `resolveConflict` without re-hashing.
|
|
367
|
+
*/
|
|
368
|
+
type PullPlanItem =
|
|
369
|
+
| { action: "download"; remoteFile: RemoteFile; localPath: string }
|
|
370
|
+
| { action: "skip-ignored"; remoteFile: RemoteFile; localPath: string }
|
|
371
|
+
| { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
|
|
372
|
+
| { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
|
|
373
|
+
| { action: "skip-local-only"; remoteFile: RemoteFile; localPath: string }
|
|
374
|
+
| {
|
|
375
|
+
action: "conflict";
|
|
376
|
+
remoteFile: RemoteFile;
|
|
377
|
+
localPath: string;
|
|
378
|
+
localHash: string;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
interface PullPlan {
|
|
382
|
+
items: PullPlanItem[];
|
|
383
|
+
filesToDownload: number;
|
|
384
|
+
bytesToDownload: number;
|
|
385
|
+
filesToSkip: number;
|
|
386
|
+
filesToConflict: number;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Stage-1 planning pass: classify every remote file into download / skip /
|
|
391
|
+
* conflict buckets without performing any S3 transfers. Local hashes are
|
|
392
|
+
* computed here (not in the transfer loop) so the totals returned reflect
|
|
393
|
+
* the real outcome of the upcoming Stage-2 execution rather than an
|
|
394
|
+
* upper-bound guess.
|
|
395
|
+
*
|
|
396
|
+
* Pure function: no S3 calls, no journal writes, no event emission. The
|
|
397
|
+
* caller (`sync()`) is responsible for emitting the resulting plan event
|
|
398
|
+
* before iterating `items`.
|
|
399
|
+
*/
|
|
400
|
+
function computePullPlan(
|
|
401
|
+
remoteFiles: RemoteFile[],
|
|
402
|
+
journal: SyncJournal,
|
|
403
|
+
companyRoot: string,
|
|
404
|
+
shouldSync: (filePath: string) => boolean,
|
|
405
|
+
personalMode: boolean,
|
|
406
|
+
): PullPlan {
|
|
407
|
+
const items: PullPlanItem[] = [];
|
|
408
|
+
|
|
409
|
+
for (const remoteFile of remoteFiles) {
|
|
410
|
+
const localPath = path.join(companyRoot, remoteFile.key);
|
|
411
|
+
|
|
412
|
+
if (personalMode && remoteFile.key.startsWith("companies/")) {
|
|
413
|
+
items.push({ action: "skip-personal-mode", remoteFile, localPath });
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (!shouldSync(localPath)) {
|
|
418
|
+
items.push({ action: "skip-ignored", remoteFile, localPath });
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const journalEntry = getEntry(journal, remoteFile.key);
|
|
423
|
+
|
|
424
|
+
if (fs.existsSync(localPath)) {
|
|
425
|
+
const localHash = hashFile(localPath);
|
|
426
|
+
const localChanged = !!journalEntry && journalEntry.hash !== localHash;
|
|
427
|
+
const remoteChanged =
|
|
428
|
+
!!journalEntry && hasRemoteChanged(remoteFile, journalEntry);
|
|
429
|
+
|
|
430
|
+
// Mirror the original 3-way merge from the inline loop. Tested by
|
|
431
|
+
// `does NOT flag a pull conflict when only local changed since last
|
|
432
|
+
// sync` and `detects conflicts with local changes…`.
|
|
433
|
+
if (localChanged && remoteChanged) {
|
|
434
|
+
items.push({
|
|
435
|
+
action: "conflict",
|
|
436
|
+
remoteFile,
|
|
437
|
+
localPath,
|
|
438
|
+
localHash,
|
|
439
|
+
});
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (journalEntry && localChanged && !remoteChanged) {
|
|
443
|
+
items.push({ action: "skip-local-only", remoteFile, localPath });
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (journalEntry && !localChanged && !remoteChanged) {
|
|
447
|
+
items.push({ action: "skip-unchanged", remoteFile, localPath });
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
// No journal entry, or remote-only changed → fall through to download.
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
items.push({ action: "download", remoteFile, localPath });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
let filesToDownload = 0;
|
|
457
|
+
let bytesToDownload = 0;
|
|
458
|
+
let filesToSkip = 0;
|
|
459
|
+
let filesToConflict = 0;
|
|
460
|
+
for (const item of items) {
|
|
461
|
+
if (item.action === "download") {
|
|
462
|
+
filesToDownload++;
|
|
463
|
+
bytesToDownload += item.remoteFile.size;
|
|
464
|
+
} else if (item.action === "conflict") {
|
|
465
|
+
filesToConflict++;
|
|
466
|
+
} else {
|
|
467
|
+
filesToSkip++;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
items,
|
|
473
|
+
filesToDownload,
|
|
474
|
+
bytesToDownload,
|
|
475
|
+
filesToSkip,
|
|
476
|
+
filesToConflict,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
290
480
|
/**
|
|
291
481
|
* Check if an error is an S3 access denied (expected for filtered guests).
|
|
292
482
|
*/
|
|
@@ -303,7 +493,26 @@ function isAccessDenied(err: unknown): boolean {
|
|
|
303
493
|
* without an `onEvent` see no behavioral change.
|
|
304
494
|
*/
|
|
305
495
|
function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
306
|
-
if (event.type === "
|
|
496
|
+
if (event.type === "plan") {
|
|
497
|
+
// Terse single line so humans see what's about to happen without
|
|
498
|
+
// drowning the per-file output that follows. Skip when there's
|
|
499
|
+
// nothing to do — no signal, no noise.
|
|
500
|
+
const movement = event.filesToDownload + event.filesToUpload + event.filesToConflict;
|
|
501
|
+
if (movement > 0) {
|
|
502
|
+
const parts: string[] = [];
|
|
503
|
+
if (event.filesToDownload > 0) {
|
|
504
|
+
parts.push(`${event.filesToDownload} to download (${event.bytesToDownload} bytes)`);
|
|
505
|
+
}
|
|
506
|
+
if (event.filesToUpload > 0) {
|
|
507
|
+
parts.push(`${event.filesToUpload} to upload (${event.bytesToUpload} bytes)`);
|
|
508
|
+
}
|
|
509
|
+
if (event.filesToConflict > 0) {
|
|
510
|
+
parts.push(`${event.filesToConflict} conflict(s)`);
|
|
511
|
+
}
|
|
512
|
+
parts.push(`${event.filesToSkip} unchanged`);
|
|
513
|
+
console.log(`Plan: ${parts.join(", ")}`);
|
|
514
|
+
}
|
|
515
|
+
} else if (event.type === "progress") {
|
|
307
516
|
if (event.message) {
|
|
308
517
|
console.log(` ✓ ${event.path} — "${event.message}"`);
|
|
309
518
|
} else {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conflict file naming + writing.
|
|
3
|
+
*
|
|
4
|
+
* When share/sync detects divergence, the cloud's version of the file is
|
|
5
|
+
* written next to the original with a name encoding the timestamp and the
|
|
6
|
+
* machine that detected the conflict. Lets multiple machines independently
|
|
7
|
+
* surface their own conflicts without name collisions, and lets the user
|
|
8
|
+
* (or the `/resolve-conflicts` HQ skill) see local + cloud side-by-side
|
|
9
|
+
* in their file browser.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import * as os from "os";
|
|
14
|
+
import * as path from "path";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Path to `~/.hq/menubar.json`. Evaluated lazily at call time (not module
|
|
18
|
+
* load) so that tests overriding `HOME` after import — and any future code
|
|
19
|
+
* that changes the user's effective home dir at runtime — see the right
|
|
20
|
+
* file. Going through `os.homedir()` rather than `process.env.HOME` keeps
|
|
21
|
+
* the Windows USERPROFILE fallback intact.
|
|
22
|
+
*/
|
|
23
|
+
function menubarJsonPath(): string {
|
|
24
|
+
return path.join(os.homedir(), ".hq", "menubar.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read the short machine ID (first 6 chars) from `~/.hq/menubar.json`.
|
|
29
|
+
* Falls back to "unknown" if the file is missing/unreadable — conflict
|
|
30
|
+
* files should still be written even when machine identity is unclear.
|
|
31
|
+
*/
|
|
32
|
+
export function readShortMachineId(): string {
|
|
33
|
+
try {
|
|
34
|
+
const raw = fs.readFileSync(menubarJsonPath(), "utf-8");
|
|
35
|
+
const parsed = JSON.parse(raw);
|
|
36
|
+
const id = typeof parsed.machineId === "string" ? parsed.machineId : "";
|
|
37
|
+
return id.slice(0, 6) || "unknown";
|
|
38
|
+
} catch {
|
|
39
|
+
return "unknown";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build the conflict file path for an original. ISO uses `-` instead of
|
|
45
|
+
* `:` so the result is filesystem-safe on every OS, and the original
|
|
46
|
+
* extension is preserved at the end so editors syntax-highlight correctly.
|
|
47
|
+
*
|
|
48
|
+
* knowledge/notes.md, 2026-04-27T22:05:14Z, abc123
|
|
49
|
+
* → knowledge/notes.md.conflict-2026-04-27T22-05-14Z-abc123.md
|
|
50
|
+
*
|
|
51
|
+
* projects/foo/prd.json, ..., abc123
|
|
52
|
+
* → projects/foo/prd.json.conflict-...-abc123.json
|
|
53
|
+
*
|
|
54
|
+
* Files without an extension get the `.conflict-...` suffix appended verbatim.
|
|
55
|
+
*/
|
|
56
|
+
export function buildConflictPath(
|
|
57
|
+
originalRelative: string,
|
|
58
|
+
detectedAt: string,
|
|
59
|
+
shortMachineId: string,
|
|
60
|
+
): string {
|
|
61
|
+
const safeTs = detectedAt.replace(/:/g, "-").replace(/\.\d+/, "");
|
|
62
|
+
const ext = path.extname(originalRelative); // ".md" or "" if none
|
|
63
|
+
// The full original path is preserved (extension and all) so users can
|
|
64
|
+
// visually pair `notes.md` with `notes.md.conflict-…md` in their file
|
|
65
|
+
// browser. The trailing `<ext>` after the timestamp keeps the file
|
|
66
|
+
// syntax-highlighted in editors that key off the final extension.
|
|
67
|
+
const suffix = `.conflict-${safeTs}-${shortMachineId}${ext}`;
|
|
68
|
+
return `${originalRelative}${suffix}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Write the cloud-side bytes to the conflict path. Creates parent dirs as
|
|
73
|
+
* needed (the conflict file always lives next to the original, so the
|
|
74
|
+
* parent already exists in the steady-state — but defense-in-depth).
|
|
75
|
+
*/
|
|
76
|
+
export function writeConflictFile(
|
|
77
|
+
hqRoot: string,
|
|
78
|
+
conflictRelative: string,
|
|
79
|
+
contents: Buffer,
|
|
80
|
+
): void {
|
|
81
|
+
const abs = path.join(hqRoot, conflictRelative);
|
|
82
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
83
|
+
fs.writeFileSync(abs, contents);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Stable conflict ID — used to dedupe re-detections of the same conflict.
|
|
88
|
+
* Re-running sync after a conflict but before the user has resolved should
|
|
89
|
+
* NOT pile up duplicate entries. The id is derived from the original path
|
|
90
|
+
* and the detection timestamp; if the same original conflicts twice with
|
|
91
|
+
* the user resolving in between, that's a new id (different timestamp),
|
|
92
|
+
* which is correct.
|
|
93
|
+
*/
|
|
94
|
+
export function buildConflictId(
|
|
95
|
+
originalRelative: string,
|
|
96
|
+
detectedAt: string,
|
|
97
|
+
): string {
|
|
98
|
+
const safeTs = detectedAt.replace(/:/g, "-").replace(/\.\d+/, "");
|
|
99
|
+
const safePath = originalRelative.replace(/[\/\\.]/g, "-");
|
|
100
|
+
return `${safePath}-${safeTs}`;
|
|
101
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conflict index — durable record of pending divergences awaiting resolution.
|
|
3
|
+
*
|
|
4
|
+
* Lives at `<hq_root>/.hq-conflicts/index.json` (inside HQ content, NOT in
|
|
5
|
+
* `~/.hq/`). Two reasons it sits in HQ content rather than in the state dir:
|
|
6
|
+
* 1. The `/resolve-conflicts` HQ skill discovers it relative to the user's
|
|
7
|
+
* HQ folder — that's the user's mental anchor for "where my files are."
|
|
8
|
+
* 2. The conflict-side files themselves live in HQ content, so the index
|
|
9
|
+
* and the files it references stay co-located. If the user moves HQ,
|
|
10
|
+
* the index moves with it.
|
|
11
|
+
*
|
|
12
|
+
* Excluded from cross-machine sync via `.hqignore` — each machine resolves
|
|
13
|
+
* its own queue. We never propagate conflict files (they'd just create more
|
|
14
|
+
* conflicts on the other side).
|
|
15
|
+
*
|
|
16
|
+
* Writes are atomic (tmp + rename). The resolution skill mutates this file
|
|
17
|
+
* mid-walk; a torn write would corrupt the only record of pending conflicts
|
|
18
|
+
* and could lose track of files we'd written to disk. Higher stakes than the
|
|
19
|
+
* journal, which the next sync can rebuild.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as crypto from "crypto";
|
|
23
|
+
import * as fs from "fs";
|
|
24
|
+
import * as path from "path";
|
|
25
|
+
import type { ConflictIndex, ConflictIndexEntry } from "../types.js";
|
|
26
|
+
|
|
27
|
+
const CONFLICTS_DIR = ".hq-conflicts";
|
|
28
|
+
const INDEX_FILENAME = "index.json";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Absolute path to the conflict index for a given HQ root.
|
|
32
|
+
*/
|
|
33
|
+
export function getConflictIndexPath(hqRoot: string): string {
|
|
34
|
+
return path.join(hqRoot, CONFLICTS_DIR, INDEX_FILENAME);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Read the conflict index. Returns an empty index if the file doesn't exist
|
|
39
|
+
* yet (first-conflict-ever case).
|
|
40
|
+
*
|
|
41
|
+
* Throws on corrupt JSON — we deliberately don't auto-repair, since the only
|
|
42
|
+
* record of pending conflicts is too important to silently overwrite. The
|
|
43
|
+
* `/resolve-conflicts` skill surfaces this case to the user with a manual
|
|
44
|
+
* inspection prompt.
|
|
45
|
+
*/
|
|
46
|
+
export function readConflictIndex(hqRoot: string): ConflictIndex {
|
|
47
|
+
const indexPath = getConflictIndexPath(hqRoot);
|
|
48
|
+
if (!fs.existsSync(indexPath)) {
|
|
49
|
+
return { version: 1, conflicts: [] };
|
|
50
|
+
}
|
|
51
|
+
const raw = fs.readFileSync(indexPath, "utf-8");
|
|
52
|
+
const parsed = JSON.parse(raw) as ConflictIndex;
|
|
53
|
+
// Defensive: an empty file or wrong-shape JSON shouldn't crash callers.
|
|
54
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.conflicts)) {
|
|
55
|
+
return { version: 1, conflicts: [] };
|
|
56
|
+
}
|
|
57
|
+
return parsed;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Atomically write the conflict index. Writes to `<index>.tmp.<random>` then
|
|
62
|
+
* renames into place — `rename(2)` is atomic on POSIX, so a crash mid-write
|
|
63
|
+
* leaves either the old file or the new one, never a half-written one.
|
|
64
|
+
*
|
|
65
|
+
* Always sorts conflicts by `detectedAt` ascending before writing — keeps
|
|
66
|
+
* the file diff-friendly across runs and makes "oldest-first walk" the
|
|
67
|
+
* natural read order in the resolution skill.
|
|
68
|
+
*/
|
|
69
|
+
export function writeConflictIndex(
|
|
70
|
+
hqRoot: string,
|
|
71
|
+
index: ConflictIndex,
|
|
72
|
+
): void {
|
|
73
|
+
const indexPath = getConflictIndexPath(hqRoot);
|
|
74
|
+
fs.mkdirSync(path.dirname(indexPath), { recursive: true });
|
|
75
|
+
|
|
76
|
+
const sorted: ConflictIndex = {
|
|
77
|
+
version: index.version,
|
|
78
|
+
conflicts: [...index.conflicts].sort((a, b) =>
|
|
79
|
+
a.detectedAt.localeCompare(b.detectedAt),
|
|
80
|
+
),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Random suffix in tmp name avoids collision if two sync runs ever overlap
|
|
84
|
+
// (shouldn't happen — the runner serializes — but cheap insurance).
|
|
85
|
+
const tmpPath = `${indexPath}.tmp.${crypto.randomBytes(6).toString("hex")}`;
|
|
86
|
+
fs.writeFileSync(tmpPath, JSON.stringify(sorted, null, 2));
|
|
87
|
+
fs.renameSync(tmpPath, indexPath);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Idempotent append. If an entry with the same `id` already exists (same
|
|
92
|
+
* original path, same detection timestamp), update it in place rather than
|
|
93
|
+
* duplicating. This matters because re-running sync after a conflict but
|
|
94
|
+
* before resolution will re-detect the same divergence — without dedup the
|
|
95
|
+
* index would grow unboundedly.
|
|
96
|
+
*
|
|
97
|
+
* The "update in place" path also covers the case where the cloud advanced
|
|
98
|
+
* again between detections: we want the latest `remoteVersionId` and
|
|
99
|
+
* `remoteHash` so the resolution skill shows the user the *current* cloud
|
|
100
|
+
* state, not stale data from the first detection.
|
|
101
|
+
*/
|
|
102
|
+
export function appendConflictEntry(
|
|
103
|
+
hqRoot: string,
|
|
104
|
+
entry: ConflictIndexEntry,
|
|
105
|
+
): void {
|
|
106
|
+
const index = readConflictIndex(hqRoot);
|
|
107
|
+
const existingIdx = index.conflicts.findIndex((c) => c.id === entry.id);
|
|
108
|
+
if (existingIdx >= 0) {
|
|
109
|
+
index.conflicts[existingIdx] = entry;
|
|
110
|
+
} else {
|
|
111
|
+
index.conflicts.push(entry);
|
|
112
|
+
}
|
|
113
|
+
writeConflictIndex(hqRoot, index);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Remove an entry by id. Used by the `/resolve-conflicts` skill after the
|
|
118
|
+
* user picks a resolution and the conflict file is cleaned up. No-op if the
|
|
119
|
+
* id isn't present (e.g. user manually removed the file then re-ran the
|
|
120
|
+
* skill — we want that to be a clean exit, not an error).
|
|
121
|
+
*/
|
|
122
|
+
export function removeConflictEntry(hqRoot: string, id: string): void {
|
|
123
|
+
const index = readConflictIndex(hqRoot);
|
|
124
|
+
const filtered = index.conflicts.filter((c) => c.id !== id);
|
|
125
|
+
if (filtered.length === index.conflicts.length) return;
|
|
126
|
+
writeConflictIndex(hqRoot, { version: index.version, conflicts: filtered });
|
|
127
|
+
}
|