From e7413d926b2dce0b30d70d95bf0e571e77008fde Mon Sep 17 00:00:00 2001
From: Domantas Petrauskas <dom.petrauskas@gmail.com>
Date: Wed, 4 May 2022 21:23:45 +0300
Subject: [PATCH] Implement package manager abstraction

---
 .github/workflows/ci.yml                      |   6 +-
 manual/src/ornate/reference.md                |  22 +-
 .../scalajsbundler/ExternalCommand.scala      |   4 +
 .../scala/scalajsbundler/PackageJson.scala    |   5 +-
 .../scala/scalajsbundler/PackageManager.scala | 252 ++++++++++++++++++
 .../sbtplugin/NpmUpdateTasks.scala            |  66 ++++-
 .../sbtplugin/PackageJsonTasks.scala          |  11 +-
 .../sbtplugin/ScalaJSBundlerPlugin.scala      |  42 ++-
 .../additonalNpmConfig/build.sbt              |   1 +
 .../sbt-scalajs-bundler/npm/build.sbt         |  11 +
 .../npm/project/plugins.sbt                   |   8 +
 .../npm/src/main/scala/example/Main.scala     |   7 +
 .../src/sbt-test/sbt-scalajs-bundler/npm/test |   2 +
 .../sbt-scalajs-bundler/pnpm/build.sbt        |  21 ++
 .../pnpm/project/plugins.sbt                  |   8 +
 .../pnpm/src/main/scala/example/Main.scala    |   7 +
 .../sbt-test/sbt-scalajs-bundler/pnpm/test    |   2 +
 .../sharedconfig/build.sbt                    |   2 +-
 .../sbt-scalajs-bundler/static/build.sbt      |   2 +-
 .../webpack-assets/build.sbt                  |   2 +-
 .../yarn-version/build.sbt                    |  25 ++
 .../yarn-version/project/plugins.sbt          |   8 +
 .../src/main/scala/example/Main.scala         |   7 +
 .../sbt-scalajs-bundler/yarn-version/test     |   3 +
 24 files changed, 495 insertions(+), 29 deletions(-)
 create mode 100644 sbt-scalajs-bundler/src/main/scala/scalajsbundler/PackageManager.scala
 create mode 100644 sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/npm/build.sbt
 create mode 100644 sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/npm/project/plugins.sbt
 create mode 100644 sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/npm/src/main/scala/example/Main.scala
 create mode 100644 sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/npm/test
 create mode 100644 sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/pnpm/build.sbt
 create mode 100644 sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/pnpm/project/plugins.sbt
 create mode 100644 sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/pnpm/src/main/scala/example/Main.scala
 create mode 100644 sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/pnpm/test
 create mode 100644 sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/yarn-version/build.sbt
 create mode 100644 sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/yarn-version/project/plugins.sbt
 create mode 100644 sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/yarn-version/src/main/scala/example/Main.scala
 create mode 100644 sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/yarn-version/test

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2f31bbf2..d38799dc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,8 +21,12 @@ jobs:
       - uses: actions/setup-node@v3
         with:
           node-version: 16.14.2
+      - name: Enable Corepack
+        run: corepack enable
       - name: Setup yarn
-        run: npm install -g yarn@1.22.15
+        run: npm install -g yarn@1.22.15 --force
+      - name: Setup pnpm
+        run: npm install -g pnpm@7.0.1 --force
       - name: Unit tests
         run: sbt test
       - name: Scripted tests
