@openparachute/vault 0.6.3 → 0.6.4-rc.10

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 (51) hide show
  1. package/README.md +46 -11
  2. package/core/src/attribution.test.ts +273 -0
  3. package/core/src/core.test.ts +126 -0
  4. package/core/src/cursor.ts +8 -0
  5. package/core/src/enforced-writes.test.ts +533 -0
  6. package/core/src/mcp.ts +235 -9
  7. package/core/src/migrate-tag-field.test.ts +471 -0
  8. package/core/src/migrate-tag-field.ts +638 -0
  9. package/core/src/notes.ts +280 -7
  10. package/core/src/query-operators.ts +117 -0
  11. package/core/src/schema-defaults.ts +162 -9
  12. package/core/src/schema.ts +61 -2
  13. package/core/src/store.ts +51 -19
  14. package/core/src/tag-schemas.ts +12 -0
  15. package/core/src/triggers-store.ts +6 -0
  16. package/core/src/types.ts +35 -14
  17. package/core/src/vault-projection.ts +19 -0
  18. package/package.json +1 -1
  19. package/src/admin-spa.test.ts +18 -5
  20. package/src/admin-spa.ts +24 -3
  21. package/src/attribution-threading.test.ts +350 -0
  22. package/src/auth.ts +82 -4
  23. package/src/cli.ts +345 -9
  24. package/src/config.ts +11 -0
  25. package/src/first-boot-create.test.ts +155 -0
  26. package/src/import-daemon-busy.test.ts +8 -17
  27. package/src/mcp-http.ts +27 -0
  28. package/src/mcp-tools.ts +31 -4
  29. package/src/mirror-credentials.test.ts +47 -0
  30. package/src/mirror-credentials.ts +33 -0
  31. package/src/mirror-history.test.ts +426 -0
  32. package/src/mirror-manager.ts +202 -22
  33. package/src/mirror-routes.test.ts +78 -0
  34. package/src/mirror-routes.ts +195 -1
  35. package/src/module-config.ts +46 -80
  36. package/src/routes.ts +209 -41
  37. package/src/routing.test.ts +115 -25
  38. package/src/routing.ts +64 -2
  39. package/src/scale.bench.test.ts +82 -0
  40. package/src/scopes.test.ts +24 -0
  41. package/src/scopes.ts +63 -0
  42. package/src/self-register.test.ts +5 -5
  43. package/src/self-register.ts +8 -3
  44. package/src/server.ts +58 -38
  45. package/src/subscribe.test.ts +23 -2
  46. package/src/test-support/spawn.ts +85 -0
  47. package/src/triggers-api.ts +24 -0
  48. package/src/triggers.test.ts +33 -0
  49. package/src/triggers.ts +17 -0
  50. package/src/vault-remove.test.ts +4 -5
  51. package/src/vault.test.ts +188 -17
package/src/cli.ts CHANGED
@@ -110,11 +110,17 @@ import { getVaultStore } from "./vault-store.ts";
110
110
  import { seedOnboardingNotesBestEffort } from "./onboarding-seed.ts";
111
111
  import {
112
112
  defaultMirrorConfig,
113
+ readMirrorConfigForVault,
113
114
  resolveMirrorPath,
114
115
  writeMirrorConfigForVault,
115
116
  type MirrorConfig,
116
117
  } from "./mirror-config.ts";
117
- import { bootstrapInternalMirror } from "./mirror-manager.ts";
118
+ import {
119
+ bootstrapInternalMirror,
120
+ readMirrorHistory,
121
+ showMirrorRevision,
122
+ } from "./mirror-manager.ts";
123
+ import { GitNotInstalledError, ensureGitAvailable } from "./git-preflight.ts";
118
124
  import { selfRegister } from "./self-register.ts";
