@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.
- package/core/src/portable-md.test.ts +247 -0
- package/core/src/portable-md.ts +118 -1
- package/package.json +1 -1
- package/src/cli.ts +94 -2
- package/src/config.ts +24 -0
- package/src/export-watch.test.ts +99 -0
- package/src/mirror-config.test.ts +328 -0
- package/src/mirror-config.ts +470 -0
- package/src/mirror-deps.ts +88 -0
- package/src/mirror-manager.test.ts +550 -0
- package/src/mirror-manager.ts +521 -0
- package/src/mirror-registry.ts +26 -0
- package/src/mirror-routes.test.ts +380 -0
- package/src/mirror-routes.ts +152 -0
- package/src/routing.test.ts +76 -0
- package/src/routing.ts +46 -0
- package/src/server.ts +52 -0
package/src/export-watch.test.ts
CHANGED
|
@@ -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
|
+
});
|