Skip to content

Commit 6bcaf8b

Browse files
authored
Merge pull request #92 from mkurz/npm-use-integrated
JS engines that support local installed node should prefer local installed npm
2 parents 732bef3 + 9f38033 commit 6bcaf8b

File tree

5 files changed

+123
-33
lines changed

5 files changed

+123
-33
lines changed

build.sbt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ libraryDependencies ++= Seq(
2323
"io.apigee.trireme" % "trireme-node10src" % "0.9.4",
2424

2525
// NPM
26-
"org.webjars" % "npm" % "5.0.0-2",
26+
"org.webjars" % "npm" % "5.0.0-2", // we are currently stuck: https://github.com/webjars/webjars/issues/1926
2727
"org.webjars" % "webjars-locator-core" % "0.52",
2828

2929
// Test deps

src/main/scala/com/typesafe/sbt/jse/SbtJsEngine.scala

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,19 @@ object JsEngineImport {
2727
AutoDetect = Value
2828
}
2929

30+
object NpmSubcommand extends Enumeration {
31+
val Install, Update, Ci = Value
32+
}
33+
3034
val command = SettingKey[Option[File]]("jse-command", "An optional path to the command used to invoke the engine.")
3135
val engineType = SettingKey[EngineType.Value]("jse-engine-type", "The type of engine to use.")
3236
@deprecated("No longer used", "1.3.0")
3337
val parallelism = SettingKey[Int]("jse-parallelism", "The number of parallel tasks for the JavaScript engine. Defaults to the # of available processors + 1 to keep things busy.")
3438
@deprecated("No longer used", "1.3.0")
3539
val npmTimeout = SettingKey[FiniteDuration]("jse-npm-timeout", "The maximum number amount of time for npm to do its thing.")
3640
val npmNodeModules = TaskKey[Seq[File]]("jse-npm-node-modules", "Node module files generated by NPM.")
41+
val npmPreferSystemInstalledNpm = SettingKey[Boolean]("jse-npm-prefer-system-installed-npm","Prefer detecting and using locally installed NPM when using a local engine that provides Node support")
42+
val npmSubcommand = SettingKey[NpmSubcommand.Value]("jse-npm-subcommand", "The subcommand to use in NPM: install, update or ci")
3743
}
3844

