@openparachute/vault 0.5.0-rc.1 → 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/package.json +1 -1
- 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/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-import.test.ts +106 -0
- package/src/mirror-import.ts +44 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +60 -0
- package/src/mirror-routes.ts +62 -1
package/package.json
CHANGED
package/src/export-watch.test.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
runGitCommitCycle,
|
|
36
36
|
shouldCommit,
|
|
37
37
|
} from "./export-watch.ts";
|
|
38
|
+
import { GitNotInstalledError } from "./git-preflight.ts";
|
|
38
39
|
|
|
39
40
|
const CLI = path.resolve(import.meta.dir, "cli.ts");
|
|
40
41
|
|
|
@@ -504,6 +505,28 @@ describe("runGitCommitCycle", () => {
|
|
|
504
505
|
});
|
|
505
506
|
expect(result.message).toBe("note: Inbox/DonorMeeting");
|
|
506
507
|
});
|
|
508
|
+
|
|
509
|
+
test("git missing → throws GitNotInstalledError (sync surfaces friendly error, not raw spawn crash)", async () => {
|
|
510
|
+
// vault#415 — the sync/commit path must surface the actionable
|
|
511
|
+
// git-not-installed message (which the manager threads into
|
|
512
|
+
// status.last_error) instead of crashing with a raw "Executable not
|
|
513
|
+
// found in $PATH". Force the preflight to see no git via the `which`
|
|
514
|
+
// seam; no real spawn should be reached.
|
|
515
|
+
fs.writeFileSync(path.join(dir, "Note.md"), "# n\n");
|
|
516
|
+
await expect(
|
|
517
|
+
runGitCommitCycle({
|
|
518
|
+
repoDir: dir,
|
|
519
|
+
template: DEFAULT_COMMIT_TEMPLATE,
|
|
520
|
+
notesChanged: 1,
|
|
521
|
+
vaultName: "default",
|
|
522
|
+
firstNoteTitle: "Note",
|
|
523
|
+
push: false,
|
|
524
|
+
which: () => null,
|
|
525
|
+
}),
|
|
526
|
+
).rejects.toBeInstanceOf(GitNotInstalledError);
|
|
527
|
+
// The commit cycle bailed at the preflight — no commit landed.
|
|
528
|
+
expect(gitLogOneline(dir)).toHaveLength(1); // only the seed
|
|
529
|
+
});
|
|
507
530
|
});
|
|
508
531
|
|
|
509
532
|
// ---------------------------------------------------------------------------
|
package/src/export-watch.ts
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* detection. See `parachute-patterns/cookbook/vault-portable-export.md`.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { ensureGitAvailable } from "./git-preflight.ts";
|
|
17
|
+
|
|
16
18
|
// ---------------------------------------------------------------------------
|
|
17
19
|
// Commit message templating
|
|
18
20
|
// ---------------------------------------------------------------------------
|
|
@@ -269,11 +271,23 @@ export async function runGitCommitCycle(opts: {
|
|
|
269
271
|
push: boolean;
|
|
270
272
|
/** Override for tests — defaults to `new Date().toISOString()`. */
|
|
271
273
|
now?: () => string;
|
|
274
|
+
/**
|
|
275
|
+
* Override the git-presence probe (test seam — defaults to `Bun.which`).
|
|
276
|
+
* Inject a fn returning `null` to exercise the git-not-installed path.
|
|
277
|
+
*/
|
|
278
|
+
which?: (cmd: string) => string | null;
|
|
272
279
|
}): Promise<{
|
|
273
280
|
committed: boolean;
|
|
274
281
|
message?: string;
|
|
275
282
|
push?: { attempted: true; ok: boolean; error?: string };
|
|
276
283
|
}> {
|
|
284
|
+
// Preflight: every step below shells `git`. On a git-less server the first
|
|
285
|
+
// `Bun.spawn(["git", ...])` would throw a raw "Executable not found" error;
|
|
286
|
+
// surface the friendly, actionable GitNotInstalledError so callers can
|
|
287
|
+
// thread it into mirror status (`last_error`) instead of crashing the
|
|
288
|
+
// watch loop with an opaque message.
|
|
289
|
+
ensureGitAvailable(opts.which);
|
|
290
|
+
|
|
277
291
|
const now = opts.now ?? (() => new Date().toISOString());
|
|
278
292
|
|
|
279
293
|
const add = await gitAddAll(opts.repoDir);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared git-availability preflight (vault#415).
|
|
3
|
+
*
|
|
4
|
+
* Found live: importing a repo on a git-less Amazon Linux EC2 box failed
|
|
5
|
+
* with a raw `Executable not found in $PATH: "git"` 500. The preflight gives
|
|
6
|
+
* every git entry point a fast, friendly, actionable failure instead.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test, expect } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
GitNotInstalledError,
|
|
12
|
+
ensureGitAvailable,
|
|
13
|
+
isGitNotFoundSpawnError,
|
|
14
|
+
} from "./git-preflight.ts";
|
|
15
|
+
|
|
16
|
+
describe("ensureGitAvailable", () => {
|
|
17
|
+
test("throws GitNotInstalledError when which returns null", () => {
|
|
18
|
+
expect(() => ensureGitAvailable(() => null)).toThrow(GitNotInstalledError);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("does not throw when which resolves git", () => {
|
|
22
|
+
expect(() => ensureGitAvailable(() => "/usr/bin/git")).not.toThrow();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("defaults to Bun.which (git is present in this test env)", () => {
|
|
26
|
+
// The test host has git; the default-arg path resolves it cleanly.
|
|
27
|
+
expect(() => ensureGitAvailable()).not.toThrow();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("GitNotInstalledError message", () => {
|
|
32
|
+
test("is OS-agnostic-but-helpful — names dnf, apt-get, and brew", () => {
|
|
33
|
+
const msg = new GitNotInstalledError().message;
|
|
34
|
+
expect(msg).toContain("git is required for this operation");
|
|
35
|
+
expect(msg).toContain("sudo dnf install git");
|
|
36
|
+
expect(msg).toContain("sudo apt-get install -y git");
|
|
37
|
+
expect(msg).toContain("brew install git");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("carries the GitNotInstalledError name (instanceof + name both work)", () => {
|
|
41
|
+
const err = new GitNotInstalledError();
|
|
42
|
+
expect(err).toBeInstanceOf(GitNotInstalledError);
|
|
43
|
+
expect(err.name).toBe("GitNotInstalledError");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("isGitNotFoundSpawnError", () => {
|
|
48
|
+
test("matches Bun's executable-not-found message for git", () => {
|
|
49
|
+
expect(
|
|
50
|
+
isGitNotFoundSpawnError(
|
|
51
|
+
new Error('Executable not found in $PATH: "git"'),
|
|
52
|
+
),
|
|
53
|
+
).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("matches an ENOENT spawn error mentioning git", () => {
|
|
57
|
+
const err = new Error("spawn git ENOENT") as Error & { code?: string };
|
|
58
|
+
err.code = "ENOENT";
|
|
59
|
+
expect(isGitNotFoundSpawnError(err)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("does not match an unrelated error", () => {
|
|
63
|
+
expect(isGitNotFoundSpawnError(new Error("network unreachable"))).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("does not match a non-Error value", () => {
|
|
67
|
+
expect(isGitNotFoundSpawnError("git missing")).toBe(false);
|
|
68
|
+
expect(isGitNotFoundSpawnError(null)).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared git-availability preflight.
|
|
3
|
+
*
|
|
4
|
+
* Every git-using entry point in vault (mirror import, mirror sync/commit/
|
|
5
|
+
* push, internal-mirror bootstrap) shells out to the `git` binary via
|
|
6
|
+
* `Bun.spawn(["git", ...])`. On a server where `git` isn't installed (a
|
|
7
|
+
* fresh Amazon Linux / minimal Docker image, etc.) Bun throws a raw
|
|
8
|
+
* `Executable not found in $PATH: "git"` error, which the import route only
|
|
9
|
+
* caught in its generic `internal` 500 branch — surfacing an unhelpful,
|
|
10
|
+
* un-actionable error to the operator.
|
|
11
|
+
*
|
|
12
|
+
* This module centralizes the preflight so every git entry point fails
|
|
13
|
+
* fast with a clear, actionable message that tells the operator HOW to
|
|
14
|
+
* fix it (install git via their distro's package manager).
|
|
15
|
+
*
|
|
16
|
+
* Found live on the gitcoin-parachute EC2 deploy (Amazon Linux, no git).
|
|
17
|
+
* See vault#415-era fix.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Thrown when `git` is required for an operation but isn't on PATH. Carries
|
|
22
|
+
* an OS-agnostic-but-helpful message with the common install commands so the
|
|
23
|
+
* operator can act without leaving the error surface.
|
|
24
|
+
*/
|
|
25
|
+
export class GitNotInstalledError extends Error {
|
|
26
|
+
constructor() {
|
|
27
|
+
super(
|
|
28
|
+
"git is required for this operation but was not found on the server. " +
|
|
29
|
+
"Install git and retry — e.g. `sudo dnf install git` (Amazon Linux / Fedora), " +
|
|
30
|
+
"`sudo apt-get install -y git` (Debian / Ubuntu), or `brew install git` (macOS).",
|
|
31
|
+
);
|
|
32
|
+
this.name = "GitNotInstalledError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Throw `GitNotInstalledError` if `git` isn't resolvable on PATH.
|
|
38
|
+
*
|
|
39
|
+
* `which` is a TEST SEAM (default `Bun.which`) so tests can force the
|
|
40
|
+
* git-missing branch without uninstalling git from the test host. Production
|
|
41
|
+
* callers pass nothing and get the real `Bun.which`.
|
|
42
|
+
*/
|
|
43
|
+
export function ensureGitAvailable(
|
|
44
|
+
which: (cmd: string) => string | null = Bun.which,
|
|
45
|
+
): void {
|
|
46
|
+
if (which("git") === null) {
|
|
47
|
+
throw new GitNotInstalledError();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Heuristic: does this error look like the "git executable not found" failure
|
|
53
|
+
* Bun throws when it can't resolve the binary? Used as a belt-and-suspenders
|
|
54
|
+
* catch around `Bun.spawn(["git", ...])` so a spawn that slips past the
|
|
55
|
+
* preflight (race where git is removed between check and spawn, or a code path
|
|
56
|
+
* that didn't preflight) still surfaces the friendly error instead of the raw
|
|
57
|
+
* `Executable not found in $PATH: "git"` string.
|
|
58
|
+
*/
|
|
59
|
+
export function isGitNotFoundSpawnError(err: unknown): boolean {
|
|
60
|
+
if (!(err instanceof Error)) return false;
|
|
61
|
+
const msg = err.message ?? "";
|
|
62
|
+
// Bun: `Executable not found in $PATH: "git"`.
|
|
63
|
+
// Node/posix: ENOENT spawn errors mention the missing file.
|
|
64
|
+
return (
|
|
65
|
+
(msg.includes("Executable not found") && msg.includes("git")) ||
|
|
66
|
+
((err as { code?: string }).code === "ENOENT" && msg.includes("git"))
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
validateExternalPath,
|
|
24
24
|
validateMirrorConfigShape,
|
|
25
25
|
} from "./mirror-config.ts";
|
|
26
|
+
import { GitNotInstalledError } from "./git-preflight.ts";
|
|
26
27
|
|
|
27
28
|
function tmp(prefix: string): string {
|
|
28
29
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
@@ -496,6 +497,19 @@ describe("validateExternalPath", () => {
|
|
|
496
497
|
expect(r.ok).toBe(true);
|
|
497
498
|
if (r.ok) expect(r.resolved_path).toBe(dir);
|
|
498
499
|
});
|
|
500
|
+
|
|
501
|
+
test("git not installed → throws GitNotInstalledError (route maps to 503)", async () => {
|
|
502
|
+
// vault#415 nit — the isGitRepo() check shells `git`. On a git-less
|
|
503
|
+
// server, throw the friendly error (handleMirrorPut maps it to 503
|
|
504
|
+
// git_not_installed) instead of a raw "Executable not found" crash.
|
|
505
|
+
// Force the preflight via the `which` seam; a real, valid git repo is
|
|
506
|
+
// used so the ONLY failure source is the preflight.
|
|
507
|
+
dir = tmp("mirror-validate-nogit-installed-");
|
|
508
|
+
initRepo(dir);
|
|
509
|
+
await expect(validateExternalPath(dir, () => null)).rejects.toBeInstanceOf(
|
|
510
|
+
GitNotInstalledError,
|
|
511
|
+
);
|
|
512
|
+
});
|
|
499
513
|
});
|
|
500
514
|
|
|
501
515
|
// ---------------------------------------------------------------------------
|
package/src/mirror-config.ts
CHANGED
|
@@ -45,6 +45,7 @@ import { homedir } from "os";
|
|
|
45
45
|
|
|
46
46
|
import { DEFAULT_COMMIT_TEMPLATE, isGitRepo } from "./export-watch.ts";
|
|
47
47
|
import { readCredentials, type MirrorCredentials } from "./mirror-credentials.ts";
|
|
48
|
+
import { ensureGitAvailable } from "./git-preflight.ts";
|
|
48
49
|
|
|
49
50
|
// ---------------------------------------------------------------------------
|
|
50
51
|
// Types
|
|
@@ -888,7 +889,17 @@ export type PathValidation = PathValidationOk | PathValidationError;
|
|
|
888
889
|
*/
|
|
889
890
|
export async function validateExternalPath(
|
|
890
891
|
externalPath: string,
|
|
892
|
+
// Test seam for the git-presence preflight (default `Bun.which`). Inject a
|
|
893
|
+
// fn returning `null` to exercise the git-not-installed path.
|
|
894
|
+
which?: (cmd: string) => string | null,
|
|
891
895
|
): Promise<PathValidation> {
|
|
896
|
+
// Preflight: the git-repo check below shells `git`. On a git-less server,
|
|
897
|
+
// throw the friendly, actionable GitNotInstalledError (which handleMirrorPut
|
|
898
|
+
// maps to a 503 `git_not_installed`, consistent with the import route)
|
|
899
|
+
// instead of letting `isGitRepo`'s `Bun.spawn` throw a raw
|
|
900
|
+
// "Executable not found in $PATH: \"git\"".
|
|
901
|
+
ensureGitAvailable(which);
|
|
902
|
+
|
|
892
903
|
if (!existsSync(externalPath)) {
|
|
893
904
|
return {
|
|
894
905
|
ok: false,
|
|
@@ -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,
|
|
@@ -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
|
/**
|
|
@@ -315,19 +325,32 @@ export function authedCloneUrl(
|
|
|
315
325
|
* exit code + stderr text + timeout flag.
|
|
316
326
|
*/
|
|
317
327
|
export const defaultGitSpawn: GitSpawn = async (argv, options) => {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
+
}
|
|
331
354
|
let timedOut = false;
|
|
332
355
|
const timer = setTimeout(() => {
|
|
333
356
|
timedOut = true;
|
|
@@ -365,6 +388,14 @@ export const defaultGitSpawn: GitSpawn = async (argv, options) => {
|
|
|
365
388
|
* Always cleans up the temp dir.
|
|
366
389
|
*/
|
|
367
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
|
+
|
|
368
399
|
if (inFlight.has(opts.vaultName)) {
|
|
369
400
|
throw new ImportConflictError(opts.vaultName);
|
|
370
401
|
}
|
|
@@ -215,6 +215,21 @@ describe("bootstrapInternalMirror", () => {
|
|
|
215
215
|
expect(isGitRepoSync(dir)).toBe(true);
|
|
216
216
|
}
|
|
217
217
|
});
|
|
218
|
+
|
|
219
|
+
test("git missing → ok:false with actionable error (status surfaces it, no raw crash)", async () => {
|
|
220
|
+
// vault#415 — a git-less server can't bootstrap a mirror. The error
|
|
221
|
+
// channel carries the friendly message that MirrorManager.start threads
|
|
222
|
+
// into status.last_error, instead of a raw "Executable not found" crash.
|
|
223
|
+
dir = path.join(tmp("mirror-boot-nogit-"), "mirror");
|
|
224
|
+
const r = await bootstrapInternalMirror(dir, () => null);
|
|
225
|
+
expect(r.ok).toBe(false);
|
|
226
|
+
if (!r.ok) {
|
|
227
|
+
expect(r.error).toContain("git is required");
|
|
228
|
+
expect(r.error).toContain("dnf install git");
|
|
229
|
+
}
|
|
230
|
+
// Failed fast at the preflight — the dir was never created.
|
|
231
|
+
expect(fs.existsSync(dir)).toBe(false);
|
|
232
|
+
});
|
|
218
233
|
});
|
|
219
234
|
|
|
220
235
|
// ---------------------------------------------------------------------------
|
|
@@ -378,6 +393,42 @@ describe("MirrorManager.start — lifecycle matrix", () => {
|
|
|
378
393
|
fs.rmSync(external, { recursive: true, force: true });
|
|
379
394
|
});
|
|
380
395
|
|
|
396
|
+
test("external + git not installed → enabled:false with friendly error, never spawns/exports", async () => {
|
|
397
|
+
// vault#415 nit — the external branch's isGitRepo() check shells `git`
|
|
398
|
+
// with no preflight; on a git-less server it would throw a raw
|
|
399
|
+
// "Executable not found in $PATH: \"git\"" and crash start(). The
|
|
400
|
+
// top-of-start() preflight (forced via the `which` seam) lands the
|
|
401
|
+
// friendly, actionable message in last_error for the external location.
|
|
402
|
+
home = tmp("mgr-ext-nogit-installed-");
|
|
403
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
404
|
+
// Use a real, valid external git repo so the ONLY thing that can fail is
|
|
405
|
+
// the git-presence preflight — proving the preflight (not the path/repo
|
|
406
|
+
// checks) produced the disabled state.
|
|
407
|
+
const external = tmp("mgr-ext-nogit-target-");
|
|
408
|
+
initRepo(external);
|
|
409
|
+
seedCommit(external);
|
|
410
|
+
const deps = makeFakeDeps({
|
|
411
|
+
parachuteHome: home,
|
|
412
|
+
initialConfig: {
|
|
413
|
+
...defaultMirrorConfig(),
|
|
414
|
+
enabled: true,
|
|
415
|
+
location: "external",
|
|
416
|
+
external_path: external,
|
|
417
|
+
sync_mode: "events",
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
const mgr = new MirrorManager(deps);
|
|
421
|
+
// Force the preflight to see no git on PATH.
|
|
422
|
+
const status = await mgr.start(() => null);
|
|
423
|
+
expect(status.enabled).toBe(false);
|
|
424
|
+
expect(status.last_error).toContain("git is required");
|
|
425
|
+
expect(status.last_error).toContain("dnf install git");
|
|
426
|
+
// Never reached export — the preflight bailed before any git work.
|
|
427
|
+
expect(deps.exportCalls).toHaveLength(0);
|
|
428
|
+
await mgr.stop();
|
|
429
|
+
fs.rmSync(external, { recursive: true, force: true });
|
|
430
|
+
});
|
|
431
|
+
|
|
381
432
|
test("internal bootstrap refuses to clobber pre-existing non-git data", async () => {
|
|
382
433
|
home = tmp("mgr-int-clobber-");
|
|
383
434
|
const mirrorPath = path.join(home, "vault", "data", "default", "mirror");
|
package/src/mirror-manager.ts
CHANGED
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
applyToGitRemote,
|
|
76
76
|
readCredentials,
|
|
77
77
|
} from "./mirror-credentials.ts";
|
|
78
|
+
import { GitNotInstalledError, ensureGitAvailable } from "./git-preflight.ts";
|
|
78
79
|
import type { HookRegistry } from "../core/src/hooks.ts";
|
|
79
80
|
|
|
80
81
|
/**
|
|
@@ -230,7 +231,20 @@ export type BootstrapResult = BootstrapResultOk | BootstrapResultError;
|
|
|
230
231
|
*/
|
|
231
232
|
export async function bootstrapInternalMirror(
|
|
232
233
|
path: string,
|
|
234
|
+
// Test seam for the git-presence preflight (default `Bun.which`). Inject a
|
|
235
|
+
// fn returning `null` to exercise the git-not-installed bootstrap path.
|
|
236
|
+
which?: (cmd: string) => string | null,
|
|
233
237
|
): Promise<BootstrapResult> {
|
|
238
|
+
// Preflight: a git-less server can't bootstrap a mirror. Surface the
|
|
239
|
+
// friendly, actionable message into the bootstrap-error channel so the
|
|
240
|
+
// caller threads it into mirror status (`last_error`) rather than letting
|
|
241
|
+
// a raw `Executable not found in $PATH: "git"` crash out of the spawn.
|
|
242
|
+
try {
|
|
243
|
+
ensureGitAvailable(which);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
return { ok: false, error: (err as Error).message };
|
|
246
|
+
}
|
|
247
|
+
|
|
234
248
|
if (existsSync(path)) {
|
|
235
249
|
let stat;
|
|
236
250
|
try {
|
|
@@ -466,7 +480,11 @@ export class MirrorManager {
|
|
|
466
480
|
* Returns the final status snapshot — useful for tests + the PUT
|
|
467
481
|
* endpoint response.
|
|
468
482
|
*/
|
|
469
|
-
async start(
|
|
483
|
+
async start(
|
|
484
|
+
// Test seam for the git-presence preflight (default `Bun.which`). Inject
|
|
485
|
+
// a fn returning `null` to exercise the git-not-installed start path.
|
|
486
|
+
which?: (cmd: string) => string | null,
|
|
487
|
+
): Promise<MirrorStatus> {
|
|
470
488
|
this.startCount++;
|
|
471
489
|
await this.stop({ preserveStatus: true });
|
|
472
490
|
|
|
@@ -501,6 +519,24 @@ export class MirrorManager {
|
|
|
501
519
|
}
|
|
502
520
|
this.status.mirror_path = path;
|
|
503
521
|
|
|
522
|
+
// Preflight git BEFORE branching on location. Both branches shell `git`
|
|
523
|
+
// (internal → bootstrapInternalMirror; external → isGitRepo). On a
|
|
524
|
+
// git-less server the external branch's `isGitRepo` would otherwise throw
|
|
525
|
+
// a raw "Executable not found in $PATH: \"git\"" and crash start();
|
|
526
|
+
// catching it here lands the friendly, actionable message in
|
|
527
|
+
// status.last_error (disabled) for either location, uniformly.
|
|
528
|
+
try {
|
|
529
|
+
ensureGitAvailable(which);
|
|
530
|
+
} catch (err) {
|
|
531
|
+
if (err instanceof GitNotInstalledError) {
|
|
532
|
+
this.status.enabled = false;
|
|
533
|
+
this.status.last_error = err.message;
|
|
534
|
+
console.warn(`[mirror] ${err.message}`);
|
|
535
|
+
return this.getStatus();
|
|
536
|
+
}
|
|
537
|
+
throw err;
|
|
538
|
+
}
|
|
539
|
+
|
|
504
540
|
// Internal bootstrap. External path is the operator's responsibility —
|
|
505
541
|
// they should have validated via the PUT endpoint before we hit boot.
|
|
506
542
|
// We re-check `isGitRepo` defensively here either way; a missing/non-
|
|
@@ -641,9 +677,13 @@ export class MirrorManager {
|
|
|
641
677
|
* the operator-intended config on disk; on the next vault boot it
|
|
642
678
|
* applies cleanly.
|
|
643
679
|
*/
|
|
644
|
-
async reload(
|
|
680
|
+
async reload(
|
|
681
|
+
newConfig: MirrorConfig,
|
|
682
|
+
// Test seam forwarded to `start()` — see `start(which)`.
|
|
683
|
+
which?: (cmd: string) => string | null,
|
|
684
|
+
): Promise<MirrorStatus> {
|
|
645
685
|
this.deps.writeMirrorConfig(newConfig);
|
|
646
|
-
return this.start();
|
|
686
|
+
return this.start(which);
|
|
647
687
|
}
|
|
648
688
|
|
|
649
689
|
/**
|
|
@@ -887,14 +927,25 @@ export class MirrorManager {
|
|
|
887
927
|
}
|
|
888
928
|
|
|
889
929
|
const firstNoteTitle = await this.deps.firstChangedNoteTitle(sinceCursor);
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
930
|
+
let commitResult: Awaited<ReturnType<typeof runGitCommitCycle>>;
|
|
931
|
+
try {
|
|
932
|
+
commitResult = await runGitCommitCycle({
|
|
933
|
+
repoDir: path,
|
|
934
|
+
template: this.currentConfig.commit_template,
|
|
935
|
+
notesChanged: totalChanged,
|
|
936
|
+
vaultName: this.deps.vaultName,
|
|
937
|
+
firstNoteTitle,
|
|
938
|
+
push: this.currentConfig.auto_push,
|
|
939
|
+
});
|
|
940
|
+
} catch (err) {
|
|
941
|
+
// git-not-installed (or any commit-cycle throw) lands in status as a
|
|
942
|
+
// friendly last_error rather than crashing the cycle. Matches the
|
|
943
|
+
// "errors reflected in last_error, never rethrown" contract above.
|
|
944
|
+
const msg = (err as Error).message ?? String(err);
|
|
945
|
+
this.status.last_error = `commit cycle failed: ${msg}`;
|
|
946
|
+
console.warn(`[mirror] ${this.status.last_error}`);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
898
949
|
|
|
899
950
|
if (commitResult.committed) {
|
|
900
951
|
// Resolve the new HEAD sha so the status displays the commit that
|
|
@@ -950,6 +1001,17 @@ export class MirrorManager {
|
|
|
950
1001
|
if (!this.status.enabled) return { fired: false, reason: "not_enabled" };
|
|
951
1002
|
if (!this.status.mirror_path) return { fired: false, reason: "no_mirror_path" };
|
|
952
1003
|
const path = this.status.mirror_path;
|
|
1004
|
+
// Preflight: git-less server can't push. Surface the friendly message
|
|
1005
|
+
// into last_push_error (the SPA renders it) rather than throwing a raw
|
|
1006
|
+
// "Executable not found" out of the gitPush spawn.
|
|
1007
|
+
try {
|
|
1008
|
+
ensureGitAvailable();
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
const msg = (err as Error).message ?? String(err);
|
|
1011
|
+
this.status.last_push_error = msg;
|
|
1012
|
+
console.warn(`[mirror] push-now failed: ${msg}`);
|
|
1013
|
+
return { fired: true, pushed: false, error: msg };
|
|
1014
|
+
}
|
|
953
1015
|
const pushResult = await gitPush(path);
|
|
954
1016
|
const now = new Date().toISOString();
|
|
955
1017
|
// Refresh commits_unpushed either way — a no-op push still reflects
|
|
@@ -245,6 +245,37 @@ describe("handleMirrorPut", () => {
|
|
|
245
245
|
}
|
|
246
246
|
});
|
|
247
247
|
|
|
248
|
+
test("external + git not installed → 503 git_not_installed + actionable message", async () => {
|
|
249
|
+
// vault#415 nit — handleMirrorPut validates the external path via
|
|
250
|
+
// validateExternalPath, which shells `git`. On a git-less server it must
|
|
251
|
+
// return the friendly 503 (consistent with the import route), not let a
|
|
252
|
+
// raw "Executable not found" crash out. Force the preflight via the
|
|
253
|
+
// whichOverride seam against a REAL git repo so the only failure is the
|
|
254
|
+
// preflight.
|
|
255
|
+
home = tmp("mirror-put-nogit-installed-");
|
|
256
|
+
const { manager } = makeManager(home);
|
|
257
|
+
const external = tmp("mirror-put-nogit-target-");
|
|
258
|
+
initRepo(external);
|
|
259
|
+
try {
|
|
260
|
+
const req = new Request("http://x/admin/mirror", {
|
|
261
|
+
method: "PUT",
|
|
262
|
+
body: JSON.stringify({
|
|
263
|
+
enabled: true,
|
|
264
|
+
location: "external",
|
|
265
|
+
external_path: external,
|
|
266
|
+
}),
|
|
267
|
+
});
|
|
268
|
+
const res = await handleMirrorPut(req, manager, () => null);
|
|
269
|
+
expect(res.status).toBe(503);
|
|
270
|
+
const body = (await res.json()) as { error_type: string; message: string };
|
|
271
|
+
expect(body.error_type).toBe("git_not_installed");
|
|
272
|
+
expect(body.message).toContain("git is required");
|
|
273
|
+
expect(body.message).toContain("dnf install git");
|
|
274
|
+
} finally {
|
|
275
|
+
fs.rmSync(external, { recursive: true, force: true });
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
248
279
|
test("accepts a valid external config, persists, restarts watch", async () => {
|
|
249
280
|
home = tmp("mirror-put-happy-");
|
|
250
281
|
const external = tmp("mirror-put-ext-");
|
|
@@ -1258,6 +1289,35 @@ describe("handleMirrorImport", () => {
|
|
|
1258
1289
|
expect(body.message).toContain("vault.yaml");
|
|
1259
1290
|
});
|
|
1260
1291
|
|
|
1292
|
+
test("git not installed returns 503 + git_not_installed + actionable message", async () => {
|
|
1293
|
+
// vault#415 — live bug on a git-less Amazon Linux EC2 box. Force the
|
|
1294
|
+
// preflight (via the whichOverride seam) to see no git; the spawn seam
|
|
1295
|
+
// should never be reached.
|
|
1296
|
+
home = tmp("import-route-nogit-");
|
|
1297
|
+
await bootstrapVault(home);
|
|
1298
|
+
let spawnCalled = false;
|
|
1299
|
+
const spyingSpawn: GitSpawn = async () => {
|
|
1300
|
+
spawnCalled = true;
|
|
1301
|
+
return { exitCode: 0, stderr: "", timedOut: false };
|
|
1302
|
+
};
|
|
1303
|
+
const req = new Request("http://x/import", {
|
|
1304
|
+
method: "POST",
|
|
1305
|
+
body: JSON.stringify({
|
|
1306
|
+
remote_url: "https://github.com/a/b.git",
|
|
1307
|
+
mode: "merge",
|
|
1308
|
+
credentials: { kind: "none" },
|
|
1309
|
+
}),
|
|
1310
|
+
});
|
|
1311
|
+
const res = await handleMirrorImport(req, "default", spyingSpawn, () => null);
|
|
1312
|
+
expect(res.status).toBe(503);
|
|
1313
|
+
const body = (await res.json()) as { error_type: string; message: string };
|
|
1314
|
+
expect(body.error_type).toBe("git_not_installed");
|
|
1315
|
+
expect(body.message).toContain("git is required");
|
|
1316
|
+
expect(body.message).toContain("dnf install git");
|
|
1317
|
+
// Failed fast: the git spawn was never reached.
|
|
1318
|
+
expect(spawnCalled).toBe(false);
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1261
1321
|
test("uses stored credentials when credentials: null (credentialsFile path)", async () => {
|
|
1262
1322
|
home = tmp("import-route-stored-creds-");
|
|
1263
1323
|
await bootstrapVault(home);
|
package/src/mirror-routes.ts
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
type ImportResult,
|
|
63
63
|
} from "./mirror-import.ts";
|
|
64
64
|
import { redactToken } from "./export-watch.ts";
|
|
65
|
+
import { GitNotInstalledError, ensureGitAvailable } from "./git-preflight.ts";
|
|
65
66
|
import { getVaultStore } from "./vault-store.ts";
|
|
66
67
|
import { assetsDir } from "./routes.ts";
|
|
67
68
|
|
|
@@ -120,6 +121,10 @@ export function handleMirrorGet(manager: MirrorManager): Response {
|
|
|
120
121
|
export async function handleMirrorPut(
|
|
121
122
|
req: Request,
|
|
122
123
|
manager: MirrorManager,
|
|
124
|
+
// Test seam for the external-path git-presence preflight (default
|
|
125
|
+
// `Bun.which` inside `validateExternalPath`). Inject a fn returning `null`
|
|
126
|
+
// to exercise the git_not_installed 503 path without uninstalling git.
|
|
127
|
+
whichOverride?: (cmd: string) => string | null,
|
|
123
128
|
): Promise<Response> {
|
|
124
129
|
let body: unknown;
|
|
125
130
|
try {
|
|
@@ -154,7 +159,25 @@ export async function handleMirrorPut(
|
|
|
154
159
|
// to *do* something with an external path. Disabling the mirror by-
|
|
155
160
|
// flipping enabled to false shouldn't fail because the path went away.
|
|
156
161
|
if (config.enabled && config.location === "external" && config.external_path) {
|
|
157
|
-
|
|
162
|
+
let pathCheck;
|
|
163
|
+
try {
|
|
164
|
+
pathCheck = await validateExternalPath(config.external_path, whichOverride);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (err instanceof GitNotInstalledError) {
|
|
167
|
+
// 503 git_not_installed — consistent with the import route. The
|
|
168
|
+
// server can't validate (or later sync) an external git mirror
|
|
169
|
+
// without git installed; the message tells the operator how to fix.
|
|
170
|
+
return Response.json(
|
|
171
|
+
{
|
|
172
|
+
error: "git not installed",
|
|
173
|
+
error_type: "git_not_installed",
|
|
174
|
+
message: err.message,
|
|
175
|
+
},
|
|
176
|
+
{ status: 503 },
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
158
181
|
if (!pathCheck.ok) {
|
|
159
182
|
return Response.json(
|
|
160
183
|
{
|
|
@@ -597,6 +620,26 @@ export async function handleAuthPat(
|
|
|
597
620
|
);
|
|
598
621
|
}
|
|
599
622
|
|
|
623
|
+
// Preflight: the validation probe (`git ls-remote`) and every later push
|
|
624
|
+
// shell `git`. On a git-less server, surface the friendly, actionable
|
|
625
|
+
// 503 instead of letting the probe's `Bun.spawn` throw a raw
|
|
626
|
+
// "Executable not found in $PATH: \"git\"".
|
|
627
|
+
try {
|
|
628
|
+
ensureGitAvailable();
|
|
629
|
+
} catch (err) {
|
|
630
|
+
if (err instanceof GitNotInstalledError) {
|
|
631
|
+
return Response.json(
|
|
632
|
+
{
|
|
633
|
+
error: "git not installed",
|
|
634
|
+
error_type: "git_not_installed",
|
|
635
|
+
message: err.message,
|
|
636
|
+
},
|
|
637
|
+
{ status: 503 },
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
throw err;
|
|
641
|
+
}
|
|
642
|
+
|
|
600
643
|
// Validate via `git ls-remote <embedded-auth-url>` — uses the same
|
|
601
644
|
// x-access-token shape we'd embed at push time so the probe exercises
|
|
602
645
|
// the actual auth path. If the operator pasted a URL that already has
|
|
@@ -1089,11 +1132,16 @@ export async function applyCredentialsToMirror(
|
|
|
1089
1132
|
*
|
|
1090
1133
|
* `spawnOverride` is a test seam: lets the test inject a fake git binary.
|
|
1091
1134
|
* Production callers omit it; `cloneAndImport` falls back to `defaultGitSpawn`.
|
|
1135
|
+
*
|
|
1136
|
+
* `whichOverride` is a test seam for the git-presence preflight (default
|
|
1137
|
+
* `Bun.which` inside `cloneAndImport`). Inject a fn returning `null` to
|
|
1138
|
+
* exercise the git_not_installed 503 path without uninstalling git.
|
|
1092
1139
|
*/
|
|
1093
1140
|
export async function handleMirrorImport(
|
|
1094
1141
|
req: Request,
|
|
1095
1142
|
vaultName: string,
|
|
1096
1143
|
spawnOverride?: GitSpawn,
|
|
1144
|
+
whichOverride?: (cmd: string) => string | null,
|
|
1097
1145
|
): Promise<Response> {
|
|
1098
1146
|
let body: {
|
|
1099
1147
|
remote_url?: unknown;
|
|
@@ -1208,8 +1256,21 @@ export async function handleMirrorImport(
|
|
|
1208
1256
|
store,
|
|
1209
1257
|
assetsDir: assets,
|
|
1210
1258
|
spawn: spawnOverride,
|
|
1259
|
+
which: whichOverride,
|
|
1211
1260
|
});
|
|
1212
1261
|
} catch (err) {
|
|
1262
|
+
if (err instanceof GitNotInstalledError) {
|
|
1263
|
+
// 503 Service Unavailable — the server isn't configured to do this
|
|
1264
|
+
// yet (git missing). The message tells the operator how to fix it.
|
|
1265
|
+
return Response.json(
|
|
1266
|
+
{
|
|
1267
|
+
error: "git not installed",
|
|
1268
|
+
error_type: "git_not_installed",
|
|
1269
|
+
message: err.message,
|
|
1270
|
+
},
|
|
1271
|
+
{ status: 503 },
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1213
1274
|
if (err instanceof ImportConflictError) {
|
|
1214
1275
|
return Response.json(
|
|
1215
1276
|
{
|