UNPKG

postinstall-build

Version:

Helper for conditionally building your npm package on postinstall in order to support git installs.

394 lines (357 loc) 14.4 kB
var fs = require('fs') var path = require('path') var spawn = require('child_process').spawn // Use `spawn` to make a better `exec` without stdio buffering. function exec (command, options, callback) { // Copied from npm's lib/utils/lifecycle.js var sh = 'sh' var shFlag = '-c' // Doesn't need to support all options, we only ever pass these two. var shOpts = { env: options.env, stdio: options.stdio || 'inherit' } // Copied from npm's lib/utils/lifecycle.js if (process.platform === 'win32') { sh = process.env.comspec || 'cmd' shFlag = '/d /s /c' shOpts.windowsVerbatimArguments = true // Even though `command` may properly quote arguments that contain spaces, // cmd.exe might strip them. Wrap the entire command in quotes, and the /s // flag will remove them and leave the rest. if (options.quote) { command = '"' + command + '"' } } var proc = spawn(sh, [shFlag, command], shOpts) var done = false function procKill () { proc.kill() } function procDone (err) { if (!done) { done = true process.removeListener('SIGTERM', procKill) callback(err) } } process.once('SIGTERM', procKill) proc.on('error', procDone) proc.on('close', function (code, signal) { var err if (code) { // Behave like `exec` and construct an Error object. var cmdStr = [sh, shFlag, command].join(' ') err = new Error('Command failed: ' + cmdStr + '\n') err.killed = signal != null err.code = code err.signal = signal err.cmd = cmdStr } procDone(err) }) return proc } // If we call `process.exit` immediately after logging to the console, then // any process that is reading our output through a pipe would potentially get // truncated output, because the pipe would be closed before it could be read. // See: https://github.com/nodejs/node-v0.x-archive/issues/3737 function safeExit (code) { process.on('exit', function () { process.exit(code) }) } function postinstallBuild () { var CWD = process.cwd() var POSTINSTALL_BUILD_CWD = process.env.POSTINSTALL_BUILD_CWD || '' // If we didn't have this check, then we'd be stuck in an infinite // `postinstall` loop, since we run `npm install --only=dev` below, // triggering another `postinstall`. We can't use `--ignore-scripts` because // that ignores scripts in all the modules that get installed, too, which // would break stuff. So instead, we set an environment variable, // `POSTINSTALL_BUILD_CWD`, that keeps track of what we're installing. It's // more than just a yes/no flag because the dev dependencies we're installing // might use `postinstall-build` too, and we don't want the flag to prevent // them from running. if (POSTINSTALL_BUILD_CWD === CWD) { return } var buildArtifact var buildCommand var flags = { quote: false, verbosity: 1 } for (var i = 2; i < process.argv.length; i++) { var arg = process.argv[i] if (arg === '--silent') { flags.verbosity = 0 } else if (arg === '--verbose') { flags.verbosity = 2 } else if (arg === '--only-as-dependency') { flags.onlyAsDependency = true } else if (arg === '--script') { // Consume the next argument. flags.script = process.argv[++i] } else if (arg.indexOf('--script=') === 0) { flags.script = arg.slice(9) } else if (buildArtifact == null) { buildArtifact = arg } else if (buildCommand == null) { buildCommand = arg } } // Some packages (e.g. `ember-cli`) install their own version of npm, which // can shadow the expected version and break the package trying to use // `postinstall-build`. var npm = 'npm' var execPath = process.env.npm_execpath var userAgent = process.env.npm_config_user_agent || '' var npmVersion = (userAgent.match(/^npm\/([^\s]+)/) || [])[1] // If the user agent doesn't start with `npm/`, just fall back to running // `npm` since alternative agents (e.g. Yarn) may not support the same // commands (like `prune`). if (execPath && npmVersion) { npm = '"' + process.argv[0] + '" "' + execPath + '"' } if (flags.script != null) { // Hopefully people aren't putting special characters that need escaping // in their script names. If they are, they can take care of escaping it // themselves when they supply `--script`. flags.quote = true buildCommand = npm + ' run ' + flags.script } else if (buildCommand == null) { // If no command or script was given, run the 'build' script. flags.quote = true buildCommand = npm + ' run build' } var _write = function (verbosity, message) { if (flags.verbosity >= verbosity) { process.stderr.write(message + '\n') } } var log = { info: _write.bind(this, 2), warn: _write.bind(this, 1), error: _write.bind(this, 0) } var handleError = function (err) { log.error('postinstall-build:\n ' + err + '\n') safeExit(1) } if (buildArtifact == null) { return handleError('A build artifact must be supplied.') } else if (/^(npm|yarn) /.test(buildArtifact)) { log.warn( "postinstall-build:\n '" + buildArtifact + "' is being passed as the " + 'build artifact, not the build command.\n If your build artifact is ' + "a file or folder named '" + buildArtifact + "', you may ignore\n " + 'this warning. Otherwise, you probably meant to pass this as the build ' + 'command\n instead, and must supply a build artifact.\n' ) } // After building, we almost always want to prune back to the production // dependencies, so that the transient development dependencies aren't // left behind. The only reason we wouldn't want to prune is if we're the // top-level package being `npm install`ed with no arguments for // development or testing purposes. If we're the `postinstall` script of a // dependency, we should always prune. Unfortunately, npm doesn't set // any helpful environment variables to indicate whether we're being // installed as a dependency or not. The best we can do is check whether // the parent directory is `node_modules` but the package can be nested // in multiple parent directories so we split apart the name of the module. var parentDirs = CWD.split(path.sep) var isDependency = parentDirs.indexOf('node_modules') !== -1 if (flags.onlyAsDependency && !isDependency) { log.info( 'postinstall-build:\n Not installed as a dependency, skipping build.\n' ) return } // If we're the top-level package being `npm install`ed with no arguments, // we still might want to prune if certain flags indicate that only production // dependencies were requested. var isProduction = (process.env.npm_config_production === 'true' && process.env.npm_config_only !== 'development' && process.env.npm_config_only !== 'dev') var isOnlyProduction = (process.env.npm_config_only === 'production' || process.env.npm_config_only === 'prod') var shouldPrune = isDependency || isProduction || isOnlyProduction var getInstallArgs = function () { var packageFile = path.join(CWD, 'package.json') var packageInfo = require(packageFile) var devDependencies = packageInfo.devDependencies || {} var buildDependencies = packageInfo.buildDependencies var installArgs = ' --only=dev' if (buildDependencies && Array.isArray(buildDependencies)) { installArgs = buildDependencies.map(function (name) { var spec = devDependencies[name] // If a name specified in `buildDependencies` doesn't actually exist in // `devDependencies`, it may be a global, peer, or production dependency. // Assume it is already available. If a user puts something in // `buildDependencies` and expects it to be installed by // `postinstall-build`, then it must also be in `devDependencies`. if (typeof spec === 'undefined') { log.warn( "postinstall-build:\n The dependency '" + name + "' appears in " + 'buildDependencies but not devDependencies.\n Instead of ' + 'installing it, postinstall-build will assume it is already ' + 'available.\n' ) return '' } else if (spec) { // This previously used `npm-package-arg` to determine which specs are // appropriate to use with the @ syntax and which aren't, but the latest // version no longer works on Node <4, and older versions don't have the // properties we want. So instead of trying to parse `spec`, just assume // npm is okay with always using @ syntax. return ' "' + name + '@' + spec + '"' } else { // We shouldn't really expect to find a null or empty spec, but it's // technically possible, and npm will interpret it as `latest`. return ' "' + name + '"' } }).join('') } return installArgs } var warnUserAgent = function () { if (npmVersion && !/^(3\.|4\.0\.)/.test(npmVersion)) { log.warn( 'postinstall-build:\n This version of npm (' + npmVersion + ') may ' + 'not be compatible with postinstall-build! There\n are outstanding ' + 'bugs in certain versions of npm that prevent it from working with\n ' + 'postinstall-build. See https://github.com/exogen/postinstall-build#bugs-in-npm\n ' + 'for more information.\n' ) } } var checkBuildArtifact = function (callback) { fs.stat(buildArtifact, function (err, stats) { if (err || !(stats.isFile() || stats.isDirectory())) { log.info( 'postinstall-build:\n Build artifact not found, proceeding to ' + 'build.\n' ) callback(null, true) } else { log.info( 'postinstall-build:\n Build artifact found, skipping build.\n' ) callback(null, false) } }) } var checkUserAgent = function (callback) { // If we know an incompatible version of npm triggered `postinstall-build`, // print a warning. Note that if the original user agent is NOT npm, then we // don't know what version `postinstall-build` will actually run, since the // `$npm_config_user_agent` string didn't tell us, so run `npm --version` to // find out. if (npmVersion) { warnUserAgent() return callback(null, npmVersion) } var output = '' var command = npm + ' --version' log.info('postinstall-build:\n ' + command + '\n') var proc = exec(command, { stdio: 'pipe' }, function (err) { npmVersion = (output.match(/^(\d[^\s]*)/) || [])[1] warnUserAgent() callback(err, npmVersion) }) proc.stdout.setEncoding('utf8') proc.stdout.on('data', function (chunk) { output += chunk }) } var installBuildDependencies = function (execOpts, callback) { // We only need to install dependencies if `shouldPrune` is true. Why? // Because this flag detects whether `devDependencies` were already // installed in order to determine whether they need to be pruned at the // end or not. And if we already have `devDependencies` then installing // here isn't going to get us anything. if (shouldPrune) { // If `installArgs` is empty, the build doesn't depend on installing any // extra dependencies. var installArgs = getInstallArgs() if (installArgs) { var command = npm + ' install' + installArgs log.info('postinstall-build:\n ' + command + '\n') return exec(command, execOpts, callback) } log.info( 'postinstall-build:\n No install arguments, skipping install.\n' ) } log.info( 'postinstall-build:\n Already have devDependencies, skipping install.\n' ) callback(null) } var runBuildCommand = function (execOpts, callback) { // Only quote the build command if necessary, otherwise run it exactly // as npm would. execOpts.quote = flags.quote log.info('postinstall-build:\n ' + buildCommand + '\n') exec(buildCommand, execOpts, callback) } var cleanUp = function (execOpts, callback) { if (shouldPrune) { execOpts.quote = true var command = npm + ' prune --production' log.info('postinstall-build:\n ' + command + '\n') return exec(command, execOpts, callback) } log.info('postinstall-build:\n Skipping prune.\n') callback(null) } checkBuildArtifact(function (err, shouldBuild) { if (err) { return handleError(err) } if (shouldBuild) { checkUserAgent(function (err) { // If an error occurred, the only consequence is that we don't know // which version of npm is going to be run, and can't issue a warning. // If there's an actual problem running npm we'll find out soon enough. if (err) { log.warn('postinstall-build:\n ' + err + '\n') } // If `npm install` ends up being run by `installBuildDependencies`, this // script will be run again. Set an environment variable to tell it to // skip the check. Really we just want the spawned child's `env` to be // modified, but it's easier just modify and pass along our entire // `process.env`. process.env.POSTINSTALL_BUILD_CWD = CWD var execOpts = { env: process.env, quote: true } if (flags.verbosity === 0) { execOpts.stdio = 'ignore' } installBuildDependencies(execOpts, function (err) { if (err) { return handleError(err) } // Don't need this flag anymore as `postinstall` was already run. // Change it back so the environment is minimally changed for the // remaining commands. process.env.POSTINSTALL_BUILD_CWD = POSTINSTALL_BUILD_CWD runBuildCommand(execOpts, function (err) { if (err) { return handleError(err) } cleanUp(execOpts, function (err) { if (err) { return handleError(err) } }) }) }) }) } }) } module.exports = postinstallBuild