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.
133 lines
7.2 KiB
JavaScript
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'] })
|
|
}
|