@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.
- package/core/src/core.test.ts +4 -1
- package/core/src/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +99 -41
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +304 -1
- package/core/src/portable-md.ts +418 -2
- package/core/src/schema.ts +114 -2
- package/core/src/store.ts +185 -2
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +147 -0
- package/src/auth.ts +121 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- package/src/cli.ts +131 -36
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +74 -0
- package/src/export-watch.ts +108 -7
- package/src/github-device-flow.test.ts +404 -0
- package/src/github-device-flow.ts +415 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/mcp-http.ts +48 -39
- package/src/mcp-install-interactive.test.ts +10 -21
- package/src/mcp-install-interactive.ts +12 -21
- package/src/mcp-install.test.ts +141 -30
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +460 -3
- package/src/mirror-config.test.ts +277 -14
- package/src/mirror-config.ts +482 -31
- package/src/mirror-credentials.test.ts +601 -0
- package/src/mirror-credentials.ts +700 -0
- package/src/mirror-deps.ts +67 -17
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +487 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +621 -72
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1111 -7
- package/src/module-config.ts +11 -5
- package/src/routes.ts +38 -1
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +193 -20
- package/src/server.ts +116 -35
- package/src/storage.test.ts +132 -7
- package/src/token-store.ts +300 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +681 -2
- package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
package/src/mirror-config.ts
CHANGED
|
@@ -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
|
|
8
|
-
* - Parse + serialize that block
|
|
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 {
|
|
23
|
-
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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/
|
|
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
|
-
* - `
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
-
* - `
|
|
64
|
-
* the
|
|
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
|
-
|
|
130
|
+
sync_mode: MirrorSyncMode;
|
|
71
131
|
auto_commit: boolean;
|
|
72
132
|
auto_push: boolean;
|
|
73
133
|
commit_template: string;
|
|
74
|
-
|
|
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
|
-
|
|
148
|
+
sync_mode: "events",
|
|
89
149
|
auto_commit: true,
|
|
90
150
|
auto_push: false,
|
|
91
151
|
commit_template: DEFAULT_COMMIT_TEMPLATE,
|
|
92
|
-
|
|
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" | "
|
|
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)
|
|
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(`
|
|
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(`
|
|
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
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
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,
|
|
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.
|
|
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 ("
|
|
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
|
-
|
|
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
|
|