@indigoai-us/hq-cloud 6.14.9 → 6.14.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +89 -14
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +131 -3
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +138 -41
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +271 -25
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +7 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +22 -1
- package/dist/journal.js.map +1 -1
- package/dist/personal-vault-exclusions.d.ts +20 -4
- package/dist/personal-vault-exclusions.d.ts.map +1 -1
- package/dist/personal-vault-exclusions.js +37 -3
- package/dist/personal-vault-exclusions.js.map +1 -1
- package/dist/personal-vault-exclusions.test.js +19 -0
- package/dist/personal-vault-exclusions.test.js.map +1 -1
- package/dist/personal-vault.d.ts +6 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +12 -0
- package/dist/personal-vault.js.map +1 -1
- package/dist/s3.d.ts +23 -1
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +74 -3
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +71 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +176 -3
- package/src/cli/share.ts +114 -15
- package/src/cli/sync.test.ts +330 -24
- package/src/cli/sync.ts +184 -50
- package/src/journal.ts +28 -0
- package/src/personal-vault-exclusions.test.ts +23 -0
- package/src/personal-vault-exclusions.ts +39 -3
- package/src/personal-vault.ts +15 -0
- package/src/s3.test.ts +91 -0
- package/src/s3.ts +88 -2
- package/src/types.ts +13 -0
package/src/cli/sync.test.ts
CHANGED
|
@@ -26,9 +26,16 @@ vi.mock("../s3.js", async (importOriginal) => {
|
|
|
26
26
|
...actual,
|
|
27
27
|
toPosixKey: (key: string) => key.split("\\").join("/"),
|
|
28
28
|
uploadFile: innerVi.fn().mockResolvedValue(undefined),
|
|
29
|
-
|
|
29
|
+
uploadSymlink: innerVi.fn().mockResolvedValue(undefined),
|
|
30
|
+
downloadFile: innerVi.fn().mockImplementation(async (
|
|
31
|
+
_ctx: unknown,
|
|
32
|
+
_key: string,
|
|
33
|
+
localPath: string,
|
|
34
|
+
options?: { beforeReplace?: () => void },
|
|
35
|
+
) => {
|
|
30
36
|
const dir = innerPath.dirname(localPath);
|
|
31
37
|
if (!innerFs.existsSync(dir)) innerFs.mkdirSync(dir, { recursive: true });
|
|
38
|
+
options?.beforeReplace?.();
|
|
32
39
|
innerFs.writeFileSync(localPath, "mock file content");
|
|
33
40
|
return { metadata: {} };
|
|
34
41
|
}),
|
|
@@ -36,6 +43,7 @@ vi.mock("../s3.js", async (importOriginal) => {
|
|
|
36
43
|
deleteRemoteFile: innerVi.fn().mockResolvedValue(undefined),
|
|
37
44
|
headRemoteFile: innerVi.fn().mockResolvedValue(null),
|
|
38
45
|
primeObjectTransport: innerVi.fn().mockResolvedValue(undefined),
|
|
46
|
+
primeUploads: innerVi.fn().mockResolvedValue(undefined),
|
|
39
47
|
};
|
|
40
48
|
});
|
|
41
49
|
|
|
@@ -46,6 +54,7 @@ vi.mock("./reindex.js", () => ({
|
|
|
46
54
|
}));
|
|
47
55
|
|
|
48
56
|
import { sync, reportNewFilesToNotify } from "./sync.js";
|
|
57
|
+
import { share } from "./share.js";
|
|
49
58
|
import * as s3Module from "../s3.js";
|
|
50
59
|
import { reindex } from "./reindex.js";
|
|
51
60
|
import { readSyncProgress } from "../sync-progress.js";
|
|
@@ -618,6 +627,8 @@ describe("sync", () => {
|
|
|
618
627
|
const localContent = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
|
|
619
628
|
fs.writeFileSync(localPath, localContent);
|
|
620
629
|
const st = fs.statSync(localPath);
|
|
630
|
+
const crypto = await import("node:crypto");
|
|
631
|
+
const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
|
|
621
632
|
|
|
622
633
|
// Remote object is SMALLER (an older, truncated copy) with a CHANGED etag.
|
|
623
634
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
@@ -638,7 +649,7 @@ describe("sync", () => {
|
|
|
638
649
|
lastSync: new Date().toISOString(),
|
|
639
650
|
files: {
|
|
640
651
|
"docs/handoff.md": {
|
|
641
|
-
hash:
|
|
652
|
+
hash: localHash,
|
|
642
653
|
size: st.size,
|
|
643
654
|
mtimeMs: st.mtimeMs,
|
|
644
655
|
remoteEtag: "old-etag",
|
|
@@ -682,6 +693,8 @@ describe("sync", () => {
|
|
|
682
693
|
Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
|
|
683
694
|
fs.writeFileSync(localPath, localContent);
|
|
684
695
|
const st = fs.statSync(localPath);
|
|
696
|
+
const crypto = await import("node:crypto");
|
|
697
|
+
const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
|
|
685
698
|
|
|
686
699
|
// Remote is SMALLER with a changed etag — a legitimate shorter rewrite.
|
|
687
700
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
@@ -695,7 +708,7 @@ describe("sync", () => {
|
|
|
695
708
|
lastSync: new Date().toISOString(),
|
|
696
709
|
files: {
|
|
697
710
|
"docs/handoff.md": {
|
|
698
|
-
hash:
|
|
711
|
+
hash: localHash,
|
|
699
712
|
size: st.size,
|
|
700
713
|
mtimeMs: st.mtimeMs,
|
|
701
714
|
remoteEtag: "old-etag",
|
|
@@ -733,6 +746,8 @@ describe("sync", () => {
|
|
|
733
746
|
const localContent = "short local\n";
|
|
734
747
|
fs.writeFileSync(localPath, localContent);
|
|
735
748
|
const st = fs.statSync(localPath);
|
|
749
|
+
const crypto = await import("node:crypto");
|
|
750
|
+
const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
|
|
736
751
|
|
|
737
752
|
// Remote is LARGER with a changed etag → legitimate advance.
|
|
738
753
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
@@ -746,7 +761,7 @@ describe("sync", () => {
|
|
|
746
761
|
lastSync: new Date().toISOString(),
|
|
747
762
|
files: {
|
|
748
763
|
"docs/handoff.md": {
|
|
749
|
-
hash:
|
|
764
|
+
hash: localHash,
|
|
750
765
|
size: st.size,
|
|
751
766
|
mtimeMs: st.mtimeMs,
|
|
752
767
|
remoteEtag: "old-etag",
|
|
@@ -960,12 +975,9 @@ describe("sync", () => {
|
|
|
960
975
|
expect(journal.files[trackedKey]).toBeUndefined();
|
|
961
976
|
});
|
|
962
977
|
|
|
963
|
-
it("
|
|
964
|
-
//
|
|
965
|
-
//
|
|
966
|
-
// FILE_TOMBSTONE fetch is degraded (empty map — the logged failure mode).
|
|
967
|
-
// The only intent record is the LOCAL journal tombstone (`removedAt`).
|
|
968
|
-
// The drift-restore path must honor it and NOT re-download the key.
|
|
978
|
+
it("fails closed for a legacy local-delete tombstone and restores the remote key", async () => {
|
|
979
|
+
// A legacy `removedReason:local-delete` was inferred from an ENOENT. It
|
|
980
|
+
// has no version-bound explicit intent, so it must never suppress a pull.
|
|
969
981
|
const key = "docs/handoff.md";
|
|
970
982
|
const localPath = path.join(tmpDir, "companies", "acme", key);
|
|
971
983
|
|
|
@@ -1012,20 +1024,16 @@ describe("sync", () => {
|
|
|
1012
1024
|
onEvent: () => {},
|
|
1013
1025
|
});
|
|
1014
1026
|
|
|
1015
|
-
|
|
1016
|
-
expect(s3Module.downloadFile).not.toHaveBeenCalledWith(
|
|
1027
|
+
expect(s3Module.downloadFile).toHaveBeenCalledWith(
|
|
1017
1028
|
expect.anything(),
|
|
1018
1029
|
key,
|
|
1019
1030
|
expect.anything(),
|
|
1031
|
+
expect.anything(),
|
|
1020
1032
|
);
|
|
1021
|
-
expect(fs.
|
|
1033
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
|
|
1022
1034
|
});
|
|
1023
1035
|
|
|
1024
|
-
it("
|
|
1025
|
-
// The user deleted a genuinely-synced file. Its entry is clean (etag matches
|
|
1026
|
-
// the current remote — currency holds), not divergent. The pull leg must
|
|
1027
|
-
// treat the absence as an intentional delete: stamp a persisting
|
|
1028
|
-
// `local-delete` tombstone and SKIP the re-download — not resurrect it.
|
|
1036
|
+
it("does not infer local-delete intent from a single missing file", async () => {
|
|
1029
1037
|
const key = "docs/gone.md";
|
|
1030
1038
|
const localPath = path.join(tmpDir, "companies", "acme", key);
|
|
1031
1039
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
@@ -1052,15 +1060,15 @@ describe("sync", () => {
|
|
|
1052
1060
|
|
|
1053
1061
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1054
1062
|
|
|
1055
|
-
expect(s3Module.downloadFile).
|
|
1063
|
+
expect(s3Module.downloadFile).toHaveBeenCalledWith(
|
|
1056
1064
|
expect.anything(),
|
|
1057
1065
|
key,
|
|
1058
1066
|
expect.anything(),
|
|
1067
|
+
expect.anything(),
|
|
1059
1068
|
);
|
|
1060
|
-
expect(fs.
|
|
1069
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
|
|
1061
1070
|
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1062
|
-
expect(journal.files[key]?.removedAt).
|
|
1063
|
-
expect(journal.files[key]?.removedReason).toBe("local-delete");
|
|
1071
|
+
expect(journal.files[key]?.removedAt).toBeUndefined();
|
|
1064
1072
|
});
|
|
1065
1073
|
|
|
1066
1074
|
it("pull producer: a BULK disappearance is drift-restored, NOT tombstoned (ridge-incident guard in the pull leg)", async () => {
|
|
@@ -1079,7 +1087,14 @@ describe("sync", () => {
|
|
|
1079
1087
|
size: 10,
|
|
1080
1088
|
syncedAt: new Date().toISOString(),
|
|
1081
1089
|
direction: "down",
|
|
1090
|
+
kind: "file",
|
|
1082
1091
|
remoteEtag: `e-${k}`,
|
|
1092
|
+
localDeleteIntent: {
|
|
1093
|
+
version: 1,
|
|
1094
|
+
remoteEtag: `e-${k}`,
|
|
1095
|
+
localHash: "h",
|
|
1096
|
+
localKind: "file",
|
|
1097
|
+
},
|
|
1083
1098
|
};
|
|
1084
1099
|
}
|
|
1085
1100
|
fs.writeFileSync(
|
|
@@ -1123,9 +1138,16 @@ describe("sync", () => {
|
|
|
1123
1138
|
size: 42,
|
|
1124
1139
|
syncedAt: new Date().toISOString(),
|
|
1125
1140
|
direction: "down",
|
|
1141
|
+
kind: "file",
|
|
1126
1142
|
remoteEtag: "cur123",
|
|
1127
1143
|
removedAt: new Date().toISOString(),
|
|
1128
1144
|
removedReason: "local-delete",
|
|
1145
|
+
localDeleteIntent: {
|
|
1146
|
+
version: 1,
|
|
1147
|
+
remoteEtag: "cur123",
|
|
1148
|
+
localHash: "h",
|
|
1149
|
+
localKind: "file",
|
|
1150
|
+
},
|
|
1129
1151
|
},
|
|
1130
1152
|
},
|
|
1131
1153
|
pulls: [],
|
|
@@ -1237,6 +1259,43 @@ describe("sync", () => {
|
|
|
1237
1259
|
expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "readme.md"))).toBe(false);
|
|
1238
1260
|
});
|
|
1239
1261
|
|
|
1262
|
+
it("personalMode skips generated namespaced skill-wrapper objects and still pulls local skills", async () => {
|
|
1263
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1264
|
+
{
|
|
1265
|
+
key: ".claude/skills/acme:demo/scripts",
|
|
1266
|
+
size: 0,
|
|
1267
|
+
lastModified: new Date(),
|
|
1268
|
+
etag: '"generated"',
|
|
1269
|
+
},
|
|
1270
|
+
{
|
|
1271
|
+
key: ".claude/skills/local-skill/SKILL.md",
|
|
1272
|
+
size: 30,
|
|
1273
|
+
lastModified: new Date(),
|
|
1274
|
+
etag: '"local"',
|
|
1275
|
+
},
|
|
1276
|
+
]);
|
|
1277
|
+
|
|
1278
|
+
const result = await sync({
|
|
1279
|
+
company: "acme",
|
|
1280
|
+
vaultConfig: mockConfig,
|
|
1281
|
+
hqRoot: tmpDir,
|
|
1282
|
+
personalMode: true,
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
expect(result.filesDownloaded).toBe(1);
|
|
1286
|
+
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
1287
|
+
expect(
|
|
1288
|
+
fs.existsSync(path.join(tmpDir, ".claude", "skills", "acme:demo", "scripts")),
|
|
1289
|
+
).toBe(false);
|
|
1290
|
+
expect(
|
|
1291
|
+
fs.existsSync(path.join(tmpDir, ".claude", "skills", "local-skill", "SKILL.md")),
|
|
1292
|
+
).toBe(true);
|
|
1293
|
+
const downloadedKeys = vi
|
|
1294
|
+
.mocked(s3Module.downloadFile)
|
|
1295
|
+
.mock.calls.map((call) => call[1]);
|
|
1296
|
+
expect(downloadedKeys).not.toContain(".claude/skills/acme:demo/scripts");
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1240
1299
|
it("personalMode: downloads + journals companies/manifest.yaml (carve-out round-trips) while still skipping team-synced orphan keys", async () => {
|
|
1241
1300
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1242
1301
|
{ key: "companies/foo/bar.md", size: 50, lastModified: new Date(), etag: '"xyz789"' },
|
|
@@ -1771,6 +1830,252 @@ describe("sync", () => {
|
|
|
1771
1830
|
expect(journal.files["docs/racy-delete.md"].hash).toBe(baselineHash);
|
|
1772
1831
|
});
|
|
1773
1832
|
|
|
1833
|
+
it("CAS: preserves a file created after a remote-delete plan", async () => {
|
|
1834
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1835
|
+
const localPath = path.join(companyRoot, "docs", "created-after-plan.md");
|
|
1836
|
+
const baseline = "synced baseline";
|
|
1837
|
+
const crypto = await import("node:crypto");
|
|
1838
|
+
const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
|
|
1839
|
+
|
|
1840
|
+
fs.writeFileSync(
|
|
1841
|
+
journalPath,
|
|
1842
|
+
JSON.stringify({
|
|
1843
|
+
version: "2",
|
|
1844
|
+
lastSync: new Date().toISOString(),
|
|
1845
|
+
files: {
|
|
1846
|
+
"docs/created-after-plan.md": {
|
|
1847
|
+
hash: baselineHash,
|
|
1848
|
+
size: baseline.length,
|
|
1849
|
+
syncedAt: new Date().toISOString(),
|
|
1850
|
+
direction: "down",
|
|
1851
|
+
kind: "file",
|
|
1852
|
+
remoteEtag: "before-delete",
|
|
1853
|
+
},
|
|
1854
|
+
},
|
|
1855
|
+
pulls: [],
|
|
1856
|
+
}),
|
|
1857
|
+
);
|
|
1858
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
|
|
1859
|
+
vi.mocked(s3Module.headRemoteFile).mockImplementationOnce(async () => {
|
|
1860
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
1861
|
+
// Same bytes as the journal baseline: hash-only revalidation would unlink
|
|
1862
|
+
// this newly-created file, but the planned snapshot was "absent".
|
|
1863
|
+
fs.writeFileSync(localPath, baseline);
|
|
1864
|
+
return null;
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
const result = await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1868
|
+
|
|
1869
|
+
expect(result.filesTombstoned).toBe(0);
|
|
1870
|
+
expect(result.conflicts).toBe(1);
|
|
1871
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe(baseline);
|
|
1872
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1873
|
+
expect(journal.files["docs/created-after-plan.md"]).toBeDefined();
|
|
1874
|
+
});
|
|
1875
|
+
|
|
1876
|
+
it("CAS: preserves a concurrent local edit when a staged download is ready to replace it", async () => {
|
|
1877
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1878
|
+
const localPath = path.join(companyRoot, "docs", "staged-race.md");
|
|
1879
|
+
const baseline = "synced baseline";
|
|
1880
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
1881
|
+
fs.writeFileSync(localPath, baseline);
|
|
1882
|
+
const crypto = await import("node:crypto");
|
|
1883
|
+
const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
|
|
1884
|
+
fs.writeFileSync(
|
|
1885
|
+
journalPath,
|
|
1886
|
+
JSON.stringify({
|
|
1887
|
+
version: "2",
|
|
1888
|
+
lastSync: new Date().toISOString(),
|
|
1889
|
+
files: {
|
|
1890
|
+
"docs/staged-race.md": {
|
|
1891
|
+
hash: baselineHash,
|
|
1892
|
+
size: baseline.length,
|
|
1893
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
1894
|
+
direction: "down",
|
|
1895
|
+
kind: "file",
|
|
1896
|
+
remoteEtag: "old-etag",
|
|
1897
|
+
},
|
|
1898
|
+
},
|
|
1899
|
+
pulls: [],
|
|
1900
|
+
}),
|
|
1901
|
+
);
|
|
1902
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1903
|
+
{ key: "docs/staged-race.md", size: baseline.length + 10, lastModified: new Date(), etag: '"new-etag"' },
|
|
1904
|
+
]);
|
|
1905
|
+
vi.mocked(s3Module.downloadFile).mockImplementationOnce(async (
|
|
1906
|
+
_ctx: unknown,
|
|
1907
|
+
_key: string,
|
|
1908
|
+
destination: string,
|
|
1909
|
+
options?: { beforeReplace?: () => void },
|
|
1910
|
+
) => {
|
|
1911
|
+
fs.writeFileSync(destination, "concurrent local edit");
|
|
1912
|
+
options?.beforeReplace?.();
|
|
1913
|
+
fs.writeFileSync(destination, "remote replacement");
|
|
1914
|
+
return { metadata: {} };
|
|
1915
|
+
});
|
|
1916
|
+
|
|
1917
|
+
const result = await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1918
|
+
|
|
1919
|
+
expect(result.filesDownloaded).toBe(0);
|
|
1920
|
+
expect(result.conflicts).toBe(1);
|
|
1921
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("concurrent local edit");
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
it("personal vault delete planning excludes release core and generated skill mirrors", async () => {
|
|
1925
|
+
const personalJournalPath = path.join(stateDir, "sync-journal.personal-delete.json");
|
|
1926
|
+
const intent = (remoteEtag: string) => ({
|
|
1927
|
+
version: 1 as const,
|
|
1928
|
+
remoteEtag,
|
|
1929
|
+
localHash: "synced-hash",
|
|
1930
|
+
localKind: "file" as const,
|
|
1931
|
+
});
|
|
1932
|
+
fs.writeFileSync(
|
|
1933
|
+
personalJournalPath,
|
|
1934
|
+
JSON.stringify({
|
|
1935
|
+
version: "2",
|
|
1936
|
+
lastSync: new Date().toISOString(),
|
|
1937
|
+
files: {
|
|
1938
|
+
"core/core.yaml": {
|
|
1939
|
+
hash: "synced-hash",
|
|
1940
|
+
size: 1,
|
|
1941
|
+
syncedAt: new Date().toISOString(),
|
|
1942
|
+
direction: "up",
|
|
1943
|
+
kind: "file",
|
|
1944
|
+
remoteEtag: "core-etag",
|
|
1945
|
+
localDeleteIntent: intent("core-etag"),
|
|
1946
|
+
},
|
|
1947
|
+
".claude/skills/core:demo/SKILL.md": {
|
|
1948
|
+
hash: "synced-hash",
|
|
1949
|
+
size: 1,
|
|
1950
|
+
syncedAt: new Date().toISOString(),
|
|
1951
|
+
direction: "up",
|
|
1952
|
+
kind: "file",
|
|
1953
|
+
remoteEtag: "mirror-etag",
|
|
1954
|
+
localDeleteIntent: intent("mirror-etag"),
|
|
1955
|
+
},
|
|
1956
|
+
},
|
|
1957
|
+
pulls: [],
|
|
1958
|
+
}),
|
|
1959
|
+
);
|
|
1960
|
+
|
|
1961
|
+
await share({
|
|
1962
|
+
company: "acme",
|
|
1963
|
+
vaultConfig: mockConfig,
|
|
1964
|
+
hqRoot: tmpDir,
|
|
1965
|
+
paths: [tmpDir],
|
|
1966
|
+
personalMode: true,
|
|
1967
|
+
journalSlug: "personal-delete",
|
|
1968
|
+
propagateDeletes: true,
|
|
1969
|
+
propagateDeletePolicy: "all",
|
|
1970
|
+
});
|
|
1971
|
+
|
|
1972
|
+
expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
|
|
1973
|
+
const journal = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
|
|
1974
|
+
expect(journal.files["core/core.yaml"]).toBeDefined();
|
|
1975
|
+
expect(journal.files[".claude/skills/core:demo/SKILL.md"]).toBeDefined();
|
|
1976
|
+
});
|
|
1977
|
+
|
|
1978
|
+
it("does not issue DeleteObject for two ordinary missing journal files without explicit intent", async () => {
|
|
1979
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1980
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1981
|
+
const entry = (remoteEtag: string) => ({
|
|
1982
|
+
hash: "synced-hash",
|
|
1983
|
+
size: 1,
|
|
1984
|
+
syncedAt: new Date().toISOString(),
|
|
1985
|
+
direction: "up",
|
|
1986
|
+
kind: "file",
|
|
1987
|
+
remoteEtag,
|
|
1988
|
+
});
|
|
1989
|
+
fs.writeFileSync(
|
|
1990
|
+
journalPath,
|
|
1991
|
+
JSON.stringify({
|
|
1992
|
+
version: "2",
|
|
1993
|
+
lastSync: new Date().toISOString(),
|
|
1994
|
+
files: {
|
|
1995
|
+
"docs/a.md": entry("a-etag"),
|
|
1996
|
+
"docs/b.md": entry("b-etag"),
|
|
1997
|
+
},
|
|
1998
|
+
pulls: [],
|
|
1999
|
+
}),
|
|
2000
|
+
);
|
|
2001
|
+
|
|
2002
|
+
const result = await share({
|
|
2003
|
+
company: "acme",
|
|
2004
|
+
vaultConfig: mockConfig,
|
|
2005
|
+
hqRoot: tmpDir,
|
|
2006
|
+
paths: [companyRoot],
|
|
2007
|
+
propagateDeletes: true,
|
|
2008
|
+
propagateDeletePolicy: "all",
|
|
2009
|
+
});
|
|
2010
|
+
|
|
2011
|
+
expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
|
|
2012
|
+
expect(result.filesRefusedStale).toBe(2);
|
|
2013
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2014
|
+
expect(journal.files["docs/a.md"]).toBeDefined();
|
|
2015
|
+
expect(journal.files["docs/b.md"]).toBeDefined();
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
it("uses a conditional PUT conflict for concurrent board.json appends", async () => {
|
|
2019
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2020
|
+
const boardPath = path.join(companyRoot, "board.json");
|
|
2021
|
+
const baseline = '{"items":[]}';
|
|
2022
|
+
const localAppend = '{"items":["local append"]}';
|
|
2023
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2024
|
+
fs.writeFileSync(boardPath, localAppend);
|
|
2025
|
+
const crypto = await import("node:crypto");
|
|
2026
|
+
const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
|
|
2027
|
+
fs.writeFileSync(
|
|
2028
|
+
journalPath,
|
|
2029
|
+
JSON.stringify({
|
|
2030
|
+
version: "2",
|
|
2031
|
+
lastSync: new Date().toISOString(),
|
|
2032
|
+
files: {
|
|
2033
|
+
"board.json": {
|
|
2034
|
+
hash: baselineHash,
|
|
2035
|
+
size: baseline.length,
|
|
2036
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
2037
|
+
direction: "up",
|
|
2038
|
+
kind: "file",
|
|
2039
|
+
remoteEtag: "old-etag",
|
|
2040
|
+
},
|
|
2041
|
+
},
|
|
2042
|
+
pulls: [],
|
|
2043
|
+
}),
|
|
2044
|
+
);
|
|
2045
|
+
vi.mocked(s3Module.headRemoteFile).mockResolvedValueOnce({
|
|
2046
|
+
etag: "old-etag",
|
|
2047
|
+
size: baseline.length,
|
|
2048
|
+
lastModified: new Date(),
|
|
2049
|
+
});
|
|
2050
|
+
const preconditionError = Object.assign(new Error("remote changed"), {
|
|
2051
|
+
name: "PreconditionFailed",
|
|
2052
|
+
});
|
|
2053
|
+
vi.mocked(s3Module.uploadFile).mockRejectedValueOnce(preconditionError);
|
|
2054
|
+
|
|
2055
|
+
const { isCloudAuthoritative } = await import(
|
|
2056
|
+
"../lib/cloud-authoritative.js",
|
|
2057
|
+
);
|
|
2058
|
+
expect(isCloudAuthoritative("companies/acme/board.json")).toBe(true);
|
|
2059
|
+
|
|
2060
|
+
const result = await share({
|
|
2061
|
+
company: "acme",
|
|
2062
|
+
vaultConfig: mockConfig,
|
|
2063
|
+
hqRoot: tmpDir,
|
|
2064
|
+
paths: [companyRoot],
|
|
2065
|
+
onConflict: "keep",
|
|
2066
|
+
});
|
|
2067
|
+
|
|
2068
|
+
expect(result.conflictPaths).toContain("board.json");
|
|
2069
|
+
expect(fs.readFileSync(boardPath, "utf-8")).toBe(localAppend);
|
|
2070
|
+
expect(s3Module.uploadFile).toHaveBeenCalledWith(
|
|
2071
|
+
expect.anything(),
|
|
2072
|
+
boardPath,
|
|
2073
|
+
"board.json",
|
|
2074
|
+
undefined,
|
|
2075
|
+
{ ifMatch: "old-etag" },
|
|
2076
|
+
);
|
|
2077
|
+
});
|
|
2078
|
+
|
|
1774
2079
|
it("does NOT tombstone symlinks whose readlink target has diverged from the journal (Codex P1 round 4)", async () => {
|
|
1775
2080
|
// Codex review on PR #24 round 4 caught: the round-3 local-edit
|
|
1776
2081
|
// divergence guard only covered regular files (`isFile()` is false
|
|
@@ -2586,7 +2891,7 @@ describe("sync", () => {
|
|
|
2586
2891
|
try {
|
|
2587
2892
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(remoteFiles);
|
|
2588
2893
|
vi.mocked(s3Module.downloadFile).mockImplementation(
|
|
2589
|
-
async (ctx, key, localPath) => {
|
|
2894
|
+
async (ctx, key, localPath, options) => {
|
|
2590
2895
|
if (key === "bulk/file-8.md") {
|
|
2591
2896
|
// This models the next transfer crashing the process. The first
|
|
2592
2897
|
// eight entries must already be durable before the final writer.
|
|
@@ -2594,7 +2899,7 @@ describe("sync", () => {
|
|
|
2594
2899
|
expect(Object.keys(checkpoint.files)).toHaveLength(8);
|
|
2595
2900
|
throw new Error("simulated crash after checkpoint");
|
|
2596
2901
|
}
|
|
2597
|
-
return defaultDownload(ctx, key, localPath);
|
|
2902
|
+
return defaultDownload(ctx, key, localPath, options);
|
|
2598
2903
|
},
|
|
2599
2904
|
);
|
|
2600
2905
|
|
|
@@ -2627,6 +2932,7 @@ describe("sync", () => {
|
|
|
2627
2932
|
expect.anything(),
|
|
2628
2933
|
"bulk/file-8.md",
|
|
2629
2934
|
expect.any(String),
|
|
2935
|
+
expect.objectContaining({ beforeReplace: expect.any(Function) }),
|
|
2630
2936
|
);
|
|
2631
2937
|
} finally {
|
|
2632
2938
|
vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
|