@openparachute/vault 0.4.9-rc.9 → 0.5.0-rc.2

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 (62) hide show
  1. package/README.md +51 -54
  2. package/core/src/core.test.ts +4 -1
  3. package/core/src/indexed-fields.test.ts +151 -0
  4. package/core/src/indexed-fields.ts +98 -0
  5. package/core/src/mcp.ts +66 -43
  6. package/core/src/notes.ts +26 -2
  7. package/core/src/portable-md.test.ts +52 -0
  8. package/core/src/portable-md.ts +48 -0
  9. package/core/src/schema.ts +87 -14
  10. package/core/src/store.ts +117 -0
  11. package/core/src/types.ts +28 -0
  12. package/package.json +2 -2
  13. package/src/auth-hub-jwt.test.ts +191 -11
  14. package/src/auth-status.ts +12 -5
  15. package/src/auth.test.ts +135 -219
  16. package/src/auth.ts +158 -107
  17. package/src/cli.ts +306 -224
  18. package/src/config.ts +12 -4
  19. package/src/export-watch.test.ts +23 -0
  20. package/src/export-watch.ts +14 -0
  21. package/src/git-preflight.test.ts +70 -0
  22. package/src/git-preflight.ts +68 -0
  23. package/src/hub-jwt.test.ts +27 -2
  24. package/src/hub-jwt.ts +10 -0
  25. package/src/init-summary.test.ts +4 -4
  26. package/src/init-summary.ts +36 -10
  27. package/src/mcp-config.test.ts +4 -2
  28. package/src/mcp-http.ts +24 -3
  29. package/src/mcp-install-interactive.test.ts +33 -71
  30. package/src/mcp-install-interactive.ts +23 -76
  31. package/src/mcp-install.test.ts +156 -55
  32. package/src/mcp-install.ts +109 -3
  33. package/src/mcp-tools.ts +249 -74
  34. package/src/mirror-config.test.ts +107 -0
  35. package/src/mirror-config.ts +275 -9
  36. package/src/mirror-credentials.test.ts +168 -17
  37. package/src/mirror-credentials.ts +155 -32
  38. package/src/mirror-deps.ts +25 -16
  39. package/src/mirror-import.test.ts +122 -16
  40. package/src/mirror-import.ts +50 -16
  41. package/src/mirror-manager.test.ts +51 -0
  42. package/src/mirror-manager.ts +116 -22
  43. package/src/mirror-per-vault.test.ts +519 -0
  44. package/src/mirror-registry.ts +91 -14
  45. package/src/mirror-routes.test.ts +81 -21
  46. package/src/mirror-routes.ts +90 -16
  47. package/src/routes.ts +39 -2
  48. package/src/routing.test.ts +203 -118
  49. package/src/routing.ts +46 -59
  50. package/src/scopes.test.ts +0 -86
  51. package/src/scopes.ts +9 -97
  52. package/src/server.ts +102 -34
  53. package/src/storage.test.ts +132 -7
  54. package/src/token-store.test.ts +88 -169
  55. package/src/token-store.ts +123 -249
  56. package/src/vault-create.test.ts +12 -4
  57. package/src/vault.test.ts +408 -103
  58. package/web/ui/dist/assets/index-DDRo6F4u.js +60 -0
  59. package/web/ui/dist/index.html +1 -1
  60. package/src/tokens-routes.test.ts +0 -727
  61. package/src/tokens-routes.ts +0 -392
  62. package/web/ui/dist/assets/index-Degr8snN.js +0 -60
@@ -23,16 +23,27 @@
23
23
  * embeds them in the mirror's remote URL. Works against GitHub, GitLab,
24
24
  * Codeberg, Gitea, anything that accepts an HTTPS token in the URL.
25
25
  *
