Files
hermes-agent/scripts/build/node-deps.mjs
ethernet 2efa4ff94f refactor(desktop): prepare dependencies before saving build caches
Dependency acquisition during packaging left native wheels and packager
inputs outside the pre-build cache save. Compose PM and existing providers
into a preparation phase, then require builds to consume admitted inputs.

Share native preparation with PM Bundle. Keep path-bound environments and
signing outputs separate from reusable caches. Use read-only cache tokens
for commit builds and preserve the one-command local build path.

Verify pinned tools through PM, probe PTYs under the prepared Electron,
and supply dmgbuild through a build-only PM package. Resolve bundled tool
stores from their payload manifest so relocation preserves discovery.

Validation: focused Python and JS tests, checkJs, Ruff, Windows checks,
anti-slop, cache relocation, and network-denied Linux AppImage builds.
Relocated runtime smoke passed with NixOS host libraries supplied.
Native Windows/macOS signing and live GitHub cache behavior remain untested.
2026-09-13 14:28:31 -04:00

133 lines
7.2 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync, spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
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]
}
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)))
}
/** 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 = 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 })
execFileSync(node, [npm, ...args], { cwd: source, env, stdio: 'inherit' })
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'] })
}