chore: harden installer and add smoke ci
This commit is contained in:
@@ -1,73 +1,12 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
function isBunInstall() {
|
||||
const ua = process.env.npm_config_user_agent ?? "";
|
||||
return ua.includes("bun/");
|
||||
}
|
||||
|
||||
function getRepoRoot() {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
return path.resolve(here, "..");
|
||||
}
|
||||
|
||||
function run(cmd, args, opts = {}) {
|
||||
const res = spawnSync(cmd, args, { stdio: "inherit", ...opts });
|
||||
if (typeof res.status === "number") return res.status;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function applyPatchIfNeeded(opts) {
|
||||
const patchPath = path.resolve(opts.patchPath);
|
||||
if (!fs.existsSync(patchPath)) {
|
||||
throw new Error(`missing patch: ${patchPath}`);
|
||||
}
|
||||
|
||||
let targetDir = path.resolve(opts.targetDir);
|
||||
if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) {
|
||||
console.warn(`[postinstall] skip missing target: ${targetDir}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve symlinks to avoid "beyond a symbolic link" errors from git apply
|
||||
// (bun/pnpm use symlinks in node_modules)
|
||||
targetDir = fs.realpathSync(targetDir);
|
||||
|
||||
const gitArgsBase = ["apply", "--unsafe-paths", "--whitespace=nowarn"];
|
||||
const reverseCheck = [
|
||||
...gitArgsBase,
|
||||
"--reverse",
|
||||
"--check",
|
||||
"--directory",
|
||||
targetDir,
|
||||
patchPath,
|
||||
];
|
||||
const forwardCheck = [
|
||||
...gitArgsBase,
|
||||
"--check",
|
||||
"--directory",
|
||||
targetDir,
|
||||
patchPath,
|
||||
];
|
||||
const apply = [...gitArgsBase, "--directory", targetDir, patchPath];
|
||||
|
||||
// Already applied?
|
||||
if (run("git", reverseCheck, { stdio: "ignore" }) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (run("git", forwardCheck, { stdio: "ignore" }) !== 0) {
|
||||
throw new Error(`patch does not apply cleanly: ${path.basename(patchPath)}`);
|
||||
}
|
||||
|
||||
const status = run("git", apply);
|
||||
if (status !== 0) {
|
||||
throw new Error(`failed applying patch: ${path.basename(patchPath)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function extractPackageName(key) {
|
||||
if (key.startsWith("@")) {
|
||||
const idx = key.indexOf("@", 1);
|
||||
@@ -79,9 +18,180 @@ function extractPackageName(key) {
|
||||
return key.slice(0, idx);
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!isBunInstall()) return;
|
||||
function stripPrefix(p) {
|
||||
if (p.startsWith("a/") || p.startsWith("b/")) return p.slice(2);
|
||||
return p;
|
||||
}
|
||||
|
||||
function parseRange(segment) {
|
||||
// segment: "-12,5" or "+7"
|
||||
const [startRaw, countRaw] = segment.slice(1).split(",");
|
||||
const start = Number.parseInt(startRaw, 10);
|
||||
const count = countRaw ? Number.parseInt(countRaw, 10) : 1;
|
||||
if (Number.isNaN(start) || Number.isNaN(count)) {
|
||||
throw new Error(`invalid hunk range: ${segment}`);
|
||||
}
|
||||
return { start, count };
|
||||
}
|
||||
|
||||
function parsePatch(patchText) {
|
||||
const lines = patchText.split("\n");
|
||||
const files = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
if (!lines[i].startsWith("diff --git ")) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = { oldPath: null, newPath: null, hunks: [] };
|
||||
i += 1;
|
||||
|
||||
// Skip index line(s)
|
||||
while (i < lines.length && lines[i].startsWith("index ")) i += 1;
|
||||
|
||||
if (i < lines.length && lines[i].startsWith("--- ")) {
|
||||
file.oldPath = stripPrefix(lines[i].slice(4).trim());
|
||||
i += 1;
|
||||
}
|
||||
if (i < lines.length && lines[i].startsWith("+++ ")) {
|
||||
file.newPath = stripPrefix(lines[i].slice(4).trim());
|
||||
i += 1;
|
||||
}
|
||||
|
||||
while (i < lines.length && lines[i].startsWith("@@")) {
|
||||
const header = lines[i];
|
||||
const match = /^@@\s+(-\d+(?:,\d+)?)\s+(\+\d+(?:,\d+)?)\s+@@/.exec(header);
|
||||
if (!match) throw new Error(`invalid hunk header: ${header}`);
|
||||
const oldRange = parseRange(match[1]);
|
||||
const newRange = parseRange(match[2]);
|
||||
i += 1;
|
||||
|
||||
const hunkLines = [];
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (line.startsWith("@@") || line.startsWith("diff --git ")) break;
|
||||
if (line === "") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("\\ No newline at end of file")) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
hunkLines.push(line);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
file.hunks.push({
|
||||
oldStart: oldRange.start,
|
||||
oldLines: oldRange.count,
|
||||
newStart: newRange.start,
|
||||
newLines: newRange.count,
|
||||
lines: hunkLines,
|
||||
});
|
||||
}
|
||||
|
||||
if (file.newPath && file.hunks.length > 0) {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function readFileLines(targetPath) {
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
throw new Error(`target file missing: ${targetPath}`);
|
||||
}
|
||||
const raw = fs.readFileSync(targetPath, "utf-8");
|
||||
const hasTrailingNewline = raw.endsWith("\n");
|
||||
const parts = raw.split("\n");
|
||||
if (hasTrailingNewline) parts.pop();
|
||||
return { lines: parts, hasTrailingNewline };
|
||||
}
|
||||
|
||||
function writeFileLines(targetPath, lines, hadTrailingNewline) {
|
||||
const content = lines.join("\n") + (hadTrailingNewline ? "\n" : "");
|
||||
fs.writeFileSync(targetPath, content, "utf-8");
|
||||
}
|
||||
|
||||
function applyHunk(lines, hunk, offset) {
|
||||
let cursor = hunk.oldStart - 1 + offset;
|
||||
|
||||
for (const raw of hunk.lines) {
|
||||
const marker = raw[0];
|
||||
const text = raw.slice(1);
|
||||
if (marker === " ") {
|
||||
if (lines[cursor] !== text) {
|
||||
throw new Error(
|
||||
`context mismatch at line ${cursor + 1}: expected "${text}", found "${lines[cursor] ?? "<eof>"}"`,
|
||||
);
|
||||
}
|
||||
cursor += 1;
|
||||
} else if (marker === "-") {
|
||||
if (lines[cursor] !== text) {
|
||||
throw new Error(
|
||||
`delete mismatch at line ${cursor + 1}: expected "${text}", found "${lines[cursor] ?? "<eof>"}"`,
|
||||
);
|
||||
}
|
||||
lines.splice(cursor, 1);
|
||||
} else if (marker === "+") {
|
||||
lines.splice(cursor, 0, text);
|
||||
cursor += 1;
|
||||
} else {
|
||||
throw new Error(`unexpected hunk marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
const delta = hunk.newLines - hunk.oldLines;
|
||||
return offset + delta;
|
||||
}
|
||||
|
||||
function applyPatchToFile(targetDir, filePatch) {
|
||||
if (filePatch.newPath === "/dev/null") {
|
||||
// deletion not needed for our patches
|
||||
return;
|
||||
}
|
||||
const relPath = stripPrefix(filePatch.newPath ?? filePatch.oldPath ?? "");
|
||||
const targetPath = path.join(targetDir, relPath);
|
||||
const { lines, hasTrailingNewline } = readFileLines(targetPath);
|
||||
|
||||
let offset = 0;
|
||||
for (const hunk of filePatch.hunks) {
|
||||
offset = applyHunk(lines, hunk, offset);
|
||||
}
|
||||
|
||||
writeFileLines(targetPath, lines, hasTrailingNewline);
|
||||
}
|
||||
|
||||
function applyPatchSet({ patchText, targetDir }) {
|
||||
let resolvedTarget = path.resolve(targetDir);
|
||||
if (!fs.existsSync(resolvedTarget) || !fs.statSync(resolvedTarget).isDirectory()) {
|
||||
console.warn(`[postinstall] skip missing target: ${resolvedTarget}`);
|
||||
return;
|
||||
}
|
||||
resolvedTarget = fs.realpathSync(resolvedTarget);
|
||||
|
||||
const files = parsePatch(patchText);
|
||||
if (files.length === 0) return;
|
||||
|
||||
for (const filePatch of files) {
|
||||
applyPatchToFile(resolvedTarget, filePatch);
|
||||
}
|
||||
}
|
||||
|
||||
function applyPatchFile({ patchPath, targetDir }) {
|
||||
const absPatchPath = path.resolve(patchPath);
|
||||
if (!fs.existsSync(absPatchPath)) {
|
||||
throw new Error(`missing patch: ${absPatchPath}`);
|
||||
}
|
||||
const patchText = fs.readFileSync(absPatchPath, "utf-8");
|
||||
applyPatchSet({ patchText, targetDir });
|
||||
}
|
||||
|
||||
function main() {
|
||||
const repoRoot = getRepoRoot();
|
||||
process.chdir(repoRoot);
|
||||
|
||||
@@ -95,7 +205,7 @@ function main() {
|
||||
if (typeof relPatchPath !== "string" || !relPatchPath.trim()) continue;
|
||||
const pkgName = extractPackageName(String(key));
|
||||
if (!pkgName) continue;
|
||||
applyPatchIfNeeded({
|
||||
applyPatchFile({
|
||||
targetDir: path.join("node_modules", ...pkgName.split("/")),
|
||||
patchPath: relPatchPath,
|
||||
});
|
||||
@@ -103,8 +213,22 @@ function main() {
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
const skip =
|
||||
process.env.CLAWDBOT_SKIP_POSTINSTALL === "1" ||
|
||||
process.env.VITEST === "true" ||
|
||||
process.env.NODE_ENV === "test";
|
||||
|
||||
if (!skip) {
|
||||
main();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export {
|
||||
applyPatchFile,
|
||||
applyPatchSet,
|
||||
applyPatchToFile,
|
||||
parsePatch,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user