@openparachute/vault 0.6.5-rc.2 → 0.6.5-rc.9
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/do-param-cap.test.ts +161 -0
- package/core/src/links.ts +8 -13
- package/core/src/notes.ts +33 -15
- package/core/src/onboarding.ts +14 -296
- package/core/src/seed-packs.test.ts +191 -0
- package/core/src/seed-packs.ts +559 -0
- package/core/src/sql-in.test.ts +58 -0
- package/core/src/sql-in.ts +76 -0
- package/core/src/transcription/provider.ts +141 -0
- package/core/src/vault-projection.ts +1 -1
- package/core/src/wikilinks.ts +10 -4
- package/package.json +1 -1
- package/src/add-pack.test.ts +142 -0
- package/src/cli.ts +542 -7
- package/src/onboarding-seed.test.ts +126 -40
- package/src/onboarding-seed.ts +41 -46
- package/src/routes.ts +17 -0
- package/src/server.ts +77 -31
- package/src/transcription/build.test.ts +224 -0
- package/src/transcription/build.ts +252 -0
- package/src/transcription/capability.test.ts +93 -0
- package/src/transcription/capability.ts +71 -0
- package/src/transcription/install.test.ts +167 -0
- package/src/transcription/install.ts +296 -0
- package/src/transcription/providers/scribe-http.test.ts +195 -0
- package/src/transcription/providers/scribe-http.ts +144 -0
- package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
- package/src/transcription/providers/transcribe-cpp.ts +293 -0
- package/src/transcription/select.test.ts +134 -0
- package/src/transcription/select.ts +164 -0
- package/src/transcription-worker.test.ts +44 -0
- package/src/transcription-worker.ts +57 -122
- package/src/vault-create.test.ts +38 -10
- package/src/vault.test.ts +48 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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 "./
|
|
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"
|
package/core/src/wikilinks.ts
CHANGED
|
@@ -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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
@@ -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
|
+
});
|