diff --git a/SCAFFOLDING.md b/SCAFFOLDING.md index c428f5bea43..359badf91ad 100644 --- a/SCAFFOLDING.md +++ b/SCAFFOLDING.md @@ -14,7 +14,12 @@ Before writing a `webpack-cli` scaffold, think about what you're trying to achie ## webpack-addons-yourpackage -In order for `webpack-cli` to compile your package, it relies on a prefix of `webpack-addons`. The package must also be published on npm. If you are curious about how you can create your very own `addon`, please read [How do I compose a webpack-addon?](https://github.com/ev1stensberg/webpack-addons-demo). +In order for `webpack-cli` to compile your package, it must be available on npm or on your local filesystem. If you are curious about how you can create your very own `addon`, please read [How do I compose a +webpack-addon?](https://github.com/ev1stensberg/webpack-addons-demo). + +If the package is on npm, its name must have a prefix of `webpack-addons`. + +If the package is on your local filesystem, it can be named whatever you want. Pass the path to the package. ## API diff --git a/lib/utils/is-local-path.js b/lib/utils/is-local-path.js new file mode 100644 index 00000000000..af17d590594 --- /dev/null +++ b/lib/utils/is-local-path.js @@ -0,0 +1,19 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +/** + * Attempts to detect whether the string is a local path regardless of its + * existence by checking its format. The point is to distinguish between + * paths and modules on the npm registry. This will fail for non-existent + * local Windows paths that begin with a drive letter, e.g. C:..\generator.js, + * but will succeed for any existing files and any absolute paths. + * + * @param {String} str - string to check + * @returns {Boolean} whether the string could be a path to a local file or directory + */ + +module.exports = function(str) { + return path.isAbsolute(str) || /^\./.test(str) || fs.existsSync(str); +}; diff --git a/lib/utils/is-local-path.spec.js b/lib/utils/is-local-path.spec.js new file mode 100644 index 00000000000..32acbaf8384 --- /dev/null +++ b/lib/utils/is-local-path.spec.js @@ -0,0 +1,24 @@ +"use strict"; + +const isLocalPath = require("./is-local-path"); +const path = require("path"); + +describe("is-local-path", () => { + it("returns true for paths beginning in the current directory", () => { + const p = path.resolve(".", "test", "dir"); + expect(isLocalPath(p)).toBe(true); + }); + + it("returns true for absolute paths", () => { + const p = path.join("/", "test", "dir"); + expect(isLocalPath(p)).toBe(true); + }); + + it("returns false for npm packages names", () => { + expect(isLocalPath("webpack-addons-ylvis")).toBe(false); + }); + + it("returns false for scoped npm package names", () => { + expect(isLocalPath("@webpack/test")).toBe(false); + }); +}); diff --git a/lib/utils/npm-packages-exists.js b/lib/utils/npm-packages-exists.js index 65c38e80eaa..fccc1c25e7a 100644 --- a/lib/utils/npm-packages-exists.js +++ b/lib/utils/npm-packages-exists.js @@ -1,8 +1,11 @@ "use strict"; const chalk = require("chalk"); +const isLocalPath = require("./is-local-path"); const npmExists = require("./npm-exists"); const resolvePackages = require("./resolve-packages").resolvePackages; +const WEBPACK_ADDON_PREFIX = "webpack-addons"; + /** * * Loops through an array and checks if a package is registered @@ -14,21 +17,38 @@ const resolvePackages = require("./resolve-packages").resolvePackages; module.exports = function npmPackagesExists(pkg) { let acceptedPackages = []; + + function resolvePackagesIfReady() { + if (acceptedPackages.length === pkg.length) + return resolvePackages(acceptedPackages); + } + pkg.forEach(addon => { - //eslint-disable-next-line - if (addon.length <= 14 || addon.slice(0, 14) !== "webpack-addons") { + if (isLocalPath(addon)) { + // If the addon is a path to a local folder, no name validation is necessary. + acceptedPackages.push(addon); + resolvePackagesIfReady(); + return; + } + + // The addon is on npm; validate name and existence + if ( + addon.length <= WEBPACK_ADDON_PREFIX.length || + addon.slice(0, WEBPACK_ADDON_PREFIX.length) !== WEBPACK_ADDON_PREFIX + ) { throw new TypeError( chalk.bold(`${addon} isn't a valid name.\n`) + chalk.red( - "\nIt should be prefixed with 'webpack-addons', but have different suffix.\n" + `\nIt should be prefixed with '${WEBPACK_ADDON_PREFIX}', but have different suffix.\n` ) ); } + npmExists(addon) .then(moduleExists => { if (!moduleExists) { Error.stackTraceLimit = 0; - throw new TypeError("Package isn't registered on npm."); + throw new TypeError(`Cannot resolve location of package ${addon}.`); } if (moduleExists) { acceptedPackages.push(addon); @@ -38,9 +58,6 @@ module.exports = function npmPackagesExists(pkg) { console.error(err.stack || err); process.exit(0); }) - .then(_ => { - if (acceptedPackages.length === pkg.length) - return resolvePackages(acceptedPackages); - }); + .then(resolvePackagesIfReady); }); }; diff --git a/lib/utils/npm-packages-exists.spec.js b/lib/utils/npm-packages-exists.spec.js new file mode 100644 index 00000000000..784f700502e --- /dev/null +++ b/lib/utils/npm-packages-exists.spec.js @@ -0,0 +1,36 @@ +const npmPackagesExists = require("./npm-packages-exists"); + +jest.mock("./npm-exists"); +jest.mock("./resolve-packages"); + +const mockResolvePackages = require("./resolve-packages").resolvePackages; + +describe("npmPackagesExists", () => { + test("resolves packages when they are available on the local filesystem", () => { + npmPackagesExists(["./testpkg"]); + expect( + mockResolvePackages.mock.calls[ + mockResolvePackages.mock.calls.length - 1 + ][0] + ).toEqual(["./testpkg"]); + }); + + test("throws a TypeError when an npm package name doesn't include the prefix", () => { + expect(() => npmPackagesExists(["my-webpack-addon"])).toThrowError( + TypeError + ); + }); + + test("resolves packages when they are available on npm", done => { + require("./npm-exists").mockImplementation(() => Promise.resolve(true)); + npmPackagesExists(["webpack-addons-foobar"]); + setTimeout(() => { + expect( + mockResolvePackages.mock.calls[ + mockResolvePackages.mock.calls.length - 1 + ][0] + ).toEqual(["webpack-addons-foobar"]); + done(); + }, 10); + }); +}); diff --git a/lib/utils/package-manager.js b/lib/utils/package-manager.js index 4f2fead6d56..415eafef016 100644 --- a/lib/utils/package-manager.js +++ b/lib/utils/package-manager.js @@ -48,7 +48,8 @@ function spawnYarn(pkg, isNew) { */ function spawnChild(pkg) { - const pkgPath = path.resolve(globalPath, pkg); + const rootPath = getPathToGlobalPackages(); + const pkgPath = path.resolve(rootPath, pkg); const packageManager = getPackageManager(); const isNew = !fs.existsSync(pkgPath); @@ -71,7 +72,34 @@ function getPackageManager() { return "yarn"; } +/** + * + * Returns the path to globally installed + * npm packages, depending on the available + * package manager determined by `getPackageManager` + * + * @returns {String} path - Path to global node_modules folder + */ +function getPathToGlobalPackages() { + const manager = getPackageManager(); + + if (manager === "yarn") { + try { + const yarnDir = spawn + .sync("yarn", ["global", "dir"]) + .stdout.toString() + .trim(); + return path.join(yarnDir, "node_modules"); + } catch (e) { + // Default to the global npm path below + } + } + + return globalPath; +} + module.exports = { getPackageManager, + getPathToGlobalPackages, spawnChild }; diff --git a/lib/utils/package-manager.spec.js b/lib/utils/package-manager.spec.js index e45b920fb4a..c99fa0bbf79 100644 --- a/lib/utils/package-manager.spec.js +++ b/lib/utils/package-manager.spec.js @@ -1,5 +1,7 @@ "use strict"; +const path = require("path"); + jest.mock("cross-spawn"); jest.mock("fs"); @@ -27,6 +29,11 @@ describe("package-manager", () => { ); } + function mockSpawnErrorTwice() { + mockSpawnErrorOnce(); + mockSpawnErrorOnce(); + } + spawn.sync.mockReturnValue(defaultSyncResult); it("should return 'yarn' from getPackageManager if it's installed", () => { @@ -65,7 +72,7 @@ describe("package-manager", () => { it("should spawn npm install from spawnChild", () => { const packageName = "some-pkg"; - mockSpawnErrorOnce(); + mockSpawnErrorTwice(); packageManager.spawnChild(packageName); expect(spawn.sync).toHaveBeenLastCalledWith( "npm", @@ -77,7 +84,7 @@ describe("package-manager", () => { it("should spawn npm update from spawnChild", () => { const packageName = "some-pkg"; - mockSpawnErrorOnce(); + mockSpawnErrorTwice(); fs.existsSync.mockReturnValueOnce(true); packageManager.spawnChild(packageName); @@ -87,4 +94,25 @@ describe("package-manager", () => { { stdio: "inherit" } ); }); + + it("should return the yarn global dir from getPathToGlobalPackages if yarn is installed", () => { + const yarnDir = "/Users/test/.config/yarn/global"; + // Mock confirmation that yarn is installed + spawn.sync.mockReturnValueOnce(defaultSyncResult); + // Mock stdout of `yarn global dir` + spawn.sync.mockReturnValueOnce({ + stdout: { + toString: () => `${yarnDir}\n` + } + }); + const globalPath = packageManager.getPathToGlobalPackages(); + const expected = path.join(yarnDir, "node_modules"); + expect(globalPath).toBe(expected); + }); + + it("should return the npm global dir from getPathToGlobalPackages if yarn is not installed", () => { + mockSpawnErrorOnce(); + const globalPath = packageManager.getPathToGlobalPackages(); + expect(globalPath).toBe(require("global-modules")); + }); }); diff --git a/lib/utils/resolve-packages.js b/lib/utils/resolve-packages.js index b937a4ee43b..7ed4fb2b582 100644 --- a/lib/utils/resolve-packages.js +++ b/lib/utils/resolve-packages.js @@ -2,10 +2,12 @@ const path = require("path"); const chalk = require("chalk"); -const globalPath = require("global-modules"); const creator = require("../init/index").creator; +const getPathToGlobalPackages = require("./package-manager") + .getPathToGlobalPackages; +const isLocalPath = require("./is-local-path"); const spawnChild = require("./package-manager").spawnChild; /** @@ -41,10 +43,36 @@ function resolvePackages(pkg) { let packageLocations = []; + function invokeGeneratorIfReady() { + if (packageLocations.length === pkg.length) + return creator(packageLocations); + } + pkg.forEach(addon => { + // Resolve paths to modules on local filesystem + if (isLocalPath(addon)) { + let absolutePath = addon; + + try { + absolutePath = path.resolve(process.cwd(), addon); + require.resolve(absolutePath); + packageLocations.push(absolutePath); + } catch (err) { + console.log(`Cannot find a generator at ${absolutePath}.`); + console.log("\nReason:\n"); + console.error(chalk.bold.red(err)); + process.exitCode = 1; + } + + invokeGeneratorIfReady(); + return; + } + + // Resolve modules on npm registry processPromise(spawnChild(addon)) .then(_ => { try { + const globalPath = getPathToGlobalPackages(); packageLocations.push(path.resolve(globalPath, addon)); } catch (err) { console.log("Package wasn't validated correctly.."); @@ -55,15 +83,12 @@ function resolvePackages(pkg) { } }) .catch(err => { - console.log("Package Couldn't be installed, aborting.."); + console.log("Package couldn't be installed, aborting.."); console.log("\nReason: \n"); console.error(chalk.bold.red(err)); process.exitCode = 1; }) - .then(_ => { - if (packageLocations.length === pkg.length) - return creator(packageLocations); - }); + .then(invokeGeneratorIfReady); }); }