@openparachute/vault 0.2.4 → 0.3.0

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 (102) hide show
  1. package/.claude/settings.local.json +2 -25
  2. package/CHANGELOG.md +64 -0
  3. package/CLAUDE.md +17 -7
  4. package/README.md +169 -136
  5. package/core/src/core.test.ts +591 -19
  6. package/core/src/hooks.ts +111 -3
  7. package/core/src/indexed-fields.test.ts +285 -0
  8. package/core/src/indexed-fields.ts +238 -0
  9. package/core/src/mcp.ts +127 -6
  10. package/core/src/notes.ts +153 -11
  11. package/core/src/query-operators.ts +174 -0
  12. package/core/src/schema.ts +69 -2
  13. package/core/src/store.ts +95 -1
  14. package/core/src/tag-schemas.ts +5 -0
  15. package/core/src/types.ts +28 -1
  16. package/docs/HTTP_API.md +105 -1
  17. package/docs/auth-model.md +340 -0
  18. package/package/package.json +32 -0
  19. package/package.json +2 -2
  20. package/src/auth.test.ts +83 -114
  21. package/src/auth.ts +68 -6
  22. package/src/backup-launchd.ts +1 -1
  23. package/src/backup.test.ts +1 -1
  24. package/src/backup.ts +18 -17
  25. package/src/bind.test.ts +28 -0
  26. package/src/bind.ts +19 -0
  27. package/src/cli.ts +228 -133
  28. package/src/config-triggers.test.ts +49 -0
  29. package/src/config.test.ts +317 -2
  30. package/src/config.ts +420 -40
  31. package/src/context.test.ts +136 -0
  32. package/src/context.ts +115 -0
  33. package/src/daemon.ts +17 -16
  34. package/src/doctor.test.ts +9 -7
  35. package/src/launchd.test.ts +1 -1
  36. package/src/launchd.ts +6 -6
  37. package/src/mcp-http.ts +75 -21
  38. package/src/mcp-install.test.ts +125 -0
  39. package/src/mcp-install.ts +60 -0
  40. package/src/mcp-tools.ts +34 -96
  41. package/src/module-config.ts +109 -0
  42. package/src/oauth.test.ts +345 -57
  43. package/src/oauth.ts +155 -35
  44. package/src/published.test.ts +2 -2
  45. package/src/routes.ts +209 -33
  46. package/src/routing.test.ts +817 -300
  47. package/src/routing.ts +204 -202
  48. package/src/scopes.test.ts +294 -0
  49. package/src/scopes.ts +253 -0
  50. package/src/scribe-env.test.ts +49 -0
  51. package/src/scribe-env.ts +33 -0
  52. package/src/server.ts +73 -9
  53. package/src/services-manifest.test.ts +140 -0
  54. package/src/services-manifest.ts +99 -0
  55. package/src/systemd.ts +3 -3
  56. package/src/token-store.ts +42 -9
  57. package/src/transcription-worker.test.ts +864 -0
  58. package/src/transcription-worker.ts +501 -0
  59. package/src/triggers.test.ts +191 -1
  60. package/src/triggers.ts +17 -2
  61. package/src/vault.test.ts +693 -77
  62. package/src/version.test.ts +1 -1
  63. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  64. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  65. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  66. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  67. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  68. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  69. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  70. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  71. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  72. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  73. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  74. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  75. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  76. package/religions-abrahamic-filter.png +0 -0
  77. package/religions-buddhism-v2.png +0 -0
  78. package/religions-buddhism.png +0 -0
  79. package/religions-final.png +0 -0
  80. package/religions-v1.png +0 -0
  81. package/religions-v2.png +0 -0
  82. package/religions-zen.png +0 -0
  83. package/web/README.md +0 -73
  84. package/web/bun.lock +0 -827
  85. package/web/eslint.config.js +0 -23
  86. package/web/index.html +0 -15
  87. package/web/package.json +0 -36
  88. package/web/public/favicon.svg +0 -1
  89. package/web/public/icons.svg +0 -24
  90. package/web/src/App.tsx +0 -149
  91. package/web/src/Graph.tsx +0 -200
  92. package/web/src/NoteView.tsx +0 -155
  93. package/web/src/Sidebar.tsx +0 -186
  94. package/web/src/api.ts +0 -21
  95. package/web/src/index.css +0 -50
  96. package/web/src/main.tsx +0 -10
  97. package/web/src/types.ts +0 -37
  98. package/web/src/utils.ts +0 -107
  99. package/web/tsconfig.app.json +0 -25
  100. package/web/tsconfig.json +0 -7
  101. package/web/tsconfig.node.json +0 -24
  102. package/web/vite.config.ts +0 -16
