Files
hermes-agent/scripts/build/node-deps.mjs
liuhao1024 646c3c8ad5 fix(update): resolve npm's manifest through symlinks and fall back to the probe
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.
2026-09-26 23:48:52 -04:00

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'] })
}