@openparachute/vault 0.4.8 → 0.4.9-rc.11

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.
Files changed (58) hide show
  1. package/core/src/core.test.ts +4 -1
  2. package/core/src/hooks.test.ts +320 -1
  3. package/core/src/hooks.ts +243 -38
  4. package/core/src/indexed-fields.test.ts +151 -0
  5. package/core/src/indexed-fields.ts +98 -0
  6. package/core/src/mcp.ts +99 -41
  7. package/core/src/notes.ts +26 -2
  8. package/core/src/portable-md.test.ts +304 -1
  9. package/core/src/portable-md.ts +418 -2
  10. package/core/src/schema.ts +114 -2
  11. package/core/src/store.ts +185 -2
  12. package/core/src/types.ts +28 -0
  13. package/package.json +2 -2
  14. package/src/auth-hub-jwt.test.ts +147 -0
  15. package/src/auth.ts +121 -1
  16. package/src/auto-transcribe.test.ts +7 -2
  17. package/src/auto-transcribe.ts +6 -2
  18. package/src/cli.ts +131 -36
  19. package/src/config.ts +12 -4
  20. package/src/export-watch.test.ts +74 -0
  21. package/src/export-watch.ts +108 -7
  22. package/src/github-device-flow.test.ts +404 -0
  23. package/src/github-device-flow.ts +415 -0
  24. package/src/hub-jwt.test.ts +27 -2
  25. package/src/hub-jwt.ts +10 -0
  26. package/src/mcp-http.ts +48 -39
  27. package/src/mcp-install-interactive.test.ts +10 -21
  28. package/src/mcp-install-interactive.ts +12 -21
  29. package/src/mcp-install.test.ts +141 -30
  30. package/src/mcp-install.ts +109 -3
  31. package/src/mcp-tools.ts +460 -3
  32. package/src/mirror-config.test.ts +277 -14
  33. package/src/mirror-config.ts +482 -31
  34. package/src/mirror-credentials.test.ts +601 -0
  35. package/src/mirror-credentials.ts +700 -0
  36. package/src/mirror-deps.ts +67 -17
  37. package/src/mirror-import.test.ts +550 -0
  38. package/src/mirror-import.ts +487 -0
  39. package/src/mirror-manager.test.ts +423 -12
  40. package/src/mirror-manager.ts +621 -72
  41. package/src/mirror-per-vault.test.ts +519 -0
  42. package/src/mirror-registry.ts +91 -14
  43. package/src/mirror-routes.test.ts +966 -10
  44. package/src/mirror-routes.ts +1111 -7
  45. package/src/module-config.ts +11 -5
  46. package/src/routes.ts +38 -1
  47. package/src/routing.test.ts +92 -1
  48. package/src/routing.ts +193 -20
  49. package/src/server.ts +116 -35
  50. package/src/storage.test.ts +132 -7
  51. package/src/token-store.ts +300 -5
  52. package/src/transcription-worker.ts +9 -4
  53. package/src/triggers.ts +16 -3
  54. package/src/vault.test.ts +681 -2
  55. package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
  56. package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
@@ -4,12 +4,25 @@
4
4
  * Builds on the manual export primitives from vault#346 (`parachute-vault
