@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.
@@ -0,0 +1,470 @@
1
+ /**
2
+ * Mirror configuration — the "vault knows about its git projection" surface.
3
+ *
4
+ * Builds on the manual export primitives from vault#346 (`parachute-vault
5
+ * export --watch --git-commit`). This module owns the *persistent* form:
6
+ *
7
+ * - Schema for the `mirror:` block in `~/.parachute/vault/config.yaml`.
8
+ * - Parse + serialize that block alongside the existing global config.
9
+ * - Resolve the on-disk mirror path (internal vs external).
10
+ * - Validate the operator-supplied shape (location enum, external_path
11
+ * existence + git-repo-ness).
12
+ *
13
+ * The lifecycle wiring (boot-time bootstrap, watch loop start/stop/reload)
14
+ * lives in `./mirror-manager.ts`; the HTTP surface lives in
15
+ * `./mirror-routes.ts`. This file is intentionally I/O-light: pure parsing,
16
+ * pure validation, plus the path-resolution helper that needs `path.join`.
17
+ *
18
+ * Phase A1 of the vault-sync arc — see
19
+ * `parachute.computer/design/2026-05-20-vault-as-git-projection.md`.
20
+ */
21
+
22
+ import { existsSync, statSync } from "fs";
23
+ import { join } from "path";
24
+
25
+ import { DEFAULT_COMMIT_TEMPLATE, isGitRepo } from "./export-watch.ts";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Types
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * The two axes of operator choice. See the design doc:
33
+ * - `internal` → vault-managed at `~/.parachute/vault/data/<name>/mirror/`.
34
+ * Hidden under vault's own data dir; recreated on next boot if missing.
35
+ * - `external` → operator-picked path. Visible to the operator; designed
36
+ * for Obsidian / GitHub / shared backups.
37
+ */
38
+ export type MirrorLocation = "internal" | "external";
39
+
40
+ /**
41
+ * The persistent mirror configuration block. Lives under the `mirror:` key
42
+ * in the global config.yaml (one mirror per vault server today — multi-vault
43
+ * mirroring is a future ripple, see open question 2 in the design doc).
44
+ *
45
+ * Field semantics:
46
+ * - `enabled` — master switch. When false (the default for upgrading
47
+ * vaults), no mirror behavior runs at all. The other fields are
48
+ * preserved so the operator can flip enabled back on without losing
49
+ * their location/path/watch settings.
50
+ * - `location` — "internal" or "external". Drives `resolveMirrorPath`.
51
+ * - `external_path` — required when location=external. Operator-picked
52
+ * absolute path. Must exist + be a git repo when first validated.
53
+ * - `watch` — when true, the manager runs the export-watch loop in the
54
+ * vault server process. When false, the mirror gets a one-shot export
55
+ * on boot/config-change only; subsequent updates need an explicit
56
+ * manual export.
57
+ * - `auto_commit` — after each export pass, `git add -A && git commit`.
58
+ * Reuses the existing `runGitCommitCycle` from vault#346.
59
+ * - `auto_push` — after commit, `git push`. Failures non-fatal.
60
+ * - `commit_template` — passed verbatim to `renderCommitMessage`. Same
61
+ * variable set as the CLI: `{{date}}`, `{{notes_changed}}`,
62
+ * `{{plural}}`, `{{first_note_title}}`, `{{vault_name}}`.
63
+ * - `interval_seconds` — watch-loop poll interval. Default 5, matching
64
+ * the CLI flag's default.
65
+ */
66
+ export interface MirrorConfig {
67
+ enabled: boolean;
68
+ location: MirrorLocation;
69
+ external_path: string | null;
70
+ watch: boolean;
71
+ auto_commit: boolean;
72
+ auto_push: boolean;
73
+ commit_template: string;
74
+ interval_seconds: number;
75
+ }
76
+
77
+ /**
78
+ * Default mirror config — what callers see when no `mirror:` block has
79
+ * been written yet. `enabled: false` is the load-bearing default: vaults
80
+ * upgrading across this PR boundary see zero behavior change until they
81
+ * explicitly opt in.
82
+ */
83
+ export function defaultMirrorConfig(): MirrorConfig {
84
+ return {
85
+ enabled: false,
86
+ location: "internal",
87
+ external_path: null,
88
+ watch: false,
89
+ auto_commit: true,
90
+ auto_push: false,
91
+ commit_template: DEFAULT_COMMIT_TEMPLATE,
92
+ interval_seconds: 5,
93
+ };
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // YAML parsing — mirrors the hand-rolled style in config.ts.
98
+ //
99
+ // Format under config.yaml:
100
+ //
101
+ // mirror:
102
+ // enabled: true
103
+ // location: internal
104
+ // external_path: /home/aaron/mirrors/team-brain
105
+ // watch: true
106
+ // auto_commit: true
107
+ // auto_push: false
108
+ // commit_template: "export: {{date}} ({{notes_changed}} note{{plural}})"
109
+ // interval_seconds: 5
110
+ //
111
+ // The block sits next to existing top-level keys (port, default_vault, …).
112
+ // All fields optional; missing fields fall back to defaultMirrorConfig().
113
+ // Parser stops at the next 0-indent line (mirroring the trigger/backup
114
+ // section parsers — same shape, same stop rule).
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Parse the `mirror:` section from a config.yaml string. Returns
119
+ * `undefined` if no section is present — distinct from "section present
120
+ * with defaults" so callers can tell "operator has never touched mirror"
121
+ * apart from "operator set enabled: false explicitly." Phase A1 doesn't
122
+ * yet use that distinction, but it's cheap to preserve.
123
+ */
124
+ export function parseMirrorConfig(yaml: string): MirrorConfig | undefined {
125
+ const startMatch = yaml.match(/^mirror:\s*$/m);
126
+ if (!startMatch) return undefined;
127
+
128
+ const startIdx = (startMatch.index ?? 0) + startMatch[0].length;
129
+ const lines = yaml.slice(startIdx).split("\n");
130
+
131
+ const config = defaultMirrorConfig();
132
+
133
+ for (const line of lines) {
134
+ // Stop at the next top-level key.
135
+ if (line.match(/^\S/) && line.trim().length > 0) break;
136
+ if (line.trim().length === 0) continue;
137
+
138
+ const trimmed = line.trim();
139
+
140
+ const boolField = (
141
+ name: keyof Pick<
142
+ MirrorConfig,
143
+ "enabled" | "watch" | "auto_commit" | "auto_push"
144
+ >,
145
+ ): boolean => {
146
+ const m = trimmed.match(new RegExp(`^${name}:\\s*(true|false)\\s*$`));
147
+ if (m) {
148
+ config[name] = m[1] === "true";
149
+ return true;
150
+ }
151
+ return false;
152
+ };
153
+ if (boolField("enabled")) continue;
154
+ if (boolField("watch")) continue;
155
+ if (boolField("auto_commit")) continue;
156
+ if (boolField("auto_push")) continue;
157
+
158
+ const locationMatch = trimmed.match(/^location:\s*(internal|external)\s*$/);
159
+ if (locationMatch) {
160
+ config.location = locationMatch[1] as MirrorLocation;
161
+ continue;
162
+ }
163
+
164
+ const pathMatch = trimmed.match(/^external_path:\s*(.*)$/);
165
+ if (pathMatch) {
166
+ const raw = pathMatch[1]!.trim();
167
+ if (raw === "" || raw === "null" || raw === "~") {
168
+ config.external_path = null;
169
+ } else {
170
+ // Strip optional surrounding quotes (matches the path-quoting in
171
+ // serializeMirrorConfig defensively for paths with `:` or `#`).
172
+ config.external_path = raw.replace(/^"(.*)"$/, "$1");
173
+ }
174
+ continue;
175
+ }
176
+
177
+ const templateMatch = trimmed.match(/^commit_template:\s*(.*)$/);
178
+ if (templateMatch) {
179
+ const raw = templateMatch[1]!.trim();
180
+ config.commit_template = raw.replace(/^"(.*)"$/, "$1");
181
+ continue;
182
+ }
183
+
184
+ const intervalMatch = trimmed.match(/^interval_seconds:\s*(\d+)\s*$/);
185
+ if (intervalMatch) {
186
+ const n = parseInt(intervalMatch[1]!, 10);
187
+ if (Number.isFinite(n) && n > 0) config.interval_seconds = n;
188
+ continue;
189
+ }
190
+ }
191
+
192
+ return config;
193
+ }
194
+
195
+ /**
196
+ * Serialize a MirrorConfig as YAML lines suitable for appending under the
197
+ * top-level keys of `config.yaml`. Returns the lines without a trailing
198
+ * newline — the caller joins with `\n` and adds its own terminator, same
199
+ * convention as the existing `writeGlobalConfig`.
200
+ */
201
+ export function serializeMirrorConfig(config: MirrorConfig): string[] {
202
+ const lines: string[] = ["mirror:"];
203
+ lines.push(` enabled: ${config.enabled}`);
204
+ lines.push(` location: ${config.location}`);
205
+ // Serialize external_path even when null — keeps the slot visible to
206
+ // operators editing the file by hand. Null renders as the YAML literal,
207
+ // which round-trips back through parseMirrorConfig as `null`.
208
+ if (config.external_path === null) {
209
+ lines.push(" external_path: null");
210
+ } else {
211
+ // Defensive quoting for paths containing `:` or `#` (YAML special
212
+ // characters that would confuse a less-forgiving parser).
213
+ const needsQuote = /[:#]/.test(config.external_path);
214
+ lines.push(
215
+ ` external_path: ${needsQuote ? `"${config.external_path}"` : config.external_path}`,
216
+ );
217
+ }
218
+ lines.push(` watch: ${config.watch}`);
219
+ lines.push(` auto_commit: ${config.auto_commit}`);
220
+ lines.push(` auto_push: ${config.auto_push}`);
221
+ // Templates contain `{{ }}` and frequently `:` — always quote.
222
+ lines.push(` commit_template: "${config.commit_template.replace(/"/g, '\\"')}"`);
223
+ lines.push(` interval_seconds: ${config.interval_seconds}`);
224
+ return lines;
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Path resolution
229
+ // ---------------------------------------------------------------------------
230
+
231
+ /**
232
+ * Resolve where this vault's mirror lives on disk.
233
+ *
234
+ * - `internal` → `<vaultDataDir>/mirror/` — under the vault's own data
235
+ * dir, the same hierarchy the SQLite DB + assets live in. Hidden by
236
+ * convention; the operator never has to think about where it lives.
237
+ * - `external` → `config.external_path` verbatim. Caller is responsible
238
+ * for having validated the path before this point — `resolveMirrorPath`
239
+ * trusts the config.
240
+ *
241
+ * `vaultDataDir` is injected (rather than computed from `vaultDir()`) so
242
+ * this module doesn't depend on `./config.ts` — that file imports our
243
+ * types reflexively, and breaking the cycle keeps the boot path clean.
244
+ *
245
+ * Returns `null` for external + no path set; the manager treats that as
246
+ * "mirror disabled in effect" rather than crashing.
247
+ */
248
+ export function resolveMirrorPath(
249
+ vaultDataDir: string,
250
+ config: MirrorConfig,
251
+ ): string | null {
252
+ if (config.location === "internal") {
253
+ return join(vaultDataDir, "mirror");
254
+ }
255
+ if (!config.external_path) return null;
256
+ return config.external_path;
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Validation
261
+ //
262
+ // Two surfaces:
263
+ // - `validateMirrorConfigShape` — pure, no I/O. Sanity-checks the JSON
264
+ // shape (location enum, external_path required when external, etc.).
265
+ // The HTTP PUT handler uses this for fast-fail validation before
266
+ // touching the filesystem.
267
+ // - `validateExternalPath` — async, hits the filesystem. Verifies the
268
+ // external path exists + is a git working tree. Reused by the PUT
269
+ // handler when location=external.
270
+ // ---------------------------------------------------------------------------
271
+
272
+ export interface ShapeValidationOk { ok: true; config: MirrorConfig; }
273
+ export interface ShapeValidationError {
274
+ ok: false;
275
+ /** Human-readable, actionable error message. Surfaced verbatim in 400s. */
276
+ error: string;
277
+ /** Field that triggered the rejection (when localized to one). */
278
+ field?: keyof MirrorConfig;
279
+ }
280
+ export type ShapeValidation = ShapeValidationOk | ShapeValidationError;
281
+
282
+ /**
283
+ * Validate + normalize an operator-supplied mirror config blob (e.g. from
284
+ * a `PUT /admin/mirror` JSON body). Fills missing fields from
285
+ * `defaultMirrorConfig()`; rejects values that don't conform to the
286
+ * declared types.
287
+ *
288
+ * Does NOT touch the filesystem — operators get a fast 400 on shape
289
+ * errors before vault attempts any filesystem work. Filesystem-level
290
+ * validation (path exists, is a git repo) lives in `validateExternalPath`.
291
+ */
292
+ export function validateMirrorConfigShape(
293
+ input: unknown,
294
+ ): ShapeValidation {
295
+ if (input === null || typeof input !== "object") {
296
+ return {
297
+ ok: false,
298
+ error: "Mirror config must be a JSON object.",
299
+ };
300
+ }
301
+ const blob = input as Record<string, unknown>;
302
+ const out = defaultMirrorConfig();
303
+
304
+ if ("enabled" in blob) {
305
+ if (typeof blob.enabled !== "boolean") {
306
+ return { ok: false, field: "enabled", error: "`enabled` must be boolean." };
307
+ }
308
+ out.enabled = blob.enabled;
309
+ }
310
+
311
+ if ("location" in blob) {
312
+ if (blob.location !== "internal" && blob.location !== "external") {
313
+ return {
314
+ ok: false,
315
+ field: "location",
316
+ error: '`location` must be "internal" or "external".',
317
+ };
318
+ }
319
+ out.location = blob.location;
320
+ }
321
+
322
+ if ("external_path" in blob) {
323
+ if (blob.external_path === null) {
324
+ out.external_path = null;
325
+ } else if (typeof blob.external_path === "string") {
326
+ const trimmed = blob.external_path.trim();
327
+ out.external_path = trimmed.length === 0 ? null : trimmed;
328
+ } else {
329
+ return {
330
+ ok: false,
331
+ field: "external_path",
332
+ error: "`external_path` must be a string or null.",
333
+ };
334
+ }
335
+ }
336
+
337
+ if ("watch" in blob) {
338
+ if (typeof blob.watch !== "boolean") {
339
+ return { ok: false, field: "watch", error: "`watch` must be boolean." };
340
+ }
341
+ out.watch = blob.watch;
342
+ }
343
+
344
+ if ("auto_commit" in blob) {
345
+ if (typeof blob.auto_commit !== "boolean") {
346
+ return {
347
+ ok: false,
348
+ field: "auto_commit",
349
+ error: "`auto_commit` must be boolean.",
350
+ };
351
+ }
352
+ out.auto_commit = blob.auto_commit;
353
+ }
354
+
355
+ if ("auto_push" in blob) {
356
+ if (typeof blob.auto_push !== "boolean") {
357
+ return {
358
+ ok: false,
359
+ field: "auto_push",
360
+ error: "`auto_push` must be boolean.",
361
+ };
362
+ }
363
+ out.auto_push = blob.auto_push;
364
+ }
365
+
366
+ if ("commit_template" in blob) {
367
+ if (typeof blob.commit_template !== "string") {
368
+ return {
369
+ ok: false,
370
+ field: "commit_template",
371
+ error: "`commit_template` must be a string.",
372
+ };
373
+ }
374
+ const trimmed = blob.commit_template.trim();
375
+ if (trimmed.length === 0) {
376
+ return {
377
+ ok: false,
378
+ field: "commit_template",
379
+ error: "`commit_template` cannot be empty.",
380
+ };
381
+ }
382
+ out.commit_template = blob.commit_template;
383
+ }
384
+
385
+ if ("interval_seconds" in blob) {
386
+ if (
387
+ typeof blob.interval_seconds !== "number" ||
388
+ !Number.isFinite(blob.interval_seconds) ||
389
+ blob.interval_seconds <= 0 ||
390
+ !Number.isInteger(blob.interval_seconds)
391
+ ) {
392
+ return {
393
+ ok: false,
394
+ field: "interval_seconds",
395
+ error: "`interval_seconds` must be a positive integer.",
396
+ };
397
+ }
398
+ out.interval_seconds = blob.interval_seconds;
399
+ }
400
+
401
+ // Cross-field rule: external requires external_path — but ONLY when
402
+ // the mirror is enabled. Disable-only PUTs (and disabled persisted
403
+ // configs in general) shouldn't fail validation on path-related
404
+ // issues; the operator might be turning off a mirror whose external
405
+ // path went missing without first fixing the path. The filesystem
406
+ // check in `validateExternalPath` is also gated on enabled at the
407
+ // route layer for the same reason.
408
+ if (out.enabled && out.location === "external" && !out.external_path) {
409
+ return {
410
+ ok: false,
411
+ field: "external_path",
412
+ error:
413
+ '`external_path` is required when `location` is "external" and `enabled` is true. Provide an absolute path to an existing git repository.',
414
+ };
415
+ }
416
+
417
+ return { ok: true, config: out };
418
+ }
419
+
420
+ export interface PathValidationOk { ok: true; resolved_path: string; }
421
+ export interface PathValidationError {
422
+ ok: false;
423
+ /** Human-readable, actionable. Suggests the next step where possible. */
424
+ error: string;
425
+ }
426
+ export type PathValidation = PathValidationOk | PathValidationError;
427
+
428
+ /**
429
+ * Validate an external mirror path. Checks:
430
+ * - Path exists on the filesystem.
431
+ * - Path resolves to a directory (not a file or symlink-to-file).
432
+ * - Path is a git working tree (`git rev-parse --is-inside-work-tree`).
433
+ *
434
+ * Returns actionable error messages — the operator gets enough to fix the
435
+ * problem without reading vault logs. Use case: `PUT /admin/mirror` when
436
+ * `location: external`.
437
+ */
438
+ export async function validateExternalPath(
439
+ externalPath: string,
440
+ ): Promise<PathValidation> {
441
+ if (!existsSync(externalPath)) {
442
+ return {
443
+ ok: false,
444
+ error: `Path "${externalPath}" doesn't exist. Create the directory and \`git init\` it first, then re-submit.`,
445
+ };
446
+ }
447
+ let stat;
448
+ try {
449
+ stat = statSync(externalPath);
450
+ } catch (err) {
451
+ return {
452
+ ok: false,
453
+ error: `Could not stat "${externalPath}": ${(err as Error).message ?? err}`,
454
+ };
455
+ }
456
+ if (!stat.isDirectory()) {
457
+ return {
458
+ ok: false,
459
+ error: `Path "${externalPath}" exists but isn't a directory. Pick a directory path.`,
460
+ };
461
+ }
462
+ const inGitRepo = await isGitRepo(externalPath);
463
+ if (!inGitRepo) {
464
+ return {
465
+ ok: false,
466
+ error: `Path "${externalPath}" exists but isn't a git repository. Run \`git init\` inside it (or pick a path under an existing repo) and re-submit.`,
467
+ };
468
+ }
469
+ return { ok: true, resolved_path: externalPath };
470
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Production wiring for the mirror manager — builds a `MirrorDeps` from
3
+ * the live vault store + config writers.
4
+ *
5
+ * Kept separate from `mirror-manager.ts` so the manager stays mock-
6
+ * friendly: tests pass fake deps directly, never importing real
7
+ * vault-store + portable-md.
8
+ */
9
+
10
+ import { exportVaultToDir } from "../core/src/portable-md.ts";
11
+
12
+ import { readGlobalConfig, writeGlobalConfig, readVaultConfig } from "./config.ts";
13
+ import { defaultMirrorConfig, type MirrorConfig } from "./mirror-config.ts";
14
+ import type { MirrorDeps } from "./mirror-manager.ts";
15
+ import { assetsDir } from "./routes.ts";
16
+ import { getVaultStore } from "./vault-store.ts";
17
+
18
+ /**
19
+ * Build production MirrorDeps for a given vault.
20
+ *
21
+ * - `runExport` → `core/src/portable-md.ts:exportVaultToDir`. The same
22
+ * entry point the CLI's `cmdExport` uses; behavior matches the manual
23
+ * CLI mode exactly.
24
+ * - `firstChangedNoteTitle` → DB query for the most recent note with
25
+ * `updated_at >= cursor`. Identical to the CLI helper.
26
+ * - `readMirrorConfig` / `writeMirrorConfig` → round-trip through
27
+ * `readGlobalConfig` + `writeGlobalConfig`, preserving the rest of
28
+ * the global config file atomically.
29
+ */
30
+ export function buildMirrorDeps(vaultName: string): MirrorDeps {
31
+ return {
32
+ vaultName,
33
+ runExport: async ({ outDir, sinceCursor }) => {
34
+ const store = getVaultStore(vaultName);
35
+ const vaultConfig = readVaultConfig(vaultName);
36
+ const stats = await exportVaultToDir(store, {
37
+ outDir,
38
+ vaultName,
39
+ assetsDir: assetsDir(vaultName),
40
+ ...(vaultConfig?.description ? { vaultDescription: vaultConfig.description } : {}),
41
+ ...(sinceCursor ? { since: sinceCursor } : {}),
42
+ });
43
+ return { notes: stats.notes };
44
+ },
45
+ firstChangedNoteTitle: async (cursor) => {
46
+ if (!cursor) return "";
47
+ try {
48
+ const store = getVaultStore(vaultName);
49
+ const notes = await store.queryNotes({
50
+ limit: 1,
51
+ sort: "asc",
52
+ dateFilter: { field: "updated_at", from: cursor },
53
+ });
54
+ return notes[0]?.path ?? notes[0]?.id ?? "";
55
+ } catch {
56
+ return "";
57
+ }
58
+ },
59
+ readMirrorConfig: () => readGlobalConfig().mirror,
60
+ writeMirrorConfig: (config: MirrorConfig) => {
61
+ const global = readGlobalConfig();
62
+ global.mirror = config;
63
+ writeGlobalConfig(global);
64
+ },
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Resolve the mirror's owning vault. Today the mirror is per-server
70
+ * (single config block in `config.yaml`), and the natural binding is
71
+ * `default_vault` (the same vault the CLI + MCP wire up by default).
72
+ * If no default is set, fall back to the first listed vault.
73
+ *
74
+ * Multi-vault mirror routing is future work (open question 2 in the
75
+ * design doc); this helper localizes the binding decision so a future
76
+ * refactor only touches one site.
77
+ */
78
+ export function resolveMirrorVaultName(
79
+ listVaults: () => string[],
80
+ ): string | null {
81
+ const global = readGlobalConfig();
82
+ if (global.default_vault) return global.default_vault;
83
+ const vaults = listVaults();
84
+ return vaults[0] ?? null;
85
+ }
86
+
87
+ /** Re-export for callers that want defaults without importing two modules. */
88
+ export { defaultMirrorConfig };