@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
|
@@ -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 };
|