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
This commit is contained in:
bitloi 2026-02-15 02:29:12 +01:00
parent 0c017d2e52
commit 40a1cfd6d3
8 changed files with 139 additions and 95 deletions

View File

@ -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,

View File

@ -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)

View File

@ -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]("<arg>").unbounded().optional().action((x, c) =>
c.copy(residual = c.residual :+ x)
),
arg[String]("<arg>")
.unbounded()
.optional()
.action((x, c) => c.copy(residual = c.residual :+ x)),
)
}
OParser.parse(parser, args, LauncherOptions()).map { opts =>

View File

@ -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 }
}

View File

@ -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 {

View File

@ -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

View File

@ -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)

View File

@ -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)).!