@openparachute/vault 0.6.4-rc.6 → 0.6.4-rc.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -586,16 +586,12 @@ Range params require content in the response — with `include_content=false` (o
586
586
 
587
587
  ### Incremental rebuilds: "what changed since X"
588
588
 
589
- The SSG / sync pattern. Two equivalent forms — bracket-style is canonical going forward; the flat form is the same shape that ships through the REST/MCP date filter today.
589
+ The SSG / sync pattern. Bracket-style is the query-string date filter. (The flat `date_field` / `date_from` / `date_to` params were removed in 0.6.4 vault#288 — and are now ignored.)
590
590
 
591
591
  ```bash
592
- # Bracket-style (canonical)
592
+ # Bracket-style (the query-string date filter)
593
593
  curl -H "Authorization: Bearer $VAULT_TOKEN" \
594
594
  "http://localhost:1940/vault/default/api/notes?meta[updated_at][gte]=2026-04-01T00:00:00Z"
595
-
596
- # Flat form (DEPRECATED in 0.4.3; planned removal in a later 0.x per vault#288)
597
- curl -H "Authorization: Bearer $VAULT_TOKEN" \
598
- "http://localhost:1940/vault/default/api/notes?date_field=updated_at&date_from=2026-04-01T00:00:00Z"
599
595
  ```
600
596
 
601
597
  ```jsonc
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.6.4-rc.6",
3
+ "version": "0.6.4-rc.8",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -110,11 +110,17 @@ import { getVaultStore } from "./vault-store.ts";
110
110
  import { seedOnboardingNotesBestEffort } from "./onboarding-seed.ts";
111
111
  import {
112
112
  defaultMirrorConfig,
113
+ readMirrorConfigForVault,
113
114
  resolveMirrorPath,
114
115
  writeMirrorConfigForVault,
115
116
  type MirrorConfig,
116
117
  } from "./mirror-config.ts";
117
- import { bootstrapInternalMirror } from "./mirror-manager.ts";
118
+ import {
119
+ bootstrapInternalMirror,
120
+ readMirrorHistory,
121
+ showMirrorRevision,
122
+ } from "./mirror-manager.ts";
123
+ import { GitNotInstalledError, ensureGitAvailable } from "./git-preflight.ts";
118
124
  import { selfRegister } from "./self-register.ts";
119
125
  import {
120
126
  hasOwnerPassword,
@@ -234,6 +240,9 @@ switch (command) {
234
240
  case "export":
235
241
  await cmdExport(cmdArgs);
236
242
  break;
243
+ case "history":
244
+ await cmdHistory(cmdArgs);
245
+ break;
237
246
  case "schema":
238
247
  await cmdSchema(cmdArgs);
239
248
  break;
@@ -3330,6 +3339,163 @@ async function cmdExport(args: string[]) {
3330
3339
  await new Promise(() => {});
3331
3340
  }
3332
3341
 
3342
+ // ---------------------------------------------------------------------------
3343
+ // `parachute-vault history` — surface the vault's git write history (vault#300)
3344
+ // ---------------------------------------------------------------------------
3345
+
3346
+ /**
3347
+ * `parachute-vault history [--note <path>] [--limit N] [--vault <name>] [--json]`
3348
+ *
3349
+ * Surfaces the mirror's git commit log — the vault is already git-backed
3350
+ * (one file per note), so `git log` IS a tamper-evident, diffable write
3351
+ * history. This CLI front-door wraps the SAME `readMirrorHistory` helper the
3352
+ * REST `/history` endpoint uses (no drift between the two surfaces).
3353
+ *
3354
+ * `--note <path>` scopes to a single note's history (`git log --follow --
3355
+ * <path>.md`). `--show <sha>` (with `--note`) prints that note's content at
3356
+ * a past revision (`git show <sha>:<path>.md`).
3357
+ */
3358
+ async function cmdHistory(args: string[]) {
3359
+ let vaultName = "default";
3360
+ let notePath: string | undefined;
3361
+ let limit: number | undefined;
3362
+ let showSha: string | undefined;
3363
+ let asJson = false;
3364
+
3365
+ for (let i = 0; i < args.length; i++) {
3366
+ const arg = args[i]!;
3367
+ if (arg === "--vault") {
3368
+ const v = args[++i];
3369
+ if (!v) {
3370
+ console.error("--vault requires a value.");
3371
+ process.exit(1);
3372
+ }
3373
+ vaultName = v;
3374
+ } else if (arg === "--note") {
3375
+ const v = args[++i];
3376
+ if (!v) {
3377
+ console.error("--note requires a note path.");
3378
+ process.exit(1);
3379
+ }
3380
+ notePath = v;
3381
+ } else if (arg === "--limit") {
3382
+ const v = args[++i];
3383
+ if (!v) {
3384
+ console.error("--limit requires a number.");
3385
+ process.exit(1);
3386
+ }
3387
+ const n = Number(v);
3388
+ if (!Number.isInteger(n) || n <= 0) {
3389
+ console.error(`--limit: must be a positive integer (got '${v}')`);
3390
+ process.exit(1);
3391
+ }
3392
+ limit = n;
3393
+ } else if (arg === "--show") {
3394
+ const v = args[++i];
3395
+ if (!v) {
3396
+ console.error("--show requires a commit sha.");
3397
+ process.exit(1);
3398
+ }
3399
+ showSha = v;
3400
+ } else if (arg === "--json") {
3401
+ asJson = true;
3402
+ } else if (arg === "--help" || arg === "-h") {
3403
+ printHistoryUsage();
3404
+ return;
3405
+ } else {
3406
+ console.error(`Unknown argument: ${arg}`);
3407
+ printHistoryUsage();
3408
+ process.exit(1);
3409
+ }
3410
+ }
3411
+
3412
+ if (showSha && !notePath) {
3413
+ console.error("--show <sha> requires --note <path> (which file to read at that revision).");
3414
+ process.exit(1);
3415
+ }
3416
+
3417
+ const config = readVaultConfig(vaultName);
3418
+ if (!config) {
3419
+ console.error(`Vault "${vaultName}" not found. Available: ${listVaults().join(", ") || "(none)"}.`);
3420
+ process.exit(1);
3421
+ }
3422
+
3423
+ // Resolve the mirror dir the same way the server does: per-vault mirror
3424
+ // config (or defaults) → resolveMirrorPath against the vault's data dir.
3425
+ const mirrorConfig = readMirrorConfigForVault(vaultName) ?? defaultMirrorConfig();
3426
+ const mirrorPath = resolveMirrorPath(vaultDir(vaultName), mirrorConfig);
3427
+ if (!mirrorPath || !existsSync(mirrorPath)) {
3428
+ console.error(
3429
+ `No git history for vault "${vaultName}" — the mirror isn't initialized yet.\n` +
3430
+ `Enable history (backup) so writes are recorded, then re-run.`,
3431
+ );
3432
+ process.exit(1);
3433
+ }
3434
+
3435
+ // Preflight git — friendly, actionable message instead of a raw spawn throw.
3436
+ try {
3437
+ ensureGitAvailable();
3438
+ } catch (err) {
3439
+ if (err instanceof GitNotInstalledError) {
3440
+ console.error(err.message);
3441
+ process.exit(1);
3442
+ }
3443
+ throw err;
3444
+ }
3445
+
3446
+ // `--show` path: print one note's content at a past revision.
3447
+ if (showSha && notePath) {
3448
+ const content = await showMirrorRevision(mirrorPath, showSha, notePath);
3449
+ if (content === null) {
3450
+ console.error(
3451
+ `No content for "${notePath}" at ${showSha} — the sha may be unknown, the note may not have existed at that commit, or the path is invalid.`,
3452
+ );
3453
+ process.exit(1);
3454
+ }
3455
+ process.stdout.write(content);
3456
+ return;
3457
+ }
3458
+
3459
+ const history = await readMirrorHistory(mirrorPath, { notePath, limit });
3460
+
3461
+ if (asJson) {
3462
+ console.log(JSON.stringify(history, null, 2));
3463
+ return;
3464
+ }
3465
+
3466
+ if (history.length === 0) {
3467
+ if (notePath) {
3468
+ console.log(`No history for note "${notePath}" in vault "${vaultName}".`);
3469
+ } else {
3470
+ console.log(`No history yet for vault "${vaultName}".`);
3471
+ }
3472
+ return;
3473
+ }
3474
+
3475
+ const scope = notePath ? ` for "${notePath}"` : "";
3476
+ console.log(`Git history for vault "${vaultName}"${scope} (${history.length} commit${history.length === 1 ? "" : "s"}):\n`);
3477
+ for (const entry of history) {
3478
+ // Short sha + date + subject — one line per commit, the `git log --oneline`
3479
+ // shape an operator expects.
3480
+ console.log(` ${entry.sha.slice(0, 8)} ${entry.date} ${entry.message}`);
3481
+ }
3482
+ }
3483
+
3484
+ function printHistoryUsage(): void {
3485
+ console.error(
3486
+ "Usage: parachute-vault history [--note <path>] [--limit N] [--vault <name>] [--json]\n" +
3487
+ " [--note <path> --show <sha>]",
3488
+ );
3489
+ console.error("\nSurface the vault's git write history. The vault is already git-backed via the");
3490
+ console.error("mirror (one file per note), so `git log` IS a tamper-evident, diffable history.");
3491
+ console.error("\nOptions:");
3492
+ console.error(" --vault <name> Vault to read (default: 'default')");
3493
+ console.error(" --note <path> Scope to a single note's history (git log --follow)");
3494
+ console.error(" --limit N Cap the number of commits returned (default 100)");
3495
+ console.error(" --show <sha> With --note: print that note's content at the given revision");
3496
+ console.error(" --json Emit the history as JSON instead of the human-readable list");
3497
+ }
3498
+
3333
3499
  // ---------------------------------------------------------------------------
