wireit
Version:
Upgrade your npm scripts to make them smarter and more efficient
424 lines • 17.4 kB
JavaScript
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as os from 'os';
import * as pathlib from 'path';
import { SimpleLogger } from './logging/simple-logger.js';
import { Console } from './logging/logger.js';
import { MetricsLogger } from './logging/metrics-logger.js';
import { QuietCiLogger, QuietLogger } from './logging/quiet-logger.js';
import * as fs from './util/fs.js';
import { unreachable } from './util/unreachable.js';
export const packageDir = await (async () => {
// Recent versions of npm, and node --run, set environment variables to tell
// us the current package.json.
const packageJsonPath = process.env.npm_package_json ?? process.env.NODE_RUN_PACKAGE_JSON_PATH;
if (packageJsonPath) {
return pathlib.dirname(packageJsonPath);
}
// Older versions of npm, as well as yarn and pnpm, don't set this variable,
// so we have to find the nearest package.json by walking up the filesystem.
let maybePackageDir = process.cwd();
while (true) {
try {
await fs.stat(pathlib.join(maybePackageDir, 'package.json'));
return maybePackageDir;
}
catch (error) {
const { code } = error;
if (code !== 'ENOENT') {
throw code;
}
}
const parent = pathlib.dirname(maybePackageDir);
if (parent === maybePackageDir) {
// Reached the root of the filesystem, no package.json.
return undefined;
}
maybePackageDir = parent;
}
})();
export const getOptions = async () => {
// This environment variable is set by npm, yarn, and pnpm, and tells us which
// script is running.
const scriptName = process.env.npm_lifecycle_event ?? process.env['NODE_RUN_SCRIPT_NAME'];
// We need to handle "npx wireit" as a special case, because it sets
// "npm_lifecycle_event" to "npx". The "npm_execpath" will be "npx-cli.js",
// though, so we use that combination to detect this special case.
const launchedWithNpx = scriptName === 'npx' && process.env.npm_command === 'exec';
if (!packageDir || !scriptName || launchedWithNpx) {
const detail = [];
if (!packageDir) {
detail.push('Wireit could not find a package.json.');
}
if (!scriptName) {
detail.push('Wireit could not identify the script to run.');
}
if (launchedWithNpx) {
detail.push('Launching Wireit with npx is not supported.');
}
return {
ok: false,
error: {
type: 'failure',
reason: 'launched-incorrectly',
script: { packageDir: packageDir ?? process.cwd() },
detail: detail.join(' '),
},
};
}
const script = { packageDir, name: scriptName };
const numWorkersResult = (() => {
const workerString = process.env['WIREIT_PARALLEL'] ?? '';
// Many scripts will be IO blocked rather than CPU blocked, so running
// multiple scripts per CPU will help keep things moving.
const defaultValue = os.cpus().length * 2;
if (workerString.match(/^infinity$/i)) {
return { ok: true, value: Infinity };
}
if (workerString == null || workerString === '') {
return { ok: true, value: defaultValue };
}
const parsedInt = parseInt(workerString, 10);
if (Number.isNaN(parsedInt) || parsedInt <= 0) {
return {
ok: false,
error: {
reason: 'invalid-usage',
message: `Expected the WIREIT_PARALLEL env variable to be ` +
`a positive integer, got ${JSON.stringify(workerString)}`,
script,
type: 'failure',
},
};
}
return { ok: true, value: parsedInt };
})();
if (!numWorkersResult.ok) {
return numWorkersResult;
}
const cacheResult = (() => {
const str = process.env['WIREIT_CACHE'];
if (str === undefined) {
// The CI variable is a convention that is automatically set by GitHub
// Actions [0], Travis [1], and other CI (continuous integration)
// providers.
//
// [0] https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables
// [1] https://docs.travis-ci.com/user/environment-variables/#default-environment-variables
//
// If we're on CI, we don't want "local" caching, because anything we
// store locally will be lost when the VM shuts down.
//
// We also don't want "github", because (even if we also detected that
// we're specifically on GitHub) we should be cautious about using up
// storage quota, and instead require opt-in via WIREIT_CACHE=github.
const ci = process.env['CI'] === 'true';
return { ok: true, value: ci ? 'none' : 'local' };
}
if (str === 'local' || str === 'github' || str === 'none') {
return { ok: true, value: str };
}
return {
ok: false,
error: {
reason: 'invalid-usage',
message: `Expected the WIREIT_CACHE env variable to be ` +
`"local", "github", or "none", got ${JSON.stringify(str)}`,
script,
type: 'failure',
},
};
})();
if (!cacheResult.ok) {
return cacheResult;
}
const failureModeResult = (() => {
const str = process.env['WIREIT_FAILURES'];
if (!str) {
return { ok: true, value: 'no-new' };
}
if (str === 'no-new' || str === 'continue' || str === 'kill') {
return { ok: true, value: str };
}
return {
ok: false,
error: {
reason: 'invalid-usage',
message: `Expected the WIREIT_FAILURES env variable to be ` +
`"no-new", "continue", or "kill", got ${JSON.stringify(str)}`,
script,
type: 'failure',
},
};
})();
if (!failureModeResult.ok) {
return failureModeResult;
}
const agent = getNpmUserAgent();
const console = new Console(process.stdout, process.stderr);
const packageRoot = packageDir ?? process.cwd();
const loggerResult = (() => {
const str = process.env['WIREIT_LOGGER'];
if (!str) {
if (process.env.CI || !process.stdout.isTTY) {
return { ok: true, value: new QuietCiLogger(packageRoot, console) };
}
return { ok: true, value: new QuietLogger(packageRoot, console) };
}
if (str === 'quiet') {
return { ok: true, value: new QuietLogger(packageRoot, console) };
}
if (str === 'quiet-ci') {
return { ok: true, value: new QuietCiLogger(packageRoot, console) };
}
if (str === 'simple') {
return { ok: true, value: new SimpleLogger(packageRoot, console) };
}
if (str === 'metrics') {
return { ok: true, value: new MetricsLogger(packageRoot, console) };
}
return {
ok: false,
error: {
reason: 'invalid-usage',
message: `Expected the WIREIT_LOGGER env variable to be ` +
`"quiet", "simple", or "metrics", got ${JSON.stringify(str)}`,
script,
type: 'failure',
},
};
})();
if (!loggerResult.ok) {
return loggerResult;
}
let logger = loggerResult.value;
if (process.env['WIREIT_DEBUG_LOG_FILE']) {
const [{ DebugLogger }, { CombinationLogger }] = await Promise.all([
import('./logging/debug-logger.js'),
import('./logging/combination-logger.js'),
]);
const debugLogStream = await fs.createWriteStream(process.env['WIREIT_DEBUG_LOG_FILE']);
const debugLogConsole = new Console(debugLogStream, debugLogStream, true);
logger = new CombinationLogger([logger, new DebugLogger(packageRoot, debugLogConsole)], console);
}
return {
ok: true,
value: {
script,
numWorkers: numWorkersResult.value,
cache: cacheResult.value,
failureMode: failureModeResult.value,
agent,
logger,
...getArgvOptions(script, agent),
},
};
};
/**
* Get options that are set as command-line arguments.
*/
function getArgvOptions(script, agent) {
// The way command-line arguments are handled in npm, yarn, and pnpm are all
// different. Our goal here is for `<agent> --watch -- --extra` to behave the
// same in all agents.
switch (agent) {
case 'npm': {
// npm 6.14.17
// - Arguments before the "--" in "--flag" style turn into "npm_config_<flag>"
// environment variables.
// - Arguments before the "--" in "plain" style go to argv.
// - Arguments after "--" go to argv.
// - The "npm_config_argv" environment variable contains full details as JSON.
//
// npm 8.11.0
// - Like npm 6, except there is no "npm_config_argv" environment variable.
return {
watch: process.env['npm_config_watch'] !== undefined
? readWatchConfigFromEnv()
: false,
extraArgs: process.argv.slice(2),
};
}
case 'nodeRun': {
return parseRemainingArgs(process.argv.slice(2));
}
case 'yarnClassic': {
// yarn 1.22.18
// - If there is no "--", all arguments go to argv.
// - If there is a "--", arguments in "--flag" style before it are eaten,
// arguments in "plain" style before it go to argv, and all arguments after
// it go to argv. Also a warning is emitted saying "In a future version, any
// explicit "--" will be forwarded as-is to the scripts."
// - The "npm_config_argv" environment variable contains full details as JSON,
// but unlike npm 6, it reflects the first script in a chain of scripts, instead
// of the last.
return parseRemainingArgs(findRemainingArgsFromNpmConfigArgv(script, agent));
}
case 'yarnBerry':
case 'pnpm': {
// yarn 3.2.1
// - Arguments before the script name are yarn arguments and error if unknown.
// - Arguments after the script name go to argv.
// pnpm 7.1.7
// - Arguments before the script name are pnpm arguments and error if unknown.
// - Arguments after the script name go to argv.
return parseRemainingArgs(process.argv.slice(2));
}
default: {
throw new Error(`Unhandled npm agent: ${unreachable(agent)}`);
}
}
}
/**
* Try to find the npm user agent being used. If we can't detect it, assume npm.
*/
function getNpmUserAgent() {
if (process.env['NODE_RUN_SCRIPT_NAME'] !== undefined) {
return 'nodeRun';
}
const userAgent = process.env['npm_config_user_agent'];
if (userAgent !== undefined) {
const match = userAgent.match(/^(npm|yarn|pnpm)\//);
if (match !== null) {
if (match[1] === 'yarn') {
return /^yarn\/[01]\./.test(userAgent) ? 'yarnClassic' : 'yarnBerry';
}
return match[1];
}
}
console.error('⚠️ Wireit could not identify the npm user agent, ' +
'assuming it behaves like npm. ' +
'Arguments may not be interpreted correctly.');
return 'npm';
}
/**
* Parses the `npm_config_argv` environment variable to find the command-line
* arguments that follow the main arguments. For example, given the result of
* `"yarn run build --watch -- --extra"`, return `["--watch", "--", "--extra"]`.
*/
function findRemainingArgsFromNpmConfigArgv(script, agent) {
const configArgvStr = process.env['npm_config_argv'];
if (!configArgvStr) {
console.error('⚠️ The "npm_config_argv" environment variable was not set. ' +
'Arguments may not be interpreted correctly.');
return [];
}
let configArgv;
try {
configArgv = JSON.parse(configArgvStr);
}
catch {
console.error('⚠️ Wireit could not parse the "npm_config_argv" ' +
'environment variable as JSON. ' +
'Arguments may not be interpreted correctly.');
return [];
}
// Since the "remain" and "cooked" arrays are unreliable in Yarn, the only
// reliable way to find the remaining args is to look for where the script
// name first appeared in the "original" array.
const scriptNameIdx = configArgv.original.indexOf(script.name);
if (scriptNameIdx === -1) {
// We're probably dealing with a recursive situation where one yarn 1.x
// script is calling another, such as `"watch": "yarn run build --watch"`.
//
// Usually we would handle this situation by looking at the original raw
// arguments provided by the "npm_config_argv" environment variable, but in
// the recursive case we can't do that, because due to
// https://github.com/yarnpkg/yarn/issues/8905 that variable reflects the
// first script in the chain, instead of the current script (unlike npm 6.x
// which does it correctly).
//
// So instead, we'll log a warning and at least handle the case where there
// is no "--" argument. If there is no "--" argument, then argv will contain
// all arguments. However, if there was a "--" argument, then all arguments
// before the "--" are lost, and argv only contains the arguments after the
// "--" (e.g. `yarn run build --watch` works fine, but `yarn run build
// --watch -- --extra` loses the `--watch`).
console.error('⚠️ Wireit could not find the script name in ' +
'the "npm_config_argv" environment variable. ' +
'Arguments may not be interpreted correctly. ' +
(agent === 'yarnClassic'
? `See https://github.com/yarnpkg/yarn/issues/8905, ` +
`and please consider upgrading to yarn 3.x or switching to npm.`
: ''));
return process.argv.slice(2);
}
return configArgv.original.slice(scriptNameIdx + 1);
}
/**
* Given a list of remaining command-line arguments (the arguments after e.g.
* "yarn run build"), parse out the arguments that are Wireit options, warn
* about any unrecognized options, and return everything after a `"--"` argument
* as `extraArgs` to be passed down to the script.
*/
function parseRemainingArgs(args) {
let watch = false;
let extraArgs = [];
const unrecognized = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--') {
extraArgs = args.slice(i + 1);
break;
}
else if (arg === '--watch') {
watch = readWatchConfigFromEnv();
}
else {
unrecognized.push(arg);
}
}
if (unrecognized.length > 0) {
console.error(`⚠️ Unrecognized Wireit argument(s): ` +
unrecognized.map((arg) => JSON.stringify(arg)).join(', ') +
`. To pass arguments to the script, use a double-dash, ` +
`e.g. "npm run build -- --extra".`);
}
return {
watch,
extraArgs,
};
}
const DEFAULT_WATCH_STRATEGY = { strategy: 'event' };
const DEFAULT_WATCH_INTERVAL = 500;
/**
* Interpret the WIREIT_WATCH_* environment variables.
*/
function readWatchConfigFromEnv() {
switch (process.env['WIREIT_WATCH_STRATEGY']) {
case 'event':
case '':
case undefined: {
return DEFAULT_WATCH_STRATEGY;
}
case 'poll': {
let interval = DEFAULT_WATCH_INTERVAL;
const intervalStr = process.env['WIREIT_WATCH_POLL_MS'];
if (intervalStr) {
const parsed = Number(intervalStr);
if (Number.isNaN(parsed) || parsed <= 0) {
console.error(`⚠️ Expected WIREIT_WATCH_POLL_MS to be a positive integer, ` +
`got ${JSON.stringify(intervalStr)}. Using default interval of ` +
`${DEFAULT_WATCH_INTERVAL}ms.`);
}
else {
interval = parsed;
}
}
return {
strategy: 'poll',
interval,
};
}
default: {
console.error(`⚠️ Unrecognized WIREIT_WATCH_STRATEGY: ` +
`${JSON.stringify(process.env['WIREIT_WATCH_STRATEGY'])}. ` +
`Using default strategy of ${DEFAULT_WATCH_STRATEGY.strategy}.`);
return DEFAULT_WATCH_STRATEGY;
}
}
}
//# sourceMappingURL=cli-options.js.map