26
- * **Storage:** `<configDir>/vault/.mirror-credentials.yaml`, perms `0o600`,
27
- * **not encrypted at rest**. Rationale: encryption-at-rest with the key on
28
- * the same disk doesn't add real security; OS perms ARE the protection. Same
29
- * trust model as `~/.git-credentials` (which most operators already use).
30
- * The file is documented as sensitive; redaction in logs is enforced by
31
- * `sanitizeCredentials` + a discipline of "never log the raw token."
26
+ * **Storage:** `<configDir>/vault/data/<vaultName>/.mirror-credentials.yaml`,
27
+ * perms `0o600`, **not encrypted at rest**. Rationale: encryption-at-rest
28
+ * with the key on the same disk doesn't add real security; OS perms ARE the
29
+ * protection. Same trust model as `~/.git-credentials` (which most operators
30
+ * already use). The file is documented as sensitive; redaction in logs is
31
+ * enforced by `sanitizeCredentials` + a discipline of "never log the raw
32
+ * token."
33
+ *
34
+ * **Per-vault (vault#399).** Credentials — both the PAT and the embedded
35
+ * `remote_url` — live under each vault's own data dir, alongside its SQLite
36
+ * DB + vault.yaml. This is the existing per-vault-state pattern. Before
37
+ * vault#399 they lived in a single SERVER-WIDE file
38
+ * (`<configDir>/vault/.mirror-credentials.yaml`); that leaked the first
39
+ * vault's remote + PAT onto every other vault that configured git sync —
40
+ * pointing vault B at vault A's GitHub repo. The legacy server-wide file is
41
+ * migrated to its owning vault on first per-vault read (see
42
+ * `migrateLegacyServerWideCredentials`).
32
43
  *
33
44
  * **One credential set per vault.** Multi-credential ("I want repo A pushed
34
- * with token X, repo B with token Y") isn't supported — vault#382 ships one
35
- * mirror per vault server today; one credential set per vault matches.
45
+ * with token X, repo B with token Y") within a single vault isn't supported —
46
+ * vault#382 ships one mirror per vault; one credential set per vault matches.
36
47
  */
37
48
 
38
49
  import {
@@ -98,8 +109,10 @@ export interface PATCredential {
98
109
  }
99
110
 
100
111
  /**
101
- * The on-disk + on-the-wire shape. One file per vault server (matches the
102
- * "one mirror per vault server today" invariant from mirror-config.ts).
112
+ * The on-disk + on-the-wire shape. One file per VAULT (vault#399) keyed by
113
+ * the vault's data dir, not the server. Each vault has its own PAT + its own
114
+ * `remote_url`, so configuring git sync for vault B never reuses vault A's
115
+ * remote.
103
116
  */
104
117
  export interface MirrorCredentials {
105
118
  /**
@@ -137,23 +150,38 @@ export interface MirrorCredentialsPublic {
137
150
  // Path resolution
138
151
  // ---------------------------------------------------------------------------
139
152
 
153
+ /** The vault home root — `<configDir>/vault`. Re-reads PARACHUTE_HOME per call. */
154
+ function vaultHomeRoot(): string {
155
+ const root = process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
156
+ return join(root, "vault");
157
+ }
158
+
140
159
  /**
141
- * Path to the per-vault-server credentials file.
160
+ * Path to a vault's per-vault credentials file (vault#399).
142
161
  *
143
- * Note: this is a SERVER-wide credentials file (`<configDir>/vault/.mirror-credentials.yaml`),
144
- * not a per-vault file. The mirror manager itself is server-wide (one mirror
145
- * per vault server today) so the credentials follow that scope. When multi-
146
- * vault mirroring lands (open question 2 in the design doc), this becomes
147
- * per-vault and gets keyed by name. Today's shape: one file.
162
+ * `<configDir>/vault/data/<vaultName>/.mirror-credentials.yaml` — under the
163
+ * vault's own data dir, alongside its SQLite DB (`vault.db`) + config
164
+ * (`vault.yaml`). This is the canonical per-vault-state location; every
165
+ * vault carries its own PAT + remote_url so git sync for one vault never
166
+ * reuses another vault's remote.
148
167
  *
149
- * Path resolution mirrors `config.ts:vaultHomePath()` — re-reads
150
- * `PARACHUTE_HOME` on every call so test sandboxes (`PARACHUTE_VAULT_HOME`
151
- * is a vault-internal var that doesn't override here we use the canonical
152
- * `PARACHUTE_HOME` that the rest of vault honors).
168
+ * Path resolution mirrors `config.ts:vaultDir()` rather than importing it
169
+ * mirror-config.ts imports this module and config.ts imports mirror-config.ts,
170
+ * so importing config.ts here would close an import cycle. We re-derive the
171
+ * path from `PARACHUTE_HOME` (the canonical override the rest of vault honors)
172
+ * instead, which keeps this module dependency-light.
153
173
  */
154
- export function mirrorCredentialsPath(): string {
155
- const root = process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
156
- return join(root, "vault", ".mirror-credentials.yaml");
174
+ export function mirrorCredentialsPath(vaultName: string): string {
175
+ return join(vaultHomeRoot(), "data", vaultName, ".mirror-credentials.yaml");
176
+ }
177
+
178
+ /**
179
+ * Path to the LEGACY server-wide credentials file
180
+ * (`<configDir>/vault/.mirror-credentials.yaml`) used before vault#399.
181
+ * Retained only so the migration can find + attribute + rename it.
182
+ */
183
+ export function legacyServerWideCredentialsPath(): string {
184
+ return join(vaultHomeRoot(), ".mirror-credentials.yaml");
157
185
  }
158
186
 
159
187
  // ---------------------------------------------------------------------------
@@ -344,12 +372,18 @@ export function parseCredentials(yaml: string): MirrorCredentials {
344
372
  // ---------------------------------------------------------------------------
345
373
 
346
374
  /**
347
- * Read credentials from disk. Returns `null` when the file doesn't exist
348
- * (operator hasn't connected anything yet); throws when the file is present
349
- * but unreadable (a permission error is a loud configuration problem).
375
+ * Read a vault's credentials from disk. Returns `null` when the file doesn't
376
+ * exist (operator hasn't connected anything yet); throws when the file is
377
+ * present but unreadable (a permission error is a loud configuration problem).
378
+ *
379
+ * vault#399 migration: if the per-vault file is absent but the legacy
380
+ * server-wide file exists, `migrateLegacyServerWideCredentials` may have
381
+ * promoted it to THIS vault (when it's the migration target). The migration
382
+ * runs at boot (server.ts) and is idempotent; this read just picks up
383
+ * whatever landed on disk.
350
384
  */
351
- export function readCredentials(): MirrorCredentials | null {
352
- const path = mirrorCredentialsPath();
385
+ export function readCredentials(vaultName: string): MirrorCredentials | null {
386
+ const path = mirrorCredentialsPath(vaultName);
353
387
  if (!existsSync(path)) return null;
354
388
  const raw = readFileSync(path, "utf8");
355
389
  return parseCredentials(raw);
@@ -366,8 +400,8 @@ export function readCredentials(): MirrorCredentials | null {
366
400
  * surface a 500 with the underlying message — quietly losing credentials
367
401
  * would be worse than crashing the request.
368
402
  */
369
- export function writeCredentials(creds: MirrorCredentials): void {
370
- const path = mirrorCredentialsPath();
403
+ export function writeCredentials(vaultName: string, creds: MirrorCredentials): void {
404
+ const path = mirrorCredentialsPath(vaultName);
371
405
  const dir = dirname(path);
372
406
  // Vault home may not exist yet (tests, fresh installs); create it.
373
407
  if (!existsSync(dir)) {
@@ -401,11 +435,100 @@ export function writeCredentials(creds: MirrorCredentials): void {
401
435
  * Delete the credentials file. Idempotent — missing file is a no-op (the
402
436
  * Disconnect UX should succeed even if the file was already removed).
403
437
  */
404
- export function deleteCredentials(): void {
405
- const path = mirrorCredentialsPath();
438
+ export function deleteCredentials(vaultName: string): void {
439
+ const path = mirrorCredentialsPath(vaultName);
406
440
  if (existsSync(path)) unlinkSync(path);
407
441
  }
408
442
 
443
+ // ---------------------------------------------------------------------------
444
+ // Migration — legacy server-wide → per-vault (vault#399)
445
+ // ---------------------------------------------------------------------------
446
+
447
+ /**
448
+ * One-time migration of the legacy server-wide credentials file to the
449
+ * per-vault layout (vault#399).
450
+ *
451
+ * The bug: pre-vault#399, credentials (PAT + embedded `remote_url`) lived in a
452
+ * single `<configDir>/vault/.mirror-credentials.yaml` shared across ALL
453
+ * vaults. Configuring git sync for a second vault reused the first vault's
454
+ * remote, pointing it at the wrong GitHub repo.
455
+ *
456
+ * Attribution policy (SAFEST per the design): attribute the legacy creds to
457
+ * the FIRST vault — the earliest-created one, which is what
458
+ * `resolveMirrorVaultName()` already bound the single server-wide mirror to.
459
+ * That's the vault whose remote/PAT the legacy file actually corresponds to.
460
+ * We do NOT copy the same remote/PAT onto every vault — that would recreate
461
+ * the leak.
462
+ *
463
+ * Safety:
464
+ * - No-op when no legacy file exists (fresh installs, already-migrated).
465
+ * - No-op when the target vault already has a per-vault file (don't clobber
466
+ * creds the operator set post-migration).
467
+ * - Leaves the legacy file in place renamed `.bak` (never silently deleted)
468
+ * so nothing is lost if attribution was wrong — the operator can recover.
469
+ * - Logs the attribution decision clearly.
470
+ *
471
+ * @param targetVaultName the vault to attribute the legacy creds to (caller
472
+ * passes the result of `resolveMirrorVaultName()`, i.e. default-or-first).
473
+ * @returns a struct describing what happened, for logging + tests.
474
+ */
475
+ export function migrateLegacyServerWideCredentials(
476
+ targetVaultName: string | null,
477
+ ):
478
+ | { migrated: false; reason: "no_legacy_file" | "no_target_vault" | "target_already_has_creds" }
479
+ | { migrated: true; targetVaultName: string; backupPath: string } {
480
+ const legacyPath = legacyServerWideCredentialsPath();
481
+ if (!existsSync(legacyPath)) {
482
+ return { migrated: false, reason: "no_legacy_file" };
483
+ }
484
+ if (!targetVaultName) {
485
+ // Legacy file present but no vault to attribute it to. Leave it in
486
+ // place — a future boot (once a vault exists) migrates it.
487
+ return { migrated: false, reason: "no_target_vault" };
488
+ }
489
+ const targetPath = mirrorCredentialsPath(targetVaultName);
490
+ if (existsSync(targetPath)) {
491
+ // The target vault already has per-vault creds (operator configured
492
+ // them post-migration, or a prior migration ran). Don't clobber.
493
+ // Still rename the legacy file so we don't re-evaluate it forever.
494
+ const backupPath = `${legacyPath}.bak`;
495
+ try {
496
+ if (!existsSync(backupPath)) renameSync(legacyPath, backupPath);
497
+ } catch {
498
+ // Non-fatal — worst case we re-check next boot and short-circuit here.
499
+ }
500
+ return { migrated: false, reason: "target_already_has_creds" };
501
+ }
502
+
503
+ // Read the legacy creds + write them to the target vault's per-vault file.
504
+ const raw = readFileSync(legacyPath, "utf8");
505
+ const creds = parseCredentials(raw);
506
+ writeCredentials(targetVaultName, creds);
507
+
508
+ // Rename the legacy file to .bak rather than deleting — never silently
509
+ // lose an operator's only copy of a token/remote.
510
+ const backupPath = `${legacyPath}.bak`;
511
+ try {
512
+ renameSync(legacyPath, backupPath);
513
+ } catch {
514
+ // If the rename fails (e.g. .bak already exists from a partial prior
515
+ // run), fall back to unlinking the original — the creds are now safely
516
+ // in the per-vault file.
517
+ try {
518
+ unlinkSync(legacyPath);
519
+ } catch {
520
+ // Leave it; the target_already_has_creds branch short-circuits next boot.
521
+ }
522
+ }
523
+
524
+ console.log(
525
+ `[mirror] migrated legacy server-wide mirror credentials → vault "${targetVaultName}" (per-vault, vault#399). ` +
526
+ `Other vaults start with no mirror credentials (configure each separately). ` +
527
+ `Legacy file preserved at ${backupPath}.`,
528
+ );
529
+ return { migrated: true, targetVaultName, backupPath };
530
+ }
531
+
409
532
  // ---------------------------------------------------------------------------
410
533
  // Redaction
411
534
  // ---------------------------------------------------------------------------
@@ -10,8 +10,13 @@
10
10
  import { exportVaultToDir, hasSchemaContent, pruneOrphans } from "../core/src/portable-md.ts";
11
11
 
12
12
  import { defaultHookRegistry } from "../core/src/hooks.ts";
13
- import { readGlobalConfig, writeGlobalConfig, readVaultConfig } from "./config.ts";
14
- import { defaultMirrorConfig, type MirrorConfig } from "./mirror-config.ts";
13
+ import { readGlobalConfig, readVaultConfig } from "./config.ts";
14
+ import {
15
+ defaultMirrorConfig,
16
+ readMirrorConfigForVault,
17
+ writeMirrorConfigForVault,
18
+ type MirrorConfig,
19
+ } from "./mirror-config.ts";
15
20
  import type { MirrorDeps } from "./mirror-manager.ts";
16
21
  import { assetsDir } from "./routes.ts";
17
22
  import { getVaultStore } from "./vault-store.ts";
@@ -24,9 +29,9 @@ import { getVaultStore } from "./vault-store.ts";
24
29
  * CLI mode exactly.
25
30
  * - `firstChangedNoteTitle` → DB query for the most recent note with
26
31
  * `updated_at >= cursor`. Identical to the CLI helper.
27
- * - `readMirrorConfig` / `writeMirrorConfig` → round-trip through
28
- * `readGlobalConfig` + `writeGlobalConfig`, preserving the rest of
29
- * 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.
30
35
  */
31
36
  export function buildMirrorDeps(vaultName: string): MirrorDeps {
32
37
  return {
@@ -92,11 +97,12 @@ export function buildMirrorDeps(vaultName: string): MirrorDeps {
92
97
  return "";
93
98
  }
94
99
  },
95
- 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),
96
104
  writeMirrorConfig: (config: MirrorConfig) => {
97
- const global = readGlobalConfig();
98
- global.mirror = config;
99
- writeGlobalConfig(global);
105
+ writeMirrorConfigForVault(vaultName, config);
100
106
  },
101
107
  // Share the process-wide hook registry so mirror's subscriptions land
102
108
  // on the same event bus that `BunSqliteStore` dispatches on. This is
@@ -107,14 +113,17 @@ export function buildMirrorDeps(vaultName: string): MirrorDeps {
107
113
  }
108
114
 
109
115
  /**
110
- * Resolve the mirror's owning vault. Today the mirror is per-server
111
- * (single config block in `config.yaml`), and the natural binding is
112
- * `default_vault` (the same vault the CLI + MCP wire up by default).
113
- * 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.
114
118
  *
115
- * Multi-vault mirror routing is future work (open question 2 in the
116
- * design doc); this helper localizes the binding decision so a future
117
- * 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.
118
127
  */
119
128
  export function resolveMirrorVaultName(
120
129
  listVaults: () => string[],
@@ -30,8 +30,10 @@ import {
30
30
  _resetImportInFlightForTest,
31
31
  authedCloneUrl,
32
32
  cloneAndImport,
33
+ defaultGitSpawn,
33
34
  type GitSpawn,
34
35
  } from "./mirror-import.ts";
36
+ import { GitNotInstalledError } from "./git-preflight.ts";
35
37
  import {
36
38
  emptyCredentials,
37
39
  mirrorCredentialsPath,
@@ -112,13 +114,13 @@ afterEach(() => {
112
114
 
113
115
  describe("authedCloneUrl", () => {
114
116
  test("returns null for unparseable URL", () => {
115
- expect(authedCloneUrl("not-a-url", { kind: "none" })).toBeNull();
117
+ expect(authedCloneUrl("not-a-url", { kind: "none" }, "v")).toBeNull();
116
118
  });
117
119
 
118
120
  test("passes git:// URLs verbatim (no userinfo to embed)", () => {
119
121
  // `git://github.com/owner/repo.git` parses as a URL with protocol
120
122
  // `git:`, not http/https — our helper returns it verbatim.
121
- const r = authedCloneUrl("git://github.com/owner/repo.git", { kind: "pat", token: "ghp_x" });
123
+ const r = authedCloneUrl("git://github.com/owner/repo.git", { kind: "pat", token: "ghp_x" }, "v");
122
124
  expect(r).not.toBeNull();
123
125
  expect(r!.authedUrl).toBe("git://github.com/owner/repo.git");
124
126
  expect(r!.appliedAuth).toBe("none");
@@ -134,7 +136,7 @@ describe("authedCloneUrl", () => {
134
136
  const r = authedCloneUrl("git@github.com:owner/repo.git", {
135
137
  kind: "pat",
136
138
  token: "ghp_should_not_appear",
137
- });
139
+ }, "v");
138
140
  expect(r).not.toBeNull();
139
141
  expect(r!.authedUrl).toBe("git@github.com:owner/repo.git");
140
142
  expect(r!.authedUrl).not.toContain("ghp_should_not_appear");
@@ -145,7 +147,7 @@ describe("authedCloneUrl", () => {
145
147
  const r = authedCloneUrl("ssh://git@github.com/owner/repo.git", {
146
148
  kind: "pat",
147
149
  token: "ghp_x",
148
- });
150
+ }, "v");
149
151
  expect(r).not.toBeNull();
150
152
  expect(r!.authedUrl).toBe("ssh://git@github.com/owner/repo.git");
151
153
  expect(r!.appliedAuth).toBe("none");
@@ -155,7 +157,7 @@ describe("authedCloneUrl", () => {
155
157
  const r = authedCloneUrl("https://user:pass@github.com/owner/repo.git", {
156
158
  kind: "pat",
157
159
  token: "ghp_x",
158
- });
160
+ }, "v");
159
161
  expect(r).not.toBeNull();
160
162
  expect(r!.authedUrl).toContain("user:pass@");
161
163
  expect(r!.appliedAuth).toBe("none");
@@ -165,14 +167,14 @@ describe("authedCloneUrl", () => {
165
167
  const r = authedCloneUrl("https://github.com/owner/repo.git", {
166
168
  kind: "pat",
167
169
  token: "ghp_abc123",
168
- });
170
+ }, "v");
169
171
  expect(r).not.toBeNull();
170
172
  expect(r!.authedUrl).toContain("x-access-token:ghp_abc123@");
171
173
  expect(r!.appliedAuth).toBe("per_call_pat");
172
174
  });
173
175
 
174
176
  test("none auth returns verbatim URL", () => {
175
- const r = authedCloneUrl("https://github.com/owner/repo.git", { kind: "none" });
177
+ const r = authedCloneUrl("https://github.com/owner/repo.git", { kind: "none" }, "v");
176
178
  expect(r).not.toBeNull();
177
179
  expect(r!.authedUrl).toBe("https://github.com/owner/repo.git");
178
180
  expect(r!.appliedAuth).toBe("none");
@@ -190,7 +192,7 @@ describe("authedCloneUrl", () => {
190
192
  });
191
193
 
192
194
  test("no credentials file → verbatim URL", () => {
193
- const r = authedCloneUrl("https://github.com/owner/repo.git", { kind: "credentialsFile" });
195
+ const r = authedCloneUrl("https://github.com/owner/repo.git", { kind: "credentialsFile" }, "default");
194
196
  expect(r!.appliedAuth).toBe("none");
195
197
  });
196
198
 
@@ -206,8 +208,8 @@ describe("authedCloneUrl", () => {
206
208
  user_id: 1,
207
209
  },
208
210
  };
209
- writeCredentials(creds);
210
- const r = authedCloneUrl("https://github.com/owner/repo.git", { kind: "credentialsFile" });
211
+ writeCredentials("default", creds);
212
+ const r = authedCloneUrl("https://github.com/owner/repo.git", { kind: "credentialsFile" }, "default");
211
213
  expect(r!.authedUrl).toContain("x-access-token:gho_abc@");
212
214
  expect(r!.appliedAuth).toBe("stored_oauth");
213
215
  });
@@ -224,8 +226,8 @@ describe("authedCloneUrl", () => {
224
226
  user_id: 1,
225
227
  },
226
228
  };
227
- writeCredentials(creds);
228
- const r = authedCloneUrl("https://gitlab.com/owner/repo.git", { kind: "credentialsFile" });
229
+ writeCredentials("default", creds);
230
+ const r = authedCloneUrl("https://gitlab.com/owner/repo.git", { kind: "credentialsFile" }, "default");
229
231
  expect(r!.appliedAuth).toBe("none");
230
232
  });
231
233
 
@@ -239,8 +241,8 @@ describe("authedCloneUrl", () => {
239
241
  label: "GitLab PAT",
240
242
  },
241
243
  };
242
- writeCredentials(creds);
243
- const r = authedCloneUrl("https://gitlab.com/owner/repo.git", { kind: "credentialsFile" });
244
+ writeCredentials("default", creds);
245
+ const r = authedCloneUrl("https://gitlab.com/owner/repo.git", { kind: "credentialsFile" }, "default");
244
246
  expect(r!.authedUrl).toContain("x-access-token:glpat_xyz@");
245
247
  expect(r!.appliedAuth).toBe("stored_pat");
246
248
  });
@@ -255,8 +257,8 @@ describe("authedCloneUrl", () => {
255
257
  label: "GitLab PAT",
256
258
  },
257
259
  };
258
- writeCredentials(creds);
259
- const r = authedCloneUrl("https://github.com/owner/repo.git", { kind: "credentialsFile" });
260
+ writeCredentials("default", creds);
261
+ const r = authedCloneUrl("https://github.com/owner/repo.git", { kind: "credentialsFile" }, "default");
260
262
  expect(r!.appliedAuth).toBe("none");
261
263
  });
262
264
  });
@@ -548,3 +550,107 @@ describe("cloneAndImport — failures", () => {
548
550
  rmSync(notAnExport, { recursive: true, force: true });
549
551
  });
550
552
  });
553
+
554
+ // ---------------------------------------------------------------------------
555
+ // cloneAndImport — git not installed (vault#415 — live bug on a git-less EC2)
556
+ // ---------------------------------------------------------------------------
557
+
558
+ describe("cloneAndImport — git not installed", () => {
559
+ let assetsDir: string;
560
+ let store: SqliteStore;
561
+
562
+ beforeEach(() => {
563
+ assetsDir = tmp("import-assets-nogit-");
564
+ store = new SqliteStore(new Database(":memory:"));
565
+ });
566
+
567
+ afterEach(() => {
568
+ if (assetsDir) rmSync(assetsDir, { recursive: true, force: true });
569
+ });
570
+
571
+ test("git missing → GitNotInstalledError, fails fast (no spawn, no tempdir)", async () => {
572
+ const workDirRoot = tmp("import-workroot-nogit-");
573
+ let spawnCalled = false;
574
+ const spyingSpawn: GitSpawn = async () => {
575
+ spawnCalled = true;
576
+ return { exitCode: 0, stderr: "", timedOut: false };
577
+ };
578
+ await expect(
579
+ cloneAndImport({
580
+ vaultName: "default",
581
+ remoteUrl: "https://github.com/owner/repo.git",
582
+ auth: { kind: "none" },
583
+ mode: "merge",
584
+ store,
585
+ assetsDir,
586
+ spawn: spyingSpawn,
587
+ workDirRoot,
588
+ // Force the preflight to see no git on PATH.
589
+ which: () => null,
590
+ }),
591
+ ).rejects.toBeInstanceOf(GitNotInstalledError);
592
+
593
+ // Fails fast: the spawner is never reached and no tempdir is created.
594
+ expect(spawnCalled).toBe(false);
595
+ const { readdirSync } = await import("node:fs");
596
+ const entries = readdirSync(workDirRoot);
597
+ expect(entries.filter((e) => e.startsWith("parachute-import-"))).toEqual([]);
598
+ rmSync(workDirRoot, { recursive: true, force: true });
599
+ });
600
+
601
+ test("git missing → does not lay an in-flight marker (clean retry after install)", async () => {
602
+ await expect(
603
+ cloneAndImport({
604
+ vaultName: "default",
605
+ remoteUrl: "https://github.com/owner/repo.git",
606
+ auth: { kind: "none" },
607
+ mode: "merge",
608
+ store,
609
+ assetsDir,
610
+ spawn: async () => ({ exitCode: 0, stderr: "", timedOut: false }),
611
+ which: () => null,
612
+ }),
613
+ ).rejects.toBeInstanceOf(GitNotInstalledError);
614
+ // The preflight runs BEFORE the concurrency marker, so a failed call
615
+ // leaves no stale in-flight entry blocking the post-install retry.
616
+ expect(_isImportInFlight("default")).toBe(false);
617
+ });
618
+
619
+ test("git present (injected which) → normal import path still works", async () => {
620
+ const fixtureDir = await buildExportFixture();
621
+ try {
622
+ const result = await cloneAndImport({
623
+ vaultName: "default",
624
+ remoteUrl: "https://github.com/owner/repo.git",
625
+ auth: { kind: "none" },
626
+ mode: "merge",
627
+ store,
628
+ assetsDir,
629
+ spawn: spawnCloneSuccess(fixtureDir),
630
+ which: () => "/usr/bin/git",
631
+ });
632
+ expect(result.notes_imported).toBe(2);
633
+ } finally {
634
+ rmSync(fixtureDir, { recursive: true, force: true });
635
+ }
636
+ });
637
+
638
+ test("GitNotInstalledError message is actionable (names install commands)", () => {
639
+ const msg = new GitNotInstalledError().message;
640
+ expect(msg).toContain("git is required");
641
+ expect(msg).toContain("dnf install git");
642
+ expect(msg).toContain("apt-get install");
643
+ expect(msg).toContain("brew install git");
644
+ });
645
+
646
+ test("defaultGitSpawn rethrows a git-not-found spawn error as GitNotInstalledError", async () => {
647
+ // Belt-and-suspenders: spawn a non-existent binary to trigger Bun's
648
+ // "Executable not found" throw, and confirm the friendly rethrow.
649
+ // (We can't uninstall git, so spawn an impossible command name.)
650
+ await expect(
651
+ defaultGitSpawn(["definitely-not-a-real-binary-xyzzy-git"], {
652
+ timeoutMs: 5_000,
653
+ }),
654
+ ).rejects.toBeInstanceOf(GitNotInstalledError);
655
+ });
656
+ });
@@ -73,6 +73,11 @@ import {
73
73
  } from "../core/src/portable-md.ts";
74
74
  import type { SqliteStore } from "../core/src/store.ts";
75
75
  import { redactRemoteUrl, readCredentials } from "./mirror-credentials.ts";
76
+ import {
77
+ GitNotInstalledError,
78
+ ensureGitAvailable,
79
+ isGitNotFoundSpawnError,
80
+ } from "./git-preflight.ts";
76
81
 
77
82
  // ---------------------------------------------------------------------------
78
83
  // Types
@@ -125,6 +130,11 @@ export interface ImportOpts {
125
130
  importer?: typeof importPortableVault;
126
131
  /** Override the clone timeout (default 60s; test seam to shorten). */
127
132
  cloneTimeoutMs?: number;
133
+ /**
134
+ * Override the git-presence probe (test seam — defaults to `Bun.which`).
135
+ * Inject a fn returning `null` to exercise the git-not-installed path.
136
+ */
137
+ which?: (cmd: string) => string | null;
128
138
  }
129
139
 
130
140
  /**
@@ -209,6 +219,8 @@ export class CloneFailedError extends Error {
209
219
  * - `credentialsFile` → if stored creds are GitHub OAuth, embed the
210
220
  * token. If stored creds are a PAT with a saved URL whose host
211
221
  * matches, reuse the stored URL. Otherwise pass the URL verbatim.
222
+ * The stored creds are read from the target vault's PER-VAULT file
223
+ * (vault#399) — `vaultName` is required for this auth kind.
212
224
  * - `none` → return the URL verbatim.
213
225
  *
214
226
  * Returns `null` when the URL is unparseable (caller surfaces a 400).
@@ -216,6 +228,7 @@ export class CloneFailedError extends Error {
216
228
  export function authedCloneUrl(
217
229
  remoteUrl: string,
218
230
  auth: ImportAuth,
231
+ vaultName: string,
219
232
  ): { authedUrl: string; appliedAuth: "stored_oauth" | "stored_pat" | "per_call_pat" | "none" } | null {
220
233
  // Local-path / SSH-shorthand pass-through. Git accepts three URL shapes:
221
234
  // - HTTPS/HTTP — what we embed auth into below.
@@ -267,8 +280,8 @@ export function authedCloneUrl(
267
280
  return { authedUrl: u.toString(), appliedAuth: "per_call_pat" };
268
281
  }
269
282
 
270
- // credentialsFile path — read from disk.
271
- const creds = readCredentials();
283
+ // credentialsFile path — read the target vault's per-vault creds (vault#399).
284
+ const creds = readCredentials(vaultName);
272
285
  if (!creds || !creds.active_method) {
273
286
  return { authedUrl: remoteUrl, appliedAuth: "none" };
274
287
  }
@@ -312,19 +325,32 @@ export function authedCloneUrl(
312
325
  * exit code + stderr text + timeout flag.
313
326
  */
314
327
  export const defaultGitSpawn: GitSpawn = async (argv, options) => {
315
- const proc = Bun.spawn(argv, {
316
- cwd: options.cwd,
317
- stdout: "pipe",
318
- stderr: "pipe",
319
- env: {
320
- ...process.env,
321
- GIT_TERMINAL_PROMPT: "0",
322
- // Kill any system credential helper from intercepting — we want
323
- // the clone to use ONLY the URL-embedded credential, not whatever's
324
- // in keychain. Same shape as the ls-remote probe.
325
- GIT_ASKPASS: "/bin/echo",
326
- },
327
- });
328
+ let proc;
329
+ try {
330
+ proc = Bun.spawn(argv, {
331
+ cwd: options.cwd,
332
+ stdout: "pipe",
333
+ stderr: "pipe",
334
+ env: {
335
+ ...process.env,
336
+ GIT_TERMINAL_PROMPT: "0",
337
+ // Kill any system credential helper from intercepting — we want
338
+ // the clone to use ONLY the URL-embedded credential, not whatever's
339
+ // in keychain. Same shape as the ls-remote probe.
340
+ GIT_ASKPASS: "/bin/echo",
341
+ },
342
+ });
343
+ } catch (err) {
344
+ // Belt-and-suspenders: `cloneAndImport` preflights via
345
+ // `ensureGitAvailable`, but if a git-missing spawn still slips through
346
+ // (race, or a future caller that skipped the preflight) rethrow it as
347
+ // the friendly error rather than leaking Bun's raw
348
+ // `Executable not found in $PATH: "git"`.
349
+ if (isGitNotFoundSpawnError(err)) {
350
+ throw new GitNotInstalledError();
351
+ }
352
+ throw err;
353
+ }
328
354
  let timedOut = false;
329
355
  const timer = setTimeout(() => {
330
356
  timedOut = true;
@@ -362,6 +388,14 @@ export const defaultGitSpawn: GitSpawn = async (argv, options) => {
362
388
  * Always cleans up the temp dir.
363
389
  */
364
390
  export async function cloneAndImport(opts: ImportOpts): Promise<ImportResult> {
391
+ // Fail fast + clean when git isn't installed — BEFORE the concurrency
392
+ // marker, tempdir creation, or any spawn. The route maps the resulting
393
+ // GitNotInstalledError to a friendly 503 (git_not_installed). Without
394
+ // this, the first `Bun.spawn(["git", ...])` threw a raw
395
+ // `Executable not found in $PATH: "git"` that only the generic 500 branch
396
+ // caught — the unhelpful failure mode found live on the gitcoin EC2 box.
397
+ ensureGitAvailable(opts.which);
398
+
365
399
  if (inFlight.has(opts.vaultName)) {
366
400
  throw new ImportConflictError(opts.vaultName);
367
401
  }
@@ -372,7 +406,7 @@ export async function cloneAndImport(opts: ImportOpts): Promise<ImportResult> {
372
406
  const workDirRoot = opts.workDirRoot ?? tmpdir();
373
407
  const cloneTimeoutMs = opts.cloneTimeoutMs ?? 60_000;
374
408
 
375
- const authResult = authedCloneUrl(opts.remoteUrl, opts.auth);
409
+ const authResult = authedCloneUrl(opts.remoteUrl, opts.auth, opts.vaultName);
376
410
  if (!authResult) {
377
411
  inFlight.delete(opts.vaultName);
378
412
  throw new CloneFailedError(