blob: 3f494050db863e98aa50ef8f4d6453edd4f80e52 [file] [log] [blame]
const fs = require('fs')
const exists = require('path-exists')
const os = require('os')
const path = require('path')
const { safeReadPackageJsonFromDir } = require('@pnpm/read-package-json')
const { runLifecycleHook } = require('@pnpm/lifecycle')
async function mkdirp(p) {
if (p && !fs.existsSync(p)) {
await mkdirp(path.dirname(p))
await fs.promises.mkdir(p)
}
}
function normalizeBinPath(p) {
let result = p.replace(/\\/g, '/')
if (result.startsWith('./')) {
result = result.substring(2)
}
return result
}
async function makeBins(nodeModulesPath, scope, segmentsUp) {
const packages = await fs.promises.readdir(
path.join(nodeModulesPath, scope)
)
for (const _package of packages) {
if (!scope && _package.startsWith('@')) {
await makeBins(nodeModulesPath, _package, segmentsUp)
continue
}
const packageName = path.join(scope, _package)
const packageJsonPath = path.join(
nodeModulesPath,
packageName,
'package.json'
)
if (fs.existsSync(packageJsonPath)) {
let packageJsonStr = await fs.promises.readFile(packageJsonPath)
let packageJson
try {
packageJson = JSON.parse(packageJsonStr)
} catch (e) {
// Catch and throw a more detailed error message.
throw new Error(
`Error parsing ${packageName}/package.json: ${e}\n\n""""\n${packageJsonStr}\n""""`
)
}
// https://docs.npmjs.com/cli/v7/configuring-npm/package-json#bin
if (packageJson.bin) {
await mkdirp(path.join(nodeModulesPath, '.bin'))
let bin = packageJson.bin
if (typeof bin == 'string') {
bin = { [_package]: bin }
}
for (const binName of Object.keys(bin)) {
if (binName.includes('/') || binName.includes('\\')) {
// multi-segment bin names are not supported; pnpm itself
// also does not make .bin entries in this case as of pnpm v8.3.1
continue
}
const binPath = normalizeBinPath(bin[binName])
let binEntryPath = path.join(
nodeModulesPath,
'.bin',
binName
)
let binExec
if (isWindows()) {
binEntryPath += '.cmd'
binExec = `node "${path.join(
...segmentsUp,
packageName,
binPath
)}" "%*"`
} else {
binExec = `#!/usr/bin/env bash\nexec node "${path.join(
...segmentsUp,
packageName,
binPath
)}" "$@"`
}
await fs.promises.writeFile(binEntryPath, binExec)
await fs.promises.chmod(binEntryPath, '755') // executable
}
}
}
}
}
// Returns true if the package uses node-gyp.
async function useNodeGyp(root) {
return await exists(path.join(root, 'binding.gyp'))
}
// Helper which is exported from @pnpm/lifecycle:
// https://github.com/pnpm/pnpm/blob/bc18d33fe00d9ed43f1562d4cc6d37f49d9c2c38/exec/lifecycle/src/index.ts#L52
async function checkBindingGyp(root, scripts) {
if (await useNodeGyp(root)) {
scripts['install'] = 'node-gyp rebuild'
}
}
// Like runPostinstallHooks from @pnpm/lifecycle at
// https://github.com/pnpm/pnpm/blob/bc18d33fe00d9ed43f1562d4cc6d37f49d9c2c38/exec/lifecycle/src/index.ts#L20
// but also runs a customizable list of lifecycle hooks.
async function runLifecycleHooks(opts, hooks) {
const pkg = await safeReadPackageJsonFromDir(opts.pkgRoot)
if (pkg == null) {
return
}
if (pkg.scripts == null) {
pkg.scripts = {}
}
const runInstallScripts =
hooks.includes('preinstall') ||
hooks.includes('install') ||
hooks.includes('postinstall')
if (runInstallScripts && !pkg.scripts.install) {
await checkBindingGyp(opts.pkgRoot, pkg.scripts)
}
for (const hook of hooks) {
if (pkg.scripts[hook]) {
await runLifecycleHook(hook, pkg, opts)
}
}
}
// Remove the indeterministic Makefile from the resulting directory.
// Can be removed when https://github.com/nodejs/gyp-next/pull/293
// has sufficiently propagated and the vast majority of downloads on
// npm are for packages that are >=node-gyp@11.3.0, where the fix is
// first included
//
// https://www.npmjs.com/package/node-gyp?activeTab=versions
async function cleanupNodeGypIndeterminism(root) {
if (await useNodeGyp(root)) {
try {
await fs.promises.rm(path.join(root,'build', 'Makefile'))
} catch (e) {
// Best effort, ignore errors.
}
}
}
function isWindows() {
return os.platform() === 'win32'
}
async function main(args) {
if (args.length < 3) {
console.error(
'Usage: node lifecycle-hooks.js [packageName] [packageDir] [outputDir] [--arch=...]? [--platform=...]?'
)
process.exit(1)
}
const packageName = args[0]
const packageDir = args[1]
const outputDir = args[2]
let platform = null
let arch = null
let libc = null
// This is naive "parsing" of the argv, but allows to avoid bringing in additional dependencies:
for (let i = 3; i < args.length; ++i) {
let found = args[i].match(/--arch=(.*)/)
if (found) {
arch = found[1]
}
found = args[i].match(/--platform=(.*)/)
if (found) {
platform = found[1]
}
found = args[i].match(/--libc=(.*)/)
if (found) {
libc = found[1]
}
}
await copyPackageContents(packageDir, outputDir)
// Resolve the path to the node_modules folder for this package in the symlinked node_modules
// tree. Output path is of the format,
// .../node_modules/.aspect_rules_js/package@version/node_modules/package
// .../node_modules/.aspect_rules_js/@scope+package@version/node_modules/@scope/package
// Path to node_modules is one or two segments up from the output path depending on the packageName
const segmentsUp = Array(packageName.split('/').length).fill('..')
const nodeModulesPath = path.resolve(path.join(outputDir, ...segmentsUp))
// Create .bin entry point files for all packages in node_modules
await makeBins(nodeModulesPath, '', segmentsUp)
// export interface RunLifecycleHookOptions {
// args?: string[];
// depPath: string;
// extraBinPaths?: string[];
// extraEnv?: Record<string, string>;
// initCwd?: string;
// optional?: boolean;
// pkgRoot: string;
// rawConfig: object;
// rootModulesDir: string;
// scriptShell?: string;
// silent?: boolean;
// scriptsPrependNodePath?: boolean | 'warn-only';
// shellEmulator?: boolean;
// stdio?: string;
// unsafePerm: boolean;
// }
// We need to explicitly pass `npm_config_` prefixed env-variables as configuration to the lifecycle hook (or gyp).
// 1. rules_js allow to provide per action_type environment using `lifecycle_hooks_envs`.
// 2. One of the important use-cases is to able provide mirror where prebuild binaries are stored:
// (see: https://github.com/mapbox/node-pre-gyp#download-binary-files-from-a-mirror
// by {module_name}_binary_host_mirror)
// 3. Such flags are taken (by gyp) from environment variables:
// https://github.com/mapbox/node-pre-gyp/blob/a74f5e367c0d71033620aa0112e7baf7f3515b9d/lib/util/versioning.js#L316
// 4. Unfortunetely pnpm/lifecycle drops all npm_ prefixed env-variables prior to calling lifecycle hook:
// https://github.com/pnpm/npm-lifecycle/blob/99ac0429025bdf1303879723d3fbd57c585ae8a1/index.js#L351
// and later recreates it based on explicitly given config:
// https://github.com/pnpm/npm-lifecycle/blob/99ac0429025bdf1303879723d3fbd57c585ae8a1/index.js#L408
// 5. So we need to perform reversed process: generate rawConfig based on env-variables to preserve them.
let inherited_env = {}
const npm_config_prefix = 'npm_config_'
const config_regexp = new RegExp('^' + npm_config_prefix, 'i')
for (let e in process.env) {
if (e.match(config_regexp)) {
inherited_env[e.substring(npm_config_prefix.length)] =
process.env[e]
}
}
const opts = {
pkgRoot: path.resolve(outputDir),
// rawConfig is passed as `config {...}`
// in @pnpm/lifecycle: https://github.com/pnpm/pnpm/blob/0da8703063797f59b01523f4283b9bd27123d063/exec/lifecycle/src/runLifecycleHook.ts#L65
// echo property within `config {...}` is exposed in env_variable 'npm_config_'
// by @pnpm/npm-lifecycle: https://github.com/pnpm/npm-lifecycle/blob/99ac0429025bdf1303879723d3fbd57c585ae8a1/index.js#L434
// The lifecycle hooks can interpret the npm_config_arch and npm_config_platform env variables:
// e.g. sharp: https://github.com/lovell/sharp/blob/9c217ab580123ee14ad65d5043d74d8ea7c245e5/lib/platform.js#L12
// The npm_config_arch & npm_config_platform conversion is obeyed by tools like prebuild-install:
// https://yarnpkg.com/package?name=prebuild-install
// ("... you can set environment variables npm_config_build_from_source=true, npm_config_platform"
// npm_config_arch, npm_config_target npm_config_runtime and npm_config_libc").
// or node-pre-gyp:
// https://github.com/mapbox/node-pre-gyp/blob/a74f5e367c0d71033620aa0112e7baf7f3515b9d/lib/node-pre-gyp.js#L188
//
rawConfig: Object.assign(
{},
{
stdio: 'inherit',
platform: platform,
target_platform: platform, // Interpreted by https://github.com/mapbox/node-pre-gyp
arch: arch,
target_arch: arch, // node-pre-gyp
libc: libc,
target_libc: libc, // node-pre-gyp
},
inherited_env
),
silent: false,
stdio: 'inherit',
rootModulesDir: nodeModulesPath,
unsafePerm: true, // Don't run under a specific user/group
}
const rulesJsJson = JSON.parse(
await fs.promises.readFile(
path.join(packageDir, 'aspect_rules_js_metadata.json')
)
)
if (rulesJsJson.lifecycle_hooks) {
// Runs configured lifecycle hooks
await runLifecycleHooks(opts, rulesJsJson.lifecycle_hooks.split(','))
}
if (rulesJsJson.scripts?.custom_postinstall) {
// Run user specified custom postinstall hook
await runLifecycleHook('custom_postinstall', rulesJsJson, opts)
}
await cleanupNodeGypIndeterminism(opts.pkgRoot)
}
// Copy contents of a package dir to a destination dir (without copying the package dir itself)
async function copyPackageContents(packageDir, destDir) {
const contents = await fs.promises.readdir(packageDir)
await Promise.all(
contents.map((file) =>
copyRecursive(path.join(packageDir, file), path.join(destDir, file))
)
)
}
// Recursively copy files and folders
async function copyRecursive(src, dest) {
const stats = await fs.promises.stat(src)
if (stats.isDirectory()) {
await mkdirp(dest)
const contents = await fs.promises.readdir(src)
await Promise.all(
contents.map((file) =>
copyRecursive(path.join(src, file), path.join(dest, file))
)
)
} else {
await fs.promises.copyFile(src, dest)
}
}
;(async () => {
try {
await main(process.argv.slice(2))
} catch (e) {
// Note: use .log rather than .error. The former is deferred and the latter is immediate.
// The error is harder to spot and parse when it appears in the middle of the other logs.
if (e.code === 'ELIFECYCLE' && !!e.pkgid && !!e.stage && !!e.script) {
console.log(
'==============================================================='
)
console.log(
`Failure while running lifecycle hook for package '${e.pkgid}':\n`
)
console.log(` Script: '${e.stage}'`)
console.log(` Command: \`${e.script}\``)
console.log(`\nStack trace:\n`)
// First line of error is always the message, which is redundant with the above logging.
console.log(e.stack.replace(/^.*?\n/, ''))
console.log(
'==============================================================='
)
} else {
console.log(e)
}
process.exit(1)
}
})()