@openparachute/vault 0.6.2 → 0.6.3-rc.3
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/core/src/onboarding.ts +300 -0
- package/core/src/vault-projection.ts +77 -1
- package/package.json +1 -1
- package/src/cli.ts +9 -1
- package/src/mcp-tools.ts +37 -0
- package/src/onboarding-seed.test.ts +222 -0
- package/src/onboarding-seed.ts +83 -0
- package/src/server.ts +5 -0
- package/src/vault-create.test.ts +46 -0
- package/src/vault.test.ts +19 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding doctrine — the in-vault guide a freshly-created vault seeds so a
|
|
3
|
+
* connected AI can self-orient and set the vault up on the operator's behalf.
|
|
4
|
+
*
|
|
5
|
+
* This module is the single source of truth for the *content* + the *paths* of
|
|
6
|
+
* the seeded notes. It lives in `core` (not `src`) so both:
|
|
7
|
+
* - the seeding path (`src/onboarding-seed.ts`, called from `createVault`), and
|
|
8
|
+
* - the projection/`vault-info` pointer (`core/src/vault-projection.ts`)
|
|
9
|
+
* can share the canonical note paths without a cross-layer import.
|
|
10
|
+
*
|
|
11
|
+
* The notes are AI-legible, practical (SKILL.md-style) guides addressed to the
|
|
12
|
+
* assistant that connects to the vault. They are a starting point, not gospel —
|
|
13
|
+
* Getting Started tells the AI it can adapt them as the vault matures. Seeding is
|
|
14
|
+
* create-time only and idempotent (never clobbers a note the operator/AI has
|
|
15
|
+
* since edited).
|
|
16
|
+
*
|
|
17
|
+
* See the demo-prep Workstream A (A1/A2/A3).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Canonical path of the seeded onboarding guide. Top-level, title-cased so it
|
|
21
|
+
* reads as a doc and sorts to the top of a casual file listing. */
|
|
22
|
+
export const GETTING_STARTED_PATH = "Getting Started";
|
|
23
|
+
|
|
24
|
+
/** Canonical path of the seeded surface-build starter, linked from Getting
|
|
25
|
+
* Started. */
|
|
26
|
+
export const SURFACE_STARTER_PATH = "Surface Starter";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Body of the seeded `Getting Started` note.
|
|
30
|
+
*
|
|
31
|
+
* Voice: addressed to the connected AI ("you"), practical, SKILL.md-style.
|
|
32
|
+
* Covers (a) what a Parachute vault is, (b) tags-vs-paths-vs-schemas design,
|
|
33
|
+
* (c) importing existing notes, (d) that it's an adaptable starting point.
|
|
34
|
+
*/
|
|
35
|
+
export const GETTING_STARTED_CONTENT = `# Getting Started
|
|
36
|
+
|
|
37
|
+
This is the **start-here guide** for this Parachute vault — think of it like a
|
|
38
|
+
\`SKILL.md\`: practical instructions for setting up and growing the vault. Read it
|
|
39
|
+
when you're **getting the vault started** or orienting yourself to it — you don't
|
|
40
|
+
need to re-read it every session. It's a **starting point, not a script**, and
|
|
41
|
+
it's adaptable: edit it (see "Adapt this note") as the vault takes shape.
|
|
42
|
+
|
|
43
|
+
When the operator says something like *"help me set up my parachute,"* this is
|
|
44
|
+
your brief: design their structure with them, import what they already have, and
|
|
45
|
+
shape the vault around how they actually think and work.
|
|
46
|
+
|
|
47
|
+
## What a Parachute vault is
|
|
48
|
+
|
|
49
|
+
A vault is **notes + tags + links** in one graph, reachable over MCP (you, now),
|
|
50
|
+
a REST API (scripts), and any surface (a UI). It ships *blank* — no predefined
|
|
51
|
+
tags or schema. You and the operator design the structure that fits *their*
|
|
52
|
+
life and work. The vault is the engine; the meaning is yours to bring.
|
|
53
|
+
|
|
54
|
+
Core moves you already have as MCP tools:
|
|
55
|
+
- \`create-note\` / \`update-note\` / \`delete-note\` — write notes (single or batch).
|
|
56
|
+
- \`query-notes\` — by id/path, by tag, full-text \`search\`, or graph \`near\` a note.
|
|
57
|
+
- \`list-tags\` / \`update-tag\` / \`delete-tag\` — manage the tag vocabulary + schemas.
|
|
58
|
+
- \`find-path\` — shortest link path between two notes.
|
|
59
|
+
- \`vault-info\` — refresh the live schema/stats projection any time.
|
|
60
|
+
|
|
61
|
+
\`[[wikilinks]]\` in note content auto-link to the note at that path — use them
|
|
62
|
+
freely; they resolve even if the target is created later.
|
|
63
|
+
|
|
64
|
+
## Tags vs paths vs schemas — the design vocabulary
|
|
65
|
+
|
|
66
|
+
These three axes are the heart of vault design. Use the right one for the job:
|
|
67
|
+
|
|
68
|
+
- **Tags = types / membership.** A tag answers *"what kind of thing is this?"*
|
|
69
|
+
(\`#person\`, \`#meeting\`, \`#project\`). Queries **expand over tags**: a tag can
|
|
70
|
+
declare \`parent_names\` so \`tag:X\` also returns its subtypes (e.g. tagging a
|
|
71
|
+
note \`#meeting/standup\` with \`parent_names: [meeting]\` means \`query-notes
|
|
72
|
+
{ tag: "meeting" }\` finds it). Tags are how you ask *"show me all my people."*
|
|
73
|
+
This is the primary structure — reach for a tag first.
|
|
74
|
+
|
|
75
|
+
- **Paths = organization / filing.** A path (\`Projects/Acme/Kickoff\`) is *where*
|
|
76
|
+
a note lives — a human-browsable address, unique per note. Paths are for
|
|
77
|
+
folders and named docs (like this one). They do **not** drive type queries;
|
|
78
|
+
don't encode meaning in a path that a tag should carry. A note can have a
|
|
79
|
+
path, tags, or both.
|
|
80
|
+
|
|
81
|
+
- **Schemas = typed metadata fields.** Attach a schema to a tag (via
|
|
82
|
+
\`update-tag\`) to declare typed metadata fields — e.g. \`#meeting\` with a
|
|
83
|
+
\`held_on\` date, \`#person\` with an \`email\`. Each field can **optionally** be
|
|
84
|
+
marked \`indexed: true\` to make it **queryable with operators** (\`query-notes
|
|
85
|
+
{ tag: "meeting", metadata: { held_on: { gte: "2026-01-01" } } }\`); indexing
|
|
86
|
+
is opt-in per field, not automatic. Add a schema (and index a field) when you
|
|
87
|
+
find yourself wanting to filter or sort on a value, not before.
|
|
88
|
+
|
|
89
|
+
Rule of thumb: **type with tags, file with paths, make-it-queryable with
|
|
90
|
+
schemas.** Start minimal — invent tags as real notes need them, declare a
|
|
91
|
+
schema only when a query demands it. Over-designing an empty vault is the
|
|
92
|
+
common mistake.
|
|
93
|
+
|
|
94
|
+
Declaring a schema is one \`update-tag\` call — the \`fields\` object maps each
|
|
95
|
+
field name to \`{ type, enum?, indexed? }\` (\`type\` is \`"string"\`, \`"boolean"\`,
|
|
96
|
+
or \`"integer"\`):
|
|
97
|
+
|
|
98
|
+
\`\`\`
|
|
99
|
+
update-tag {
|
|
100
|
+
tag: "meeting",
|
|
101
|
+
description: "A meeting with notes",
|
|
102
|
+
fields: {
|
|
103
|
+
held_on: { type: "string", indexed: true }, // queryable with operators
|
|
104
|
+
status: { type: "string", enum: ["scheduled", "done"] }, // first enum value is the default
|
|
105
|
+
rating: { type: "integer" }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
\`\`\`
|
|
109
|
+
|
|
110
|
+
\`fields\` is **merged** (new keys added, existing replaced); \`parent_names\` and
|
|
111
|
+
\`relationships\` are replaced wholesale when passed. Only \`indexed: true\` fields
|
|
112
|
+
support operator queries (\`metadata: { held_on: { gte: "..." } }\`) and
|
|
113
|
+
\`order_by\`; all tags declaring the same field must agree on its \`type\` and
|
|
114
|
+
\`indexed\` flag.
|
|
115
|
+
|
|
116
|
+
## Write gotchas
|
|
117
|
+
|
|
118
|
+
A few behaviors worth knowing before you write at scale:
|
|
119
|
+
|
|
120
|
+
- **\`update-note\` requires optimistic concurrency by default.** Pass
|
|
121
|
+
\`if_updated_at\` with the \`updated_at\` you last read; a mismatch returns a
|
|
122
|
+
conflict error (re-read, reconcile, retry). For bulk/scripted writes where
|
|
123
|
+
concurrency is known-safe, pass \`force: true\` to waive the *requirement to
|
|
124
|
+
supply* it. \`append\`/\`prepend\`-only updates are exempt (no-conflict-by-design).
|
|
125
|
+
- **A schema field's default is filled in on write, so it shows up even when you
|
|
126
|
+
didn't set it.** When a note gets a tag whose schema declares a field, the
|
|
127
|
+
missing field is back-filled: an \`enum\` field → its **first listed value**, an
|
|
128
|
+
\`integer\` → \`0\`, a \`boolean\` → \`false\`, a plain string → \`""\`. So a
|
|
129
|
+
\`rating: { type: "integer" }\` reads as \`0\` on notes nobody rated — that \`0\`
|
|
130
|
+
is "unset," not "rated zero." Order an \`enum\`'s values so the first is a sane
|
|
131
|
+
default, and don't read a back-filled \`0\`/\`""\`/\`false\` as a real value.
|
|
132
|
+
- **Validation is advisory, never blocking.** A type/enum mismatch comes back as
|
|
133
|
+
a \`validation_status\` warning on the write response — the write still lands.
|
|
134
|
+
Read those warnings and self-correct on the next turn.
|
|
135
|
+
|
|
136
|
+
(Full design guide, with copy-paste examples: https://parachute.computer/scripting/)
|
|
137
|
+
|
|
138
|
+
## Importing existing notes
|
|
139
|
+
|
|
140
|
+
If the operator already keeps notes (Obsidian, Markdown, etc.), bring them in
|
|
141
|
+
rather than starting cold:
|
|
142
|
+
|
|
143
|
+
- **Obsidian / a Markdown folder:** \`parachute-vault import <path>\` — preserves
|
|
144
|
+
frontmatter, tags, \`[[wikilinks]]\`, and file paths.
|
|
145
|
+
- **A portable Parachute export** (a dir with \`.parachute/vault.yaml\`): the same
|
|
146
|
+
\`import\` command auto-detects it and does a lossless round-trip (ids, typed
|
|
147
|
+
links, tag schemas, attachments).
|
|
148
|
+
- **Ad hoc / pasted content:** just \`create-note\` it. Then help the operator tag
|
|
149
|
+
and schematize: read a sample of imported notes, propose a small tag
|
|
150
|
+
vocabulary, and apply it.
|
|
151
|
+
|
|
152
|
+
After an import, orient yourself: \`vault-info\` for the new schema picture,
|
|
153
|
+
\`list-tags\` to see what vocabulary arrived, \`query-notes { search: "..." }\` to
|
|
154
|
+
spot-check. Then propose structure — don't impose it silently.
|
|
155
|
+
|
|
156
|
+
## Later: a custom surface
|
|
157
|
+
|
|
158
|
+
Building a custom UI over the vault (a dashboard, a notes app) is usually **not**
|
|
159
|
+
the starting point — get the notes and structure right first. If and when the
|
|
160
|
+
operator wants one, see **[[Surface Starter]]** (built with
|
|
161
|
+
\`@openparachute/surface-client\` + \`@openparachute/surface-render\`).
|
|
162
|
+
|
|
163
|
+
## Adapt this note
|
|
164
|
+
|
|
165
|
+
This guide is a **default starting point, not gospel** — edit it to fit this
|
|
166
|
+
vault. As you and the operator settle on a tag vocabulary, conventions, or a
|
|
167
|
+
surface, you can record that here so a future session inherits the current shape
|
|
168
|
+
of the vault instead of this blank-slate default. Useful things to capture:
|
|
169
|
+
- the tag vocabulary you've settled on and what each tag means;
|
|
170
|
+
- naming/path conventions for this vault;
|
|
171
|
+
- which schemas exist and why;
|
|
172
|
+
- anything a fresh AI would need to be immediately useful.
|
|
173
|
+
|
|
174
|
+
Treat setup as a relationship, not a one-time install.
|
|
175
|
+
`;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Body of the seeded `Surface Starter` note. Linked from Getting Started.
|
|
179
|
+
*
|
|
180
|
+
* A concise, *living* starter prompt for building a custom surface (UI) over
|
|
181
|
+
* the vault using the published surface packages. Tells the AI to import the
|
|
182
|
+
* packages rather than hand-roll OAuth/API/rendering.
|
|
183
|
+
*/
|
|
184
|
+
export const SURFACE_STARTER_CONTENT = `# Surface Starter
|
|
185
|
+
|
|
186
|
+
A **surface** is a custom UI over this vault — a dashboard, a notes app, a
|
|
187
|
+
single-purpose tool. This note is a living starter for building one *with the
|
|
188
|
+
operator*. Update it as you settle on a stack, conventions, or a deployed
|
|
189
|
+
surface for this vault.
|
|
190
|
+
|
|
191
|
+
## ⚠️ Build a surface in your editor, not from this session
|
|
192
|
+
|
|
193
|
+
A surface runs **in a browser**: it needs a real OAuth round-trip (a redirect to
|
|
194
|
+
the hub's consent screen and back), a dev server to serve the app, and a CORS
|
|
195
|
+
origin the hub trusts. **None of that exists in this MCP/chat session** — there's
|
|
196
|
+
no browser, no redirect, no dev server. So **don't try to "run" a surface from
|
|
197
|
+
the vault session.** Build it in **Claude Code (or your editor)** against a local
|
|
198
|
+
dev server (\`vite\`/\`bun dev\`), sign in through the browser there, and iterate.
|
|
199
|
+
From *this* session you design the vault structure the surface will consume and
|
|
200
|
+
write the code — you can't exercise the OAuth/render loop here.
|
|
201
|
+
|
|
202
|
+
## Don't hand-roll the plumbing
|
|
203
|
+
|
|
204
|
+
Two published packages do the heavy lifting — import them instead of writing
|
|
205
|
+
OAuth, the vault API client, or note rendering by hand:
|
|
206
|
+
|
|
207
|
+
- **\`@openparachute/surface-client\`** — \`createVaultSurface(...)\` wires up
|
|
208
|
+
Parachute OAuth (sign-in on first connect) and a typed vault API client
|
|
209
|
+
(query/create/update notes, tags, links) so your app code just calls methods.
|
|
210
|
+
- **\`@openparachute/surface-render\`** — \`<NoteRenderer>\` and friends render note
|
|
211
|
+
content (Markdown, wikilinks, embeds) the way the rest of the ecosystem does,
|
|
212
|
+
so your surface looks native without re-implementing the renderer.
|
|
213
|
+
|
|
214
|
+
## Minimal end-to-end (config → sign-in → query → render)
|
|
215
|
+
|
|
216
|
+
A React sketch wiring all four steps. \`createVaultSurface\` is the only required
|
|
217
|
+
config (its \`clientName\` is the sole required option; \`hubUrl\` defaults to the
|
|
218
|
+
page origin, \`vaultName\` to \`"default"\`, \`scope\` to \`"vault:read vault:write"\`).
|
|
219
|
+
\`getClient()\` returns a \`VaultClient\` (or \`null\` until signed in) whose
|
|
220
|
+
\`queryNotes()\` takes the same query grammar you use over MCP. See
|
|
221
|
+
[[Getting Started]] / \`vault-info\` for this vault's NAME and hub origin.
|
|
222
|
+
|
|
223
|
+
\`\`\`tsx
|
|
224
|
+
import { useEffect, useState } from "react";
|
|
225
|
+
import { createVaultSurface, type Note } from "@openparachute/surface-client";
|
|
226
|
+
import { NoteRenderer } from "@openparachute/surface-render";
|
|
227
|
+
|
|
228
|
+
// One surface per (hub, vault) config. clientName shows on the consent screen.
|
|
229
|
+
const surface = createVaultSurface({
|
|
230
|
+
clientName: "My Vault Surface",
|
|
231
|
+
hubUrl: "https://your-hub.example", // omit to default to window.location.origin
|
|
232
|
+
vaultName: "default", // this vault's name (see vault-info)
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
export function App() {
|
|
236
|
+
const [notes, setNotes] = useState<Note[] | null>(null);
|
|
237
|
+
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
(async () => {
|
|
240
|
+
// OAuth: finish a redirect callback if we're on it, else send the browser
|
|
241
|
+
// off to sign in. handleCallback() needs BOTH code + state, so guard on
|
|
242
|
+
// both. (Real apps route /oauth/callback to its own component.)
|
|
243
|
+
const q = new URLSearchParams(location.search);
|
|
244
|
+
if (q.get("code") && q.get("state")) await surface.handleCallback();
|
|
245
|
+
// getClient() builds a FRESH VaultClient on each call — fine here (one-shot
|
|
246
|
+
// effect); in a real component keep it in state/ref, don't call it per render.
|
|
247
|
+
const client = surface.getClient(); // VaultClient | null (null = not signed in)
|
|
248
|
+
if (!client) return void surface.login();
|
|
249
|
+
setNotes(await client.queryNotes({ tag: "note", limit: 20 }));
|
|
250
|
+
})();
|
|
251
|
+
}, []);
|
|
252
|
+
|
|
253
|
+
if (!notes) return <p>Connecting…</p>;
|
|
254
|
+
return (
|
|
255
|
+
<>
|
|
256
|
+
{notes.map((n) => (
|
|
257
|
+
// resolve maps a [[wikilink]] target → { href, exists } (or null = inert).
|
|
258
|
+
// You own this href's trust boundary — keep it a fragment (or validate the
|
|
259
|
+
// target). Don't build a raw passthrough href: a vault note could carry a
|
|
260
|
+
// javascript: target.
|
|
261
|
+
<NoteRenderer
|
|
262
|
+
key={n.id}
|
|
263
|
+
note={n}
|
|
264
|
+
resolve={(target) => ({ href: \`#/n/\${encodeURIComponent(target)}\`, exists: true })}
|
|
265
|
+
/>
|
|
266
|
+
))}
|
|
267
|
+
</>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
\`\`\`
|
|
271
|
+
|
|
272
|
+
That's the whole spine. \`<NoteRenderer>\` also takes \`linkComponent\` (your
|
|
273
|
+
router's \`<Link>\`) and \`fetchBlob\` (\`(url) => Promise<Blob>\`, for auth'd
|
|
274
|
+
image/audio embeds) when you need them — both optional.
|
|
275
|
+
|
|
276
|
+
## Build order
|
|
277
|
+
|
|
278
|
+
1. **Auth + data first.** Stand up \`createVaultSurface\` pointed at this vault;
|
|
279
|
+
confirm you can sign in and \`queryNotes\` round-trips before any UI polish.
|
|
280
|
+
2. **Render next.** Drop in \`<NoteRenderer>\` to display note content; wire
|
|
281
|
+
wikilink/embed resolution through the package, not by hand.
|
|
282
|
+
3. **UX last.** Layout, navigation, and the surface's actual purpose — now that
|
|
283
|
+
auth, data, and rendering are solid.
|
|
284
|
+
|
|
285
|
+
## Design it around this vault's structure
|
|
286
|
+
|
|
287
|
+
A good surface is shaped by the vault's tags + schemas (see [[Getting Started]]
|
|
288
|
+
for the tags-vs-paths-vs-schemas design vocabulary). Query by the tags that
|
|
289
|
+
matter to the operator; surface the indexed fields they filter on. If the vault
|
|
290
|
+
doesn't yet have the structure a surface wants, that's a signal to design tags +
|
|
291
|
+
schemas first.
|
|
292
|
+
|
|
293
|
+
## Adapt this note
|
|
294
|
+
|
|
295
|
+
When you build a surface for this vault, record it here: what it's for, the
|
|
296
|
+
stack, how to run it, the queries it depends on. The next session should be able
|
|
297
|
+
to pick it up from this note.
|
|
298
|
+
|
|
299
|
+
(More on the surface stack + examples: https://parachute.computer/scripting/)
|
|
300
|
+
`;
|
|
@@ -33,6 +33,23 @@ import {
|
|
|
33
33
|
import { DEFAULT_TAG_NAME } from "./tag-hierarchy.ts";
|
|
34
34
|
import * as noteOps from "./notes.ts";
|
|
35
35
|
import type { VaultStats } from "./types.ts";
|
|
36
|
+
import { GETTING_STARTED_PATH } from "./onboarding.ts";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Does a note live at the seeded onboarding path? Used to gate the "Start here"
|
|
40
|
+
* pointer so it only fires when the guide actually exists (fresh vaults seed it;
|
|
41
|
+
* pre-existing vaults that never seeded one shouldn't dangle a dead pointer).
|
|
42
|
+
*/
|
|
43
|
+
function hasGettingStartedNote(db: Database): boolean {
|
|
44
|
+
try {
|
|
45
|
+
const row = db
|
|
46
|
+
.query("SELECT 1 FROM notes WHERE path = ? LIMIT 1")
|
|
47
|
+
.get(GETTING_STARTED_PATH);
|
|
48
|
+
return row != null;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
36
53
|
|
|
37
54
|
// ---------------------------------------------------------------------------
|
|
38
55
|
// Types
|
|
@@ -76,6 +93,14 @@ export interface VaultProjection {
|
|
|
76
93
|
tags: ProjectionTag[];
|
|
77
94
|
indexed_fields: ProjectionIndexedField[];
|
|
78
95
|
query_hints: string[];
|
|
96
|
+
/**
|
|
97
|
+
* Path of the seeded onboarding guide, when present. A pointer (NOT the note
|
|
98
|
+
* body) so any connected AI is steered to read `Getting Started` first, every
|
|
99
|
+
* session — the AI fetches it with `query-notes { id: "<path>" }`. Omitted
|
|
100
|
+
* when no such note exists (older vaults that never seeded one). See
|
|
101
|
+
* core/src/onboarding.ts.
|
|
102
|
+
*/
|
|
103
|
+
getting_started?: string;
|
|
79
104
|
/** Included when the caller requests stats; omitted otherwise. */
|
|
80
105
|
stats?: VaultStats;
|
|
81
106
|
}
|
|
@@ -210,6 +235,12 @@ export function buildVaultProjection(
|
|
|
210
235
|
query_hints: [...QUERY_HINTS],
|
|
211
236
|
};
|
|
212
237
|
|
|
238
|
+
// A2: point any connected AI at the seeded onboarding guide (a path pointer,
|
|
239
|
+
// never the body). Only when the note actually exists.
|
|
240
|
+
if (hasGettingStartedNote(db)) {
|
|
241
|
+
projection.getting_started = GETTING_STARTED_PATH;
|
|
242
|
+
}
|
|
243
|
+
|
|
213
244
|
if (opts?.includeStats) {
|
|
214
245
|
projection.stats = noteOps.getVaultStats(db);
|
|
215
246
|
}
|
|
@@ -242,8 +273,19 @@ export function projectionToMarkdown(args: {
|
|
|
242
273
|
vaultName: string;
|
|
243
274
|
description?: string | null;
|
|
244
275
|
projection: VaultProjection;
|
|
276
|
+
/**
|
|
277
|
+
* This vault's own public coordinates, surfaced so a surface-builder never has
|
|
278
|
+
* to guess the vault NAME or reconstruct the REST/MCP URLs from the connector
|
|
279
|
+
* config. When `hubOrigin` is a known absolute origin (configured
|
|
280
|
+
* `PARACHUTE_HUB_ORIGIN` or an active expose-state FQDN), the absolute base
|
|
281
|
+
* URLs are rendered; when it's only the loopback fallback (no public origin
|
|
282
|
+
* known to the vault), relative path templates are rendered with a note that
|
|
283
|
+
* the origin is whatever the client connected through. The NAME is always
|
|
284
|
+
* known and always surfaced. See `chooseHubOrigin` (src/mcp-install.ts).
|
|
285
|
+
*/
|
|
286
|
+
coordinates?: { hubOrigin: string; hubOriginKnown: boolean };
|
|
245
287
|
}): string {
|
|
246
|
-
const { vaultName, description, projection } = args;
|
|
288
|
+
const { vaultName, description, projection, coordinates } = args;
|
|
247
289
|
const stats = projection.stats;
|
|
248
290
|
|
|
249
291
|
const lines: string[] = [];
|
|
@@ -253,6 +295,40 @@ export function projectionToMarkdown(args: {
|
|
|
253
295
|
lines.push(description.trim());
|
|
254
296
|
}
|
|
255
297
|
|
|
298
|
+
// GAP 3 / vault coordinates: state the vault's own NAME + REST/MCP URLs so a
|
|
299
|
+
// surface-builder (or any scripting client) doesn't have to learn them from
|
|
300
|
+
// the MCP connector config. Absolute when the vault knows its public origin;
|
|
301
|
+
// relative templates otherwise.
|
|
302
|
+
if (coordinates) {
|
|
303
|
+
lines.push("");
|
|
304
|
+
lines.push("## This vault's coordinates");
|
|
305
|
+
lines.push("");
|
|
306
|
+
lines.push(`- Name: \`${vaultName}\``);
|
|
307
|
+
if (coordinates.hubOriginKnown) {
|
|
308
|
+
const base = coordinates.hubOrigin.replace(/\/$/, "");
|
|
309
|
+
lines.push(`- REST API base: \`${base}/vault/${vaultName}/api/...\``);
|
|
310
|
+
lines.push(`- MCP endpoint: \`${base}/vault/${vaultName}/mcp\``);
|
|
311
|
+
} else {
|
|
312
|
+
lines.push(
|
|
313
|
+
`- REST API: \`<hub-origin>/vault/${vaultName}/api/...\` — \`<hub-origin>\` is the origin you connected through (the vault has no public origin configured to render here).`,
|
|
314
|
+
);
|
|
315
|
+
lines.push(`- MCP endpoint: \`<hub-origin>/vault/${vaultName}/mcp\``);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// A2: surface the seeded onboarding guide so a connected AI can discover it
|
|
320
|
+
// when setting up or orienting. A pointer only — the AI fetches the body on
|
|
321
|
+
// demand. NOT a mandate to re-read every session; it's a start-here guide.
|
|
322
|
+
if (projection.getting_started) {
|
|
323
|
+
lines.push("");
|
|
324
|
+
lines.push(
|
|
325
|
+
`## Start here\n\nThis vault has a **${projection.getting_started}** guide — how to set it up ` +
|
|
326
|
+
`and grow it (what a Parachute vault is, designing tags vs paths vs schemas, importing ` +
|
|
327
|
+
`existing notes). Read it when setting up or getting oriented, or when the operator asks ` +
|
|
328
|
+
`for help setting up their parachute. Fetch it with \`query-notes { id: "${projection.getting_started}" }\`.`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
256
332
|
lines.push("");
|
|
257
333
|
lines.push("## Quick orientation (call `vault-info` for full schema)");
|
|
258
334
|
lines.push("");
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -107,6 +107,7 @@ import { VAULT_SCOPES } from "./scopes.ts";
|
|
|
107
107
|
import { validateVaultName, decideInitVaultName } from "./vault-name.ts";
|
|
108
108
|
import { decideAutostart } from "./autostart.ts";
|
|
109
109
|
import { getVaultStore } from "./vault-store.ts";
|
|
110
|
+
import { seedOnboardingNotesBestEffort } from "./onboarding-seed.ts";
|
|
110
111
|
import {
|
|
111
112
|
defaultMirrorConfig,
|
|
112
113
|
resolveMirrorPath,
|
|
@@ -3639,7 +3640,14 @@ async function createVault(
|
|
|
3639
3640
|
|
|
3640
3641
|
// Touch the store so the vault's SQLite DB + schema are created. No token
|
|
3641
3642
|
// row is written — vault is a pure hub resource-server post-0.5.0.
|
|
3642
|
-
getVaultStore(name);
|
|
3643
|
+
const seedStore = getVaultStore(name);
|
|
3644
|
+
|
|
3645
|
+
// Seed the in-vault onboarding guide (Getting Started + Surface Starter) so a
|
|
3646
|
+
// connected AI can read it and help the operator set the vault up. Idempotent
|
|
3647
|
+
// + best-effort: only writes notes that are absent, and never fails the
|
|
3648
|
+
// create. Runs BEFORE the mirror bootstrap so the seeded notes land in the
|
|
3649
|
+
// initial mirror commit. See src/onboarding-seed.ts + core/src/onboarding.ts.
|
|
3650
|
+
await seedOnboardingNotesBestEffort(seedStore);
|
|
3643
3651
|
|
|
3644
3652
|
// Default new vaults to an internal live mirror (local git backup of the
|
|
3645
3653
|
// markdown projection). Backup-on-by-default; GitHub off-site backup is an
|
package/src/mcp-tools.ts
CHANGED
|
@@ -100,9 +100,23 @@ export async function getServerInstruction(
|
|
|
100
100
|
vaultName,
|
|
101
101
|
description: config?.description ?? null,
|
|
102
102
|
projection,
|
|
103
|
+
coordinates: resolveVaultCoordinates(),
|
|
103
104
|
});
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Resolve this vault's own public coordinates for the projection. The vault
|
|
109
|
+
* always knows its NAME; its hub origin comes from `resolveHubOrigin()`
|
|
110
|
+
* (PARACHUTE_HUB_ORIGIN → expose-state FQDN → loopback). A loopback source
|
|
111
|
+
* means no public origin is configured — flagged via `hubOriginKnown: false`
|
|
112
|
+
* so the projection renders relative path templates rather than a loopback URL
|
|
113
|
+
* a remote surface-builder can't use.
|
|
114
|
+
*/
|
|
115
|
+
function resolveVaultCoordinates(): { hubOrigin: string; hubOriginKnown: boolean } {
|
|
116
|
+
const { url, source } = resolveHubOrigin();
|
|
117
|
+
return { hubOrigin: url, hubOriginKnown: source !== "loopback" };
|
|
118
|
+
}
|
|
119
|
+
|
|
106
120
|
/**
|
|
107
121
|
* Generate MCP tools scoped to a single vault.
|
|
108
122
|
*
|
|
@@ -482,14 +496,37 @@ function overrideVaultInfo(
|
|
|
482
496
|
const includeStats = Boolean(params.include_stats);
|
|
483
497
|
const projection = buildVaultProjection(store.db, { includeStats });
|
|
484
498
|
|
|
499
|
+
// GAP 3 / vault coordinates: surface the vault's own NAME + REST/MCP URL
|
|
500
|
+
// templates so a surface-builder doesn't have to learn them from the MCP
|
|
501
|
+
// connector config. `base_url` is absolute when the vault knows its public
|
|
502
|
+
// origin (PARACHUTE_HUB_ORIGIN / expose-state); null on a loopback-only
|
|
503
|
+
// box, where `rest_api` / `mcp` carry `<hub-origin>` placeholder templates
|
|
504
|
+
// resolved against whatever origin the client connected through.
|
|
505
|
+
const coords = resolveVaultCoordinates();
|
|
506
|
+
const coordBase = coords.hubOriginKnown ? coords.hubOrigin.replace(/\/$/, "") : "<hub-origin>";
|
|
507
|
+
|
|
485
508
|
const result: Record<string, unknown> = {
|
|
486
509
|
name: config.name,
|
|
487
510
|
description: config.description ?? null,
|
|
511
|
+
coordinates: {
|
|
512
|
+
name: config.name,
|
|
513
|
+
base_url: coords.hubOriginKnown ? `${coordBase}/vault/${config.name}` : null,
|
|
514
|
+
rest_api: `${coordBase}/vault/${config.name}/api`,
|
|
515
|
+
mcp: `${coordBase}/vault/${config.name}/mcp`,
|
|
516
|
+
},
|
|
488
517
|
tags: projection.tags,
|
|
489
518
|
indexed_fields: projection.indexed_fields,
|
|
490
519
|
query_hints: projection.query_hints,
|
|
491
520
|
};
|
|
492
521
|
|
|
522
|
+
// A2: surface a pointer (path, not body) to the seeded onboarding guide so
|
|
523
|
+
// any connected AI is steered to read it first. Present only when the note
|
|
524
|
+
// exists (buildVaultProjection gates on it). Older vaults without one omit
|
|
525
|
+
// the field entirely.
|
|
526
|
+
if (projection.getting_started) {
|
|
527
|
+
result.getting_started = projection.getting_started;
|
|
528
|
+
}
|
|
529
|
+
|
|
493
530
|
if (projection.stats) {
|
|
494
531
|
result.stats = projection.stats;
|
|
495
532
|
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the onboarding-guide seeding (demo-prep Workstream A — A1/A2/A3).
|
|
3
|
+
*
|
|
4
|
+
* Store-direct (no CLI subprocess) so they're fast. Covers:
|
|
5
|
+
* - A1: seedOnboardingNotes writes Getting Started + Surface Starter.
|
|
6
|
+
* - A3: Surface Starter is linked from Getting Started (wikilink).
|
|
7
|
+
* - idempotency: a re-run never clobbers an edited note.
|
|
8
|
+
* - A2: the projection / vault-info pointer steers the AI at Getting Started.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
12
|
+
import { Database } from "bun:sqlite";
|
|
13
|
+
import { mkdirSync, rmSync } from "fs";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
import { tmpdir } from "os";
|
|
16
|
+
import { BunStore } from "./vault-store.ts";
|
|
17
|
+
import { seedOnboardingNotes } from "./onboarding-seed.ts";
|
|
18
|
+
import {
|
|
19
|
+
GETTING_STARTED_PATH,
|
|
20
|
+
SURFACE_STARTER_PATH,
|
|
21
|
+
} from "../core/src/onboarding.ts";
|
|
22
|
+
import {
|
|
23
|
+
buildVaultProjection,
|
|
24
|
+
projectionToMarkdown,
|
|
25
|
+
} from "../core/src/vault-projection.ts";
|
|
26
|
+
|
|
27
|
+
let db: Database;
|
|
28
|
+
let store: BunStore;
|
|
29
|
+
let tmpDir: string;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
tmpDir = join(tmpdir(), `onboarding-seed-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
33
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
34
|
+
db = new Database(join(tmpDir, "test.db"));
|
|
35
|
+
store = new BunStore(db);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
db.close();
|
|
40
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("seedOnboardingNotes (A1/A3)", () => {
|
|
44
|
+
test("seeds Getting Started + Surface Starter on a blank vault", async () => {
|
|
45
|
+
const result = await seedOnboardingNotes(store);
|
|
46
|
+
expect(result.seeded.sort()).toEqual(
|
|
47
|
+
[GETTING_STARTED_PATH, SURFACE_STARTER_PATH].sort(),
|
|
48
|
+
);
|
|
49
|
+
expect(result.skipped).toEqual([]);
|
|
50
|
+
|
|
51
|
+
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
52
|
+
expect(gs).not.toBeNull();
|
|
53
|
+
expect(gs!.content).toContain("# Getting Started");
|
|
54
|
+
// A1 doctrine markers: tags-as-types, the three-axis design vocab, import,
|
|
55
|
+
// and the living-note instruction.
|
|
56
|
+
expect(gs!.content).toContain("Tags = types");
|
|
57
|
+
expect(gs!.content).toContain("Paths = organization");
|
|
58
|
+
expect(gs!.content).toContain("Schemas = typed metadata fields");
|
|
59
|
+
expect(gs!.content).toContain("parachute-vault import");
|
|
60
|
+
expect(gs!.content).toContain("Adapt this note");
|
|
61
|
+
|
|
62
|
+
const ss = await store.getNoteByPath(SURFACE_STARTER_PATH);
|
|
63
|
+
expect(ss).not.toBeNull();
|
|
64
|
+
expect(ss!.content).toContain("# Surface Starter");
|
|
65
|
+
// A3: the two surface packages by name.
|
|
66
|
+
expect(ss!.content).toContain("@openparachute/surface-client");
|
|
67
|
+
expect(ss!.content).toContain("@openparachute/surface-render");
|
|
68
|
+
expect(ss!.content).toContain("createVaultSurface");
|
|
69
|
+
expect(ss!.content).toContain("NoteRenderer");
|
|
70
|
+
|
|
71
|
+
// GAP 2: warn that a surface can't be run/developed from this MCP session.
|
|
72
|
+
expect(ss!.content).toContain("not from this");
|
|
73
|
+
expect(ss!.content.toLowerCase()).toContain("browser");
|
|
74
|
+
|
|
75
|
+
// GAP 1: a real, copy-paste end-to-end snippet — every load-bearing symbol
|
|
76
|
+
// present, verified against the surface-client / surface-render source.
|
|
77
|
+
expect(ss!.content).toContain("clientName"); // the one required createVaultSurface option
|
|
78
|
+
expect(ss!.content).toContain("getClient");
|
|
79
|
+
expect(ss!.content).toContain("queryNotes");
|
|
80
|
+
expect(ss!.content).toContain("handleCallback");
|
|
81
|
+
expect(ss!.content).toContain("resolve="); // NoteRenderer wikilink resolver prop
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("GAP 4: Getting Started shows a concrete update-tag fields example + write gotchas", async () => {
|
|
85
|
+
await seedOnboardingNotes(store);
|
|
86
|
+
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
87
|
+
|
|
88
|
+
// (a) a literal update-tag fields object: field name → { type, enum?, indexed? }.
|
|
89
|
+
expect(gs!.content).toContain("update-tag {");
|
|
90
|
+
expect(gs!.content).toContain('{ type: "string", indexed: true }');
|
|
91
|
+
expect(gs!.content).toContain('enum: ["scheduled", "done"]');
|
|
92
|
+
|
|
93
|
+
// (b) the write-gotchas subsection — each gotcha verified against mcp.ts.
|
|
94
|
+
expect(gs!.content).toContain("## Write gotchas");
|
|
95
|
+
expect(gs!.content).toContain("if_updated_at"); // optimistic concurrency
|
|
96
|
+
expect(gs!.content).toContain("force: true");
|
|
97
|
+
// schema-default back-fill: enum→first value, integer→0.
|
|
98
|
+
expect(gs!.content).toContain("first listed value");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("A3: Getting Started links to Surface Starter via a resolved wikilink", async () => {
|
|
102
|
+
await seedOnboardingNotes(store);
|
|
103
|
+
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
104
|
+
expect(gs!.content).toContain("[[Surface Starter]]");
|
|
105
|
+
|
|
106
|
+
// Wikilink auto-resolution: Getting Started → Surface Starter link row.
|
|
107
|
+
const ss = await store.getNoteByPath(SURFACE_STARTER_PATH);
|
|
108
|
+
const outbound = await store.getLinks(gs!.id, { direction: "outbound" });
|
|
109
|
+
expect(outbound.some((l) => l.targetId === ss!.id)).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("idempotent: a second seed run skips both (does not duplicate)", async () => {
|
|
113
|
+
await seedOnboardingNotes(store);
|
|
114
|
+
const second = await seedOnboardingNotes(store);
|
|
115
|
+
expect(second.seeded).toEqual([]);
|
|
116
|
+
expect(second.skipped.sort()).toEqual(
|
|
117
|
+
[GETTING_STARTED_PATH, SURFACE_STARTER_PATH].sort(),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Exactly one note per path — no duplicates.
|
|
121
|
+
const all = await store.queryNotes({});
|
|
122
|
+
expect(all.filter((n) => n.path === GETTING_STARTED_PATH)).toHaveLength(1);
|
|
123
|
+
expect(all.filter((n) => n.path === SURFACE_STARTER_PATH)).toHaveLength(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("idempotent: never clobbers an operator-edited Getting Started", async () => {
|
|
127
|
+
await seedOnboardingNotes(store);
|
|
128
|
+
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
129
|
+
|
|
130
|
+
// Operator/AI rewrites the note — the living-note case.
|
|
131
|
+
const edited = "# Getting Started\n\nThis vault is for my book research.";
|
|
132
|
+
await store.updateNote(gs!.id, { content: edited });
|
|
133
|
+
|
|
134
|
+
// Re-seed (simulates a re-init / restart): the edit must survive untouched.
|
|
135
|
+
const rerun = await seedOnboardingNotes(store);
|
|
136
|
+
expect(rerun.seeded).toEqual([]);
|
|
137
|
+
const after = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
138
|
+
expect(after!.content).toBe(edited);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("vault-info / projection pointer (A2)", () => {
|
|
143
|
+
test("projection carries getting_started when the note exists", async () => {
|
|
144
|
+
// Absent before seeding → no pointer.
|
|
145
|
+
const before = buildVaultProjection(db);
|
|
146
|
+
expect(before.getting_started).toBeUndefined();
|
|
147
|
+
|
|
148
|
+
await seedOnboardingNotes(store);
|
|
149
|
+
const after = buildVaultProjection(db);
|
|
150
|
+
expect(after.getting_started).toBe(GETTING_STARTED_PATH);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("connect-time markdown brief steers the AI to read Getting Started first", async () => {
|
|
154
|
+
await seedOnboardingNotes(store);
|
|
155
|
+
const projection = buildVaultProjection(db, { includeStats: true });
|
|
156
|
+
const md = projectionToMarkdown({
|
|
157
|
+
vaultName: "fresh",
|
|
158
|
+
description: null,
|
|
159
|
+
projection,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(md).toContain("## Start here");
|
|
163
|
+
expect(md).toContain(GETTING_STARTED_PATH);
|
|
164
|
+
// It's a POINTER, not the embedded body — the fetch command appears, the
|
|
165
|
+
// note's distinctive body copy does not.
|
|
166
|
+
expect(md).toContain(`query-notes { id: "${GETTING_STARTED_PATH}" }`);
|
|
167
|
+
expect(md).not.toContain("Adapt this note");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("no Start-here block on a vault without the guide (back-compat)", async () => {
|
|
171
|
+
const projection = buildVaultProjection(db, { includeStats: true });
|
|
172
|
+
const md = projectionToMarkdown({
|
|
173
|
+
vaultName: "legacy",
|
|
174
|
+
description: null,
|
|
175
|
+
projection,
|
|
176
|
+
});
|
|
177
|
+
expect(md).not.toContain("## Start here");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("vault coordinates in the projection (GAP 3)", () => {
|
|
182
|
+
test("renders absolute REST + MCP URLs when the hub origin is known", () => {
|
|
183
|
+
const projection = buildVaultProjection(db, { includeStats: true });
|
|
184
|
+
const md = projectionToMarkdown({
|
|
185
|
+
vaultName: "research",
|
|
186
|
+
description: null,
|
|
187
|
+
projection,
|
|
188
|
+
coordinates: { hubOrigin: "https://hub.example.com", hubOriginKnown: true },
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(md).toContain("## This vault's coordinates");
|
|
192
|
+
expect(md).toContain("Name: `research`");
|
|
193
|
+
expect(md).toContain("https://hub.example.com/vault/research/api/...");
|
|
194
|
+
expect(md).toContain("https://hub.example.com/vault/research/mcp");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("renders relative templates + connected-origin note when origin is unknown", () => {
|
|
198
|
+
const projection = buildVaultProjection(db, { includeStats: true });
|
|
199
|
+
const md = projectionToMarkdown({
|
|
200
|
+
vaultName: "research",
|
|
201
|
+
description: null,
|
|
202
|
+
projection,
|
|
203
|
+
coordinates: { hubOrigin: "http://127.0.0.1:1940", hubOriginKnown: false },
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(md).toContain("## This vault's coordinates");
|
|
207
|
+
expect(md).toContain("Name: `research`");
|
|
208
|
+
// The loopback URL is NOT leaked; relative templates carry a placeholder.
|
|
209
|
+
expect(md).not.toContain("127.0.0.1");
|
|
210
|
+
expect(md).toContain("<hub-origin>/vault/research/api/...");
|
|
211
|
+
expect(md).toContain("<hub-origin>/vault/research/mcp");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("no coordinates block when coordinates are omitted; name still in the opening line", () => {
|
|
215
|
+
const projection = buildVaultProjection(db, { includeStats: true });
|
|
216
|
+
const md = projectionToMarkdown({ vaultName: "research", description: null, projection });
|
|
217
|
+
// Without coordinates the dedicated block is absent; the vault NAME is still
|
|
218
|
+
// stated in the pre-existing opening line (the always-known coordinate).
|
|
219
|
+
expect(md).toContain('Parachute Vault "research"');
|
|
220
|
+
expect(md).not.toContain("## This vault's coordinates");
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed the in-vault onboarding guide on vault creation.
|
|
3
|
+
*
|
|
4
|
+
* A freshly-created vault gets a `Getting Started` note (AI-legible doctrine)
|
|
5
|
+
* and a linked `Surface Starter` note, so a connected AI can read the guide and
|
|
6
|
+
* help the operator set the vault up — and keep growing it over time.
|
|
7
|
+
*
|
|
8
|
+
* IDEMPOTENT + create-time only: each note is written ONLY when absent. A
|
|
9
|
+
* re-init, restart, or re-run never clobbers a note the operator/AI has since
|
|
10
|
+
* edited. Existing vaults are unaffected — this runs on the create path, not as
|
|
11
|
+
* a sweep over existing vaults.
|
|
12
|
+
*
|
|
13
|
+
* Best-effort + non-fatal: seeding must NEVER fail a vault create. Any error is
|
|
14
|
+
* caught and logged; the vault is still usable without the guide.
|
|
15
|
+
*
|
|
16
|
+
* See the demo-prep Workstream A (A1/A2/A3).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
GETTING_STARTED_PATH,
|
|
21
|
+
GETTING_STARTED_CONTENT,
|
|
22
|
+
SURFACE_STARTER_PATH,
|
|
23
|
+
SURFACE_STARTER_CONTENT,
|
|
24
|
+
} from "../core/src/onboarding.ts";
|
|
25
|
+
import type { Store } from "../core/src/types.ts";
|
|
26
|
+
|
|
27
|
+
interface SeedResult {
|
|
28
|
+
/** Paths actually written this run (absent ones). Empty when both pre-existed. */
|
|
29
|
+
seeded: string[];
|
|
30
|
+
/** Paths skipped because the note already existed (idempotency). */
|
|
31
|
+
skipped: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Write a single onboarding note only if no note already lives at `path`.
|
|
36
|
+
* Returns true when it wrote (was absent), false when it skipped (present).
|
|
37
|
+
*/
|
|
38
|
+
async function seedNoteIfAbsent(
|
|
39
|
+
store: Store,
|
|
40
|
+
path: string,
|
|
41
|
+
content: string,
|
|
42
|
+
): Promise<boolean> {
|
|
43
|
+
const existing = await store.getNoteByPath(path);
|
|
44
|
+
if (existing) return false;
|
|
45
|
+
await store.createNote(content, { path });
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Seed the onboarding notes into `store`, idempotently. Surface Starter is
|
|
51
|
+
* created first so the `[[Surface Starter]]` wikilink in Getting Started
|
|
52
|
+
* resolves immediately (the create path also auto-resolves later, but this
|
|
53
|
+
* keeps the link live from the first write).
|
|
54
|
+
*/
|
|
55
|
+
export async function seedOnboardingNotes(store: Store): Promise<SeedResult> {
|
|
56
|
+
const seeded: string[] = [];
|
|
57
|
+
const skipped: string[] = [];
|
|
58
|
+
|
|
59
|
+
for (const [path, content] of [
|
|
60
|
+
[SURFACE_STARTER_PATH, SURFACE_STARTER_CONTENT],
|
|
61
|
+
[GETTING_STARTED_PATH, GETTING_STARTED_CONTENT],
|
|
62
|
+
] as const) {
|
|
63
|
+
const wrote = await seedNoteIfAbsent(store, path, content);
|
|
64
|
+
(wrote ? seeded : skipped).push(path);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { seeded, skipped };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Best-effort wrapper for the create path: seed the onboarding notes, swallow
|
|
72
|
+
* and log any error so a seeding failure never fails the vault create.
|
|
73
|
+
*/
|
|
74
|
+
export async function seedOnboardingNotesBestEffort(store: Store): Promise<void> {
|
|
75
|
+
try {
|
|
76
|
+
await seedOnboardingNotes(store);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error(
|
|
79
|
+
`Note: onboarding guide not seeded (${(err as Error)?.message ?? err}). ` +
|
|
80
|
+
`The vault was still created; you can connect an AI and ask it to set things up.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { existsSync, rmSync } from "fs";
|
|
|
20
20
|
import { migrateVaultKeys } from "./token-store.ts";
|
|
21
21
|
import { resolveFirstBootVaultName, reservedNameSquatWarnings } from "./vault-name.ts";
|
|
22
22
|
import { getVaultStore, getVaultNameForStore } from "./vault-store.ts";
|
|
23
|
+
import { seedOnboardingNotesBestEffort } from "./onboarding-seed.ts";
|
|
23
24
|
import { defaultHookRegistry } from "../core/src/hooks.ts";
|
|
24
25
|
import { registerTriggers } from "./triggers.ts";
|
|
25
26
|
import { loadVaultTriggers } from "./triggers-api.ts";
|
|
@@ -205,6 +206,10 @@ if (listVaults().length === 0) {
|
|
|
205
206
|
}
|
|
206
207
|
writeGlobalConfig(globalConfig);
|
|
207
208
|
console.log(`Auto-created vault "${vaultName}" (API key: ${fullKey})`);
|
|
209
|
+
// Seed the in-vault onboarding guide so a connected AI can self-orient and
|
|
210
|
+
// help set the vault up — same as the `create`/`init` CLI path. Idempotent
|
|
211
|
+
// + best-effort (never fails first boot). Mirrors createVault() in cli.ts.
|
|
212
|
+
await seedOnboardingNotesBestEffort(getVaultStore(vaultName));
|
|
208
213
|
}
|
|
209
214
|
}
|
|
210
215
|
|
package/src/vault-create.test.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
12
|
+
import { Database } from "bun:sqlite";
|
|
12
13
|
import { resolve } from "path";
|
|
13
14
|
import {
|
|
14
15
|
mkdtempSync,
|
|
@@ -353,6 +354,51 @@ describe("vault create — services.json registration (#208)", () => {
|
|
|
353
354
|
});
|
|
354
355
|
});
|
|
355
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Onboarding-guide seeding on create (demo-prep Workstream A — A1/A3).
|
|
359
|
+
*
|
|
360
|
+
* A freshly-created vault must contain the `Getting Started` + `Surface Starter`
|
|
361
|
+
* notes so a connected AI can read them and help set the vault up. Idempotent +
|
|
362
|
+
* best-effort: the seed never fails a create and never clobbers an edited note.
|
|
363
|
+
*/
|
|
364
|
+
describe("vault create — onboarding guide seeding (A1/A3)", () => {
|
|
365
|
+
/** Read all (path, content) rows from a created vault's SQLite DB. */
|
|
366
|
+
function readNotes(name: string): { path: string | null; content: string }[] {
|
|
367
|
+
const dbPath = join(home, "vault", "data", name, "vault.db");
|
|
368
|
+
const db = new Database(dbPath, { readonly: true });
|
|
369
|
+
try {
|
|
370
|
+
return db
|
|
371
|
+
.query("SELECT path, content FROM notes")
|
|
372
|
+
.all() as { path: string | null; content: string }[];
|
|
373
|
+
} finally {
|
|
374
|
+
db.close();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
test("create seeds Getting Started + Surface Starter notes", () => {
|
|
379
|
+
const { exitCode } = runCli(["create", "guided", "--json"], {
|
|
380
|
+
PARACHUTE_HOME: home,
|
|
381
|
+
});
|
|
382
|
+
expect(exitCode).toBe(0);
|
|
383
|
+
|
|
384
|
+
const notes = readNotes("guided");
|
|
385
|
+
const gs = notes.find((n) => n.path === "Getting Started");
|
|
386
|
+
const ss = notes.find((n) => n.path === "Surface Starter");
|
|
387
|
+
expect(gs).toBeDefined();
|
|
388
|
+
expect(ss).toBeDefined();
|
|
389
|
+
expect(gs!.content).toContain("# Getting Started");
|
|
390
|
+
expect(gs!.content).toContain("[[Surface Starter]]");
|
|
391
|
+
expect(ss!.content).toContain("@openparachute/surface-client");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("seeding doesn't break --json stdout (notes seeded silently)", () => {
|
|
395
|
+
const { stdout } = runCli(["create", "jsonclean", "--json"], {
|
|
396
|
+
PARACHUTE_HOME: home,
|
|
397
|
+
});
|
|
398
|
+
expect(() => JSON.parse(stdout.trim())).not.toThrow();
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
356
402
|
describe("vault create (human mode)", () => {
|
|
357
403
|
test("prints multi-line human output without --json", () => {
|
|
358
404
|
const { exitCode, stdout } = runCli(
|
package/src/vault.test.ts
CHANGED
|
@@ -578,6 +578,13 @@ describe("scoped MCP wrapper", async () => {
|
|
|
578
578
|
const { writeVaultConfig } = await import("./config.ts");
|
|
579
579
|
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
580
580
|
|
|
581
|
+
// The GAP-3 coordinates block reads PARACHUTE_HUB_ORIGIN; another test FILE
|
|
582
|
+
// in a shared `bun test ./src` run may have left it set (cross-file env
|
|
583
|
+
// pollution — e.g. hub-jwt.test.ts). Pin loopback so the coordinates
|
|
584
|
+
// assertion below is deterministic regardless of file order; restore after.
|
|
585
|
+
const prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
|
|
586
|
+
delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
587
|
+
|
|
581
588
|
const vaultName = `proj-${Date.now()}`;
|
|
582
589
|
writeVaultConfig({
|
|
583
590
|
name: vaultName,
|
|
@@ -641,12 +648,24 @@ describe("scoped MCP wrapper", async () => {
|
|
|
641
648
|
expect(Array.isArray(result.query_hints)).toBe(true);
|
|
642
649
|
expect((result.query_hints as string[]).length).toBeGreaterThan(0);
|
|
643
650
|
|
|
651
|
+
// GAP 3 — coordinates block: the vault states its own NAME + REST/MCP URL
|
|
652
|
+
// templates so a surface-builder doesn't reconstruct them from the
|
|
653
|
+
// connector config. No public hub origin in this test fixture → loopback →
|
|
654
|
+
// `<hub-origin>` placeholder templates, `base_url` null.
|
|
655
|
+
const coords = result.coordinates as Record<string, unknown>;
|
|
656
|
+
expect(coords.name).toBe(vaultName);
|
|
657
|
+
expect(coords.rest_api).toBe(`<hub-origin>/vault/${vaultName}/api`);
|
|
658
|
+
expect(coords.mcp).toBe(`<hub-origin>/vault/${vaultName}/mcp`);
|
|
659
|
+
expect(coords.base_url).toBeNull();
|
|
660
|
+
|
|
644
661
|
// stats omitted unless requested
|
|
645
662
|
expect(result.stats).toBeUndefined();
|
|
646
663
|
|
|
647
664
|
const withStats = await vaultInfo.execute({ include_stats: true }) as any;
|
|
648
665
|
expect(withStats.stats).toBeTruthy();
|
|
649
666
|
|
|
667
|
+
if (prevHubOrigin === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
668
|
+
else process.env.PARACHUTE_HUB_ORIGIN = prevHubOrigin;
|
|
650
669
|
closeAllStores();
|
|
651
670
|
});
|
|
652
671
|
|