Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Package manager abstraction #420

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: corepack prepare yarn@1.22.15 --activate
- name: Setup pnpm
run: corepack prepare pnpm@7.0.1 --activate
Comment on lines +24 to +29
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Managing package manager installations inside and outside Corepack do not mix well, so we got to do it with Corepack only.

- name: Unit tests
run: sbt test
- name: Scripted tests
Expand Down
22 changes: 14 additions & 8 deletions manual/src/ornate/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import scalajsbundler.util.Commands
*
* @param name Name of the command to run
*/
@deprecated("Use jsPackageManager instead.")
class ExternalCommand(name: String) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should keep all of this as @deprecated, for compatibility reasons.


/**
Expand All @@ -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")

Expand Down Expand Up @@ -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,
Expand All @@ -107,6 +110,7 @@ object ExternalCommand {
}
}

@deprecated("Use jsPackageManager instead.")
def install(baseDir: File,
installDir: File,
useYarn: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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))
): _*
Expand Down
252 changes: 252 additions & 0 deletions sbt-scalajs-bundler/src/main/scala/scalajsbundler/PackageManager.scala
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to convert this to a File to allow for better lockfile location customization.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though I do not have the best idea on where to put PackageManager.Npm and PackageManager.Yarn so that baseDirectory is available during construction.


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()
}
}
Loading