openclaw/scripts/worktree-setup.mjs
Jason (Json) f5c3d16561
fix: keep source-only sessions free of dependency installs (#149587)
* fix: keep source-only sessions free of dependency installs

* fix: satisfy setup typing and ordering checks

* test(plugins): cover bundled but publishable packages
2026-09-16 15:47:50 -06:00

288 lines
10 KiB
JavaScript

#!/usr/bin/env node
// Explicit repository preparation; source composition never selects a workload.
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const WORKLOADS = new Set(["source", "gateway", "full"]);
const SOURCE_GUIDANCE =
"[worktree-setup] Source-only: no dependencies installed or artifacts built. " +
"For gateway work run node scripts/worktree-setup.mjs gateway; " +
"for full preparation run node scripts/worktree-setup.mjs full.";
const FULL_TRANSITION = "Run git sparse-checkout disable, then retry preparation.";
function git(rootDir, args, input) {
return execFileSync("git", args, {
cwd: rootDir,
encoding: "utf8",
input,
maxBuffer: 32 * 1024 * 1024,
stdio: ["pipe", "pipe", "pipe"],
});
}
function readSparseState(rootDir) {
const result = spawnSync("git", ["config", "--get", "--bool", "core.sparseCheckout"], {
cwd: rootDir,
encoding: "utf8",
});
if (result.error) {
throw result.error;
}
if (result.status === 1) {
return false;
}
if (result.status !== 0) {
throw new Error(result.stderr.trim() || "Cannot read Git sparse-checkout state.");
}
return result.stdout.trim() === "true";
}
function validateInputs(rootDir, workload) {
const topLevel = git(rootDir, ["rev-parse", "--show-toplevel"]).trim();
if (fs.realpathSync(topLevel) !== fs.realpathSync(rootDir)) {
throw new Error("Run preparation from the target repository root.");
}
const sparse = readSparseState(rootDir);
if (workload === "full" && sparse) {
throw new Error("Full preparation requires full source. " + FULL_TRANSITION);
}
const tracked = git(rootDir, ["ls-files", "-z"]).split("\0").filter(Boolean);
let required = tracked;
if (sparse) {
// Git interprets the repository-owned cone list. This helper has no profile
// parser and never changes checkout state.
const definition = path.join(rootDir, ".openclaw/worktree-profiles/gateway");
if (!fs.existsSync(definition)) {
throw new Error(
"Sparse gateway preparation needs the repository gateway definition. " + FULL_TRANSITION,
);
}
required = git(
rootDir,
["sparse-checkout", "check-rules", "--cone", "--rules-file", definition, "-z"],
tracked.join("\0") + "\0",
)
.split("\0")
.filter(Boolean);
const included = new Set(
git(rootDir, ["sparse-checkout", "check-rules", "-z"], required.join("\0") + "\0")
.split("\0")
.filter(Boolean),
);
const excluded = required.filter((file) => !included.has(file));
if (excluded.length > 0) {
throw new Error(
"Gateway inputs are excluded: " + excluded.slice(0, 10).join(", ") + ". " + FULL_TRANSITION,
);
}
}
const missing = required.filter((file) => !fs.existsSync(path.join(rootDir, file)));
if (missing.length > 0) {
throw new Error(
"Tracked preparation inputs are missing: " +
missing.slice(0, 10).join(", ") +
". " +
FULL_TRANSITION,
);
}
// The package/build inventories below use the filesystem. Validate first so
// a missing tracked plugin cannot silently narrow the selected install.
for (const file of [
"package.json",
"pnpm-workspace.yaml",
"pnpm-lock.yaml",
"scripts/build-all.mts",
]) {
if (!fs.existsSync(path.join(rootDir, file))) {
throw new Error("Missing preparation input: " + file);
}
}
return { sparse, tracked, requiredCount: required.length };
}
export async function createWorktreeSetupPlan({ rootDir, workload = "source", env = process.env }) {
if (!WORKLOADS.has(workload)) {
throw new Error("Unknown preparation workload: " + workload);
}
if (workload === "source") {
return { workload, packageManager: null, install: null, build: null, requiredInputs: null };
}
const inputs = validateInputs(rootDir, workload);
const pkg = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
if (
typeof pkg.packageManager !== "string" ||
!/^pnpm@\d+\.\d+\.\d+(?:[-+].*)?$/u.test(pkg.packageManager)
) {
throw new Error("Target package.json must pin pnpm through packageManager.");
}
const filters = new Set();
if (workload === "gateway") {
const { collectSourceCheckoutPluginBuildEntries } =
await import("./lib/bundled-plugin-build-entries.mjs");
const { readBundledPluginAssetHooks } = await import("./bundled-plugin-assets.mts");
if (typeof pkg.name !== "string" || !pkg.name) {
throw new Error("Target package.json is missing its package name.");
}
filters.add(pkg.name + "...");
filters.add("./packages/*...");
for (const entry of collectSourceCheckoutPluginBuildEntries({ cwd: rootDir, env })) {
if (entry.hasPackageJson) {
filters.add("./extensions/" + entry.id + "...");
}
}
for (const phase of ["build", "copy"]) {
for (const hook of await readBundledPluginAssetHooks({ rootDir, phase })) {
filters.add(
"./" + path.relative(rootDir, hook.pluginDir).split(path.sep).join("/") + "...",
);
}
}
}
const filterArgs = [...filters]
.toSorted((left, right) => (left < right ? -1 : left > right ? 1 : 0))
.flatMap((filter) => ["--filter", filter]);
return {
workload,
packageManager: pkg.packageManager,
install: { command: "pnpm", args: [...filterArgs, "install", "--frozen-lockfile"] },
build:
workload === "gateway"
? {
command: "node",
args: ["--import", "./scripts/tsx.mjs", "scripts/build-all.mts", "qaRuntime"],
}
: { command: "pnpm", args: ["build"] },
requiredInputs: { sparse: inputs.sparse, trackedFiles: inputs.requiredCount },
};
}
function existingAncestor(target) {
let current = target;
while (!fs.existsSync(current)) {
const parent = path.dirname(current);
if (parent === current) {
throw new Error("Cannot inspect package store volume: " + target);
}
current = parent;
}
return current;
}
async function executePreparation(rootDir, workload) {
const plan = await createWorktreeSetupPlan({ rootDir, workload });
const { assertRealOutputRoot } = await import("./lib/output-root-guard.mjs");
const { createPnpmRunnerSpawnSpec } = await import("./pnpm-runner.mts");
const { runManagedCommand } = await import("./lib/managed-child-process.mts");
const validateOutputRoots = () => {
const manifests = git(rootDir, ["ls-files", "-z"])
.split("\0")
.filter((file) => file === "package.json" || file.endsWith("/package.json"));
for (const manifest of manifests) {
const packageRoot = path.join(rootDir, path.dirname(manifest));
assertRealOutputRoot(path.join(packageRoot, "node_modules"));
}
assertRealOutputRoot(path.join(rootDir, "node_modules/.pnpm"));
assertRealOutputRoot(path.join(rootDir, "dist"));
};
validateOutputRoots();
const pnpm = (args) => createPnpmRunnerSpawnSpec({ cwd: rootDir, pnpmArgs: args });
const capture = (args) => {
const spec = pnpm(args);
return execFileSync(spec.command, spec.args, {
...spec.options,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
};
const version = capture(["--version"]);
const pinnedVersion = plan.packageManager.slice("pnpm@".length).split("+")[0];
if (version !== pinnedVersion) {
throw new Error(
"Expected repository pnpm " +
pinnedVersion +
", got " +
version +
". Run through the repository-pinned toolchain.",
);
}
const storePath = capture(["store", "path"]);
if (
!path.isAbsolute(storePath) ||
fs.statSync(existingAncestor(storePath)).dev !== fs.statSync(rootDir).dev
) {
throw new Error("Preparation requires a pnpm store on the same volume as the worktree.");
}
// Do not trust a previously displayed plan as permission to prepare changed inputs.
const current = await createWorktreeSetupPlan({ rootDir, workload });
if (JSON.stringify(current) !== JSON.stringify(plan)) {
throw new Error("Preparation inputs changed while checking the toolchain; retry.");
}
// Toolchain probes can take time; reject output roots linked since the first check.
validateOutputRoots();
for (const step of [current.install, current.build]) {
const spec =
step.command === "pnpm"
? pnpm(step.args)
: {
command: process.execPath,
args: step.args,
options: { cwd: rootDir, shell: false, stdio: "inherit" },
};
const status = await runManagedCommand({
bin: spec.command,
args: spec.args,
...spec.options,
requireProcessTreeExit: process.platform !== "win32",
});
if (status !== 0) {
return status;
}
}
return 0;
}
export function parseWorktreeSetupArgs(argv) {
let workload;
let plan = false;
let help = false;
for (const arg of argv) {
if (arg === "--plan") {
plan = true;
} else if (arg === "--help" || arg === "-h") {
help = true;
} else if (WORKLOADS.has(arg) && workload === undefined) {
workload = arg;
} else {
throw new Error("Unexpected preparation argument: " + arg);
}
}
return { workload: workload ?? "source", plan, help };
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
try {
const args = parseWorktreeSetupArgs(process.argv.slice(2));
if (args.help) {
console.log(
"Usage: node scripts/worktree-setup.mjs [source|gateway|full] [--plan]\n" + SOURCE_GUIDANCE,
);
} else if (args.plan) {
console.log(
JSON.stringify(
await createWorktreeSetupPlan({ rootDir: process.cwd(), workload: args.workload }),
null,
2,
),
);
} else if (args.workload === "source") {
console.log(SOURCE_GUIDANCE);
} else {
process.exitCode = await executePreparation(process.cwd(), args.workload);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}