@openparachute/vault 0.6.5-rc.2 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.parachute/module.json +1 -0
  2. package/core/src/core.test.ts +7 -7
  3. package/core/src/do-param-cap.test.ts +161 -0
  4. package/core/src/links.ts +8 -13
  5. package/core/src/mcp.ts +1 -1
  6. package/core/src/notes.ts +33 -15
  7. package/core/src/onboarding.ts +14 -296
  8. package/core/src/schema.ts +5 -5
  9. package/core/src/seed-packs.test.ts +357 -0
  10. package/core/src/seed-packs.ts +823 -0
  11. package/core/src/sql-in.test.ts +58 -0
  12. package/core/src/sql-in.ts +76 -0
  13. package/core/src/tag-hierarchy.ts +1 -1
  14. package/core/src/tag-schemas.ts +2 -2
  15. package/core/src/transcription/provider.ts +141 -0
  16. package/core/src/types.ts +1 -1
  17. package/core/src/vault-projection.ts +1 -1
  18. package/core/src/wikilinks.ts +10 -4
  19. package/package.json +1 -1
  20. package/src/add-pack.test.ts +142 -0
  21. package/src/admin-spa.ts +2 -1
  22. package/src/auth.ts +1 -1
  23. package/src/cli.ts +806 -7
  24. package/src/export-watch.ts +1 -1
  25. package/src/live-frame-parity.test.ts +201 -0
  26. package/src/module-manifest.ts +8 -0
  27. package/src/onboarding-seed.test.ts +188 -40
  28. package/src/onboarding-seed.ts +41 -46
  29. package/src/routes.ts +20 -3
  30. package/src/routing.test.ts +2 -2
  31. package/src/routing.ts +18 -6
  32. package/src/self-register.test.ts +19 -0
  33. package/src/self-register.ts +6 -1
  34. package/src/server.ts +133 -31
  35. package/src/services-manifest.ts +8 -0
  36. package/src/subscriptions.ts +100 -42
  37. package/src/tag-scope.ts +3 -3
  38. package/src/test-support/live-frame-corpus.ts +72 -0
  39. package/src/token-store.ts +2 -2
  40. package/src/transcription/build.test.ts +305 -0
  41. package/src/transcription/build.ts +332 -0
  42. package/src/transcription/capability.test.ts +118 -0
  43. package/src/transcription/capability.ts +96 -0
  44. package/src/transcription/download.test.ts +101 -0
  45. package/src/transcription/download.ts +71 -0
  46. package/src/transcription/install-python.test.ts +366 -0
  47. package/src/transcription/install-python.ts +471 -0
  48. package/src/transcription/install.test.ts +167 -0
  49. package/src/transcription/install.ts +296 -0
  50. package/src/transcription/providers/onnx-asr.test.ts +229 -0
  51. package/src/transcription/providers/onnx-asr.ts +239 -0
  52. package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
  53. package/src/transcription/providers/parakeet-mlx.ts +242 -0
  54. package/src/transcription/providers/scribe-http.test.ts +195 -0
  55. package/src/transcription/providers/scribe-http.ts +144 -0
  56. package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
  57. package/src/transcription/providers/transcribe-cpp.ts +293 -0
  58. package/src/transcription/select.test.ts +299 -0
  59. package/src/transcription/select.ts +397 -0
  60. package/src/transcription/tiers.test.ts +197 -0
  61. package/src/transcription/tiers.ts +184 -0
  62. package/src/transcription-worker.test.ts +44 -0
  63. package/src/transcription-worker.ts +57 -122
  64. package/src/vault-create.test.ts +43 -10
  65. package/src/vault.test.ts +49 -1
  66. package/src/ws-server.ts +408 -0
  67. package/src/ws-subscribe.test.ts +474 -0
  68. package/src/ws-subscribe.ts +242 -0
  69. package/src/subscribe.test.ts +0 -609
  70. package/src/subscribe.ts +0 -248
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Unit coverage for the DO-safe IN-list helpers (sql-in.ts).
3
+ *
4
+ * These are the cheap, backing-store-independent guards: a regression that
5
+ * raises the chunk size above the Cloudflare Durable Object 100-bound-param
6
+ * cap — the exact class that 500'd cloud-vault exports — fails HERE, without
7
+ * needing a live DO. The behavioral proof over a real >100-note vault lives
8
+ * in do-param-cap.test.ts; the ultimate DO-constraint proof is the cloud
9
+ * staging export re-verify (bun:sqlite tolerates 999 params, so it can't
10
+ * reproduce the DO failure locally).
11
+ */
12
+ import { describe, it, expect } from "bun:test";
13
+ import { IN_PARAM_CHUNK, chunkForInClause, IN_VIA_JSON_EACH, jsonEachParam } from "./sql-in.js";
14
+
15
+ describe("IN_PARAM_CHUNK", () => {
16
+ it("stays at or under the DO 100-bound-param cap (regression guard)", () => {
17
+ // The whole point: never let the chunk size drift back over the cap.
18
+ expect(IN_PARAM_CHUNK).toBeLessThanOrEqual(100);
19
+ expect(IN_PARAM_CHUNK).toBeGreaterThan(0);
20
+ });
21
+ });
22
+
23
+ describe("chunkForInClause", () => {
24
+ it("splits 250 ids into chunks that are each <= the chunk size", () => {
25
+ const ids = Array.from({ length: 250 }, (_, i) => `id${i}`);
26
+ const chunks = chunkForInClause(ids);
27
+ // 250 / 90 -> 3 chunks (90, 90, 70)
28
+ expect(chunks.length).toBe(3);
29
+ for (const c of chunks) expect(c.length).toBeLessThanOrEqual(IN_PARAM_CHUNK);
30
+ expect(chunks.map((c) => c.length)).toEqual([90, 90, 70]);
31
+ // Every id is present exactly once, in order.
32
+ expect(chunks.flat()).toEqual(ids);
33
+ });
34
+
35
+ it("returns no chunks for empty input", () => {
36
+ expect(chunkForInClause([])).toEqual([]);
37
+ });
38
+
39
+ it("returns a single chunk when input fits", () => {
40
+ const ids = ["a", "b", "c"];
41
+ expect(chunkForInClause(ids)).toEqual([["a", "b", "c"]]);
42
+ });
43
+
44
+ it("honors an explicit smaller size", () => {
45
+ const chunks = chunkForInClause([1, 2, 3, 4, 5], 2);
46
+ expect(chunks).toEqual([[1, 2], [3, 4], [5]]);
47
+ });
48
+ });
49
+
50
+ describe("json_each single-param IN", () => {
51
+ it("serializes an id-set to one bound JSON param", () => {
52
+ const ids = ["a", "b", "c"];
53
+ expect(jsonEachParam(ids)).toBe('["a","b","c"]');
54
+ // The fragment carries exactly one placeholder regardless of set size.
55
+ expect(IN_VIA_JSON_EACH).toContain("json_each(?)");
56
+ expect((IN_VIA_JSON_EACH.match(/\?/g) ?? []).length).toBe(1);
57
+ });
58
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * SQL `IN`-list helpers sized for the smallest backing SQLite we run on.
3
+ *
4
+ * ## The DO 100-bound-parameter cap
5
+ *
6
+ * Cloudflare Durable Object SQLite (the cloud vault's backing store) rejects
7
+ * any statement carrying **more than 100 bound parameters** with
8
+ * `too many SQL variables`. Standard SQLite — including bun:sqlite (self-host)
9
+ * — tolerates 999 on old builds and 32766 on new ones, which is why an
10
+ * `IN (?, ?, …)` list sized for the 999 floor shipped fine on self-host and
11
+ * only 500'd on cloud vaults: a >100-note export page passed >100 ids in one
12
+ * statement and DO SQLite rejected it. See vault export-500 diagnosis.
13
+ *
14
+ * Two DO-safe shapes, depending on whether the id-list is the *only* thing
15
+ * in the statement:
16
+ *
17
+ * 1. **Standalone id-list** (its own `SELECT … WHERE id IN (…)`, no other
18
+ * params, no pagination): chunk at {@link IN_PARAM_CHUNK} and union the
19
+ * results in JS. Use {@link chunkForInClause}.
20
+ *
21
+ * 2. **Embedded id-set** (an `IN` filter inside a larger statement that also
22
+ * carries `ORDER BY` / `LIMIT` / `OFFSET` / other params — chunking would
23
+ * break the shared pagination window): bind the whole set as a SINGLE JSON
24
+ * array param via {@link IN_VIA_JSON_EACH} + {@link jsonEachParam}. One
25
+ * param regardless of set size, so it can never trip the cap. Requires the
26
+ * JSON1 extension, which both bun:sqlite and DO SQLite ship (the vault
27
+ * already relies on `json_extract` on DO for metadata queries).
28
+ *
29
+ * ## Future refinement — `maxBoundParams`
30
+ *
31
+ * The clean long-term shape is for the Store / DB shim to advertise a
32
+ * `maxBoundParams` (DO = 100, bun = 999) and chunk to
33
+ * `min(IN_PARAM_CHUNK, maxBoundParams - reservedParams)`, so self-host keeps
34
+ * large-batch efficiency instead of paying the DO tax. Until then a global
35
+ * 90 is SAFE on bun — it just issues more cheap, fully-indexed IN queries —
36
+ * and is the conservative floor everywhere.
37
+ */
38
+
39
+ /**
40
+ * Chunk size for standalone `IN (?, ?, …)` id-lists. 90 leaves headroom
41
+ * under the DO 100-param cap for statements whose only bound params are the
42
+ * IN-list (the extra 10 covers the rare embedded extra param). Never raise
43
+ * this above 100 without teaching every embedded callsite the DO cap — see
44
+ * the module header.
45
+ */
46
+ export const IN_PARAM_CHUNK = 90;
47
+
48
+ /**
49
+ * Split `items` into consecutive chunks of at most `size` (default
50
+ * {@link IN_PARAM_CHUNK}) for feeding to a chunked `IN (?, …)` query. Empty
51
+ * input yields no chunks. This is the single place that decides IN-list chunk
52
+ * boundaries, so a regression that raises the size is caught by one test.
53
+ */
54
+ export function chunkForInClause<T>(items: readonly T[], size = IN_PARAM_CHUNK): T[][] {
55
+ const chunks: T[][] = [];
56
+ for (let i = 0; i < items.length; i += size) {
57
+ chunks.push(items.slice(i, i + size));
58
+ }
59
+ return chunks;
60
+ }
61
+
62
+ /**
63
+ * SQL fragment for a single-param `IN` filter over an id-set embedded in a
64
+ * larger statement: `WHERE col IN ${IN_VIA_JSON_EACH}`. Bind the matching
65
+ * `?` with {@link jsonEachParam}. json_each expands the bound JSON array into
66
+ * a one-column table, so the whole set costs exactly ONE bound parameter and
67
+ * can never exceed the DO cap. Callers must guarantee the set is non-empty
68
+ * (`json_each('[]')` yields no rows — correct for `IN`, but an empty set is
69
+ * usually short-circuited to a `0 = 1` no-match upstream to skip the query).
70
+ */
71
+ export const IN_VIA_JSON_EACH = "(SELECT value FROM json_each(?))";
72
+
73
+ /** Serialize an id-set for the single {@link IN_VIA_JSON_EACH} bound param. */
74
+ export function jsonEachParam(values: readonly (string | number)[]): string {
75
+ return JSON.stringify(values);
76
+ }
@@ -9,7 +9,7 @@
9
9
  * History: pre-v14 vaults stored hierarchy in notes-as-config at
10
10
  * `_tags/<name>`. The v14 migration (see core/src/schema.ts:migrateToV14)
11
11
  * lifts those parent declarations onto the tags row and the resolver here
12
- * was swapped accordingly. See parachute-patterns/patterns/tag-data-model.md.
12
+ * was swapped accordingly. See docs/contracts/tag-data-model.md.
13
13
  *
14
14
  * Resolution model:
15
15
  * - Lazy: built on first access, cached on the store.
@@ -4,7 +4,7 @@
4
4
  * Each tag row carries: human-readable description, indexed metadata field
5
5
  * declarations (`fields`), typed-link relationship declarations
6
6
  * (`relationships`), and the hierarchy parent list (`parent_names`).
7
- * See parachute-patterns/patterns/tag-data-model.md.
7
+ * See docs/contracts/tag-data-model.md.
8
8
  *
9
9
  * This module retains the historical `tag-schemas` filename and exports
10
10
  * (`TagSchema`, `listTagSchemas`, `getTagSchema`, `upsertTagSchema`,
@@ -51,7 +51,7 @@ export interface TagFieldSchema {
51
51
  * `{ target_tag, cardinality }` declaration — but `relationships` is now an
52
52
  * opaque vocabulary map (see `TagRelationshipMap` / `validateRelationships`),
53
53
  * so this is one valid value shape among many, not a required one.
54
- * See patterns/tag-data-model.md §Relationships.
54
+ * See docs/contracts/tag-data-model.md §Relationships.
55
55
  */
56
56
  export type TagRelCardinality = "one" | "optional" | "many" | "many-required";
57
57
 
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Transcription provider seam (scribe-fold Phase 1).
3
+ *
4
+ * A `TranscriptionProvider` is the single, runtime-agnostic contract for
5
+ * turning an audio blob into text. It exists so vault's transcription worker
6
+ * doesn't hard-code "POST to scribe" — the worker resolves a provider and
7
+ * calls `transcribe()`, and the provider owns HOW the bytes become text.
8
+ *
9
+ * ## Why this lives in core (not src)
10
+ *
11
+ * The interface must be importable from BOTH runtimes without dragging in
12
+ * Bun- or Node-only APIs:
13
+ *
14
+ * - the Bun vault (`src/transcription/providers/scribe-http.ts` — the
15
+ * whisper-compatible remote provider that reproduces today's scribe call);
16
+ * - the cloud vault (a future Cloudflare Workers-AI provider implemented
17
+ * against THIS interface for the metered voice build).
18
+ *
19
+ * So it is pure types + one dependency-free error class — no `fs`, no
20
+ * `FormData` serialization, no `fetch`. `audio` arrives as a `Uint8Array`
21
+ * (both runtimes have it); a provider decides how to ship it. Core has no
22
+ * package `exports` map, so consumers import the src subpath directly
23
+ * (`../core/src/transcription/provider.ts`), the same convention as
24
+ * `core/src/seed-packs.ts`.
25
+ *
26
+ * ## The fold (context)
27
+ *
28
+ * Ratified 2026-07-03 (Aaron): scribe folds into vault as a built-in
29
+ * transcription feature. Phase 1 (this seam) is behavior-preserving — the
30
+ * default `scribe-http` provider does EXACTLY what today's `callScribe` did,
31
+ * so existing scribe installs keep working unchanged. Phase 2 moves local-ASR
32
+ * backends behind the same interface. See
33
+ * `design/2026-07-03-transcription-provider-seam.md`.
34
+ */
35
+
36
+ /**
37
+ * A single context entry attached alongside the audio (person/project notes,
38
+ * etc.). Structurally identical to `src/context.ts`'s `ContextEntry` — the
39
+ * same shape scribe already consumes over the `context` multipart part — so a
40
+ * `ContextPayload` produced there is assignable here without conversion. The
41
+ * interface stays runtime-agnostic: a provider decides whether/how to
42
+ * serialize it.
43
+ */
44
+ export interface TranscriptionContextEntry {
45
+ /** Note path basename, or note id if no path. */
46
+ name: string;
47
+ /** Whitelisted metadata fields carried through from the vault query. */
48
+ [key: string]: unknown;
49
+ }
50
+
51
+ /** The `{ entries: [...] }` context blob a provider may forward with the audio. */
52
+ export interface TranscriptionContext {
53
+ entries: TranscriptionContextEntry[];
54
+ }
55
+
56
+ /** The audio + descriptors handed to a provider for one transcription. */
57
+ export interface TranscribeInput {
58
+ /** Raw audio bytes. Both runtimes expose `Uint8Array`; a `Buffer` is one. */
59
+ audio: Uint8Array;
60
+ /** Original filename (used for the multipart `file` part / provider hints). */
61
+ filename: string;
62
+ /** MIME type of the audio (e.g. `audio/webm`). */
63
+ mimeType: string;
64
+ /**
65
+ * Optional vault-provided context (person/project notes) to forward with the
66
+ * audio so the provider/model can use it. `null`/`undefined` means none.
67
+ */
68
+ context?: TranscriptionContext | null;
69
+ }
70
+
71
+ /** What a provider returns on a successful transcription. */
72
+ export interface TranscribeResult {
73
+ /** The transcript text. */
74
+ text: string;
75
+ /**
76
+ * Duration of the transcribed AUDIO in seconds, when the provider reports it.
77
+ * This is a metered/plan concern (the cloud voice build will decrement a
78
+ * minutes balance by it); self-host providers that don't measure it leave it
79
+ * unset. NOT the wall-clock request time — the worker measures that itself.
80
+ */
81
+ audioSeconds?: number;
82
+ }
83
+
84
+ /** Provider readiness probe result. */
85
+ export interface ProviderAvailability {
86
+ /** True when the provider can service `transcribe()` calls right now. */
87
+ ok: boolean;
88
+ /** Human-readable cause when `ok` is false (e.g. "no scribe URL configured"). */
89
+ reason?: string;
90
+ }
91
+
92
+ /**
93
+ * The transcription contract. Implementations own the audio→text step; the
94
+ * worker owns queueing, backoff/retry, transcript-note materialization, and
95
+ * retention — none of which belong here.
96
+ */
97
+ export interface TranscriptionProvider {
98
+ /** Stable provider identifier, surfaced on the capability flag (e.g. "scribe-http"). */
99
+ readonly name: string;
100
+ /** Turn audio into text (or throw — see `TranscriptionError` for the retry contract). */
101
+ transcribe(input: TranscribeInput): Promise<TranscribeResult>;
102
+ /**
103
+ * Cheap, side-effect-free readiness check. MUST NOT perform the actual
104
+ * transcription or a network round-trip unless that is genuinely cheap —
105
+ * it gates the capability flag on the vault landing, called on config reads.
106
+ */
107
+ available(): Promise<ProviderAvailability>;
108
+ }
109
+
110
+ /**
111
+ * Structured, provider-agnostic transcription failure.
112
+ *
113
+ * The worker's terminal-vs-retry decision keys off `retriable`: a
114
+ * non-retriable error (a 4xx the operator must fix — no provider configured,
115
+ * bad auth) fails immediately with no backoff; a retriable one (5xx, transient
116
+ * upstream) is retried with exponential backoff. `code` is a stable machine
117
+ * string (e.g. `missing_provider`) surfaced on the transcript note's
118
+ * frontmatter and the attachment's `transcribe_error_code` so callers branch
119
+ * on it instead of regex-matching message text.
120
+ *
121
+ * Providers throw this for structured failures; a plain `Error` (network drop,
122
+ * timeout, malformed response) is treated as retriable by the worker.
123
+ */
124
+ export class TranscriptionError extends Error {
125
+ /** Stable machine code (e.g. "missing_provider"), when the provider supplied one. */
126
+ readonly code?: string;
127
+ /** Upstream HTTP status, when the failure came from an HTTP provider. */
128
+ readonly httpStatus?: number;
129
+ /** Whether retrying the same audio could plausibly succeed. */
130
+ readonly retriable: boolean;
131
+ constructor(
132
+ message: string,
133
+ opts: { code?: string; httpStatus?: number; retriable: boolean },
134
+ ) {
135
+ super(message);
136
+ this.name = "TranscriptionError";
137
+ this.code = opts.code;
138
+ this.httpStatus = opts.httpStatus;
139
+ this.retriable = opts.retriable;
140
+ }
141
+ }
package/core/src/types.ts CHANGED
@@ -406,7 +406,7 @@ export interface Store {
406
406
 
407
407
  // Tag records — full v14 identity row (description + fields + typed
408
408
  // relationships + parent_names + timestamps). See
409
- // parachute-patterns/patterns/tag-data-model.md.
409
+ // docs/contracts/tag-data-model.md.
410
410
  listTagRecords(): Promise<TagRecord[]>;
411
411
  getTagRecord(tag: string): Promise<TagRecord | null>;
412
412
  /**
@@ -33,7 +33,7 @@ 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";
36
+ import { GETTING_STARTED_PATH } from "./seed-packs.ts";
37
37
 
38
38
  /**
39
39
  * Does a note live at the seeded onboarding path? Used to gate the "Start here"
@@ -1,5 +1,6 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import * as linkOps from "./links.js";
3
+ import { chunkForInClause } from "./sql-in.js";
3
4
 
4
5
  // ---------------------------------------------------------------------------
5
6
  // Parser — extract [[wikilinks]] from markdown content
@@ -272,10 +273,15 @@ export function listUnresolvedWikilinks(db: Database, limit = 50): { unresolved:
272
273
  if (rows.length === 0) return { unresolved: [], count: total };
273
274
 
274
275
  const sourceIds = [...new Set(rows.map((r) => r.source_id))];
275
- const placeholders = sourceIds.map(() => "?").join(", ");
276
- const pathRows = db.prepare(
277
- `SELECT id, path FROM notes WHERE id IN (${placeholders})`,
278
- ).all(...sourceIds) as { id: string; path: string | null }[];
276
+ // Chunk under the DO 100-bound-param cap (see sql-in.ts) with a large
277
+ // `limit` this hydrates >100 source ids in one IN-list otherwise.
278
+ const pathRows: { id: string; path: string | null }[] = [];
279
+ for (const chunk of chunkForInClause(sourceIds)) {
280
+ const placeholders = chunk.map(() => "?").join(", ");
281
+ pathRows.push(...db.prepare(
282
+ `SELECT id, path FROM notes WHERE id IN (${placeholders})`,
283
+ ).all(...chunk) as { id: string; path: string | null }[]);
284
+ }
279
285
  const pathMap = new Map(pathRows.map((r) => [r.id, r.path]));
280
286
 
281
287
  const unresolved: UnresolvedWikilink[] = rows.map((r) => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.6.5-rc.2",
3
+ "version": "0.6.5",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Integration tests for `parachute-vault add-pack <pack> [--vault <name>]`.
3
+ *
4
+ * Spawns the CLI in a temp `PARACHUTE_HOME` (same harness shape as
5
+ * vault-create.test.ts). Covers: listing on no-arg / unknown pack, applying
6
+ * `surface-starter` onto a default-seeded vault, idempotent re-runs, the
7
+ * missing-vault error, and `--vault` targeting.
8
+ *
9
+ * No in-test `Bun.serve` is involved, so `Bun.spawnSync` is fine here (see
10
+ * CLAUDE.md "Subprocess tests + Bun.serve").
11
+ */
12
+
13
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
14
+ import { Database } from "bun:sqlite";
15
+ import { resolve } from "path";
16
+ import { mkdtempSync, rmSync } from "fs";
17
+ import { tmpdir } from "os";
18
+ import { join } from "path";
19
+
20
+ const CLI = resolve(import.meta.dir, "cli.ts");
21
+
22
+ function runCli(
23
+ args: string[],
24
+ env: Record<string, string>,
25
+ ): { exitCode: number; stdout: string; stderr: string } {
26
+ // Hermetic: don't inherit the dev/CI box's PARACHUTE_HUB_ORIGIN (see
27
+ // vault-create.test.ts for the rationale).
28
+ const baseEnv: Record<string, string | undefined> = { ...process.env };
29
+ delete baseEnv.PARACHUTE_HUB_ORIGIN;
30
+ const proc = Bun.spawnSync({
31
+ cmd: ["bun", CLI, ...args],
32
+ stdout: "pipe",
33
+ stderr: "pipe",
34
+ env: { ...baseEnv, ...env },
35
+ });
36
+ return {
37
+ exitCode: proc.exitCode ?? -1,
38
+ stdout: new TextDecoder().decode(proc.stdout),
39
+ stderr: new TextDecoder().decode(proc.stderr),
40
+ };
41
+ }
42
+
43
+ /** Read all note paths from a vault's SQLite DB inside the temp home. */
44
+ function readNotePaths(home: string, name: string): (string | null)[] {
45
+ const db = new Database(join(home, "vault", "data", name, "vault.db"), {
46
+ readonly: true,
47
+ });
48
+ try {
49
+ return (db.query("SELECT path FROM notes").all() as { path: string | null }[]).map(
50
+ (r) => r.path,
51
+ );
52
+ } finally {
53
+ db.close();
54
+ }
55
+ }
56
+
57
+ let home: string;
58
+
59
+ beforeEach(() => {
60
+ home = mkdtempSync(join(tmpdir(), "add-pack-test-"));
61
+ });
62
+
63
+ afterEach(() => {
64
+ rmSync(home, { recursive: true, force: true });
65
+ });
66
+
67
+ describe("add-pack — listing + errors", () => {
68
+ test("no arguments → usage + the three packs, exit 1", () => {
69
+ const { exitCode, stderr } = runCli(["add-pack"], { PARACHUTE_HOME: home });
70
+ expect(exitCode).toBe(1);
71
+ expect(stderr).toContain("Usage: parachute-vault add-pack");
72
+ expect(stderr).toContain("Available packs:");
73
+ expect(stderr).toContain("welcome");
74
+ expect(stderr).toContain("getting-started");
75
+ expect(stderr).toContain("surface-starter");
76
+ });
77
+
78
+ test("unknown pack → error + listing, exit 1", () => {
79
+ const { exitCode, stderr } = runCli(["add-pack", "does-not-exist"], {
80
+ PARACHUTE_HOME: home,
81
+ });
82
+ expect(exitCode).toBe(1);
83
+ expect(stderr).toContain('Unknown pack: "does-not-exist"');
84
+ expect(stderr).toContain("Available packs:");
85
+ });
86
+
87
+ test("missing vault → actionable error, exit 1", () => {
88
+ const { exitCode, stderr } = runCli(
89
+ ["add-pack", "surface-starter", "--vault", "ghost"],
90
+ { PARACHUTE_HOME: home },
91
+ );
92
+ expect(exitCode).toBe(1);
93
+ expect(stderr).toContain('Vault "ghost" not found');
94
+ expect(stderr).toContain("parachute-vault create ghost");
95
+ });
96
+ });
97
+
98
+ describe("add-pack surface-starter", () => {
99
+ test("applies onto a default-seeded vault and reports what was added", () => {
100
+ expect(runCli(["create", "packed", "--json"], { PARACHUTE_HOME: home }).exitCode).toBe(0);
101
+ // Default seed does NOT include Surface Starter.
102
+ expect(readNotePaths(home, "packed")).not.toContain("Surface Starter");
103
+
104
+ const { exitCode, stdout } = runCli(
105
+ ["add-pack", "surface-starter", "--vault", "packed"],
106
+ { PARACHUTE_HOME: home },
107
+ );
108
+ expect(exitCode).toBe(0);
109
+ expect(stdout).toContain('Pack "surface-starter" applied to vault "packed"');
110
+ expect(stdout).toContain("+ note Surface Starter");
111
+ expect(readNotePaths(home, "packed")).toContain("Surface Starter");
112
+ });
113
+
114
+ test("idempotent: a re-run skips (exit 0) and doesn't duplicate", () => {
115
+ runCli(["create", "packed", "--json"], { PARACHUTE_HOME: home });
116
+ runCli(["add-pack", "surface-starter", "--vault", "packed"], { PARACHUTE_HOME: home });
117
+
118
+ const { exitCode, stdout } = runCli(
119
+ ["add-pack", "surface-starter", "--vault", "packed"],
120
+ { PARACHUTE_HOME: home },
121
+ );
122
+ expect(exitCode).toBe(0);
123
+ expect(stdout).toContain("= note Surface Starter (already exists — left untouched)");
124
+ expect(stdout).not.toContain("+ note");
125
+
126
+ const paths = readNotePaths(home, "packed");
127
+ expect(paths.filter((p) => p === "Surface Starter")).toHaveLength(1);
128
+ });
129
+
130
+ test("re-applying a default pack is a clean no-op-style run (welcome on a fresh vault)", () => {
131
+ runCli(["create", "packed", "--json"], { PARACHUTE_HOME: home });
132
+ const { exitCode, stdout } = runCli(
133
+ ["add-pack", "welcome", "--vault", "packed"],
134
+ { PARACHUTE_HOME: home },
135
+ );
136
+ expect(exitCode).toBe(0);
137
+ // Notes were already default-seeded → all skipped; tags re-upsert.
138
+ expect(stdout).not.toContain("+ note");
139
+ expect(stdout).toContain("= note Welcome to your vault 🪂 (already exists — left untouched)");
140
+ expect(stdout).toContain("~ tag capture (upserted)");
141
+ });
142
+ });
package/src/admin-spa.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  *
9
9
  * Per-vault mount (vault#252): the SPA lives under `/vault/<name>/admin/*`
10
10
  * rather than the origin-rooted `/admin/*` it shipped with. The hub only
11
- * proxies `/vault/<name>/*` paths (per parachute-patterns/module-protocol),
11
+ * proxies `/vault/<name>/*` paths (per the module protocol
12
+ * https://github.com/ParachuteComputer/parachute-hub/blob/main/docs/contracts/module-protocol.md),
12
13
  * so an origin-rooted SPA is unreachable through the hub. Asset URLs are
13
14
  * relative (Vite `base: "./"`), so the same bundle works at any mount
14
15
  * point — no rebuild per vault.
package/src/auth.ts CHANGED
@@ -123,7 +123,7 @@ export interface AuthResult {
123
123
  * Tag-allowlist (root tag names) for tag-scoped tokens. NULL = unscoped
124
124
  * (current full-vault behavior). Hub-issued JWTs and legacy YAML keys
125
125
  * always have null — tag scoping is a per-token vault-DB attribute, not
126
- * an OAuth-claim concern. See patterns/tag-scoped-tokens.md.
126
+ * an OAuth-claim concern. See docs/contracts/tag-scoped-tokens.md.
127
127
  */
128
128
  scoped_tags: string[] | null;
129
129
  /**