mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
246 lines
8.5 KiB
TypeScript
246 lines
8.5 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { isRecord } from "../../packages/normalization-core/src/record-coerce.ts";
|
|
import { resolveNpmRunner, type NpmRunnerParams } from "../npm-runner.mts";
|
|
import { resolveNpmJsonEntries } from "./npm-json-output.mts";
|
|
|
|
const NPM_VERSION_TIMEOUT_MS = 10_000;
|
|
const ISOLATED_ENV_KEYS =
|
|
"HOME INIT_CWD OLDPWD PWD USERPROFILE XDG_CACHE_HOME XDG_CONFIG_HOME".split(" ");
|
|
type NpmSandbox = Record<"cacheDir" | "configDir" | "cwd" | "homeDir", string>;
|
|
type NpmPackInventoryOptions = {
|
|
runnerParams?: Omit<NpmRunnerParams, "env" | "npmArgs">;
|
|
sourceEnv?: NodeJS.ProcessEnv;
|
|
timeoutMs: number;
|
|
};
|
|
export function compareNpmPackInventory(
|
|
tarFiles: Iterable<string>,
|
|
npmFiles: Iterable<string>,
|
|
ignoredPaths: Iterable<string> = [],
|
|
): { extra: string[]; missing: string[] } {
|
|
const ignored = new Set(ignoredPaths);
|
|
const tarSet = new Set([...tarFiles].filter((entry) => !ignored.has(entry)));
|
|
const npmSet = new Set([...npmFiles].filter((entry) => !ignored.has(entry)));
|
|
return {
|
|
extra: [...tarSet].filter((entry) => !npmSet.has(entry)).toSorted(),
|
|
missing: [...npmSet].filter((entry) => !tarSet.has(entry)).toSorted(),
|
|
};
|
|
}
|
|
|
|
function normalizePackagePath(value: unknown): string {
|
|
if (typeof value !== "string") {
|
|
throw new Error("npm pack returned a file entry without a string path");
|
|
}
|
|
const normalized = value.replaceAll("\\", "/").replace(/^\.\/+/u, "");
|
|
if (
|
|
!normalized ||
|
|
normalized.endsWith("/") ||
|
|
path.posix.isAbsolute(normalized) ||
|
|
path.win32.isAbsolute(value) ||
|
|
normalized.split("/").some((segment) => segment === "." || segment === "..")
|
|
) {
|
|
throw new Error(`npm pack returned an invalid package path: ${JSON.stringify(value)}`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function parseNpmVersion(value: unknown, source: string): string {
|
|
const version = typeof value === "string" ? value.trim() : "";
|
|
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(version)) {
|
|
throw new Error(`${source} returned invalid version: ${JSON.stringify(version.slice(0, 80))}`);
|
|
}
|
|
return version;
|
|
}
|
|
|
|
function parseNpmPackFiles(stdout: string): string[] {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(stdout);
|
|
} catch {
|
|
throw new Error("npm pack returned invalid JSON");
|
|
}
|
|
const entries = resolveNpmJsonEntries(parsed);
|
|
if (entries.length !== 1 || !isRecord(entries[0])) {
|
|
throw new Error("npm pack JSON must contain exactly one package result");
|
|
}
|
|
const files = entries[0].files;
|
|
if (!Array.isArray(files)) {
|
|
throw new Error("npm pack JSON result is missing its files array");
|
|
}
|
|
const normalizedFiles: string[] = [];
|
|
const seen = new Set<string>();
|
|
for (const entry of files) {
|
|
if (!isRecord(entry)) {
|
|
throw new Error("npm pack returned an invalid file entry");
|
|
}
|
|
const file = normalizePackagePath(entry.path);
|
|
if (seen.has(file)) {
|
|
throw new Error(`npm pack returned duplicate package path ${file}`);
|
|
}
|
|
seen.add(file);
|
|
normalizedFiles.push(file);
|
|
}
|
|
return normalizedFiles.toSorted();
|
|
}
|
|
|
|
function controlledNpmEnvironment(
|
|
sourceEnv: NodeJS.ProcessEnv,
|
|
sandbox: NpmSandbox,
|
|
): NodeJS.ProcessEnv {
|
|
const env = Object.fromEntries(
|
|
Object.entries(sourceEnv).filter(
|
|
([key]) => !/^npm_/iu.test(key) && !ISOLATED_ENV_KEYS.includes(key.toUpperCase()),
|
|
),
|
|
);
|
|
return {
|
|
...env,
|
|
HOME: sandbox.homeDir,
|
|
INIT_CWD: sandbox.cwd,
|
|
NPM_CONFIG_CACHE: sandbox.cacheDir,
|
|
NPM_CONFIG_GLOBALCONFIG: path.join(sandbox.configDir, "global.npmrc"),
|
|
NPM_CONFIG_USERCONFIG: path.join(sandbox.configDir, "user.npmrc"),
|
|
PWD: sandbox.cwd,
|
|
USERPROFILE: sandbox.homeDir,
|
|
XDG_CACHE_HOME: sandbox.cacheDir,
|
|
XDG_CONFIG_HOME: sandbox.configDir,
|
|
};
|
|
}
|
|
|
|
function describeSpawnFailure(
|
|
label: string,
|
|
result: ReturnType<typeof spawnSync>,
|
|
timeoutMs: number,
|
|
): string {
|
|
// Output-limit and timeout errors can also carry the signal used to stop the child.
|
|
switch ((result.error as NodeJS.ErrnoException | undefined)?.code) {
|
|
case "ENOBUFS":
|
|
return `${label} exceeded its output limit`;
|
|
case "ENOENT":
|
|
return `${label} executable was not found`;
|
|
case "ETIMEDOUT":
|
|
return `${label} timed out after ${timeoutMs}ms`;
|
|
default: {
|
|
const stderr = typeof result.stderr === "string" ? result.stderr.trim().slice(0, 2_000) : "";
|
|
const reason = result.signal ? `signal ${result.signal}` : `status ${String(result.status)}`;
|
|
const detail = stderr ? `: ${stderr}` : "";
|
|
return `${label} failed${result.signal || !stderr ? ` with ${reason}` : ""}${detail}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
function withoutPackageScripts<T>(packageRoot: string, run: () => T): T {
|
|
const packageJsonPath = path.join(packageRoot, "package.json");
|
|
const originalBytes = fs.readFileSync(packageJsonPath);
|
|
const originalMode = fs.statSync(packageJsonPath).mode;
|
|
const packageJson = JSON.parse(originalBytes.toString("utf8")) as unknown;
|
|
if (!isRecord(packageJson)) {
|
|
throw new Error("package.json must contain an object");
|
|
}
|
|
delete packageJson.scripts;
|
|
|
|
// Callers provide unique disposable extracted trees, so this synchronous mutation is isolated.
|
|
try {
|
|
if ((originalMode & 0o200) === 0) {
|
|
fs.chmodSync(packageJsonPath, originalMode | 0o200);
|
|
}
|
|
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson));
|
|
return run();
|
|
} finally {
|
|
try {
|
|
fs.writeFileSync(packageJsonPath, originalBytes);
|
|
} finally {
|
|
fs.chmodSync(packageJsonPath, originalMode);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function collectNpmPackInventory(packageRoot: string, options: NpmPackInventoryOptions) {
|
|
const sandboxRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-npm-pack-inventory-"));
|
|
const sandbox = {
|
|
cacheDir: path.join(sandboxRoot, "cache"),
|
|
configDir: path.join(sandboxRoot, "config"),
|
|
cwd: path.join(sandboxRoot, "cwd"),
|
|
homeDir: path.join(sandboxRoot, "home"),
|
|
};
|
|
for (const directory of Object.values(sandbox)) {
|
|
fs.mkdirSync(directory);
|
|
}
|
|
fs.writeFileSync(path.join(sandbox.configDir, "global.npmrc"), "", { mode: 0o600 });
|
|
fs.writeFileSync(path.join(sandbox.configDir, "user.npmrc"), "", { mode: 0o600 });
|
|
|
|
const npmEnv = controlledNpmEnvironment(options.sourceEnv ?? process.env, sandbox);
|
|
const spawnOptions = { cwd: sandbox.cwd, encoding: "utf8" as const, windowsHide: true };
|
|
const resolveNpm = (args: string[]) =>
|
|
resolveNpmRunner({
|
|
env: npmEnv,
|
|
npmArgs: [`--prefix=${sandbox.cwd}`, ...args],
|
|
...options.runnerParams,
|
|
});
|
|
const runNpm = (
|
|
label: string,
|
|
npm: ReturnType<typeof resolveNpmRunner>,
|
|
timeout: number,
|
|
maxBuffer: number,
|
|
): string => {
|
|
const result = spawnSync(npm.command, npm.args, {
|
|
...spawnOptions,
|
|
env: npm.env ?? npmEnv,
|
|
maxBuffer,
|
|
shell: npm.shell,
|
|
timeout,
|
|
windowsVerbatimArguments: npm.windowsVerbatimArguments,
|
|
});
|
|
if (result.status !== 0 || result.error) {
|
|
throw new Error(describeSpawnFailure(label, result, timeout));
|
|
}
|
|
return result.stdout;
|
|
};
|
|
const startedAt = Date.now();
|
|
try {
|
|
const versionRunner = resolveNpm(["--version"]);
|
|
// npm reports this manifest's version; avoid booting its CLI just for diagnostics.
|
|
const manifest: unknown = versionRunner.packageJsonPath
|
|
? JSON.parse(fs.readFileSync(versionRunner.packageJsonPath, "utf8"))
|
|
: undefined;
|
|
const npmVersion = parseNpmVersion(
|
|
versionRunner.packageJsonPath
|
|
? isRecord(manifest) && manifest.version
|
|
: runNpm("npm --version", versionRunner, NPM_VERSION_TIMEOUT_MS, 64 * 1024),
|
|
versionRunner.packageJsonPath ? "npm package.json" : "npm --version",
|
|
);
|
|
const packOutput = withoutPackageScripts(packageRoot, () =>
|
|
runNpm(
|
|
"npm pack inventory",
|
|
resolveNpm([
|
|
"pack",
|
|
packageRoot,
|
|
"--dry-run",
|
|
"--json",
|
|
"--ignore-scripts",
|
|
"--offline",
|
|
"--workspaces=false",
|
|
"--include-workspace-root=false",
|
|
"--audit=false",
|
|
"--fund=false",
|
|
"--update-notifier=false",
|
|
"--color=false",
|
|
"--loglevel=error",
|
|
]),
|
|
options.timeoutMs,
|
|
64 * 1024 * 1024,
|
|
),
|
|
);
|
|
if (fs.readdirSync(sandbox.cwd).length !== 0) {
|
|
throw new Error("npm pack inventory wrote files outside the extracted package root");
|
|
}
|
|
return {
|
|
durationMs: Date.now() - startedAt,
|
|
files: parseNpmPackFiles(packOutput),
|
|
npmVersion,
|
|
};
|
|
} finally {
|
|
fs.rmSync(sandboxRoot, { force: true, recursive: true });
|
|
}
|
|
}
|