// @npmcli/run-script for the guest npm, with no shell under it: a script parses as // script.ts reads sh (npm appends the arguments to it quoted, as promise-spawn does), // and each simple command runs under node, as `node ` or as a file found by name // on the script's PATH (a bin link, /usr/local/bin/npm) or by its path. A command that // is neither prints dash's `not found` and is status 127, as sh would make it; `env` // alone prints the environment. The environment is make-spawn-args.js's and // set-path.js's. Guest code: node: builtins only. import { spawn, type ChildProcess } from "node:child_process"; import { readFileSync, statSync } from "node:fs"; import { dirname, resolve } from "node:path"; import process from "node:process"; import { NpmFailure } from "./output"; import { parseScript, shQuote, type SimpleCommand } from "./script"; /** package.json as run-script reads it, normalized: bin an object, _id when named and versioned. */ export interface Pkg { name?: string; version?: string; _id?: string; scripts?: Record; bin?: Record; config?: unknown; engines?: unknown; } /** How a script ended: the status sh would exit with, and the signal its last command died of. */ export interface Exit { code: number; signal: string | null; } export const NPM_ROOT = "/usr/local/lib/node_modules/npm"; const NODE_GYP = `${NPM_ROOT}/node_modules/node-gyp/bin/node-gyp.js`; const NODE_GYP_BIN = `${NPM_ROOT}/node_modules/@npmcli/run-script/lib/node-gyp-bin`; const DEFAULT_PATH = "/usr/local/bin:/usr/bin:/bin"; const SIGNALS: Record = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGUSR1: 10, SIGUSR2: 12, SIGPIPE: 13, SIGALRM: 14, SIGTERM: 15 }; /** @npmcli/package-json's normalize, for what scripts read: `_id`, and a string bin as the unscoped name's. */ export function readPackage(dir: string): Pkg { const path = resolve(dir, "package.json"); let text: string; try { text = readFileSync(path, "utf8"); } catch { throw new NpmFailure({ code: "ENOENT", syscall: "open", path, errno: -2, message: `Could not read package.json: Error: ENOENT: no such file or directory, open '${path}'`, }); } const pkg = JSON.parse(text) as Pkg & { bin?: unknown }; if (typeof pkg.bin === "string" && pkg.name) pkg.bin = { [pkg.name.replace(/^@[^/]+\//, "")]: pkg.bin }; if (pkg.bin && typeof pkg.bin === "object") { pkg.bin = Object.fromEntries(Object.entries(pkg.bin).map(([k, v]) => [k, resolve("/", String(v)).slice(1)])); } if (pkg.name && pkg.version) pkg._id = `${pkg.name}@${pkg.version}`; return pkg as Pkg; } /** package-envs.js: `npm_package_` for each scalar, `_`-joined through objects and arrays. */ function packageEnvs(vals: Record, prefix: string, env: Record): Record { for (const [key, val] of Object.entries(vals)) { if (val === undefined) continue; else if (val === null || val === false) env[`${prefix}${key}`] = ""; else if (Array.isArray(val)) val.forEach((item, i) => packageEnvs({ [`${key}_${i}`]: item }, prefix, env)); else if (typeof val === "object") packageEnvs(val as Record, `${prefix}${key}_`, env); else env[`${prefix}${key}`] = String(val); } return env; } interface ScriptOptions { pkg: Pkg; /** Where it runs, and where the .bin walk starts. */ path: string; event: string; cmd: string; args: readonly string[]; /** Bin dirs ahead of the walk: npx's install dir or the local bin it found. */ binPaths?: readonly string[]; /** run-script-pkg.js prints it for `stdio: inherit`; display.js hides it for `npm exec`. */ banner: boolean; } /** make-spawn-args.js and set-path.js: npm's environment, the package's, the event's, * and PATH with each node_modules/.bin from `path` to the root in front. */ export function scriptEnv({ pkg, path, event, cmd, binPaths = [] }: ScriptOptions): Record { const env: Record = {}; for (const [k, v] of Object.entries(process.env)) if (v !== undefined) env[k] = v; packageEnvs({ name: pkg.name, version: pkg.version, config: pkg.config, engines: pkg.engines, bin: pkg.bin }, "npm_package_", env); Object.assign(env, { npm_package_json: resolve(path, "package.json"), npm_lifecycle_event: event, npm_lifecycle_script: cmd, npm_config_node_gyp: env.npm_config_node_gyp ?? NODE_GYP, }); const keys = Object.keys(env).filter((k) => /^path$/i.test(k) && env[k]); const inherited: string[] = []; for (const k of keys) for (const p of env[k]!.split(":")) if (!inherited.includes(p)) inherited.push(p); const dirs = [...binPaths]; for (let p = path, pp = ""; p !== pp; pp = p, p = dirname(p)) dirs.push(resolve(p, "node_modules", ".bin")); // set-path.js only rewrites a PATH the environment has; a process started with none // would find no bins at all, so it gets one ending in the dirs npm and npx are in. const value = [...dirs, NODE_GYP_BIN, inherited.length ? inherited.join(":") : DEFAULT_PATH].join(":"); for (const k of keys.length ? keys : ["PATH"]) env[k] = value; return env; } export function banner({ pkg, event, cmd, args }: ScriptOptions): string { const head = pkg._id ? `\n> ${pkg._id} ${event}\n` : `\n> ${event}\n`; return `${head}> ${cmd.trim().replace(/\n/g, "\n> ")}${args.length ? ` ${args.join(" ")}` : ""}\n\n`; } const isFile = (path: string): boolean => { try { return statSync(path).isFile(); } catch { return false; } }; /** What sh would run for a command name: node itself, a file, or nothing. */ function which(name: string, env: Record, cwd: string): string | "node" | null { if (name === "node") return "node"; if (name.includes("/")) return isFile(resolve(cwd, name)) ? resolve(cwd, name) : null; for (const dir of (env.PATH ?? "").split(":")) { const candidate = resolve(cwd, dir, name); if (isFile(candidate)) return candidate; } return null; } /** The interpreter a file's `#!` names, when it is not node. */ function foreignInterpreter(file: string): string | null { const head = readFileSync(file, "utf8").slice(0, 256); if (!head.startsWith("#!")) return null; const line = head.slice(2).split("\n")[0]!.trim(); return /(^|[/\s])node(\s|$)/.test(line) ? null : line; } const running = new Set(); /** signal-manager.js: SIGINT and SIGTERM reach the running children, and npm waits for them. */ const forward = (signal: NodeJS.Signals): void => { for (const child of running) child.kill(signal); }; const onInt = (): void => forward("SIGINT"); const onTerm = (): void => forward("SIGTERM"); function runCommand(command: SimpleCommand, env: Record, cwd: string): Promise { const [name, ...args] = command.argv; const childEnv = { ...env, ...command.assign }; if (name === "env" && args.length === 0) { // env(1) with no arguments, which `npm run env` runs: the environment, one per line. process.stdout.write(Object.entries(childEnv).map(([k, v]) => `${k}=${v}\n`).join("")); return Promise.resolve({ code: 0, signal: null }); } const found = which(name, childEnv, cwd); if (found === null) { process.stderr.write(`sh: ${command.line}: ${name}: not found\n`); return Promise.resolve({ code: 127, signal: null }); } if (found !== "node") { const interpreter = foreignInterpreter(found); if (interpreter !== null) { throw new NpmFailure({ code: "ENOTSUP", message: `${name} is a script for \`${interpreter}\`; this runtime runs node and nothing else` }); } } let child: ReturnType; try { child = spawn("node", found === "node" ? args : [found, ...args], { cwd, env: childEnv, stdio: "inherit" }); } catch (e) { throw new NpmFailure({ code: "ENOTSUP", message: (e as Error).message }); } if (running.size === 0) { process.on("SIGINT", onInt); process.on("SIGTERM", onTerm); } running.add(child); return new Promise((done) => { child.on("close", (code: number | null, signal: string | null) => { running.delete(child); if (running.size === 0) { process.removeListener("SIGINT", onInt); process.removeListener("SIGTERM", onTerm); } done({ code: signal ? 128 + (SIGNALS[signal] ?? 0) : code ?? 0, signal }); }); }); } /** run-script-pkg.js: the banner, then the script's commands in order, each gated by the * `&&`, `||` or `;` before it on the status so far, as sh runs an and-or list. */ export async function runScript(options: ScriptOptions): Promise { if (options.banner) process.stdout.write(banner(options)); const env = scriptEnv(options); const text = [options.cmd, ...options.args.map(shQuote)].join(" "); const script = parseScript(text, env); if (script.kind === "refused") { throw new NpmFailure({ code: "ENOTSUP", message: `This runtime has no shell, so a script runs only simple commands joined by &&, || and ;\n\`${text}\` uses ${script.construct} (${script.near})`, }); } let exit: Exit = { code: 0, signal: null }; for (const { when, command } of script.steps) { if ((when === "ok" && exit.code !== 0) || (when === "failed" && exit.code === 0)) continue; exit = await runCommand(command, env, options.path); } return exit; } /** npm's own exit once a script ended: its status, or its signal raised again on npm, * as run-script-pkg.js does so that npm dies the way the child did. */ export function exitLike(exit: Exit): void { if (exit.signal) { process.removeAllListeners(exit.signal); process.kill(process.pid, exit.signal); return; } if (exit.code) process.exitCode = exit.code; }