3334
3500
  // Schema maintenance — `parachute-vault schema <subcommand>`
3335
3501
  // ---------------------------------------------------------------------------
@@ -4106,6 +4272,15 @@ Import/Export:
4106
4272
  template via --git-message-template;
4107
4273
  --git-push to push after commit)
4108
4274
 
4275
+ History:
4276
+ parachute-vault history [--vault <name>] Show the vault's git write history (the
4277
+ mirror is git-backed — one file per note,
4278
+ so git log IS a tamper-evident history)
4279
+ parachute-vault history --note <path> Scope to one note's history (git log --follow)
4280
+ parachute-vault history --limit N Cap the number of commits (default 100)
4281
+ parachute-vault history --note <path> --show <sha> Print that note's content at a past revision
4282
+ parachute-vault history --json Emit history as JSON
4283
+
4109
4284
  Schema maintenance:
4110
4285
  parachute-vault schema prune [--vault <name>] Drop orphaned indexed-field columns +
4111
4286
  indexes whose declaring tags no longer
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Tests for the git-history read surface (vault#300).
3
+ *
4
+ * The vault is already git-backed via the mirror — one file per note — so
5
+ * `git log` IS the write history. These tests exercise:
6
+ * - the `readMirrorHistory` / `showMirrorRevision` / `noteHistoryPathspec`
7
+ * helpers (mirror-manager.ts) against a real seeded git repo in a tmpdir
8
+ * - the `handleMirrorHistory` / `handleMirrorHistoryShow` REST handlers
9
+ * (mirror-routes.ts), including the not-initialized + path-scoped cases
10
+ *
11
+ * Routing-level admin-gate enforcement lives in routing.test.ts (alongside
12
+ * the sibling mirror routes); these are the after-auth handler + helper
13
+ * tests, matching the mirror-manager / mirror-routes test split.
14
+ *
15
+ * Like the other mirror tests: real tempdirs + real `git` so we exercise
16
+ * the actual log/show behavior, not a mock.
17
+ */
18
+
19
+ import { describe, test, expect, afterEach, afterAll } from "bun:test";
20
+ import fs from "node:fs";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+
24
+ import {
25
+ HISTORY_MAX_LIMIT,
26
+ noteHistoryPathspec,
27
+ readMirrorHistory,
28
+ showMirrorRevision,
29
+ MirrorManager,
30
+ type MirrorDeps,
31
+ } from "./mirror-manager.ts";
32
+ import {
33
+ handleMirrorHistory,
34
+ handleMirrorHistoryShow,
35
+ } from "./mirror-routes.ts";
36
+ import { defaultMirrorConfig, type MirrorConfig } from "./mirror-config.ts";
37
+
38
+ // Keep HOME / PARACHUTE_HOME from leaking between test files — same pattern
39
+ // as mirror-manager.test.ts.
40
+ const ORIG_HOME = process.env.HOME;
41
+ const ORIG_PARACHUTE_HOME = process.env.PARACHUTE_HOME;
42
+ afterEach(() => {
43
+ if (ORIG_HOME === undefined) delete process.env.HOME;
44
+ else process.env.HOME = ORIG_HOME;
45
+ if (ORIG_PARACHUTE_HOME === undefined) delete process.env.PARACHUTE_HOME;
46
+ else process.env.PARACHUTE_HOME = ORIG_PARACHUTE_HOME;
47
+ });
48
+ afterAll(() => {
49
+ if (ORIG_HOME === undefined) delete process.env.HOME;
50
+ else process.env.HOME = ORIG_HOME;
51
+ });
52
+
53
+ function tmp(prefix: string): string {
54
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
55
+ }
56
+
57
+ function initRepo(dir: string): void {
58
+ Bun.spawnSync(["git", "init", "-q", "-b", "main"], { cwd: dir });
59
+ Bun.spawnSync(["git", "config", "user.email", "t@p.computer"], { cwd: dir });
60
+ Bun.spawnSync(["git", "config", "user.name", "T P"], { cwd: dir });
61
+ Bun.spawnSync(["git", "config", "commit.gpgsign", "false"], { cwd: dir });
62
+ }
63
+
64
+ /** Write a file (creating parent dirs), `git add -A`, commit with `message`. */
65
+ function commitFile(dir: string, relPath: string, content: string, message: string): void {
66
+ const full = path.join(dir, relPath);
67
+ fs.mkdirSync(path.dirname(full), { recursive: true });
68
+ fs.writeFileSync(full, content);
69
+ Bun.spawnSync(["git", "add", "-A"], { cwd: dir });
70
+ Bun.spawnSync(["git", "commit", "-q", "-m", message], { cwd: dir });
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // noteHistoryPathspec — normalize + traversal guard
75
+ // ---------------------------------------------------------------------------
76
+
77
+ describe("noteHistoryPathspec", () => {
78
+ test("appends .md to a bare note path", () => {
79
+ expect(noteHistoryPathspec("Inbox/today")).toBe("Inbox/today.md");
80
+ });
81
+
82
+ test("is idempotent on an already-suffixed path", () => {
83
+ expect(noteHistoryPathspec("Inbox/today.md")).toBe("Inbox/today.md");
84
+ });
85
+
86
+ test("rejects traversal", () => {
87
+ expect(noteHistoryPathspec("../etc/passwd")).toBeNull();
88
+ expect(noteHistoryPathspec("Inbox/../../secret")).toBeNull();
89
+ });
90
+
91
+ test("rejects absolute paths", () => {
92
+ expect(noteHistoryPathspec("/etc/passwd")).toBeNull();
93
+ });
94
+
95
+ test("rejects empty / whitespace", () => {
96
+ expect(noteHistoryPathspec("")).toBeNull();
97
+ expect(noteHistoryPathspec(" ")).toBeNull();
98
+ });
99
+ });
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // readMirrorHistory — the core git-log read
103
+ // ---------------------------------------------------------------------------
104
+
105
+ describe("readMirrorHistory", () => {
106
+ test("returns commits newest-first with sha/date/message", async () => {
107
+ const dir = tmp("vault-history-");
108
+ initRepo(dir);
109
+ commitFile(dir, "Inbox/one.md", "one", "export: first (1 note)");
110
+ commitFile(dir, "Inbox/two.md", "two", "export: second (1 note)");
111
+
112
+ const history = await readMirrorHistory(dir);
113
+ expect(history.length).toBe(2);
114
+ // Newest first.
115
+ expect(history[0]!.message).toBe("export: second (1 note)");
116
+ expect(history[1]!.message).toBe("export: first (1 note)");
117
+ // Each entry has a full sha + an ISO date.
118
+ for (const entry of history) {
119
+ expect(entry.sha).toMatch(/^[0-9a-f]{40}$/);
120
+ expect(Number.isNaN(Date.parse(entry.date))).toBe(false);
121
+ }
122
+ });
123
+
124
+ test("?path filters to a single note's commits, --follow across renames", async () => {
125
+ const dir = tmp("vault-history-path-");
126
+ initRepo(dir);
127
+ commitFile(dir, "Notes/alpha.md", "a1", "export: alpha created");
128
+ commitFile(dir, "Notes/beta.md", "b1", "export: beta created");
129
+ commitFile(dir, "Notes/alpha.md", "a2", "export: alpha edited");
130
+
131
+ const alpha = await readMirrorHistory(dir, { notePath: "Notes/alpha" });
132
+ // Two commits touched alpha; beta's commit is excluded.
133
+ expect(alpha.length).toBe(2);
134
+ expect(alpha.map((e) => e.message)).toEqual([
135
+ "export: alpha edited",
136
+ "export: alpha created",
137
+ ]);
138
+
139
+ const beta = await readMirrorHistory(dir, { notePath: "Notes/beta" });
140
+ expect(beta.length).toBe(1);
141
+ expect(beta[0]!.message).toBe("export: beta created");
142
+ });
143
+
144
+ test("limit caps the number of commits returned", async () => {
145
+ const dir = tmp("vault-history-limit-");
146
+ initRepo(dir);
147
+ for (let i = 0; i < 5; i++) {
148
+ commitFile(dir, `Notes/n${i}.md`, `v${i}`, `export: commit ${i}`);
149
+ }
150
+ const limited = await readMirrorHistory(dir, { limit: 2 });
151
+ expect(limited.length).toBe(2);
152
+ // Newest two.
153
+ expect(limited[0]!.message).toBe("export: commit 4");
154
+ expect(limited[1]!.message).toBe("export: commit 3");
155
+ });
156
+
157
+ test("an out-of-range limit is clamped to HISTORY_MAX_LIMIT (never exceeds the ceiling)", async () => {
158
+ const dir = tmp("vault-history-ceiling-");
159
+ initRepo(dir);
160
+ // Seed just a few commits — the assertion is on the ARG cap, not commit
161
+ // count: an absurd limit must never spawn `git log` with --max-count
162
+ // above the hard ceiling. With <ceiling commits the result is bounded by
163
+ // commit count, so we assert it's never MORE than the ceiling.
164
+ commitFile(dir, "Notes/a.md", "a", "export: a");
165
+ commitFile(dir, "Notes/b.md", "b", "export: b");
166
+ const history = await readMirrorHistory(dir, { limit: 99_999_999 });
167
+ expect(history.length).toBeLessThanOrEqual(HISTORY_MAX_LIMIT);
168
+ // Sanity: a huge limit still returns the (few) real commits.
169
+ expect(history.length).toBe(2);
170
+ });
171
+
172
+ test("no commits yet → empty list, not an error", async () => {
173
+ const dir = tmp("vault-history-empty-");
174
+ initRepo(dir); // repo but no commits
175
+ const history = await readMirrorHistory(dir);
176
+ expect(history).toEqual([]);
177
+ });
178
+
179
+ test("not a git repo → empty list, not an error", async () => {
180
+ const dir = tmp("vault-history-nonrepo-");
181
+ // No `git init`.
182
+ const history = await readMirrorHistory(dir);
183
+ expect(history).toEqual([]);
184
+ });
185
+
186
+ test("a path with no history → empty list", async () => {
187
+ const dir = tmp("vault-history-nopath-");
188
+ initRepo(dir);
189
+ commitFile(dir, "Notes/exists.md", "x", "export: exists");
190
+ const history = await readMirrorHistory(dir, { notePath: "Notes/ghost" });
191
+ expect(history).toEqual([]);
192
+ });
193
+
194
+ test("an unsafe path → empty list (never an unscoped log)", async () => {
195
+ const dir = tmp("vault-history-unsafe-");
196
+ initRepo(dir);
197
+ commitFile(dir, "Notes/a.md", "a", "export: a");
198
+ commitFile(dir, "Notes/b.md", "b", "export: b");
199
+ // A traversal path must NOT fall back to the full (unscoped) log.
200
+ const history = await readMirrorHistory(dir, { notePath: "../../../etc/passwd" });
201
+ expect(history).toEqual([]);
202
+ });
203
+
204
+ test("redacts tokens that appear in a commit subject", async () => {
205
+ const dir = tmp("vault-history-redact-");
206
+ initRepo(dir);
207
+ commitFile(dir, "Notes/a.md", "a", "synced from https://x-access-token:ghp_supersecrettoken123@github.com/a/b");
208
+ const history = await readMirrorHistory(dir);
209
+ expect(history.length).toBe(1);
210
+ expect(history[0]!.message).not.toContain("ghp_supersecrettoken123");
211
+ expect(history[0]!.message).toContain("***");
212
+ });
213
+ });
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // showMirrorRevision — read a past revision of a note
217
+ // ---------------------------------------------------------------------------
218
+
219
+ describe("showMirrorRevision", () => {
220
+ test("returns the file content at a past revision", async () => {
221
+ const dir = tmp("vault-show-");
222
+ initRepo(dir);
223
+ commitFile(dir, "Notes/a.md", "version one", "export: a v1");
224
+ // Capture the sha at v1.
225
+ const v1 = (await readMirrorHistory(dir))[0]!.sha;
226
+ commitFile(dir, "Notes/a.md", "version two", "export: a v2");
227
+
228
+ const past = await showMirrorRevision(dir, v1, "Notes/a");
229
+ expect(past).toBe("version one");
230
+
231
+ const current = await showMirrorRevision(dir, (await readMirrorHistory(dir))[0]!.sha, "Notes/a");
232
+ expect(current).toBe("version two");
233
+ });
234
+
235
+ test("unknown sha → null", async () => {
236
+ const dir = tmp("vault-show-badsha-");
237
+ initRepo(dir);
238
+ commitFile(dir, "Notes/a.md", "x", "export: a");
239
+ const result = await showMirrorRevision(dir, "deadbeef", "Notes/a");
240
+ expect(result).toBeNull();
241
+ });
242
+
243
+ test("non-hex / option-looking sha → null (no smuggled ref)", async () => {
244
+ const dir = tmp("vault-show-refsmuggle-");
245
+ initRepo(dir);
246
+ commitFile(dir, "Notes/a.md", "x", "export: a");
247
+ expect(await showMirrorRevision(dir, "HEAD", "Notes/a")).toBeNull();
248
+ expect(await showMirrorRevision(dir, "--output=/tmp/x", "Notes/a")).toBeNull();
249
+ });
250
+
251
+ test("unsafe path → null", async () => {
252
+ const dir = tmp("vault-show-unsafe-");
253
+ initRepo(dir);
254
+ commitFile(dir, "Notes/a.md", "x", "export: a");
255
+ const sha = (await readMirrorHistory(dir))[0]!.sha;
256
+ expect(await showMirrorRevision(dir, sha, "../../../etc/passwd")).toBeNull();
257
+ });
258
+ });
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Route handlers — handleMirrorHistory / handleMirrorHistoryShow
262
+ // ---------------------------------------------------------------------------
263
+
264
+ /**
265
+ * Build a MirrorManager whose status reports `mirror_path = mirrorPath`
266
+ * without standing up the full lifecycle — the history handlers only read
267
+ * `getStatus().mirror_path`, so we start a minimal enabled internal mirror
268
+ * pointed at a tmp repo we control.
269
+ *
270
+ * Simpler: construct the manager with fake deps and force the status by
271
+ * starting it against an internal mirror dir we then seed. But the history
272
+ * read is path-only, so we instead use a tiny manager whose deps resolve the
273
+ * internal mirror under a tmp PARACHUTE_HOME and start it (which bootstraps a
274
+ * real git repo), then commit into that repo.
275
+ */
276
+ function makeStartedManager(home: string): { manager: MirrorManager; deps: MirrorDeps } {
277
+ process.env.PARACHUTE_HOME = home;
278
+ process.env.HOME = home;
279
+ fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
280
+ let stored: MirrorConfig | undefined = { ...defaultMirrorConfig(), enabled: true };
281
+ const deps: MirrorDeps = {
282
+ vaultName: "default",
283
+ runExport: async () => ({ notes: 0 }),
284
+ firstChangedNoteTitle: async () => "",
285
+ readMirrorConfig: () => stored,
286
+ writeMirrorConfig: (c) => {
287
+ stored = c;
288
+ },
289
+ };
290
+ return { manager: new MirrorManager(deps), deps };
291
+ }
292
+
293
+ describe("handleMirrorHistory route", () => {
294
+ test("not-initialized mirror → 200 empty history + note (not 500)", async () => {
295
+ const home = tmp("vault-route-noinit-");
296
+ const { manager } = makeStartedManager(home);
297
+ // Never started → no mirror_path resolved.
298
+ const res = await handleMirrorHistory(
299
+ new Request("http://localhost/vault/default/.parachute/mirror/history"),
300
+ manager,
301
+ );
302
+ expect(res.status).toBe(200);
303
+ const body = (await res.json()) as { history: unknown[]; mirror_path: null; note?: string };
304
+ expect(body.history).toEqual([]);
305
+ expect(body.mirror_path).toBeNull();
306
+ expect(body.note).toBeDefined();
307
+ });
308
+
309
+ test("started mirror with commits → 200 history list", async () => {
310
+ const home = tmp("vault-route-hist-");
311
+ const { manager } = makeStartedManager(home);
312
+ await manager.start();
313
+ const mirrorPath = manager.getStatus().mirror_path!;
314
+ expect(mirrorPath).toBeTruthy();
315
+ commitFile(mirrorPath, "Notes/a.md", "a", "export: a created");
316
+ commitFile(mirrorPath, "Notes/b.md", "b", "export: b created");
317
+
318
+ const res = await handleMirrorHistory(
319
+ new Request("http://localhost/vault/default/.parachute/mirror/history"),
320
+ manager,
321
+ );
322
+ expect(res.status).toBe(200);
323
+ const body = (await res.json()) as {
324
+ history: Array<{ sha: string; date: string; message: string }>;
325
+ mirror_path: string;
326
+ };
327
+ // Includes the bootstrap commit + our two; newest first.
328
+ expect(body.history.length).toBeGreaterThanOrEqual(3);
329
+ expect(body.history[0]!.message).toBe("export: b created");
330
+ expect(body.mirror_path).toBe(mirrorPath);
331
+ });
332
+
333
+ test("?path scopes to one note", async () => {
334
+ const home = tmp("vault-route-histpath-");
335
+ const { manager } = makeStartedManager(home);
336
+ await manager.start();
337
+ const mirrorPath = manager.getStatus().mirror_path!;
338
+ commitFile(mirrorPath, "Notes/alpha.md", "a1", "export: alpha");
339
+ commitFile(mirrorPath, "Notes/beta.md", "b1", "export: beta");
340
+
341
+ const res = await handleMirrorHistory(
342
+ new Request("http://localhost/vault/default/.parachute/mirror/history?path=Notes/alpha"),
343
+ manager,
344
+ );
345
+ const body = (await res.json()) as {
346
+ history: Array<{ message: string }>;
347
+ path?: string;
348
+ };
349
+ expect(body.path).toBe("Notes/alpha");
350
+ expect(body.history.length).toBe(1);
351
+ expect(body.history[0]!.message).toBe("export: alpha");
352
+ });
353
+
354
+ test("?limit caps the count", async () => {
355
+ const home = tmp("vault-route-histlimit-");
356
+ const { manager } = makeStartedManager(home);
357
+ await manager.start();
358
+ const mirrorPath = manager.getStatus().mirror_path!;
359
+ for (let i = 0; i < 4; i++) commitFile(mirrorPath, `Notes/n${i}.md`, `v${i}`, `export: c${i}`);
360
+
361
+ const res = await handleMirrorHistory(
362
+ new Request("http://localhost/vault/default/.parachute/mirror/history?limit=2"),
363
+ manager,
364
+ );
365
+ const body = (await res.json()) as { history: unknown[] };
366
+ expect(body.history.length).toBe(2);
367
+ });
368
+
369
+ test("invalid limit → 400", async () => {
370
+ const home = tmp("vault-route-histbadlimit-");
371
+ const { manager } = makeStartedManager(home);
372
+ await manager.start();
373
+ const res = await handleMirrorHistory(
374
+ new Request("http://localhost/vault/default/.parachute/mirror/history?limit=-3"),
375
+ manager,
376
+ );
377
+ expect(res.status).toBe(400);
378
+ });
379
+ });
380
+
381
+ describe("handleMirrorHistoryShow route", () => {
382
+ test("returns a past revision's content", async () => {
383
+ const home = tmp("vault-route-show-");
384
+ const { manager } = makeStartedManager(home);
385
+ await manager.start();
386
+ const mirrorPath = manager.getStatus().mirror_path!;
387
+ commitFile(mirrorPath, "Notes/a.md", "v1 content", "export: a v1");
388
+ const sha = (await readMirrorHistory(mirrorPath, { notePath: "Notes/a" }))[0]!.sha;
389
+ commitFile(mirrorPath, "Notes/a.md", "v2 content", "export: a v2");
390
+
391
+ const res = await handleMirrorHistoryShow(
392
+ new Request(
393
+ `http://localhost/vault/default/.parachute/mirror/history/show?sha=${sha}&path=Notes/a`,
394
+ ),
395
+ manager,
396
+ );
397
+ expect(res.status).toBe(200);
398
+ const body = (await res.json()) as { content: string; sha: string; path: string };
399
+ expect(body.content).toBe("v1 content");
400
+ expect(body.path).toBe("Notes/a");
401
+ });
402
+
403
+ test("missing sha/path → 400", async () => {
404
+ const home = tmp("vault-route-show-missing-");
405
+ const { manager } = makeStartedManager(home);
406
+ await manager.start();
407
+ const res = await handleMirrorHistoryShow(
408
+ new Request("http://localhost/vault/default/.parachute/mirror/history/show?sha=abc123"),
409
+ manager,
410
+ );
411
+ expect(res.status).toBe(400);
412
+ });
413
+
414
+ test("unknown sha/path → 404", async () => {
415
+ const home = tmp("vault-route-show-404-");
416
+ const { manager } = makeStartedManager(home);
417
+ await manager.start();
418
+ const res = await handleMirrorHistoryShow(
419
+ new Request(
420
+ "http://localhost/vault/default/.parachute/mirror/history/show?sha=deadbeef&path=Notes/ghost",
421
+ ),
422
+ manager,
423
+ );
424
+ expect(res.status).toBe(404);
425
+ });
426
+ });