3945
}
@@ -80,7 +86,9 @@ object SbtJsEngine extends AutoPlugin {
8086
private lazy val autoDetectNode: Boolean = {
8187
val nodeExists = Try(Process("node --version").!!).isSuccess
8288
if (!nodeExists) {
89+
println("!!!")
8390
println("Warning: node.js detection failed, sbt will use the Rhino based Trireme JavaScript engine instead to run JavaScript assets compilation, which in some cases may be orders of magnitude slower than using node.js.")
91+
println("!!!")
8492
}
8593
nodeExists
8694
}
@@ -96,11 +104,17 @@ object SbtJsEngine extends AutoPlugin {
96104
val nodeModulesDirectory = (Plugin / webJarsNodeModulesDirectory).value
97105
val logger = streams.value.log
98106
val baseDir = baseDirectory.value
107+
val preferSystemInstalledNpm = npmPreferSystemInstalledNpm.value
108+
val subcommand = npmSubcommand.value
99109

100110
val runUpdate = FileFunction.cached(cacheDirectory, FilesInfo.hash) { _ =>
101111
if (npmPackageJson.exists) {
102-
val npm = new Npm(engine, nodeModulesDirectory / "npm" / "lib" / "npm.js")
103-
val result = npm.update(global = false, Seq("--prefix", baseDir.getPath), logger.info(_), logger.error(_))
112+
val npm = new Npm(engine, Some(nodeModulesDirectory / "npm" / "lib" / "npm.js"), preferSystemNpm = preferSystemInstalledNpm)
113+
val result = subcommand match {
114+
case NpmSubcommand.Install => npm.install(global = false, Seq("--prefix", baseDir.getPath), logger.info(_), logger.error(_))
115+
case NpmSubcommand.Update => npm.update(global = false, Seq("--prefix", baseDir.getPath), logger.info(_), logger.error(_))
116+
case NpmSubcommand.Ci => npm.ci(Seq("--prefix", baseDir.getPath), logger.info(_), logger.error(_))
117+
}
104118
if (result.exitValue != 0) {
105119
sys.error("Problems with NPM resolution. Aborting build.")
106120
}
@@ -125,7 +139,9 @@ object SbtJsEngine extends AutoPlugin {
125139
println(s"Unknown engine type $engineTypeStr for sbt.jse.engineType. Resorting back to the default of $defaultEngineType.")
126140
defaultEngineType
127141
}),
128-
command := sys.props.get("sbt.jse.command").map(file)
142+
command := sys.props.get("sbt.jse.command").map(file),
143+
npmPreferSystemInstalledNpm := true,
144+
npmSubcommand := NpmSubcommand.Update,
129145

130146
) ++ inConfig(Assets)(jsEngineUnscopedSettings) ++ inConfig(TestAssets)(jsEngineUnscopedSettings)
131147

src/main/scala/com/typesafe/sbt/jse/engines/LocalEngine.scala

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,7 @@ import scala.collection.JavaConverters._
66
import scala.collection.immutable
77
import scala.sys.process.{Process, ProcessLogger}
88

9-
class LocalEngine(stdArgs: immutable.Seq[String], stdEnvironment: Map[String, String], override val isNode: Boolean) extends Engine {
10-
11-
// This quoting functionality is as recommended per http://bugs.java.com/view_bug.do?bug_id=6511002
12-
// The JDK can't change due to its backward compatibility requirements, but we have no such constraint
13-
// here. Args should be able to be expressed consistently by the user of our API no matter whether
14-
// execution is on Windows or not.
15-
private def needsQuoting(s: String): Boolean =
16-
if (s.isEmpty) true else s.exists(c => c == ' ' || c == '\t' || c == '\\' || c == '"')
17-
18-
private def winQuote(s: String): String = {
19-
if (!needsQuoting(s)) {
20-
s
21-
} else {
22-
"\"" + s.replaceAll("([\\\\]*)\"", "$1$1\\\\\"").replaceAll("([\\\\]*)\\z", "$1$1") + "\""
23-
}
24-
}
25-
26-
private val isWindows: Boolean = System.getProperty("os.name").toLowerCase.contains("win")
27-
28-
private def prepareArgs(args: immutable.Seq[String]): immutable.Seq[String] =
29-
if (isWindows) args.map(winQuote) else args
9+
class LocalEngine(val stdArgs: immutable.Seq[String], val stdEnvironment: Map[String, String], override val isNode: Boolean) extends Engine {
3010

3111
override def executeJs(source: File, args: immutable.Seq[String], environment: Map[String, String],
3212
stdOutSink: String => Unit, stdErrSink: String => Unit): JsExecutionResult = {
@@ -35,7 +15,7 @@ class LocalEngine(stdArgs: immutable.Seq[String], stdEnvironment: Map[String, St
3515
val allEnvironment = stdEnvironment ++ environment
3616

3717

38-
val pb = new ProcessBuilder(prepareArgs(allArgs).asJava)
18+
val pb = new ProcessBuilder(LocalEngine.prepareArgs(allArgs).asJava)
3919
pb.environment().putAll(allEnvironment.asJava)
4020
JsExecutionResult(Process(pb).!(ProcessLogger(stdOutSink, stdErrSink)))
4121
}
@@ -56,6 +36,26 @@ object LocalEngine {
5636
val newNodePath = Option(System.getenv("NODE_PATH")).fold(nodePath)(_ + nodePathDelim + nodePath)
5737
if (newNodePath.isEmpty) Map.empty[String, String] else Map("NODE_PATH" -> newNodePath)
5838
}
39+
40+
// This quoting functionality is as recommended per http://bugs.java.com/view_bug.do?bug_id=6511002
41+
// The JDK can't change due to its backward compatibility requirements, but we have no such constraint
42+
// here. Args should be able to be expressed consistently by the user of our API no matter whether
43+
// execution is on Windows or not.
44+
private def needsQuoting(s: String): Boolean =
45+
if (s.isEmpty) true else s.exists(c => c == ' ' || c == '\t' || c == '\\' || c == '"')
46+
47+
private def winQuote(s: String): String = {
48+
if (!needsQuoting(s)) {
49+
s
50+
} else {
51+
"\"" + s.replaceAll("([\\\\]*)\"", "$1$1\\\\\"").replaceAll("([\\\\]*)\\z", "$1$1") + "\""
52+
}
53+
}
54+
55+
private val isWindows: Boolean = System.getProperty("os.name").toLowerCase.contains("win")
56+
57+
private[jse] def prepareArgs(args: immutable.Seq[String]): immutable.Seq[String] =
58+
if (isWindows) args.map(winQuote) else args
5959
}
6060

6161
/**

src/main/scala/com/typesafe/sbt/jse/npm/Npm.scala

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,105 @@ package com.typesafe.sbt.jse.npm
33

44
import java.io.File
55

6-
import com.typesafe.sbt.jse.engines.{Engine, JsExecutionResult}
6+
import com.typesafe.sbt.jse.engines.{Engine, JsExecutionResult, LocalEngine}
77

88
import scala.collection.immutable
9+
import scala.collection.JavaConverters._
910
import scala.collection.mutable.ListBuffer
11+
import scala.sys.process.{Process, ProcessLogger}
12+
import scala.util.Try
1013

1114
/**
1215
* A JVM class for performing NPM commands. Requires a JS engine to use.
1316
*/
14-
class Npm(engine: Engine, npmFile: File, verbose: Boolean = false) {
17+
class Npm(engine: Engine, npmFile: Option[File] = None, preferSystemNpm: Boolean = true, verbose: Boolean = false) {
1518

16-
def this(engine: Engine, npmFile: File) = this(engine, npmFile, false)
19+
@deprecated("Use one of the other non-deprecated constructors instead", "1.3.0")
20+
def this(engine: Engine, npmFile: File) = this(engine, Some(npmFile))
1721

22+
@deprecated("Use one of the other non-deprecated constructors instead", "1.3.0")
23+
def this(engine: Engine, npmFile: File, verbose: Boolean) = this(engine, Some(npmFile), verbose)
24+
25+
/**
26+
* https://docs.npmjs.com/cli/commands/npm-install
27+
*/
28+
def install(global: Boolean = false, names: Seq[String] = Nil, outSink: String => Unit, errSink: String => Unit): JsExecutionResult = {
29+
cmd("install", global, names, outSink, errSink)
30+
}
31+
32+
/**
33+
* https://docs.npmjs.com/cli/commands/npm-update
34+
*/
1835
def update(global: Boolean = false, names: Seq[String] = Nil, outSink: String => Unit, errSink: String => Unit): JsExecutionResult = {
36+
cmd("update", global, names, outSink, errSink)
37+
}
38+
39+
/**
40+
* https://docs.npmjs.com/cli/commands/npm-ci
41+
*/
42+
def ci(names: Seq[String] = Nil, outSink: String => Unit, errSink: String => Unit): JsExecutionResult = {
43+
cmd("ci", false, names, outSink, errSink) // ci subcommand does not support -g (global) flag
44+
}
45+
46+
private def cmd(cmd: String, global: Boolean = false, names: Seq[String] = Nil, outSink: String => Unit, errSink: String => Unit): JsExecutionResult = {
1947
val args = ListBuffer[String]()
20-
args += "update"
48+
args += cmd
2149
if (global) args += "-g"
2250
if (verbose) args += "--verbose"
2351
args ++= names
2452
invokeNpm(args, outSink, errSink)
2553
}
2654

55+
private def detectNpm(command: String): Option[String] = {
56+
val npmExists = Try(Process(s"$command --version").!!).isSuccess
57+
if (!npmExists) {
58+
println("!!!")
59+
println(s"Warning: npm detection failed. Tried the command: $command")
60+
println("!!!")
61+
None
62+
} else {
63+
Some(command)
64+
}
65+
}
66+
2767
private def invokeNpm(args: ListBuffer[String], outSink: String => Unit, errSink: String => Unit): JsExecutionResult = {
2868
if (!engine.isNode) {
2969
throw new IllegalStateException("node not found: a Node.js installation is required to run npm.")
3070
}
31-
engine.executeJs(npmFile, args.to[immutable.Seq], Map.empty, outSink, errSink)
71+
72+
def executeJsNpm(): JsExecutionResult =
73+
engine.executeJs(npmFile.getOrElse(throw new RuntimeException("No NPM JavaScript file passed to the Npm instance via the npmFile param")), args.to[immutable.Seq], Map.empty, outSink, errSink)
74+
75+
engine match {
76+
case localEngine: LocalEngine if preferSystemNpm =>
77+
// The first argument always is the command of the js engine, e.g. either just "node", "phantomjs,.. or a path like "/usr/bin/node"
78+
// So, if the command is a path, we first try to detect if there is a npm command available in the same folder
79+
val localEngineCmd = new File(localEngine.stdArgs.head)
80+
val localNpmCmd = if (localEngineCmd.getParent() == null) {
81+
// Pretty sure the command was not a path but just something like "node"
82+
// Therefore we assume the npm command is on the operating system path, just like the js engine command
83+
detectNpm("npm")
84+
} else {
85+
// Looks like the command was a valid path, so let's see if we can detect a npm command within the same folder
86+
// If we can't, try to fallback to a npm command that is on the operating system path
87+
val cmdPath = new File(localEngineCmd.getParentFile, "npm").getCanonicalPath
88+
detectNpm(cmdPath).orElse(detectNpm("npm"))
89+
}
90+
localNpmCmd match {
91+
case Some(cmd) =>
92+
val allArgs = immutable.Seq(cmd) ++ args
93+
val pb = new ProcessBuilder(LocalEngine.prepareArgs(allArgs).asJava)
94+
pb.environment().putAll(localEngine.stdEnvironment.asJava)
95+
JsExecutionResult(Process(pb).!(ProcessLogger(outSink, errSink)))
96+
case None =>
97+
println("!!!")
98+
println(s"Warning: npm detection failed. Falling back to npm provided by WebJars, which is outdated and will not work with Node versions 14 and newer.")
99+
println("!!!")
100+
executeJsNpm()
101+
}
102+
case _ => // e.g. Trireme provides node, but is not a local install and does not provide npm, therefore fallback using the webjar npm
103+
executeJsNpm()
104+
}
32105
}
33106

34107
}

src/test/scala/com/typesafe/sbt/jse/npm/NpmSpec.scala

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class NpmSpec extends Specification {
1919
val err = new StringBuilder
2020

2121
val to = new File(new File("target"), "webjars")
22-
val npm = new Npm(Node(), NpmLoader.load(to, this.getClass.getClassLoader), verbose = true)
22+
val npm = new Npm(Node(), Some(NpmLoader.load(to, this.getClass.getClassLoader)), verbose = true)
2323
val result = npm.update(false, Nil, s => {
2424
println("stdout: " + s)
2525
out.append(s + "\n")
@@ -32,7 +32,8 @@ class NpmSpec extends Specification {
3232
val stdOut = out.toString()
3333

3434
result.exitValue must_== 0
35-
stdErr must contain("npm http request GET https://registry.npmjs.org/amdefine")
35+
stdErr must contain("npm http request GET https://registry.npmjs.org/amdefine") // when using webjar npm
36+
.or(contain("npm http fetch GET 200 https://registry.npmjs.org/amdefine")) // when using local installed npm
3637
}
3738
}
3839
}

0 commit comments

Comments
 (0)