@openparachute/vault 0.4.8 → 0.4.9-rc.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/core/src/core.test.ts +4 -1
  2. package/core/src/hooks.test.ts +320 -1
  3. package/core/src/hooks.ts +243 -38
  4. package/core/src/indexed-fields.test.ts +151 -0
  5. package/core/src/indexed-fields.ts +98 -0
  6. package/core/src/mcp.ts +99 -41
  7. package/core/src/notes.ts +26 -2
  8. package/core/src/portable-md.test.ts +304 -1
  9. package/core/src/portable-md.ts +418 -2
  10. package/core/src/schema.ts +114 -2
  11. package/core/src/store.ts +185 -2
  12. package/core/src/types.ts +28 -0
  13. package/package.json +2 -2
  14. package/src/auth-hub-jwt.test.ts +147 -0
  15. package/src/auth.ts +121 -1
  16. package/src/auto-transcribe.test.ts +7 -2
  17. package/src/auto-transcribe.ts +6 -2
  18. package/src/cli.ts +131 -36
  19. package/src/config.ts +12 -4
  20. package/src/export-watch.test.ts +74 -0
  21. package/src/export-watch.ts +108 -7
  22. package/src/github-device-flow.test.ts +404 -0
  23. package/src/github-device-flow.ts +415 -0
  24. package/src/hub-jwt.test.ts +27 -2
  25. package/src/hub-jwt.ts +10 -0
  26. package/src/mcp-http.ts +48 -39
  27. package/src/mcp-install-interactive.test.ts +10 -21
  28. package/src/mcp-install-interactive.ts +12 -21
  29. package/src/mcp-install.test.ts +141 -30
  30. package/src/mcp-install.ts +109 -3
  31. package/src/mcp-tools.ts +460 -3
  32. package/src/mirror-config.test.ts +277 -14
  33. package/src/mirror-config.ts +482 -31
  34. package/src/mirror-credentials.test.ts +601 -0
  35. package/src/mirror-credentials.ts +700 -0
  36. package/src/mirror-deps.ts +67 -17
  37. package/src/mirror-import.test.ts +550 -0
  38. package/src/mirror-import.ts +487 -0
  39. package/src/mirror-manager.test.ts +423 -12
  40. package/src/mirror-manager.ts +621 -72
  41. package/src/mirror-per-vault.test.ts +519 -0
  42. package/src/mirror-registry.ts +91 -14
  43. package/src/mirror-routes.test.ts +966 -10
  44. package/src/mirror-routes.ts +1111 -7
  45. package/src/module-config.ts +11 -5
  46. package/src/routes.ts +38 -1
  47. package/src/routing.test.ts +92 -1
  48. package/src/routing.ts +193 -20
  49. package/src/server.ts +116 -35
  50. package/src/storage.test.ts +132 -7
  51. package/src/token-store.ts +300 -5
  52. package/src/transcription-worker.ts +9 -4
  53. package/src/triggers.ts +16 -3
  54. package/src/vault.test.ts +681 -2
  55. package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
  56. package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
@@ -7,10 +7,16 @@
7
7
  * vault-store + portable-md.
8
8
  */
9
9
 
10
- import { exportVaultToDir } from "../core/src/portable-md.ts";
10
+ import { exportVaultToDir, hasSchemaContent, pruneOrphans } from "../core/src/portable-md.ts";
11
11
 
12
- import { readGlobalConfig, writeGlobalConfig, readVaultConfig } from "./config.ts";
13
- import { defaultMirrorConfig, type MirrorConfig } from "./mirror-config.ts";
12
+ import { defaultHookRegistry } from "../core/src/hooks.ts";
13
+ import { readGlobalConfig, readVaultConfig } from "./config.ts";
14
+ import {
15
+ defaultMirrorConfig,
16
+ readMirrorConfigForVault,
17
+ writeMirrorConfigForVault,
18
+ type MirrorConfig,
19
+ } from "./mirror-config.ts";
14
20
  import type { MirrorDeps } from "./mirror-manager.ts";
15
21
  import { assetsDir } from "./routes.ts";
16
22
  import { getVaultStore } from "./vault-store.ts";
@@ -23,9 +29,9 @@ import { getVaultStore } from "./vault-store.ts";
23
29
  * CLI mode exactly.
24
30
  * - `firstChangedNoteTitle` → DB query for the most recent note with
25
31
  * `updated_at >= cursor`. Identical to the CLI helper.