diff --git a/manual/src/ornate/reference.md b/manual/src/ornate/reference.md
index 12c8ff93..43101850 100644
--- a/manual/src/ornate/reference.md
+++ b/manual/src/ornate/reference.md
@@ -55,18 +55,24 @@ their execution so that they can be loaded by jsdom.
 You can find an example of project requiring the DOM for its tests
 [here](https://github.com/scalacenter/scalajs-bundler/blob/main/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/static/).
 
-### Yarn {#yarn}
+### Package managers
 
-By default, `npm` is used to fetch the dependencies but you can use [Yarn](https://yarnpkg.com/) by setting the
-`useYarn` key to `true`:
+By default, `npm` is used to fetch the dependencies, but you can use [Yarn](https://yarnpkg.com/) by setting the 
+`jsPackageManager` key to `Yarn()`:
 
-~~~ scala
-useYarn := true
-~~~
+``` scala
+jsPackageManager := Yarn()
+```
+
+If your sbt (sub-)project directory contains a lockfile (`package-lock.json` for `npm` or `yarn.lock` for `yarn`), it will be used. Else, a new one will be created. 
+You should check the lockfile into source control.
 
-If your sbt (sub-)project directory contains a `yarn.lock`, it will be used. Else, a new one will be created. You should check `yarn.lock` into source control.
+Package manager behavior can be customized by passing your own [PackageManager](api:scalajsbundler.PackageManager) to the key. 
+You can use it to modify commands and their arguments for `npm` or `yarn`, or to set up new package managers like 
+[pnpm](https://pnpm.io/) (see example [here](https://github.com/scalacenter/scalajs-bundler/blob/main/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/pnpm/)).
 
-Yarn 0.22.0+ must be available on your machine.
+Scalajs-bundler does not install your chosen package manager, it must be available on your machine. However, [Corepack](https://nodejs.org/api/corepack.html)
+is supported - setting `Yarn.withVersion(Some("1.22.19"))` will modify underlying `package.json` with field `"packageManager": "yarn@1.22.19"`. 
 
 ### Bundling Mode {#bundling-mode}
 
diff --git a/sbt-scalajs-bundler/src/main/scala/scalajsbundler/ExternalCommand.scala b/sbt-scalajs-bundler/src/main/scala/scalajsbundler/ExternalCommand.scala
index 885483c6..8f9ea6b5 100644
--- a/sbt-scalajs-bundler/src/main/scala/scalajsbundler/ExternalCommand.scala
+++ b/sbt-scalajs-bundler/src/main/scala/scalajsbundler/ExternalCommand.scala
@@ -10,6 +10,7 @@ import scalajsbundler.util.Commands
   *
   * @param name Name of the command to run
   */
+@deprecated("Use jsPackageManager instead.")
 class ExternalCommand(name: String) {
 
   /**
@@ -32,6 +33,7 @@ object Npm extends ExternalCommand("npm")
 
 object Yarn extends ExternalCommand("yarn")
 
+@deprecated("Use jsPackageManager instead.")
 object ExternalCommand {
   private val yarnOptions = List("--non-interactive", "--mutex", "network")
 
@@ -89,6 +91,7 @@ object ExternalCommand {
     * @param npmExtraArgs Additional arguments to pass to npm
     * @param npmPackages Packages to install (e.g. "webpack", "webpack@2.2.1")
     */
+  @deprecated("Use jsPackageManager instead.")
   def addPackages(baseDir: File,
                   installDir: File,
                   useYarn: Boolean,
@@ -107,6 +110,7 @@ object ExternalCommand {
       }
     }
 
+  @deprecated("Use jsPackageManager instead.")
   def install(baseDir: File,
               installDir: File,
               useYarn: Boolean,
diff --git a/sbt-scalajs-bundler/src/main/scala/scalajsbundler/PackageJson.scala b/sbt-scalajs-bundler/src/main/scala/scalajsbundler/PackageJson.scala
index d8b34ab9..4f9fa07f 100644
--- a/sbt-scalajs-bundler/src/main/scala/scalajsbundler/PackageJson.scala
+++ b/sbt-scalajs-bundler/src/main/scala/scalajsbundler/PackageJson.scala
@@ -31,7 +31,8 @@ object PackageJson {
     currentConfiguration: Configuration,
     webpackVersion: String,
     webpackDevServerVersion: String,
-    webpackCliVersion: String
+    webpackCliVersion: String,
+    packageManager: PackageManager
   ): Unit = {
     val npmManifestDependencies = NpmDependencies.collectFromClasspath(fullClasspath)
     val dependencies =
@@ -62,7 +63,7 @@ object PackageJson {
     val packageJson =
       JSON.obj(
         (
-          additionalNpmConfig.toSeq :+
+          (additionalNpmConfig.toSeq ++ packageManager.packageJsonContents.toSeq) :+
           "dependencies" -> JSON.objStr(resolveDependencies(dependencies, npmResolutions, log)) :+
           "devDependencies" -> JSON.objStr(resolveDependencies(devDependencies, npmResolutions, log))
         ): _*
diff --git a/sbt-scalajs-bundler/src/main/scala/scalajsbundler/PackageManager.scala b/sbt-scalajs-bundler/src/main/scala/scalajsbundler/PackageManager.scala
new file mode 100644
index 00000000..ea02aeb4
--- /dev/null
+++ b/sbt-scalajs-bundler/src/main/scala/scalajsbundler/PackageManager.scala
@@ -0,0 +1,252 @@
+package scalajsbundler
+
+import java.io.File
+
+import sbt._
+import scalajsbundler.util.Commands
+import scalajsbundler.util.JSON
+
+trait PackageManager {
+
+  val name: String
+
+  /**
+    * Runs the command `cmd`
+    * @param args Command arguments
+    * @param workingDir Working directory of the process
+    * @param logger Logger
+    */
+  def run(args: String*)(workingDir: File, logger: Logger): Unit
+
+  def install(baseDir: File, installDir: File, logger: Logger): Unit
+
+  val packageJsonContents: Map[String, JSON]
+}
+
+object PackageManager {
+
+  abstract class ExternalProcess(
+    val name: String,
+    val installCommand: String,
+    val installArgs: Seq[String]
+  ) extends PackageManager {
+
+    def run(args: String*)(workingDir: File, logger: Logger): Unit =
+      Commands.run(cmd ++: args, workingDir, logger)
+
+    private val cmd = sys.props("os.name").toLowerCase match {
+      case os if os.contains("win") => Seq("cmd", "/c", name)
+      case _                        => Seq(name)
+    }
+
+    def install(baseDir: File, installDir: File, logger: Logger): Unit = {
+      this match {
+        case lfs: LockFileSupport =>
+          lfs.lockFileRead(baseDir, installDir, logger)
+        case _ =>
+          ()
+      }
+
+      run(installCommand +: installArgs: _*)(installDir, logger)
+
+      this match {
+        case lfs: LockFileSupport =>
+          lfs.lockFileWrite(baseDir, installDir, logger)
+        case _ =>
+          ()
+      }
+    }
+  }
+
+  trait AddPackagesSupport { this: PackageManager =>
+
+    val addPackagesCommand: String
+    val addPackagesArgs: Seq[String]
+
+    /**
+      * Locally install NPM packages
+      *
+      * @param baseDir The (sub-)project directory which contains yarn.lock
+      * @param installDir The directory in which to install the packages
+      * @param logger sbt logger
+      * @param npmPackages Packages to install (e.g. "webpack", "webpack@2.2.1")
+      */
+    def addPackages(baseDir: File,
+                    installDir: File,
+                    logger: Logger,
+                   )(npmPackages: String*): Unit = {
+      this match {
+        case lfs: LockFileSupport  =>
+          lfs.lockFileRead(baseDir, installDir, logger)
+        case _ =>
+          ()
+      }
+
+      run(addPackagesCommand +: (addPackagesArgs ++ npmPackages): _*)(installDir, logger)
+
+      this match {
+        case lfs: LockFileSupport  =>
+          lfs.lockFileWrite(baseDir, installDir, logger)
+        case _ =>
+          ()
+      }
+    }
+  }
+
+  trait LockFileSupport {
+    val lockFileName: String
+
+    def lockFileRead(
+                      baseDir: File,
+                      installDir: File,
+                      logger: Logger
+                    ): Unit = {
+      val sourceLockFile = baseDir / lockFileName
+      val targetLockFile = installDir / lockFileName
+
+      if (sourceLockFile.exists()) {
+        logger.info("Using lockfile " + sourceLockFile)
+        IO.copyFile(sourceLockFile, targetLockFile)
+      }
+    }
+
+    def lockFileWrite(
+                       baseDir: File,
+                       installDir: File,
+                       logger: Logger
+                     ): Unit = {
+      val sourceLockFile = baseDir / lockFileName
+      val targetLockFile = installDir / lockFileName
+
+      if (targetLockFile.exists()) {
+        logger.debug("Wrote lockfile to " + sourceLockFile)
+        IO.copyFile(targetLockFile, sourceLockFile)
+      }
+    }
+  }
+
+  final class Npm private (
+    override val name: String,
+    val lockFileName: String,
+    override val installCommand: String,
+    override val installArgs: Seq[String],
+    val addPackagesCommand: String,
+    val addPackagesArgs: Seq[String],
+  ) extends ExternalProcess(name, installCommand, installArgs)
+    with LockFileSupport
+    with AddPackagesSupport {
+
+    override val packageJsonContents: Map[String, JSON] = Map.empty
+
+    private def this() = {
+      this(
+        name = "npm",
+        lockFileName = "package-lock.json",
+        installCommand = "install",
+        installArgs = Seq.empty,
+        addPackagesCommand = "install",
+        addPackagesArgs = Seq.empty
+      )
+    }
+
+    def withName(name: String): Npm = copy(name = name)
+
+    def withLockFileName(lockFileName: String): Npm = copy(lockFileName = lockFileName)
+
+    def withInstallCommand(installCommand: String): Npm = copy(installCommand = installCommand)
+
+    def withInstallArgs(installArgs: Seq[String]): Npm = copy(installArgs = installArgs)
+
+    def withAddPackagesCommand(addPackagesCommand: String): Npm = copy(addPackagesCommand = addPackagesCommand)
+
+    def withAddPackagesArgs(addPackagesArgs: Seq[String]): Npm = copy(addPackagesArgs = addPackagesArgs)
+
+    private def copy(
+      name: String = name,
+      lockFileName: String = lockFileName,
+      installCommand: String = installCommand,
+      installArgs: Seq[String] = installArgs,
+      addPackagesCommand: String = addPackagesCommand,
+      addPackagesArgs: Seq[String] = addPackagesArgs
+    ) = {
+      new Npm(
+        name,
+        lockFileName,
+        installCommand,
+        installArgs,
+        addPackagesCommand,
+        addPackagesArgs
+      )
+    }
+  }
+  object Npm {
+    def apply() = new Npm()
+  }
+
+  final class Yarn private (
+    override val name: String,
+    val version: Option[String],
+    val lockFileName: String,
+    override val installCommand: String,
+    override val installArgs: Seq[String],
+    val addPackagesCommand: String,
+    val addPackagesArgs: Seq[String],
+  ) extends ExternalProcess(name, installCommand, installArgs)
+    with LockFileSupport
+    with AddPackagesSupport {
+
+    override val packageJsonContents: Map[String, JSON] =
+      version.map(v => Map("packageManager" -> JSON.str(s"$name@$v"))).getOrElse(Map.empty)
+
+    private def this() = {
+      this(
+        name = "yarn",
+        version = None,
+        lockFileName = "yarn.lock",
+        installCommand = "install",
+        installArgs = Seq.empty,
+        addPackagesCommand = "add",
+        addPackagesArgs = Seq.empty
+      )
+    }
+
+    def withName(name: String): Yarn = copy(name = name)
+
+    def withVersion(version: Option[String]): Yarn = copy(version = version)
+
+    def withLockFileName(lockFileName: String): Yarn = copy(lockFileName = lockFileName)
+
+    def withInstallCommand(installCommand: String): Yarn = copy(installCommand = installCommand)
+
+    def withInstallArgs(installArgs: Seq[String]): Yarn = copy(installArgs = installArgs)
+
+    def withAddPackagesCommand(addPackagesCommand: String): Yarn = copy(addPackagesCommand = addPackagesCommand)
+
+    def withAddPackagesArgs(addPackagesArgs: Seq[String]): Yarn = copy(addPackagesArgs = addPackagesArgs)
+
+    private def copy(
+      name: String = name,
+      version: Option[String] = version,
+      lockFileName: String = lockFileName,
+      installCommand: String = installCommand,
+      installArgs: Seq[String] = installArgs,
+      addPackagesCommand: String = addPackagesCommand,
+      addPackagesArgs: Seq[String] = addPackagesArgs
+    ) = {
+      new Yarn(
+        name,
+        version,
+        lockFileName,
+        installCommand,
+        installArgs,
+        addPackagesCommand,
+        addPackagesArgs
+      )
+    }
+  }
+  object Yarn {
+    val DefaultArgs: Seq[String] = Seq("--non-interactive", "--mutex", "network")
+
+    def apply() = new Yarn()
+  }
+}
diff --git a/sbt-scalajs-bundler/src/main/scala/scalajsbundler/sbtplugin/NpmUpdateTasks.scala b/sbt-scalajs-bundler/src/main/scala/scalajsbundler/sbtplugin/NpmUpdateTasks.scala
index cb4d5612..b6a0d7f6 100644
--- a/sbt-scalajs-bundler/src/main/scala/scalajsbundler/sbtplugin/NpmUpdateTasks.scala
+++ b/sbt-scalajs-bundler/src/main/scala/scalajsbundler/sbtplugin/NpmUpdateTasks.scala
@@ -1,8 +1,9 @@
 package scalajsbundler.sbtplugin
 
 import java.nio.file.Path
-import scalajsbundler.ExternalCommand
+
 import sbt._
+import scalajsbundler.PackageManager
 
 object NpmUpdateTasks {
 
@@ -18,6 +19,7 @@ object NpmUpdateTasks {
     * @param yarnExtraArgs Additional arguments to pass to yarn
     * @return The written npm directory
     */
+  @deprecated("Use overload with packageManager instead.")
   def npmUpdate(baseDir: File,
                 targetDir: File,
                 packageJsonFile: File,
@@ -26,7 +28,37 @@ object NpmUpdateTasks {
                 streams: Keys.TaskStreams,
                 npmExtraArgs: Seq[String],
                 yarnExtraArgs: Seq[String]): File = {
-    val dir = npmInstallDependencies(baseDir, targetDir, packageJsonFile, useYarn, streams, npmExtraArgs, yarnExtraArgs)
+    npmUpdate(
+      baseDir,
+      targetDir,
+      packageJsonFile,
+      jsResources,
+      streams,
+      if (useYarn){
+        PackageManager.Yarn().withInstallArgs(yarnExtraArgs).withAddPackagesArgs(yarnExtraArgs)
+      } else {
+        PackageManager.Npm().withInstallArgs(npmExtraArgs).withAddPackagesArgs(npmExtraArgs)
+      }
+    )
+  }
+
+  /**
+    * Uses package manager to install JavaScript resources as node packages.
+    *
+    * @param targetDir npm directory
+    * @param packageJsonFile Json file containing NPM dependencies
+    * @param jsResources A sequence of JavaScript resources
+    * @param streams A sbt TaskStream
+    * @param packageManager package manager
+    * @return The written npm directory
+    */
+  def npmUpdate(baseDir: File,
+                targetDir: File,
+                packageJsonFile: File,
+                jsResources: Seq[(String, Path)],
+                streams: Keys.TaskStreams,
+                packageManager: PackageManager): File = {
+    val dir = npmInstallDependencies(baseDir, targetDir, packageJsonFile, streams, packageManager)
     npmInstallJSResources(targetDir, jsResources, Seq.empty, streams)
     dir
   }
@@ -42,6 +74,7 @@ object NpmUpdateTasks {
     * @param yarnExtraArgs Additional arguments to pass to yarn
     * @return The written npm directory
     */
+  @deprecated("Use overload with packageManager instead.")
   def npmInstallDependencies(baseDir: File,
                              targetDir: File,
                              packageJsonFile: File,
@@ -49,6 +82,33 @@ object NpmUpdateTasks {
                              streams: Keys.TaskStreams,
                              npmExtraArgs: Seq[String],
                              yarnExtraArgs: Seq[String]): File = {
+    npmInstallDependencies(
+      baseDir,
+      targetDir,
+      packageJsonFile,
+      streams,
+      if (useYarn){
+        PackageManager.Yarn().withInstallArgs(yarnExtraArgs).withAddPackagesArgs(yarnExtraArgs)
+      } else {
+        PackageManager.Npm().withInstallArgs(npmExtraArgs).withAddPackagesArgs(npmExtraArgs)
+      }
+    )
+  }
+
+  /**
+    * Runs install command of package manager.
+    *
+    * @param targetDir npm directory
+    * @param packageJsonFile Json file containing NPM dependencies
+    * @param streams A sbt TaskStream
+    * @param packageManager package manager
+    * @return The written npm directory
+    */
+  def npmInstallDependencies(baseDir: File,
+                             targetDir: File,
+                             packageJsonFile: File,
+                             streams: Keys.TaskStreams,
+                             packageManager: PackageManager): File = {
     val log = streams.log
     val cachedActionFunction =
       FileFunction.cached(
@@ -56,7 +116,7 @@ object NpmUpdateTasks {
         inStyle = FilesInfo.hash
       ) { _ =>
         log.info("Updating NPM dependencies")
-        ExternalCommand.install(baseDir, targetDir, useYarn, log, npmExtraArgs, yarnExtraArgs)
+        packageManager.install(baseDir, targetDir, log)
         Set.empty
       }
     cachedActionFunction(Set(packageJsonFile))
diff --git a/sbt-scalajs-bundler/src/main/scala/scalajsbundler/sbtplugin/PackageJsonTasks.scala b/sbt-scalajs-bundler/src/main/scala/scalajsbundler/sbtplugin/PackageJsonTasks.scala
index e9754efd..dd7650a8 100644
--- a/sbt-scalajs-bundler/src/main/scala/scalajsbundler/sbtplugin/PackageJsonTasks.scala
+++ b/sbt-scalajs-bundler/src/main/scala/scalajsbundler/sbtplugin/PackageJsonTasks.scala
@@ -1,7 +1,7 @@
 package scalajsbundler.sbtplugin
 
 import sbt._
-
+import scalajsbundler.PackageManager
 import scalajsbundler.{BundlerFile, PackageJson}
 import scalajsbundler.util.{Caching, JSON}
 
@@ -31,7 +31,8 @@ object PackageJsonTasks {
     webpackVersion: String,
     webpackDevServerVersion: String,
     webpackCliVersion: String,
-    streams: Keys.TaskStreams
+    streams: Keys.TaskStreams,
+    packageManager: PackageManager
   ): BundlerFile.PackageJson = {
 
     val hash = Seq(
@@ -42,7 +43,8 @@ object PackageJsonTasks {
       fullClasspath.map(_.data.name).toString,
       webpackVersion,
       webpackDevServerVersion,
-      webpackCliVersion
+      webpackCliVersion,
+      packageManager.toString
     ).mkString(",")
 
     val packageJsonFile = targetDir / "package.json"
@@ -63,7 +65,8 @@ object PackageJsonTasks {
         configuration,
         webpackVersion,
         webpackDevServerVersion,
-        webpackCliVersion
+        webpackCliVersion,
+        packageManager
       )
       ()
     }
diff --git a/sbt-scalajs-bundler/src/main/scala/scalajsbundler/sbtplugin/ScalaJSBundlerPlugin.scala b/sbt-scalajs-bundler/src/main/scala/scalajsbundler/sbtplugin/ScalaJSBundlerPlugin.scala
index fdda11e5..8d29521c 100644
--- a/sbt-scalajs-bundler/src/main/scala/scalajsbundler/sbtplugin/ScalaJSBundlerPlugin.scala
+++ b/sbt-scalajs-bundler/src/main/scala/scalajsbundler/sbtplugin/ScalaJSBundlerPlugin.scala
@@ -4,9 +4,9 @@ import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport._
 import org.scalajs.sbtplugin.{ScalaJSPlugin, Stage}
 import sbt.Keys._
 import sbt.{Def, _}
+import scalajsbundler.PackageManager.AddPackagesSupport
 import scalajsbundler.{BundlerFile, NpmDependencies, Webpack, WebpackDevServer}
-
-import scalajsbundler.ExternalCommand.addPackages
+import scalajsbundler.PackageManager
 import scalajsbundler.util.{JSON, ScalaJSNativeLibraries}
 
 
@@ -212,6 +212,7 @@ object ScalaJSBundlerPlugin extends AutoPlugin {
       *
       * @group settings
       */
+    @deprecated("Use jsPackageManager instead.")
     val npmExtraArgs = SettingKey[Seq[String]](
       "npmExtraArgs",
       "Custom arguments for npm"
@@ -405,9 +406,22 @@ object ScalaJSBundlerPlugin extends AutoPlugin {
       *
       * @group settings
       */
+    @deprecated("Use jsPackageManager instead.")
     val useYarn: SettingKey[Boolean] =
       settingKey[Boolean]("Whether to use yarn for updates")
 
+    /**
+      * Sets package manager to be used for installation of npm dependencies.
+      *
+      * Defaults to `Npm`.
+      *
+      * @group settings
+      */
+    val jsPackageManager = SettingKey[PackageManager](
+      "packageManager",
+      "Package manager which will be used for fetching dependencies. Constructor also allows definition of extra arguments."
+    )
+
     /**
       * Port, on which webpack-dev-server will be launched.
       *
@@ -428,6 +442,7 @@ object ScalaJSBundlerPlugin extends AutoPlugin {
       *
       * @group settings
       */
+    @deprecated("Use jsPackageManager instead.")
     val yarnExtraArgs = SettingKey[Seq[String]](
       "yarnExtraArgs",
       "Custom arguments for yarn"
@@ -570,6 +585,14 @@ object ScalaJSBundlerPlugin extends AutoPlugin {
 
     useYarn := false,
 
+    jsPackageManager := (
+      if (useYarn.value){
+        PackageManager.Yarn().withInstallArgs(yarnExtraArgs.value).withAddPackagesArgs(yarnExtraArgs.value)
+      } else {
+        PackageManager.Npm().withInstallArgs(npmExtraArgs.value).withAddPackagesArgs(npmExtraArgs.value)
+      }
+    ),
+
     ensureModuleKindIsCommonJSModule := {
       if (scalaJSLinkerConfig.value.moduleKind == ModuleKind.CommonJSModule) true
       else sys.error(s"scalaJSModuleKind must be set to ModuleKind.CommonJSModule in projects where ScalaJSBundler plugin is enabled")
@@ -612,7 +635,12 @@ object ScalaJSBundlerPlugin extends AutoPlugin {
       if (!jsdomDir.exists()) {
         log.info(s"Installing jsdom in ${installDir.absolutePath}")
         IO.createDirectory(installDir)
-        addPackages(baseDir, installDir, useYarn.value, log, npmExtraArgs.value, yarnExtraArgs.value)(s"jsdom@$jsdomVersion")
+        jsPackageManager.value match {
+          case aps: AddPackagesSupport =>
+            aps.addPackages(baseDir, installDir, log)(s"jsdom@$jsdomVersion")
+          case unsupported =>
+            throw new RuntimeException(s"Package manager (${unsupported.name}) used by this module does not support adding of packages")
+        }
       }
       installDir
     }
@@ -644,10 +672,7 @@ object ScalaJSBundlerPlugin extends AutoPlugin {
         baseDirectory.value,
         (crossTarget in npmUpdate).value,
         scalaJSBundlerPackageJson.value.file,
-        useYarn.value,
-        streams.value,
-        npmExtraArgs.value,
-        yarnExtraArgs.value),
+        streams.value, jsPackageManager.value),
 
       npmInstallJSResources := NpmUpdateTasks.npmInstallJSResources(
         (crossTarget in npmUpdate).value,
@@ -667,7 +692,8 @@ object ScalaJSBundlerPlugin extends AutoPlugin {
           (version in webpack).value,
           (version in startWebpackDevServer).value,
           webpackCliVersion.value,
-          streams.value
+          streams.value,
+          jsPackageManager.value
         ),
 
 
diff --git a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/additonalNpmConfig/build.sbt b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/additonalNpmConfig/build.sbt
index d0929914..066c7f25 100644
--- a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/additonalNpmConfig/build.sbt
+++ b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/additonalNpmConfig/build.sbt
@@ -1,4 +1,5 @@
 import scalajsbundler.util.JSON._
+import scalajsbundler.Npm
 
 val checkPackageJson = taskKey[Unit]("Check that the package.json file does not contain duplicate entries for the 'react' dependency")
 
diff --git a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/npm/build.sbt b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/npm/build.sbt
new file mode 100644
index 00000000..a0507447
--- /dev/null
+++ b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/npm/build.sbt
@@ -0,0 +1,11 @@
+scalaVersion := "2.12.8"
+
+jsPackageManager := scalajsbundler.PackageManager.Npm()
+
+scalaJSUseMainModuleInitializer := true
+
+npmDependencies in Compile += "neat" -> "2.1.0"
+
+enablePlugins(ScalaJSBundlerPlugin)
+
+ivyLoggingLevel in ThisBuild := UpdateLogging.Quiet
diff --git a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/npm/project/plugins.sbt b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/npm/project/plugins.sbt
new file mode 100644
index 00000000..77ac06f1
--- /dev/null
+++ b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/npm/project/plugins.sbt
@@ -0,0 +1,8 @@
+val scalaJSVersion = sys.props.getOrElse("scalajs.version", sys.error("'scalajs.version' environment variable is not defined"))
+val scalaJSBundlerVersion = sys.props.getOrElse("plugin.version", sys.error("'plugin.version' environment variable is not set"))
+
+addSbtPlugin("org.scala-js" % "sbt-scalajs" % scalaJSVersion)
+
+addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % scalaJSBundlerVersion)
+
+ivyLoggingLevel in ThisBuild := UpdateLogging.Quiet
diff --git a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/npm/src/main/scala/example/Main.scala b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/npm/src/main/scala/example/Main.scala
new file mode 100644
index 00000000..5e8ae3ed
--- /dev/null
+++ b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/npm/src/main/scala/example/Main.scala
@@ -0,0 +1,7 @@
+package example
+
+object Main {
+  def main(args: Array[String]): Unit = {
+    println("npm main")
+  }
+}
diff --git a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/npm/test b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/npm/test
new file mode 100644
index 00000000..9c6c1a39
--- /dev/null
+++ b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/npm/test
@@ -0,0 +1,2 @@
+> fastOptJS::webpack
+$ exists package-lock.json
diff --git a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/pnpm/build.sbt b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/pnpm/build.sbt
new file mode 100644
index 00000000..451d1799
--- /dev/null
+++ b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/pnpm/build.sbt
@@ -0,0 +1,21 @@
+import scalajsbundler.PackageManager
+import scalajsbundler.util.JSON
+
+scalaVersion := "2.12.8"
+
+jsPackageManager := new PackageManager.ExternalProcess("pnpm", "install", Seq.empty)
+  with PackageManager.LockFileSupport
+  with PackageManager.AddPackagesSupport {
+    val lockFileName: String = "pnpm-lock.yaml"
+    val addPackagesCommand: String = "add"
+    val addPackagesArgs: Seq[String] = Seq.empty
+    val packageJsonContents: Map[String, JSON] = Map.empty
+  }
+
+scalaJSUseMainModuleInitializer := true
+
+npmDependencies in Compile += "neat" -> "2.1.0"
+
+enablePlugins(ScalaJSBundlerPlugin)
+
+ivyLoggingLevel in ThisBuild := UpdateLogging.Quiet
diff --git a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/pnpm/project/plugins.sbt b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/pnpm/project/plugins.sbt
new file mode 100644
index 00000000..77ac06f1
--- /dev/null
+++ b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/pnpm/project/plugins.sbt
@@ -0,0 +1,8 @@
+val scalaJSVersion = sys.props.getOrElse("scalajs.version", sys.error("'scalajs.version' environment variable is not defined"))
+val scalaJSBundlerVersion = sys.props.getOrElse("plugin.version", sys.error("'plugin.version' environment variable is not set"))
+
+addSbtPlugin("org.scala-js" % "sbt-scalajs" % scalaJSVersion)
+
+addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % scalaJSBundlerVersion)
+
+ivyLoggingLevel in ThisBuild := UpdateLogging.Quiet
diff --git a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/pnpm/src/main/scala/example/Main.scala b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/pnpm/src/main/scala/example/Main.scala
new file mode 100644
index 00000000..6aab86d7
--- /dev/null
+++ b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/pnpm/src/main/scala/example/Main.scala
@@ -0,0 +1,7 @@
+package example
+
+object Main {
+  def main(args: Array[String]): Unit = {
+    println("pnpm main")
+  }
+}
diff --git a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/pnpm/test b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/pnpm/test
new file mode 100644
index 00000000..6a170d44
--- /dev/null
+++ b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/pnpm/test
@@ -0,0 +1,2 @@
+> fastOptJS::webpack
+$ exists pnpm-lock.yaml
\ No newline at end of file
diff --git a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/sharedconfig/build.sbt b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/sharedconfig/build.sbt
index d4d2b773..c0b2d46b 100644
--- a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/sharedconfig/build.sbt
+++ b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/sharedconfig/build.sbt
@@ -33,7 +33,7 @@ requireJsDomEnv in Test := true
 
 webpackBundlingMode in fastOptJS := BundlingMode.LibraryAndApplication()
 
-useYarn := true
+jsPackageManager := scalajsbundler.PackageManager.Yarn()
 
 // HtmlUnit does not support ECMAScript 2015
 scalaJSLinkerConfig ~= { _.withESFeatures(_.withUseECMAScript2015(false)) }
diff --git a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/static/build.sbt b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/static/build.sbt
index 5498802e..478e4025 100644
--- a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/static/build.sbt
+++ b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/static/build.sbt
@@ -17,7 +17,7 @@ version in installJsdom := "16.4.0"
 
 webpackBundlingMode := BundlingMode.LibraryAndApplication()
 
-useYarn := true
+jsPackageManager := scalajsbundler.PackageManager.Yarn()
 
 // HtmlUnit does not support ECMAScript 2015
 scalaJSLinkerConfig ~= { _.withESFeatures(_.withUseECMAScript2015(false)) }
diff --git a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/webpack-assets/build.sbt b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/webpack-assets/build.sbt
index 3d57c472..c3cbbb6c 100644
--- a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/webpack-assets/build.sbt
+++ b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/webpack-assets/build.sbt
@@ -31,7 +31,7 @@ npmDevDependencies in Compile += "mini-css-extract-plugin" -> "1.3.4"
 
 webpackDevServerPort := 7357
 
-useYarn := true
+jsPackageManager := scalajsbundler.PackageManager.Yarn()
 
 // HtmlUnit does not support ECMAScript 2015
 scalaJSLinkerConfig ~= { _.withESFeatures(_.withUseECMAScript2015(false)) }
diff --git a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/yarn-version/build.sbt b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/yarn-version/build.sbt
new file mode 100644
index 00000000..50add51f
--- /dev/null
+++ b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/yarn-version/build.sbt
@@ -0,0 +1,25 @@
+scalaVersion := "2.12.8"
+
+lazy val yarnVersion = "1.22.16"
+
+jsPackageManager := scalajsbundler.PackageManager.Yarn().withVersion(Some(yarnVersion))
+
+scalaJSUseMainModuleInitializer := true
+
+npmDependencies in Compile += "neat" -> "2.1.0"
+
+enablePlugins(ScalaJSBundlerPlugin)
+
+ivyLoggingLevel in ThisBuild := UpdateLogging.Quiet
+
+TaskKey[Unit]("check-yarn-version") := {
+  import scala.sys.process.Process
+  val cmd = sys.props("os.name").toLowerCase match {
+    case os if os.contains("win") => "powershell yarn"
+    case _                        => "yarn"
+  }
+  val process = Process(s"$cmd -v", new File("target/scala-2.12/scalajs-bundler/main"))
+  val out = (process!!)
+  if(out.trim != yarnVersion) sys.error(s"unexpected yarn version: ${out.trim}")
+  ()
+}
diff --git a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/yarn-version/project/plugins.sbt b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/yarn-version/project/plugins.sbt
new file mode 100644
index 00000000..77ac06f1
--- /dev/null
+++ b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/yarn-version/project/plugins.sbt
@@ -0,0 +1,8 @@
+val scalaJSVersion = sys.props.getOrElse("scalajs.version", sys.error("'scalajs.version' environment variable is not defined"))
+val scalaJSBundlerVersion = sys.props.getOrElse("plugin.version", sys.error("'plugin.version' environment variable is not set"))
+
+addSbtPlugin("org.scala-js" % "sbt-scalajs" % scalaJSVersion)
+
+addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % scalaJSBundlerVersion)
+
+ivyLoggingLevel in ThisBuild := UpdateLogging.Quiet
diff --git a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/yarn-version/src/main/scala/example/Main.scala b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/yarn-version/src/main/scala/example/Main.scala
new file mode 100644
index 00000000..41033196
--- /dev/null
+++ b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/yarn-version/src/main/scala/example/Main.scala
@@ -0,0 +1,7 @@
+package example
+
+object Main {
+  def main(args: Array[String]): Unit = {
+    println("yarn-version main")
+  }
+}
diff --git a/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/yarn-version/test b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/yarn-version/test
new file mode 100644
index 00000000..272ac667
--- /dev/null
+++ b/sbt-scalajs-bundler/src/sbt-test/sbt-scalajs-bundler/yarn-version/test
@@ -0,0 +1,3 @@
+> fastOptJS::webpack
+> checkYarnVersion
+$ exists yarn.lock