Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221// @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 <args>` 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<string, string>; bin?: Record<string, string>; 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<string, number> = { 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_<key>` for each scalar, `_`-joined through objects and arrays. */function packageEnvs(vals: Record<string, unknown>, prefix: string, env: Record<string, string>): Record<string, string> { 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<string, unknown>, `${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<string, string> { const env: Record<string, string> = {}; 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<string, string>, 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<ChildProcess>();/** 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<string, string>, cwd: string): Promise<Exit> { 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<typeof spawn>; 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<Exit> { 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;}