@openparachute/vault 0.4.6 → 0.4.7-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.4.6",
3
+ "version": "0.4.7-rc.1",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/config.ts CHANGED
@@ -33,6 +33,12 @@ import { join } from "path";
33
33
  import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync } from "fs";
34
34
  import crypto from "node:crypto";
35
35
 
36
+ import {
37
+ parseMirrorConfig as parseMirrorSectionFromYaml,
38
+ serializeMirrorConfig as serializeMirrorSection,
39
+ type MirrorConfig as MirrorConfigType,
40
+ } from "./mirror-config.ts";
41
+
36
42
  // ---------------------------------------------------------------------------
37
43
  // Paths
38
44
  //
@@ -263,6 +269,14 @@ export interface GlobalConfig {
263
269
  autostart?: boolean;
264
270
  /** Backup configuration: schedule, retention, destinations. */
265
271
  backup?: BackupConfig;
272
+ /**
273
+ * Persistent vault-managed mirror configuration (vault-sync Phase A1).
274
+ * Unset when the operator has never touched the mirror block; defaults to
275
+ * `enabled: false` semantics in that case. When `enabled: true`, the
276
+ * vault server bootstraps and optionally watches a git mirror at the
277
+ * resolved path. See `./mirror-config.ts`.
278
+ */
279
+ mirror?: MirrorConfigType;
266
280
  }
267
281
 
268
282
  // ---------------------------------------------------------------------------
@@ -1187,6 +1201,12 @@ export function readGlobalConfig(): GlobalConfig {
1187
1201
  // Parse backup section
1188
1202
  config.backup = parseBackup(yaml);
1189
1203
 
1204
+ // Parse mirror section (vault-sync Phase A1). Imported lazily via a
1205
+ // narrow helper to keep config.ts free of a top-level cycle into the
1206
+ // mirror module — `mirror-config.ts` imports `vaultDir` from here.
1207
+ const mirror = parseMirrorSectionFromYaml(yaml);
1208
+ if (mirror) config.mirror = mirror;
1209
+
1190
1210
  return config;
1191
1211
  }
1192
1212
  } catch {}
@@ -1273,6 +1293,10 @@ export function writeGlobalConfig(config: GlobalConfig): void {
1273
1293
  lines.push(...serializeBackup(config.backup));
1274
1294
  }
1275
1295
 
1296
+ if (config.mirror) {
1297
+ lines.push(...serializeMirrorSection(config.mirror));
1298
+ }
1299
+
1276
1300
  // 0600 — owner read/write only. This file may contain the bcrypt password
1277
1301
  // hash and plaintext TOTP secret; it must not be world- or group-readable.
1278
1302
  writeFileSync(globalConfigPath(), lines.join("\n") + "\n", { mode: 0o600 });
@@ -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
+ });