fix: make prepare script cross-platform so npm install works on Windows

The prepare script used POSIX sh syntax, which fails under npm's
default cmd.exe script shell on Windows and aborts npm install in git
checkouts. Port the hook installer to a Node script.

Fixes #778

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Václav
2026-07-20 16:48:10 +02:00
committed by Tobias Lütke
parent 97ad6ea4cd
commit 9640d24a5f
6 changed files with 89 additions and 20 deletions
+2
View File
@@ -23,6 +23,8 @@
### Fixed
- `npm install` / `prepare` no longer fails on Windows in a git checkout (#778). The hook installer is now cross-platform `scripts/install-hooks.mjs` instead of a POSIX shell script; `prepare` still runs `node scripts/build.mjs` so git installs keep producing `dist/` (#824).
- `qmd mcp` (stdio) now shuts down gracefully when stdin reaches EOF instead
of orphaning to PID 1 when the parent MCP client dies (#751): the server
closes its transport, gives in-flight request handlers a bounded window to
+1 -1
View File
@@ -26,7 +26,7 @@
"CHANGELOG.md"
],
"scripts": {
"prepare": "([ -d .git ] && ./scripts/install-hooks.sh || true) && node scripts/build.mjs",
"prepare": "node scripts/install-hooks.mjs && node scripts/build.mjs",
"build": "node scripts/build.mjs",
"test": "node scripts/test-all.mjs",
"test:types": "node ./node_modules/typescript/bin/tsc -p tsconfig.build.json --noEmit",
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env node
// Self-installing git hooks for qmd
// Called from the package.json "prepare" script after install
import { chmodSync, copyFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(fileURLToPath(new URL("..", import.meta.url)));
const hooksDir = join(root, ".git", "hooks");
if (!existsSync(hooksDir)) {
console.log("Not a git repository, skipping hook install");
process.exit(0);
}
copyFileSync(join(root, "scripts", "pre-push"), join(hooksDir, "pre-push"));
chmodSync(join(hooksDir, "pre-push"), 0o755);
console.log("Installed git hooks: pre-push");
-19
View File
@@ -1,19 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Self-installing git hooks for qmd
# Called from package.json "prepare" script after bun install
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOOKS_DIR="$REPO_ROOT/.git/hooks"
if [[ ! -d "$HOOKS_DIR" ]]; then
echo "Not a git repository, skipping hook install"
exit 0
fi
# Install pre-push hook
cp "$REPO_ROOT/scripts/pre-push" "$HOOKS_DIR/pre-push"
chmod +x "$HOOKS_DIR/pre-push"
echo "Installed git hooks: pre-push"
+15
View File
@@ -35,6 +35,21 @@ function assertPath(path, label = path) {
return full;
}
// prepare must be pure Node (no POSIX test/[) so Windows cmd.exe can run it,
// and must still invoke the build so git installs produce dist/.
{
const prepare = pkg.scripts?.prepare ?? "";
if (/\[\s*-d/.test(prepare) || prepare.includes("install-hooks.sh")) {
console.error("Package smoke failed: prepare still uses a POSIX shell hook installer");
process.exit(1);
}
if (!prepare.includes("install-hooks.mjs") || !prepare.includes("scripts/build.mjs")) {
console.error("Package smoke failed: prepare must run install-hooks.mjs and build.mjs");
process.exit(1);
}
console.log("==> prepare script is Windows-safe and builds dist");
}
run("build compiled package", process.execPath, ["scripts/build.mjs"]);
run("AST grammar runtime packages", process.execPath, ["scripts/check-package-grammars.mjs"]);
+53
View File
@@ -0,0 +1,53 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, statSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const repoRoot = fileURLToPath(new URL("..", import.meta.url));
const installer = join(repoRoot, "scripts", "install-hooks.mjs");
describe("scripts/install-hooks.mjs", () => {
let tempRoot: string;
beforeEach(() => { tempRoot = mkdtempSync(join(tmpdir(), "qmd-install-hooks-")); });
afterEach(() => { rmSync(tempRoot, { recursive: true, force: true }); });
test("skips cleanly when .git/hooks is absent", () => {
const fakeRoot = join(tempRoot, "pkg");
mkdirSync(join(fakeRoot, "scripts"), { recursive: true });
writeFileSync(join(fakeRoot, "scripts", "pre-push"), "#!/bin/sh\necho hook\n");
writeFileSync(join(fakeRoot, "scripts", "install-hooks.mjs"), readFileSync(installer, "utf8"));
const result = spawnSync(process.execPath, [join(fakeRoot, "scripts", "install-hooks.mjs")], { cwd: fakeRoot, encoding: "utf8" });
expect(result.status).toBe(0);
expect(result.stdout).toMatch(/Not a git repository, skipping hook install/);
expect(existsSync(join(fakeRoot, ".git", "hooks", "pre-push"))).toBe(false);
});
test("copies pre-push into .git/hooks and makes it executable", () => {
const fakeRoot = join(tempRoot, "repo");
mkdirSync(join(fakeRoot, "scripts"), { recursive: true });
mkdirSync(join(fakeRoot, ".git", "hooks"), { recursive: true });
writeFileSync(join(fakeRoot, "scripts", "pre-push"), "#!/bin/sh\necho pre-push-ok\n");
writeFileSync(join(fakeRoot, "scripts", "install-hooks.mjs"), readFileSync(installer, "utf8"));
const result = spawnSync(process.execPath, [join(fakeRoot, "scripts", "install-hooks.mjs")], { cwd: fakeRoot, encoding: "utf8" });
expect(result.status).toBe(0);
expect(result.stdout).toMatch(/Installed git hooks: pre-push/);
const dest = join(fakeRoot, ".git", "hooks", "pre-push");
expect(readFileSync(dest, "utf8")).toContain("pre-push-ok");
if (process.platform !== "win32") {
expect(statSync(dest).mode & 0o111).toBeTruthy();
}
});
});
describe("package.json prepare script", () => {
test("is Windows-safe Node and still builds dist", () => {
const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"));
const prepare: string = pkg.scripts.prepare;
expect(prepare.includes("[ -d")).toBe(false);
expect(prepare.includes("install-hooks.sh")).toBe(false);
expect(prepare.includes("node scripts/install-hooks.mjs")).toBe(true);
expect(prepare.includes("node scripts/build.mjs")).toBe(true);
});
});