@openparachute/vault 0.2.3 → 0.3.0-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/.claude/settings.local.json +8 -0
- package/CHANGELOG.md +70 -0
- package/CLAUDE.md +17 -7
- package/README.md +169 -136
- package/core/src/core.test.ts +603 -19
- package/core/src/indexed-fields.test.ts +285 -0
- package/core/src/indexed-fields.ts +238 -0
- package/core/src/mcp.ts +127 -6
- package/core/src/notes.ts +157 -11
- package/core/src/query-operators.ts +174 -0
- package/core/src/schema.ts +69 -2
- package/core/src/store.ts +92 -0
- package/core/src/tag-schemas.ts +5 -0
- package/core/src/types.ts +29 -1
- package/docs/HTTP_API.md +105 -1
- package/package/package.json +32 -0
- package/package.json +2 -2
- package/src/auth.test.ts +83 -114
- package/src/auth.ts +68 -6
- package/src/backup-launchd.ts +1 -1
- package/src/backup.test.ts +1 -1
- package/src/backup.ts +18 -17
- package/src/cli.ts +179 -121
- package/src/config-triggers.test.ts +49 -0
- package/src/config.test.ts +317 -2
- package/src/config.ts +420 -40
- package/src/context.test.ts +136 -0
- package/src/context.ts +115 -0
- package/src/daemon.ts +17 -16
- package/src/doctor.test.ts +9 -7
- package/src/launchd.test.ts +1 -1
- package/src/launchd.ts +6 -6
- package/src/mcp-http.ts +75 -21
- package/src/mcp-install.test.ts +125 -0
- package/src/mcp-install.ts +60 -0
- package/src/mcp-tools.ts +34 -96
- package/src/module-config.ts +109 -0
- package/src/oauth.test.ts +345 -57
- package/src/oauth.ts +155 -35
- package/src/published.test.ts +2 -2
- package/src/routes.ts +209 -33
- package/src/routing.test.ts +817 -300
- package/src/routing.ts +204 -202
- package/src/scopes.test.ts +136 -0
- package/src/scopes.ts +105 -0
- package/src/scribe-env.test.ts +49 -0
- package/src/scribe-env.ts +33 -0
- package/src/server.ts +57 -5
- package/src/services-manifest.test.ts +140 -0
- package/src/services-manifest.ts +99 -0
- package/src/systemd.ts +3 -3
- package/src/token-store.ts +42 -9
- package/src/transcription-worker.test.ts +583 -0
- package/src/transcription-worker.ts +346 -0
- package/src/triggers.test.ts +191 -1
- package/src/triggers.ts +17 -2
- package/src/vault.test.ts +693 -77
- package/src/version.test.ts +1 -1
|
@@ -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
|
});
|
package/src/config.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
});
|