@openparachute/vault 0.4.6-rc.3 → 0.4.7-rc.1
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 +41 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +161 -1
- package/src/auth.ts +57 -3
- package/src/cli.ts +420 -22
- package/src/config.ts +24 -0
- package/src/export-watch.test.ts +811 -0
- package/src/export-watch.ts +255 -0
- package/src/mcp-config.test.ts +260 -0
- package/src/mcp-install.test.ts +60 -0
- package/src/mcp-install.ts +61 -0
- package/src/mirror-config.test.ts +328 -0
- package/src/mirror-config.ts +470 -0
- package/src/mirror-deps.ts +88 -0
- package/src/mirror-manager.test.ts +550 -0
- package/src/mirror-manager.ts +521 -0
- package/src/mirror-registry.ts +26 -0
- package/src/mirror-routes.test.ts +380 -0
- package/src/mirror-routes.ts +152 -0
- package/src/routing.test.ts +76 -0
- package/src/routing.ts +46 -0
- package/src/server.ts +52 -0
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `parachute-vault export --watch` and `--git-commit`.
|
|
3
|
+
*
|
|
4
|
+
* Three layers:
|
|
5
|
+
*
|
|
6
|
+
* 1. Pure helpers in `export-watch.ts` — `renderCommitMessage`,
|
|
7
|
+
* `shouldCommit`. No I/O.
|
|
8
|
+
* 2. Git-shell helpers — `isGitRepo`, `gitAddAll`, `gitCommit`,
|
|
9
|
+
* `listStagedFiles`, `runGitCommitCycle`. Spawn real `git` against a
|
|
10
|
+
* throwaway repo in a tempdir.
|
|
11
|
+
* 3. CLI end-to-end — spawn `bun src/cli.ts export …` against an isolated
|
|
12
|
+
* `PARACHUTE_HOME` with a seeded vault. Covers the flag-parsing surface
|
|
13
|
+
* (single-shot + --git-commit) and the watch-loop happy paths via the
|
|
14
|
+
* `--interval` knob set to a small value.
|
|
15
|
+
*
|
|
16
|
+
* Watch-loop tests use child process kill/timeout discipline: spawn, wait
|
|
17
|
+
* for the expected log line (or timeout cap), then SIGINT and assert
|
|
18
|
+
* graceful exit. No real wall-clock sleeps longer than the interval.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
DEFAULT_COMMIT_TEMPLATE,
|
|
28
|
+
gitAddAll,
|
|
29
|
+
gitCommit,
|
|
30
|
+
gitUnstageAll,
|
|
31
|
+
isGitRepo,
|
|
32
|
+
listStagedFiles,
|
|
33
|
+
renderCommitMessage,
|
|
34
|
+
runGitCommitCycle,
|
|
35
|
+
shouldCommit,
|
|
36
|
+
} from "./export-watch.ts";
|
|
37
|
+
|
|
38
|
+
const CLI = path.resolve(import.meta.dir, "cli.ts");
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Shared test helpers
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
function makeTmp(prefix: string): string {
|
|
45
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function setupBareVault(parachuteHome: string, name: string): void {
|
|
49
|
+
const vaultsDir = path.join(parachuteHome, "vault", "data");
|
|
50
|
+
fs.mkdirSync(path.join(vaultsDir, name), { recursive: true });
|
|
51
|
+
fs.writeFileSync(
|
|
52
|
+
path.join(vaultsDir, name, "vault.yaml"),
|
|
53
|
+
`name: ${name}\napi_keys: []\n`,
|
|
54
|
+
);
|
|
55
|
+
const globalPath = path.join(parachuteHome, "vault", "config.yaml");
|
|
56
|
+
if (!fs.existsSync(globalPath)) {
|
|
57
|
+
fs.mkdirSync(path.dirname(globalPath), { recursive: true });
|
|
58
|
+
fs.writeFileSync(globalPath, `default_vault: ${name}\nport: 1940\n`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Seed a SQLite vault DB at the path the server would use, populated with
|
|
64
|
+
* `n` notes. Returns the store handle so the test can write more notes
|
|
65
|
+
* mid-test (to simulate a live vault under a watch loop).
|
|
66
|
+
*/
|
|
67
|
+
async function seedVaultWithNotes(
|
|
68
|
+
parachuteHome: string,
|
|
69
|
+
vaultName: string,
|
|
70
|
+
notes: Array<{ id?: string; path: string; content: string; tags?: string[] }>,
|
|
71
|
+
) {
|
|
72
|
+
// Defer imports so they read `PARACHUTE_HOME` from the env we set just
|
|
73
|
+
// before calling this helper.
|
|
74
|
+
process.env.PARACHUTE_HOME = parachuteHome;
|
|
75
|
+
process.env.HOME = parachuteHome;
|
|
76
|
+
const { clearVaultStoreCache, getVaultStore } = await import("./vault-store.ts");
|
|
77
|
+
clearVaultStoreCache();
|
|
78
|
+
const store = getVaultStore(vaultName);
|
|
79
|
+
for (const n of notes) {
|
|
80
|
+
await store.createNote(n.content, {
|
|
81
|
+
...(n.id ? { id: n.id } : {}),
|
|
82
|
+
path: n.path,
|
|
83
|
+
...(n.tags ? { tags: n.tags } : {}),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return store;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function initGitRepo(dir: string): void {
|
|
90
|
+
// Each test gets a brand-new repo. Set user.email/name locally so commits
|
|
91
|
+
// succeed without depending on the dev's global git config.
|
|
92
|
+
Bun.spawnSync(["git", "init", "-q", "-b", "main"], { cwd: dir });
|
|
93
|
+
Bun.spawnSync(["git", "config", "user.email", "test@parachute.computer"], { cwd: dir });
|
|
94
|
+
Bun.spawnSync(["git", "config", "user.name", "Parachute Test"], { cwd: dir });
|
|
95
|
+
Bun.spawnSync(["git", "config", "commit.gpgsign", "false"], { cwd: dir });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function gitLogOneline(dir: string): string[] {
|
|
99
|
+
const proc = Bun.spawnSync(["git", "log", "--oneline"], { cwd: dir, stdout: "pipe" });
|
|
100
|
+
return new TextDecoder()
|
|
101
|
+
.decode(proc.stdout)
|
|
102
|
+
.split("\n")
|
|
103
|
+
.map((l) => l.trim())
|
|
104
|
+
.filter((l) => l.length > 0);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function gitLastCommitMessage(dir: string): string {
|
|
108
|
+
const proc = Bun.spawnSync(["git", "log", "-1", "--pretty=%B"], { cwd: dir, stdout: "pipe" });
|
|
109
|
+
return new TextDecoder().decode(proc.stdout).trim();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function runCli(
|
|
113
|
+
args: string[],
|
|
114
|
+
parachuteHome: string,
|
|
115
|
+
extraEnv: Record<string, string | undefined> = {},
|
|
116
|
+
): { exitCode: number; stdout: string; stderr: string } {
|
|
117
|
+
const proc = Bun.spawnSync({
|
|
118
|
+
cmd: ["bun", CLI, ...args],
|
|
119
|
+
cwd: parachuteHome,
|
|
120
|
+
env: {
|
|
121
|
+
...process.env,
|
|
122
|
+
PARACHUTE_HOME: parachuteHome,
|
|
123
|
+
HOME: parachuteHome,
|
|
124
|
+
...extraEnv,
|
|
125
|
+
},
|
|
126
|
+
stdout: "pipe",
|
|
127
|
+
stderr: "pipe",
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
exitCode: proc.exitCode ?? -1,
|
|
131
|
+
stdout: new TextDecoder().decode(proc.stdout),
|
|
132
|
+
stderr: new TextDecoder().decode(proc.stderr),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// 1. Pure helpers
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
describe("renderCommitMessage", () => {
|
|
141
|
+
test("substitutes all documented vars", () => {
|
|
142
|
+
const msg = renderCommitMessage(
|
|
143
|
+
"{{date}} | {{notes_changed}} | {{plural}} | {{first_note_title}} | {{vault_name}}",
|
|
144
|
+
{
|
|
145
|
+
date: "2026-05-20T15:42:01Z",
|
|
146
|
+
notes_changed: 3,
|
|
147
|
+
first_note_title: "Inbox/2026-05-20",
|
|
148
|
+
vault_name: "gitcoin",
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
expect(msg).toBe("2026-05-20T15:42:01Z | 3 | s | Inbox/2026-05-20 | gitcoin");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("{{plural}} is empty when notes_changed === 1", () => {
|
|
155
|
+
const msg = renderCommitMessage("{{notes_changed}} note{{plural}}", {
|
|
156
|
+
date: "x",
|
|
157
|
+
notes_changed: 1,
|
|
158
|
+
first_note_title: "",
|
|
159
|
+
vault_name: "v",
|
|
160
|
+
});
|
|
161
|
+
expect(msg).toBe("1 note");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("{{plural}} is 's' when notes_changed === 0", () => {
|
|
165
|
+
// "0 notes" reads correctly — pluralize unless exactly one.
|
|
166
|
+
const msg = renderCommitMessage("{{notes_changed}} note{{plural}}", {
|
|
167
|
+
date: "x",
|
|
168
|
+
notes_changed: 0,
|
|
169
|
+
first_note_title: "",
|
|
170
|
+
vault_name: "v",
|
|
171
|
+
});
|
|
172
|
+
expect(msg).toBe("0 notes");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("default template renders the expected shape", () => {
|
|
176
|
+
const msg = renderCommitMessage(DEFAULT_COMMIT_TEMPLATE, {
|
|
177
|
+
date: "2026-05-20T00:00:00Z",
|
|
178
|
+
notes_changed: 2,
|
|
179
|
+
first_note_title: "",
|
|
180
|
+
vault_name: "default",
|
|
181
|
+
});
|
|
182
|
+
expect(msg).toBe("export: 2026-05-20T00:00:00Z (2 notes)");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("unknown tokens pass through untouched (typo-visible)", () => {
|
|
186
|
+
const msg = renderCommitMessage("{{not_a_var}} and {{date}}", {
|
|
187
|
+
date: "now",
|
|
188
|
+
notes_changed: 0,
|
|
189
|
+
first_note_title: "",
|
|
190
|
+
vault_name: "",
|
|
191
|
+
});
|
|
192
|
+
expect(msg).toBe("{{not_a_var}} and now");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("repeated tokens are all substituted", () => {
|
|
196
|
+
const msg = renderCommitMessage("{{date}} / {{date}}", {
|
|
197
|
+
date: "X",
|
|
198
|
+
notes_changed: 0,
|
|
199
|
+
first_note_title: "",
|
|
200
|
+
vault_name: "",
|
|
201
|
+
});
|
|
202
|
+
expect(msg).toBe("X / X");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("shouldCommit", () => {
|
|
207
|
+
test("empty staging → skip", () => {
|
|
208
|
+
expect(shouldCommit([], 0)).toEqual({ commit: false, reason: "empty" });
|
|
209
|
+
expect(shouldCommit([], 5)).toEqual({ commit: false, reason: "empty" });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("note changes → commit even if .parachute/ also dirty", () => {
|
|
213
|
+
expect(
|
|
214
|
+
shouldCommit([".parachute/vault.yaml", "Inbox/foo.md"], 1),
|
|
215
|
+
).toEqual({ commit: true, reason: "ok" });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("only .parachute/ + no notes changed → skip (filter exported_at churn)", () => {
|
|
219
|
+
expect(shouldCommit([".parachute/vault.yaml"], 0)).toEqual({
|
|
220
|
+
commit: false,
|
|
221
|
+
reason: "parachute_meta_only",
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("only .parachute/ + nonzero notes_changed → commit (defensive)", () => {
|
|
226
|
+
// notes_changed > 0 means real work happened even if its files don't
|
|
227
|
+
// appear in the staged set (could be tags-only schema export edge); we
|
|
228
|
+
// trust the stats over the staging filter.
|
|
229
|
+
expect(shouldCommit([".parachute/schemas/foo.yaml"], 1)).toEqual({
|
|
230
|
+
commit: true,
|
|
231
|
+
reason: "ok",
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("non-parachute file + zero notes_changed → commit", () => {
|
|
236
|
+
// Probably a manual edit in the export dir — defensive: commit it.
|
|
237
|
+
// Watch loop doesn't put us here normally, but the rule should be
|
|
238
|
+
// total over the staged set.
|
|
239
|
+
expect(shouldCommit(["Inbox/foo.md"], 0)).toEqual({
|
|
240
|
+
commit: true,
|
|
241
|
+
reason: "ok",
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// 2. Git-shell helpers
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
describe("git-shell helpers", () => {
|
|
251
|
+
let dir: string;
|
|
252
|
+
beforeEach(() => {
|
|
253
|
+
dir = makeTmp("vault-git-helpers-");
|
|
254
|
+
});
|
|
255
|
+
afterEach(() => {
|
|
256
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("isGitRepo: false in a fresh directory, true after `git init`", async () => {
|
|
260
|
+
expect(await isGitRepo(dir)).toBe(false);
|
|
261
|
+
initGitRepo(dir);
|
|
262
|
+
expect(await isGitRepo(dir)).toBe(true);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("gitAddAll + listStagedFiles: stage everything", async () => {
|
|
266
|
+
initGitRepo(dir);
|
|
267
|
+
fs.writeFileSync(path.join(dir, "a.md"), "# a\n");
|
|
268
|
+
fs.writeFileSync(path.join(dir, "b.md"), "# b\n");
|
|
269
|
+
const add = await gitAddAll(dir);
|
|
270
|
+
expect(add.ok).toBe(true);
|
|
271
|
+
const staged = await listStagedFiles(dir);
|
|
272
|
+
expect(staged.sort()).toEqual(["a.md", "b.md"]);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("gitCommit lands a real commit with the given message", async () => {
|
|
276
|
+
initGitRepo(dir);
|
|
277
|
+
fs.writeFileSync(path.join(dir, "a.md"), "# a\n");
|
|
278
|
+
await gitAddAll(dir);
|
|
279
|
+
const result = await gitCommit(dir, "test: first commit");
|
|
280
|
+
expect(result.ok).toBe(true);
|
|
281
|
+
expect(gitLastCommitMessage(dir)).toBe("test: first commit");
|
|
282
|
+
expect(gitLogOneline(dir)).toHaveLength(1);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("gitUnstageAll works on a fresh repo with no commits (no HEAD yet)", async () => {
|
|
286
|
+
// Regression for vault#346 reviewer note: prior version used `git reset
|
|
287
|
+
// HEAD -- .` which fails on a fresh repo before any commit. The
|
|
288
|
+
// .parachute/-only skip path on the first cycle would leave staging
|
|
289
|
+
// dirty. Verify `git restore --staged .` clears staging cleanly.
|
|
290
|
+
initGitRepo(dir);
|
|
291
|
+
fs.writeFileSync(path.join(dir, "a.md"), "# a\n");
|
|
292
|
+
await gitAddAll(dir);
|
|
293
|
+
expect(await listStagedFiles(dir)).toEqual(["a.md"]);
|
|
294
|
+
await gitUnstageAll(dir);
|
|
295
|
+
expect(await listStagedFiles(dir)).toEqual([]);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// 3. runGitCommitCycle — stage → decide → commit → push
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
describe("runGitCommitCycle", () => {
|
|
304
|
+
let dir: string;
|
|
305
|
+
beforeEach(() => {
|
|
306
|
+
dir = makeTmp("vault-commit-cycle-");
|
|
307
|
+
initGitRepo(dir);
|
|
308
|
+
// Seed an initial commit so HEAD exists — `git commit` on a fresh repo
|
|
309
|
+
// with no parent works, but the test mostly assumes a non-empty history.
|
|
310
|
+
fs.writeFileSync(path.join(dir, ".gitkeep"), "");
|
|
311
|
+
Bun.spawnSync(["git", "add", "-A"], { cwd: dir });
|
|
312
|
+
Bun.spawnSync(["git", "commit", "-q", "-m", "initial"], { cwd: dir });
|
|
313
|
+
});
|
|
314
|
+
afterEach(() => {
|
|
315
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("commits when there are real note changes", async () => {
|
|
319
|
+
fs.writeFileSync(path.join(dir, "Inbox.md"), "# Inbox\n");
|
|
320
|
+
const result = await runGitCommitCycle({
|
|
321
|
+
repoDir: dir,
|
|
322
|
+
template: DEFAULT_COMMIT_TEMPLATE,
|
|
323
|
+
notesChanged: 1,
|
|
324
|
+
vaultName: "default",
|
|
325
|
+
firstNoteTitle: "Inbox",
|
|
326
|
+
push: false,
|
|
327
|
+
now: () => "2026-05-20T15:42:01Z",
|
|
328
|
+
});
|
|
329
|
+
expect(result.committed).toBe(true);
|
|
330
|
+
expect(result.message).toBe("export: 2026-05-20T15:42:01Z (1 note)");
|
|
331
|
+
expect(gitLastCommitMessage(dir)).toBe("export: 2026-05-20T15:42:01Z (1 note)");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("skips commit when nothing is staged", async () => {
|
|
335
|
+
// No new files since the seed commit.
|
|
336
|
+
const result = await runGitCommitCycle({
|
|
337
|
+
repoDir: dir,
|
|
338
|
+
template: DEFAULT_COMMIT_TEMPLATE,
|
|
339
|
+
notesChanged: 0,
|
|
340
|
+
vaultName: "default",
|
|
341
|
+
firstNoteTitle: "",
|
|
342
|
+
push: false,
|
|
343
|
+
});
|
|
344
|
+
expect(result.committed).toBe(false);
|
|
345
|
+
expect(gitLogOneline(dir)).toHaveLength(1); // only the seed
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("skips when only .parachute/ metadata changed (and notes_changed=0)", async () => {
|
|
349
|
+
fs.mkdirSync(path.join(dir, ".parachute"), { recursive: true });
|
|
350
|
+
fs.writeFileSync(
|
|
351
|
+
path.join(dir, ".parachute", "vault.yaml"),
|
|
352
|
+
"exported_at: 2026-05-20T00:00:00Z\nexport_format_version: 1\n",
|
|
353
|
+
);
|
|
354
|
+
const result = await runGitCommitCycle({
|
|
355
|
+
repoDir: dir,
|
|
356
|
+
template: DEFAULT_COMMIT_TEMPLATE,
|
|
357
|
+
notesChanged: 0,
|
|
358
|
+
vaultName: "default",
|
|
359
|
+
firstNoteTitle: "",
|
|
360
|
+
push: false,
|
|
361
|
+
});
|
|
362
|
+
expect(result.committed).toBe(false);
|
|
363
|
+
// History unchanged.
|
|
364
|
+
expect(gitLogOneline(dir)).toHaveLength(1);
|
|
365
|
+
// Staging area cleared so the next cycle starts clean.
|
|
366
|
+
expect(await listStagedFiles(dir)).toEqual([]);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("commits when .parachute/ AND a note changed", async () => {
|
|
370
|
+
fs.mkdirSync(path.join(dir, ".parachute"), { recursive: true });
|
|
371
|
+
fs.writeFileSync(path.join(dir, ".parachute", "vault.yaml"), "x\n");
|
|
372
|
+
fs.writeFileSync(path.join(dir, "Note.md"), "# n\n");
|
|
373
|
+
const result = await runGitCommitCycle({
|
|
374
|
+
repoDir: dir,
|
|
375
|
+
template: "test: {{notes_changed}}",
|
|
376
|
+
notesChanged: 1,
|
|
377
|
+
vaultName: "default",
|
|
378
|
+
firstNoteTitle: "Note",
|
|
379
|
+
push: false,
|
|
380
|
+
});
|
|
381
|
+
expect(result.committed).toBe(true);
|
|
382
|
+
expect(gitLastCommitMessage(dir)).toBe("test: 1");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("--git-push failure is non-fatal (no remote configured)", async () => {
|
|
386
|
+
fs.writeFileSync(path.join(dir, "Note.md"), "# n\n");
|
|
387
|
+
// No remote — `git push` will fail. runGitCommitCycle should still
|
|
388
|
+
// return committed=true (the commit landed) and not throw.
|
|
389
|
+
const result = await runGitCommitCycle({
|
|
390
|
+
repoDir: dir,
|
|
391
|
+
template: "test push",
|
|
392
|
+
notesChanged: 1,
|
|
393
|
+
vaultName: "default",
|
|
394
|
+
firstNoteTitle: "Note",
|
|
395
|
+
push: true,
|
|
396
|
+
});
|
|
397
|
+
expect(result.committed).toBe(true);
|
|
398
|
+
// Commit landed even though push failed.
|
|
399
|
+
expect(gitLogOneline(dir)).toHaveLength(2); // seed + new
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("first_note_title substitutes into single-note commit messages", async () => {
|
|
403
|
+
fs.writeFileSync(path.join(dir, "DonorMeeting.md"), "# d\n");
|
|
404
|
+
const result = await runGitCommitCycle({
|
|
405
|
+
repoDir: dir,
|
|
406
|
+
template: "note: {{first_note_title}}",
|
|
407
|
+
notesChanged: 1,
|
|
408
|
+
vaultName: "gitcoin",
|
|
409
|
+
firstNoteTitle: "Inbox/DonorMeeting",
|
|
410
|
+
push: false,
|
|
411
|
+
});
|
|
412
|
+
expect(result.message).toBe("note: Inbox/DonorMeeting");
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// 4. CLI end-to-end — single-shot mode
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
describe("export CLI: single-shot", () => {
|
|
421
|
+
let tmp: string;
|
|
422
|
+
let exportDir: string;
|
|
423
|
+
|
|
424
|
+
beforeEach(async () => {
|
|
425
|
+
tmp = makeTmp("vault-export-cli-");
|
|
426
|
+
exportDir = makeTmp("vault-export-out-");
|
|
427
|
+
setupBareVault(tmp, "default");
|
|
428
|
+
await seedVaultWithNotes(tmp, "default", [
|
|
429
|
+
{ id: "01HZA111111111111111111111", path: "Inbox/note-a", content: "# a\n" },
|
|
430
|
+
{ id: "01HZA222222222222222222222", path: "Inbox/note-b", content: "# b\n" },
|
|
431
|
+
]);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
afterEach(async () => {
|
|
435
|
+
// Close cached stores before the tempdir disappears — otherwise the
|
|
436
|
+
// next test's reset trips a "vault not found" against a dangling
|
|
437
|
+
// SQLite handle pointing at a deleted file.
|
|
438
|
+
const { clearVaultStoreCache } = await import("./vault-store.ts");
|
|
439
|
+
clearVaultStoreCache();
|
|
440
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
441
|
+
fs.rmSync(exportDir, { recursive: true, force: true });
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("--git-commit requires an initialized git repo (clear error)", () => {
|
|
445
|
+
const res = runCli(["export", exportDir, "--git-commit"], tmp);
|
|
446
|
+
expect(res.exitCode).toBe(1);
|
|
447
|
+
expect(res.stderr).toContain("--git-commit requires");
|
|
448
|
+
expect(res.stderr).toContain("git init");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("--git-commit on a real repo: export → commit", () => {
|
|
452
|
+
initGitRepo(exportDir);
|
|
453
|
+
// Seed a commit so the export commit is a delta.
|
|
454
|
+
fs.writeFileSync(path.join(exportDir, ".gitkeep"), "");
|
|
455
|
+
Bun.spawnSync(["git", "add", "-A"], { cwd: exportDir });
|
|
456
|
+
Bun.spawnSync(["git", "commit", "-q", "-m", "initial"], { cwd: exportDir });
|
|
457
|
+
|
|
458
|
+
const res = runCli(["export", exportDir, "--git-commit"], tmp);
|
|
459
|
+
expect(res.exitCode).toBe(0);
|
|
460
|
+
expect(res.stdout).toContain("[git-commit] export:");
|
|
461
|
+
const log = gitLogOneline(exportDir);
|
|
462
|
+
expect(log.length).toBe(2); // initial + export
|
|
463
|
+
expect(gitLastCommitMessage(exportDir)).toMatch(/^export: /);
|
|
464
|
+
expect(gitLastCommitMessage(exportDir)).toContain("2 notes");
|
|
465
|
+
// Sanity: the export landed on disk.
|
|
466
|
+
expect(fs.existsSync(path.join(exportDir, ".parachute", "vault.yaml"))).toBe(true);
|
|
467
|
+
expect(fs.existsSync(path.join(exportDir, "Inbox", "note-a.md"))).toBe(true);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("--git-commit with custom template renders correctly", () => {
|
|
471
|
+
initGitRepo(exportDir);
|
|
472
|
+
fs.writeFileSync(path.join(exportDir, ".gitkeep"), "");
|
|
473
|
+
Bun.spawnSync(["git", "add", "-A"], { cwd: exportDir });
|
|
474
|
+
Bun.spawnSync(["git", "commit", "-q", "-m", "initial"], { cwd: exportDir });
|
|
475
|
+
|
|
476
|
+
const res = runCli(
|
|
477
|
+
[
|
|
478
|
+
"export",
|
|
479
|
+
exportDir,
|
|
480
|
+
"--git-commit",
|
|
481
|
+
"--git-message-template",
|
|
482
|
+
"vault {{vault_name}}: {{notes_changed}} note{{plural}}",
|
|
483
|
+
],
|
|
484
|
+
tmp,
|
|
485
|
+
);
|
|
486
|
+
expect(res.exitCode).toBe(0);
|
|
487
|
+
expect(gitLastCommitMessage(exportDir)).toBe("vault default: 2 notes");
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("--git-commit on a second export with no changes: skips commit", () => {
|
|
491
|
+
initGitRepo(exportDir);
|
|
492
|
+
fs.writeFileSync(path.join(exportDir, ".gitkeep"), "");
|
|
493
|
+
Bun.spawnSync(["git", "add", "-A"], { cwd: exportDir });
|
|
494
|
+
Bun.spawnSync(["git", "commit", "-q", "-m", "initial"], { cwd: exportDir });
|
|
495
|
+
|
|
496
|
+
// First export — commits.
|
|
497
|
+
const first = runCli(["export", exportDir, "--git-commit"], tmp);
|
|
498
|
+
expect(first.exitCode).toBe(0);
|
|
499
|
+
const afterFirst = gitLogOneline(exportDir).length;
|
|
500
|
+
expect(afterFirst).toBe(2);
|
|
501
|
+
|
|
502
|
+
// Second export — only .parachute/vault.yaml's exported_at changes, so
|
|
503
|
+
// we expect a skip-message and no new commit.
|
|
504
|
+
const second = runCli(
|
|
505
|
+
["export", exportDir, "--git-commit", "--since", "2999-01-01T00:00:00Z"],
|
|
506
|
+
tmp,
|
|
507
|
+
);
|
|
508
|
+
expect(second.exitCode).toBe(0);
|
|
509
|
+
expect(second.stdout).toContain("skipping commit");
|
|
510
|
+
expect(gitLogOneline(exportDir).length).toBe(afterFirst);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("--interval without --watch is a usage error", () => {
|
|
514
|
+
const res = runCli(["export", exportDir, "--interval", "2"], tmp);
|
|
515
|
+
expect(res.exitCode).toBe(1);
|
|
516
|
+
expect(res.stderr).toContain("--interval only applies with --watch");
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("--git-push without --git-commit is a usage error", () => {
|
|
520
|
+
const res = runCli(["export", exportDir, "--git-push"], tmp);
|
|
521
|
+
expect(res.exitCode).toBe(1);
|
|
522
|
+
expect(res.stderr).toContain("--git-push / --git-message-template only apply with --git-commit");
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("{{first_note_title}} resolves to the actual updated note (end-to-end)", async () => {
|
|
526
|
+
// Regression test for vault#346 reviewer note: previously
|
|
527
|
+
// firstChangedNoteTitle used `sort: "asc"` + `limit: 1` + a client-side
|
|
528
|
+
// `stamp >= cursor` post-filter, which fetched the vault's *oldest*
|
|
529
|
+
// note and almost always failed the post-filter — rendering
|
|
530
|
+
// {{first_note_title}} as "" in production. The fix moves the filter
|
|
531
|
+
// to the DB via dateFilter; this test exercises the full CLI to catch
|
|
532
|
+
// a regression that the helper-unit tests (which inject the title)
|
|
533
|
+
// can't see.
|
|
534
|
+
initGitRepo(exportDir);
|
|
535
|
+
fs.writeFileSync(path.join(exportDir, ".gitkeep"), "");
|
|
536
|
+
Bun.spawnSync(["git", "add", "-A"], { cwd: exportDir });
|
|
537
|
+
Bun.spawnSync(["git", "commit", "-q", "-m", "initial"], { cwd: exportDir });
|
|
538
|
+
|
|
539
|
+
// Capture a cursor strictly after the seed notes were created, then
|
|
540
|
+
// add a fresh note whose `updated_at` is after the cursor.
|
|
541
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
542
|
+
const cursor = new Date().toISOString();
|
|
543
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
544
|
+
await seedVaultWithNotes(tmp, "default", [
|
|
545
|
+
{
|
|
546
|
+
id: "01HZN111111111111111111111",
|
|
547
|
+
path: "Inbox/DonorMeeting",
|
|
548
|
+
content: "# fresh\n",
|
|
549
|
+
},
|
|
550
|
+
]);
|
|
551
|
+
const { clearVaultStoreCache } = await import("./vault-store.ts");
|
|
552
|
+
clearVaultStoreCache();
|
|
553
|
+
|
|
554
|
+
const res = runCli(
|
|
555
|
+
[
|
|
556
|
+
"export",
|
|
557
|
+
exportDir,
|
|
558
|
+
"--since",
|
|
559
|
+
cursor,
|
|
560
|
+
"--git-commit",
|
|
561
|
+
"--git-message-template",
|
|
562
|
+
"note: {{first_note_title}}",
|
|
563
|
+
],
|
|
564
|
+
tmp,
|
|
565
|
+
);
|
|
566
|
+
expect(res.exitCode).toBe(0);
|
|
567
|
+
const last = gitLastCommitMessage(exportDir);
|
|
568
|
+
// The fix renders "Inbox/DonorMeeting"; the bug rendered "note: ".
|
|
569
|
+
expect(last).toBe("note: Inbox/DonorMeeting");
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("--git-message-template without --git-commit is a usage error", () => {
|
|
573
|
+
// Same guard as --git-push but covers the template-only misuse path —
|
|
574
|
+
// an operator who set just the template (e.g. via shell history) and
|
|
575
|
+
// forgot --git-commit would otherwise silently do a plain export.
|
|
576
|
+
const res = runCli(
|
|
577
|
+
["export", exportDir, "--git-message-template", "hi"],
|
|
578
|
+
tmp,
|
|
579
|
+
);
|
|
580
|
+
expect(res.exitCode).toBe(1);
|
|
581
|
+
expect(res.stderr).toContain(
|
|
582
|
+
"--git-push / --git-message-template only apply with --git-commit",
|
|
583
|
+
);
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
// 5. CLI end-to-end — --watch loop
|
|
589
|
+
// ---------------------------------------------------------------------------
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Spawn the CLI as a streaming child process for watch tests. Returns the
|
|
593
|
+
* process handle + an awaitWatchLine helper that resolves when stdout
|
|
594
|
+
* surfaces a line matching `predicate` (or rejects on timeout).
|
|
595
|
+
*/
|
|
596
|
+
function spawnWatchCli(args: string[], parachuteHome: string) {
|
|
597
|
+
const proc = Bun.spawn({
|
|
598
|
+
cmd: ["bun", CLI, ...args],
|
|
599
|
+
cwd: parachuteHome,
|
|
600
|
+
env: {
|
|
601
|
+
...process.env,
|
|
602
|
+
PARACHUTE_HOME: parachuteHome,
|
|
603
|
+
HOME: parachuteHome,
|
|
604
|
+
},
|
|
605
|
+
stdout: "pipe",
|
|
606
|
+
stderr: "pipe",
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
const seenLines: string[] = [];
|
|
610
|
+
const seenStderr: string[] = [];
|
|
611
|
+
let stdoutBuffer = "";
|
|
612
|
+
let stderrBuffer = "";
|
|
613
|
+
const waiters: Array<{ predicate: (line: string) => boolean; resolve: () => void }> = [];
|
|
614
|
+
|
|
615
|
+
(async () => {
|
|
616
|
+
for await (const chunk of proc.stdout as ReadableStream<Uint8Array>) {
|
|
617
|
+
stdoutBuffer += new TextDecoder().decode(chunk);
|
|
618
|
+
let nl: number;
|
|
619
|
+
while ((nl = stdoutBuffer.indexOf("\n")) >= 0) {
|
|
620
|
+
const line = stdoutBuffer.slice(0, nl);
|
|
621
|
+
stdoutBuffer = stdoutBuffer.slice(nl + 1);
|
|
622
|
+
seenLines.push(line);
|
|
623
|
+
for (let i = waiters.length - 1; i >= 0; i--) {
|
|
624
|
+
if (waiters[i]!.predicate(line)) {
|
|
625
|
+
waiters[i]!.resolve();
|
|
626
|
+
waiters.splice(i, 1);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
})();
|
|
632
|
+
|
|
633
|
+
(async () => {
|
|
634
|
+
for await (const chunk of proc.stderr as ReadableStream<Uint8Array>) {
|
|
635
|
+
stderrBuffer += new TextDecoder().decode(chunk);
|
|
636
|
+
let nl: number;
|
|
637
|
+
while ((nl = stderrBuffer.indexOf("\n")) >= 0) {
|
|
638
|
+
seenStderr.push(stderrBuffer.slice(0, nl));
|
|
639
|
+
stderrBuffer = stderrBuffer.slice(nl + 1);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
})();
|
|
643
|
+
|
|
644
|
+
async function awaitLine(predicate: (line: string) => boolean, timeoutMs: number): Promise<string> {
|
|
645
|
+
// Fast path: already seen.
|
|
646
|
+
for (const l of seenLines) {
|
|
647
|
+
if (predicate(l)) return l;
|
|
648
|
+
}
|
|
649
|
+
return await new Promise<string>((resolve, reject) => {
|
|
650
|
+
const timer = setTimeout(() => {
|
|
651
|
+
reject(
|
|
652
|
+
new Error(
|
|
653
|
+
`awaitLine timeout after ${timeoutMs}ms. ` +
|
|
654
|
+
`Lines seen so far (stdout):\n${seenLines.join("\n")}\n` +
|
|
655
|
+
`(stderr):\n${seenStderr.join("\n")}`,
|
|
656
|
+
),
|
|
657
|
+
);
|
|
658
|
+
}, timeoutMs);
|
|
659
|
+
waiters.push({
|
|
660
|
+
predicate,
|
|
661
|
+
resolve: () => {
|
|
662
|
+
clearTimeout(timer);
|
|
663
|
+
// Find the matching line we just pushed (last one matching).
|
|
664
|
+
for (let i = seenLines.length - 1; i >= 0; i--) {
|
|
665
|
+
if (predicate(seenLines[i]!)) {
|
|
666
|
+
resolve(seenLines[i]!);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
resolve("");
|
|
671
|
+
},
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return {
|
|
677
|
+
proc,
|
|
678
|
+
awaitLine,
|
|
679
|
+
seenLines,
|
|
680
|
+
seenStderr,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
describe("export CLI: --watch", () => {
|
|
685
|
+
let tmp: string;
|
|
686
|
+
let exportDir: string;
|
|
687
|
+
|
|
688
|
+
beforeEach(async () => {
|
|
689
|
+
tmp = makeTmp("vault-watch-cli-");
|
|
690
|
+
exportDir = makeTmp("vault-watch-out-");
|
|
691
|
+
setupBareVault(tmp, "default");
|
|
692
|
+
await seedVaultWithNotes(tmp, "default", [
|
|
693
|
+
{ id: "01HZB111111111111111111111", path: "Inbox/seed", content: "# seed\n" },
|
|
694
|
+
]);
|
|
695
|
+
// Close the in-test store so the spawned CLI can open its own writer.
|
|
696
|
+
const { clearVaultStoreCache } = await import("./vault-store.ts");
|
|
697
|
+
clearVaultStoreCache();
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
afterEach(async () => {
|
|
701
|
+
const { clearVaultStoreCache } = await import("./vault-store.ts");
|
|
702
|
+
clearVaultStoreCache();
|
|
703
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
704
|
+
fs.rmSync(exportDir, { recursive: true, force: true });
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test(
|
|
708
|
+
"initial export + idle polling: status lines + SIGINT exits cleanly",
|
|
709
|
+
async () => {
|
|
710
|
+
const watch = spawnWatchCli(["export", exportDir, "--watch", "--interval", "1"], tmp);
|
|
711
|
+
try {
|
|
712
|
+
// Initial export prints the "Exported N notes" line.
|
|
713
|
+
await watch.awaitLine((l) => l.includes("Exported 1 note"), 10_000);
|
|
714
|
+
// Watch loop prints the polling banner.
|
|
715
|
+
await watch.awaitLine((l) => l.includes("[watch] polling every 1s"), 5_000);
|
|
716
|
+
// At least one idle tick.
|
|
717
|
+
await watch.awaitLine((l) => l.includes("[watch] no changes"), 5_000);
|
|
718
|
+
} finally {
|
|
719
|
+
watch.proc.kill("SIGINT");
|
|
720
|
+
}
|
|
721
|
+
const exit = await watch.proc.exited;
|
|
722
|
+
expect(exit).toBe(0);
|
|
723
|
+
// Stopping line printed on shutdown.
|
|
724
|
+
expect(watch.seenLines.some((l) => l.includes("[watch] stopping watch"))).toBe(true);
|
|
725
|
+
},
|
|
726
|
+
30_000,
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
test(
|
|
730
|
+
"vault write triggers a re-export within the next poll interval",
|
|
731
|
+
async () => {
|
|
732
|
+
const watch = spawnWatchCli(["export", exportDir, "--watch", "--interval", "1"], tmp);
|
|
733
|
+
try {
|
|
734
|
+
await watch.awaitLine((l) => l.includes("[watch] polling every 1s"), 10_000);
|
|
735
|
+
|
|
736
|
+
// Open the vault DB out-of-band and add a note. The watch loop
|
|
737
|
+
// should pick it up within ~2 intervals.
|
|
738
|
+
const { clearVaultStoreCache, getVaultStore } = await import("./vault-store.ts");
|
|
739
|
+
clearVaultStoreCache();
|
|
740
|
+
const store = getVaultStore("default");
|
|
741
|
+
await store.createNote("# new\n", {
|
|
742
|
+
id: "01HZB999999999999999999999",
|
|
743
|
+
path: "Inbox/triggered",
|
|
744
|
+
tags: ["watched"],
|
|
745
|
+
});
|
|
746
|
+
clearVaultStoreCache();
|
|
747
|
+
|
|
748
|
+
// Expect the watch-mode incremental-export line.
|
|
749
|
+
const line = await watch.awaitLine(
|
|
750
|
+
(l) => l.startsWith("[watch] exported"),
|
|
751
|
+
10_000,
|
|
752
|
+
);
|
|
753
|
+
expect(line).toMatch(/\[watch\] exported \d+ note/);
|
|
754
|
+
// And the on-disk file landed.
|
|
755
|
+
// Wait briefly for the write — awaitLine returned as soon as the
|
|
756
|
+
// log line appeared, but the file write happens immediately before.
|
|
757
|
+
} finally {
|
|
758
|
+
watch.proc.kill("SIGINT");
|
|
759
|
+
}
|
|
760
|
+
await watch.proc.exited;
|
|
761
|
+
expect(fs.existsSync(path.join(exportDir, "Inbox", "triggered.md"))).toBe(true);
|
|
762
|
+
},
|
|
763
|
+
30_000,
|
|
764
|
+
);
|
|
765
|
+
|
|
766
|
+
test(
|
|
767
|
+
"--watch + --git-commit: vault write → re-export → auto-commit → continues",
|
|
768
|
+
async () => {
|
|
769
|
+
initGitRepo(exportDir);
|
|
770
|
+
fs.writeFileSync(path.join(exportDir, ".gitkeep"), "");
|
|
771
|
+
Bun.spawnSync(["git", "add", "-A"], { cwd: exportDir });
|
|
772
|
+
Bun.spawnSync(["git", "commit", "-q", "-m", "initial"], { cwd: exportDir });
|
|
773
|
+
|
|
774
|
+
const watch = spawnWatchCli(
|
|
775
|
+
["export", exportDir, "--watch", "--interval", "1", "--git-commit"],
|
|
776
|
+
tmp,
|
|
777
|
+
);
|
|
778
|
+
try {
|
|
779
|
+
await watch.awaitLine((l) => l.includes("[watch] polling every 1s"), 10_000);
|
|
780
|
+
// Initial export should have produced a commit.
|
|
781
|
+
await watch.awaitLine((l) => l.startsWith("[git-commit] export:"), 10_000);
|
|
782
|
+
const afterInitial = gitLogOneline(exportDir).length;
|
|
783
|
+
|
|
784
|
+
// Add a note → expect another commit on the next cycle.
|
|
785
|
+
const { clearVaultStoreCache, getVaultStore } = await import("./vault-store.ts");
|
|
786
|
+
clearVaultStoreCache();
|
|
787
|
+
const store = getVaultStore("default");
|
|
788
|
+
await store.createNote("# live\n", {
|
|
789
|
+
id: "01HZC111111111111111111111",
|
|
790
|
+
path: "Inbox/live-edit",
|
|
791
|
+
});
|
|
792
|
+
clearVaultStoreCache();
|
|
793
|
+
|
|
794
|
+
// Wait for a second `[git-commit] export:` line (distinct from the
|
|
795
|
+
// initial one). Use the watch handle's seenLines snapshot to count.
|
|
796
|
+
await watch.awaitLine((_l) => {
|
|
797
|
+
const count = watch.seenLines.filter((line) =>
|
|
798
|
+
line.startsWith("[git-commit] export:"),
|
|
799
|
+
).length;
|
|
800
|
+
return count >= 2;
|
|
801
|
+
}, 15_000);
|
|
802
|
+
const afterEdit = gitLogOneline(exportDir).length;
|
|
803
|
+
expect(afterEdit).toBe(afterInitial + 1);
|
|
804
|
+
} finally {
|
|
805
|
+
watch.proc.kill("SIGINT");
|
|
806
|
+
}
|
|
807
|
+
await watch.proc.exited;
|
|
808
|
+
},
|
|
809
|
+
30_000,
|
|
810
|
+
);
|
|
811
|
+
});
|