From a7894ebb43523152d36720efa770bb1fe8b58c07 Mon Sep 17 00:00:00 2001 From: Kai Cataldo <7041728+kaicataldo@users.noreply.github.com> Date: Wed, 25 Sep 2019 16:45:34 -0400 Subject: [PATCH] New: add --env-info flag to CLI (#12270) --- bin/eslint.js | 13 +- docs/user-guide/command-line-interface.md | 5 + lib/cli.js | 25 +-- lib/options.js | 6 + lib/shared/runtime-info.js | 152 +++++++++++++++ tests/lib/cli.js | 27 ++- tests/lib/shared/runtime-info.js | 216 ++++++++++++++++++++++ 7 files changed, 417 insertions(+), 27 deletions(-) create mode 100644 lib/shared/runtime-info.js create mode 100644 tests/lib/shared/runtime-info.js diff --git a/bin/eslint.js b/bin/eslint.js index 061e94767f0..82bcc1e033b 100755 --- a/bin/eslint.js +++ b/bin/eslint.js @@ -16,9 +16,9 @@ require("v8-compile-cache"); // Helpers //------------------------------------------------------------------------------ -const useStdIn = (process.argv.indexOf("--stdin") > -1), - init = (process.argv.indexOf("--init") > -1), - debug = (process.argv.indexOf("--debug") > -1); +const useStdIn = process.argv.includes("--stdin"), + init = process.argv.includes("--init"), + debug = process.argv.includes("--debug"); // must do this initialization *before* other requires in order to work if (debug) { @@ -30,9 +30,9 @@ if (debug) { //------------------------------------------------------------------------------ // now we can safely include the other modules that use debug -const cli = require("../lib/cli"), - path = require("path"), - fs = require("fs"); +const path = require("path"), + fs = require("fs"), + cli = require("../lib/cli"); //------------------------------------------------------------------------------ // Execution @@ -50,7 +50,6 @@ process.once("uncaughtException", err => { console.error("\nOops! Something went wrong! :("); console.error(`\nESLint: ${pkg.version}.\n\n${template(err.messageData || {})}`); } else { - console.error(err.stack); } diff --git a/docs/user-guide/command-line-interface.md b/docs/user-guide/command-line-interface.md index 532d8f94735..8938bc91f24 100644 --- a/docs/user-guide/command-line-interface.md +++ b/docs/user-guide/command-line-interface.md @@ -78,6 +78,7 @@ Caching: Miscellaneous: --init Run config initialization wizard - default: false + --env-info Output execution environment information - default: false --debug Output debugging information -h, --help Show help -v, --version Output the version number @@ -446,6 +447,10 @@ This option will start config initialization wizard. It's designed to help new u The resulting configuration file will be created in the current directory. +#### `--env-info` + +This option outputs information about the execution environment, including the version of Node, npm, and local and global installations of ESLint. The ESLint team may ask for this information to help solve bugs. + #### `--debug` This option outputs debugging information to the console. This information is useful when you're seeing a problem and having a hard time pinpointing it. The ESLint team may ask for this debugging information to help solve bugs. diff --git a/lib/cli.js b/lib/cli.js index c34545544b1..18a917cf0b0 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -20,7 +20,8 @@ const fs = require("fs"), mkdirp = require("mkdirp"), { CLIEngine } = require("./cli-engine"), options = require("./options"), - log = require("./shared/logging"); + log = require("./shared/logging"), + RuntimeInfo = require("./shared/runtime-info"); const debug = require("debug")("eslint:cli"); @@ -159,13 +160,18 @@ const cli = { } const files = currentOptions._; - const useStdin = typeof text === "string"; - if (currentOptions.version) { // version from package.json - - log.info(`v${require("../package.json").version}`); - + if (currentOptions.version) { + log.info(RuntimeInfo.version()); + } else if (currentOptions.envInfo) { + try { + log.info(RuntimeInfo.environment()); + return 0; + } catch (err) { + log.error(err.message); + return 2; + } } else if (currentOptions.printConfig) { if (files.length) { log.error("The --print-config option must be used with exactly one file name."); @@ -177,17 +183,13 @@ const cli = { } const engine = new CLIEngine(translateOptions(currentOptions)); - const fileConfig = engine.getConfigForFile(currentOptions.printConfig); log.info(JSON.stringify(fileConfig, null, " ")); return 0; } else if (currentOptions.help || (!files.length && !useStdin)) { - log.info(options.generateHelp()); - } else { - debug(`Running on ${useStdin ? "text" : "files"}`); if (currentOptions.fix && currentOptions.fixDryRun) { @@ -227,9 +229,8 @@ const cli = { return (report.errorCount || tooManyWarnings) ? 1 : 0; } - return 2; - + return 2; } return 0; diff --git a/lib/options.js b/lib/options.js index 440773a844b..83bf9afc22c 100644 --- a/lib/options.js +++ b/lib/options.js @@ -224,6 +224,12 @@ module.exports = optionator({ default: "false", description: "Run config initialization wizard" }, + { + option: "env-info", + type: "Boolean", + default: "false", + description: "Output execution environment information" + }, { option: "debug", type: "Boolean", diff --git a/lib/shared/runtime-info.js b/lib/shared/runtime-info.js new file mode 100644 index 00000000000..324f457d80b --- /dev/null +++ b/lib/shared/runtime-info.js @@ -0,0 +1,152 @@ +/** + * @fileoverview Utility to get information about the execution environment. + * @author Kai Cataldo + */ + +"use strict"; + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const path = require("path"); +const spawn = require("cross-spawn"); +const { isEmpty } = require("lodash"); +const log = require("../shared/logging"); +const packageJson = require("../../package.json"); + +//------------------------------------------------------------------------------ +// Helpers +//------------------------------------------------------------------------------ + +/** + * Checks if a path is a child of a directory. + * @param {string} parentPath - The parent path to check. + * @param {string} childPath - The path to check. + * @returns {boolean} Whether or not the given path is a child of a directory. + */ +function isChildOfDirectory(parentPath, childPath) { + return !path.relative(parentPath, childPath).startsWith(".."); +} + +/** + * Synchronously executes a shell command and formats the result. + * @param {string} cmd - The command to execute. + * @param {Array} args - The arguments to be executed with the command. + * @returns {string} The version returned by the command. + */ +function execCommand(cmd, args) { + const process = spawn.sync(cmd, args, { encoding: "utf8" }); + + if (process.error) { + throw process.error; + } + + return process.stdout.trim(); +} + +/** + * Normalizes a version number. + * @param {string} versionStr - The string to normalize. + * @returns {string} The normalized version number. + */ +function normalizeVersionStr(versionStr) { + return versionStr.startsWith("v") ? versionStr : `v${versionStr}`; +} + +/** + * Gets bin version. + * @param {string} bin - The bin to check. + * @returns {string} The normalized version returned by the command. + */ +function getBinVersion(bin) { + const binArgs = ["--version"]; + + try { + return normalizeVersionStr(execCommand(bin, binArgs)); + } catch (e) { + log.error(`Error finding ${bin} version running the command \`${bin} ${binArgs.join(" ")}\``); + throw e; + } +} + +/** + * Gets installed npm package version. + * @param {string} pkg - The package to check. + * @param {boolean} global - Whether to check globally or not. + * @returns {string} The normalized version returned by the command. + */ +function getNpmPackageVersion(pkg, { global = false } = {}) { + const npmBinArgs = ["bin", "-g"]; + const npmLsArgs = ["ls", "--depth=0", "--json", "eslint"]; + + if (global) { + npmLsArgs.push("-g"); + } + + try { + const parsedStdout = JSON.parse(execCommand("npm", npmLsArgs)); + + /* + * Checking globally returns an empty JSON object, while local checks + * include the name and version of the local project. + */ + if (isEmpty(parsedStdout) || !(parsedStdout.dependencies && parsedStdout.dependencies.eslint)) { + return "Not found"; + } + + const [, processBinPath] = process.argv; + let npmBinPath; + + try { + npmBinPath = execCommand("npm", npmBinArgs); + } catch (e) { + log.error(`Error finding npm binary path when running command \`npm ${npmBinArgs.join(" ")}\``); + throw e; + } + + const isGlobal = isChildOfDirectory(npmBinPath, processBinPath); + let pkgVersion = parsedStdout.dependencies.eslint.version; + + if ((global && isGlobal) || (!global && !isGlobal)) { + pkgVersion += " (Currently used)"; + } + + return normalizeVersionStr(pkgVersion); + } catch (e) { + log.error(`Error finding ${pkg} version running the command \`npm ${npmLsArgs.join(" ")}\``); + throw e; + } +} + +/** + * Generates and returns execution environment information. + * @returns {string} A string that contains execution environment information. + */ +function environment() { + return [ + "Environment Info:", + "", + `Node version: ${getBinVersion("node")}`, + `npm version: ${getBinVersion("npm")}`, + `Local ESLint version: ${getNpmPackageVersion("eslint", { global: false })}`, + `Global ESLint version: ${getNpmPackageVersion("eslint", { global: true })}` + ].join("\n"); +} + +/** + * Returns version of currently executing ESLint. + * @returns {string} The version from the currently executing ESLint's package.json. + */ +function version() { + return `v${packageJson.version}`; +} + +//------------------------------------------------------------------------------ +// Public Interface +//------------------------------------------------------------------------------ + +module.exports = { + environment, + version +}; diff --git a/tests/lib/cli.js b/tests/lib/cli.js index baae75b53cb..1b98fb137a7 100644 --- a/tests/lib/cli.js +++ b/tests/lib/cli.js @@ -29,14 +29,18 @@ const proxyquire = require("proxyquire").noCallThru().noPreserveCache(); //------------------------------------------------------------------------------ describe("cli", () => { - let fixtureDir; const log = { info: sinon.spy(), error: sinon.spy() }; + const RuntimeInfo = { + environment: sinon.stub(), + version: sinon.stub() + }; const cli = proxyquire("../../lib/cli", { - "./shared/logging": log + "./shared/logging": log, + "./shared/runtime-info": RuntimeInfo }); /** @@ -324,15 +328,27 @@ describe("cli", () => { describe("when executing with version flag", () => { it("should print out current version", () => { assert.strictEqual(cli.execute("-v"), 0); + assert.strictEqual(log.info.callCount, 1); + }); + }); + describe("when executing with env-info flag", () => { + it("should print out environment information", () => { + assert.strictEqual(cli.execute("--env-info"), 0); assert.strictEqual(log.info.callCount, 1); }); + + it("should print error message and return error code", () => { + RuntimeInfo.environment.throws("There was an error!"); + + assert.strictEqual(cli.execute("--env-info"), 2); + assert.strictEqual(log.error.callCount, 1); + }); }); describe("when executing with help flag", () => { it("should print out help", () => { assert.strictEqual(cli.execute("-h"), 0); - assert.strictEqual(log.info.callCount, 1); }); }); @@ -349,7 +365,6 @@ describe("cli", () => { }); describe("when given a file in excluded files list", () => { - it("should not process the file", () => { const ignorePath = getFixturePath(".eslintignore"); const filePath = getFixturePath("passing.js"); @@ -398,7 +413,6 @@ describe("cli", () => { }); describe("when executing a file with a shebang", () => { - it("should execute without error", () => { const filePath = getFixturePath("shebang.js"); const exit = cli.execute(`--no-ignore ${filePath}`); @@ -408,7 +422,6 @@ describe("cli", () => { }); describe("when loading a custom rule", () => { - it("should return an error when rule isn't found", () => { const rulesPath = getFixturePath("rules", "wrong"); const configPath = getFixturePath("rules", "eslint.json"); @@ -551,7 +564,6 @@ describe("cli", () => { }); describe("when supplied with report output file path", () => { - afterEach(() => { sh.rm("-rf", "tests/output"); }); @@ -594,7 +606,6 @@ describe("cli", () => { }); describe("when supplied with a plugin", () => { - it("should pass plugins to CLIEngine", () => { const examplePluginName = "eslint-plugin-example"; diff --git a/tests/lib/shared/runtime-info.js b/tests/lib/shared/runtime-info.js new file mode 100644 index 00000000000..77ada8bc78d --- /dev/null +++ b/tests/lib/shared/runtime-info.js @@ -0,0 +1,216 @@ +/** + * @fileoverview Tests for RuntimeInfo util. + * @author Kai Cataldo + */ + +"use strict"; + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const assert = require("chai").assert; +const sinon = require("sinon"); +const spawn = require("cross-spawn"); +const { unIndent } = require("../_utils"); +const RuntimeInfo = require("../../../lib/shared/runtime-info"); +const log = require("../../../lib/shared/logging"); +const packageJson = require("../../../package.json"); + +//------------------------------------------------------------------------------ +// Helpers +//------------------------------------------------------------------------------ + +/** + * Sets up spawn.sync() stub calls to return values and throw errors in the order in which they are given. + * @param {Function} stub - The stub to set up. + * @param {Array} returnVals - Values to be returned by subsequent stub calls. + * @returns {Function} The set up stub. + */ +function setupSpawnSyncStubReturnVals(stub, returnVals) { + let stubChain = stub; + + for (const [i, val] of returnVals.entries()) { + const returnVal = val instanceof Error + ? { error: val } + : { stdout: val }; + + stubChain = stubChain.onCall(i).returns(returnVal); + } + + return stubChain; +} + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const LOCAL_ESLINT_BIN_PATH = "/Users/username/code/project/node_modules/eslint/bin/eslint.js"; +const GLOBAL_ESLINT_BIN_PATH = "/usr/local/bin/npm/node_modules/eslint/bin/eslint.js"; +const NPM_BIN_PATH = "/usr/local/bin/npm"; + +describe("RuntimeInfo", () => { + describe("environment()", () => { + let spawnSyncStub; + let logErrorStub; + let originalProcessArgv; + let spawnSyncStubArgs; + + beforeEach(() => { + spawnSyncStub = sinon.stub(spawn, "sync"); + logErrorStub = sinon.stub(log, "error"); + originalProcessArgv = process.argv; + process.argv[1] = LOCAL_ESLINT_BIN_PATH; + spawnSyncStubArgs = [ + "v12.8.0", + "6.11.3", + unIndent` + { + "name": "project", + "version": "1.0.0", + "dependencies": { + "eslint": { + "version": "6.3.0" + } + } + } + `, + NPM_BIN_PATH, + unIndent` + { + "dependencies": { + "eslint": { + "version": "5.16.0", + "from": "eslint", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-6.16.0.tgz" + } + } + } + `, + NPM_BIN_PATH + ]; + }); + + afterEach(() => { + spawnSyncStub.restore(); + logErrorStub.restore(); + process.argv = originalProcessArgv; + }); + + + it("should return a string containing environment information when running local installation", () => { + setupSpawnSyncStubReturnVals(spawnSyncStub, spawnSyncStubArgs); + + assert.strictEqual( + RuntimeInfo.environment(), + unIndent` + Environment Info: + + Node version: v12.8.0 + npm version: v6.11.3 + Local ESLint version: v6.3.0 (Currently used) + Global ESLint version: v5.16.0 + ` + ); + }); + + it("should return a string containing environment information when running global installation", () => { + setupSpawnSyncStubReturnVals(spawnSyncStub, spawnSyncStubArgs); + process.argv[1] = GLOBAL_ESLINT_BIN_PATH; + + assert.strictEqual( + RuntimeInfo.environment(), + unIndent` + Environment Info: + + Node version: v12.8.0 + npm version: v6.11.3 + Local ESLint version: v6.3.0 + Global ESLint version: v5.16.0 (Currently used) + ` + ); + }); + + it("should return a string containing environment information when not installed locally", () => { + spawnSyncStubArgs.splice(2, 2, unIndent` + { + "name": "project", + "version": "1.0.0" + } + `); + setupSpawnSyncStubReturnVals(spawnSyncStub, spawnSyncStubArgs); + process.argv[1] = GLOBAL_ESLINT_BIN_PATH; + + assert.strictEqual( + RuntimeInfo.environment(), + unIndent` + Environment Info: + + Node version: v12.8.0 + npm version: v6.11.3 + Local ESLint version: Not found + Global ESLint version: v5.16.0 (Currently used) + ` + ); + }); + + it("should return a string containing environment information when not installed globally", () => { + spawnSyncStubArgs[4] = "{}"; + setupSpawnSyncStubReturnVals(spawnSyncStub, spawnSyncStubArgs); + + assert.strictEqual( + RuntimeInfo.environment(), + unIndent` + Environment Info: + + Node version: v12.8.0 + npm version: v6.11.3 + Local ESLint version: v6.3.0 (Currently used) + Global ESLint version: Not found + ` + ); + }); + + it("log and throw an error when npm version can not be found", () => { + const expectedErr = new Error("npm can not be found"); + + spawnSyncStubArgs[1] = expectedErr; + setupSpawnSyncStubReturnVals(spawnSyncStub, spawnSyncStubArgs); + + assert.throws(RuntimeInfo.environment, expectedErr); + assert.strictEqual(logErrorStub.args[0][0], "Error finding npm version running the command `npm --version`"); + }); + + it("log and throw an error when npm binary path can not be found", () => { + const expectedErr = new Error("npm can not be found"); + + spawnSyncStubArgs[3] = expectedErr; + setupSpawnSyncStubReturnVals(spawnSyncStub, spawnSyncStubArgs); + + assert.throws(RuntimeInfo.environment, expectedErr); + assert.strictEqual(logErrorStub.args[0][0], "Error finding npm binary path when running command `npm bin -g`"); + }); + + it("log and throw an error when checking for local ESLint version when returned output of command is malformed", () => { + spawnSyncStubArgs[2] = "This is not JSON"; + setupSpawnSyncStubReturnVals(spawnSyncStub, spawnSyncStubArgs); + + assert.throws(RuntimeInfo.environment, "Unexpected token T in JSON at position 0"); + assert.strictEqual(logErrorStub.args[0][0], "Error finding eslint version running the command `npm ls --depth=0 --json eslint`"); + }); + + it("log and throw an error when checking for global ESLint version when returned output of command is malformed", () => { + spawnSyncStubArgs[4] = "This is not JSON"; + setupSpawnSyncStubReturnVals(spawnSyncStub, spawnSyncStubArgs); + + assert.throws(RuntimeInfo.environment, "Unexpected token T in JSON at position 0"); + assert.strictEqual(logErrorStub.args[0][0], "Error finding eslint version running the command `npm ls --depth=0 --json eslint -g`"); + }); + }); + + describe("version()", () => { + it("should return the version of the package defined in package.json", () => { + assert.strictEqual(RuntimeInfo.version(), `v${packageJson.version}`); + }); + }); +});