@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.
- package/README.md +51 -54
- package/core/src/core.test.ts +4 -1
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +66 -43
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +52 -0
- package/core/src/portable-md.ts +48 -0
- package/core/src/schema.ts +87 -14
- package/core/src/store.ts +117 -0
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +191 -11
- package/src/auth-status.ts +12 -5
- package/src/auth.test.ts +135 -219
- package/src/auth.ts +158 -107
- package/src/cli.ts +306 -224
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/init-summary.test.ts +4 -4
- package/src/init-summary.ts +36 -10
- package/src/mcp-config.test.ts +4 -2
- package/src/mcp-http.ts +24 -3
- package/src/mcp-install-interactive.test.ts +33 -71
- package/src/mcp-install-interactive.ts +23 -76
- package/src/mcp-install.test.ts +156 -55
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +249 -74
- package/src/mirror-config.test.ts +107 -0
- package/src/mirror-config.ts +275 -9
- package/src/mirror-credentials.test.ts +168 -17
- package/src/mirror-credentials.ts +155 -32
- package/src/mirror-deps.ts +25 -16
- package/src/mirror-import.test.ts +122 -16
- package/src/mirror-import.ts +50 -16
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +116 -22
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +81 -21
- package/src/mirror-routes.ts +90 -16
- package/src/routes.ts +39 -2
- package/src/routing.test.ts +203 -118
- package/src/routing.ts +46 -59
- package/src/scopes.test.ts +0 -86
- package/src/scopes.ts +9 -97
- package/src/server.ts +102 -34
- package/src/storage.test.ts +132 -7
- package/src/token-store.test.ts +88 -169
- package/src/token-store.ts +123 -249
- package/src/vault-create.test.ts +12 -4
- package/src/vault.test.ts +408 -103
- package/web/ui/dist/assets/index-DDRo6F4u.js +60 -0
- package/web/ui/dist/index.html +1 -1
- package/src/tokens-routes.test.ts +0 -727
- package/src/tokens-routes.ts +0 -392
- 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
|
|
27
|
-
* **not encrypted at rest**. Rationale: encryption-at-rest
|
|
28
|
-
* the same disk doesn't add real security; OS perms ARE the
|
|
29
|
-
* trust model as `~/.git-credentials` (which most operators
|
|
30
|
-
* The file is documented as sensitive; redaction in logs is
|
|
31
|
-
* `sanitizeCredentials` + a discipline of "never log the raw
|
|
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 —
|
|
35
|
-
* mirror per vault
|
|
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
|
|
102
|
-
*
|
|
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
|
|
160
|
+
* Path to a vault's per-vault credentials file (vault#399).
|
|
142
161
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* vault
|
|
147
|
-
*
|
|
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:
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* `PARACHUTE_HOME`
|
|
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
|
-
|
|
156
|
-
|
|
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
|
|
348
|
-
* (operator hasn't connected anything yet); throws when the file is
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/mirror-deps.ts
CHANGED
|
@@ -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,
|
|
14
|
-
import {
|
|
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` →
|
|
28
|
-
* `
|
|
29
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
111
|
-
*
|
|
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
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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
|
+
});
|
package/src/mirror-import.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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(
|