@@ -80,4 +80,53 @@ describe("trigger config round-trip", () => {
80
80
  const config = readGlobalConfig();
81
81
  expect(config.triggers).toBeUndefined();
82
82
  });
83
+
84
+ it("roundtrips triggers with action.include_context", () => {
85
+ mkdirSync(testDir, { recursive: true });
86
+ writeGlobalConfig({
87
+ port: 1940,
88
+ triggers: [
89
+ {
90
+ name: "transcribe-with-people",
91
+ when: { tags: ["capture"], has_content: false },
92
+ action: {
93
+ webhook: "http://localhost:1943/v1/audio/transcriptions",
94
+ send: "attachment",
95
+ include_context: [
96
+ { tag: "person", include_metadata: ["summary", "aliases"] },
97
+ { tag: "project", exclude_tag: "archived", include_metadata: ["summary"] },
98
+ ],
99
+ },
100
+ },
101
+ ],
102
+ });
103
+
104
+ const config = readGlobalConfig();
105
+ const ctx = config.triggers![0].action.include_context!;
106
+ expect(ctx.length).toBe(2);
107
+ expect(ctx[0]).toEqual({ tag: "person", include_metadata: ["summary", "aliases"] });
108
+ expect(ctx[1]).toEqual({
109
+ tag: "project",
110
+ exclude_tag: "archived",
111
+ include_metadata: ["summary"],
112
+ });
113
+ });
114
+
115
+ it("skips include_context serialization when empty", () => {
116
+ mkdirSync(testDir, { recursive: true });
117
+ writeGlobalConfig({
118
+ port: 1940,
119
+ triggers: [
120
+ {
121
+ name: "plain",
122
+ when: { tags: ["t"] },
123
+ action: { webhook: "http://x/y" },
124
+ },
125
+ ],
126
+ });
127
+ const raw = readFileSync(join(testDir, "vault", "config.yaml"), "utf8");
128
+ expect(raw).not.toContain("include_context");
129
+ const config = readGlobalConfig();
130
+ expect(config.triggers![0].action.include_context).toBeUndefined();
131
+ });
83
132
  });
@@ -125,6 +125,47 @@ describe("config", () => {
125
125
  expect(loaded!.tag_schemas).toBeUndefined();
126
126
  });
127
127
 
