@openparachute/vault 0.5.1 → 0.5.2-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/usage.ts ADDED
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Per-vault usage monitoring — a cheap, read-scoped "how big is this vault"
3
+ * report (data footprint). Built for running many vaults for many users: the
4
+ * operator needs to see each vault's size, and a vault's own user should be
5
+ * able to see their own vault's footprint.
6
+ *
7
+ * The endpoint that consumes these helpers lives in `routing.ts` at
8
+ * `GET /vault/<name>/.parachute/usage` (read-scoped). This module owns the
9
+ * filesystem arithmetic and the dir-walk cache.
10
+ *
11
+ * Cost model:
12
+ * - counts + contentBytes come from `getVaultStats` (a handful of indexed
13
+ * COUNT/SUM queries) — cheap, computed on every request.
14
+ * - `dbBytes` is three `statSync` calls (the WAL trio) — cheap, every request.
15
+ * - the two recursive directory walks (`assets`, `mirror`) can traverse
16
+ * thousands of files, so they go through a 60s TTL cache keyed by vault
17
+ * name. `?fresh=1` bypasses the cache; an attachment upload invalidates it.
18
+ *
19
+ * Everything I/O-touching goes through an injectable `UsageFs` seam + an
20
+ * injectable clock so tests can assert call counts and TTL behavior without
21
+ * touching the real disk.
22
+ */
23
+
24
+ import {
25
+ statSync as realStatSync,
26
+ readdirSync as realReaddirSync,
27
+ type Dirent,
28
+ } from "fs";
29
+ import { join } from "path";
30
+ import { vaultDir, assetsDir } from "./config.ts";
31
+ import { readMirrorConfigForVault, resolveMirrorPath } from "./mirror-config.ts";
32
+ import type { VaultStats } from "../core/src/types.ts";
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Injectable seams
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * The minimal filesystem surface the usage helpers depend on. Injecting it
40
+ * lets tests count how many times the dir-walk runs (to prove the cache
41
+ * actually skips it) and synthesize a tree without writing real files.
42
+ *
43
+ * Symlink safety lives in the WALK, not in `statFile`: `dirSize` never
44
+ * descends into or sizes a symlink entry (it filters on
45
+ * `Dirent.isSymbolicLink()` before ever calling `statFile`), so a symlink
46
+ * loop can't hang the walk and a symlink pointing at a huge tree elsewhere
47
+ * can't inflate the count. `statFile` is therefore only ever called on real
48
+ * files / the WAL trio (which are never symlinks).
49
+ */
50
+ export interface UsageFs {
51
+ /**
52
+ * `stat` (symlink-FOLLOWING, like `statSync`): returns the size of a file
53
+ * (or throws if absent — callers wrap in try/catch → 0). Following symlinks
54
+ * is safe here because dir descent already filters symlinks via
55
+ * `Dirent.isSymbolicLink()`, and the WAL files (`vault.db{,-wal,-shm}`) are
56
+ * never symlinks.
57
+ */
58
+ statFile(path: string): { size: number; isDirectory(): boolean; isSymbolicLink(): boolean };
59
+ /** `readdirSync(path, { withFileTypes: true })`. */
60
+ readDir(path: string): Dirent[];
61
+ }
62
+
63
+ /** Monotonic-enough clock seam: returns epoch millis. */
64
+ export type Clock = () => number;
65
+
66
+ /** Real filesystem implementation (production default). */
67
+ export const realUsageFs: UsageFs = {
68
+ statFile: (path) => realStatSync(path),
69
+ readDir: (path) => realReaddirSync(path, { withFileTypes: true }),
70
+ };
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Primitive byte counters
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Physical bytes of a vault's SQLite database, WAL-aware: `vault.db` +
78
+ * `vault.db-wal` + `vault.db-shm`. The WAL (write-ahead log) and shared-memory
79
+ * index files hold committed-but-not-yet-checkpointed pages; ignoring them
80
+ * undercounts a busy vault. A missing `-wal`/`-shm` (the common case at rest,
81
+ * after a checkpoint) contributes 0 rather than erroring.
82
+ *
83
+ * This is the PHYSICAL DB-file size — it includes SQLite page overhead, free
84
+ * pages from deletes, etc. It is intentionally distinct from the LOGICAL
85
+ * `contentBytes` in `VaultStats` (sum of note-content UTF-8 bytes).
86
+ */
87
+ export function dbBytes(vaultName: string, fs: UsageFs = realUsageFs): number {
88
+ const base = join(vaultDir(vaultName), "vault.db");
89
+ const candidates = [base, `${base}-wal`, `${base}-shm`];
90
+ let total = 0;
91
+ for (const path of candidates) {
92
+ total += safeFileSize(path, fs);
93
+ }
94
+ return total;
95
+ }
96
+
97
+ /** Size of a single file, or 0 if it's missing / unreadable. */
98
+ function safeFileSize(path: string, fs: UsageFs): number {
99
+ try {
100
+ return fs.statFile(path).size;
101
+ } catch {
102
+ return 0;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Recursively sum the size of every regular file under `dirPath`.
108
+ *
109
+ * - Missing directory → 0 (never throws; a vault may have no assets / no
110
+ * mirror yet).
111
+ * - Symlinks are NOT followed — a symlink (file or dir) is skipped entirely.
112
+ * This guards against symlink loops (which would hang an infinite walk)
113
+ * and against a symlink that points at a large tree outside the vault
114
+ * inflating the reported footprint.
115
+ *
116
+ * Uses an explicit stack (not recursion) so a deep tree can't blow the call
117
+ * stack, and reads dirents with types so we avoid an extra `stat` per entry
118
+ * just to learn directory-ness.
119
+ */
120
+ export function dirSize(dirPath: string, fs: UsageFs = realUsageFs): number {
121
+ let total = 0;
122
+ const stack: string[] = [dirPath];
123
+
124
+ while (stack.length > 0) {
125
+ const current = stack.pop()!;
126
+ let entries: Dirent[];
127
+ try {
128
+ entries = fs.readDir(current);
129
+ } catch {
130
+ // Missing/unreadable dir (including the root) → contributes 0.
131
+ continue;
132
+ }
133
+ for (const entry of entries) {
134
+ // Never follow symlinks — neither symlinked files nor symlinked dirs.
135
+ if (entry.isSymbolicLink()) continue;
136
+ const childPath = join(current, entry.name);
137
+ if (entry.isDirectory()) {
138
+ stack.push(childPath);
139
+ } else if (entry.isFile()) {
140
+ total += safeFileSize(childPath, fs);
141
+ }
142
+ // Sockets/FIFOs/devices: ignored.
143
+ }
144
+ }
145
+ return total;
146
+ }
147
+
148
+ /**
149
+ * Resolve a vault's mirror directory on disk, or `null` when no mirror is
150
+ * configured / resolvable (never configured, or external-without-path). The
151
+ * usage endpoint reports `mirror: 0` (and omits it from the total) in that
152
+ * case.
153
+ */
154
+ export function resolveVaultMirrorDir(vaultName: string): string | null {
155
+ const config = readMirrorConfigForVault(vaultName);
156
+ if (!config) return null;
157
+ return resolveMirrorPath(vaultDir(vaultName), config);
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Dir-walk cache (the only expensive part)
162
+ // ---------------------------------------------------------------------------
163
+
164
+ export interface DirWalkResult {
165
+ /** Bytes under the vault's assets directory. */
166
+ assets: number;
167
+ /**
168
+ * Bytes under the vault's mirror directory, or `null` when no mirror is
169
+ * configured. `null` → omit `mirror` from the response (or report 0).
170
+ */
171
+ mirror: number | null;
172
+ }
173
+
174
+ interface CacheEntry {
175
+ value: DirWalkResult;
176
+ /** Epoch millis when this entry was computed. */
177
+ computedAt: number;
178
+ }
179
+
180
+ /** Default cache lifetime for the dir-walk numbers. */
181
+ export const DIR_WALK_TTL_MS = 60_000;
182
+
183
+ /**
184
+ * In-process, per-vault cache of the two dir-walk numbers. Module-level so it
185
+ * survives across requests within a server process. Tests construct their own
186
+ * `UsageCache` to get isolation + a controllable clock.
187
+ */
188
+ export class UsageCache {
189
+ private readonly entries = new Map<string, CacheEntry>();
190
+
191
+ constructor(
192
+ private readonly fs: UsageFs = realUsageFs,
193
+ private readonly now: Clock = Date.now,
194
+ private readonly ttlMs: number = DIR_WALK_TTL_MS,
195
+ ) {}
196
+
197
+ /**
198
+ * Get the dir-walk numbers for a vault. Returns whether the numbers came
199
+ * from cache (`cached: true`) or were freshly walked (`cached: false`).
200
+ *
201
+ * - `fresh: true` always recomputes (and refreshes the cache entry).
202
+ * - Otherwise a non-expired entry is returned as-is (no walk); an absent
203
+ * or expired entry triggers a walk + store.
204
+ */
205
+ get(vaultName: string, opts: { fresh?: boolean } = {}): { result: DirWalkResult; cached: boolean } {
206
+ const existing = this.entries.get(vaultName);
207
+ const ts = this.now();
208
+ if (!opts.fresh && existing && ts - existing.computedAt < this.ttlMs) {
209
+ return { result: existing.value, cached: true };
210
+ }
211
+ const value = this.compute(vaultName);
212
+ this.entries.set(vaultName, { value, computedAt: ts });
213
+ return { result: value, cached: false };
214
+ }
215
+
216
+ /** Drop a vault's cached dir-walk so the next read recomputes. */
217
+ invalidate(vaultName: string): void {
218
+ this.entries.delete(vaultName);
219
+ }
220
+
221
+ /** Run the (expensive) walks. */
222
+ private compute(vaultName: string): DirWalkResult {
223
+ const assets = dirSize(assetsDir(vaultName), this.fs);
224
+ const mirrorDir = resolveVaultMirrorDir(vaultName);
225
+ const mirror = mirrorDir === null ? null : dirSize(mirrorDir, this.fs);
226
+ return { assets, mirror };
227
+ }
228
+ }
229
+
230
+ /**
231
+ * The process-wide cache the live HTTP route uses. A single instance shared
232
+ * across requests gives the 60s TTL its meaning. `invalidateUsageCache` is the
233
+ * write-path hook (attachment upload, post-export).
234
+ */
235
+ export const usageCache = new UsageCache();
236
+
237
+ /** Invalidate the process-wide usage cache for a vault (write-path hook). */
238
+ export function invalidateUsageCache(vaultName: string): void {
239
+ usageCache.invalidate(vaultName);
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Report assembly (consumed by the HTTP route)
244
+ // ---------------------------------------------------------------------------
245
+
246
+ export interface UsageReport {
247
+ counts: {
248
+ notes: number;
249
+ attachments: number;
250
+ links: number;
251
+ tags: number;
252
+ };
253
+ bytes: {
254
+ /** Logical: sum of note-content UTF-8 bytes (from VaultStats). */
255
+ content: number;
256
+ /** Physical: vault.db + WAL + shm. */
257
+ db: number;
258
+ /** Bytes under the vault's assets directory (dir-walk, cached). */
259
+ assets: number;
260
+ /**
261
+ * Bytes under the vault's mirror directory (dir-walk, cached), present
262
+ * only when a mirror is configured. The mirror is a git PROJECTION of the
263
+ * same notes/attachments — it is NOT added to `total` (that would
264
+ * double-count the data); it's reported as a separate line item.
265
+ */
266
+ mirror?: number;
267
+ /**
268
+ * Physical on-disk footprint (db + assets); excludes content (inside db)
269
+ * and mirror (separate projection). This is the number an operator sizes a
270
+ * vault by.
271
+ */
272
+ total: number;
273
+ };
274
+ /** ISO-8601 timestamp of when this report was assembled. */
275
+ computedAt: string;
276
+ /** Whether the dir-walk numbers (assets/mirror) came from cache. */
277
+ cached: boolean;
278
+ }
279
+
280
+ /**
281
+ * Assemble the usage report for a vault. `stats` is the already-computed
282
+ * `getVaultStats()` result (counts + contentBytes); the cache supplies the two
283
+ * dir-walk numbers and reports whether they were cached.
284
+ *
285
+ * `total = db + assets` — the physical footprint. Mirror is a separate line
286
+ * item (projection of the same data; adding it double-counts). Content is the
287
+ * logical note size, already inside `db` physically.
288
+ */
289
+ export function buildUsageReport(
290
+ vaultName: string,
291
+ stats: VaultStats,
292
+ opts: { fresh?: boolean; cache?: UsageCache; fs?: UsageFs; now?: Clock } = {},
293
+ ): UsageReport {
294
+ const cache = opts.cache ?? usageCache;
295
+ const { result, cached } = cache.get(vaultName, { fresh: opts.fresh });
296
+ const db = dbBytes(vaultName, opts.fs ?? realUsageFs);
297
+ const total = db + result.assets;
298
+
299
+ const bytes: UsageReport["bytes"] = {
300
+ content: stats.contentBytes,
301
+ db,
302
+ assets: result.assets,
303
+ total,
304
+ };
305
+ if (result.mirror !== null) bytes.mirror = result.mirror;
306
+
307
+ return {
308
+ counts: {
309
+ notes: stats.totalNotes,
310
+ attachments: stats.attachmentCount,
311
+ links: stats.linkCount,
312
+ tags: stats.tagCount,
313
+ },
314
+ bytes,
315
+ computedAt: new Date((opts.now ?? Date.now)()).toISOString(),
316
+ cached,
317
+ };
318
+ }
@@ -10,7 +10,15 @@
10
10
 
11
11
  import { describe, test, expect, beforeEach, afterEach } from "bun:test";
12
12
  import { resolve } from "path";
13
- import { mkdtempSync, rmSync, existsSync, readFileSync } from "fs";
13
+ import {
14
+ mkdtempSync,
15
+ rmSync,
16
+ existsSync,
17
+ readFileSync,
18
+ mkdirSync,
19
+ writeFileSync,
20
+ symlinkSync,
21
+ } from "fs";
14
22
  import { tmpdir } from "os";
15
23
  import { join } from "path";
16
24
 
@@ -33,6 +41,23 @@ function runCli(
33
41
  };
34
42
  }
35
43
 
44
+ /** Path to a vault's per-vault mirror-config file inside a temp PARACHUTE_HOME. */
45
+ function mirrorConfigPath(home: string, name: string): string {
46
+ return join(home, "vault", "data", name, "mirror-config.yaml");
47
+ }
48
+
49
+ /** Path to a vault's internal mirror git working tree. */
50
+ function mirrorRepoPath(home: string, name: string): string {
51
+ return join(home, "vault", "data", name, "mirror");
52
+ }
53
+
54
+ /** Write a minimal config.yaml carrying a `default_mirror` knob value. */
55
+ function writeDefaultMirrorKnob(home: string, value: "internal" | "off"): void {
56
+ const dir = join(home, "vault");
57
+ mkdirSync(dir, { recursive: true });
58
+ writeFileSync(join(dir, "config.yaml"), `port: 1940\ndefault_mirror: ${value}\n`);
59
+ }
60
+
36
61
  let home: string;
37
62
 
38
63
  beforeEach(() => {
@@ -107,7 +132,20 @@ describe("vault create --json", () => {
107
132
  );
108
133
  expect(exitCode).not.toBe(0);
109
134
  expect(stdout).toBe("");
110
- expect(stderr).toContain("letters, numbers");
135
+ expect(stderr).toContain("lowercase alphanumeric");
136
+ });
137
+
138
+ test("UPPERCASE vault name is rejected (security review — audience case-drift)", () => {
139
+ // An uppercase name would flip the JWT audience case (vault.<Name> vs
140
+ // vault.<name>) and drift from hub/init lowercasing. cmdCreate must
141
+ // reject it the same way `init` does.
142
+ const { exitCode, stdout, stderr } = runCli(
143
+ ["create", "MyVault", "--json"],
144
+ { PARACHUTE_HOME: home },
145
+ );
146
+ expect(exitCode).not.toBe(0);
147
+ expect(stdout).toBe("");
148
+ expect(stderr).toContain("lowercase");
111
149
  });
112
150
 
113
151
  test("duplicate name in --json mode errors on stderr and exits non-zero", () => {
@@ -199,3 +237,157 @@ describe("vault create (human mode)", () => {
199
237
  expect(() => JSON.parse(stdout.trim())).toThrow();
200
238
  });
201
239
  });
240
+
241
+ /**
242
+ * Create-time default backup posture: new vaults default to an internal live
243
+ * mirror (local git backup of the markdown projection). The History preset
244
+ * `{enabled:true, location:internal, sync_mode:events, auto_commit:true,
245
+ * auto_push:false}` is written to `data/<vault>/mirror-config.yaml` and (when
246
+ * git is present) the internal mirror dir is git-bootstrapped. The behavior is
247
+ * controlled by the server-wide `default_mirror` knob (default `internal`) and
248
+ * overridable per-create by `--no-mirror`.
249
+ *
250
+ * Critically: create-time ONLY — already-created vaults are NOT retroactively
251
+ * migrated, and the git-less box stays a successful create (best-effort
252
+ * bootstrap, config still written, actionable log).
253
+ */
254
+ describe("vault create — default internal live mirror", () => {
255
+ test("default (no knob) → History-preset mirror config written + git-bootstrapped", () => {
256
+ const { exitCode } = runCli(["create", "backed", "--json"], {
257
+ PARACHUTE_HOME: home,
258
+ });
259
+ expect(exitCode).toBe(0);
260
+
261
+ // Mirror config exists with the exact History preset.
262
+ const cfgPath = mirrorConfigPath(home, "backed");
263
+ expect(existsSync(cfgPath)).toBe(true);
264
+ const cfg = readFileSync(cfgPath, "utf-8");
265
+ expect(cfg).toContain("enabled: true");
266
+ expect(cfg).toContain("location: internal");
267
+ expect(cfg).toContain("sync_mode: events");
268
+ expect(cfg).toContain("auto_commit: true");
269
+ expect(cfg).toContain("auto_push: false");
270
+
271
+ // Git present on the test host → the internal mirror dir is a real repo
272
+ // with the seed commit (bootstrap ran). counts/usage reflect it: the
273
+ // mirror working tree exists under the vault data dir.
274
+ const repo = mirrorRepoPath(home, "backed");
275
+ expect(existsSync(join(repo, ".git"))).toBe(true);
276
+ const log = Bun.spawnSync({
277
+ cmd: ["git", "-C", repo, "log", "--oneline"],
278
+ stdout: "pipe",
279
+ });
280
+ expect(new TextDecoder().decode(log.stdout)).toContain("initial mirror bootstrap");
281
+ });
282
+
283
+ test("default_mirror: off knob → no mirror config written", () => {
284
+ writeDefaultMirrorKnob(home, "off");
285
+ const { exitCode } = runCli(["create", "bare", "--json"], {
286
+ PARACHUTE_HOME: home,
287
+ });
288
+ expect(exitCode).toBe(0);
289
+
290
+ // Vault is created…
291
+ expect(existsSync(join(home, "vault", "data", "bare", "vault.db"))).toBe(true);
292
+ // …but NO mirror config + NO mirror dir.
293
+ expect(existsSync(mirrorConfigPath(home, "bare"))).toBe(false);
294
+ expect(existsSync(mirrorRepoPath(home, "bare"))).toBe(false);
295
+ });
296
+
297
+ test("default_mirror: internal knob (explicit) → mirror config written", () => {
298
+ writeDefaultMirrorKnob(home, "internal");
299
+ const { exitCode } = runCli(["create", "explicit", "--json"], {
300
+ PARACHUTE_HOME: home,
301
+ });
302
+ expect(exitCode).toBe(0);
303
+ expect(existsSync(mirrorConfigPath(home, "explicit"))).toBe(true);
304
+ });
305
+
306
+ test("--no-mirror → no mirror config even when knob is internal", () => {
307
+ writeDefaultMirrorKnob(home, "internal");
308
+ const { exitCode } = runCli(["create", "optout", "--no-mirror", "--json"], {
309
+ PARACHUTE_HOME: home,
310
+ });
311
+ expect(exitCode).toBe(0);
312
+
313
+ expect(existsSync(join(home, "vault", "data", "optout", "vault.db"))).toBe(true);
314
+ expect(existsSync(mirrorConfigPath(home, "optout"))).toBe(false);
315
+ expect(existsSync(mirrorRepoPath(home, "optout"))).toBe(false);
316
+ });
317
+
318
+ test("--no-mirror in human mode keeps the rest of the create working", () => {
319
+ const { exitCode, stdout } = runCli(["create", "humanbare", "--no-mirror"], {
320
+ PARACHUTE_HOME: home,
321
+ });
322
+ expect(exitCode).toBe(0);
323
+ expect(stdout).toContain('Vault "humanbare" created.');
324
+ expect(existsSync(mirrorConfigPath(home, "humanbare"))).toBe(false);
325
+ });
326
+
327
+ test("git missing → vault still creates, config written, mirror NOT bootstrapped, actionable log, no crash", () => {
328
+ // Simulate a git-less server: spawn the CLI with a PATH that resolves
329
+ // `bun` (so the test can run) but NOT `git`. `bootstrapInternalMirror`'s
330
+ // `Bun.which("git")` preflight then returns null → friendly,
331
+ // best-effort failure. The vault create MUST still succeed.
332
+ const bunBin = Bun.which("bun");
333
+ expect(bunBin).toBeTruthy();
334
+ const fakeBin = mkdtempSync(join(tmpdir(), "vault-create-nogit-bin-"));
335
+ symlinkSync(bunBin!, join(fakeBin, "bun"));
336
+ try {
337
+ const proc = Bun.spawnSync({
338
+ cmd: [bunBin!, CLI, "create", "nogit", "--json"],
339
+ stdout: "pipe",
340
+ stderr: "pipe",
341
+ // Replace PATH entirely so `git` is unresolvable — only our fake bin
342
+ // (bun only) is on the path.
343
+ env: { ...process.env, PARACHUTE_HOME: home, PATH: fakeBin },
344
+ });
345
+ const exitCode = proc.exitCode ?? -1;
346
+ const stdout = new TextDecoder().decode(proc.stdout);
347
+ const stderr = new TextDecoder().decode(proc.stderr);
348
+
349
+ // No crash — the vault create succeeds on a git-less box.
350
+ expect(exitCode).toBe(0);
351
+ // The vault itself was created.
352
+ expect(existsSync(join(home, "vault", "data", "nogit", "vault.db"))).toBe(true);
353
+ // The mirror CONFIG was still written (intent persists for when git
354
+ // lands later).
355
+ expect(existsSync(mirrorConfigPath(home, "nogit"))).toBe(true);
356
+ // But the mirror was NOT git-bootstrapped (no .git — git was absent).
357
+ expect(existsSync(join(mirrorRepoPath(home, "nogit"), ".git"))).toBe(false);
358
+ // And the operator got an actionable, clear log on stderr.
359
+ expect(stderr).toContain("local git backup configured but not yet active");
360
+ expect(stderr).toContain("Install git");
361
+ // JSON stdout stays clean + parseable (the note went to stderr).
362
+ const payload = JSON.parse(stdout.trim());
363
+ expect(payload.name).toBe("nogit");
364
+ } finally {
365
+ rmSync(fakeBin, { recursive: true, force: true });
366
+ }
367
+ });
368
+
369
+ test("EXISTING vault (created before this change) is NOT auto-migrated on a later create", () => {
370
+ // Simulate a vault that predates the default-mirror behavior: create it
371
+ // with the knob OFF so it gets no mirror config (stand-in for "created by
372
+ // an older vault build").
373
+ writeDefaultMirrorKnob(home, "off");
374
+ const first = runCli(["create", "legacy", "--json"], { PARACHUTE_HOME: home });
375
+ expect(first.exitCode).toBe(0);
376
+ expect(existsSync(mirrorConfigPath(home, "legacy"))).toBe(false);
377
+
378
+ // Now flip the knob ON and create a SECOND, different vault. The act of
379
+ // creating "fresh" (which DOES get a mirror) must not retroactively write
380
+ // a mirror config onto the pre-existing "legacy" vault — create-time only,
381
+ // never a sweep over existing vaults.
382
+ writeDefaultMirrorKnob(home, "internal");
383
+ const second = runCli(["create", "fresh", "--json"], { PARACHUTE_HOME: home });
384
+ expect(second.exitCode).toBe(0);
385
+
386
+ // The new vault is backed…
387
+ expect(existsSync(mirrorConfigPath(home, "fresh"))).toBe(true);
388
+ // …but the pre-existing vault is left exactly as it was — no surprise
389
+ // mirror config, no surprise disk-doubling mirror repo.
390
+ expect(existsSync(mirrorConfigPath(home, "legacy"))).toBe(false);
391
+ expect(existsSync(mirrorRepoPath(home, "legacy"))).toBe(false);
392
+ });
393
+ });