26
- * - `readMirrorConfig` / `writeMirrorConfig` → round-trip through
27
- * `readGlobalConfig` + `writeGlobalConfig`, preserving the rest of
28
- * the global config file atomically.
32
+ * - `readMirrorConfig` / `writeMirrorConfig` → per-vault config file at
33
+ * `data/<vault>/mirror-config.yaml` (vault#400). Each vault carries its
34
+ * own mirror config, so configuring vault B never touches vault A's.
29
35
  */
30
36
  export function buildMirrorDeps(vaultName: string): MirrorDeps {
31
37
  return {
@@ -42,6 +48,41 @@ export function buildMirrorDeps(vaultName: string): MirrorDeps {
42
48
  });
43
49
  return { notes: stats.notes };
44
50
  },
51
+ runPrune: async ({ outDir }) => {
52
+ const store = getVaultStore(vaultName);
53
+ // Build the valid-id sets the prune sweep needs. Single-query
54
+ // walk per dimension; cheap on typical vaults.
55
+ const allNotes = await store.queryNotes({ limit: 1_000_000, sort: "asc" });
56
+ const validNoteIds = new Set(allNotes.map((n) => n.id));
57
+ // Tag names with schema content drive the schema sidecars. Filter
58
+ // through `hasSchemaContent` — a tag whose schema content was wiped
59
+ // via `deleteTagSchema` keeps its tags-table row (bare name), so a
60
+ // map-by-name set would leave the stale sidecar in the mirror
61
+ // indefinitely. Only schema-bearing tags belong in this set.
62
+ // Reviewer-flagged on vault#382 (Critical #1).
63
+ const tagRecords = await store.listTagRecords();
64
+ const validTagNames = new Set(
65
+ tagRecords.filter((t) => hasSchemaContent(t)).map((t) => t.tag),
66
+ );
67
+ // Attachment IDs across all notes (the prune sweep keys on id).
68
+ const validAttachmentIds = new Set<string>();
69
+ for (const note of allNotes) {
70
+ const atts = await store.getAttachments(note.id);
71
+ for (const a of atts) validAttachmentIds.add(a.id);
72
+ }
73
+ const stats = pruneOrphans({
74
+ outDir,
75
+ validNoteIds,
76
+ validTagNames,
77
+ validAttachmentIds,
78
+ });
79
+ return {
80
+ notes_removed: stats.notes_removed,
81
+ sidecars_removed: stats.sidecars_removed,
82
+ schemas_removed: stats.schemas_removed,
83
+ attachment_dirs_removed: stats.attachment_dirs_removed,
84
+ };
85
+ },
45
86
  firstChangedNoteTitle: async (cursor) => {
46
87
  if (!cursor) return "";
47
88
  try {
@@ -56,24 +97,33 @@ export function buildMirrorDeps(vaultName: string): MirrorDeps {
56
97
  return "";
57
98
  }
58
99
  },
59
- readMirrorConfig: () => readGlobalConfig().mirror,
100
+ // Per-vault (vault#400): read/write THIS vault's own config file, never
101
+ // a shared server-wide block. Configuring vault B's mirror leaves vault
102
+ // A's config untouched.
103
+ readMirrorConfig: () => readMirrorConfigForVault(vaultName),
60
104
  writeMirrorConfig: (config: MirrorConfig) => {
61
- const global = readGlobalConfig();
62
- global.mirror = config;
63
- writeGlobalConfig(global);
105
+ writeMirrorConfigForVault(vaultName, config);
64
106
  },
107
+ // Share the process-wide hook registry so mirror's subscriptions land
108
+ // on the same event bus that `BunSqliteStore` dispatches on. This is
109
+ // load-bearing for the event-driven path; without it, the manager
110
+ // falls back to safety-net polling only.
111
+ hooks: defaultHookRegistry,
65
112
  };
66
113
  }
67
114
 
68
115
  /**
69
- * Resolve the mirror's owning vault. Today the mirror is per-server
70
- * (single config block in `config.yaml`), and the natural binding is
71
- * `default_vault` (the same vault the CLI + MCP wire up by default).
72
- * If no default is set, fall back to the first listed vault.
116
+ * Resolve the mirror's "owning" vault the one the LEGACY server-wide
117
+ * config + credentials are attributed to during migration.
73
118
  *
74
- * Multi-vault mirror routing is future work (open question 2 in the
75
- * design doc); this helper localizes the binding decision so a future
76
- * refactor only touches one site.
119
+ * Post-vault#400 every vault has its own mirror config + manager (real
120
+ * multi-vault mirroring), so this is no longer "the one vault that can
121
+ * mirror." It survives as the migration-attribution target: the legacy
122
+ * server-wide `mirror:` block (vault#400) and the legacy server-wide
123
+ * credentials file (vault#399) belong to the vault the single old mirror
124
+ * was bound to — `default_vault`, or the first listed vault when no default
125
+ * is set. Localizing the binding here keeps the migration attribution in one
126
+ * place.
77
127
  */
78
128
  export function resolveMirrorVaultName(
79
129
  listVaults: () => string[],
@@ -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" }, "v")).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" }, "v");
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
+ }, "v");
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
+ }, "v");
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
+ }, "v");
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
+ }, "v");
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" }, "v");
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" }, "default");
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("default", creds);
210
+ const r = authedCloneUrl("https://github.com/owner/repo.git", { kind: "credentialsFile" }, "default");
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("default", creds);
228
+ const r = authedCloneUrl("https://gitlab.com/owner/repo.git", { kind: "credentialsFile" }, "default");
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("default", creds);
243
+ const r = authedCloneUrl("https://gitlab.com/owner/repo.git", { kind: "credentialsFile" }, "default");
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("default", creds);
259
+ const r = authedCloneUrl("https://github.com/owner/repo.git", { kind: "credentialsFile" }, "default");
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
+ });