This commit is contained in:
bitloi 2026-02-16 02:50:07 +01:00 committed by GitHub
commit 80ece20096
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 644 additions and 20 deletions

View File

@ -51,8 +51,9 @@ jobs:
if: ${{ matrix.os == 'ubuntu-latest' }}
shell: bash
run: |
# test building sbtn on Linux
# test building sbtn and sbtw on Linux
sbt "-Dsbt.io.virtual=false" nativeImage
sbt "-Dsbt.io.virtual=false" sbtwProj/nativeImage
# smoke test native Image
./client/target/bin/sbtn --sbt-script=$(pwd)/sbt about
./client/target/bin/sbtn --sbt-script=$(pwd)/sbt shutdown
@ -69,8 +70,9 @@ jobs:
if: ${{ matrix.os == 'macos-latest' }}
shell: bash
run: |
# test building sbtn on macOS
# test building sbtn and sbtw on macOS
./sbt "-Dsbt.io.virtual=false" nativeImage
./sbt "-Dsbt.io.virtual=false" sbtwProj/nativeImage
# test launcher script
launcher-package/bin/coursier resolve
sbt -Dsbt.build.version=$TEST_SBT_VER launcherPackageIntegrationTest/test
@ -84,19 +86,24 @@ jobs:
max_attempts: 3
shell: bash
command: |
# test building sbtn on Windows
# test building sbtn and sbtw on Windows
sbt "-Dsbt.io.virtual=false" nativeImage
sbt "-Dsbt.io.virtual=false" sbtwProj/nativeImage
- name: Client test (Windows)
if: ${{ matrix.os == 'windows-latest' }}
shell: bash
run: |
# smoke test native Image
# smoke test native Image (sbtn)
./client/target/bin/sbtn --sbt-script=$(pwd)/launcher-package/src/universal/bin/sbt.bat about
./client/target/bin/sbtn --sbt-script=$(pwd)/launcher-package/src/universal/bin/sbt.bat shutdown
# verify sbtw native image was built (we do not smoke-test it: scopt uses lazy vals that misbehave under Graal native-image; sbtw is tested as JVM runner below)
test -f sbtw/target/bin/sbtw.exe || test -f sbtw/target/bin/sbtw
# test launcher script
echo build using JDK 17 test using JDK 17 and JDK 25
launcher-package/bin/coursier.bat resolve
sbt -Dsbt.build.version=$TEST_SBT_VER launcherPackageIntegrationTest/test
# integration tests with sbtw as drop-in for sbt.bat (JVM runner)
sbt -Dsbt.build.version=$TEST_SBT_VER -Dsbt.test.useSbtw=true launcherPackageIntegrationTest/test
cd launcher-package/citest
./test.bat
test3/test3.bat

View File

