@openparachute/vault 0.4.9-rc.7 → 0.4.9-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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.4.9-rc.7",
3
+ "version": "0.4.9-rc.8",
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,550 @@
1
+ /**
2
+ * Tests for the clone-and-import worker (vault#391).
3
+ *
4
+ * Mocks the git binary via the `spawn` injection point so we don't
5
+ * actually clone anything in tests — instead we pre-populate the tempdir
6
+ * the fake "clone" claims to have written, or have the fake return a
7
+ * non-zero exit code to exercise the failure paths.
8
+ */
9
+
10
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
11
+ import { Database } from "bun:sqlite";
12
+ import {
13
+ cpSync,
14
+ existsSync,
15
+ mkdirSync,
16
+ mkdtempSync,
17
+ rmSync,
18
+ writeFileSync,
19
+ } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ import { SqliteStore } from "../core/src/store.ts";
24
+ import { exportVaultToDir } from "../core/src/portable-md.ts";
25
+ import {
26
+ CloneFailedError,
27
+ ImportConflictError,
28
+ NotAVaultExportError,
29
+ _isImportInFlight,
30
+ _resetImportInFlightForTest,
31
+ authedCloneUrl,
32
+ cloneAndImport,
33
+ type GitSpawn,
34
+ } from "./mirror-import.ts";
35
+ import {
36
+ emptyCredentials,
37
+ mirrorCredentialsPath,
38
+ writeCredentials,
39
+ type MirrorCredentials,
40
+ } from "./mirror-credentials.ts";
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Helpers
44
+ // ---------------------------------------------------------------------------
45
+
46
+ function tmp(prefix: string): string {
47
+ return mkdtempSync(join(tmpdir(), prefix));
48
+ }
49
+
50
+ /**
51
+ * Build a real portable-md export on disk + return its path. Used by tests
52
+ * that need a valid clone-target so the import succeeds.
53
+ */
54
+ async function buildExportFixture(opts?: { extraNotes?: number }): Promise<string> {
55
+ const extraNotes = opts?.extraNotes ?? 0;
56
+ const store = new SqliteStore(new Database(":memory:"));
57
+ await store.createNote("alpha body", { id: "n-alpha", path: "alpha", tags: ["t1"] });
58
+ await store.createNote("beta body", { id: "n-beta", path: "beta" });
59
+ for (let i = 0; i < extraNotes; i++) {
60
+ await store.createNote(`extra ${i}`, { id: `n-extra-${i}`, path: `extra-${i}` });
61
+ }
62
+ const exportDir = tmp("import-fixture-export-");
63
+ await exportVaultToDir(store, {
64
+ outDir: exportDir,
65
+ vaultName: "source",
66
+ exportedAt: "2026-05-28T00:00:00.000Z",
67
+ });
68
+ return exportDir;
69
+ }
70
+
71
+ /**
72
+ * Build a fake spawn that copies a pre-baked fixture into the tempdir
73
+ * the importer expects. Mimics what `git clone <url> <tempDir>` would do
74
+ * on success: populate the tempDir with the fixture's contents.
75
+ */
76
+ function spawnCloneSuccess(fixtureDir: string): GitSpawn {
77
+ return async (argv) => {
78
+ // argv = ["git", "clone", "--depth", "1", <url>, <destDir>]
79
+ expect(argv[0]).toBe("git");
80
+ expect(argv[1]).toBe("clone");
81
+ const destDir = argv[argv.length - 1]!;
82
+ // Copy fixture into the destination. The destination already exists
83
+ // (mkdtempSync created it), so use cpSync's `recursive` mode.
84
+ cpSync(fixtureDir, destDir, { recursive: true });
85
+ return { exitCode: 0, stderr: "", timedOut: false };
86
+ };
87
+ }
88
+
89
+ const spawnCloneFailure = (stderr: string): GitSpawn =>
90
+ async () => ({ exitCode: 128, stderr, timedOut: false });
91
+
92
+ const spawnCloneTimeout: GitSpawn = async () => ({
93
+ exitCode: -1,
94
+ stderr: "",
95
+ timedOut: true,
96
+ });
97
+
98
+ const ORIG_HOME = process.env.HOME;
99
+ const ORIG_PARACHUTE_HOME = process.env.PARACHUTE_HOME;
100
+
101
+ afterEach(() => {
102
+ _resetImportInFlightForTest();
103
+ if (ORIG_HOME === undefined) delete process.env.HOME;
104
+ else process.env.HOME = ORIG_HOME;
105
+ if (ORIG_PARACHUTE_HOME === undefined) delete process.env.PARACHUTE_HOME;
106
+ else process.env.PARACHUTE_HOME = ORIG_PARACHUTE_HOME;
107
+ });
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // authedCloneUrl
111
+ // ---------------------------------------------------------------------------
112
+
113
+ describe("authedCloneUrl", () => {
114
+ test("returns null for unparseable URL", () => {
115
+ expect(authedCloneUrl("not-a-url", { kind: "none" })).toBeNull();
116
+ });
117
+
118
+ test("passes git:// URLs verbatim (no userinfo to embed)", () => {
119
+ // `git://github.com/owner/repo.git` parses as a URL with protocol
120
+ // `git:`, not http/https — our helper returns it verbatim.
121
+ const r = authedCloneUrl("git://github.com/owner/repo.git", { kind: "pat", token: "ghp_x" });
122
+ expect(r).not.toBeNull();
123
+ expect(r!.authedUrl).toBe("git://github.com/owner/repo.git");
124
+ expect(r!.appliedAuth).toBe("none");
125
+ });
126
+
127
+ // Reviewer-flagged on vault#390 — the original ssh-shorthand assertion
128
+ // accidentally tested `git://` instead of the `git@host:owner/repo`
129
+ // shape, leaving the ssh-shorthand regex branch uncovered. SSH
130
+ // shorthand doesn't parse as `new URL()` (no scheme), so authedCloneUrl
131
+ // falls through the regex matcher and returns the URL verbatim — there
132
+ // is no userinfo slot to embed a token into. This test pins that path.
133
+ test("passes ssh-shorthand URLs verbatim (no scheme, no userinfo slot)", () => {
134
+ const r = authedCloneUrl("git@github.com:owner/repo.git", {
135
+ kind: "pat",
136
+ token: "ghp_should_not_appear",
137
+ });
138
+ expect(r).not.toBeNull();
139
+ expect(r!.authedUrl).toBe("git@github.com:owner/repo.git");
140
+ expect(r!.authedUrl).not.toContain("ghp_should_not_appear");
141
+ expect(r!.appliedAuth).toBe("none");
142
+ });
143
+
144
+ test("passes ssh:// URLs verbatim", () => {
145
+ const r = authedCloneUrl("ssh://git@github.com/owner/repo.git", {
146
+ kind: "pat",
147
+ token: "ghp_x",
148
+ });
149
+ expect(r).not.toBeNull();
150
+ expect(r!.authedUrl).toBe("ssh://git@github.com/owner/repo.git");
151
+ expect(r!.appliedAuth).toBe("none");
152
+ });
153
+
154
+ test("does not override URL that already carries userinfo", () => {
155
+ const r = authedCloneUrl("https://user:pass@github.com/owner/repo.git", {
156
+ kind: "pat",
157
+ token: "ghp_x",
158
+ });
159
+ expect(r).not.toBeNull();
160
+ expect(r!.authedUrl).toContain("user:pass@");
161
+ expect(r!.appliedAuth).toBe("none");
162
+ });
163
+
164
+ test("per-call PAT embeds x-access-token user", () => {
165
+ const r = authedCloneUrl("https://github.com/owner/repo.git", {
166
+ kind: "pat",
167
+ token: "ghp_abc123",
168
+ });
169
+ expect(r).not.toBeNull();
170
+ expect(r!.authedUrl).toContain("x-access-token:ghp_abc123@");
171
+ expect(r!.appliedAuth).toBe("per_call_pat");
172
+ });
173
+
174
+ test("none auth returns verbatim URL", () => {
175
+ const r = authedCloneUrl("https://github.com/owner/repo.git", { kind: "none" });
176
+ expect(r).not.toBeNull();
177
+ expect(r!.authedUrl).toBe("https://github.com/owner/repo.git");
178
+ expect(r!.appliedAuth).toBe("none");
179
+ });
180
+
181
+ describe("credentialsFile path", () => {
182
+ let home: string;
183
+ beforeEach(() => {
184
+ home = tmp("import-creds-");
185
+ process.env.PARACHUTE_HOME = home;
186
+ process.env.HOME = home;
187
+ });
188
+ afterEach(() => {
189
+ if (home) rmSync(home, { recursive: true, force: true });
190
+ });
191
+
192
+ test("no credentials file → verbatim URL", () => {
193
+ const r = authedCloneUrl("https://github.com/owner/repo.git", { kind: "credentialsFile" });
194
+ expect(r!.appliedAuth).toBe("none");
195
+ });
196
+
197
+ test("stored github_oauth credentials → embed token on github.com", () => {
198
+ const creds: MirrorCredentials = {
199
+ ...emptyCredentials(),
200
+ active_method: "github_oauth",
201
+ github_oauth: {
202
+ access_token: "gho_abc",
203
+ scope: "repo",
204
+ authorized_at: "2026-05-28T00:00:00.000Z",
205
+ user_login: "aaron",
206
+ user_id: 1,
207
+ },
208
+ };
209
+ writeCredentials(creds);
210
+ const r = authedCloneUrl("https://github.com/owner/repo.git", { kind: "credentialsFile" });
211
+ expect(r!.authedUrl).toContain("x-access-token:gho_abc@");
212
+ expect(r!.appliedAuth).toBe("stored_oauth");
213
+ });
214
+
215
+ test("stored github_oauth + non-github host → verbatim (OAuth token is useless off-host)", () => {
216
+ const creds: MirrorCredentials = {
217
+ ...emptyCredentials(),
218
+ active_method: "github_oauth",
219
+ github_oauth: {
220
+ access_token: "gho_abc",
221
+ scope: "repo",
222
+ authorized_at: "2026-05-28T00:00:00.000Z",
223
+ user_login: "aaron",
224
+ user_id: 1,
225
+ },
226
+ };
227
+ writeCredentials(creds);
228
+ const r = authedCloneUrl("https://gitlab.com/owner/repo.git", { kind: "credentialsFile" });
229
+ expect(r!.appliedAuth).toBe("none");
230
+ });
231
+
232
+ test("stored PAT + matching host → embed token", () => {
233
+ const creds: MirrorCredentials = {
234
+ ...emptyCredentials(),
235
+ active_method: "pat",
236
+ pat: {
237
+ token: "glpat_xyz",
238
+ remote_url: "https://x-access-token:glpat_xyz@gitlab.com/owner/repo.git",
239
+ label: "GitLab PAT",
240
+ },
241
+ };
242
+ writeCredentials(creds);
243
+ const r = authedCloneUrl("https://gitlab.com/owner/repo.git", { kind: "credentialsFile" });
244
+ expect(r!.authedUrl).toContain("x-access-token:glpat_xyz@");
245
+ expect(r!.appliedAuth).toBe("stored_pat");
246
+ });
247
+
248
+ test("stored PAT + non-matching host → verbatim", () => {
249
+ const creds: MirrorCredentials = {
250
+ ...emptyCredentials(),
251
+ active_method: "pat",
252
+ pat: {
253
+ token: "glpat_xyz",
254
+ remote_url: "https://x-access-token:glpat_xyz@gitlab.com/owner/repo.git",
255
+ label: "GitLab PAT",
256
+ },
257
+ };
258
+ writeCredentials(creds);
259
+ const r = authedCloneUrl("https://github.com/owner/repo.git", { kind: "credentialsFile" });
260
+ expect(r!.appliedAuth).toBe("none");
261
+ });
262
+ });
263
+ });
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // cloneAndImport — success path
267
+ // ---------------------------------------------------------------------------
268
+
269
+ describe("cloneAndImport — success", () => {
270
+ let fixtureDir: string;
271
+ let assetsDir: string;
272
+ let store: SqliteStore;
273
+
274
+ beforeEach(async () => {
275
+ fixtureDir = await buildExportFixture();
276
+ assetsDir = tmp("import-assets-");
277
+ store = new SqliteStore(new Database(":memory:"));
278
+ });
279
+
280
+ afterEach(() => {
281
+ if (fixtureDir) rmSync(fixtureDir, { recursive: true, force: true });
282
+ if (assetsDir) rmSync(assetsDir, { recursive: true, force: true });
283
+ });
284
+
285
+ test("merge mode — imports notes from a fixture export", async () => {
286
+ const result = await cloneAndImport({
287
+ vaultName: "default",
288
+ remoteUrl: "https://github.com/owner/repo.git",
289
+ auth: { kind: "none" },
290
+ mode: "merge",
291
+ store,
292
+ assetsDir,
293
+ spawn: spawnCloneSuccess(fixtureDir),
294
+ });
295
+ expect(result.notes_imported).toBe(2); // alpha + beta
296
+ expect(result.notes_deleted).toBeUndefined();
297
+ expect(result.warnings).toEqual([]);
298
+
299
+ const restored = await store.getNote("n-alpha");
300
+ expect(restored).toBeTruthy();
301
+ expect(restored!.content.trimEnd()).toBe("alpha body");
302
+ });
303
+
304
+ test("merge mode — preserves existing notes that aren't in the remote", async () => {
305
+ // Seed a local note that the remote doesn't carry.
306
+ await store.createNote("local-only", { id: "n-local", path: "local" });
307
+ const result = await cloneAndImport({
308
+ vaultName: "default",
309
+ remoteUrl: "https://github.com/owner/repo.git",
310
+ auth: { kind: "none" },
311
+ mode: "merge",
312
+ store,
313
+ assetsDir,
314
+ spawn: spawnCloneSuccess(fixtureDir),
315
+ });
316
+ expect(result.notes_imported).toBe(2);
317
+ expect(result.notes_deleted).toBeUndefined();
318
+ // Local note survives.
319
+ const localStill = await store.getNote("n-local");
320
+ expect(localStill).toBeTruthy();
321
+ });
322
+
323
+ test("replace mode — wipes existing notes, sets notes_deleted", async () => {
324
+ await store.createNote("local-only", { id: "n-local", path: "local" });
325
+ const result = await cloneAndImport({
326
+ vaultName: "default",
327
+ remoteUrl: "https://github.com/owner/repo.git",
328
+ auth: { kind: "none" },
329
+ mode: "replace",
330
+ store,
331
+ assetsDir,
332
+ spawn: spawnCloneSuccess(fixtureDir),
333
+ });
334
+ expect(result.notes_imported).toBe(2);
335
+ expect(result.notes_deleted).toBe(1);
336
+ // Local note got wiped before the import replayed.
337
+ const localGone = await store.getNote("n-local");
338
+ expect(localGone).toBeNull();
339
+ });
340
+
341
+ test("cleans up tempdir on success", async () => {
342
+ const workDirRoot = tmp("import-workroot-");
343
+ await cloneAndImport({
344
+ vaultName: "default",
345
+ remoteUrl: "https://github.com/owner/repo.git",
346
+ auth: { kind: "none" },
347
+ mode: "merge",
348
+ store,
349
+ assetsDir,
350
+ spawn: spawnCloneSuccess(fixtureDir),
351
+ workDirRoot,
352
+ });
353
+ // workDirRoot itself still exists; the parachute-import-<rand> subdir
354
+ // inside it should be gone.
355
+ const { readdirSync } = await import("node:fs");
356
+ const entries = readdirSync(workDirRoot);
357
+ expect(entries.filter((e) => e.startsWith("parachute-import-"))).toEqual([]);
358
+ rmSync(workDirRoot, { recursive: true, force: true });
359
+ });
360
+ });
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // cloneAndImport — failure paths
364
+ // ---------------------------------------------------------------------------
365
+
366
+ describe("cloneAndImport — failures", () => {
367
+ let assetsDir: string;
368
+ let store: SqliteStore;
369
+
370
+ beforeEach(() => {
371
+ assetsDir = tmp("import-assets-fail-");
372
+ store = new SqliteStore(new Database(":memory:"));
373
+ });
374
+
375
+ afterEach(() => {
376
+ if (assetsDir) rmSync(assetsDir, { recursive: true, force: true });
377
+ });
378
+
379
+ test("invalid URL → CloneFailedError before any spawn", async () => {
380
+ let spawnCalled = false;
381
+ const fakeSpawn: GitSpawn = async () => {
382
+ spawnCalled = true;
383
+ return { exitCode: 0, stderr: "", timedOut: false };
384
+ };
385
+ await expect(
386
+ cloneAndImport({
387
+ vaultName: "default",
388
+ remoteUrl: "not-a-url",
389
+ auth: { kind: "none" },
390
+ mode: "merge",
391
+ store,
392
+ assetsDir,
393
+ spawn: fakeSpawn,
394
+ }),
395
+ ).rejects.toThrow(CloneFailedError);
396
+ expect(spawnCalled).toBe(false);
397
+ });
398
+
399
+ test("git clone non-zero exit → CloneFailedError with redacted stderr", async () => {
400
+ await expect(
401
+ cloneAndImport({
402
+ vaultName: "default",
403
+ remoteUrl: "https://github.com/owner/repo.git",
404
+ auth: { kind: "pat", token: "ghp_secret" },
405
+ mode: "merge",
406
+ store,
407
+ assetsDir,
408
+ spawn: spawnCloneFailure(
409
+ "fatal: could not read Password for 'https://x-access-token:ghp_secret@github.com'",
410
+ ),
411
+ }),
412
+ ).rejects.toThrow(/git clone failed/);
413
+
414
+ // Re-run to capture the error message; ensure the token is redacted.
415
+ try {
416
+ await cloneAndImport({
417
+ vaultName: "default",
418
+ remoteUrl: "https://github.com/owner/repo.git",
419
+ auth: { kind: "pat", token: "ghp_secret" },
420
+ mode: "merge",
421
+ store,
422
+ assetsDir,
423
+ spawn: spawnCloneFailure(
424
+ "fatal: could not read Password for 'https://x-access-token:ghp_secret@github.com'",
425
+ ),
426
+ });
427
+ } catch (err) {
428
+ const message = (err as Error).message;
429
+ expect(message).not.toContain("ghp_secret");
430
+ expect(message).toContain("***@");
431
+ }
432
+ });
433
+
434
+ test("clone timeout → CloneFailedError mentioning timeout", async () => {
435
+ await expect(
436
+ cloneAndImport({
437
+ vaultName: "default",
438
+ remoteUrl: "https://github.com/owner/repo.git",
439
+ auth: { kind: "none" },
440
+ mode: "merge",
441
+ store,
442
+ assetsDir,
443
+ spawn: spawnCloneTimeout,
444
+ cloneTimeoutMs: 100,
445
+ }),
446
+ ).rejects.toThrow(/timed out/);
447
+ });
448
+
449
+ test("clone target lacks .parachute/vault.yaml → NotAVaultExportError", async () => {
450
+ const notAnExport = tmp("import-not-export-");
451
+ writeFileSync(join(notAnExport, "README.md"), "hello");
452
+ const fakeSpawn: GitSpawn = async (argv) => {
453
+ const dest = argv[argv.length - 1]!;
454
+ cpSync(notAnExport, dest, { recursive: true });
455
+ return { exitCode: 0, stderr: "", timedOut: false };
456
+ };
457
+ await expect(
458
+ cloneAndImport({
459
+ vaultName: "default",
460
+ remoteUrl: "https://github.com/owner/repo.git",
461
+ auth: { kind: "none" },
462
+ mode: "merge",
463
+ store,
464
+ assetsDir,
465
+ spawn: fakeSpawn,
466
+ }),
467
+ ).rejects.toThrow(NotAVaultExportError);
468
+ rmSync(notAnExport, { recursive: true, force: true });
469
+ });
470
+
471
+ test("concurrent imports against the same vault → ImportConflictError", async () => {
472
+ // Build a real fixture so the long-running clone has something
473
+ // valid to find.
474
+ const fixture = await buildExportFixture();
475
+
476
+ // First import — slow spawn (resolves on the next tick), but starts
477
+ // immediately.
478
+ let firstSpawnGate: (v?: unknown) => void;
479
+ const firstSpawn: GitSpawn = async (argv) => {
480
+ await new Promise((res) => {
481
+ firstSpawnGate = res;
482
+ });
483
+ const dest = argv[argv.length - 1]!;
484
+ cpSync(fixture, dest, { recursive: true });
485
+ return { exitCode: 0, stderr: "", timedOut: false };
486
+ };
487
+
488
+ const firstPromise = cloneAndImport({
489
+ vaultName: "default",
490
+ remoteUrl: "https://github.com/owner/repo.git",
491
+ auth: { kind: "none" },
492
+ mode: "merge",
493
+ store,
494
+ assetsDir,
495
+ spawn: firstSpawn,
496
+ });
497
+
498
+ // Wait a tick so the inFlight set has populated.
499
+ await new Promise((res) => setTimeout(res, 10));
500
+ expect(_isImportInFlight("default")).toBe(true);
501
+
502
+ // Second import — should immediately reject.
503
+ await expect(
504
+ cloneAndImport({
505
+ vaultName: "default",
506
+ remoteUrl: "https://github.com/owner/repo.git",
507
+ auth: { kind: "none" },
508
+ mode: "merge",
509
+ store,
510
+ assetsDir,
511
+ spawn: spawnCloneSuccess(fixture),
512
+ }),
513
+ ).rejects.toThrow(ImportConflictError);
514
+
515
+ // Let the first import finish.
516
+ firstSpawnGate!();
517
+ await firstPromise;
518
+ expect(_isImportInFlight("default")).toBe(false);
519
+
520
+ rmSync(fixture, { recursive: true, force: true });
521
+ });
522
+
523
+ test("cleans up tempdir even when import throws", async () => {
524
+ const workDirRoot = tmp("import-workroot-fail-");
525
+ const notAnExport = tmp("import-not-export-fail-");
526
+ writeFileSync(join(notAnExport, "README.md"), "hello");
527
+ const fakeSpawn: GitSpawn = async (argv) => {
528
+ const dest = argv[argv.length - 1]!;
529
+ cpSync(notAnExport, dest, { recursive: true });
530
+ return { exitCode: 0, stderr: "", timedOut: false };
531
+ };
532
+ await expect(
533
+ cloneAndImport({
534
+ vaultName: "default",
535
+ remoteUrl: "https://github.com/owner/repo.git",
536
+ auth: { kind: "none" },
537
+ mode: "merge",
538
+ store,
539
+ assetsDir,
540
+ spawn: fakeSpawn,
541
+ workDirRoot,
542
+ }),
543
+ ).rejects.toThrow();
544
+ const { readdirSync } = await import("node:fs");
545
+ const entries = readdirSync(workDirRoot);
546
+ expect(entries.filter((e) => e.startsWith("parachute-import-"))).toEqual([]);
547
+ rmSync(workDirRoot, { recursive: true, force: true });
548
+ rmSync(notAnExport, { recursive: true, force: true });
549
+ });
550
+ });