119
125
  import {
120
126
  hasOwnerPassword,
@@ -234,6 +240,9 @@ switch (command) {
234
240
  case "export":
235
241
  await cmdExport(cmdArgs);
236
242
  break;
243
+ case "history":
244
+ await cmdHistory(cmdArgs);
245
+ break;
237
246
  case "schema":
238
247
  await cmdSchema(cmdArgs);
239
248
  break;
@@ -1665,11 +1674,11 @@ function cmdRemove(args: string[]) {
1665
1674
  // out of the parachute-vault row immediately — the same selfRegister
1666
1675
  // refresh cmdCreate does (#208). Without this, the hub's well-known
1667
1676
  // fan-out kept advertising the deleted vault until the next server boot.
1668
- // Note: with zero vaults remaining, selfRegister falls back to the
1669
- // manifest's canonical paths (`/vault/default`) the same row a
1670
- // subsequent boot would write so CLI-remove and boot agree on the
1671
- // zero-vault registration shape. Warnings go to stderr; status lines stay
1672
- // ours.
1677
+ // Note: with zero vaults remaining, selfRegister emits paths: [] (#478) —
1678
+ // the row stays present (so the hub still sees the module as installed)
1679
+ // but advertises no /vault/<name> path. The hub must tolerate empty-paths
1680
+ // rows and skip them rather than resolving a phantom "default". Warnings
1681
+ // go to stderr; status lines stay ours.
1673
1682
  selfRegister({
1674
1683
  version: pkg.version,
1675
1684
  warn: (msg) => console.error(`Warning: ${msg}`),
@@ -3330,6 +3339,163 @@ async function cmdExport(args: string[]) {
3330
3339
  await new Promise(() => {});
3331
3340
  }
3332
3341
 
3342
+ // ---------------------------------------------------------------------------
3343
+ // `parachute-vault history` — surface the vault's git write history (vault#300)
3344
+ // ---------------------------------------------------------------------------
3345
+
3346
+ /**
3347
+ * `parachute-vault history [--note <path>] [--limit N] [--vault <name>] [--json]`
3348
+ *
3349
+ * Surfaces the mirror's git commit log — the vault is already git-backed
3350
+ * (one file per note), so `git log` IS a tamper-evident, diffable write
3351
+ * history. This CLI front-door wraps the SAME `readMirrorHistory` helper the
3352
+ * REST `/history` endpoint uses (no drift between the two surfaces).
3353
+ *
3354
+ * `--note <path>` scopes to a single note's history (`git log --follow --
3355
+ * <path>.md`). `--show <sha>` (with `--note`) prints that note's content at
3356
+ * a past revision (`git show <sha>:<path>.md`).
3357
+ */
3358
+ async function cmdHistory(args: string[]) {
3359
+ let vaultName = "default";
3360
+ let notePath: string | undefined;
3361
+ let limit: number | undefined;
3362
+ let showSha: string | undefined;
3363
+ let asJson = false;
3364
+
3365
+ for (let i = 0; i < args.length; i++) {
3366
+ const arg = args[i]!;
3367
+ if (arg === "--vault") {
3368
+ const v = args[++i];
3369
+ if (!v) {
3370
+ console.error("--vault requires a value.");
3371
+ process.exit(1);
3372
+ }
3373
+ vaultName = v;
3374
+ } else if (arg === "--note") {
3375
+ const v = args[++i];
3376
+ if (!v) {
3377
+ console.error("--note requires a note path.");
3378
+ process.exit(1);
3379
+ }
3380
+ notePath = v;
3381
+ } else if (arg === "--limit") {
3382
+ const v = args[++i];
3383
+ if (!v) {
3384
+ console.error("--limit requires a number.");
3385
+ process.exit(1);
3386
+ }
3387
+ const n = Number(v);
3388
+ if (!Number.isInteger(n) || n <= 0) {
3389
+ console.error(`--limit: must be a positive integer (got '${v}')`);
3390
+ process.exit(1);
3391
+ }
3392
+ limit = n;
3393
+ } else if (arg === "--show") {
3394
+ const v = args[++i];
3395
+ if (!v) {
3396
+ console.error("--show requires a commit sha.");
3397
+ process.exit(1);
3398
+ }
3399
+ showSha = v;
3400
+ } else if (arg === "--json") {
3401
+ asJson = true;
3402
+ } else if (arg === "--help" || arg === "-h") {
3403
+ printHistoryUsage();
3404
+ return;
3405
+ } else {
3406
+ console.error(`Unknown argument: ${arg}`);
3407
+ printHistoryUsage();
3408
+ process.exit(1);
3409
+ }
3410
+ }
3411
+
3412
+ if (showSha && !notePath) {
3413
+ console.error("--show <sha> requires --note <path> (which file to read at that revision).");
3414
+ process.exit(1);
3415
+ }
3416
+
3417
+ const config = readVaultConfig(vaultName);
3418
+ if (!config) {
3419
+ console.error(`Vault "${vaultName}" not found. Available: ${listVaults().join(", ") || "(none)"}.`);
3420
+ process.exit(1);
3421
+ }
3422
+
3423
+ // Resolve the mirror dir the same way the server does: per-vault mirror
3424
+ // config (or defaults) → resolveMirrorPath against the vault's data dir.
3425
+ const mirrorConfig = readMirrorConfigForVault(vaultName) ?? defaultMirrorConfig();
3426
+ const mirrorPath = resolveMirrorPath(vaultDir(vaultName), mirrorConfig);
3427
+ if (!mirrorPath || !existsSync(mirrorPath)) {
3428
+ console.error(
3429
+ `No git history for vault "${vaultName}" — the mirror isn't initialized yet.\n` +
3430
+ `Enable history (backup) so writes are recorded, then re-run.`,
3431
+ );
3432
+ process.exit(1);
3433
+ }
3434
+
3435
+ // Preflight git — friendly, actionable message instead of a raw spawn throw.
3436
+ try {
3437
+ ensureGitAvailable();
3438
+ } catch (err) {
3439
+ if (err instanceof GitNotInstalledError) {
3440
+ console.error(err.message);
3441
+ process.exit(1);
3442
+ }
3443
+ throw err;
3444
+ }
3445
+
3446
+ // `--show` path: print one note's content at a past revision.
3447
+ if (showSha && notePath) {
3448
+ const content = await showMirrorRevision(mirrorPath, showSha, notePath);
3449
+ if (content === null) {
3450
+ console.error(
3451
+ `No content for "${notePath}" at ${showSha} — the sha may be unknown, the note may not have existed at that commit, or the path is invalid.`,
3452
+ );
3453
+ process.exit(1);
3454
+ }
3455
+ process.stdout.write(content);
3456
+ return;
3457
+ }
3458
+
3459
+ const history = await readMirrorHistory(mirrorPath, { notePath, limit });
3460
+
3461
+ if (asJson) {
3462
+ console.log(JSON.stringify(history, null, 2));
3463
+ return;
3464
+ }
3465
+
3466
+ if (history.length === 0) {
3467
+ if (notePath) {
3468
+ console.log(`No history for note "${notePath}" in vault "${vaultName}".`);
3469
+ } else {
3470
+ console.log(`No history yet for vault "${vaultName}".`);
3471
+ }
3472
+ return;
3473
+ }
3474
+
3475
+ const scope = notePath ? ` for "${notePath}"` : "";
3476
+ console.log(`Git history for vault "${vaultName}"${scope} (${history.length} commit${history.length === 1 ? "" : "s"}):\n`);
3477
+ for (const entry of history) {
3478
+ // Short sha + date + subject — one line per commit, the `git log --oneline`
3479
+ // shape an operator expects.
3480
+ console.log(` ${entry.sha.slice(0, 8)} ${entry.date} ${entry.message}`);
3481
+ }
3482
+ }
3483
+
3484
+ function printHistoryUsage(): void {
3485
+ console.error(
3486
+ "Usage: parachute-vault history [--note <path>] [--limit N] [--vault <name>] [--json]\n" +
3487
+ " [--note <path> --show <sha>]",
3488
+ );
3489
+ console.error("\nSurface the vault's git write history. The vault is already git-backed via the");
3490
+ console.error("mirror (one file per note), so `git log` IS a tamper-evident, diffable history.");
3491
+ console.error("\nOptions:");
3492
+ console.error(" --vault <name> Vault to read (default: 'default')");
3493
+ console.error(" --note <path> Scope to a single note's history (git log --follow)");
3494
+ console.error(" --limit N Cap the number of commits returned (default 100)");
3495
+ console.error(" --show <sha> With --note: print that note's content at the given revision");
3496
+ console.error(" --json Emit the history as JSON instead of the human-readable list");
3497
+ }
3498
+
3333
3499
  // ---------------------------------------------------------------------------
3334
3500
  // Schema maintenance — `parachute-vault schema <subcommand>`
3335
3501
  // ---------------------------------------------------------------------------
@@ -3345,11 +3511,17 @@ async function cmdExport(args: string[]) {
3345
3511
  */
3346
3512
  async function cmdSchema(args: string[] = []) {
3347
3513
  const sub = args[0];
3514
+ if (sub === "migrate-field") {
3515
+ await cmdSchemaMigrateField(args.slice(1));
3516
+ return;
3517
+ }
3348
3518
  if (sub !== "prune") {
3349
- console.error("Usage: parachute-vault schema prune [--vault <name>] [--dry-run|--apply|--yes]");
3519
+ console.error("Usage: parachute-vault schema <prune|migrate-field> ...");
3350
3520
  console.error("\nSubcommands:");
3351
- console.error(" prune Drop orphaned indexed-field columns whose declaring tags are gone.");
3352
- console.error(" Dry-run by default (--dry-run is an explicit alias); pass --apply (or --yes) to execute.");
3521
+ console.error(" prune Drop orphaned indexed-field columns whose declaring tags are gone.");
3522
+ console.error(" Dry-run by default (--dry-run is an explicit alias); pass --apply (or --yes) to execute.");
3523
+ console.error(" migrate-field Evolve a tag-schema field across existing notes (rename/remap/set-default/retype).");
3524
+ console.error(" Dry-run by default; pass --apply (or --yes) to write. See `schema migrate-field --help`.");
3353
3525
  process.exit(1);
3354
3526
  }
3355
3527
 
@@ -3416,6 +3588,154 @@ async function cmdSchema(args: string[] = []) {
3416
3588
  }
3417
3589
  }
3418
3590
 
3591
+ /**
3592
+ * `parachute-vault schema migrate-field <tag> <field> --transform <spec>` —
3593
+ * evolve a tag-schema field's type / values across the existing notes that
3594
+ * carry the tag (vault#314). Dry-run by default — prints the count + a sample
3595
+ * of what WOULD change and writes nothing; `--apply` (alias `--yes`) executes.
3596
+ *
3597
+ * Transform specs (the built-in set — issue #314):
3598
+ * rename:<newkey> move the field's value to <newkey>, drop the old key
3599
+ * remap:<old>=<new>[,…] rewrite enum-style values (repeatable / comma-sep)
3600
+ * set-default:<value> fill the field where missing/null
3601
+ * set-default-invalid:<v> set <v> where missing/null OR currently schema-invalid
3602
+ * to_string | to_int | to_number | to_boolean retype the value
3603
+ *
3604
+ * The migration writes through `strict` enforcement (the migrate-bypass path,
3605
+ * vault#299) so a freshly-declared strict field that the back-catalog violates
3606
+ * doesn't block the rewrite, and every bypassed write is logged. Rewrites are
3607
+ * attributed `migrate` (last_updated_*); created_* is preserved.
3608
+ *
3609
+ * Atomic by default: if any note's value can't be converted (e.g. `to_int` on
3610
+ * "banana"), the apply aborts BEFORE writing anything and lists the offenders.
3611
+ * Pass `--continue-on-error` for best-effort (write the convertible, skip the rest).
3612
+ */
3613
+ async function cmdSchemaMigrateField(args: string[]) {
3614
+ const usageLine =
3615
+ "Usage: parachute-vault schema migrate-field <tag> <field> --transform <spec> [--vault <name>] [--apply|--yes] [--continue-on-error]";
3616
+ if (args[0] === "--help" || args[0] === "-h") {
3617
+ console.log(usageLine);
3618
+ console.log("\nTransform specs:");
3619
+ console.log(" rename:<newkey> Move the field's value to <newkey>, drop the old key.");
3620
+ console.log(" remap:<old>=<new>[,...] Rewrite enum-style values (repeatable, comma-separated).");
3621
+ console.log(" set-default:<value> Set <value> on notes where the field is missing/null.");
3622
+ console.log(" set-default-invalid:<v> Set <v> where missing/null OR the current value is schema-invalid.");
3623
+ console.log(" to_string | to_int | to_number | to_boolean Retype the field's value.");
3624
+ console.log("\nDry-run by default — prints the plan. Pass --apply (or --yes) to write.");
3625
+ return;
3626
+ }
3627
+
3628
+ let vaultName = "default";
3629
+ let apply = false;
3630
+ let continueOnError = false;
3631
+ let transformSpec: string | undefined;
3632
+ const positionals: string[] = [];
3633
+ for (let i = 0; i < args.length; i++) {
3634
+ const arg = args[i]!;
3635
+ if (arg === "--vault") {
3636
+ const v = args[++i];
3637
+ if (!v) { console.error("--vault requires a value."); process.exit(1); }
3638
+ vaultName = v;
3639
+ } else if (arg === "--transform") {
3640
+ const v = args[++i];
3641
+ if (!v) { console.error("--transform requires a value."); process.exit(1); }
3642
+ transformSpec = v;
3643
+ } else if (arg === "--apply" || arg === "--yes") {
3644
+ apply = true;
3645
+ } else if (arg === "--dry-run") {
3646
+ // default; accept as an explicit affirmative
3647
+ } else if (arg === "--continue-on-error") {
3648
+ continueOnError = true;
3649
+ } else if (arg.startsWith("--")) {
3650
+ console.error(`Unknown flag for \`schema migrate-field\`: ${arg}`);
3651
+ process.exit(1);
3652
+ } else {
3653
+ positionals.push(arg);
3654
+ }
3655
+ }
3656
+
3657
+ const [tag, field] = positionals;
3658
+ if (!tag || !field || !transformSpec) {
3659
+ console.error(usageLine);
3660
+ console.error("\n <tag> <field> and --transform <spec> are all required.");
3661
+ console.error(" Run `schema migrate-field --help` for the transform spec list.");
3662
+ process.exit(1);
3663
+ }
3664
+
3665
+ const { parseTransformSpec } = await import("../core/src/migrate-tag-field.ts");
3666
+ let transform;
3667
+ try {
3668
+ transform = parseTransformSpec(transformSpec);
3669
+ } catch (err) {
3670
+ console.error(`Invalid --transform "${transformSpec}": ${(err as Error).message}`);
3671
+ process.exit(1);
3672
+ }
3673
+
3674
+ const config = readVaultConfig(vaultName);
3675
+ if (!config) {
3676
+ console.error(`Vault "${vaultName}" not found.`);
3677
+ process.exit(1);
3678
+ }
3679
+
3680
+ const { getVaultStore } = await import("./vault-store.ts");
3681
+ const { migrateTagField } = await import("../core/src/migrate-tag-field.ts");
3682
+ const { logStrictBypass } = await import("./scopes.ts");
3683
+ const store = getVaultStore(vaultName);
3684
+
3685
+ const result = await migrateTagField(store, {
3686
+ tag,
3687
+ field,
3688
+ transform: transform!,
3689
+ apply,
3690
+ continueOnError,
3691
+ // The CLI is the operator path — it bypasses strict enforcement by nature
3692
+ // (writes go through the store, not the scope-gated transport). Log every
3693
+ // waived violation so the bypass is auditable, exactly as MW2 intends.
3694
+ onStrictBypass: (info) => logStrictBypass(info),
3695
+ });
3696
+
3697
+ const verb = result.applied ? "Migrated" : "Would migrate";
3698
+ console.log(
3699
+ `${verb} field "${result.field}" on #${result.tag} in vault "${vaultName}"${result.applied ? "" : " (dry-run)"}:`,
3700
+ );
3701
+ console.log(
3702
+ ` scanned ${result.scanned} · ${result.applied ? "changed" : "would change"} ${result.changed} · unchanged ${result.unchanged} · errored ${result.errored}`,
3703
+ );
3704
+
3705
+ if (result.sample.length > 0) {
3706
+ console.log(`\n ${result.applied ? "Changes" : "Sample of changes"} (${result.sample.length}${result.changed > result.sample.length ? ` of ${result.changed}` : ""}):`);
3707
+ for (const c of result.sample) {
3708
+ const ref = c.path ?? c.id;
3709
+ if (c.renamedTo) {
3710
+ console.log(` ${ref}: ${field} → ${c.renamedTo} = ${JSON.stringify(c.after)}`);
3711
+ } else {
3712
+ console.log(` ${ref}: ${JSON.stringify(c.before)} → ${JSON.stringify(c.after)}`);
3713
+ }
3714
+ }
3715
+ }
3716
+
3717
+ if (result.errors.length > 0) {
3718
+ console.log(`\n Unconvertible (${result.errors.length}):`);
3719
+ for (const e of result.errors) {
3720
+ console.log(` ${e.path ?? e.id}: ${e.error}`);
3721
+ }
3722
+ if (apply && !result.applied) {
3723
+ console.log(
3724
+ "\n Aborted without writing — some notes can't be converted (atomic by default).",
3725
+ );
3726
+ console.log(" Fix the data, or re-run with --continue-on-error to migrate the rest.");
3727
+ process.exit(1);
3728
+ }
3729
+ }
3730
+
3731
+ if (!apply && result.changed > 0) {
3732
+ console.log("\n Re-run with --apply (or --yes) to write these changes.");
3733
+ }
3734
+ if (result.changed === 0 && result.errored === 0) {
3735
+ console.log("\n Nothing to migrate — the back-catalog already conforms.");
3736
+ }
3737
+ }
3738
+
3419
3739
  // ---------------------------------------------------------------------------
3420
3740
  // Export-watch glue. The git-shell + commit-message logic lives in
3421
3741
  // `./export-watch.ts` for unit-testability; cli.ts just wires it in.
@@ -3952,6 +4272,15 @@ Import/Export:
3952
4272
  template via --git-message-template;
3953
4273
  --git-push to push after commit)
3954
4274
 
4275
+ History:
4276
+ parachute-vault history [--vault <name>] Show the vault's git write history (the
4277
+ mirror is git-backed — one file per note,
4278
+ so git log IS a tamper-evident history)
4279
+ parachute-vault history --note <path> Scope to one note's history (git log --follow)
4280
+ parachute-vault history --limit N Cap the number of commits (default 100)
4281
+ parachute-vault history --note <path> --show <sha> Print that note's content at a past revision
4282
+ parachute-vault history --json Emit history as JSON
4283
+
3955
4284
  Schema maintenance:
3956
4285
  parachute-vault schema prune [--vault <name>] Drop orphaned indexed-field columns +
3957
4286
  indexes whose declaring tags no longer
@@ -3961,6 +4290,13 @@ Schema maintenance:
3961
4290
  parachute-vault schema prune --apply Execute the prune (alias: --yes). Co-declared
3962
4291
  fields keep their column; a drop loses only
3963
4292
  the index (data lives in notes.metadata).
4293
+ parachute-vault schema migrate-field <tag> <field> Evolve a tag-schema field across existing
4294
+ --transform <spec> notes (rename:<key>, remap:<old>=<new>,
4295
+ set-default:<v>, to_string/to_int/to_number/
4296
+ to_boolean). Dry-run by default — prints the
4297
+ plan; pass --apply (or --yes) to write.
4298
+ Writes through strict enforcement (migrate
4299
+ bypass). See \`schema migrate-field --help\`.
3964
4300
 
3965
4301
  ── Advanced / standalone ──────────────────────────────────────────────
3966
4302
 
package/src/config.ts CHANGED
@@ -215,6 +215,17 @@ export interface TriggerWhen {
215
215
  missing_metadata?: string[];
216
216
  /** Note.metadata must have ALL of these keys set (non-null). */
217
217
  has_metadata?: string[];
218
+ /**
219
+ * Value-matched metadata predicate (vault#299 Part B). A map of
220
+ * field → operator-object, evaluated against the note's live metadata with
221
+ * the SAME operators as `query-notes` (eq/ne/gt/gte/lt/lte/in/not_in/exists)
222
+ * via the shared `matchesOperator` engine. ALL entries must match (AND).
223
+ * Lets a trigger fire only on a specific transition — e.g.
224
+ * `metadata: { state: { eq: "published" } }` fires when a note reaches
225
+ * `published`, not on every edit. Combines with the presence/tag/content
226
+ * filters above (all must hold).
227
+ */
228
+ metadata?: Record<string, Record<string, unknown>>;
218
229
  }
219
230
 
220
231
  /**
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Integration tests for the first-boot vault creation gate introduced in
3
+ * vault#478 Part 2 — server creates a vault ONLY when PARACHUTE_VAULT_NAME
4
+ * is explicitly set.
5
+ *
6
+ * Each test spawns a real server subprocess against a fresh PARACHUTE_HOME
7
+ * tmpdir (the stop-signal.test.ts pattern) and probes over HTTP so the
8
+ * actual Bun.serve boot path is exercised, not a mocked code path.
9
+ *
10
+ * Port assignments (chosen to avoid collisions with the running daemon on
11
+ * 1940 and stop-signal.test.ts on 19404):
12
+ * 19410 — test 1: PARACHUTE_VAULT_NAME unset → zero vaults
13
+ * 19411 — test 2: PARACHUTE_VAULT_NAME=testvault → vault created
14
+ *
15
+ * NOTE: the unit-level assertions for resolveFirstBootVaultName (source:
16
+ * "default" / "env" / "env-invalid") already live in vault-name.test.ts.
17
+ * These tests cover the SERVER-LEVEL behaviour — whether a vault is actually
18
+ * created on disk — so they are intentionally non-duplicative.
19
+ */
20
+
21
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
22
+ import { mkdtempSync, rmSync, mkdirSync, existsSync } from "fs";
23
+ import { tmpdir } from "os";
24
+ import { join, resolve } from "path";
25
+ import { waitForHealthy } from "./health.ts";
26
+ import { listVaults } from "./config.ts";
27
+
28
+ const SERVER_PATH = resolve(import.meta.dir, "server.ts");
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Test 1 — PARACHUTE_VAULT_NAME unset
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const PORT_NO_ENV = 19_410;
35
+ let tmpHomeNoEnv: string;
36
+
37
+ beforeAll(() => {
38
+ tmpHomeNoEnv = mkdtempSync(join(tmpdir(), "vault-first-boot-no-env-"));
39
+ mkdirSync(join(tmpHomeNoEnv, "vault"), { recursive: true });
40
+ });
41
+
42
+ afterAll(() => {
43
+ rmSync(tmpHomeNoEnv, { recursive: true, force: true });
44
+ });
45
+
46
+ describe("first-boot: PARACHUTE_VAULT_NAME unset → zero vaults", () => {
47
+ test("server is healthy AND no vault was auto-created", async () => {
48
+ const proc = Bun.spawn({
49
+ cmd: ["bun", SERVER_PATH],
50
+ env: {
51
+ ...process.env,
52
+ PARACHUTE_HOME: tmpHomeNoEnv,
53
+ PORT: String(PORT_NO_ENV),
54
+ // Must be unset — do NOT inherit from the test runner's shell.
55
+ PARACHUTE_VAULT_NAME: undefined as unknown as string,
56
+ SCRIBE_URL: "",
57
+ },
58
+ stdout: "pipe",
59
+ stderr: "pipe",
60
+ });
61
+
62
+ try {
63
+ const health = await waitForHealthy(PORT_NO_ENV, { totalMs: 15_000 });
64
+ expect(health.status).toBe("healthy");
65
+
66
+ // Verify zero vaults were created inside the tmp home.
67
+ const originalHome = process.env.PARACHUTE_HOME;
68
+ try {
69
+ process.env.PARACHUTE_HOME = tmpHomeNoEnv;
70
+ const vaults = listVaults();
71
+ expect(vaults).toHaveLength(0);
72
+ expect(vaults).not.toContain("default");
73
+ } finally {
74
+ if (originalHome === undefined) delete process.env.PARACHUTE_HOME;
75
+ else process.env.PARACHUTE_HOME = originalHome;
76
+ }
77
+
78
+ // Also assert no vault.yaml was written (belt-and-suspenders).
79
+ // Path structure: <PARACHUTE_HOME>/vault/data/<name>/vault.yaml
80
+ const defaultVaultConfig = join(tmpHomeNoEnv, "vault", "data", "default", "vault.yaml");
81
+ expect(existsSync(defaultVaultConfig)).toBe(false);
82
+ } finally {
83
+ if (!proc.killed) proc.kill();
84
+ await proc.exited;
85
+ }
86
+ }, 25_000);
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Test 2 — PARACHUTE_VAULT_NAME=testvault → vault created
91
+ // ---------------------------------------------------------------------------
92
+
93
+ const PORT_WITH_ENV = 19_411;
94
+ let tmpHomeWithEnv: string;
95
+
96
+ // We need separate beforeAll/afterAll scopes for the two server lifetimes.
97
+ // Bun:test describe blocks share the outer beforeAll/afterAll, so we use
98
+ // module-level variables and a nested describe with its own lifecycle hooks.
99
+ describe("first-boot: PARACHUTE_VAULT_NAME=testvault → vault created", () => {
100
+ let proc: ReturnType<typeof Bun.spawn>;
101
+
102
+ beforeAll(async () => {
103
+ tmpHomeWithEnv = mkdtempSync(join(tmpdir(), "vault-first-boot-with-env-"));
104
+ mkdirSync(join(tmpHomeWithEnv, "vault"), { recursive: true });
105
+
106
+ proc = Bun.spawn({
107
+ cmd: ["bun", SERVER_PATH],
108
+ env: {
109
+ ...process.env,
110
+ PARACHUTE_HOME: tmpHomeWithEnv,
111
+ PORT: String(PORT_WITH_ENV),
112
+ PARACHUTE_VAULT_NAME: "testvault",
113
+ SCRIBE_URL: "",
114
+ },
115
+ stdout: "pipe",
116
+ stderr: "pipe",
117
+ });
118
+
119
+ const health = await waitForHealthy(PORT_WITH_ENV, { totalMs: 15_000 });
120
+ if (health.status !== "healthy") {
121
+ proc.kill();
122
+ throw new Error(`server failed to become healthy: ${health.status} (${health.error ?? ""})`);
123
+ }
124
+ });
125
+
126
+ afterAll(async () => {
127
+ if (proc && !proc.killed) proc.kill();
128
+ await proc?.exited;
129
+ rmSync(tmpHomeWithEnv, { recursive: true, force: true });
130
+ });
131
+
132
+ test('vault "testvault" was created (not "default")', () => {
133
+ const originalHome = process.env.PARACHUTE_HOME;
134
+ try {
135
+ process.env.PARACHUTE_HOME = tmpHomeWithEnv;
136
+ const vaults = listVaults();
137
+ expect(vaults).toContain("testvault");
138
+ expect(vaults).not.toContain("default");
139
+ expect(vaults).toHaveLength(1);
140
+ } finally {
141
+ if (originalHome === undefined) delete process.env.PARACHUTE_HOME;
142
+ else process.env.PARACHUTE_HOME = originalHome;
143
+ }
144
+ });
145
+
146
+ test("vault config dir exists at the correct path", () => {
147
+ // Path structure: <PARACHUTE_HOME>/vault/data/<name>/vault.yaml
148
+ const vaultConfigPath = join(tmpHomeWithEnv, "vault", "data", "testvault", "vault.yaml");
149
+ expect(existsSync(vaultConfigPath)).toBe(true);
150
+
151
+ // "default" must NOT have been created.
152
+ const defaultConfigPath = join(tmpHomeWithEnv, "vault", "data", "default", "vault.yaml");
153
+ expect(existsSync(defaultConfigPath)).toBe(false);
154
+ });
155
+ });
@@ -15,30 +15,21 @@ import { resolve } from "path";
15
15
  import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "fs";
16
16
  import { tmpdir } from "os";
17
17
  import { join } from "path";
18
+ import { runSubprocess } from "./test-support/spawn.ts";
18
19
 
19
20
  const CLI = resolve(import.meta.dir, "cli.ts");
20
21
 
22
+ // Async spawn — the daemon-busy probe inside the CLI fetches the test
23
+ // process's stub server, so the parent event loop must keep servicing
24
+ // requests while the child runs. `Bun.spawnSync` blocks the event loop and
25
+ // the in-test server can't answer, which makes every probe time out into
26
+ // the "not listening" branch (vault#324). `runSubprocess` is the shared
27
+ // safe primitive (vault#325 Part 1) — see src/test-support/spawn.ts.
21
28
  async function runCli(
22
29
  args: string[],
23
30
  env: Record<string, string>,
24
31
  ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
25
- // Async spawn the daemon-busy probe inside the CLI fetches the test
26
- // process's stub server, so the parent event loop must keep servicing
27
- // requests while the child runs. Bun.spawnSync blocks the event loop
28
- // and the in-test server can't answer, which makes every probe time
29
- // out into the "not listening" branch.
30
- const proc = Bun.spawn({
31
- cmd: ["bun", CLI, ...args],
32
- stdout: "pipe",
33
- stderr: "pipe",
34
- env: { ...process.env, ...env },
35
- });
36
- const [stdout, stderr, exitCode] = await Promise.all([
37
- new Response(proc.stdout).text(),
38
- new Response(proc.stderr).text(),
39
- proc.exited,
40
- ]);
41
- return { exitCode: exitCode ?? -1, stdout, stderr };
32
+ return runSubprocess({ cmd: ["bun", CLI, ...args], env });
42
33
  }
43
34
 
44
35
  let home: string;
package/src/mcp-http.ts CHANGED
@@ -151,6 +151,11 @@ async function handleMcp(
151
151
  note_path?: string | null;
152
152
  current_updated_at?: string | null;
153
153
  expected_updated_at?: string;
154
+ field?: string;
155
+ expected_from?: unknown;
156
+ to?: unknown;
157
+ current?: unknown;
158
+ violations?: unknown;
154
159
  };
155
160
  if (e?.code === "CONFLICT") {
156
161
  throw new McpError(ErrorCode.InvalidRequest, message, {
@@ -161,6 +166,28 @@ async function handleMcp(
161
166
  note_id: e.note_id,
162
167
  });
163
168
  }
169
+ // State-transition compare-and-set conflict (vault#299 Part B) — a
170
+ // DISTINCT vocabulary from `conflict` (settled lead #3): the value
171
+ // didn't match, not the updated_at token.
172
+ if (e?.code === "TRANSITION_CONFLICT") {
173
+ throw new McpError(ErrorCode.InvalidRequest, message, {
174
+ error_type: "transition_conflict",
175
+ note_id: e.note_id,
176
+ path: e.note_path ?? null,
177
+ field: e.field,
178
+ expected_from: e.expected_from,
179
+ to: e.to,
180
+ current: e.current ?? null,
181
+ });
182
+ }
183
+ // Strict-schema rejection (vault#299 Part A) — one error carrying ALL
184
+ // per-field violations (settled lead #1).
185
+ if (e?.code === "SCHEMA_VALIDATION") {
186
+ throw new McpError(ErrorCode.InvalidParams, message, {
187
+ error_type: "schema_validation",
188
+ violations: e.violations ?? [],
189
+ });
190
+ }
164
191
  if (e?.code === "PRECONDITION_REQUIRED") {
165
192
  throw new McpError(ErrorCode.InvalidParams, message, {
166
193
  error_type: "precondition_required",