@ -971,6 +971,38 @@ def scriptedTask(launch: Boolean): Def.Initialize[InputTask[Unit]] = Def.inputTa
}
lazy val publishLauncher = TaskKey[Unit]("publish-launcher")
val createSbtwBinDir = taskKey[Unit]("Create sbtw target/bin before native-image")
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",
createSbtwBinDir := {
val d = (target.value / "bin").toPath
if (!Files.exists(d)) Files.createDirectories(d)
},
Compile / nativeImage := (Compile / nativeImage).dependsOn(createSbtwBinDir).value,
nativeImageOutput := {
val outputDir = (target.value / "bin").toPath
if (!Files.exists(outputDir)) Files.createDirectories(outputDir)
outputDir.resolve("sbtw").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(
@ -990,6 +1022,7 @@ def allProjects =
sbtProj,
bundledLauncherProj,
sbtClientProj,
sbtwProj,
buildFileProj,
utilCache,
utilTracking,
@ -1347,6 +1380,7 @@ lazy val lmCoursierShadedPublishing = project
lazy val launcherPackage = (project in file("launcher-package"))
lazy val launcherPackageIntegrationTest =
(project in (file("launcher-package") / "integration-test"))
.dependsOn(sbtwProj)
.settings(
name := "integration-test",
scalaVersion := scala3,
@ -1359,6 +1393,14 @@ lazy val launcherPackageIntegrationTest =
),
testFrameworks += TestFramework("hedgehog.sbt.Framework"),
testFrameworks += TestFramework("verify.runner.Framework"),
Test / fork := true,
Test / javaOptions += {
val cp = (Test / fullClasspath).value
.map(_.data.getAbsolutePath)
.mkString(java.io.File.pathSeparator)
s"-Dsbt.test.classpath=$cp"
},
Test / javaOptions += s"-Dsbt.test.integrationtest.basedir=${(baseDirectory).value.getAbsolutePath}",
Test / test := {
(Test / test)
.dependsOn(launcherPackage / Universal / packageBin)

View File

@ -10,13 +10,13 @@ import verify.BasicTestSuite
object BspConfigTest extends BasicTestSuite:
lazy val isWindows: Boolean =
sys.props("os.name").toLowerCase(Locale.ENGLISH).contains("windows")
lazy val sbtScript =
if (isWindows) new File("launcher-package/target/universal/stage/bin/sbt.bat")
else new File("launcher-package/target/universal/stage/bin/sbt")
lazy val sbtScript = IntegrationTestPaths.sbtScript(isWindows)
private def launcherCmd = LauncherTestHelper.launcherCommand(sbtScript.getAbsolutePath)
def sbtProcessInDir(dir: File)(args: String*) =
Process(
Seq(sbtScript.getAbsolutePath) ++ args,
launcherCmd ++ args,
dir,
"JAVA_OPTS" -> "",
"SBT_OPTS" -> ""

View File

@ -12,21 +12,21 @@ object ExtendedRunnerTest extends BasicTestSuite:
lazy val isWindows: Boolean = sys.props("os.name").toLowerCase(Locale.ENGLISH).contains("windows")
lazy val isMac: Boolean = sys.props("os.name").toLowerCase(Locale.ENGLISH).contains("mac")
lazy val sbtScript =
if (isWindows) new File("launcher-package/target/universal/stage/bin/sbt.bat")
else new File("launcher-package/target/universal/stage/bin/sbt")
lazy val sbtScript = IntegrationTestPaths.sbtScript(isWindows)
private def launcherCmd = LauncherTestHelper.launcherCommand(sbtScript.getAbsolutePath)
def sbtProcess(args: String*) = sbtProcessWithOpts(args*)("", "")
def sbtProcessWithOpts(args: String*)(javaOpts: String, sbtOpts: String) =
Process(
Seq(sbtScript.getAbsolutePath) ++ args,
new File("launcher-package/citest"),
launcherCmd ++ args,
IntegrationTestPaths.citestDir("citest"),
"JAVA_OPTS" -> javaOpts,
"SBT_OPTS" -> sbtOpts
)
def sbtProcessInDir(dir: File)(args: String*) =
Process(
Seq(sbtScript.getAbsolutePath) ++ args,
launcherCmd ++ args,
dir,
"JAVA_OPTS" -> "",
"SBT_OPTS" -> ""

View File

@ -0,0 +1,32 @@
package example.test
import java.io.File
/**
* Resolves paths for launcher-package integration tests relative to the
* integration-test project base directory. The build injects
* sbt.test.integrationtest.basedir when Test/fork is true so paths are
* correct regardless of the forked JVM's working directory (e.g. on Windows).
*/
object IntegrationTestPaths {
private val baseDir: Option[File] =
sys.props.get("sbt.test.integrationtest.basedir").map(new File(_))
def sbtScript(isWindows: Boolean): File =
baseDir match {
case Some(b) =>
val name = if (isWindows) "sbt.bat" else "sbt"
new File(b.getParentFile, s"target/universal/stage/bin/$name").getAbsoluteFile
case None =>
val rel = if (isWindows) "../target/universal/stage/bin/sbt.bat" else "../target/universal/stage/bin/sbt"
new File(rel).getAbsoluteFile
}
def citestDir(citestVariant: String = "citest"): File =
baseDir match {
case Some(b) =>
new File(b.getParentFile, citestVariant).getAbsoluteFile
case None =>
new File("..", citestVariant).getAbsoluteFile
}
}

View File

@ -0,0 +1,23 @@
package example.test
import java.util.Locale
/**
* Shared helper for launcher integration tests. When sbt.test.useSbtw=true on Windows,
* tests use sbtw (JVM) as the runner instead of sbt.bat, to validate sbtw as a drop-in.
*/
object LauncherTestHelper {
def isWindows: Boolean =
sys.props("os.name").toLowerCase(Locale.ENGLISH).contains("windows")
def useSbtw: Boolean =
isWindows && sys.props.get("sbt.test.useSbtw").contains("true")
/** Command prefix to run the launcher: either script path or java -cp sbtw.Main */
def launcherCommand(scriptPath: String): Seq[String] =
if (useSbtw) {
val cp = sys.props.get("sbt.test.classpath").getOrElse(System.getProperty("java.class.path"))
Seq("java", "-cp", cp, "sbtw.Main")
} else
Seq(scriptPath)
}

View File

@ -10,7 +10,7 @@ trait ShellScriptUtil extends BasicTestSuite {
val isWindows: Boolean =
sys.props("os.name").toLowerCase(java.util.Locale.ENGLISH).contains("windows")
protected val javaBinDir = new File("launcher-package/integration-test/bin").getAbsolutePath
protected val javaBinDir = new File("bin").getAbsolutePath
protected def retry[A1](f: () => A1, maxAttempt: Int = 10): A1 =
try {
@ -22,9 +22,7 @@ trait ShellScriptUtil extends BasicTestSuite {
case e: Exception => throw e
}
val sbtScript =
if (isWindows) new File("launcher-package/target/universal/stage/bin/sbt.bat")
else new File("launcher-package/target/universal/stage/bin/sbt")
val sbtScript = IntegrationTestPaths.sbtScript(isWindows)
/**
* testOutput is a helper function to create a test for shell script.
@ -47,7 +45,7 @@ trait ShellScriptUtil extends BasicTestSuite {
else
test(name) {
val workingDirectory = Files.createTempDirectory("sbt-launcher-package-test").toFile
val citestDir = new File("launcher-package", citestVariant)
val citestDir = IntegrationTestPaths.citestDir(citestVariant)
// Clean target directory if it exists to avoid copying temporary files that may be deleted during copy
val targetDir = new File(citestDir, "target")
if (targetDir.exists()) {
@ -159,9 +157,10 @@ trait ShellScriptUtil extends BasicTestSuite {
else
envVars("PATH") = javaBinDir + File.pathSeparator + path
val cmd = LauncherTestHelper.launcherCommand(testSbtScript.getAbsolutePath) ++ args
val out = scala.sys.process
.Process(
Seq(testSbtScript.getAbsolutePath) ++ args,
cmd,
workingDirectory,
envVars.toSeq*
)

1
sbtw/build.sbt Normal file
View File

@ -0,0 +1 @@
// sbtw project is defined in the root build.sbt (sbtwProj)

View File

@ -0,0 +1,56 @@
package sbtw
import scopt.OParser
object ArgParser:
def parse(args: Array[String]): Option[LauncherOptions] =
val b = OParser.builder[LauncherOptions]
val parser =
import b.*
OParser.sequence(
programName("sbtw"),
head("sbtw", "Windows launcher for sbt"),
opt[Unit]('h', "help").action((_, c) => c.copy(help = true)),
opt[Unit]('v', "verbose").action((_, c) => c.copy(verbose = true)),
opt[Unit]('d', "debug").action((_, c) => c.copy(debug = true)),
opt[Unit]('V', "version").action((_, c) => c.copy(version = true)),
opt[Unit]("numeric-version").action((_, c) => c.copy(numericVersion = true)),
opt[Unit]("script-version").action((_, c) => c.copy(scriptVersion = true)),
opt[Unit]("shutdownall").action((_, c) => c.copy(shutdownAll = true)),
opt[Unit]("allow-empty").action((_, c) => c.copy(allowEmpty = true)),
opt[Unit]("sbt-create").action((_, c) => c.copy(allowEmpty = true)),
opt[Unit]("client").action((_, c) => c.copy(client = true)),
opt[Unit]("server").action((_, c) => c.copy(server = true)),
opt[Unit]("jvm-client").action((_, c) => c.copy(jvmClient = true)),
opt[Unit]("no-server").action((_, c) => c.copy(noServer = true)),
opt[Unit]("no-colors").action((_, c) => c.copy(noColors = true)),
opt[Unit]("no-global").action((_, c) => c.copy(noGlobal = true)),
opt[Unit]("no-share").action((_, c) => c.copy(noShare = true)),
opt[Unit]("no-hide-jdk-warnings").action((_, c) => c.copy(noHideJdkWarnings = true)),
opt[Unit]("debug-inc").action((_, c) => c.copy(debugInc = true)),
opt[Unit]("timings").action((_, c) => c.copy(timings = true)),
opt[Unit]("traces").action((_, c) => c.copy(traces = true)),
opt[Unit]("batch").action((_, c) => c.copy(batch = true)),
opt[String]("sbt-dir").action((x, c) => c.copy(sbtDir = Some(x))),
opt[String]("sbt-boot").action((x, c) => c.copy(sbtBoot = Some(x))),
opt[String]("sbt-cache").action((x, c) => c.copy(sbtCache = Some(x))),
opt[String]("sbt-jar").action((x, c) => c.copy(sbtJar = Some(x))),
opt[String]("sbt-version").action((x, c) => c.copy(sbtVersion = Some(x))),
opt[String]("ivy").action((x, c) => c.copy(ivy = Some(x))),
opt[Int]("mem").action((x, c) => c.copy(mem = Some(x))),
opt[String]("supershell").action((x, c) => c.copy(supershell = Some(x))),
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)),
)
OParser
.parse(parser, args, LauncherOptions())
.map: opts =>
val sbtNew = opts.residual.contains("new") || opts.residual.contains("init")
opts.copy(sbtNew = sbtNew)
end ArgParser

View File

@ -0,0 +1,61 @@
package sbtw
import java.io.File
import scala.io.Source
import scala.util.Using
object ConfigLoader:
def loadLines(file: File): Seq[String] =
if !file.isFile then Nil
else
try
Using.resource(Source.fromFile(file))(_.getLines().toList.flatMap: line =>
val trimmed = line.trim
if trimmed.isEmpty || trimmed.startsWith("#") then Nil
else Seq(trimmed)
)
catch { case _: Exception => Nil }
def loadSbtOpts(cwd: File, sbtHome: File): Seq[String] =
val fromProject = new File(cwd, ".sbtopts")
val fromConfig = new File(sbtHome, "conf/sbtopts")
val fromEtc = new File("/etc/sbt/sbtopts")
val fromSbtConfig = new File(sbtHome, "conf/sbtconfig.txt")
val fromEnv = sys.env.get("SBT_OPTS").toSeq.flatMap(_.split("\\s+").filter(_.nonEmpty))
val fromProjectLines = loadLines(fromProject).map(stripJ)
val fromConfigLines = loadLines(fromConfig)
val fromEtcLines = loadLines(fromEtc)
val fromSbtConfigLines = loadLines(fromSbtConfig)
(fromEtcLines ++ fromConfigLines ++ fromSbtConfigLines ++ fromEnv ++ fromProjectLines).toSeq
def loadJvmOpts(cwd: File): Seq[String] =
val fromProject = new File(cwd, ".jvmopts")
val fromEnv = sys.env.get("JAVA_OPTS").toSeq.flatMap(_.split("\\s+").filter(_.nonEmpty))
fromEnv ++ loadLines(fromProject)
private def stripJ(s: String): String = if s.startsWith("-J") then s.substring(2).trim else s
def defaultJavaOpts: Seq[String] = Seq("-Dfile.encoding=UTF-8")
def defaultSbtOpts: Seq[String] = Nil
def sbtVersionFromBuildProperties(projectDir: File): Option[String] =
val f = new File(projectDir, "project/build.properties")
if !f.isFile then None
else
try
Using.resource(Source.fromFile(f)): src =>
src
.getLines()
.map(_.trim)
.filterNot(_.startsWith("#"))
.find(line => line.startsWith("sbt.version") && line.contains("="))
.flatMap: line =>
val eq = line.indexOf('=')
if eq >= 0 then Some(line.substring(eq + 1).trim).filter(_.nonEmpty)
else None
catch { case _: Exception => None }
def isSbtProjectDir(dir: File): Boolean =
new File(dir, "build.sbt").isFile || new File(dir, "project/build.properties").isFile
end ConfigLoader

View File

@ -0,0 +1,42 @@
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,
)
object LauncherOptions:
val defaultMemMb = 1024
val initSbtVersion = "_to_be_replaced"
end LauncherOptions

View File

@ -0,0 +1,164 @@
package sbtw
import java.io.File
import scala.sys.process.*
object Main:
def main(args: Array[String]): Unit =
val cwd = new File(sys.props("user.dir"))
val sbtHome = new File(
sys.env
.get("SBT_HOME")
.getOrElse:
sys.env.get("SBT_BIN_DIR").map(d => new File(d).getParent).getOrElse(cwd.getAbsolutePath)
)
val sbtBinDir = new File(sbtHome, "bin")
val fileSbtOpts = ConfigLoader.loadSbtOpts(cwd, sbtHome)
val fileArgs = fileSbtOpts.flatMap(_.split("\\s+").filter(_.nonEmpty))
val allArgs = fileArgs ++ args
ArgParser.parse(allArgs.toArray) match
case None => System.exit(1)
case Some(opts) =>
val exitCode = run(cwd, sbtHome, sbtBinDir, opts)
System.exit(if exitCode == 0 then 0 else 1)
private def run(cwd: File, sbtHome: File, sbtBinDir: File, opts: LauncherOptions): Int =
if opts.help then return printUsage()
if opts.version || opts.numericVersion || opts.scriptVersion then
return handleVersionCommands(cwd, sbtHome, sbtBinDir, opts)
if opts.shutdownAll then
val javaCmd = Runner.findJavaCmd(opts.javaHome)
return Runner.shutdownAll(javaCmd)
if !opts.allowEmpty && !opts.sbtNew && !ConfigLoader.isSbtProjectDir(cwd) then
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
val buildPropsVersion = ConfigLoader.sbtVersionFromBuildProperties(cwd)
val clientOpt = opts.client || sys.env.get("SBT_NATIVE_CLIENT").contains("true")
val useNativeClient = shouldRunNativeClient(opts.copy(client = clientOpt), buildPropsVersion)
if useNativeClient then
val scriptPath = sbtBinDir.getAbsolutePath.replace("\\", "/") + "/sbt.bat"
return Runner.runNativeClient(sbtBinDir, scriptPath, opts)
val javaCmd = Runner.findJavaCmd(opts.javaHome)
val javaVer = Runner.javaVersion(javaCmd)
if javaVer > 0 && javaVer < 8 then
System.err.println("[error] sbt requires at least JDK 8+, you have " + javaVer)
return 1
val sbtJar = opts.sbtJar
.filter(p => new File(p).isFile)
.getOrElse(new File(sbtBinDir, "sbt-launch.jar").getAbsolutePath)
if !new File(sbtJar).isFile then
System.err.println("[error] Launcher jar not found: " + sbtJar)
return 1
var javaOpts = ConfigLoader.loadJvmOpts(cwd)
if javaOpts.isEmpty then javaOpts = ConfigLoader.defaultJavaOpts
var sbtOpts = Runner.buildSbtOpts(opts)
val (residualJava, bootArgs) = Runner.splitResidual(opts.residual)
javaOpts = javaOpts ++ residualJava
val (finalJava, finalSbt) = if opts.mem.isDefined then
val evictedJava = Memory.evictMemoryOpts(javaOpts)
val evictedSbt = Memory.evictMemoryOpts(sbtOpts)
val memOpts = Memory.addMemory(opts.mem.get, javaVer)
(evictedJava ++ memOpts, evictedSbt)
else Memory.addDefaultMemory(javaOpts, sbtOpts, javaVer, LauncherOptions.defaultMemMb)
sbtOpts = finalSbt
if !opts.noHideJdkWarnings && javaVer == 25 then
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"
)
Runner.runJvm(javaCmd, javaOptsWithDebug, sbtOpts, sbtJar, bootArgs, opts.verbose)
private def shouldRunNativeClient(
opts: LauncherOptions,
buildPropsVersion: Option[String]
): Boolean =
if opts.sbtNew then return false
if opts.jvmClient then return false
val version = buildPropsVersion.getOrElse(LauncherOptions.initSbtVersion)
val parts = version.split("[.-]").take(2).flatMap(s => scala.util.Try(s.toInt).toOption)
val (major, minor) = (parts.lift(0).getOrElse(0), parts.lift(1).getOrElse(0))
if major >= 2 then !opts.server
else if major >= 1 && minor >= 4 then opts.client
else false
private def handleVersionCommands(
cwd: File,
sbtHome: File,
sbtBinDir: File,
opts: LauncherOptions
): Int =
if opts.scriptVersion then
println(LauncherOptions.initSbtVersion)
return 0
val javaCmd = Runner.findJavaCmd(opts.javaHome)
val sbtJar = opts.sbtJar
.filter(p => new File(p).isFile)
.getOrElse(new File(sbtBinDir, "sbt-launch.jar").getAbsolutePath)
if !new File(sbtJar).isFile then
System.err.println("[error] Launcher jar not found for version check")
return 1
if opts.numericVersion then
try
val out = Process(Seq(javaCmd, "-jar", sbtJar, "sbtVersion")).!!
println(out.linesIterator.toSeq.lastOption.map(_.trim).getOrElse(""))
return 0
catch { case _: Exception => return 1 }
if opts.version then
if ConfigLoader.isSbtProjectDir(cwd) then
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."
)
return 0
0
private def printUsage(): Int =
println("""
|Usage: sbtw [options]
|
| -h | --help print this message
| -v | --verbose this runner is chattier
| -V | --version print sbt version information
| --numeric-version print the numeric sbt version
| --script-version print the version of sbt script
| shutdownall shutdown all running sbt processes
| -d | --debug set sbt log level to debug
| --allow-empty start sbt even if current directory contains no sbt project
| --client run native client (sbt 1.4+)
| --server run JVM launcher (disable native client, sbt 2.x)
| --jvm-client run JVM client
| --mem <integer> set memory options (default: 1024)
| --sbt-version <v> use the specified version of sbt
| --sbt-jar <path> use the specified jar as the sbt launcher
| --java-home <path> alternate JAVA_HOME
| -Dkey=val pass -Dkey=val to the JVM
| -X<flag> pass -X<flag> to the JVM (e.g. -Xmx1G)
| -J-X pass -X to the JVM (-J is stripped)
|""".stripMargin)
0
end Main

View File

@ -0,0 +1,56 @@
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"
)
def hasMemoryOpts(opts: Seq[String]): Boolean =
opts.exists(o => memoryOptPrefixes.exists(p => o.startsWith(p)))
def evictMemoryOpts(opts: Seq[String]): Seq[String] =
opts.filter(o => !memoryOptPrefixes.exists(p => o.startsWith(p)))
def addMemory(memMb: Int, javaVersion: Int): Seq[String] =
var codecache = memMb / 8
if codecache > 512 then codecache = 512
if codecache < 128 then codecache = 128
val classMetadataSize = codecache * 2
val base = Seq(
s"-Xms${memMb}m",
s"-Xmx${memMb}m",
"-Xss4M",
s"-XX:ReservedCodeCacheSize=${codecache}m"
)
if javaVersion < 8 then base :+ s"-XX:MaxPermSize=${classMetadataSize}m"
else base
def addDefaultMemory(
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 fromJdk = sys.env.get("JDK_JAVA_OPTIONS").exists(s => hasMemoryOpts(s.split("\\s+").toSeq))
val fromSbt = hasMemoryOpts(sbtOpts)
if fromJava || fromTool || fromJdk || fromSbt then (javaOpts, sbtOpts)
else
val evictedJava = evictMemoryOpts(javaOpts)
val evictedSbt = evictMemoryOpts(sbtOpts)
val memOpts = addMemory(defaultMemMb, javaVersion)
(evictedJava ++ memOpts, evictedSbt)
end Memory

View File

@ -0,0 +1,141 @@
package sbtw
import java.io.File
import java.lang.ProcessBuilder as JProcessBuilder
import scala.sys.process.*
object Runner:
def findJavaCmd(javaHome: Option[String]): String =
val cmd = javaHome match
case Some(h) =>
val exe = new File(h, "bin/java.exe")
if exe.isFile then exe.getAbsolutePath
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")
.orElse(sys.env.get("JAVA_HOME").map(h => new File(h, "bin/java.exe").getAbsolutePath))
.getOrElse("java")
cmd.replace("\"", "")
def javaVersion(javaCmd: String): Int =
try
val pb = Process(Seq(javaCmd, "-Xms32M", "-Xmx32M", "-version"))
val out = pb.!!
val line = out.linesIterator.find(_.contains("version")).getOrElse("")
val quoted = line.split("\"").lift(1).getOrElse("")
val parts = quoted.replaceFirst("^1\\.", "").split("[.-_]")
val major = parts.headOption.flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(0)
if quoted.startsWith("1.") && parts.nonEmpty then
scala.util.Try(parts(0).toInt).toOption.getOrElse(major)
else major
catch { case _: Exception => 0 }
def buildSbtOpts(opts: LauncherOptions): Seq[String] =
var s: Seq[String] = Nil
if opts.debug then s = s :+ "-debug"
if opts.debugInc then s = s :+ "-Dxsbt.inc.debug=true"
if opts.noColors then s = s :+ "-Dsbt.log.noformat=true"
if opts.noGlobal then s = s :+ "-Dsbt.global.base=project/.sbtboot"
if opts.noShare then
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")
opts.sbtBoot.foreach(v => s = s :+ s"-Dsbt.boot.directory=$v")
opts.sbtCache.foreach(v => s = s :+ s"-Dsbt.global.localcache=$v")
opts.ivy.foreach(v => s = s :+ s"-Dsbt.ivy.home=$v")
opts.color.foreach(v => s = s :+ s"-Dsbt.color=$v")
if opts.timings then
s = s ++ Seq("-Dsbt.task.timings=true", "-Dsbt.task.timings.on.shutdown=true")
if opts.traces then s = s :+ "-Dsbt.traces=true"
if opts.noServer then s = s ++ Seq("-Dsbt.io.virtual=false", "-Dsbt.server.autostart=false")
if opts.jvmClient then s = s :+ "--client"
s
def runNativeClient(sbtBinDir: File, scriptPath: String, opts: LauncherOptions): Int =
val sbtn = new File(sbtBinDir, "sbtn-x86_64-pc-win32.exe")
if !sbtn.isFile then
System.err.println("[error] sbtn-x86_64-pc-win32.exe not found in " + sbtBinDir)
return 1
val args = Seq("--sbt-script=" + scriptPath.replace(" ", "%20")) ++
(if opts.verbose then Seq("-v") else Nil) ++
opts.residual
val cmd = sbtn.getAbsolutePath +: args
if opts.verbose then
System.err.println("# running native client")
cmd.foreach(a => System.err.println(a))
val proc = Process(cmd, None, "SBT_SCRIPT" -> scriptPath)
proc.!
def runJvm(
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 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
if verbose then
System.err.println("# Executing command line:")
cmd.foreach(a => System.err.println(if a.contains(" ") then s""""$a"""" else a))
val jpb = new JProcessBuilder(cmd*)
jpb.inheritIO()
val p = jpb.start()
try
p.waitFor()
p.exitValue()
finally if p.isAlive then p.destroy()
def shutdownAll(javaCmd: String): Int =
try
val jpsOut = Process(Seq("jps", "-lv")).!!
val pids = jpsOut.linesIterator
.filter(_.contains("xsbt.boot.Boot"))
.flatMap: line =>
val pidStr = line.trim.takeWhile(_.isDigit)
if pidStr.nonEmpty then scala.util.Try(pidStr.toLong).toOption else None
.toList
pids.foreach: pid =>
try Process(Seq("taskkill", "/F", "/PID", pid.toString)).!
catch { case _: Exception => }
System.err.println(s"shutdown ${pids.size} sbt processes")
0
catch { case _: Exception => 1 }
def splitResidual(residual: Seq[String]): (Seq[String], Seq[String]) =
var javaOpts: Seq[String] = Nil
var bootArgs: Seq[String] = Nil
var i = 0
while i < residual.size do
val a = residual(i)
if a.startsWith("-J") then javaOpts = javaOpts :+ a.drop(2)
else if a.startsWith("-X") then javaOpts = javaOpts :+ a
else if a.startsWith("-D") && a.contains("=") then bootArgs = bootArgs :+ a
else if a.startsWith("-D") && i + 1 < residual.size then
bootArgs = bootArgs :+ s"$a=${residual(i + 1)}"
i += 1
else if a.startsWith("-XX") && a.contains("=") then bootArgs = bootArgs :+ a
else if a.startsWith("-XX") && i + 1 < residual.size then
bootArgs = bootArgs :+ s"$a=${residual(i + 1)}"
i += 1
else bootArgs = bootArgs :+ a
i += 1
(javaOpts, bootArgs)
end Runner