5
5
  * export --watch --git-commit`). This module owns the *persistent* form:
6
6
  *
7
- * - Schema for the `mirror:` block in `~/.parachute/vault/config.yaml`.
8
- * - Parse + serialize that block alongside the existing global config.
7
+ * - Schema for the per-vault mirror config block.
8
+ * - Parse + serialize that block.
9
+ * - Per-vault read/write of the config to `data/<vault>/mirror-config.yaml`
10
+ * (vault#400 — see below).
9
11
  * - Resolve the on-disk mirror path (internal vs external).
10
12
  * - Validate the operator-supplied shape (location enum, external_path
11
13
  * existence + git-repo-ness).
12
14
  *
15
+ * **Per-vault config (vault#400).** Before vault#400, mirror config lived in a
16
+ * SINGLE server-wide `mirror:` block in `~/.parachute/vault/config.yaml`.
17
+ * That made every vault's mirror page show the same config + the same git
18
+ * remote, and configuring vault B clobbered vault A. vault#399 had already
19
+ * moved credential STORAGE per-vault; vault#400 completes the job by moving
20
+ * the CONFIG block to `data/<vault>/mirror-config.yaml` — alongside the
21
+ * per-vault credentials file + the SQLite DB. The legacy server-wide
22
+ * `mirror:` block is migrated to its owning vault on boot (default/first
23
+ * vault, matching how the single server-wide mirror was bound); other
24
+ * vaults start with no mirror config. See `migrateLegacyServerWideConfig`.
25
+ *
13
26
  * The lifecycle wiring (boot-time bootstrap, watch loop start/stop/reload)
14
27
  * lives in `./mirror-manager.ts`; the HTTP surface lives in
15
28
  * `./mirror-routes.ts`. This file is intentionally I/O-light: pure parsing,
@@ -19,10 +32,19 @@
19
32
  * `parachute.computer/design/2026-05-20-vault-as-git-projection.md`.
20
33
  */
21
34
 
22
- import { existsSync, statSync } from "fs";
23
- import { join } from "path";
35
+ import {
36
+ existsSync,
37
+ mkdirSync,
38
+ readFileSync,
39
+ renameSync,
40
+ statSync,
41
+ writeFileSync,
42
+ } from "fs";
43
+ import { dirname, join } from "path";
44
+ import { homedir } from "os";
24
45
 
25
46
  import { DEFAULT_COMMIT_TEMPLATE, isGitRepo } from "./export-watch.ts";
47
+ import { readCredentials, type MirrorCredentials } from "./mirror-credentials.ts";
26
48
 
27
49
  // ---------------------------------------------------------------------------
28
50
  // Types
@@ -38,40 +60,78 @@ import { DEFAULT_COMMIT_TEMPLATE, isGitRepo } from "./export-watch.ts";
38
60
  export type MirrorLocation = "internal" | "external";
39
61
 
40
62
  /**
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).
63
+ * How the mirror stays current with vault state.
64
+ *
65
+ * - `events` in-process hooks fire on every note/tag/attachment
66
+ * mutation; the manager debounces them (~500ms) into a single export
67
+ * pass. A background safety-net poll (default 1h) catches anything
68
+ * missed (direct SQL writes, dropped hook dispatches, restart gaps).
69
+ * This is the default for new configs and the post-vault#382 shape.
70
+ * - `manual` → no event subscriptions, no polling. The operator runs
71
+ * "Run export now" (admin SPA button or POST `/.parachute/mirror/run-now`)
72
+ * or `parachute-vault export` from the CLI on their own cadence.
73
+ *
74
+ * The pre-vault#382 `interval_seconds`-driven watch loop has retired —
75
+ * existing configs migrate to `events` (when `watch: true`) or `manual`
76
+ * (when `watch: false`). The legacy field still parses, repurposed as
77
+ * `safety_net_seconds` when explicit.
78
+ */
79
+ export type MirrorSyncMode = "events" | "manual";
80
+
81
+ /** Default safety-net poll interval in seconds (1 hour). */
82
+ export const DEFAULT_SAFETY_NET_SECONDS = 3600;
83
+ /** Minimum safety-net poll interval. Anything tighter would defeat the point of "safety net" — that's what the event path is for. */
84
+ export const MIN_SAFETY_NET_SECONDS = 60;
85
+ /** Maximum safety-net poll interval (24 hours). */
86
+ export const MAX_SAFETY_NET_SECONDS = 86400;
87
+
88
+ /**
89
+ * The persistent mirror configuration block. Per-vault since vault#400: each
90
+ * vault stores its own block in `data/<vault>/mirror-config.yaml` (real
91
+ * multi-vault mirroring — every vault can mirror to its own git remote).
44
92
  *
45
93
  * Field semantics:
46
94
  * - `enabled` — master switch. When false (the default for upgrading
47
95
  * vaults), no mirror behavior runs at all. The other fields are
48
96
  * preserved so the operator can flip enabled back on without losing
49
- * their location/path/watch settings.
97
+ * their location/path/sync_mode settings.
50
98
  * - `location` — "internal" or "external". Drives `resolveMirrorPath`.
51
99
  * - `external_path` — required when location=external. Operator-picked
52
100
  * 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.
101
+ * - `sync_mode` — "events" (subscribe to in-process hooks; debounce ~500ms;
102
+ * safety-net poll on top) or "manual" (no auto-fire; operator triggers
103
+ * manually). Default "events".
57
104
  * - `auto_commit` — after each export pass, `git add -A && git commit`.
58
105
  * Reuses the existing `runGitCommitCycle` from vault#346.
59
106
  * - `auto_push` — after commit, `git push`. Failures non-fatal.
60
107
  * - `commit_template` — passed verbatim to `renderCommitMessage`. Same
61
108
  * variable set as the CLI: `{{date}}`, `{{notes_changed}}`,
62
109
  * `{{plural}}`, `{{first_note_title}}`, `{{vault_name}}`.
63
- * - `interval_seconds` — watch-loop poll interval. Default 5, matching
64
- * the CLI flag's default.
110
+ * - `safety_net_seconds` — `sync_mode: events` runs an hourly background
111
+ * sweep on top of the event-driven path. This field controls the
112
+ * interval (default 3600). Clamped to `[MIN_SAFETY_NET_SECONDS,
113
+ * MAX_SAFETY_NET_SECONDS]` at validation time.
114
+ *
115
+ * Legacy / migration:
116
+ * - `watch` (boolean) — pre-vault#382 field. `true` → `sync_mode: events`,
117
+ * `false` → `sync_mode: manual`. Parsed for back-compat; the canonical
118
+ * form on the wire is `sync_mode`.
119
+ * - `interval_seconds` (positive integer) — pre-vault#382 watch-loop
120
+ * poll interval. Reinterpreted as `safety_net_seconds` when no
121
+ * explicit `safety_net_seconds` is present AND the value lies in the
122
+ * valid range. Out-of-range / missing → default 3600. The field is
123
+ * no longer surfaced in the UI; the SPA stops emitting it but the
124
+ * parser keeps accepting it for hand-edited configs.
65
125
  */
66
126
  export interface MirrorConfig {
67
127
  enabled: boolean;
68
128
  location: MirrorLocation;
69
129
  external_path: string | null;
70
- watch: boolean;
130
+ sync_mode: MirrorSyncMode;
71
131
  auto_commit: boolean;
72
132
  auto_push: boolean;
73
133
  commit_template: string;
74
- interval_seconds: number;
134
+ safety_net_seconds: number;
75
135
  }
76
136
 
77
137
  /**
@@ -85,11 +145,11 @@ export function defaultMirrorConfig(): MirrorConfig {
85
145
  enabled: false,
86
146
  location: "internal",
87
147
  external_path: null,
88
- watch: false,
148
+ sync_mode: "events",
89
149
  auto_commit: true,
90
150
  auto_push: false,
91
151
  commit_template: DEFAULT_COMMIT_TEMPLATE,
92
- interval_seconds: 5,
152
+ safety_net_seconds: DEFAULT_SAFETY_NET_SECONDS,
93
153
  };
94
154
  }
95
155
 
@@ -129,6 +189,13 @@ export function parseMirrorConfig(yaml: string): MirrorConfig | undefined {
129
189
  const lines = yaml.slice(startIdx).split("\n");
130
190
 
131
191
  const config = defaultMirrorConfig();
192
+ // Track legacy field state for migration: if `sync_mode` is absent and
193
+ // `watch` is set, derive sync_mode from watch. If `safety_net_seconds`
194
+ // is absent and `interval_seconds` is set (legacy field), repurpose it.
195
+ let sawSyncMode = false;
196
+ let sawSafetyNet = false;
197
+ let legacyWatch: boolean | null = null;
198
+ let legacyInterval: number | null = null;
132
199
 
133
200
  for (const line of lines) {
134
201
  // Stop at the next top-level key.
@@ -140,7 +207,7 @@ export function parseMirrorConfig(yaml: string): MirrorConfig | undefined {
140
207
  const boolField = (
141
208
  name: keyof Pick<
142
209
  MirrorConfig,
143
- "enabled" | "watch" | "auto_commit" | "auto_push"
210
+ "enabled" | "auto_commit" | "auto_push"
144
211
  >,
145
212
  ): boolean => {
146
213
  const m = trimmed.match(new RegExp(`^${name}:\\s*(true|false)\\s*$`));
@@ -151,10 +218,22 @@ export function parseMirrorConfig(yaml: string): MirrorConfig | undefined {
151
218
  return false;
152
219
  };
153
220
  if (boolField("enabled")) continue;
154
- if (boolField("watch")) continue;
155
221
  if (boolField("auto_commit")) continue;
156
222
  if (boolField("auto_push")) continue;
157
223
 
224
+ const watchMatch = trimmed.match(/^watch:\s*(true|false)\s*$/);
225
+ if (watchMatch) {
226
+ legacyWatch = watchMatch[1] === "true";
227
+ continue;
228
+ }
229
+
230
+ const syncModeMatch = trimmed.match(/^sync_mode:\s*(events|manual)\s*$/);
231
+ if (syncModeMatch) {
232
+ config.sync_mode = syncModeMatch[1] as MirrorSyncMode;
233
+ sawSyncMode = true;
234
+ continue;
235
+ }
236
+
158
237
  const locationMatch = trimmed.match(/^location:\s*(internal|external)\s*$/);
159
238
  if (locationMatch) {
160
239
  config.location = locationMatch[1] as MirrorLocation;
@@ -181,17 +260,47 @@ export function parseMirrorConfig(yaml: string): MirrorConfig | undefined {
181
260
  continue;
182
261
  }
183
262
 
263
+ const safetyNetMatch = trimmed.match(/^safety_net_seconds:\s*(\d+)\s*$/);
264
+ if (safetyNetMatch) {
265
+ const n = parseInt(safetyNetMatch[1]!, 10);
266
+ if (Number.isFinite(n) && n > 0) {
267
+ config.safety_net_seconds = clampSafetyNet(n);
268
+ sawSafetyNet = true;
269
+ }
270
+ continue;
271
+ }
272
+
184
273
  const intervalMatch = trimmed.match(/^interval_seconds:\s*(\d+)\s*$/);
185
274
  if (intervalMatch) {
186
275
  const n = parseInt(intervalMatch[1]!, 10);
187
- if (Number.isFinite(n) && n > 0) config.interval_seconds = n;
276
+ if (Number.isFinite(n) && n > 0) legacyInterval = n;
188
277
  continue;
189
278
  }
190
279
  }
191
280
 
281
+ // Migration: explicit `sync_mode` wins; otherwise derive from `watch`.
282
+ // A config that carries neither falls through to defaults (events).
283
+ if (!sawSyncMode && legacyWatch !== null) {
284
+ config.sync_mode = legacyWatch ? "events" : "manual";
285
+ }
286
+ // Migration: explicit `safety_net_seconds` wins; otherwise upgrade
287
+ // `interval_seconds` (legacy short-cadence values like 5 get clamped
288
+ // up to MIN_SAFETY_NET_SECONDS — the safety-net path doesn't want
289
+ // 5-second polls). Out-of-range values fall through to the default.
290
+ if (!sawSafetyNet && legacyInterval !== null) {
291
+ config.safety_net_seconds = clampSafetyNet(legacyInterval);
292
+ }
293
+
192
294
  return config;
193
295
  }
194
296
 
297
+ /** Clamp safety_net_seconds to the valid range. */
298
+ function clampSafetyNet(n: number): number {
299
+ if (n < MIN_SAFETY_NET_SECONDS) return MIN_SAFETY_NET_SECONDS;
300
+ if (n > MAX_SAFETY_NET_SECONDS) return MAX_SAFETY_NET_SECONDS;
301
+ return n;
302
+ }
303
+
195
304
  /**
196
305
  * Serialize a MirrorConfig as YAML lines suitable for appending under the
197
306
  * top-level keys of `config.yaml`. Returns the lines without a trailing
@@ -215,15 +324,234 @@ export function serializeMirrorConfig(config: MirrorConfig): string[] {
215
324
  ` external_path: ${needsQuote ? `"${config.external_path}"` : config.external_path}`,
216
325
  );
217
326
  }
218
- lines.push(` watch: ${config.watch}`);
327
+ lines.push(` sync_mode: ${config.sync_mode}`);
219
328
  lines.push(` auto_commit: ${config.auto_commit}`);
220
329
  lines.push(` auto_push: ${config.auto_push}`);
221
330
  // Templates contain `{{ }}` and frequently `:` — always quote.
222
331
  lines.push(` commit_template: "${config.commit_template.replace(/"/g, '\\"')}"`);
