@openparachute/vault 0.4.3 → 0.4.4-rc.11
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 +58 -2
- package/core/src/core.test.ts +116 -0
- package/core/src/mcp.ts +94 -4
- package/core/src/obsidian.ts +55 -177
- package/core/src/portable-md.test.ts +1001 -0
- package/core/src/portable-md.ts +1409 -0
- package/core/src/schema-defaults.ts +19 -1
- package/core/src/store.ts +13 -0
- package/core/src/types.ts +15 -0
- package/package.json +1 -1
- package/src/cli.ts +699 -141
- package/src/doctor.test.ts +7 -6
- package/src/mcp-install-interactive.test.ts +883 -0
- package/src/mcp-install-interactive.ts +412 -0
- package/src/mcp-install.test.ts +957 -5
- package/src/mcp-install.ts +580 -13
- package/src/routes.ts +19 -3
- package/src/vault.test.ts +141 -0
package/README.md
CHANGED
|
@@ -139,7 +139,7 @@ Password and 2FA secrets live in `~/.parachute/vault/config.yaml` at mode 0600 (
|
|
|
139
139
|
|
|
140
140
|
Where `{name}` is `default` on a fresh install, or whatever vault you pointed `vault init` at. **First MCP call after `vault init` requires no browser handoff — Claude Code uses the baked-in token and the vault's tools show up in your next session.** This is intentional: for an owner connecting their own machine's vault to their own Claude Code, the token is already there and OAuth would add friction.
|
|
141
141
|
|
|
142
|
-
To re-point Claude Code at a different vault, change `default_vault` in `~/.parachute/vault/config.yaml` and re-run `parachute-vault init` — which re-mints an API token and re-writes the `~/.claude.json` entry end-to-end. To rotate the token only,
|
|
142
|
+
To re-point Claude Code at a different vault, change `default_vault` in `~/.parachute/vault/config.yaml` and re-run `parachute-vault init` — which re-mints an API token and re-writes the `~/.claude.json` entry end-to-end. To rotate the token only, run `parachute-vault mcp-install` (defaults to `--mint`, which mints a fresh scope-narrow hub JWT via `~/.parachute/operator.token` and writes it into `~/.claude.json` with an `Authorization: Bearer …` header). See the [cookbook](#install-vault-mcp-into-a-client-config) section below for the full flag surface — token paste, scope narrowing, project-level install, multi-vault.
|
|
143
143
|
|
|
144
144
|
### Claude Desktop (OAuth)
|
|
145
145
|
|
|
@@ -190,7 +190,13 @@ parachute-vault uninstall --yes --wipe # scripted destructive wipe (prints a
|
|
|
190
190
|
parachute-vault create work # create a new vault
|
|
191
191
|
parachute-vault list # list all vaults (alias: `ls`)
|
|
192
192
|
parachute-vault remove work --yes # delete a vault (alias: `rm`)
|
|
193
|
-
parachute-vault mcp-install # (re)write the
|
|
193
|
+
parachute-vault mcp-install # (re)write the MCP client entry; defaults to --mint (hub-issued JWT) at local scope
|
|
194
|
+
parachute-vault mcp-install --token <t> # paste an existing bearer instead of minting
|
|
195
|
+
parachute-vault mcp-install --legacy-pat # mint a vault-DB pvt_* (self-hosted-without-hub)
|
|
196
|
+
parachute-vault mcp-install --install-scope user # write ~/.claude.json top-level (every project)
|
|
197
|
+
parachute-vault mcp-install --install-scope local # write ~/.claude.json projects[<cwd>] (this directory only — default)
|
|
198
|
+
parachute-vault mcp-install --install-scope project # write ./.mcp.json (checked into the repo)
|
|
199
|
+
parachute-vault mcp-install --vault work # target the "work" vault (keyed as parachute-vault-work)
|
|
194
200
|
|
|
195
201
|
# OAuth — owner password + 2FA
|
|
196
202
|
parachute-vault set-password # set/change the owner password (OAuth consent page)
|
|
@@ -565,6 +571,56 @@ Atomic at the SQL layer: two concurrent appends both land in some order, never c
|
|
|
565
571
|
|
|
566
572
|
The shortest path to a public HTTPS URL for a vault you control — useful for SSG rebuilds running on GitHub Actions, Vercel, or any runner that isn't on your tailnet. See [Remote access via Tailscale Funnel](#remote-access-via-tailscale-funnel) below for the full setup.
|
|
567
573
|
|
|
574
|
+
### Install vault MCP into a client config
|
|
575
|
+
|
|
576
|
+
Bare `parachute-vault mcp-install` from a terminal **walks you through a short contextual conversation** — picks defaults informed by your environment (how many vaults you have, whether the hub is reachable, whether you're in a project directory, whether vault is already installed somewhere), shows the JSON shape it will write before doing anything, and asks before each non-obvious choice. The patterns below are the non-interactive shapes — pass any flag (`--mint`, `--token`, `--scope`, `--install-scope`, `--vault`, `--legacy-pat`) and the walkthrough is skipped.
|
|
577
|
+
|
|
578
|
+
#### Install scopes
|
|
579
|
+
|
|
580
|
+
`--install-scope` accepts three values, matching Claude Code's own `claude mcp add --scope`:
|
|
581
|
+
|
|
582
|
+
| Scope | Where the entry lives | Visibility |
|
|
583
|
+
|---|---|---|
|
|
584
|
+
| `local` *(default)* | `~/.claude.json` under `projects[<absolute-cwd>].mcpServers` | private to your machine, scoped to this directory |
|
|
585
|
+
| `user` | `~/.claude.json` top-level `mcpServers` | every project, every directory on this machine |
|
|
586
|
+
| `project` | `<cwd>/.mcp.json` | checked into the repo, shared with anyone who clones it |
|
|
587
|
+
|
|
588
|
+
The default is **`local`** — Claude Code only loads the entry when launched from the directory you ran the install from. Pick `user` for a global install (every project sees the vault); pick `project` to commit the entry to the repo so collaborators get it on `git pull`.
|
|
589
|
+
|
|
590
|
+
#### Cookbook
|
|
591
|
+
|
|
592
|
+
```bash
|
|
593
|
+
# 1. Default — mint a scope-narrow hub JWT (vault:<vault>:read) via your
|
|
594
|
+
# operator token; write it into ~/.claude.json under projects[<cwd>]
|
|
595
|
+
# (local scope, this directory only). Requires:
|
|
596
|
+
# - ~/.parachute/operator.token (run `parachute auth rotate-operator` if missing)
|
|
597
|
+
# - PARACHUTE_HUB_ORIGIN set OR an active `parachute expose` session
|
|
598
|
+
parachute-vault mcp-install --mint
|
|
599
|
+
|
|
600
|
+
# 2. Global install — write the entry at top-level ~/.claude.json so
|
|
601
|
+
# Claude Code loads it from every project, every directory.
|
|
602
|
+
parachute-vault mcp-install --install-scope user
|
|
603
|
+
|
|
604
|
+
# 3. Project-level install — write ./.mcp.json (committed to the repo,
|
|
605
|
+
# shared with the team). Pair with --scope vault:write when the
|
|
606
|
+
# project actually mutates the vault.
|
|
607
|
+
parachute-vault mcp-install --install-scope project --scope vault:write
|
|
608
|
+
|
|
609
|
+
# 4. Paste an existing token — useful when you already have a pvt_* in hand
|
|
610
|
+
# or want to re-use a long-lived bearer from another machine. Skips the
|
|
611
|
+
# mint step entirely.
|
|
612
|
+
parachute-vault mcp-install --token pvt_abc123...
|
|
613
|
+
|
|
614
|
+
# 5. Self-hosted-without-hub — mint a vault-DB pvt_* token (the legacy
|
|
615
|
+
# path; preserved so deployments without a hub keep working). Prints a
|
|
616
|
+
# deprecation notice.
|
|
617
|
+
parachute-vault mcp-install --legacy-pat
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
**Multi-vault.** `--vault <name>` targets a specific vault and writes the entry under `parachute-vault-<name>` so multiple vaults coexist. Without `--vault`, the singular `parachute-vault` slot is used and one install clobbers another — that's intentional for the common single-vault case.
|
|
621
|
+
|
|
622
|
+
**Doctor.** `parachute-vault doctor` checks `~/.claude.json` (both top-level and `projects[<cwd>]`) and `./.mcp.json`, and reports which one holds the entry, plus port-match and reachability of the MCP URL.
|
|
623
|
+
|
|
568
624
|
## Data model
|
|
569
625
|
|
|
570
626
|
```
|
package/core/src/core.test.ts
CHANGED
|
@@ -3678,6 +3678,122 @@ describe("schema validation (tags.fields)", async () => {
|
|
|
3678
3678
|
const result = await create.execute({ content: "x", tags: ["task"] }) as any;
|
|
3679
3679
|
expect(result.validation_status).toBeUndefined();
|
|
3680
3680
|
});
|
|
3681
|
+
|
|
3682
|
+
// vault#310 — integer type validation. JSON has no separate integer
|
|
3683
|
+
// type, so a JSON number with zero fractional part (`5`, `5.0`) must
|
|
3684
|
+
// pass an `integer`-typed field. Pre-fix, the validator had no
|
|
3685
|
+
// `"integer"` case at all — falling through the switch returned
|
|
3686
|
+
// undefined and every integer-typed field warned `type_mismatch` on
|
|
3687
|
+
// legitimate values. Gitcoin Brain's drift detector emits JSON for
|
|
3688
|
+
// diffs; every `kpi: 3` triggered the false-positive and buried the
|
|
3689
|
+
// real warnings.
|
|
3690
|
+
|
|
3691
|
+
it("integer-typed field: JSON integer (5) passes (vault#310)", async () => {
|
|
3692
|
+
await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
|
|
3693
|
+
const tools = generateMcpTools(store);
|
|
3694
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3695
|
+
const result = await create.execute({
|
|
3696
|
+
content: "x",
|
|
3697
|
+
tags: ["kpi"],
|
|
3698
|
+
metadata: { count: 5 },
|
|
3699
|
+
}) as any;
|
|
3700
|
+
expect(result.validation_status?.warnings ?? []).toEqual([]);
|
|
3701
|
+
});
|
|
3702
|
+
|
|
3703
|
+
it("integer-typed field: JSON `5.0` (zero-fractional) passes (vault#310)", async () => {
|
|
3704
|
+
// 5.0 is the canonical Gitcoin shape — JSON.parse decodes the
|
|
3705
|
+
// emitted JSON number as a JS Number; the JS Number for `5.0` is
|
|
3706
|
+
// identical to `5` so Number.isInteger reports true. This is the
|
|
3707
|
+
// load-bearing assertion for the Gitcoin drift-detector use case.
|
|
3708
|
+
await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
|
|
3709
|
+
const tools = generateMcpTools(store);
|
|
3710
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3711
|
+
const result = await create.execute({
|
|
3712
|
+
content: "x",
|
|
3713
|
+
tags: ["kpi"],
|
|
3714
|
+
metadata: { count: 5.0 },
|
|
3715
|
+
}) as any;
|
|
3716
|
+
expect(result.validation_status?.warnings ?? []).toEqual([]);
|
|
3717
|
+
});
|
|
3718
|
+
|
|
3719
|
+
it("integer-typed field: JSON `5.5` (non-zero fractional) warns type_mismatch (vault#310)", async () => {
|
|
3720
|
+
await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
|
|
3721
|
+
const tools = generateMcpTools(store);
|
|
3722
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3723
|
+
const result = await create.execute({
|
|
3724
|
+
content: "x",
|
|
3725
|
+
tags: ["kpi"],
|
|
3726
|
+
metadata: { count: 5.5 },
|
|
3727
|
+
}) as any;
|
|
3728
|
+
expect(result.validation_status.warnings.length).toBe(1);
|
|
3729
|
+
expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
|
|
3730
|
+
expect(result.validation_status.warnings[0].field).toBe("count");
|
|
3731
|
+
});
|
|
3732
|
+
|
|
3733
|
+
it("integer-typed field: string `\"5\"` warns type_mismatch (no string→number coercion) (vault#310)", async () => {
|
|
3734
|
+
await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
|
|
3735
|
+
const tools = generateMcpTools(store);
|
|
3736
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3737
|
+
const result = await create.execute({
|
|
3738
|
+
content: "x",
|
|
3739
|
+
tags: ["kpi"],
|
|
3740
|
+
metadata: { count: "5" },
|
|
3741
|
+
}) as any;
|
|
3742
|
+
expect(result.validation_status.warnings.length).toBe(1);
|
|
3743
|
+
expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
|
|
3744
|
+
});
|
|
3745
|
+
|
|
3746
|
+
it("integer-typed field: edge `5.0000000000001` warns type_mismatch (vault#310)", async () => {
|
|
3747
|
+
await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
|
|
3748
|
+
const tools = generateMcpTools(store);
|
|
3749
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3750
|
+
const result = await create.execute({
|
|
3751
|
+
content: "x",
|
|
3752
|
+
tags: ["kpi"],
|
|
3753
|
+
metadata: { count: 5.0000000000001 },
|
|
3754
|
+
}) as any;
|
|
3755
|
+
expect(result.validation_status.warnings.length).toBe(1);
|
|
3756
|
+
expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
|
|
3757
|
+
});
|
|
3758
|
+
|
|
3759
|
+
it("integer-typed field: boolean warns type_mismatch (vault#310)", async () => {
|
|
3760
|
+
// Pin that boolean is rejected (Number.isInteger(true) returns
|
|
3761
|
+
// false, but extra coverage in case anyone "improves" the check
|
|
3762
|
+
// with a looser predicate later).
|
|
3763
|
+
await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
|
|
3764
|
+
const tools = generateMcpTools(store);
|
|
3765
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3766
|
+
const result = await create.execute({
|
|
3767
|
+
content: "x",
|
|
3768
|
+
tags: ["kpi"],
|
|
3769
|
+
metadata: { count: true },
|
|
3770
|
+
}) as any;
|
|
3771
|
+
expect(result.validation_status.warnings.length).toBe(1);
|
|
3772
|
+
expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
|
|
3773
|
+
});
|
|
3774
|
+
|
|
3775
|
+
// Note on NaN/Infinity: those values pass through
|
|
3776
|
+
// JSON.stringify as `null`, then the validator's null short-circuit
|
|
3777
|
+
// (schema-defaults.ts:327 — "value === null → skip") filters them out
|
|
3778
|
+
// before reaching the type check. We can't observe them in
|
|
3779
|
+
// validation_status from this layer; a dedicated unit test against
|
|
3780
|
+
// `valueMatchesType` would catch the case at the inner boundary.
|
|
3781
|
+
// Pinned at the next layer down:
|
|
3782
|
+
|
|
3783
|
+
it("valueMatchesType('integer', ...) rejects NaN / Infinity (vault#310)", async () => {
|
|
3784
|
+
// Reach the unexported helper indirectly via validateNote on a
|
|
3785
|
+
// hand-built resolved-schemas + metadata where the value is the
|
|
3786
|
+
// actual NaN/Infinity (no JSON round-trip).
|
|
3787
|
+
const { validateNote, loadSchemaConfig } = await import("./schema-defaults.js");
|
|
3788
|
+
// Seed via the public surface, then load the resolved schemas
|
|
3789
|
+
// snapshot.
|
|
3790
|
+
await store.upsertTagSchema("k", { fields: { c: { type: "integer" } } });
|
|
3791
|
+
const resolved = loadSchemaConfig((store as any).db);
|
|
3792
|
+
expect(validateNote(resolved, { tags: ["k"], metadata: { c: Number.NaN } })?.warnings[0]?.reason)
|
|
3793
|
+
.toBe("type_mismatch");
|
|
3794
|
+
expect(validateNote(resolved, { tags: ["k"], metadata: { c: Number.POSITIVE_INFINITY } })?.warnings[0]?.reason)
|
|
3795
|
+
.toBe("type_mismatch");
|
|
3796
|
+
});
|
|
3681
3797
|
});
|
|
3682
3798
|
|
|
3683
3799
|
// ---------------------------------------------------------------------------
|
package/core/src/mcp.ts
CHANGED
|
@@ -469,6 +469,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
469
469
|
- When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
|
|
470
470
|
- For batch: pass a \`notes\` array, each with an \`id\` field.
|
|
471
471
|
- **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design).
|
|
472
|
+
- **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
|
|
472
473
|
- \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present.`,
|
|
473
474
|
inputSchema: {
|
|
474
475
|
type: "object",
|
|
@@ -491,6 +492,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
491
492
|
created_at: { type: "string", description: "New created_at timestamp" },
|
|
492
493
|
if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since. Required unless `force: true` is set or the call is `append`/`prepend`-only." },
|
|
493
494
|
force: { type: "boolean", description: "Override the required `if_updated_at` check and run the update unconditionally. Use only for bulk migrations or scripted writes where concurrency is known-safe." },
|
|
495
|
+
if_missing: { type: "string", enum: ["fail", "create"], description: "What to do when the note (by `id`/path) doesn't exist. `\"fail\"` (default) — error, current behavior. `\"create\"` — create the note from this same payload (content/path/tags/metadata become the create fields; the response carries `created: true`). Skips the `if_updated_at` precondition on the create branch (nothing to conflict with). Idempotent for sync loops that don't know ahead of time whether the note exists. See vault#309." },
|
|
494
496
|
tags: {
|
|
495
497
|
type: "object",
|
|
496
498
|
properties: {
|
|
@@ -554,6 +556,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
554
556
|
created_at: { type: "string" },
|
|
555
557
|
if_updated_at: { type: "string", description: "Optimistic concurrency check for this item; rejects with a conflict error if the note has been modified since. Required unless `force: true` is set on this item or the item is `append`/`prepend`-only." },
|
|
556
558
|
force: { type: "boolean", description: "Override the required `if_updated_at` check for this item." },
|
|
559
|
+
if_missing: { type: "string", enum: ["fail", "create"], description: "Per-item: see top-level `if_missing` docs. Each batch item carries its own setting." },
|
|
557
560
|
tags: { type: "object" },
|
|
558
561
|
links: { type: "object" },
|
|
559
562
|
},
|
|
@@ -572,6 +575,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
572
575
|
}
|
|
573
576
|
|
|
574
577
|
const updated: Note[] = [];
|
|
578
|
+
// Track which note IDs were freshly created via `if_missing: "create"`
|
|
579
|
+
// so the response can carry `created: true|false` per-note. The
|
|
580
|
+
// sync-loop caller (Gitcoin Brain et al) reads this to know which
|
|
581
|
+
// path fired without doing a separate query. vault#309.
|
|
582
|
+
const createdIds = new Set<string>();
|
|
575
583
|
// Wrap multi-item batches in a SQLite transaction so any mid-batch
|
|
576
584
|
// failure (precondition error, content_edit miss, ConflictError, …)
|
|
577
585
|
// rolls back every prior mutation in the batch — see #236.
|
|
@@ -581,7 +589,77 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
581
589
|
if (batched) db.exec("BEGIN");
|
|
582
590
|
try {
|
|
583
591
|
for (const item of items) {
|
|
584
|
-
|
|
592
|
+
// Try ID-then-path resolve. If not found AND
|
|
593
|
+
// `if_missing: "create"` is set, fall through to the create
|
|
594
|
+
// branch using this same item's payload. Otherwise mirror the
|
|
595
|
+
// existing `requireNote` behavior (throw "Note not found").
|
|
596
|
+
// vault#309.
|
|
597
|
+
const resolved = resolveNote(db, item.id as string);
|
|
598
|
+
if (!resolved) {
|
|
599
|
+
if (item.if_missing === "create") {
|
|
600
|
+
// Treat the update payload as a create payload. Minimum:
|
|
601
|
+
// content OR a path/id (something the createNote-empty-row
|
|
602
|
+
// invariant accepts). createNote enforces its own
|
|
603
|
+
// not-both-empty check — we leave that to the Store and
|
|
604
|
+
// surface any error to the caller verbatim.
|
|
605
|
+
//
|
|
606
|
+
// Field mapping (mirrors the create-note tool surface):
|
|
607
|
+
// - `item.id` → both the note's `id` AND a fallback
|
|
608
|
+
// `path` when `item.path` isn't set. Treating `id` as
|
|
609
|
+
// the path-or-id lookup key matches Gitcoin's nightly
|
|
610
|
+
// sync shape where the canonical key is a path string
|
|
611
|
+
// like "Inbox/2026-05-13-meeting". If the caller
|
|
612
|
+
// supplied an opaque ULID as `id` and no `path`, we
|
|
613
|
+
// still create with that as `id` (path stays null).
|
|
614
|
+
// - `item.content` / `item.path` / `item.tags` /
|
|
615
|
+
// `item.metadata` / `item.created_at` → forwarded.
|
|
616
|
+
// - `if_updated_at` / `force` / `content_edit` /
|
|
617
|
+
// `append` / `prepend` / `links` are
|
|
618
|
+
// update-only — silently ignored on the create branch.
|
|
619
|
+
// (Content-edit on a non-existent note is a nonsense
|
|
620
|
+
// combination; the caller's intent on missing-note is
|
|
621
|
+
// "create the row", not "patch in this section".)
|
|
622
|
+
const idOrPath = item.id as string;
|
|
623
|
+
// Heuristic: if `path` isn't set AND the `id` looks like a
|
|
624
|
+
// path (contains "/" or doesn't match a typical opaque-id
|
|
625
|
+
// shape), use it as the path too. Otherwise treat it as a
|
|
626
|
+
// pure id. The shared `id` field for update is ID-or-path
|
|
627
|
+
// already (see `resolveNote`), so this preserves the
|
|
628
|
+
// caller's intent.
|
|
629
|
+
const idLooksLikePath = idOrPath.includes("/") || !/^[A-Za-z0-9_-]+$/.test(idOrPath);
|
|
630
|
+
const explicitPath = typeof item.path === "string" ? item.path as string : undefined;
|
|
631
|
+
const createOpts: Parameters<Store["createNote"]>[1] = {
|
|
632
|
+
...(idLooksLikePath ? { path: explicitPath ?? idOrPath } : { id: idOrPath, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
|
|
633
|
+
...(item.tags && Array.isArray((item.tags as any).add)
|
|
634
|
+
? { tags: (item.tags as any).add as string[] }
|
|
635
|
+
: Array.isArray(item.tags)
|
|
636
|
+
? { tags: item.tags as string[] }
|
|
637
|
+
: {}),
|
|
638
|
+
...(item.metadata !== undefined ? { metadata: item.metadata as Record<string, unknown> } : {}),
|
|
639
|
+
...(item.created_at !== undefined ? { created_at: item.created_at as string } : {}),
|
|
640
|
+
};
|
|
641
|
+
const content = (item.content as string | undefined) ?? "";
|
|
642
|
+
const created = await store.createNote(content, createOpts);
|
|
643
|
+
await applySchemaDefaults(store, db, [created.id], created.tags ?? []);
|
|
644
|
+
// Apply links.add if the caller declared any.
|
|
645
|
+
const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
|
|
646
|
+
if (linksAdd) {
|
|
647
|
+
for (const link of linksAdd) {
|
|
648
|
+
const target = resolveNote(db, link.target);
|
|
649
|
+
if (target) await store.createLink(created.id, target.id, link.relationship, link.metadata);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const fresh = noteOps.getNote(db, created.id) ?? created;
|
|
653
|
+
updated.push(fresh);
|
|
654
|
+
createdIds.add(fresh.id);
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
// Fallthrough: not-found + no if_missing → existing error
|
|
658
|
+
// contract. Match `requireNote`'s message shape so existing
|
|
659
|
+
// callers see no behavior change.
|
|
660
|
+
throw new Error(`Note not found: "${item.id}"`);
|
|
661
|
+
}
|
|
662
|
+
const note = resolved;
|
|
585
663
|
|
|
586
664
|
// --- Validate mutual exclusion of content modes ---
|
|
587
665
|
const hasContent = item.content !== undefined;
|
|
@@ -745,14 +823,21 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
745
823
|
// Response shape: full Note (back-compat default) or lean NoteIndex
|
|
746
824
|
// (#285 friction point 2.response — opt-out for callers making
|
|
747
825
|
// frequent small edits to large notes). `validation_status` from
|
|
748
|
-
// `tags.fields` is preserved across either shape.
|
|
826
|
+
// `tags.fields` is preserved across either shape. `created: true|false`
|
|
827
|
+
// (vault#309) is attached to every response so callers using
|
|
828
|
+
// `if_missing: "create"` can tell which branch fired without a
|
|
829
|
+
// separate query. `false` for the (overwhelmingly common) update
|
|
830
|
+
// path; `true` only when this call took the create-on-missing
|
|
831
|
+
// branch.
|
|
749
832
|
const includeContent = params.include_content !== false;
|
|
750
833
|
const final = updated.map((n) => {
|
|
751
834
|
const validated = attachValidationStatus(store, db, n);
|
|
752
|
-
|
|
835
|
+
const created = createdIds.has(n.id);
|
|
836
|
+
if (includeContent) return { ...validated, created } as Note & { created: boolean };
|
|
753
837
|
const lean: any = noteOps.toNoteIndex(validated);
|
|
754
838
|
const vs = (validated as any).validation_status;
|
|
755
839
|
if (vs !== undefined) lean.validation_status = vs;
|
|
840
|
+
lean.created = created;
|
|
756
841
|
return lean;
|
|
757
842
|
});
|
|
758
843
|
return batch ? final : final[0];
|
|
@@ -1127,8 +1212,13 @@ function defaultForField(field: { type: string; enum?: string[] }): unknown {
|
|
|
1127
1212
|
*
|
|
1128
1213
|
* Returns the note unchanged when no tag declares fields, so callers
|
|
1129
1214
|
* without any tag schemas see no behavior change.
|
|
1215
|
+
*
|
|
1216
|
+
* Exported so both transports (MCP `update-note` here, HTTP `PATCH
|
|
1217
|
+
* /api/notes/:id` in `src/routes.ts`) attach the same status field by
|
|
1218
|
+
* the same recipe — see vault#287 for the asymmetry that motivated
|
|
1219
|
+
* exposing it.
|
|
1130
1220
|
*/
|
|
1131
|
-
function attachValidationStatus(store: Store, _db: Database, note: Note): Note {
|
|
1221
|
+
export function attachValidationStatus(store: Store, _db: Database, note: Note): Note {
|
|
1132
1222
|
// Short-circuit cheaply: when no tag declares fields, the resolver
|
|
1133
1223
|
// returns null without us paying a re-read of the note.
|
|
1134
1224
|
const status = store.validateNoteAgainstSchemas({
|
package/core/src/obsidian.ts
CHANGED
|
@@ -1,15 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Obsidian
|
|
2
|
+
* Obsidian-compatible markdown export/import — back-compat shim.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* @deprecated The canonical home for the markdown knowledge-base format
|
|
5
|
+
* is `portable-md.ts`. The format isn't Obsidian-specific (it's consumed
|
|
6
|
+
* unchanged by Logseq, Foam, Quartz, Dendron, and most markdown-shaped
|
|
7
|
+
* static-site generators) — anchoring the function name to the format
|
|
8
|
+
* keeps the door open as other consumers adopt the same shape. See
|
|
9
|
+
* vault#308.
|
|
10
|
+
*
|
|
11
|
+
* What lives here:
|
|
12
|
+
* - `toObsidianMarkdown` — the **legacy** lossy emitter (flat
|
|
13
|
+
* frontmatter, no IDs, no typed links, no attachments). Kept for
|
|
14
|
+
* existing callers; for round-trippable exports use
|
|
15
|
+
* `toPortableMarkdown` in `portable-md.ts`.
|
|
16
|
+
* - `parseObsidianVault` / `parseObsidianFile` — directory + file
|
|
17
|
+
* parsers. These delegate to `portable-md.ts`'s parser, which
|
|
18
|
+
* handles both the new lossless shape and the legacy flat
|
|
19
|
+
* frontmatter shape.
|
|
20
|
+
* - Re-exports of `parseFrontmatter`, `extractInlineTags`,
|
|
21
|
+
* `walkMarkdownFiles` from `portable-md.ts` so existing imports
|
|
22
|
+
* keep working without code-level churn.
|
|
23
|
+
*
|
|
24
|
+
* New code should import from `portable-md.ts` directly.
|
|
9
25
|
*/
|
|
10
26
|
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
27
|
+
import { readFileSync } from "fs";
|
|
28
|
+
import { relative } from "path";
|
|
29
|
+
|
|
30
|
+
// Re-export the canonical parser helpers so existing callers (and tests)
|
|
31
|
+
// keep working against the legacy import path.
|
|
32
|
+
export {
|
|
33
|
+
parseFrontmatter,
|
|
34
|
+
extractInlineTags,
|
|
35
|
+
walkMarkdownFiles,
|
|
36
|
+
} from "./portable-md.js";
|
|
37
|
+
|
|
38
|
+
import { parseFrontmatter, walkMarkdownFiles, extractInlineTags } from "./portable-md.js";
|
|
13
39
|
|
|
14
40
|
// ---------------------------------------------------------------------------
|
|
15
41
|
// Types
|
|
@@ -34,121 +60,7 @@ export interface ImportStats {
|
|
|
34
60
|
errors: { path: string; error: string }[];
|
|
35
61
|
}
|
|
36
62
|
|
|
37
|
-
|
|
38
|
-
// Frontmatter parsing
|
|
39
|
-
// ---------------------------------------------------------------------------
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Parse YAML frontmatter from markdown content.
|
|
43
|
-
* Returns { frontmatter, content } where content has frontmatter stripped.
|
|
44
|
-
*
|
|
45
|
-
* Uses a simple parser — no dependency on a YAML library.
|
|
46
|
-
* Handles common frontmatter patterns: strings, arrays, numbers, booleans.
|
|
47
|
-
*/
|
|
48
|
-
export function parseFrontmatter(raw: string): {
|
|
49
|
-
frontmatter: Record<string, unknown>;
|
|
50
|
-
content: string;
|
|
51
|
-
} {
|
|
52
|
-
if (!raw.startsWith("---")) {
|
|
53
|
-
return { frontmatter: {}, content: raw };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const endIdx = raw.indexOf("\n---", 3);
|
|
57
|
-
if (endIdx === -1) {
|
|
58
|
-
return { frontmatter: {}, content: raw };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const yamlBlock = raw.slice(4, endIdx); // skip opening "---\n"
|
|
62
|
-
const content = raw.slice(endIdx + 4).replace(/^\n/, ""); // skip closing "---\n"
|
|
63
|
-
|
|
64
|
-
const frontmatter: Record<string, unknown> = {};
|
|
65
|
-
let currentKey = "";
|
|
66
|
-
let currentArray: string[] | null = null;
|
|
67
|
-
|
|
68
|
-
for (const line of yamlBlock.split("\n")) {
|
|
69
|
-
// Array item (continuation of previous key)
|
|
70
|
-
if (currentArray !== null && /^\s+-\s+/.test(line)) {
|
|
71
|
-
const val = line.replace(/^\s+-\s+/, "").trim();
|
|
72
|
-
currentArray.push(unquote(val));
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// If we were building an array, save it (or save empty string if no items found)
|
|
77
|
-
if (currentArray !== null) {
|
|
78
|
-
frontmatter[currentKey] = currentArray.length > 0 ? currentArray : "";
|
|
79
|
-
currentArray = null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Key: value pair — keys must be YAML-valid (word chars and hyphens, no spaces)
|
|
83
|
-
const kvMatch = line.match(/^([\w][\w-]*):\s*(.*)/);
|
|
84
|
-
if (kvMatch) {
|
|
85
|
-
const key = kvMatch[1]!;
|
|
86
|
-
const value = kvMatch[2]!.trim();
|
|
87
|
-
|
|
88
|
-
if (value === "[]") {
|
|
89
|
-
frontmatter[key] = [];
|
|
90
|
-
} else if (value === "") {
|
|
91
|
-
// Empty value: could be start of array (next lines are "- item")
|
|
92
|
-
// or genuinely empty string. We start array accumulation and
|
|
93
|
-
// handle the empty case when a non-array line follows.
|
|
94
|
-
currentKey = key;
|
|
95
|
-
currentArray = [];
|
|
96
|
-
} else if (value.startsWith("[") && value.endsWith("]")) {
|
|
97
|
-
// Inline array: [item1, item2]
|
|
98
|
-
const items = value.slice(1, -1).split(",").map((s) => unquote(s.trim())).filter(Boolean);
|
|
99
|
-
frontmatter[key] = items;
|
|
100
|
-
} else {
|
|
101
|
-
frontmatter[key] = parseValue(value);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Save any trailing array (or empty string if no items)
|
|
107
|
-
if (currentArray !== null) {
|
|
108
|
-
frontmatter[currentKey] = currentArray.length > 0 ? currentArray : "";
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return { frontmatter, content };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function unquote(s: string): string {
|
|
115
|
-
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
116
|
-
return s.slice(1, -1);
|
|
117
|
-
}
|
|
118
|
-
return s;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function parseValue(s: string): unknown {
|
|
122
|
-
s = unquote(s);
|
|
123
|
-
if (s === "true") return true;
|
|
124
|
-
if (s === "false") return false;
|
|
125
|
-
if (s === "null") return null;
|
|
126
|
-
if (/^-?\d+$/.test(s)) return parseInt(s, 10);
|
|
127
|
-
if (/^-?\d+\.\d+$/.test(s)) return parseFloat(s);
|
|
128
|
-
return s;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ---------------------------------------------------------------------------
|
|
132
|
-
// Tag extraction
|
|
133
|
-
// ---------------------------------------------------------------------------
|
|
134
|
-
|
|
135
|
-
/** Extract inline #tags from markdown content. Excludes tags in code blocks. */
|
|
136
|
-
export function extractInlineTags(content: string): string[] {
|
|
137
|
-
// Strip code blocks and inline code
|
|
138
|
-
let stripped = content.replace(/```[\s\S]*?```/g, "");
|
|
139
|
-
stripped = stripped.replace(/`[^`\n]+`/g, "");
|
|
140
|
-
|
|
141
|
-
const tags = new Set<string>();
|
|
142
|
-
// Match #tag and #nested/tag — must be preceded by whitespace or start of line
|
|
143
|
-
const regex = /(?:^|\s)#([\w][\w/-]*[\w]|[\w])/gm;
|
|
144
|
-
let match: RegExpExecArray | null;
|
|
145
|
-
while ((match = regex.exec(stripped)) !== null) {
|
|
146
|
-
tags.add(match[1]!.toLowerCase());
|
|
147
|
-
}
|
|
148
|
-
return [...tags];
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Extract tags from frontmatter (handles both array and string formats). */
|
|
63
|
+
/** Tags from frontmatter (handles both array and string formats). */
|
|
152
64
|
function extractFrontmatterTags(frontmatter: Record<string, unknown>): string[] {
|
|
153
65
|
const raw = frontmatter.tags;
|
|
154
66
|
if (!raw) return [];
|
|
@@ -157,35 +69,6 @@ function extractFrontmatterTags(frontmatter: Record<string, unknown>): string[]
|
|
|
157
69
|
return [];
|
|
158
70
|
}
|
|
159
71
|
|
|
160
|
-
// ---------------------------------------------------------------------------
|
|
161
|
-
// Directory walking
|
|
162
|
-
// ---------------------------------------------------------------------------
|
|
163
|
-
|
|
164
|
-
/** Recursively list all .md files in a directory, excluding .obsidian/ and hidden dirs. */
|
|
165
|
-
export function walkMarkdownFiles(dir: string): string[] {
|
|
166
|
-
const results: string[] = [];
|
|
167
|
-
|
|
168
|
-
function walk(current: string) {
|
|
169
|
-
for (const entry of readdirSync(current)) {
|
|
170
|
-
// Skip hidden directories and .obsidian config
|
|
171
|
-
if (entry.startsWith(".")) continue;
|
|
172
|
-
if (entry === "node_modules") continue;
|
|
173
|
-
|
|
174
|
-
const full = join(current, entry);
|
|
175
|
-
const stat = statSync(full);
|
|
176
|
-
|
|
177
|
-
if (stat.isDirectory()) {
|
|
178
|
-
walk(full);
|
|
179
|
-
} else if (stat.isFile() && extname(entry).toLowerCase() === ".md") {
|
|
180
|
-
results.push(full);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
walk(dir);
|
|
186
|
-
return results.sort();
|
|
187
|
-
}
|
|
188
|
-
|
|
189
72
|
// ---------------------------------------------------------------------------
|
|
190
73
|
// Parse a single file
|
|
191
74
|
// ---------------------------------------------------------------------------
|
|
@@ -207,12 +90,7 @@ export function parseObsidianFile(filePath: string, vaultRoot: string): Obsidian
|
|
|
207
90
|
const metadata = { ...frontmatter };
|
|
208
91
|
delete metadata.tags;
|
|
209
92
|
|
|
210
|
-
return {
|
|
211
|
-
path,
|
|
212
|
-
content,
|
|
213
|
-
frontmatter: metadata,
|
|
214
|
-
tags: allTags,
|
|
215
|
-
};
|
|
93
|
+
return { path, content, frontmatter: metadata, tags: allTags };
|
|
216
94
|
}
|
|
217
95
|
|
|
218
96
|
// ---------------------------------------------------------------------------
|
|
@@ -254,9 +132,13 @@ export function parseObsidianVault(vaultPath: string): {
|
|
|
254
132
|
}
|
|
255
133
|
|
|
256
134
|
// ---------------------------------------------------------------------------
|
|
257
|
-
//
|
|
135
|
+
// Legacy export — kept for back-compat. New code: use `toPortableMarkdown`.
|
|
258
136
|
// ---------------------------------------------------------------------------
|
|
259
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Note shape the legacy export accepts. Distinct from `PortableNote` —
|
|
140
|
+
* older + lossy by design (no IDs, no typed links, no attachments).
|
|
141
|
+
*/
|
|
260
142
|
export interface ExportableNote {
|
|
261
143
|
path?: string;
|
|
262
144
|
id: string;
|
|
@@ -267,34 +149,33 @@ export interface ExportableNote {
|
|
|
267
149
|
}
|
|
268
150
|
|
|
269
151
|
/**
|
|
270
|
-
* Convert a vault note to Obsidian-compatible markdown with YAML
|
|
152
|
+
* Convert a vault note to Obsidian-compatible markdown with YAML
|
|
153
|
+
* frontmatter — legacy flat-frontmatter shape (metadata keys at the
|
|
154
|
+
* top level, no IDs, no typed links, no attachments).
|
|
155
|
+
*
|
|
156
|
+
* @deprecated Prefer `toPortableMarkdown` in `portable-md.ts` for new
|
|
157
|
+
* code. This function is preserved for callers that intentionally want
|
|
158
|
+
* the legacy lossy shape — typically one-shot "give me an Obsidian
|
|
159
|
+
* copy" exports without round-trip concerns. See vault#308.
|
|
271
160
|
*/
|
|
272
161
|
export function toObsidianMarkdown(note: ExportableNote): string {
|
|
273
162
|
const fm: Record<string, unknown> = {};
|
|
274
163
|
|
|
275
|
-
|
|
276
|
-
if (note.tags && note.tags.length > 0) {
|
|
277
|
-
fm.tags = note.tags;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Add metadata fields (excluding internal ones)
|
|
164
|
+
if (note.tags && note.tags.length > 0) fm.tags = note.tags;
|
|
281
165
|
if (note.metadata) {
|
|
282
166
|
for (const [key, value] of Object.entries(note.metadata)) {
|
|
283
|
-
if (key === "tags") continue;
|
|
167
|
+
if (key === "tags") continue;
|
|
284
168
|
fm[key] = value;
|
|
285
169
|
}
|
|
286
170
|
}
|
|
287
171
|
|
|
288
|
-
// Build frontmatter string
|
|
289
172
|
let result = "";
|
|
290
173
|
if (Object.keys(fm).length > 0) {
|
|
291
174
|
result += "---\n";
|
|
292
175
|
for (const [key, value] of Object.entries(fm)) {
|
|
293
176
|
if (Array.isArray(value)) {
|
|
294
177
|
result += `${key}:\n`;
|
|
295
|
-
for (const item of value) {
|
|
296
|
-
result += ` - ${item}\n`;
|
|
297
|
-
}
|
|
178
|
+
for (const item of value) result += ` - ${item}\n`;
|
|
298
179
|
} else if (typeof value === "object" && value !== null) {
|
|
299
180
|
result += `${key}: ${JSON.stringify(value)}\n`;
|
|
300
181
|
} else {
|
|
@@ -309,14 +190,11 @@ export function toObsidianMarkdown(note: ExportableNote): string {
|
|
|
309
190
|
}
|
|
310
191
|
|
|
311
192
|
/**
|
|
312
|
-
* Determine the file path for an exported note.
|
|
313
|
-
*
|
|
193
|
+
* Determine the file path for an exported note (legacy form).
|
|
194
|
+
* @deprecated Use `portableExportFilePath` from `portable-md.ts`.
|
|
314
195
|
*/
|
|
315
196
|
export function exportFilePath(note: ExportableNote): string {
|
|
316
|
-
if (note.path)
|
|
317
|
-
return note.path + ".md";
|
|
318
|
-
}
|
|
319
|
-
// Fallback: use date prefix + truncated id
|
|
197
|
+
if (note.path) return note.path + ".md";
|
|
320
198
|
const date = note.createdAt.split("T")[0];
|
|
321
199
|
return `${date}/${note.id}.md`;
|
|
322
200
|
}
|