From 40a1cfd6d3a4df04e216015a06db3372d385baac Mon Sep 17 00:00:00 2001 From: bitloi Date: Sun, 15 Feb 2026 02:29:12 +0100 Subject: [PATCH] Implement sbtw Windows drop-in launcher (sbt/sbt#5406) - Add sbtwProj: Scala 3.3.7 launcher with scopt, drop-in for sbt.bat - Config: .sbtopts, .jvmopts, sbtconfig.txt, JAVA_OPTS/SBT_OPTS precedence - Options: --client, --server, --jvm-client, mem, sbt-version, java-home, etc. - sbt 2.x defaults to native client; --server forces JVM launcher - JVM run via xsbt.boot.Boot; native via sbtn with --sbt-script - build.sbt: sbtwProj in root build and allProjects; NativeImagePlugin - Fixes: JAVA_OPTS then .jvmopts, build.properties trim, shutdownAll PID, Iterator.lastOption --- build.sbt | 23 +++++++ sbtw/build.sbt | 24 +------ sbtw/src/main/scala/sbtw/ArgParser.scala | 9 +-- sbtw/src/main/scala/sbtw/ConfigLoader.scala | 13 ++-- .../src/main/scala/sbtw/LauncherOptions.scala | 66 +++++++++---------- sbtw/src/main/scala/sbtw/Main.scala | 33 +++++++--- sbtw/src/main/scala/sbtw/Memory.scala | 26 +++++--- sbtw/src/main/scala/sbtw/Runner.scala | 40 +++++++---- 8 files changed, 139 insertions(+), 95 deletions(-) diff --git a/build.sbt b/build.sbt index 67bb1163b..9f1cd85e7 100644 --- a/build.sbt +++ b/build.sbt @@ -968,6 +968,28 @@ def scriptedTask(launch: Boolean): Def.Initialize[InputTask[Unit]] = Def.inputTa lazy val publishLauncher = TaskKey[Unit]("publish-launcher") +lazy val sbtwProj = (project in file("sbtw")) + .enablePlugins(NativeImagePlugin) + .settings( + commonSettings, + name := "sbtw", + description := "Windows drop-in launcher for sbt (replaces sbt.bat)", + scalaVersion := "3.3.7", + crossPaths := false, + Compile / mainClass := Some("sbtw.Main"), + libraryDependencies += "com.github.scopt" %% "scopt" % "4.1.0", + nativeImageVersion := "23.0", + nativeImageJvm := "graalvm-java23", + nativeImageOutput := (target.value / "bin" / "sbtw").toPath.toFile, + nativeImageOptions ++= Seq( + "--no-fallback", + s"--initialize-at-run-time=sbtw", + "-H:+ReportExceptionStackTraces", + s"-H:Name=${(target.value / "bin" / "sbtw").getAbsolutePath}", + ), + Utils.noPublish, + ) + def allProjects = Seq( logicProj, @@ -986,6 +1008,7 @@ def allProjects = sbtProj, bundledLauncherProj, sbtClientProj, + sbtwProj, buildFileProj, utilCache, utilTracking, diff --git a/sbtw/build.sbt b/sbtw/build.sbt index afa931e77..e0c90c6a3 100644 --- a/sbtw/build.sbt +++ b/sbtw/build.sbt @@ -1,23 +1 @@ -val sbtwScalaVersion = "3.3.7" - -lazy val sbtwProj = (project in file(".")) - .enablePlugins(NativeImagePlugin) - .settings( - commonSettings, - name := "sbtw", - description := "Windows drop-in launcher for sbt (replaces sbt.bat)", - scalaVersion := sbtwScalaVersion, - crossPaths := false, - Compile / mainClass := Some("sbtw.Main"), - libraryDependencies += "com.github.scopt" %% "scopt" % "4.1.0", - nativeImageVersion := "23.0", - nativeImageJvm := "graalvm-java23", - nativeImageOutput := (target.value / "bin" / "sbtw").toPath.toFile, - nativeImageOptions ++= Seq( - "--no-fallback", - s"--initialize-at-run-time=sbtw", - "-H:+ReportExceptionStackTraces", - s"-H:Name=${(target.value / "bin" / "sbtw").getAbsolutePath}", - ), - Utils.noPublish, - ) +// sbtw project is defined in the root build.sbt (sbtwProj) diff --git a/sbtw/src/main/scala/sbtw/ArgParser.scala b/sbtw/src/main/scala/sbtw/ArgParser.scala index 7ee35826f..abf5ffbc9 100644 --- a/sbtw/src/main/scala/sbtw/ArgParser.scala +++ b/sbtw/src/main/scala/sbtw/ArgParser.scala @@ -7,7 +7,7 @@ object ArgParser { def parse(args: Array[String]): Option[LauncherOptions] = { val b = OParser.builder[LauncherOptions] val parser = { - import b._ + import b.* OParser.sequence( programName("sbtw"), head("sbtw", "Windows launcher for sbt"), @@ -43,9 +43,10 @@ object ArgParser { opt[String]("color").action((x, c) => c.copy(color = Some(x))), opt[Int]("jvm-debug").action((x, c) => c.copy(jvmDebug = Some(x))), opt[String]("java-home").action((x, c) => c.copy(javaHome = Some(x))), - arg[String]("").unbounded().optional().action((x, c) => - c.copy(residual = c.residual :+ x) - ), + arg[String]("") + .unbounded() + .optional() + .action((x, c) => c.copy(residual = c.residual :+ x)), ) } OParser.parse(parser, args, LauncherOptions()).map { opts => diff --git a/sbtw/src/main/scala/sbtw/ConfigLoader.scala b/sbtw/src/main/scala/sbtw/ConfigLoader.scala index 9cd82eb82..fa1056028 100644 --- a/sbtw/src/main/scala/sbtw/ConfigLoader.scala +++ b/sbtw/src/main/scala/sbtw/ConfigLoader.scala @@ -1,7 +1,6 @@ package sbtw import java.io.File -import java.nio.file.{ Files, Paths } import scala.io.Source import scala.util.Using @@ -47,12 +46,16 @@ object ConfigLoader { if (!f.isFile) return None try Using.resource(Source.fromFile(f)) { src => - src.getLines() + src + .getLines() .map(_.trim) .filterNot(_.startsWith("#")) - .find(_.startsWith("sbt.version=")) - .map(_.drop("sbt.version=".length).trim) - .filter(_.nonEmpty) + .find(line => line.startsWith("sbt.version") && (line.contains("="))) + .flatMap { line => + val eq = line.indexOf('=') + if (eq >= 0) Some(line.substring(eq + 1).trim).filter(_.nonEmpty) + else None + } } catch { case _: Exception => None } } diff --git a/sbtw/src/main/scala/sbtw/LauncherOptions.scala b/sbtw/src/main/scala/sbtw/LauncherOptions.scala index 3006e0a4f..f535f0616 100644 --- a/sbtw/src/main/scala/sbtw/LauncherOptions.scala +++ b/sbtw/src/main/scala/sbtw/LauncherOptions.scala @@ -1,39 +1,39 @@ package sbtw case class LauncherOptions( - help: Boolean = false, - verbose: Boolean = false, - debug: Boolean = false, - version: Boolean = false, - numericVersion: Boolean = false, - scriptVersion: Boolean = false, - shutdownAll: Boolean = false, - allowEmpty: Boolean = false, - client: Boolean = false, - jvmClient: Boolean = false, - noServer: Boolean = false, - noColors: Boolean = false, - noGlobal: Boolean = false, - noShare: Boolean = false, - noHideJdkWarnings: Boolean = false, - debugInc: Boolean = false, - timings: Boolean = false, - traces: Boolean = false, - batch: Boolean = false, - sbtDir: Option[String] = None, - sbtBoot: Option[String] = None, - sbtCache: Option[String] = None, - sbtJar: Option[String] = None, - sbtVersion: Option[String] = None, - ivy: Option[String] = None, - mem: Option[Int] = None, - supershell: Option[String] = None, - color: Option[String] = None, - jvmDebug: Option[Int] = None, - javaHome: Option[String] = None, - server: Boolean = false, - residual: Seq[String] = Nil, - sbtNew: Boolean = false, + help: Boolean = false, + verbose: Boolean = false, + debug: Boolean = false, + version: Boolean = false, + numericVersion: Boolean = false, + scriptVersion: Boolean = false, + shutdownAll: Boolean = false, + allowEmpty: Boolean = false, + client: Boolean = false, + jvmClient: Boolean = false, + noServer: Boolean = false, + noColors: Boolean = false, + noGlobal: Boolean = false, + noShare: Boolean = false, + noHideJdkWarnings: Boolean = false, + debugInc: Boolean = false, + timings: Boolean = false, + traces: Boolean = false, + batch: Boolean = false, + sbtDir: Option[String] = None, + sbtBoot: Option[String] = None, + sbtCache: Option[String] = None, + sbtJar: Option[String] = None, + sbtVersion: Option[String] = None, + ivy: Option[String] = None, + mem: Option[Int] = None, + supershell: Option[String] = None, + color: Option[String] = None, + jvmDebug: Option[Int] = None, + javaHome: Option[String] = None, + server: Boolean = false, + residual: Seq[String] = Nil, + sbtNew: Boolean = false, ) object LauncherOptions { diff --git a/sbtw/src/main/scala/sbtw/Main.scala b/sbtw/src/main/scala/sbtw/Main.scala index cc592ed17..feb26e2b4 100644 --- a/sbtw/src/main/scala/sbtw/Main.scala +++ b/sbtw/src/main/scala/sbtw/Main.scala @@ -35,7 +35,9 @@ object Main { } if (!opts.allowEmpty && !opts.sbtNew && !ConfigLoader.isSbtProjectDir(cwd)) { - System.err.println("[error] Neither build.sbt nor a 'project' directory in the current directory: " + cwd) + System.err.println( + "[error] Neither build.sbt nor a 'project' directory in the current directory: " + cwd + ) System.err.println("[error] run 'sbt new', touch build.sbt, or run 'sbt --allow-empty'.") return 1 } @@ -82,7 +84,10 @@ object Main { sbtOpts = finalSbt if (!opts.noHideJdkWarnings && javaVer == 25) { - sbtOpts = sbtOpts ++ Seq("--sun-misc-unsafe-memory-access=allow", "--enable-native-access=ALL-UNNAMED") + sbtOpts = sbtOpts ++ Seq( + "--sun-misc-unsafe-memory-access=allow", + "--enable-native-access=ALL-UNNAMED" + ) } val javaOptsWithDebug = opts.jvmDebug.fold(finalJava)(port => finalJava :+ s"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=$port" @@ -91,7 +96,10 @@ object Main { Runner.runJvm(javaCmd, javaOptsWithDebug, sbtOpts, sbtJar, bootArgs, opts.verbose) } - private def shouldRunNativeClient(opts: LauncherOptions, buildPropsVersion: Option[String]): Boolean = { + private def shouldRunNativeClient( + opts: LauncherOptions, + buildPropsVersion: Option[String] + ): Boolean = { if (opts.sbtNew) return false if (opts.jvmClient) return false val version = buildPropsVersion.getOrElse(LauncherOptions.initSbtVersion) @@ -102,7 +110,12 @@ object Main { else false } - private def handleVersionCommands(cwd: File, sbtHome: File, sbtBinDir: File, opts: LauncherOptions): Int = { + private def handleVersionCommands( + cwd: File, + sbtHome: File, + sbtBinDir: File, + opts: LauncherOptions + ): Int = { if (opts.scriptVersion) { println(LauncherOptions.initSbtVersion) return 0 @@ -118,19 +131,23 @@ object Main { if (opts.numericVersion) { try { val out = Process(Seq(javaCmd, "-jar", sbtJar, "sbtVersion")).!! - println(out.linesIterator.lastOption.map(_.trim).getOrElse("")) + println(out.linesIterator.toSeq.lastOption.map(_.trim).getOrElse("")) return 0 } catch { case _: Exception => return 1 } } if (opts.version) { if (ConfigLoader.isSbtProjectDir(cwd)) { - val out = try Process(Seq(javaCmd, "-jar", sbtJar, "sbtVersion")).!! catch { case _: Exception => "" } - val ver = out.linesIterator.lastOption.map(_.trim).getOrElse("") + val out = + try Process(Seq(javaCmd, "-jar", sbtJar, "sbtVersion")).!! + catch { case _: Exception => "" } + val ver = out.linesIterator.toSeq.lastOption.map(_.trim).getOrElse("") println("sbt version in this project: " + ver) } println("sbt runner version: " + LauncherOptions.initSbtVersion) System.err.println("[info] sbt runner (sbtw) is a runner to run any declared version of sbt.") - System.err.println("[info] Actual version of sbt is declared using project\\build.properties for each build.") + System.err.println( + "[info] Actual version of sbt is declared using project\\build.properties for each build." + ) return 0 } 0 diff --git a/sbtw/src/main/scala/sbtw/Memory.scala b/sbtw/src/main/scala/sbtw/Memory.scala index 155f58950..2096dfc07 100644 --- a/sbtw/src/main/scala/sbtw/Memory.scala +++ b/sbtw/src/main/scala/sbtw/Memory.scala @@ -3,10 +3,17 @@ package sbtw object Memory { private val memoryOptPrefixes = Set( - "-Xmx", "-Xms", "-Xss", - "-XX:MaxPermSize", "-XX:MaxMetaspaceSize", "-XX:ReservedCodeCacheSize", - "-XX:+UseCGroupMemoryLimitForHeap", "-XX:MaxRAM", "-XX:InitialRAMPercentage", - "-XX:MaxRAMPercentage", "-XX:MinRAMPercentage" + "-Xmx", + "-Xms", + "-Xss", + "-XX:MaxPermSize", + "-XX:MaxMetaspaceSize", + "-XX:ReservedCodeCacheSize", + "-XX:+UseCGroupMemoryLimitForHeap", + "-XX:MaxRAM", + "-XX:InitialRAMPercentage", + "-XX:MaxRAMPercentage", + "-XX:MinRAMPercentage" ) def hasMemoryOpts(opts: Seq[String]): Boolean = @@ -31,13 +38,14 @@ object Memory { } def addDefaultMemory( - javaOpts: Seq[String], - sbtOpts: Seq[String], - javaVersion: Int, - defaultMemMb: Int + javaOpts: Seq[String], + sbtOpts: Seq[String], + javaVersion: Int, + defaultMemMb: Int ): (Seq[String], Seq[String]) = { val fromJava = hasMemoryOpts(javaOpts) - val fromTool = sys.env.get("JAVA_TOOL_OPTIONS").exists(s => hasMemoryOpts(s.split("\\s+").toSeq)) + val fromTool = + sys.env.get("JAVA_TOOL_OPTIONS").exists(s => hasMemoryOpts(s.split("\\s+").toSeq)) val fromJdk = sys.env.get("JDK_JAVA_OPTIONS").exists(s => hasMemoryOpts(s.split("\\s+").toSeq)) val fromSbt = hasMemoryOpts(sbtOpts) if (fromJava || fromTool || fromJdk || fromSbt) diff --git a/sbtw/src/main/scala/sbtw/Runner.scala b/sbtw/src/main/scala/sbtw/Runner.scala index 64332cbc2..3ccffc4e9 100644 --- a/sbtw/src/main/scala/sbtw/Runner.scala +++ b/sbtw/src/main/scala/sbtw/Runner.scala @@ -1,7 +1,6 @@ package sbtw import java.io.File -import java.nio.file.{ Paths, Files } import scala.sys.process.* object Runner { @@ -11,10 +10,16 @@ object Runner { case Some(h) => val exe = new File(h, "bin/java.exe") if (exe.isFile) exe.getAbsolutePath - else sys.env.get("JAVACMD").orElse(sys.env.get("JAVA_HOME").map(h0 => - new File(h0, "bin/java.exe").getAbsolutePath)).getOrElse("java") + else + sys.env + .get("JAVACMD") + .orElse( + sys.env.get("JAVA_HOME").map(h0 => new File(h0, "bin/java.exe").getAbsolutePath) + ) + .getOrElse("java") case None => - sys.env.get("JAVACMD") + sys.env + .get("JAVACMD") .orElse(sys.env.get("JAVA_HOME").map(h => new File(h, "bin/java.exe").getAbsolutePath)) .getOrElse("java") } @@ -41,7 +46,12 @@ object Runner { if (opts.debugInc) s = s :+ "-Dxsbt.inc.debug=true" if (opts.noColors) s = s :+ "-Dsbt.log.noformat=true" if (opts.noGlobal) s = s :+ "-Dsbt.global.base=project/.sbtboot" - if (opts.noShare) s = s ++ Seq("-Dsbt.global.base=project/.sbtboot", "-Dsbt.boot.directory=project/.boot", "-Dsbt.ivy.home=project/.ivy") + if (opts.noShare) + s = s ++ Seq( + "-Dsbt.global.base=project/.sbtboot", + "-Dsbt.boot.directory=project/.boot", + "-Dsbt.ivy.home=project/.ivy" + ) opts.supershell.foreach(v => s = s :+ s"-Dsbt.supershell=$v") opts.sbtVersion.foreach(v => s = s :+ s"-Dsbt.version=$v") opts.sbtDir.foreach(v => s = s :+ s"-Dsbt.global.base=$v") @@ -75,14 +85,15 @@ object Runner { } def runJvm( - javaCmd: String, - javaOpts: Seq[String], - sbtOpts: Seq[String], - sbtJar: String, - bootArgs: Seq[String], - verbose: Boolean + javaCmd: String, + javaOpts: Seq[String], + sbtOpts: Seq[String], + sbtJar: String, + bootArgs: Seq[String], + verbose: Boolean ): Int = { - val toolOpts = sys.env.get("JAVA_TOOL_OPTIONS").toSeq.flatMap(_.split("\\s+").filter(_.nonEmpty)) + val toolOpts = + sys.env.get("JAVA_TOOL_OPTIONS").toSeq.flatMap(_.split("\\s+").filter(_.nonEmpty)) val jdkOpts = sys.env.get("JDK_JAVA_OPTIONS").toSeq.flatMap(_.split("\\s+").filter(_.nonEmpty)) val fullJavaOpts = javaOpts ++ sbtOpts ++ toolOpts ++ jdkOpts val cmd = Seq(javaCmd) ++ fullJavaOpts ++ Seq("-cp", sbtJar, "xsbt.boot.Boot") ++ bootArgs @@ -98,7 +109,10 @@ object Runner { val jpsOut = Process(Seq("jps", "-lv")).!! val pids = jpsOut.linesIterator .filter(_.contains("xsbt.boot.Boot")) - .flatMap(line => scala.util.Try(line.takeWhile(_.isDigit).toLong).toOption) + .flatMap { line => + val pidStr = line.trim.takeWhile(_.isDigit) + if (pidStr.nonEmpty) scala.util.Try(pidStr.toLong).toOption else None + } .toList pids.foreach { pid => try Process(Seq("taskkill", "/F", "/PID", pid.toString)).!