223
- lines.push(` interval_seconds: ${config.interval_seconds}`);
332
+ lines.push(` safety_net_seconds: ${config.safety_net_seconds}`);
224
333
  return lines;
225
334
  }
226
335
 
336
+ // ---------------------------------------------------------------------------
337
+ // Per-vault config storage (vault#400)
338
+ //
339
+ // Each vault's mirror config lives in `data/<vault>/mirror-config.yaml`,
340
+ // alongside its SQLite DB (`vault.db`), config (`vault.yaml`), and per-vault
341
+ // credentials file (`.mirror-credentials.yaml`, vault#399). The file holds
342
+ // the same `mirror:`-prefixed block `serializeMirrorConfig` already emits, so
343
+ // `parseMirrorConfig`/`serializeMirrorConfig` are reused verbatim.
344
+ //
345
+ // Why a separate file from credentials (not folded in): config is
346
+ // non-secret + hand-editable; credentials are 0o600 + token-bearing. Keeping
347
+ // them separate avoids accidentally widening the credentials file's perms,
348
+ // and keeps the credentials migration (vault#399) and config migration
349
+ // (vault#400) cleanly independent.
350
+ //
351
+ // Path resolution mirrors `mirror-credentials.ts` rather than importing
352
+ // `config.ts:vaultDir()` — config.ts imports this module, so importing it
353
+ // back would close a cycle. We re-derive from `PARACHUTE_HOME` (the canonical
354
+ // override the rest of vault honors) instead.
355
+ // ---------------------------------------------------------------------------
356
+
357
+ /** The vault home root — `<configDir>/vault`. Re-reads PARACHUTE_HOME per call. */
358
+ function vaultHomeRoot(): string {
359
+ const root = process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
360
+ return join(root, "vault");
361
+ }
362
+
363
+ /**
364
+ * Path to a vault's per-vault mirror-config file (vault#400):
365
+ * `<configDir>/vault/data/<vaultName>/mirror-config.yaml`.
366
+ */
367
+ export function mirrorConfigPath(vaultName: string): string {
368
+ return join(vaultHomeRoot(), "data", vaultName, "mirror-config.yaml");
369
+ }
370
+
371
+ /**
372
+ * Read a vault's mirror config from its per-vault file. Returns `undefined`
373
+ * when the file is absent (operator has never configured this vault's
374
+ * mirror) — distinct from "configured with enabled:false" so callers can
375
+ * tell "never touched" apart from "explicitly disabled" (same distinction
376
+ * `parseMirrorConfig` preserves). Callers that just want a usable config
377
+ * coalesce with `defaultMirrorConfig()`.
378
+ */
379
+ export function readMirrorConfigForVault(vaultName: string): MirrorConfig | undefined {
380
+ const path = mirrorConfigPath(vaultName);
381
+ if (!existsSync(path)) return undefined;
382
+ try {
383
+ const raw = readFileSync(path, "utf8");
384
+ return parseMirrorConfig(raw);
385
+ } catch {
386
+ // Unreadable / malformed → treat as "no config" rather than crashing
387
+ // the boot path or a route. The operator can re-PUT to repair it.
388
+ return undefined;
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Persist a vault's mirror config to its per-vault file, atomically
394
+ * (write-temp → rename). Creates the vault data dir if missing (fresh
395
+ * installs / tests). The file is NOT secret (no tokens — those live in
396
+ * `.mirror-credentials.yaml`), so default perms are fine.
397
+ */
398
+ export function writeMirrorConfigForVault(
399
+ vaultName: string,
400
+ config: MirrorConfig,
401
+ ): void {
402
+ const path = mirrorConfigPath(vaultName);
403
+ const dir = dirname(path);
404
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
405
+ const body = serializeMirrorConfig(config).join("\n") + "\n";
406
+ const tmp = `${path}.tmp`;
407
+ writeFileSync(tmp, body);
408
+ renameSync(tmp, path);
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Migration — legacy server-wide `mirror:` config → per-vault (vault#400)
413
+ // ---------------------------------------------------------------------------
414
+
415
+ /**
416
+ * One-time migration of the legacy server-wide `mirror:` config block to the
417
+ * per-vault layout (vault#400). Symmetric counterpart to vault#399's
418
+ * `migrateLegacyServerWideCredentials`.
419
+ *
420
+ * The bug: pre-vault#400, mirror config lived in a single `mirror:` block in
421
+ * `<configDir>/vault/config.yaml`, shared across ALL vaults. Every vault's
422
+ * mirror page rendered that one config (+ the owning vault's git remote);
423
+ * configuring vault B clobbered vault A.
424
+ *
425
+ * Attribution: attribute the legacy block to the mirror-owning vault
426
+ * (`resolveMirrorVaultName` = default_vault → first listed) — the same vault
427
+ * the single server-wide mirror was actually bound to. Other vaults start
428
+ * with no mirror config (they read `undefined` → default disabled).
429
+ *
430
+ * Safety:
431
+ * - No-op when no legacy block exists (fresh installs, already-migrated).
432
+ * - No-op when the target vault already has a per-vault config file (don't
433
+ * clobber config the operator set post-migration).
434
+ * - The legacy block is preserved (commented out in place) rather than
435
+ * silently dropped, so nothing is lost if attribution was wrong.
436
+ * - Logs the attribution decision clearly.
437
+ *
438
+ * This function is intentionally I/O-light on the config.yaml side: it takes
439
+ * the raw config.yaml text + a rewriter callback so it doesn't import
440
+ * `config.ts` (which imports this module). server.ts supplies the read/write.
441
+ *
442
+ * @param legacyConfig the `mirror:` block parsed from config.yaml, or
443
+ * undefined when there is none. server.ts passes `readGlobalConfig().mirror`.
444
+ * @param targetVaultName the vault to attribute the legacy config to
445
+ * (caller passes `resolveMirrorVaultName()`).
446
+ * @param commentOutLegacyBlock callback that rewrites config.yaml to comment
447
+ * out the `mirror:` block (so it isn't re-migrated + the operator can see
448
+ * the old values). Best-effort; failure is non-fatal.
449
+ * @returns a struct describing what happened, for logging + tests.
450
+ */
451
+ export function migrateLegacyServerWideConfig(
452
+ legacyConfig: MirrorConfig | undefined,
453
+ targetVaultName: string | null,
454
+ commentOutLegacyBlock: () => void,
455
+ ):
456
+ | { migrated: false; reason: "no_legacy_block" | "no_target_vault" | "target_already_configured" }
457
+ | { migrated: true; targetVaultName: string } {
458
+ if (!legacyConfig) {
459
+ return { migrated: false, reason: "no_legacy_block" };
460
+ }
461
+ if (!targetVaultName) {
462
+ // Legacy block present but no vault to attribute it to. Leave it in
463
+ // place — a future boot (once a vault exists) migrates it.
464
+ return { migrated: false, reason: "no_target_vault" };
465
+ }
466
+ const targetPath = mirrorConfigPath(targetVaultName);
467
+ if (existsSync(targetPath)) {
468
+ // Target vault already has per-vault config. Don't clobber. Still
469
+ // comment out the legacy block so we don't re-evaluate it every boot.
470
+ try {
471
+ commentOutLegacyBlock();
472
+ } catch {
473
+ // Non-fatal — worst case we re-check next boot and short-circuit here.
474
+ }
475
+ return { migrated: false, reason: "target_already_configured" };
476
+ }
477
+
478
+ writeMirrorConfigForVault(targetVaultName, legacyConfig);
479
+ try {
480
+ commentOutLegacyBlock();
481
+ } catch {
482
+ // Non-fatal — the config is now in the per-vault file; the legacy block
483
+ // staying live would just re-migrate to the same place idempotently
484
+ // (the target_already_configured branch short-circuits next boot).
485
+ }
486
+
487
+ console.log(
488
+ `[mirror] migrated legacy server-wide mirror config → vault "${targetVaultName}" ` +
489
+ `(per-vault, vault#400). Other vaults start with no mirror config (configure each ` +
490
+ `separately). Legacy config.yaml \`mirror:\` block commented out (preserved for reference).`,
491
+ );
492
+ return { migrated: true, targetVaultName };
493
+ }
494
+
495
+ /**
496
+ * Pure string transform: comment out the server-wide `mirror:` block in a
497
+ * config.yaml string (vault#400 migration). Prefixes each line of the block
498
+ * with `# ` so the operator can still see the migrated values + nothing is
499
+ * silently dropped, and a subsequent boot's `parseMirrorConfig` no longer
500
+ * sees a LIVE block (so no re-migration — `parseMirrorConfig`'s anchor regex
501
+ * `^mirror:\s*$` doesn't match `# mirror:`).
502
+ *
503
+ * Block boundaries match the parser's stop rule exactly: the block starts at
504
+ * a 0-indent `mirror:` line and runs until the next 0-indent non-blank line
505
+ * (the next top-level key). Other top-level keys (port, default_vault, …) are
506
+ * left byte-for-byte intact; blank lines are preserved verbatim (not
507
+ * commented). Idempotent: an already-commented config has no live `mirror:`
508
+ * line, so it passes through unchanged.
509
+ *
510
+ * Extracted from server.ts (vault#408 review N3) so the YAML rewriting — which
511
+ * runs against the operator's real config.yaml — is directly unit-tested.
512
+ */
513
+ export function commentOutMirrorBlock(yaml: string): string {
514
+ const lines = yaml.split("\n");
515
+ const out: string[] = [];
516
+ let inBlock = false;
517
+ for (const line of lines) {
518
+ if (!inBlock && /^mirror:\s*$/.test(line)) {
519
+ inBlock = true;
520
+ out.push(`# [vault#400] migrated to per-vault data/<vault>/mirror-config.yaml`);
521
+ out.push(`# ${line}`);
522
+ continue;
523
+ }
524
+ if (inBlock) {
525
+ // The block runs until the next top-level (0-indent, non-blank) key.
526
+ if (/^\S/.test(line) && line.trim().length > 0) {
527
+ inBlock = false;
528
+ out.push(line);
529
+ } else if (line.trim().length === 0) {
530
+ // Preserve blank lines verbatim (don't comment them).
531
+ out.push(line);
532
+ } else {
533
+ out.push(`# ${line}`);
534
+ }
535
+ continue;
536
+ }
537
+ out.push(line);
538
+ }
539
+ return out.join("\n");
540
+ }
541
+
542
+ /**
543
+ * File-I/O wrapper around `commentOutMirrorBlock`: read config.yaml at
544
+ * `configPath`, comment out its `mirror:` block, write it back. No-op when
545
+ * the file is absent. server.ts passes this (bound to GLOBAL_CONFIG_PATH) as
546
+ * the migration's `commentOutLegacyBlock` callback. Best-effort — callers
547
+ * treat a throw as non-fatal.
548
+ */
549
+ export function commentOutLegacyMirrorBlockFile(configPath: string): void {
550
+ if (!existsSync(configPath)) return;
551
+ const yaml = readFileSync(configPath, "utf8");
552
+ writeFileSync(configPath, commentOutMirrorBlock(yaml));
553
+ }
554
+
227
555
  // ---------------------------------------------------------------------------
228
556
  // Path resolution
229
557
  // ---------------------------------------------------------------------------
@@ -285,12 +613,31 @@ export type ShapeValidation = ShapeValidationOk | ShapeValidationError;
285
613
  * `defaultMirrorConfig()`; rejects values that don't conform to the
286
614
  * declared types.
287
615
  *
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`.
616
+ * Mostly pure: the one filesystem touch is reading
617
+ * `.mirror-credentials.yaml` to decide whether `auto_push: true +
618
+ * location: internal` is acceptable (credentials carry a remote URL, so
619
+ * "internal mirrors have no remote" no longer holds). The credentials
620
+ * read is injectable via `opts.readCredentials` so tests stay hermetic;
621
+ * production callers omit it and pick up the canonical `readCredentials`
622
+ * from `./mirror-credentials.ts`.
623
+ *
624
+ * Filesystem-level validation of `external_path` (exists, is a git repo)
625
+ * still lives in `validateExternalPath` — that's I/O and stays out of
626
+ * this function.
291
627
  */
292
628
  export function validateMirrorConfigShape(
293
629
  input: unknown,
630
+ opts: {
631
+ /**
632
+ * Vault whose credentials gate `auto_push + internal`. Required for the
633
+ * production credential read (per-vault since vault#399). Omitting it
634
+ * AND `readCredentials` means the auto_push/internal credential check
635
+ * treats credentials as absent (fail-closed) — fine for callers that
636
+ * never set that combination.
637
+ */
638
+ vaultName?: string;
639
+ readCredentials?: () => MirrorCredentials | null;
640
+ } = {},
294
641
  ): ShapeValidation {
295
642
  if (input === null || typeof input !== "object") {
296
643
  return {
@@ -334,11 +681,24 @@ export function validateMirrorConfigShape(
334
681
  }
335
682
  }
336
683
 
684
+ // Legacy back-compat: `watch: boolean` translates to sync_mode.
685
+ // `sync_mode` takes priority if both are present.
337
686
  if ("watch" in blob) {
338
687
  if (typeof blob.watch !== "boolean") {
339
- return { ok: false, field: "watch", error: "`watch` must be boolean." };
688
+ return { ok: false, error: "`watch` must be boolean." };
689
+ }
690
+ out.sync_mode = blob.watch ? "events" : "manual";
691
+ }
692
+
693
+ if ("sync_mode" in blob) {
694
+ if (blob.sync_mode !== "events" && blob.sync_mode !== "manual") {
695
+ return {
696
+ ok: false,
697
+ field: "sync_mode",
698
+ error: '`sync_mode` must be "events" or "manual".',
699
+ };
340
700
  }
341
- out.watch = blob.watch;
701
+ out.sync_mode = blob.sync_mode;
342
702
  }
343
703
 
344
704
  if ("auto_commit" in blob) {
@@ -382,7 +742,33 @@ export function validateMirrorConfigShape(
382
742
  out.commit_template = blob.commit_template;
383
743
  }
384
744
 
385
- if ("interval_seconds" in blob) {
745
+ if ("safety_net_seconds" in blob) {
746
+ if (
747
+ typeof blob.safety_net_seconds !== "number" ||
748
+ !Number.isFinite(blob.safety_net_seconds) ||
749
+ blob.safety_net_seconds <= 0 ||
750
+ !Number.isInteger(blob.safety_net_seconds)
751
+ ) {
752
+ return {
753
+ ok: false,
754
+ field: "safety_net_seconds",
755
+ error: "`safety_net_seconds` must be a positive integer.",
756
+ };
757
+ }
758
+ if (
759
+ blob.safety_net_seconds < MIN_SAFETY_NET_SECONDS ||
760
+ blob.safety_net_seconds > MAX_SAFETY_NET_SECONDS
761
+ ) {
762
+ return {
763
+ ok: false,
764
+ field: "safety_net_seconds",
765
+ error: `\`safety_net_seconds\` must be between ${MIN_SAFETY_NET_SECONDS} and ${MAX_SAFETY_NET_SECONDS}.`,
766
+ };
767
+ }
768
+ out.safety_net_seconds = blob.safety_net_seconds;
769
+ } else if ("interval_seconds" in blob) {
770
+ // Legacy field still accepted for hand-edited configs. Migrate by
771
+ // clamping into the safety-net range.
386
772
  if (
387
773
  typeof blob.interval_seconds !== "number" ||
388
774
  !Number.isFinite(blob.interval_seconds) ||
@@ -391,11 +777,15 @@ export function validateMirrorConfigShape(
391
777
  ) {
392
778
  return {
393
779
  ok: false,
394
- field: "interval_seconds",
395
780
  error: "`interval_seconds` must be a positive integer.",
396
781
  };
397
782
  }
398
- out.interval_seconds = blob.interval_seconds;
783
+ const clamped = blob.interval_seconds < MIN_SAFETY_NET_SECONDS
784
+ ? MIN_SAFETY_NET_SECONDS
785
+ : blob.interval_seconds > MAX_SAFETY_NET_SECONDS
786
+ ? MAX_SAFETY_NET_SECONDS
787
+ : blob.interval_seconds;
788
+ out.safety_net_seconds = clamped;
399
789
  }
400
790
 
401
791
  // Cross-field rule: external requires external_path — but ONLY when
@@ -414,6 +804,67 @@ export function validateMirrorConfigShape(
414
804
  };
415
805
  }
416
806
 
807
+ // Cross-field rule: auto_push + internal location was historically
808
+ // rejected outright — the assumption being "internal mirrors live under
809
+ // vault's data dir with no configured remote, so push would always
810
+ // fail." That assumption no longer holds once credentials are wired:
811
+ // the credential save path (handleAuthPat / handleAuthGithubSelectRepo)
812
+ // sets `origin` on the internal repo to the embedded-credential URL,
813
+ // so `git push` to GitHub/GitLab/etc. is meaningful even when the
814
+ // working tree lives under `~/.parachute/vault/data/<name>/mirror/`.
815
+ //
816
+ // New rule: auto_push + internal is rejected ONLY when no credentials
817
+ // are configured. If credentials ARE wired (PAT or GitHub OAuth),
818
+ // accept the combination — vault has a remote to push to. Operators
819
+ // hitting Aaron's three-stacking-gaps bug (History preset + PAT saved,
820
+ // pushes never fire) get unblocked.
821
+ //
822
+ // Asymmetry note (reviewer-flagged on vault#392): external + auto_push +
823
+ // no vault-stored credentials is INTENTIONALLY not rejected here.
824
+ // External mirrors are operator-managed paths — operators may have
825
+ // configured push credentials via system-level git config (SSH agent,
826
+ // ~/.git-credentials, GH_TOKEN env, `gh auth login`), none of which
827
+ // vault can detect by reading `.mirror-credentials.yaml`. Rejecting on
828
+ // "vault doesn't see credentials" would refuse legitimate
829
+ // operator-managed setups. Push failures on missing credentials surface
830
+ // via the non-fatal warning path in gitPush — operators see them in
831
+ // `last_push_error` rather than being blocked at save time. Internal
832
+ // location is different: internal mirrors live under vault's data
833
+ // dir, so vault IS the only thing that can wire a remote, which is
834
+ // why the gate below requires vault-stored credentials specifically.
835
+ if (out.enabled && out.auto_push && out.location === "internal") {
836
+ // Per-vault credentials (vault#399): bind the read to opts.vaultName.
837
+ // When neither an injected reader nor a vaultName is supplied, treat
838
+ // credentials as absent (fail-closed) rather than reading a wrong file.
839
+ const readCreds =
840
+ opts.readCredentials ??
841
+ (opts.vaultName ? () => readCredentials(opts.vaultName!) : () => null);
842
+ let creds: MirrorCredentials | null = null;
843
+ try {
844
+ creds = readCreds();
845
+ } catch {
846
+ // If the credentials file is unreadable we conservatively fail
847
+ // closed — same outcome as "no credentials." Better to surface the
848
+ // actionable "configure credentials first" error than to accept a
849
+ // config that won't actually push.
850
+ creds = null;
851
+ }
852
+ const hasCredentials = !!(
853
+ creds &&
854
+ creds.active_method &&
855
+ ((creds.active_method === "pat" && creds.pat) ||
856
+ (creds.active_method === "github_oauth" && creds.github_oauth))
857
+ );
858
+ if (!hasCredentials) {
859
+ return {
860
+ ok: false,
861
+ field: "auto_push",
862
+ error:
863
+ "Configure git credentials before enabling auto-push on an internal mirror — vault has no remote to push to without them. Connect GitHub or paste a Personal Access Token in the Git remote section, or set auto_push to false.",
864
+ };
865
+ }
866
+ }
867
+
417
868
  return { ok: true, config: out };
418
869
  }
419
870