@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.
- package/README.md +46 -11
- package/core/src/attribution.test.ts +273 -0
- package/core/src/core.test.ts +126 -0
- package/core/src/cursor.ts +8 -0
- package/core/src/enforced-writes.test.ts +533 -0
- package/core/src/mcp.ts +235 -9
- package/core/src/migrate-tag-field.test.ts +471 -0
- package/core/src/migrate-tag-field.ts +638 -0
- package/core/src/notes.ts +280 -7
- package/core/src/query-operators.ts +117 -0
- package/core/src/schema-defaults.ts +162 -9
- package/core/src/schema.ts +61 -2
- package/core/src/store.ts +51 -19
- package/core/src/tag-schemas.ts +12 -0
- package/core/src/triggers-store.ts +6 -0
- package/core/src/types.ts +35 -14
- package/core/src/vault-projection.ts +19 -0
- package/package.json +1 -1
- package/src/admin-spa.test.ts +18 -5
- package/src/admin-spa.ts +24 -3
- package/src/attribution-threading.test.ts +350 -0
- package/src/auth.ts +82 -4
- package/src/cli.ts +345 -9
- package/src/config.ts +11 -0
- package/src/first-boot-create.test.ts +155 -0
- package/src/import-daemon-busy.test.ts +8 -17
- package/src/mcp-http.ts +27 -0
- package/src/mcp-tools.ts +31 -4
- package/src/mirror-credentials.test.ts +47 -0
- package/src/mirror-credentials.ts +33 -0
- package/src/mirror-history.test.ts +426 -0
- package/src/mirror-manager.ts +202 -22
- package/src/mirror-routes.test.ts +78 -0
- package/src/mirror-routes.ts +195 -1
- package/src/module-config.ts +46 -80
- package/src/routes.ts +209 -41
- package/src/routing.test.ts +115 -25
- package/src/routing.ts +64 -2
- package/src/scale.bench.test.ts +82 -0
- package/src/scopes.test.ts +24 -0
- package/src/scopes.ts +63 -0
- package/src/self-register.test.ts +5 -5
- package/src/self-register.ts +8 -3
- package/src/server.ts +58 -38
- package/src/subscribe.test.ts +23 -2
- package/src/test-support/spawn.ts +85 -0
- package/src/triggers-api.ts +24 -0
- package/src/triggers.test.ts +33 -0
- package/src/triggers.ts +17 -0
- package/src/vault-remove.test.ts +4 -5
- 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 {
|
|
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
|
|
1669
|
-
//
|
|
1670
|
-
//
|
|
1671
|
-
//
|
|
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
|
|
3519
|
+
console.error("Usage: parachute-vault schema <prune|migrate-field> ...");
|
|
3350
3520
|
console.error("\nSubcommands:");
|
|
3351
|
-
console.error(" prune
|
|
3352
|
-
console.error("
|
|
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
|
-
|
|
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",
|