@indigoai-us/hq-cloud 5.23.0 → 5.25.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 +58 -3
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +84 -2
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +90 -3
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +86 -20
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +332 -62
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +490 -6
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +48 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/personal-vault-exclusions.d.ts +128 -0
- package/dist/personal-vault-exclusions.d.ts.map +1 -0
- package/dist/personal-vault-exclusions.js +231 -0
- package/dist/personal-vault-exclusions.js.map +1 -0
- package/dist/personal-vault-exclusions.test.d.ts +22 -0
- package/dist/personal-vault-exclusions.test.d.ts.map +1 -0
- package/dist/personal-vault-exclusions.test.js +198 -0
- package/dist/personal-vault-exclusions.test.js.map +1 -0
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +113 -3
- package/src/bin/sync-runner.ts +125 -5
- package/src/cli/share.test.ts +585 -6
- package/src/cli/share.ts +461 -86
- package/src/cli/sync.ts +50 -0
- package/src/index.ts +10 -0
- package/src/personal-vault-exclusions.test.ts +256 -0
- package/src/personal-vault-exclusions.ts +277 -0
package/src/cli/share.test.ts
CHANGED
|
@@ -23,7 +23,7 @@ vi.mock("../s3.js", () => ({
|
|
|
23
23
|
headRemoteFile: vi.fn().mockResolvedValue(null),
|
|
24
24
|
}));
|
|
25
25
|
|
|
26
|
-
import { share } from "./share.js";
|
|
26
|
+
import { share, _testing as shareTesting } from "./share.js";
|
|
27
27
|
import { deleteRemoteFile, headRemoteFile, uploadFile, uploadSymlink } from "../s3.js";
|
|
28
28
|
import type { EntityContext } from "../types.js";
|
|
29
29
|
|
|
@@ -595,9 +595,14 @@ describe("share", () => {
|
|
|
595
595
|
vaultConfig: mockConfig,
|
|
596
596
|
hqRoot: tmpDir,
|
|
597
597
|
onEvent: (e) => {
|
|
598
|
-
// Only file-level events carry `.path`. The Stage-1 `plan` event
|
|
599
|
-
//
|
|
600
|
-
|
|
598
|
+
// Only file-level events carry `.path`. The Stage-1 `plan` event +
|
|
599
|
+
// the new-files event + the personal-vault-out-of-policy summary
|
|
600
|
+
// event are surfaced separately and tested in their own blocks.
|
|
601
|
+
if (
|
|
602
|
+
e.type === "plan" ||
|
|
603
|
+
e.type === "new-files" ||
|
|
604
|
+
e.type === "personal-vault-out-of-policy"
|
|
605
|
+
) return;
|
|
601
606
|
events.push({
|
|
602
607
|
type: e.type,
|
|
603
608
|
path: e.path,
|
|
@@ -915,6 +920,10 @@ describe("share", () => {
|
|
|
915
920
|
hqRoot: tmpDir,
|
|
916
921
|
skipUnchanged: true,
|
|
917
922
|
propagateDeletes: true,
|
|
923
|
+
// Pin owned-only — this test predates the currency-gated default and
|
|
924
|
+
// asserts the direction-of-origin branch. A separate currency-gated
|
|
925
|
+
// test covers the new default semantics for the same scenario.
|
|
926
|
+
propagateDeletePolicy: "owned-only",
|
|
918
927
|
});
|
|
919
928
|
|
|
920
929
|
expect(result.filesDeleted).toBe(1);
|
|
@@ -965,6 +974,9 @@ describe("share", () => {
|
|
|
965
974
|
hqRoot: tmpDir,
|
|
966
975
|
skipUnchanged: true,
|
|
967
976
|
propagateDeletes: true,
|
|
977
|
+
// Owned-only — predates currency-gated default; asserts the lstat
|
|
978
|
+
// guard which is independent of the policy branch.
|
|
979
|
+
propagateDeletePolicy: "owned-only",
|
|
968
980
|
});
|
|
969
981
|
|
|
970
982
|
expect(result.filesDeleted).toBe(0);
|
|
@@ -1013,6 +1025,8 @@ describe("share", () => {
|
|
|
1013
1025
|
hqRoot: tmpDir,
|
|
1014
1026
|
skipUnchanged: true,
|
|
1015
1027
|
propagateDeletes: true,
|
|
1028
|
+
// Owned-only — currency-gated semantics covered separately below.
|
|
1029
|
+
propagateDeletePolicy: "owned-only",
|
|
1016
1030
|
});
|
|
1017
1031
|
|
|
1018
1032
|
expect(result.filesDeleted).toBe(1);
|
|
@@ -1054,6 +1068,9 @@ describe("share", () => {
|
|
|
1054
1068
|
hqRoot: tmpDir,
|
|
1055
1069
|
skipUnchanged: true,
|
|
1056
1070
|
propagateDeletes: true,
|
|
1071
|
+
// Owned-only — currency-gated emits a separate event variant; this test
|
|
1072
|
+
// pins the legacy progress-with-deleted-flag shape.
|
|
1073
|
+
propagateDeletePolicy: "owned-only",
|
|
1057
1074
|
onEvent: (e) => events.push(e as { type: string }),
|
|
1058
1075
|
});
|
|
1059
1076
|
|
|
@@ -1143,6 +1160,9 @@ describe("share", () => {
|
|
|
1143
1160
|
hqRoot: tmpDir,
|
|
1144
1161
|
skipUnchanged: true,
|
|
1145
1162
|
propagateDeletes: true,
|
|
1163
|
+
// Owned-only — this test asserts scope containment, independent of
|
|
1164
|
+
// the policy branch. Currency-gated covered separately.
|
|
1165
|
+
propagateDeletePolicy: "owned-only",
|
|
1146
1166
|
});
|
|
1147
1167
|
|
|
1148
1168
|
expect(result.filesDeleted).toBe(1);
|
|
@@ -1190,6 +1210,9 @@ describe("share", () => {
|
|
|
1190
1210
|
hqRoot: tmpDir,
|
|
1191
1211
|
skipUnchanged: true,
|
|
1192
1212
|
propagateDeletes: true,
|
|
1213
|
+
// Owned-only — pinning so the retry-survival assertion is independent
|
|
1214
|
+
// of currency-gated's HEAD-driven bucketing.
|
|
1215
|
+
propagateDeletePolicy: "owned-only",
|
|
1193
1216
|
onEvent: (e) => events.push(e as { type: string }),
|
|
1194
1217
|
});
|
|
1195
1218
|
|
|
@@ -1227,7 +1250,7 @@ describe("share", () => {
|
|
|
1227
1250
|
// (i) `direction === "up"` requirement under the default policy.
|
|
1228
1251
|
// (ii) `shouldSync` must accept the key — same filter the pull uses.
|
|
1229
1252
|
|
|
1230
|
-
it("propagateDeletes: under owned-only (default), skips direction:'down' entries", async () => {
|
|
1253
|
+
it("propagateDeletes: under owned-only (legacy pre-5.24 default), skips direction:'down' entries", async () => {
|
|
1231
1254
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1232
1255
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1233
1256
|
// No local files. One journal entry pulled from elsewhere (direction:'down')
|
|
@@ -1259,7 +1282,8 @@ describe("share", () => {
|
|
|
1259
1282
|
hqRoot: tmpDir,
|
|
1260
1283
|
skipUnchanged: true,
|
|
1261
1284
|
propagateDeletes: true,
|
|
1262
|
-
//
|
|
1285
|
+
// Explicit opt-in — `currency-gated` is the new (5.24+) default.
|
|
1286
|
+
propagateDeletePolicy: "owned-only",
|
|
1263
1287
|
});
|
|
1264
1288
|
|
|
1265
1289
|
// Only the 'up' entry is deleted; the 'down' entry is left alone so a
|
|
@@ -1338,6 +1362,10 @@ describe("share", () => {
|
|
|
1338
1362
|
hqRoot: tmpDir,
|
|
1339
1363
|
skipUnchanged: true,
|
|
1340
1364
|
propagateDeletes: true,
|
|
1365
|
+
// Owned-only — filter symmetry is policy-independent; pinning so the
|
|
1366
|
+
// legacy direction-of-origin gate (not currency-gated's HEAD path)
|
|
1367
|
+
// resolves the "delete vs skip" decision for active/current.md.
|
|
1368
|
+
propagateDeletePolicy: "owned-only",
|
|
1341
1369
|
});
|
|
1342
1370
|
|
|
1343
1371
|
// legacy/old-layout.md is filter-skipped; only active/current.md is
|
|
@@ -1347,6 +1375,420 @@ describe("share", () => {
|
|
|
1347
1375
|
expect(deleteRemoteFile).not.toHaveBeenCalledWith(expect.anything(), "legacy/old-layout.md");
|
|
1348
1376
|
});
|
|
1349
1377
|
|
|
1378
|
+
// ── Delete propagation: currency-gated policy (5.24+ default) ──────────────
|
|
1379
|
+
//
|
|
1380
|
+
// The pre-5.24 `owned-only` default refused to delete-propagate any journal
|
|
1381
|
+
// entry whose `direction !== "up"`. That made sense as a safety net against
|
|
1382
|
+
// a behind machine's first push erasing peer uploads — but it had a worse
|
|
1383
|
+
// failure mode in practice: every `/update-hq` writes `core/`, `.claude/`,
|
|
1384
|
+
// `.codex/` with `direction: "down"` (they came from upstream), so any
|
|
1385
|
+
// subsequent local delete during a cleanup or upgrade was silently dropped.
|
|
1386
|
+
// Net effect: remote vault accumulated permanent litter the user could not
|
|
1387
|
+
// clean up without manually invoking `policy: "all"` (which has its own
|
|
1388
|
+
// safety problems).
|
|
1389
|
+
//
|
|
1390
|
+
// `currency-gated` solves this by gating on per-file proof of currency: HEAD
|
|
1391
|
+
// the remote, compare ETag, only propagate when this device has the
|
|
1392
|
+
// latest version. Direction-of-origin becomes irrelevant — a file the
|
|
1393
|
+
// upstream `/update-hq` wrote can be cleanly deleted by the device that
|
|
1394
|
+
// wrote it, as long as no other device modified it in the meantime.
|
|
1395
|
+
|
|
1396
|
+
it("currency-gated: propagates delete when remote ETag matches journal", async () => {
|
|
1397
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1398
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1399
|
+
// direction:"down" file (e.g. arrived via /update-hq), locally deleted.
|
|
1400
|
+
// Under owned-only this would be stuck on remote forever; currency-gated
|
|
1401
|
+
// checks the ETag and propagates the delete cleanly when match holds.
|
|
1402
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1403
|
+
fs.writeFileSync(
|
|
1404
|
+
journalPath,
|
|
1405
|
+
JSON.stringify({
|
|
1406
|
+
version: "1",
|
|
1407
|
+
lastSync: new Date().toISOString(),
|
|
1408
|
+
files: {
|
|
1409
|
+
"core/policies/old.md": {
|
|
1410
|
+
hash: "h", size: 100, syncedAt: new Date().toISOString(),
|
|
1411
|
+
direction: "down",
|
|
1412
|
+
remoteEtag: "abc123",
|
|
1413
|
+
},
|
|
1414
|
+
},
|
|
1415
|
+
}),
|
|
1416
|
+
);
|
|
1417
|
+
|
|
1418
|
+
// HEAD returns the same etag — this device is current for the file.
|
|
1419
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1420
|
+
lastModified: new Date(),
|
|
1421
|
+
etag: '"abc123"',
|
|
1422
|
+
size: 100,
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
const result = await share({
|
|
1426
|
+
paths: [companyRoot],
|
|
1427
|
+
company: "acme",
|
|
1428
|
+
vaultConfig: mockConfig,
|
|
1429
|
+
hqRoot: tmpDir,
|
|
1430
|
+
skipUnchanged: true,
|
|
1431
|
+
propagateDeletes: true,
|
|
1432
|
+
propagateDeletePolicy: "currency-gated",
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
expect(result.filesDeleted).toBe(1);
|
|
1436
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(
|
|
1437
|
+
expect.anything(),
|
|
1438
|
+
"core/policies/old.md",
|
|
1439
|
+
);
|
|
1440
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1441
|
+
expect(journal.files["core/policies/old.md"]).toBeUndefined();
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
it("currency-gated: refuses delete + emits stale-etag event when remote moved since last sync", async () => {
|
|
1445
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1446
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1447
|
+
// Another device modified `shared.md` after this device's last sync.
|
|
1448
|
+
// The journal still records the old etag; HEAD returns the new one.
|
|
1449
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1450
|
+
fs.writeFileSync(
|
|
1451
|
+
journalPath,
|
|
1452
|
+
JSON.stringify({
|
|
1453
|
+
version: "1",
|
|
1454
|
+
lastSync: new Date().toISOString(),
|
|
1455
|
+
files: {
|
|
1456
|
+
"shared.md": {
|
|
1457
|
+
hash: "h", size: 50, syncedAt: new Date().toISOString(),
|
|
1458
|
+
direction: "down",
|
|
1459
|
+
remoteEtag: "stale-etag",
|
|
1460
|
+
},
|
|
1461
|
+
},
|
|
1462
|
+
}),
|
|
1463
|
+
);
|
|
1464
|
+
|
|
1465
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1466
|
+
lastModified: new Date(),
|
|
1467
|
+
etag: '"fresh-etag"',
|
|
1468
|
+
size: 51,
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
const events: Array<{ type: string; path?: string; journalEtag?: string; remoteEtag?: string }> = [];
|
|
1472
|
+
const result = await share({
|
|
1473
|
+
paths: [companyRoot],
|
|
1474
|
+
company: "acme",
|
|
1475
|
+
vaultConfig: mockConfig,
|
|
1476
|
+
hqRoot: tmpDir,
|
|
1477
|
+
skipUnchanged: true,
|
|
1478
|
+
propagateDeletes: true,
|
|
1479
|
+
propagateDeletePolicy: "currency-gated",
|
|
1480
|
+
onEvent: (e) => events.push(e as { type: string }),
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
// No remote DELETE issued, journal entry preserved (pull will re-pull).
|
|
1484
|
+
expect(result.filesDeleted).toBe(0);
|
|
1485
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
1486
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1487
|
+
expect(journal.files["shared.md"]).toBeDefined();
|
|
1488
|
+
|
|
1489
|
+
// Dedicated event fired so UIs surface the refusal. `reason` field
|
|
1490
|
+
// discriminates "stale-etag" (real drift) from "legacy-no-etag" so
|
|
1491
|
+
// consumers don't have to string-compare placeholder etag values.
|
|
1492
|
+
const refusedEvent = events.find((e) => e.type === "delete-refused-stale-etag");
|
|
1493
|
+
expect(refusedEvent).toMatchObject({
|
|
1494
|
+
type: "delete-refused-stale-etag",
|
|
1495
|
+
path: "shared.md",
|
|
1496
|
+
journalEtag: "stale-etag",
|
|
1497
|
+
remoteEtag: "fresh-etag",
|
|
1498
|
+
reason: "stale-etag",
|
|
1499
|
+
});
|
|
1500
|
+
// Counter exposed on ShareResult so callers don't need to re-count events.
|
|
1501
|
+
expect(result.filesRefusedStale).toBe(1);
|
|
1502
|
+
expect(result.filesTombstoned).toBe(0);
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
it("currency-gated: tombstones journal entry when remote returns 404 (out-of-band cleanup)", async () => {
|
|
1506
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1507
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1508
|
+
// Remote file was deleted out-of-band (e.g. someone removed it via the
|
|
1509
|
+
// S3 console). Local copy also missing. Currency-gated should drop the
|
|
1510
|
+
// journal entry without issuing a DELETE — the remote is already gone.
|
|
1511
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1512
|
+
fs.writeFileSync(
|
|
1513
|
+
journalPath,
|
|
1514
|
+
JSON.stringify({
|
|
1515
|
+
version: "1",
|
|
1516
|
+
lastSync: new Date().toISOString(),
|
|
1517
|
+
files: {
|
|
1518
|
+
"removed-out-of-band.md": {
|
|
1519
|
+
hash: "h", size: 25, syncedAt: new Date().toISOString(),
|
|
1520
|
+
direction: "down",
|
|
1521
|
+
remoteEtag: "doesnt-matter",
|
|
1522
|
+
},
|
|
1523
|
+
},
|
|
1524
|
+
}),
|
|
1525
|
+
);
|
|
1526
|
+
|
|
1527
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
|
|
1528
|
+
|
|
1529
|
+
const events: Array<{ type: string; path?: string; deleted?: boolean; message?: string; bytes?: number }> = [];
|
|
1530
|
+
const result = await share({
|
|
1531
|
+
paths: [companyRoot],
|
|
1532
|
+
company: "acme",
|
|
1533
|
+
vaultConfig: mockConfig,
|
|
1534
|
+
hqRoot: tmpDir,
|
|
1535
|
+
skipUnchanged: true,
|
|
1536
|
+
propagateDeletes: true,
|
|
1537
|
+
propagateDeletePolicy: "currency-gated",
|
|
1538
|
+
onEvent: (e) => events.push(e as { type: string }),
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
// No DeleteObject (remote was already gone), but journal converges.
|
|
1542
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
1543
|
+
// filesDeleted counts actual S3 deletes; tombstones aren't counted here.
|
|
1544
|
+
expect(result.filesDeleted).toBe(0);
|
|
1545
|
+
// Tombstones have their own counter so callers can distinguish them
|
|
1546
|
+
// from real deletes without re-counting events.
|
|
1547
|
+
expect(result.filesTombstoned).toBe(1);
|
|
1548
|
+
expect(result.filesRefusedStale).toBe(0);
|
|
1549
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1550
|
+
expect(journal.files["removed-out-of-band.md"]).toBeUndefined();
|
|
1551
|
+
// Synthetic progress event carries the tombstone marker. Without this,
|
|
1552
|
+
// the tty stream renders tombstones identically to real deletes — operator
|
|
1553
|
+
// can't tell from logs alone that no S3 call was issued.
|
|
1554
|
+
const tombstoneEvent = events.find(
|
|
1555
|
+
(e) => e.type === "progress" && e.deleted === true && e.message?.includes("tombstone"),
|
|
1556
|
+
);
|
|
1557
|
+
expect(tombstoneEvent).toMatchObject({
|
|
1558
|
+
type: "progress",
|
|
1559
|
+
path: "removed-out-of-band.md",
|
|
1560
|
+
bytes: 0,
|
|
1561
|
+
deleted: true,
|
|
1562
|
+
message: expect.stringContaining("tombstone"),
|
|
1563
|
+
});
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
it("currency-gated: refuses delete for legacy journal entry with no recorded remoteEtag", async () => {
|
|
1567
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1568
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1569
|
+
// Journal entry from before remoteEtag tracking — no etag to compare
|
|
1570
|
+
// against. Refuse the delete in the safe direction; a future sync with
|
|
1571
|
+
// a recorded etag can re-evaluate.
|
|
1572
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1573
|
+
fs.writeFileSync(
|
|
1574
|
+
journalPath,
|
|
1575
|
+
JSON.stringify({
|
|
1576
|
+
version: "1",
|
|
1577
|
+
lastSync: new Date().toISOString(),
|
|
1578
|
+
files: {
|
|
1579
|
+
"legacy-no-etag.md": {
|
|
1580
|
+
hash: "h", size: 5, syncedAt: new Date().toISOString(),
|
|
1581
|
+
direction: "down",
|
|
1582
|
+
// No remoteEtag.
|
|
1583
|
+
},
|
|
1584
|
+
},
|
|
1585
|
+
}),
|
|
1586
|
+
);
|
|
1587
|
+
|
|
1588
|
+
const events: Array<{ type: string; journalEtag?: string }> = [];
|
|
1589
|
+
const result = await share({
|
|
1590
|
+
paths: [companyRoot],
|
|
1591
|
+
company: "acme",
|
|
1592
|
+
vaultConfig: mockConfig,
|
|
1593
|
+
hqRoot: tmpDir,
|
|
1594
|
+
skipUnchanged: true,
|
|
1595
|
+
propagateDeletes: true,
|
|
1596
|
+
propagateDeletePolicy: "currency-gated",
|
|
1597
|
+
onEvent: (e) => events.push(e as { type: string }),
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1600
|
+
expect(result.filesDeleted).toBe(0);
|
|
1601
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
1602
|
+
// HEAD must NOT be called — we short-circuit on the missing journal etag.
|
|
1603
|
+
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
1604
|
+
const refused = events.find((e) => e.type === "delete-refused-stale-etag");
|
|
1605
|
+
expect(refused).toMatchObject({
|
|
1606
|
+
journalEtag: "<legacy-no-etag>",
|
|
1607
|
+
reason: "legacy-no-etag",
|
|
1608
|
+
});
|
|
1609
|
+
expect(result.filesRefusedStale).toBe(1);
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
it("currency-gated: real-world /update-hq scenario — direction:'down' delete propagates when current", async () => {
|
|
1613
|
+
// Regression for the exact bug that motivated the 5.24 default flip:
|
|
1614
|
+
// every `/update-hq` writes core/.claude/.codex from upstream and
|
|
1615
|
+
// journals them as direction:"down". When the user later moves or
|
|
1616
|
+
// deletes one locally (e.g. during cleanup, dir-restructure, or the
|
|
1617
|
+
// next upgrade), pre-5.24 `owned-only` silently kept it on remote
|
|
1618
|
+
// forever. Currency-gated propagates the delete cleanly as long as
|
|
1619
|
+
// this device is current for the file.
|
|
1620
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1621
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1622
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1623
|
+
fs.writeFileSync(
|
|
1624
|
+
journalPath,
|
|
1625
|
+
JSON.stringify({
|
|
1626
|
+
version: "1",
|
|
1627
|
+
lastSync: new Date().toISOString(),
|
|
1628
|
+
files: {
|
|
1629
|
+
".claude/commands/retired-command.md": {
|
|
1630
|
+
hash: "h", size: 200, syncedAt: new Date().toISOString(),
|
|
1631
|
+
direction: "down",
|
|
1632
|
+
remoteEtag: "upstream-etag",
|
|
1633
|
+
},
|
|
1634
|
+
},
|
|
1635
|
+
}),
|
|
1636
|
+
);
|
|
1637
|
+
|
|
1638
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1639
|
+
lastModified: new Date(),
|
|
1640
|
+
etag: '"upstream-etag"',
|
|
1641
|
+
size: 200,
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
const result = await share({
|
|
1645
|
+
paths: [companyRoot],
|
|
1646
|
+
company: "acme",
|
|
1647
|
+
vaultConfig: mockConfig,
|
|
1648
|
+
hqRoot: tmpDir,
|
|
1649
|
+
skipUnchanged: true,
|
|
1650
|
+
propagateDeletes: true,
|
|
1651
|
+
// Explicit opt-in. 5.24 ships the currency-gated CODE PATH but keeps
|
|
1652
|
+
// `owned-only` as the default while it soaks; the default flips to
|
|
1653
|
+
// `currency-gated` in 5.25. This test pins the user-facing semantics
|
|
1654
|
+
// (the /update-hq delete-propagation bug) under the new policy
|
|
1655
|
+
// regardless of which default the surrounding release ships.
|
|
1656
|
+
propagateDeletePolicy: "currency-gated",
|
|
1657
|
+
});
|
|
1658
|
+
|
|
1659
|
+
expect(result.filesDeleted).toBe(1);
|
|
1660
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(
|
|
1661
|
+
expect.anything(),
|
|
1662
|
+
".claude/commands/retired-command.md",
|
|
1663
|
+
);
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
// ── Conflict-mirror exclusion (ephemeral pattern) ──────────────────────────
|
|
1667
|
+
//
|
|
1668
|
+
// Conflict mirrors (`*.conflict-<ISO>-<machineHash>.<ext>`) are local-only
|
|
1669
|
+
// safety backups written by the pull leg whenever a 3-way merge keeps
|
|
1670
|
+
// local and wants to preserve the remote version for inspection. They
|
|
1671
|
+
// MUST never round-trip to S3. Pre-fix, the push walker uploaded them,
|
|
1672
|
+
// the journal tracked them, and the owned-only delete policy then refused
|
|
1673
|
+
// to clean them up — permanent ratchet of remote litter.
|
|
1674
|
+
|
|
1675
|
+
it("conflict-mirror exclusion: push walker skips local conflict-mirror files", async () => {
|
|
1676
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1677
|
+
fs.mkdirSync(path.join(companyRoot, "skills"), { recursive: true });
|
|
1678
|
+
// Two files: a normal one and a conflict mirror. Only the normal one
|
|
1679
|
+
// should reach S3; the mirror is local-only state.
|
|
1680
|
+
fs.writeFileSync(
|
|
1681
|
+
path.join(companyRoot, "skills", "real.md"),
|
|
1682
|
+
"real content",
|
|
1683
|
+
);
|
|
1684
|
+
fs.writeFileSync(
|
|
1685
|
+
path.join(companyRoot, "skills", "real.md.conflict-2026-05-13T19-40-40Z-e5797a.md"),
|
|
1686
|
+
"conflict mirror content",
|
|
1687
|
+
);
|
|
1688
|
+
|
|
1689
|
+
await share({
|
|
1690
|
+
paths: [companyRoot],
|
|
1691
|
+
company: "acme",
|
|
1692
|
+
vaultConfig: mockConfig,
|
|
1693
|
+
hqRoot: tmpDir,
|
|
1694
|
+
skipUnchanged: true,
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1697
|
+
expect(uploadFile).toHaveBeenCalledTimes(1);
|
|
1698
|
+
expect(uploadFile).toHaveBeenCalledWith(
|
|
1699
|
+
expect.anything(),
|
|
1700
|
+
expect.stringContaining("real.md"),
|
|
1701
|
+
"skills/real.md",
|
|
1702
|
+
);
|
|
1703
|
+
// Spy was called once total — the conflict mirror never reaches uploadFile.
|
|
1704
|
+
// (Asserted by count above; the explicit non-call below is defensive.)
|
|
1705
|
+
const calls = vi.mocked(uploadFile).mock.calls;
|
|
1706
|
+
expect(calls.every((c) => !String(c[1] ?? "").includes("conflict-"))).toBe(true);
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
it("conflict-mirror exclusion: explicit user-supplied conflict-mirror path is also refused", async () => {
|
|
1710
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1711
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1712
|
+
const mirrorPath = path.join(
|
|
1713
|
+
companyRoot,
|
|
1714
|
+
"CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md",
|
|
1715
|
+
);
|
|
1716
|
+
fs.writeFileSync(mirrorPath, "mirror content");
|
|
1717
|
+
|
|
1718
|
+
await share({
|
|
1719
|
+
paths: [mirrorPath],
|
|
1720
|
+
company: "acme",
|
|
1721
|
+
vaultConfig: mockConfig,
|
|
1722
|
+
hqRoot: tmpDir,
|
|
1723
|
+
skipUnchanged: true,
|
|
1724
|
+
});
|
|
1725
|
+
|
|
1726
|
+
// Explicit caller path matching the ephemeral pattern is filtered the
|
|
1727
|
+
// same as a walker-discovered one. Belt-and-suspenders against any
|
|
1728
|
+
// tooling that hands a conflict mirror to share() directly.
|
|
1729
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
it("conflict-mirror exclusion: journaled mirror with local-missing is NOT swept by delete plan", async () => {
|
|
1733
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1734
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1735
|
+
// Simulate the existing-litter state: a conflict mirror that leaked
|
|
1736
|
+
// into the journal in a prior buggy version. Locally missing (user
|
|
1737
|
+
// already deleted it). The regular delete plan must NOT issue a
|
|
1738
|
+
// DeleteObject — that's the dedicated reconcile command's job, and
|
|
1739
|
+
// a sync should not accidentally race a user reviewing the mirror.
|
|
1740
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1741
|
+
fs.writeFileSync(
|
|
1742
|
+
journalPath,
|
|
1743
|
+
JSON.stringify({
|
|
1744
|
+
version: "1",
|
|
1745
|
+
lastSync: new Date().toISOString(),
|
|
1746
|
+
files: {
|
|
1747
|
+
"CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md": {
|
|
1748
|
+
hash: "h", size: 10, syncedAt: new Date().toISOString(),
|
|
1749
|
+
direction: "up",
|
|
1750
|
+
remoteEtag: "mirror-etag",
|
|
1751
|
+
},
|
|
1752
|
+
"regular.md": {
|
|
1753
|
+
hash: "h", size: 5, syncedAt: new Date().toISOString(),
|
|
1754
|
+
direction: "up",
|
|
1755
|
+
remoteEtag: "regular-etag",
|
|
1756
|
+
},
|
|
1757
|
+
},
|
|
1758
|
+
}),
|
|
1759
|
+
);
|
|
1760
|
+
|
|
1761
|
+
// HEAD needed for the non-mirror entry under currency-gated.
|
|
1762
|
+
vi.mocked(headRemoteFile).mockResolvedValue({
|
|
1763
|
+
lastModified: new Date(),
|
|
1764
|
+
etag: '"regular-etag"',
|
|
1765
|
+
size: 5,
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
const result = await share({
|
|
1769
|
+
paths: [companyRoot],
|
|
1770
|
+
company: "acme",
|
|
1771
|
+
vaultConfig: mockConfig,
|
|
1772
|
+
hqRoot: tmpDir,
|
|
1773
|
+
skipUnchanged: true,
|
|
1774
|
+
propagateDeletes: true,
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
expect(result.filesDeleted).toBe(1);
|
|
1778
|
+
expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
|
|
1779
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "regular.md");
|
|
1780
|
+
expect(deleteRemoteFile).not.toHaveBeenCalledWith(
|
|
1781
|
+
expect.anything(),
|
|
1782
|
+
expect.stringContaining("conflict-"),
|
|
1783
|
+
);
|
|
1784
|
+
// Mirror's journal entry survives — reconcile command (separate skill)
|
|
1785
|
+
// sweeps it once the user explicitly opts in.
|
|
1786
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1787
|
+
expect(
|
|
1788
|
+
journal.files["CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md"],
|
|
1789
|
+
).toBeDefined();
|
|
1790
|
+
});
|
|
1791
|
+
|
|
1350
1792
|
// ── personalMode ───────────────────────────────────────────────────────────
|
|
1351
1793
|
//
|
|
1352
1794
|
// The personal vault (slug "personal" in the runner's fanout plan) shares
|
|
@@ -1748,3 +2190,140 @@ describe("share", () => {
|
|
|
1748
2190
|
});
|
|
1749
2191
|
});
|
|
1750
2192
|
});
|
|
2193
|
+
|
|
2194
|
+
// ── Pure-function unit coverage: isEphemeralPath ─────────────────────────────
|
|
2195
|
+
//
|
|
2196
|
+
// EPHEMERAL_PATH_PATTERN is the single source of truth for "this is a conflict
|
|
2197
|
+
// mirror and must never round-trip to S3." Integration tests already cover the
|
|
2198
|
+
// behavior end-to-end (uploadFile is or isn't called), but a direct regex
|
|
2199
|
+
// contract test makes intent unambiguous and catches future drift in the
|
|
2200
|
+
// pattern — much cheaper than reproducing each case through share().
|
|
2201
|
+
|
|
2202
|
+
describe("isEphemeralPath (conflict-mirror pattern contract)", () => {
|
|
2203
|
+
const { isEphemeralPath } = shareTesting;
|
|
2204
|
+
|
|
2205
|
+
it.each([
|
|
2206
|
+
// Canonical: basename and relativeKey both match (the two callsites).
|
|
2207
|
+
[".claude/CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md", true],
|
|
2208
|
+
["CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md", true],
|
|
2209
|
+
[".claude/commands/adr.md.conflict-2026-05-13T19-40-41Z-e5797a.md", true],
|
|
2210
|
+
// Longer machine hash (no upper bound on `[a-f0-9]+`).
|
|
2211
|
+
["foo.conflict-2026-05-19T17-05-56Z-deadbeef.md", true],
|
|
2212
|
+
// Non-markdown extensions also valid (sh scripts, ts files, etc.).
|
|
2213
|
+
["foo.sh.conflict-2026-05-19T17-05-56Z-abc123.sh", true],
|
|
2214
|
+
])("matches conflict mirror: %s", (p, expected) => {
|
|
2215
|
+
expect(isEphemeralPath(p)).toBe(expected);
|
|
2216
|
+
});
|
|
2217
|
+
|
|
2218
|
+
it.each([
|
|
2219
|
+
// Normal files: regular markdown, scripts, etc.
|
|
2220
|
+
["README.md", false],
|
|
2221
|
+
[".claude/CLAUDE.md", false],
|
|
2222
|
+
["companies/acme/knowledge/note.md", false],
|
|
2223
|
+
// Strings containing the word "conflict" but not the timestamp+hash token.
|
|
2224
|
+
["conflict-resolution.md", false],
|
|
2225
|
+
["my-conflict.md", false],
|
|
2226
|
+
["foo.conflict-handler.md", false],
|
|
2227
|
+
// Date-shaped but missing the trailing dot + extension (real conflicts
|
|
2228
|
+
// always carry a file extension; the trailing `\.` in the pattern is the
|
|
2229
|
+
// safety against bare-substring false positives).
|
|
2230
|
+
["foo.conflict-2026-05-13T19-40-40Z-abc", false],
|
|
2231
|
+
// Wrong-case or non-hex machine hash.
|
|
2232
|
+
["foo.conflict-2026-05-13T19-40-40Z-ZZZZZZ.md", false],
|
|
2233
|
+
// Wrong timestamp format (real conflicts use UTC ISO with Z suffix).
|
|
2234
|
+
["foo.conflict-2026-05-13-abc123.md", false],
|
|
2235
|
+
// Missing leading dot before "conflict" (this protects against legitimate
|
|
2236
|
+
// files that happen to contain the word "conflict" mid-name).
|
|
2237
|
+
["fooconflict-2026-05-13T19-40-40Z-abc.md", false],
|
|
2238
|
+
])("rejects non-mirror: %s", (p, expected) => {
|
|
2239
|
+
expect(isEphemeralPath(p)).toBe(expected);
|
|
2240
|
+
});
|
|
2241
|
+
});
|
|
2242
|
+
|
|
2243
|
+
// ── Currency-gated coverage against journal version "2" fixtures ─────────────
|
|
2244
|
+
//
|
|
2245
|
+
// Production journals on disk are at `version: "2"` (verified via jq on the
|
|
2246
|
+
// real-world ~/.hq/sync-journal.personal.json). The currency-gated tests above
|
|
2247
|
+
// use v1 fixtures by historical convention — this block pins the behavior at
|
|
2248
|
+
// v2 explicitly so a future schema change can't silently strand the new policy
|
|
2249
|
+
// on a stale fixture format. The bucket logic ignores `version` (only reads
|
|
2250
|
+
// `journal.files[*]`), so this should pass identically — but we want the
|
|
2251
|
+
// regression test on record.
|
|
2252
|
+
|
|
2253
|
+
describe("currency-gated: journal version 2 fixtures", () => {
|
|
2254
|
+
let tmpDir: string;
|
|
2255
|
+
let stateDir: string;
|
|
2256
|
+
let origDataHome: string | undefined;
|
|
2257
|
+
|
|
2258
|
+
beforeEach(() => {
|
|
2259
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-share-test-v2-"));
|
|
2260
|
+
stateDir = path.join(tmpDir, ".hq");
|
|
2261
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
2262
|
+
origDataHome = process.env.XDG_DATA_HOME;
|
|
2263
|
+
process.env.XDG_DATA_HOME = stateDir;
|
|
2264
|
+
process.env.HQ_STATE_DIR = stateDir;
|
|
2265
|
+
clearContextCache();
|
|
2266
|
+
setupFetchMock();
|
|
2267
|
+
vi.mocked(uploadFile).mockClear();
|
|
2268
|
+
vi.mocked(uploadSymlink).mockClear();
|
|
2269
|
+
vi.mocked(deleteRemoteFile).mockClear();
|
|
2270
|
+
vi.mocked(headRemoteFile).mockReset();
|
|
2271
|
+
vi.mocked(headRemoteFile).mockResolvedValue(null);
|
|
2272
|
+
});
|
|
2273
|
+
|
|
2274
|
+
afterEach(() => {
|
|
2275
|
+
if (origDataHome === undefined) {
|
|
2276
|
+
delete process.env.XDG_DATA_HOME;
|
|
2277
|
+
} else {
|
|
2278
|
+
process.env.XDG_DATA_HOME = origDataHome;
|
|
2279
|
+
}
|
|
2280
|
+
delete process.env.HQ_STATE_DIR;
|
|
2281
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
2282
|
+
});
|
|
2283
|
+
|
|
2284
|
+
it("propagates delete on etag match against a v2-shaped journal", async () => {
|
|
2285
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2286
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2287
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2288
|
+
// v2-shaped fixture — same field set the production journal uses.
|
|
2289
|
+
fs.writeFileSync(
|
|
2290
|
+
journalPath,
|
|
2291
|
+
JSON.stringify({
|
|
2292
|
+
version: "2",
|
|
2293
|
+
lastSync: new Date().toISOString(),
|
|
2294
|
+
files: {
|
|
2295
|
+
"core/policies/old.md": {
|
|
2296
|
+
hash: "h",
|
|
2297
|
+
size: 100,
|
|
2298
|
+
syncedAt: new Date().toISOString(),
|
|
2299
|
+
direction: "down",
|
|
2300
|
+
remoteEtag: "v2-etag",
|
|
2301
|
+
},
|
|
2302
|
+
},
|
|
2303
|
+
pulls: [],
|
|
2304
|
+
}),
|
|
2305
|
+
);
|
|
2306
|
+
|
|
2307
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
2308
|
+
lastModified: new Date(),
|
|
2309
|
+
etag: '"v2-etag"',
|
|
2310
|
+
size: 100,
|
|
2311
|
+
});
|
|
2312
|
+
|
|
2313
|
+
const result = await share({
|
|
2314
|
+
paths: [companyRoot],
|
|
2315
|
+
company: "acme",
|
|
2316
|
+
vaultConfig: mockConfig,
|
|
2317
|
+
hqRoot: tmpDir,
|
|
2318
|
+
skipUnchanged: true,
|
|
2319
|
+
propagateDeletes: true,
|
|
2320
|
+
propagateDeletePolicy: "currency-gated",
|
|
2321
|
+
});
|
|
2322
|
+
|
|
2323
|
+
expect(result.filesDeleted).toBe(1);
|
|
2324
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(
|
|
2325
|
+
expect.anything(),
|
|
2326
|
+
"core/policies/old.md",
|
|
2327
|
+
);
|
|
2328
|
+
});
|
|
2329
|
+
});
|