#!/usr/bin/env node import { execFileSync, spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' import { existsSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { delimiter, dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { parseArgs } from 'node:util' // npm.cmd needs a shell. Use npm's JS entrypoint so paths remain argv on Windows. export function npmCommand({ env = process.env } = {}) { const names = process.platform === 'win32' ? ['npm.cmd', 'npm'] : ['npm'] const candidates = [env.npm_execpath] for (const dir of (env.PATH || env.Path || '').split(delimiter)) { for (const name of names) { const bin = join(dir, name) if (!existsSync(bin)) continue const prefix = dirname(realpathSync(bin)) const lib = join(prefix, '../lib') candidates.push( join(prefix, 'node_modules/npm/bin/npm-cli.js'), join(prefix, '../lib/node_modules/npm/bin/npm-cli.js'), // Nix packages use lib/npm or a versioned lib/npm* directory. // Discover the installed JS entrypoint, never parse/execute its shell wrapper. ...(existsSync(lib) ? readdirSync(lib).filter(name => name.startsWith('npm')).sort() .map(name => join(lib, name, 'bin/npm-cli.js')) : []), realpathSync(bin), ) } } const cli = candidates.find(path => path && path.endsWith('.js') && existsSync(path)) if (!cli) throw new Error('npm CLI is required on PATH') return [process.execPath, cli] } // npm's own manifest states its version — no child spawn. A node-under-node spawn // hits Windows Job-Object EBUSY (#123933), and the probe runs before the reuse // short-circuit: a read-only version read must not abort an otherwise complete run. // npm_execpath may point through a symlink; the manifest sits beside the resolved // CLI, never beside the link. Layouts without a readable manifest return undefined // so the caller falls back to the child probe, matching the pre-manifest behavior. function npmManifestVersion(cli) { try { return JSON.parse(readFileSync(join(dirname(realpathSync(cli)), '..', 'package.json'), 'utf8')).version } catch { return undefined } } function completedInstallMatches({ source, receipt, hiddenLock, key, nativeKey }) { if (!existsSync(receipt) || !existsSync(hiddenLock)) return false const installed = readFileSync(hiddenLock) const expected = `${key}\n${createHash('sha256').update(installed).digest('hex')}\n` if (nativeKey !== undefined) { const nativeReceipt = `${receipt}.native-toolchain` if (!existsSync(nativeReceipt) || readFileSync(nativeReceipt, 'utf8') !== `${expected}${nativeKey}\n`) return false } return readFileSync(receipt, 'utf8') === expected && Object.keys(JSON.parse(installed).packages) .every(path => existsSync(join(source, path))) } // An interrupted Windows update can leave a nested .bin that npm ci's own rmdir // cannot clear (ENOTEMPTY, #75584); only deleting node_modules recovers it. npm's // debug log names the code while stdio stays on the terminal, so give each run // its own logs dir and retry once only on that code. Other failures keep the tree. function runNpmCi(node, npm, args, { source, env }) { const logsDir = mkdtempSync(join(tmpdir(), 'hermes-npm-logs-')) // Builders set CI=1, which turns npm's spinner off. Ask for it back: npm // still shows it only on a terminal. Kept out of `args`, which keys the receipt. const run = () => execFileSync(node, [npm, ...args, '--progress=true', `--logs-dir=${logsDir}`], { cwd: source, env, stdio: 'inherit' }) try { run() } catch (error) { const logged = readdirSync(logsDir).some(name => readFileSync(join(logsDir, name), 'utf8').includes('ENOTEMPTY')) if (!logged) throw error console.log('node-deps: npm ci hit ENOTEMPTY; removing node_modules and retrying once...') rmSync(join(source, 'node_modules'), { recursive: true, force: true, maxRetries: 3 }) run() } finally { rmSync(logsDir, { recursive: true, force: true }) } } /** Install the full requested workspace union in one strict, locked operation. */ export function prepareNodeDependencies({ source, workspaces, env = process.env, reuse = false, install = true, nativeToolchain }) { source = resolve(source) if (!Array.isArray(workspaces) || workspaces.length === 0) { throw new Error('Select at least one workspace; implicit all-workspace installation is not allowed') } const manifest = JSON.parse(readFileSync(join(source, 'package.json'), 'utf8')) const lock = JSON.parse(readFileSync(join(source, 'package-lock.json'), 'utf8')) const workspacePaths = Object.values(lock.packages || {}) .filter(entry => entry.link) .map(entry => entry.resolved) const selected = [...new Set(workspaces.map(workspace => { const path = workspacePaths.find(path => path === workspace || lock.packages[path]?.name === workspace) if (!path || !existsSync(join(source, path, 'package.json'))) { throw new Error(`Unknown or missing locked workspace: ${workspace}`) } return path }))].sort() const [node, npm] = npmCommand({ env }) const npmVersion = npmManifestVersion(npm) ?? execFileSync( node, [npm, '--version'], { cwd: source, env, encoding: 'utf8' }).trim() const { satisfies } = createRequire(npm)('semver') for (const [name, version] of [['node', process.versions.node], ['npm', npmVersion]]) { const range = manifest.engines?.[name] if (range && !satisfies(version, range)) throw new Error(`${name} ${version} violates ${range}`) } const args = ['ci', '--no-audit', '--no-fund', '--engine-strict', '--include=dev', '--include=optional', '--include-workspace-root=true', ...selected.flatMap(workspace => ['--workspace', workspace]), ] // This receipt certifies dependency preparation, never compiled product freshness. // Keep it inside the cached tree so a clean npm ci also removes the receipt. const receipt = join(source, 'node_modules/.hermes-node-deps') // Ordinary product builders consume the baseline receipt; preparation also // binds lifecycle outputs to its compiler/SDK identity. On a mismatch npm ci // removes arbitrary package lifecycle outputs, not just known node-pty paths. const nativeReceipt = `${receipt}.native-toolchain` const nativeKey = nativeToolchain === undefined ? undefined : JSON.stringify(nativeToolchain) const hiddenLock = join(source, 'node_modules/.package-lock.json') const inputs = createHash('sha256').update(JSON.stringify({ node: process.versions.node, npm: npmVersion, platform: process.platform, arch: process.arch, args, // npm names are case-insensitive; Windows Python uppercases inherited keys. config: Object.entries(env).filter(([key]) => /^npm_config_/i.test(key) && !/^npm_config_(cache|offline|prefer_offline)$/i.test(key)) .map(([key, value]) => [key.toLowerCase(), value]).sort(), })) const files = ['package-lock.json', '.npmrc', ...Object.keys(lock.packages) .filter(path => !path.split('/').includes('node_modules')) .map(path => join(path, 'package.json'))].sort() for (const file of files) { inputs.update(file).update('\0').update(existsSync(join(source, file)) ? readFileSync(join(source, file)) : '').update('\0') } const key = inputs.digest('hex') if (reuse && completedInstallMatches({ source, receipt, hiddenLock, key, nativeKey })) { console.log(`node-deps: reusing completed install (${selected.join(', ')})`) return { source, workspaces: selected } } if (!install) throw new Error('Workspace dependencies are stale or missing and lazy installs are disabled; run an explicit build/update') // npm can fail during validation before deleting node_modules. Invalidate first. rmSync(receipt, { force: true }) rmSync(nativeReceipt, { force: true }) console.log(`node-deps: installing workspace dependencies with npm ci (${selected.join(', ')})...`) runNpmCi(node, npm, args, { source, env }) if (reuse) { const completed = `${key}\n${createHash('sha256').update(readFileSync(hiddenLock)).digest('hex')}\n` writeFileSync(receipt, completed) if (nativeKey !== undefined) writeFileSync(nativeReceipt, `${completed}${nativeKey}\n`) } return { source, workspaces: selected } } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { // The Python bundle driver uses this same resolver, not a second layout probe. if (process.argv[2] === '--npm') { const [node, ...command] = npmCommand() const child = spawnSync(node, [...command, ...process.argv.slice(3)], { stdio: 'inherit' }) if (child.error) throw child.error process.exit(child.status ?? 1) } const { values } = parseArgs({ options: { source: { type: 'string' }, workspace: { type: 'string', multiple: true }, reuse: { type: 'boolean', default: false }, 'no-install': { type: 'boolean', default: false }, 'native-toolchain': { type: 'string' }, } }) if (!values.source) throw new Error('--source is required') prepareNodeDependencies({ source: values.source, workspaces: values.workspace, reuse: values.reuse, install: !values['no-install'], nativeToolchain: values['native-toolchain'] }) }