128
+ test("round-trips vault config transcription.context", () => {
129
+ const config: VaultConfig = {
130
+ name: "testvault",
131
+ api_keys: [],
132
+ created_at: "2026-01-01T00:00:00.000Z",
133
+ transcription: {
134
+ context: [
135
+ { tag: "person", include_metadata: ["summary", "aliases"] },
136
+ { tag: "project", exclude_tag: "archived", include_metadata: ["summary"] },
137
+ ],
138
+ },
139
+ };
140
+
141
+ writeVaultConfig(config);
142
+
143
+ const loaded = readVaultConfig("testvault");
144
+ expect(loaded).not.toBeNull();
145
+ expect(loaded!.transcription?.context).toBeDefined();
146
+ expect(loaded!.transcription!.context!.length).toBe(2);
147
+ expect(loaded!.transcription!.context![0]).toEqual({
148
+ tag: "person",
149
+ include_metadata: ["summary", "aliases"],
150
+ });
151
+ expect(loaded!.transcription!.context![1]).toEqual({
152
+ tag: "project",
153
+ exclude_tag: "archived",
154
+ include_metadata: ["summary"],
155
+ });
156
+ });
157
+
158
+ test("vault config without transcription.context loads cleanly", () => {
159
+ const config: VaultConfig = {
160
+ name: "testvault",
161
+ api_keys: [],
162
+ created_at: "2026-01-01T00:00:00.000Z",
163
+ };
164
+ writeVaultConfig(config);
165
+ const loaded = readVaultConfig("testvault");
166
+ expect(loaded!.transcription).toBeUndefined();
167
+ });
168
+
128
169
  test("round-trips discovery: enabled|disabled", () => {
129
170
  // Default: absent means enabled (endpoint serves names).
130
171
  writeGlobalConfig({ port: 1940 });
@@ -201,7 +242,9 @@ describe2("backup config round-trip", () => {
201
242
  process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
202
243
  const fs = await import("fs");
203
244
  const path = await import("path");
204
- fs.writeFileSync(path.join(${JSON.stringify(dir)}, "config.yaml"),
245
+ const vaultHome = path.join(${JSON.stringify(dir)}, "vault");
246
+ fs.mkdirSync(vaultHome, { recursive: true });
247
+ fs.writeFileSync(path.join(vaultHome, "config.yaml"),
205
248
  "port: 1940\\nbackup:\\n schedule: daily\\n destinations: []\\n");
206
249
  const { readGlobalConfig } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
207
250
  console.log(JSON.stringify(readGlobalConfig().backup));
@@ -221,7 +264,9 @@ describe2("backup config round-trip", () => {
221
264
  process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
222
265
  const fs = await import("fs");
223
266
  const path = await import("path");
224
- fs.writeFileSync(path.join(${JSON.stringify(dir)}, "config.yaml"),
267
+ const vaultHome = path.join(${JSON.stringify(dir)}, "vault");
268
+ fs.mkdirSync(vaultHome, { recursive: true });
269
+ fs.writeFileSync(path.join(vaultHome, "config.yaml"),
225
270
  "port: 1940\\nbackup:\\n schedule: daily\\n retention:\\n daily: 3\\n destinations: []\\n");
226
271
  const { readGlobalConfig } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
227
272
  console.log(JSON.stringify(readGlobalConfig().backup));
@@ -296,3 +341,273 @@ describe2("backup config round-trip", () => {
296
341
  expect2(parsed.destinations).toEqual([]);
297
342
  });
298
343
  });
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // Legacy layout migration — pre-0.3 installs put vault files at the
347
+ // ecosystem root; on startup we move them into vault/.
348
+ // ---------------------------------------------------------------------------
349
+
350
+ describe2("migrateFromLegacyLayout", () => {
351
+ let dir: string;
352
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "parachute-migrate-")); });
353
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
354
+
355
+ test2("fresh install with no legacy files is a no-op and creates data/ + logs/", async () => {
356
+ const script = `
357
+ process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
358
+ const fs = await import("fs");
359
+ const path = await import("path");
360
+ const { ensureConfigDirSync } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
361
+ ensureConfigDirSync();
362
+ const vaultHome = path.join(${JSON.stringify(dir)}, "vault");
363
+ console.log(JSON.stringify({
364
+ vaultHomeExists: fs.existsSync(vaultHome),
365
+ dataExists: fs.existsSync(path.join(vaultHome, "data")),
366
+ logsExists: fs.existsSync(path.join(vaultHome, "logs")),
367
+ legacyVaultsDirExists: fs.existsSync(path.join(vaultHome, "vaults")),
368
+ rootEnvExists: fs.existsSync(path.join(${JSON.stringify(dir)}, ".env")),
369
+ }));
370
+ `;
371
+ const proc = Bun.spawnSync({ cmd: ["bun", "-e", script], stdout: "pipe", stderr: "pipe" });
372
+ const stderr = new TextDecoder().decode(proc.stderr);
373
+ expect2(proc.exitCode, stderr).toBe(0);
374
+ const parsed = JSON.parse(new TextDecoder().decode(proc.stdout).trim());
375
+ expect2(parsed.vaultHomeExists).toBe(true);
376
+ expect2(parsed.dataExists).toBe(true);
377
+ expect2(parsed.logsExists).toBe(true);
378
+ expect2(parsed.legacyVaultsDirExists).toBe(false);
379
+ expect2(parsed.rootEnvExists).toBe(false);
380
+ });
381
+
382
+ test2("moves legacy root files into vault/ (vaults → data/, logs → logs/)", async () => {
383
+ const script = `
384
+ process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
385
+ const fs = await import("fs");
386
+ const path = await import("path");
387
+ const root = ${JSON.stringify(dir)};
388
+ fs.mkdirSync(root, { recursive: true });
389
+ fs.writeFileSync(path.join(root, ".env"), "PORT=1940\\n");
390
+ fs.writeFileSync(path.join(root, "config.yaml"), "port: 1940\\n");
391
+ fs.writeFileSync(path.join(root, "vault.log"), "log\\n");
392
+ fs.writeFileSync(path.join(root, "vault.err"), "err\\n");
393
+ fs.writeFileSync(path.join(root, "start.sh"), "#!/bin/bash\\n");
394
+ fs.writeFileSync(path.join(root, "server-path"), "/repo/src/server.ts\\n");
395
+ fs.mkdirSync(path.join(root, "vaults", "default"), { recursive: true });
396
+ fs.writeFileSync(path.join(root, "vaults", "default", "vault.yaml"), "name: default\\napi_keys: []\\n");
397
+ const { ensureConfigDirSync } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
398
+ ensureConfigDirSync();
399
+ const vaultHome = path.join(root, "vault");
400
+ console.log(JSON.stringify({
401
+ env: fs.readFileSync(path.join(vaultHome, ".env"), "utf-8"),
402
+ config: fs.readFileSync(path.join(vaultHome, "config.yaml"), "utf-8"),
403
+ start: fs.readFileSync(path.join(vaultHome, "start.sh"), "utf-8"),
404
+ serverPath: fs.readFileSync(path.join(vaultHome, "server-path"), "utf-8"),
405
+ vaultYaml: fs.readFileSync(path.join(vaultHome, "data", "default", "vault.yaml"), "utf-8"),
406
+ log: fs.readFileSync(path.join(vaultHome, "logs", "vault.log"), "utf-8"),
407
+ err: fs.readFileSync(path.join(vaultHome, "logs", "vault.err"), "utf-8"),
408
+ legacyEnv: fs.existsSync(path.join(root, ".env")),
409
+ legacyVaults: fs.existsSync(path.join(root, "vaults")),
410
+ legacyVaultLog: fs.existsSync(path.join(root, "vault.log")),
411
+ legacyVaultErr: fs.existsSync(path.join(root, "vault.err")),
412
+ }));
413
+ `;
414
+ const proc = Bun.spawnSync({ cmd: ["bun", "-e", script], stdout: "pipe", stderr: "pipe" });
415
+ const stderr = new TextDecoder().decode(proc.stderr);
416
+ expect2(proc.exitCode, stderr).toBe(0);
417
+ const parsed = JSON.parse(new TextDecoder().decode(proc.stdout).trim());
418
+ expect2(parsed.env).toContain("PORT=1940");
419
+ expect2(parsed.config).toContain("port: 1940");
420
+ expect2(parsed.start).toContain("#!/bin/bash");
421
+ expect2(parsed.serverPath).toContain("/repo/src/server.ts");
422
+ expect2(parsed.vaultYaml).toContain("name: default");
423
+ expect2(parsed.log).toContain("log");
424
+ expect2(parsed.err).toContain("err");
425
+ // Legacy paths should be gone after the move.
426
+ expect2(parsed.legacyEnv).toBe(false);
427
+ expect2(parsed.legacyVaults).toBe(false);
428
+ expect2(parsed.legacyVaultLog).toBe(false);
429
+ expect2(parsed.legacyVaultErr).toBe(false);
430
+ });
431
+
432
+ test2("double-migration is idempotent", async () => {
433
+ const script = `
434
+ process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
435
+ const fs = await import("fs");
436
+ const path = await import("path");
437
+ const root = ${JSON.stringify(dir)};
438
+ fs.writeFileSync(path.join(root, ".env"), "PORT=1940\\n");
439
+ const { ensureConfigDirSync } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
440
+ ensureConfigDirSync();
441
+ // Second call: no legacy paths left, nothing to do.
442
+ ensureConfigDirSync();
443
+ console.log(JSON.stringify({
444
+ envInVault: fs.readFileSync(path.join(root, "vault", ".env"), "utf-8"),
445
+ rootEnv: fs.existsSync(path.join(root, ".env")),
446
+ }));
447
+ `;
448
+ const proc = Bun.spawnSync({ cmd: ["bun", "-e", script], stdout: "pipe", stderr: "pipe" });
449
+ const stderr = new TextDecoder().decode(proc.stderr);
450
+ expect2(proc.exitCode, stderr).toBe(0);
451
+ const parsed = JSON.parse(new TextDecoder().decode(proc.stdout).trim());
452
+ expect2(parsed.envInVault).toContain("PORT=1940");
453
+ expect2(parsed.rootEnv).toBe(false);
454
+ });
455
+
456
+ test2("0.3 install: vault/vaults/ → vault/data/, vault/{vault.log,vault.err} → vault/logs/", async () => {
457
+ // Users who installed on 0.3 (post-PR-8 but pre-filesystem-hygiene)
458
+ // have vault state under `vault/` with the old internal names.
459
+ const script = `
460
+ process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
461
+ const fs = await import("fs");
462
+ const path = await import("path");
463
+ const vaultHome = path.join(${JSON.stringify(dir)}, "vault");
464
+ fs.mkdirSync(path.join(vaultHome, "vaults", "work"), { recursive: true });
465
+ fs.writeFileSync(path.join(vaultHome, "vaults", "work", "vault.yaml"), "name: work\\napi_keys: []\\n");
466
+ fs.writeFileSync(path.join(vaultHome, "vault.log"), "daemon-stdout\\n");
467
+ fs.writeFileSync(path.join(vaultHome, "vault.err"), "daemon-stderr\\n");
468
+ const { ensureConfigDirSync } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
469
+ ensureConfigDirSync();
470
+ console.log(JSON.stringify({
471
+ vaultYaml: fs.readFileSync(path.join(vaultHome, "data", "work", "vault.yaml"), "utf-8"),
472
+ log: fs.readFileSync(path.join(vaultHome, "logs", "vault.log"), "utf-8"),
473
+ err: fs.readFileSync(path.join(vaultHome, "logs", "vault.err"), "utf-8"),
474
+ legacyVaultsDir: fs.existsSync(path.join(vaultHome, "vaults")),
475
+ legacyLog: fs.existsSync(path.join(vaultHome, "vault.log")),
476
+ legacyErr: fs.existsSync(path.join(vaultHome, "vault.err")),
477
+ }));
478
+ `;
479
+ const proc = Bun.spawnSync({ cmd: ["bun", "-e", script], stdout: "pipe", stderr: "pipe" });
480
+ const stderr = new TextDecoder().decode(proc.stderr);
481
+ expect2(proc.exitCode, stderr).toBe(0);
482
+ const parsed = JSON.parse(new TextDecoder().decode(proc.stdout).trim());
483
+ expect2(parsed.vaultYaml).toContain("name: work");
484
+ expect2(parsed.log).toContain("daemon-stdout");
485
+ expect2(parsed.err).toContain("daemon-stderr");
486
+ expect2(parsed.legacyVaultsDir).toBe(false);
487
+ expect2(parsed.legacyLog).toBe(false);
488
+ expect2(parsed.legacyErr).toBe(false);
489
+ });
490
+
491
+ test2("0.3 internal migration: both vault/vaults/ and vault/data/ exist — data/ wins, vaults/ preserved", async () => {
492
+ const script = `
493
+ process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
494
+ const fs = await import("fs");
495
+ const path = await import("path");
496
+ const vaultHome = path.join(${JSON.stringify(dir)}, "vault");
497
+ // Both layouts present with distinct marker content — migration
498
+ // must NOT overwrite data/ (user may have manually staged it).
499
+ fs.mkdirSync(path.join(vaultHome, "vaults"), { recursive: true });
500
+ fs.writeFileSync(path.join(vaultHome, "vaults", "MARKER_LEGACY"), "legacy\\n");
501
+ fs.mkdirSync(path.join(vaultHome, "data"), { recursive: true });
502
+ fs.writeFileSync(path.join(vaultHome, "data", "MARKER_CURRENT"), "current\\n");
503
+ const { ensureConfigDirSync } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
504
+ ensureConfigDirSync();
505
+ console.log(JSON.stringify({
506
+ dataMarker: fs.existsSync(path.join(vaultHome, "data", "MARKER_CURRENT")),
507
+ legacyMarker: fs.existsSync(path.join(vaultHome, "vaults", "MARKER_LEGACY")),
508
+ dataOverwrittenByLegacy: fs.existsSync(path.join(vaultHome, "data", "MARKER_LEGACY")),
509
+ }));
510
+ `;
511
+ const proc = Bun.spawnSync({ cmd: ["bun", "-e", script], stdout: "pipe", stderr: "pipe" });
512
+ const stderr = new TextDecoder().decode(proc.stderr);
513
+ expect2(proc.exitCode, stderr).toBe(0);
514
+ const parsed = JSON.parse(new TextDecoder().decode(proc.stdout).trim());
515
+ expect2(parsed.dataMarker).toBe(true); // data/ untouched
516
+ expect2(parsed.legacyMarker).toBe(true); // vaults/ untouched, for user to inspect
517
+ expect2(parsed.dataOverwrittenByLegacy).toBe(false);
518
+ // Warning surfaces on stderr so the user sees it.
519
+ expect2(stderr).toContain("both");
520
+ expect2(stderr).toContain("vaults");
521
+ expect2(stderr).toContain("data");
522
+ });
523
+
524
+ test2("0.3 internal migration: idempotent — second boot is a no-op", async () => {
525
+ const script = `
526
+ process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
527
+ const fs = await import("fs");
528
+ const path = await import("path");
529
+ const vaultHome = path.join(${JSON.stringify(dir)}, "vault");
530
+ fs.mkdirSync(path.join(vaultHome, "vaults", "journal"), { recursive: true });
531
+ fs.writeFileSync(path.join(vaultHome, "vaults", "journal", "vault.yaml"), "name: journal\\n");
532
+ const { ensureConfigDirSync } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
533
+ ensureConfigDirSync();
534
+ ensureConfigDirSync(); // second boot — no-op.
535
+ console.log(JSON.stringify({
536
+ vaultYaml: fs.readFileSync(path.join(vaultHome, "data", "journal", "vault.yaml"), "utf-8"),
537
+ legacyVaultsDir: fs.existsSync(path.join(vaultHome, "vaults")),
538
+ }));
539
+ `;
540
+ const proc = Bun.spawnSync({ cmd: ["bun", "-e", script], stdout: "pipe", stderr: "pipe" });
541
+ const stderr = new TextDecoder().decode(proc.stderr);
542
+ expect2(proc.exitCode, stderr).toBe(0);
543
+ const parsed = JSON.parse(new TextDecoder().decode(proc.stdout).trim());
544
+ expect2(parsed.vaultYaml).toContain("name: journal");
545
+ expect2(parsed.legacyVaultsDir).toBe(false);
546
+ });
547
+
548
+ test2("leaves legacy in place when target already exists (no overwrite)", async () => {
549
+ const script = `
550
+ process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
551
+ const fs = await import("fs");
552
+ const path = await import("path");
553
+ const root = ${JSON.stringify(dir)};
554
+ const vaultHome = path.join(root, "vault");
555
+ fs.mkdirSync(vaultHome, { recursive: true });
556
+ fs.writeFileSync(path.join(root, ".env"), "LEGACY\\n");
557
+ fs.writeFileSync(path.join(vaultHome, ".env"), "CURRENT\\n");
558
+ const { ensureConfigDirSync } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
559
+ ensureConfigDirSync();
560
+ console.log(JSON.stringify({
561
+ vaultEnv: fs.readFileSync(path.join(vaultHome, ".env"), "utf-8"),
562
+ legacyEnv: fs.existsSync(path.join(root, ".env"))
563
+ ? fs.readFileSync(path.join(root, ".env"), "utf-8")
564
+ : null,
565
+ }));
566
+ `;
567
+ const proc = Bun.spawnSync({ cmd: ["bun", "-e", script], stdout: "pipe", stderr: "pipe" });
568
+ const stderr = new TextDecoder().decode(proc.stderr);
569
+ expect2(proc.exitCode, stderr).toBe(0);
570
+ const parsed = JSON.parse(new TextDecoder().decode(proc.stdout).trim());
571
+ // Target wins; legacy file is left alone for the user to investigate.
572
+ expect2(parsed.vaultEnv).toBe("CURRENT\n");
573
+ expect2(parsed.legacyEnv).toBe("LEGACY\n");
574
+ });
575
+ });
576
+
577
+ // ---------------------------------------------------------------------------
578
+ // formatMigrationFailure: the warn-line the migration catches emit. EXDEV
579
+ // (cross-device rename) is hard to simulate in a test — PARACHUTE_HOME
580
+ // straddling a mount is the real trigger — but we can at least verify the
581
+ // helper attaches the "mount boundary" hint when the error code matches,
582
+ // so users debugging a Docker/multi-disk layout get a meaningful message
583
+ // instead of "failed to migrate X → Y: EXDEV: cross-device link not permitted".
584
+ // ---------------------------------------------------------------------------
585
+
586
+ import { formatMigrationFailure } from "./config.ts";
587
+
588
+ describe("formatMigrationFailure", () => {
589
+ test("EXDEV errors get a mount-boundary hint and note legacy fallback", () => {
590
+ const err = Object.assign(new Error("EXDEV: cross-device link not permitted"), {
591
+ code: "EXDEV",
592
+ });
593
+ const msg = formatMigrationFailure("/src/path", "/dst/path", err);
594
+ expect(msg).toContain("mount boundary");
595
+ expect(msg).toContain("EXDEV");
596
+ expect(msg).toContain("legacy layout");
597
+ expect(msg).toContain("/src/path");
598
+ expect(msg).toContain("/dst/path");
599
+ });
600
+
601
+ test("non-EXDEV errors fall back to the raw message", () => {
602
+ const err = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" });
603
+ const msg = formatMigrationFailure("/src/path", "/dst/path", err);
604
+ expect(msg).toContain("EACCES: permission denied");
605
+ expect(msg).not.toContain("mount boundary");
606
+ });
607
+
608
+ test("non-Error values are stringified safely", () => {
609
+ const msg = formatMigrationFailure("/src", "/dst", "weird string");
610
+ expect(msg).toContain("weird string");
611
+ expect(msg).not.toContain("mount boundary");
612
+ });
613
+ });