@openparachute/vault 0.4.6 → 0.4.7-rc.2

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.
@@ -763,6 +763,105 @@ describe("export CLI: --watch", () => {
763
763
  30_000,
764
764
  );
765
765
 
766
+ test(
767
+ "--strict-case-collision: mid-watch collision stops the loop with the full error + hint",
768
+ async () => {
769
+ // vault#350 reviewer fix. Before: the watch polling timer's
770
+ // generic catch swallowed a CaseCollisionError thrown by a
771
+ // post-initial-export poll, logging only `[watch] export error:
772
+ // ...` and continuing to spin. The strict-mode guarantee
773
+ // ("refuse to continue when a collision appears") evaporated
774
+ // after the initial export.
775
+ //
776
+ // Scenario: start --watch --strict-case-collision against a
777
+ // vault whose initial state has no collisions (so the watch
778
+ // loop boots). Write a colliding note out-of-band. On the next
779
+ // poll cycle, runCycle throws CaseCollisionError; the watch
780
+ // catch path should now (a) print the full err.message to
781
+ // stderr including every colliding path, (b) print the
782
+ // actionable hint, (c) exit non-zero.
783
+ //
784
+ // The CLI doesn't expose `caseSensitiveOverride`, so this test
785
+ // is meaningful only on a case-insensitive FS (macOS APFS
786
+ // default, Windows NTFS default). On a case-sensitive Linux
787
+ // ext4, the pre-scan is a no-op by design and the path under
788
+ // test never fires — skip rather than assert a behavior that
789
+ // can't manifest there.
790
+ const { probeCaseSensitive } = await import("../core/src/portable-md.ts");
791
+ if (probeCaseSensitive(exportDir)) {
792
+ console.log(
793
+ "skipping mid-watch strict-collision test on case-sensitive FS — the strict pre-scan is a no-op here",
794
+ );
795
+ return;
796
+ }
797
+ const watch = spawnWatchCli(
798
+ [
799
+ "export",
800
+ exportDir,
801
+ "--watch",
802
+ "--interval",
803
+ "1",
804
+ "--strict-case-collision",
805
+ ],
806
+ tmp,
807
+ );
808
+ try {
809
+ // Initial export must succeed (seed has no collision).
810
+ await watch.awaitLine((l) => l.includes("Exported 1 note"), 10_000);
811
+ await watch.awaitLine((l) => l.includes("[watch] polling every 1s"), 5_000);
812
+
813
+ // Inject a collision: existing seed is `Inbox/seed`; write
814
+ // `Inbox/SEED` so the lowercased (path, ext) key collides.
815
+ const { clearVaultStoreCache, getVaultStore } = await import("./vault-store.ts");
816
+ clearVaultStoreCache();
817
+ const store = getVaultStore("default");
818
+ await store.createNote("# upper\n", {
819
+ id: "01HZB222222222222222222222",
820
+ path: "Inbox/SEED",
821
+ });
822
+ clearVaultStoreCache();
823
+ } catch (err) {
824
+ watch.proc.kill("SIGKILL");
825
+ throw err;
826
+ }
827
+
828
+ // The strict-mode catch path exits the process. Wait for it.
829
+ // Use a guarded race so a hung process doesn't eat the full
830
+ // 30s suite budget — surface a useful failure message instead.
831
+ const exit = await Promise.race([
832
+ watch.proc.exited,
833
+ new Promise<number>((_, reject) =>
834
+ setTimeout(
835
+ () =>
836
+ reject(
837
+ new Error(
838
+ `CLI did not exit within 15s of collision injection.\n` +
839
+ `stdout:\n${watch.seenLines.join("\n")}\n` +
840
+ `stderr:\n${watch.seenStderr.join("\n")}`,
841
+ ),
842
+ ),
843
+ 15_000,
844
+ ),
845
+ ),
846
+ ]).catch((err) => {
847
+ watch.proc.kill("SIGKILL");
848
+ throw err;
849
+ });
850
+ // Non-zero exit: strict mode refused to continue.
851
+ expect(exit).not.toBe(0);
852
+
853
+ const stderr = watch.seenStderr.join("\n");
854
+ // Full collision message: header line + every colliding path.
855
+ expect(stderr).toContain("case-collision detected");
856
+ expect(stderr).toContain("Inbox/seed.md");
857
+ expect(stderr).toContain("Inbox/SEED.md");
858
+ // Actionable hint (the new line added by this fix).
859
+ expect(stderr).toContain("Resolve the collision in the vault");
860
+ expect(stderr).toContain("--strict-case-collision");
861
+ },
862
+ 30_000,
863
+ );
864
+
766
865
  test(
767
866
  "--watch + --git-commit: vault write → re-export → auto-commit → continues",
768
867
  async () => {
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Tests for the mirror config schema, parse/serialize, validation, and
3
+ * path resolution helpers (vault-sync Phase A1).
4
+ *
5
+ * All pure unit tests except the path-validation ones, which spawn `git`
6
+ * against tempdirs to exercise the real filesystem check.
7
+ */
8
+
9
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+
14
+ import {
15
+ defaultMirrorConfig,
16
+ parseMirrorConfig,
17
+ resolveMirrorPath,
18
+ serializeMirrorConfig,
19
+ validateExternalPath,
20
+ validateMirrorConfigShape,
21
+ } from "./mirror-config.ts";
22
+
23
+ function tmp(prefix: string): string {
24
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
25
+ }
26
+
27
+ function initRepo(dir: string): void {
28
+ Bun.spawnSync(["git", "init", "-q", "-b", "main"], { cwd: dir });
29
+ Bun.spawnSync(["git", "config", "user.email", "t@p.computer"], { cwd: dir });
30
+ Bun.spawnSync(["git", "config", "user.name", "T P"], { cwd: dir });
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Defaults
35
+ // ---------------------------------------------------------------------------
36
+
37
+ describe("defaultMirrorConfig", () => {
38
+ test("defaults to enabled=false — upgrading vaults see zero behavior change", () => {
39
+ const d = defaultMirrorConfig();
40
+ expect(d.enabled).toBe(false);
41
+ expect(d.location).toBe("internal");
42
+ expect(d.external_path).toBeNull();
43
+ expect(d.watch).toBe(false);
44
+ expect(d.auto_commit).toBe(true);
45
+ expect(d.auto_push).toBe(false);
46
+ expect(d.commit_template).toContain("{{date}}");
47
+ expect(d.interval_seconds).toBe(5);
48
+ });
49
+ });
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Parse + serialize
53
+ // ---------------------------------------------------------------------------
54
+
55
+ describe("parseMirrorConfig", () => {
56
+ test("returns undefined when no `mirror:` section is present", () => {
57
+ expect(parseMirrorConfig("port: 1940\n")).toBeUndefined();
58
+ expect(parseMirrorConfig("")).toBeUndefined();
59
+ });
60
+
61
+ test("parses a fully-specified mirror block", () => {
62
+ const yaml = [
63
+ "port: 1940",
64
+ "mirror:",
65
+ " enabled: true",
66
+ " location: external",
67
+ " external_path: /home/aaron/mirrors/gitcoin",
68
+ " watch: true",
69
+ " auto_commit: true",
70
+ " auto_push: true",
71
+ ' commit_template: "vault: {{notes_changed}} note{{plural}}"',
72
+ " interval_seconds: 10",
73
+ ].join("\n");
74
+ const m = parseMirrorConfig(yaml);
75
+ expect(m).toEqual({
76
+ enabled: true,
77
+ location: "external",
78
+ external_path: "/home/aaron/mirrors/gitcoin",
79
+ watch: true,
80
+ auto_commit: true,
81
+ auto_push: true,
82
+ commit_template: "vault: {{notes_changed}} note{{plural}}",
83
+ interval_seconds: 10,
84
+ });
85
+ });
86
+
87
+ test("partial mirror block fills missing fields from defaults", () => {
88
+ const yaml = "mirror:\n enabled: true\n watch: true\n";
89
+ const m = parseMirrorConfig(yaml)!;
90
+ expect(m.enabled).toBe(true);
91
+ expect(m.watch).toBe(true);
92
+ expect(m.location).toBe("internal");
93
+ expect(m.auto_commit).toBe(true);
94
+ });
95
+
96
+ test("external_path: null is interpreted as null", () => {
97
+ const m = parseMirrorConfig(
98
+ "mirror:\n enabled: true\n external_path: null\n",
99
+ )!;
100
+ expect(m.external_path).toBeNull();
101
+ });
102
+
103
+ test("stops at next top-level key", () => {
104
+ const yaml = [
105
+ "mirror:",
106
+ " enabled: true",
107
+ " location: external",
108
+ " external_path: /a/b",
109
+ "port: 1940",
110
+ ].join("\n");
111
+ const m = parseMirrorConfig(yaml)!;
112
+ expect(m.external_path).toBe("/a/b");
113
+ expect(m.enabled).toBe(true);
114
+ });
115
+ });
116
+
117
+ describe("serializeMirrorConfig", () => {
118
+ test("round-trips through parseMirrorConfig", () => {
119
+ const original = {
120
+ enabled: true,
121
+ location: "external" as const,
122
+ external_path: "/home/aaron/team-brain",
123
+ watch: true,
124
+ auto_commit: true,
125
+ auto_push: false,
126
+ commit_template: "export: {{date}} ({{notes_changed}} note{{plural}})",
127
+ interval_seconds: 5,
128
+ };
129
+ const yaml = serializeMirrorConfig(original).join("\n") + "\n";
130
+ const parsed = parseMirrorConfig(yaml);
131
+ expect(parsed).toEqual(original);
132
+ });
133
+
134
+ test("serializes null external_path explicitly", () => {
135
+ const lines = serializeMirrorConfig({
136
+ ...defaultMirrorConfig(),
137
+ enabled: true,
138
+ });
139
+ expect(lines.some((l) => l === " external_path: null")).toBe(true);
140
+ });
141
+
142
+ test("quotes paths with colons or hashes", () => {
143
+ const lines = serializeMirrorConfig({
144
+ ...defaultMirrorConfig(),
145
+ enabled: true,
146
+ location: "external",
147
+ external_path: "/path/with: colon",
148
+ });
149
+ expect(lines.some((l) => l.includes('"/path/with: colon"'))).toBe(true);
150
+ });
151
+ });
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Path resolution
155
+ // ---------------------------------------------------------------------------
156
+
157
+ describe("resolveMirrorPath", () => {
158
+ test("internal resolves to <vaultDataDir>/mirror", () => {
159
+ const p = resolveMirrorPath("/var/data/default", {
160
+ ...defaultMirrorConfig(),
161
+ enabled: true,
162
+ location: "internal",
163
+ });
164
+ expect(p).toBe("/var/data/default/mirror");
165
+ });
166
+
167
+ test("external returns the operator path verbatim", () => {
168
+ const p = resolveMirrorPath("/ignored", {
169
+ ...defaultMirrorConfig(),
170
+ enabled: true,
171
+ location: "external",
172
+ external_path: "/home/aaron/notes",
173
+ });
174
+ expect(p).toBe("/home/aaron/notes");
175
+ });
176
+
177
+ test("external + no path → null (manager treats as soft-disabled)", () => {
178
+ const p = resolveMirrorPath("/ignored", {
179
+ ...defaultMirrorConfig(),
180
+ enabled: true,
181
+ location: "external",
182
+ external_path: null,
183
+ });
184
+ expect(p).toBeNull();
185
+ });
186
+ });
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Shape validation
190
+ // ---------------------------------------------------------------------------
191
+
192
+ describe("validateMirrorConfigShape", () => {
193
+ test("accepts the minimal shape (empty object → defaults)", () => {
194
+ const r = validateMirrorConfigShape({});
195
+ expect(r.ok).toBe(true);
196
+ if (r.ok) {
197
+ expect(r.config).toEqual(defaultMirrorConfig());
198
+ }
199
+ });
200
+
201
+ test("rejects non-objects", () => {
202
+ expect(validateMirrorConfigShape(null).ok).toBe(false);
203
+ expect(validateMirrorConfigShape("hi").ok).toBe(false);
204
+ expect(validateMirrorConfigShape(42).ok).toBe(false);
205
+ });
206
+
207
+ test("rejects unknown location", () => {
208
+ const r = validateMirrorConfigShape({ location: "wherever" });
209
+ expect(r.ok).toBe(false);
210
+ if (!r.ok) expect(r.field).toBe("location");
211
+ });
212
+
213
+ test("rejects external + missing external_path", () => {
214
+ const r = validateMirrorConfigShape({
215
+ enabled: true,
216
+ location: "external",
217
+ });
218
+ expect(r.ok).toBe(false);
219
+ if (!r.ok) expect(r.field).toBe("external_path");
220
+ });
221
+
222
+ test("accepts external + missing external_path when disabled (operator turning off a broken mirror)", () => {
223
+ // Regression for the reviewer-flagged disable-only case: an operator
224
+ // PUTting `{enabled: false, location: "external"}` (no path) must
225
+ // succeed. Disable should never fail validation on path issues.
226
+ const r = validateMirrorConfigShape({
227
+ enabled: false,
228
+ location: "external",
229
+ });
230
+ expect(r.ok).toBe(true);
231
+ if (r.ok) {
232
+ expect(r.config.enabled).toBe(false);
233
+ expect(r.config.location).toBe("external");
234
+ expect(r.config.external_path).toBeNull();
235
+ }
236
+ });
237
+
238
+ test("accepts external + external_path", () => {
239
+ const r = validateMirrorConfigShape({
240
+ enabled: true,
241
+ location: "external",
242
+ external_path: "/tmp/foo",
243
+ });
244
+ expect(r.ok).toBe(true);
245
+ if (r.ok) expect(r.config.external_path).toBe("/tmp/foo");
246
+ });
247
+
248
+ test("rejects non-boolean enabled", () => {
249
+ const r = validateMirrorConfigShape({ enabled: "yes" });
250
+ expect(r.ok).toBe(false);
251
+ if (!r.ok) expect(r.field).toBe("enabled");
252
+ });
253
+
254
+ test("rejects non-integer interval_seconds", () => {
255
+ const r = validateMirrorConfigShape({ interval_seconds: 0.5 });
256
+ expect(r.ok).toBe(false);
257
+ if (!r.ok) expect(r.field).toBe("interval_seconds");
258
+ });
259
+
260
+ test("rejects empty commit_template", () => {
261
+ const r = validateMirrorConfigShape({ commit_template: " " });
262
+ expect(r.ok).toBe(false);
263
+ if (!r.ok) expect(r.field).toBe("commit_template");
264
+ });
265
+
266
+ test("trims external_path whitespace; with internal location empty trim → null", () => {
267
+ const r = validateMirrorConfigShape({
268
+ enabled: false,
269
+ location: "internal",
270
+ external_path: " ",
271
+ });
272
+ expect(r.ok).toBe(true);
273
+ if (r.ok) expect(r.config.external_path).toBeNull();
274
+ });
275
+
276
+ test("trims external_path whitespace on non-empty value", () => {
277
+ const r = validateMirrorConfigShape({
278
+ enabled: true,
279
+ location: "external",
280
+ external_path: " /tmp/foo ",
281
+ });
282
+ expect(r.ok).toBe(true);
283
+ if (r.ok) expect(r.config.external_path).toBe("/tmp/foo");
284
+ });
285
+ });
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // validateExternalPath — filesystem-touching
289
+ // ---------------------------------------------------------------------------
290
+
291
+ describe("validateExternalPath", () => {
292
+ let dir: string;
293
+ afterEach(() => {
294
+ if (dir) fs.rmSync(dir, { recursive: true, force: true });
295
+ });
296
+
297
+ test("rejects missing path with actionable error", async () => {
298
+ dir = tmp("mirror-validate-");
299
+ const missing = path.join(dir, "nope");
300
+ const r = await validateExternalPath(missing);
301
+ expect(r.ok).toBe(false);
302
+ if (!r.ok) expect(r.error).toContain("doesn't exist");
303
+ });
304
+
305
+ test("rejects path-is-a-file", async () => {
306
+ dir = tmp("mirror-validate-file-");
307
+ const file = path.join(dir, "f.txt");
308
+ fs.writeFileSync(file, "x");
309
+ const r = await validateExternalPath(file);
310
+ expect(r.ok).toBe(false);
311
+ if (!r.ok) expect(r.error).toContain("isn't a directory");
312
+ });
313
+
314
+ test("rejects existing-dir but not a git repo", async () => {
315
+ dir = tmp("mirror-validate-nogit-");
316
+ const r = await validateExternalPath(dir);
317
+ expect(r.ok).toBe(false);
318
+ if (!r.ok) expect(r.error).toContain("isn't a git repository");
319
+ });
320
+
321
+ test("accepts existing-dir git repo", async () => {
322
+ dir = tmp("mirror-validate-git-");
323
+ initRepo(dir);
324
+ const r = await validateExternalPath(dir);
325
+ expect(r.ok).toBe(true);
326
+ if (r.ok) expect(r.resolved_path).toBe(dir);
327
+ });
328
+ });