diff --git a/lib/can-npm-publish.js b/lib/can-npm-publish.js index 187e7f7..c715515 100644 --- a/lib/can-npm-publish.js +++ b/lib/can-npm-publish.js @@ -4,6 +4,7 @@ const path = require("path"); const spawn = require("cross-spawn"); const readPkg = require("read-pkg"); const validatePkgName = require("validate-npm-package-name"); +const { extractJSONObject } = require("extract-first-json"); /** * @param {string} [filePathOrDirPath] * @returns {Promise} @@ -63,43 +64,122 @@ const checkPrivateField = (packagePath) => { * or rejects if anything failed * @param packageName * @param registry + * @param {{verbose : boolean}} options * @returns {Promise} */ -const viewPackage = (packageName, registry) => { +const viewPackage = (packageName, registry, options) => { return new Promise((resolve, reject) => { const registryArgs = registry ? ["--registry", registry] : []; const view = spawn("npm", ["view", packageName, "versions", "--json"].concat(registryArgs)); - let result = ""; - let errorResult = ""; + let _stdoutResult = ""; + let _stderrResult = ""; + /** + * @param stdout + * @param stderr + * @returns {{stdoutJSON: null | {}, stderrJSON: null | {}}} + */ + const getJsonOutputs = ({ stdout, stderr }) => { + let stdoutJSON = null; + let stderrJSON = null; + if (stdout) { + try { + stdoutJSON = JSON.parse(stdout); + } catch (error) { + // nope + if (options.verbose) { + console.error("stdoutJSON parse error", stdout); + } + } + } + if (stderr) { + try { + stderrJSON = JSON.parse(stderr); + } catch (error) { + // nope + if (options.verbose) { + console.error("stderrJSON parse error", stdout); + } + } + } + return { + stdoutJSON, + stderrJSON + }; + }; + const isError = (json) => { + return json && "error" in json; + }; + const is404Error = (json) => { + return isError(json) && json.error.code === "E404"; + }; view.stdout.on("data", (data) => { - result += data.toString(); + _stdoutResult += data.toString(); }); view.stderr.on("data", (err) => { - errorResult += err.toString(); + const stdErrorStr = err.toString(); + // Workaround for npm 7 + // npm 7 --json option is broken + // It aim to remove non json output. + // FIXME: However,This logics will break json chunk(chunk may be invalid json) + // https://github.com/azu/can-npm-publish/issues/19 + // https://github.com/npm/cli/issues/2740 + const jsonObject = extractJSONObject(stdErrorStr); + if (jsonObject) { + _stderrResult = JSON.stringify(jsonObject, null, 4); + } }); view.on("close", (code) => { + // Note: + // npm 6 output JSON in stdout + // npm 7(7.18.1) output JSON in stderr + const { stdoutJSON, stderrJSON } = getJsonOutputs({ + stdout: _stdoutResult, + stderr: _stderrResult + }); + if (options.verbose) { + console.log("`npm view` command's exit code:", code); + console.log("`npm view` stdoutJSON", stdoutJSON); + console.log("`npm view` stderrJSON", stderrJSON); + } + // npm6 view --json output to stdout if the package is 404 → can publish + if (is404Error(stdoutJSON)) { + return resolve([]); + } + // npm7 view --json output to stderr if the package is 404 → can publish + if (is404Error(stderrJSON)) { + return resolve([]); + } + // in other error, can not publish → reject + if (isError(stdoutJSON)) { + return reject(new Error(_stdoutResult)); + } + if (isError(stderrJSON)) { + return reject(new Error(_stderrResult)); + } + // if command is failed by other reasons(no json output), treat it as actual error if (code !== 0) { - return reject(new Error(errorResult)); + return reject(new Error(_stderrResult)); } - const resultJSON = JSON.parse(result); - if (resultJSON && resultJSON.error) { - // the package is not in the npm registry => can publish - if (resultJSON.error.code === "E404") { - return resolve([]); // resolve as empty version - } else { - // other error => can not publish - return reject(new Error(errorResult)); - } + if (stdoutJSON) { + // if success to get, resolve with versions json + return resolve(stdoutJSON); + } else { + return reject(_stderrResult); } - resolve(resultJSON); }); }); }; -const checkAlreadyPublish = (packagePath) => { +/** + * + * @param {string} packagePath + * @param {{verbose : boolean}} options + * @returns {Promise} + */ +const checkAlreadyPublish = (packagePath, options) => { return readPkgWithPath(packagePath).then((pkg) => { const name = pkg["name"]; const version = pkg["version"]; @@ -111,7 +191,7 @@ const checkAlreadyPublish = (packagePath) => { if (version === undefined) { return Promise.reject(new Error("This package has no `version`.")); } - return viewPackage(name, registry).then((versions) => { + return viewPackage(name, registry, options).then((versions) => { if (versions.includes(version)) { return Promise.reject(new Error(`${name}@${version} is already published`)); } @@ -128,7 +208,7 @@ const checkAlreadyPublish = (packagePath) => { const canNpmPublish = (packagePath, options = { verbose: false }) => { return Promise.all([ checkPkgName(packagePath, options), - checkAlreadyPublish(packagePath), + checkAlreadyPublish(packagePath, options), checkPrivateField(packagePath) ]); }; diff --git a/package.json b/package.json index cc53d3a..11209f6 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ }, "dependencies": { "cross-spawn": "^7.0.3", + "extract-first-json": "^1.0.1", "meow": "^9.0.0", "read-pkg": "^5.0.0", "validate-npm-package-name": "^3.0.0" @@ -58,5 +59,9 @@ "*.{js,jsx,ts,tsx,css}": [ "prettier --write" ] + }, + "volta": { + "node": "16.4.0", + "npm": "7.18.1" } } diff --git a/test/can-npm-publish-bin-test.js b/test/can-npm-publish-bin-test.js index 3bafc72..7833255 100644 --- a/test/can-npm-publish-bin-test.js +++ b/test/can-npm-publish-bin-test.js @@ -5,10 +5,6 @@ const path = require("path"); // Path to the executable script const binPath = path.join(__dirname, "../bin/cmd.js"); -const shouldNotCalled = () => { - throw new Error("SHOULD NOT CALLED"); -}; - describe("can-npm-publish bin", () => { it("should return 0, it can publish", (done) => { const bin = spawn("node", [binPath, path.join(__dirname, "fixtures/not-published-yet.json")]); @@ -16,6 +12,8 @@ describe("can-npm-publish bin", () => { // Finish the test when the executable finishes and returns 0 bin.on("close", (exit_code) => { assert.ok(exit_code === 0); + }); + bin.on("close", () => { done(); }); }); @@ -30,11 +28,12 @@ describe("can-npm-publish bin", () => { }); it("should send errors to stderr when verbose, it can't publish", (done) => { const bin = spawn("node", [binPath, path.join(__dirname, "fixtures/already-published.json"), "--verbose"]); - // Finish the test and stop the executable when it outputs to stderr bin.stderr.on("data", (data) => { assert.ok(/almin@0.15.2 is already published/.test(data)); bin.kill(); + }); + bin.on("close", () => { done(); }); }); diff --git a/test/can-npm-publish-test.js b/test/can-npm-publish-test.js index 919f4fe..c14d055 100644 --- a/test/can-npm-publish-test.js +++ b/test/can-npm-publish-test.js @@ -37,6 +37,9 @@ describe("can-npm-publish", () => { it("should be resolve, it is not published yet", () => { return canNpmPublish(path.join(__dirname, "fixtures/not-published-yet.json")); }); + it("should be resolve, it is not 404 package", () => { + return canNpmPublish(path.join(__dirname, "fixtures/404-package.json"), { verbose: true }); + }); it("should be resolve, it is not published yet to yarnpkg registry", () => { return canNpmPublish(path.join(__dirname, "fixtures/not-published-yet-registry.json")); }); diff --git a/test/fixtures/404-package.json b/test/fixtures/404-package.json new file mode 100644 index 0000000..2969635 --- /dev/null +++ b/test/fixtures/404-package.json @@ -0,0 +1,5 @@ +{ + "private": false, + "name": "asasaiqjpjopmsonopajrpqwkmxzoj22", + "version": "1.0.0" +} diff --git a/yarn.lock b/yarn.lock index 0f814dd..186e360 100644 --- a/yarn.lock +++ b/yarn.lock @@ -373,6 +373,14 @@ execa@^5.0.0: signal-exit "^3.0.3" strip-final-newline "^2.0.0" +extract-first-json@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/extract-first-json/-/extract-first-json-1.0.1.tgz#be828615ac0c83c982f22d8c93063e4f9dc2a356" + integrity sha512-L4XmAoZaHyXawEy8fJuLfIjRH9rG9D3bLz6fiMepVMiw/0Mpu+OSYH3/XmJ6sCPjui8a8ayz6c46fWlQFhdLvA== + dependencies: + parse-json-object "^2.0.0" + reduce-first "^1.0.1" + fill-range@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" @@ -912,6 +920,13 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" +parse-json-object@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/parse-json-object/-/parse-json-object-2.0.1.tgz#a441bd8c36d2c33a69516286e7e4138a23607ee0" + integrity sha512-/oF7PUUBjCqHmMEE6xIQeX5ZokQ9+miudACzPt4KBU2qi6CxZYPdisPXx4ad7wpZJYi2ZpcW2PacLTU3De3ebw== + dependencies: + types-json "^1.2.0" + parse-json@^5.0.0: version "5.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" @@ -1010,6 +1025,11 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +reduce-first@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/reduce-first/-/reduce-first-1.0.1.tgz#ef934f0dd4e010fdcaec2c51c9027722ee810c1c" + integrity sha512-/jBjEiF5Oe1xsa7CeCscbOIxSlFJcn4h1gj3OvUHPtxnThCbZ1Wh72uqO/o1zHNSGU4EgFclvCdc5TLJyt1hOQ== + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -1258,6 +1278,11 @@ type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== +types-json@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/types-json/-/types-json-1.2.2.tgz#91ebe6de59e741ab38a98b071708a29494cedfe6" + integrity sha512-VfVLISHypS7ayIHvhacOESOTib4Sm4mAhnsgR8fzQdGp89YoBwMqvGmqENjtYehUQzgclT+7NafpEXkK/MHKwA== + validate-npm-package-license@^3.0.1: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"