| import fs from "node:fs"; |
| import path from "node:path"; |
| import { spawnSync } from "node:child_process"; |
| import { fileURLToPath } from "node:url"; |
| import { setupGitHooks } from "./setup-git-hooks.js"; |
|
|
| function detectPackageManager(ua = process.env.npm_config_user_agent ?? "") { |
| |
| |
| |
| |
| const normalized = String(ua).trim(); |
| if (normalized.startsWith("pnpm/")) return "pnpm"; |
| if (normalized.startsWith("bun/")) return "bun"; |
| if (normalized.startsWith("npm/")) return "npm"; |
| if (normalized.startsWith("yarn/")) return "yarn"; |
| return "unknown"; |
| } |
|
|
| function shouldApplyPnpmPatchedDependenciesFallback(pm = detectPackageManager()) { |
| |
| return pm !== "pnpm"; |
| } |
|
|
| function getRepoRoot() { |
| const here = path.dirname(fileURLToPath(import.meta.url)); |
| return path.resolve(here, ".."); |
| } |
|
|
| function ensureExecutable(targetPath) { |
| if (process.platform === "win32") return; |
| if (!fs.existsSync(targetPath)) return; |
| try { |
| const mode = fs.statSync(targetPath).mode & 0o777; |
| if (mode & 0o100) return; |
| fs.chmodSync(targetPath, 0o755); |
| } catch (err) { |
| console.warn(`[postinstall] chmod failed: ${err}`); |
| } |
| } |
|
|
| function hasGit(repoRoot) { |
| const result = spawnSync("git", ["--version"], { |
| cwd: repoRoot, |
| stdio: "ignore", |
| }); |
| return result.status === 0; |
| } |
|
|
| function extractPackageName(key) { |
| if (key.startsWith("@")) { |
| const idx = key.indexOf("@", 1); |
| if (idx === -1) return key; |
| return key.slice(0, idx); |
| } |
| const idx = key.lastIndexOf("@"); |
| if (idx <= 0) return key; |
| return key.slice(0, idx); |
| } |
|
|
| function stripPrefix(p) { |
| if (p.startsWith("a/") || p.startsWith("b/")) return p.slice(2); |
| return p; |
| } |
|
|
| function parseRange(segment) { |
| |
| 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; |
|
|
| |
| 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; |
| const expected = []; |
| for (const raw of hunk.lines) { |
| const marker = raw[0]; |
| if (marker === " " || marker === "+") { |
| expected.push(raw.slice(1)); |
| } |
| } |
| if (cursor >= 0 && cursor + expected.length <= lines.length) { |
| let alreadyApplied = true; |
| for (let i = 0; i < expected.length; i += 1) { |
| if (lines[cursor + i] !== expected[i]) { |
| alreadyApplied = false; |
| break; |
| } |
| } |
| if (alreadyApplied) { |
| const delta = hunk.newLines - hunk.oldLines; |
| return offset + delta; |
| } |
| } |
|
|
| 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") { |
| |
| 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 trySetupCompletion(repoRoot) { |
| |
| if (process.env.CI || process.env.OPENCLAW_SKIP_COMPLETION_SETUP) return; |
|
|
| const binPath = path.join(repoRoot, "openclaw.mjs"); |
| if (!fs.existsSync(binPath)) return; |
|
|
| |
| const distEntry = path.join(repoRoot, "dist", "index.js"); |
| if (!fs.existsSync(distEntry)) return; |
|
|
| try { |
| |
| |
| spawnSync(process.execPath, [binPath, "completion", "--install", "--yes"], { |
| cwd: repoRoot, |
| stdio: "inherit", |
| env: { ...process.env, OPENCLAW_SKIP_POSTINSTALL: "1" }, |
| }); |
| } catch (err) { |
| |
| } |
| } |
|
|
| function main() { |
| const repoRoot = getRepoRoot(); |
| process.chdir(repoRoot); |
|
|
| ensureExecutable(path.join(repoRoot, "dist", "/entry.js")); |
| setupGitHooks({ repoRoot }); |
| trySetupCompletion(repoRoot); |
|
|
| if (!shouldApplyPnpmPatchedDependenciesFallback()) { |
| return; |
| } |
|
|
| const pkgPath = path.join(repoRoot, "package.json"); |
| const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); |
| const patched = pkg?.pnpm?.patchedDependencies ?? {}; |
|
|
| |
| |
| for (const [key, relPatchPath] of Object.entries(patched)) { |
| if (typeof relPatchPath !== "string" || !relPatchPath.trim()) continue; |
| const pkgName = extractPackageName(String(key)); |
| if (!pkgName) continue; |
| applyPatchFile({ |
| targetDir: path.join("node_modules", ...pkgName.split("/")), |
| patchPath: relPatchPath, |
| }); |
| } |
| } |
|
|
| try { |
| const skip = |
| process.env.OPENCLAW_SKIP_POSTINSTALL === "1" || |
| 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, |
| detectPackageManager, |
| parsePatch, |
| shouldApplyPnpmPatchedDependenciesFallback, |
| }; |
|
|