npm_execpath can point through a symlink, and the manifest sits beside the resolved CLI, never beside the link: resolve the realpath before looking for package.json. Layouts without a readable manifest now fall back to the pre-fix child probe instead of aborting with ENOENT. Move the regression test to tests-js/node-deps.test.mjs (the module's own suite) and cover the symlinked-execpath and fallback lanes there.
173 lines
9.2 KiB
JavaScript
173 lines
9.2 KiB
JavaScript
#!/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)) : '<missing>').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'] })
|
|
}
|