From 0c017d2e525aba517e050eae667d434a9cdd881d Mon Sep 17 00:00:00 2001 From: bitloi Date: Sun, 15 Feb 2026 02:12:53 +0100 Subject: [PATCH 01/13] Implement sbtw Windows drop-in launcher --- .github/workflows/sbtw-release.yml | 53 ++++++ sbtw/build.sbt | 23 +++ sbtw/src/main/scala/sbtw/ArgParser.scala | 56 ++++++ sbtw/src/main/scala/sbtw/ConfigLoader.scala | 62 +++++++ .../src/main/scala/sbtw/LauncherOptions.scala | 42 +++++ sbtw/src/main/scala/sbtw/Main.scala | 164 ++++++++++++++++++ sbtw/src/main/scala/sbtw/Memory.scala | 52 ++++++ sbtw/src/main/scala/sbtw/Runner.scala | 133 ++++++++++++++ 8 files changed, 585 insertions(+) create mode 100644 .github/workflows/sbtw-release.yml create mode 100644 sbtw/build.sbt create mode 100644 sbtw/src/main/scala/sbtw/ArgParser.scala create mode 100644 sbtw/src/main/scala/sbtw/ConfigLoader.scala create mode 100644 sbtw/src/main/scala/sbtw/LauncherOptions.scala create mode 100644 sbtw/src/main/scala/sbtw/Main.scala create mode 100644 sbtw/src/main/scala/sbtw/Memory.scala create mode 100644 sbtw/src/main/scala/sbtw/Runner.scala diff --git a/.github/workflows/sbtw-release.yml b/.github/workflows/sbtw-release.yml new file mode 100644 index 000000000..bbc66ecd1 --- /dev/null +++ b/.github/workflows/sbtw-release.yml @@ -0,0 +1,53 @@ +name: sbtw Release +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: write + +jobs: + build-and-upload: + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup JDK + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "23" + cache: sbt + + - name: Setup sbt + uses: sbt/setup-sbt@v1 + + - name: Setup Windows C++ toolchain + uses: ilammy/msvc-dev-cmd@v1 + + - name: Build sbtw native image + uses: nick-fields/retry@v3 + with: + timeout_minutes: 15 + max_attempts: 3 + shell: bash + command: sbt -Dsbt.io.virtual=false sbtw/nativeImage + env: + JAVA_OPTS: -Xms800M -Xmx2G -Xss6M -XX:ReservedCodeCacheSize=128M -server -Dfile.encoding=UTF-8 + + - name: Upload sbtw to Release + if: github.event_name == 'release' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.release.tag_name }} + files: sbtw/target/bin/sbtw* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload sbtw artifact (manual run) + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v4 + with: + name: sbtw-windows-${{ github.sha }} + path: sbtw/target/bin/sbtw* diff --git a/sbtw/build.sbt b/sbtw/build.sbt new file mode 100644 index 000000000..afa931e77 --- /dev/null +++ b/sbtw/build.sbt @@ -0,0 +1,23 @@ +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, + ) diff --git a/sbtw/src/main/scala/sbtw/ArgParser.scala b/sbtw/src/main/scala/sbtw/ArgParser.scala new file mode 100644 index 000000000..7ee35826f --- /dev/null +++ b/sbtw/src/main/scala/sbtw/ArgParser.scala @@ -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]("").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) + } + } +} diff --git a/sbtw/src/main/scala/sbtw/ConfigLoader.scala b/sbtw/src/main/scala/sbtw/ConfigLoader.scala new file mode 100644 index 000000000..9cd82eb82 --- /dev/null +++ b/sbtw/src/main/scala/sbtw/ConfigLoader.scala @@ -0,0 +1,62 @@ +package sbtw + +import java.io.File +import java.nio.file.{ Files, Paths } +import scala.io.Source +import scala.util.Using + +object ConfigLoader { + + def loadLines(file: File): Seq[String] = + if (!file.isFile) Nil + else + try + Using.resource(Source.fromFile(file))(_.getLines().toList.flatMap { line => + val trimmed = line.trim + if (trimmed.isEmpty || trimmed.startsWith("#")) 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")) 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) return None + try + Using.resource(Source.fromFile(f)) { src => + src.getLines() + .map(_.trim) + .filterNot(_.startsWith("#")) + .find(_.startsWith("sbt.version=")) + .map(_.drop("sbt.version=".length).trim) + .filter(_.nonEmpty) + } + catch { case _: Exception => None } + } + + def isSbtProjectDir(dir: File): Boolean = + new File(dir, "build.sbt").isFile || new File(dir, "project/build.properties").isFile +} diff --git a/sbtw/src/main/scala/sbtw/LauncherOptions.scala b/sbtw/src/main/scala/sbtw/LauncherOptions.scala new file mode 100644 index 000000000..3006e0a4f --- /dev/null +++ b/sbtw/src/main/scala/sbtw/LauncherOptions.scala @@ -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" +} diff --git a/sbtw/src/main/scala/sbtw/Main.scala b/sbtw/src/main/scala/sbtw/Main.scala new file mode 100644 index 000000000..cc592ed17 --- /dev/null +++ b/sbtw/src/main/scala/sbtw/Main.scala @@ -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) 0 else 1) + } + } + + private def run(cwd: File, sbtHome: File, sbtBinDir: File, opts: LauncherOptions): Int = { + if (opts.help) return printUsage() + if (opts.version || opts.numericVersion || opts.scriptVersion) { + return handleVersionCommands(cwd, sbtHome, sbtBinDir, opts) + } + if (opts.shutdownAll) { + val javaCmd = Runner.findJavaCmd(opts.javaHome) + return Runner.shutdownAll(javaCmd) + } + + 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] 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) { + 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) { + 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) { + System.err.println("[error] Launcher jar not found: " + sbtJar) + return 1 + } + + var javaOpts = ConfigLoader.loadJvmOpts(cwd) + if (javaOpts.isEmpty) 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) { + 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) { + 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) return false + if (opts.jvmClient) 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) !opts.server + else if (major >= 1 && minor >= 4) opts.client + else false + } + + private def handleVersionCommands(cwd: File, sbtHome: File, sbtBinDir: File, opts: LauncherOptions): Int = { + if (opts.scriptVersion) { + 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) { + System.err.println("[error] Launcher jar not found for version check") + return 1 + } + if (opts.numericVersion) { + try { + val out = Process(Seq(javaCmd, "-jar", sbtJar, "sbtVersion")).!! + println(out.linesIterator.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("") + 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 set memory options (default: 1024) + | --sbt-version use the specified version of sbt + | --sbt-jar use the specified jar as the sbt launcher + | --java-home alternate JAVA_HOME + | -Dkey=val pass -Dkey=val to the JVM + | -X pass -X to the JVM (e.g. -Xmx1G) + | -J-X pass -X to the JVM (-J is stripped) + |""".stripMargin) + 0 + } +} diff --git a/sbtw/src/main/scala/sbtw/Memory.scala b/sbtw/src/main/scala/sbtw/Memory.scala new file mode 100644 index 000000000..155f58950 --- /dev/null +++ b/sbtw/src/main/scala/sbtw/Memory.scala @@ -0,0 +1,52 @@ +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) codecache = 512 + if (codecache < 128) 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) 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) + (javaOpts, sbtOpts) + else { + val evictedJava = evictMemoryOpts(javaOpts) + val evictedSbt = evictMemoryOpts(sbtOpts) + val memOpts = addMemory(defaultMemMb, javaVersion) + (evictedJava ++ memOpts, evictedSbt) + } + } +} diff --git a/sbtw/src/main/scala/sbtw/Runner.scala b/sbtw/src/main/scala/sbtw/Runner.scala new file mode 100644 index 000000000..64332cbc2 --- /dev/null +++ b/sbtw/src/main/scala/sbtw/Runner.scala @@ -0,0 +1,133 @@ +package sbtw + +import java.io.File +import java.nio.file.{ Paths, Files } +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) 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) + 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) s = s :+ "-debug" + 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") + 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) s = s ++ Seq("-Dsbt.task.timings=true", "-Dsbt.task.timings.on.shutdown=true") + if (opts.traces) s = s :+ "-Dsbt.traces=true" + if (opts.noServer) s = s ++ Seq("-Dsbt.io.virtual=false", "-Dsbt.server.autostart=false") + if (opts.jvmClient) 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) { + 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) Seq("-v") else Nil) ++ + opts.residual + val cmd = sbtn.getAbsolutePath +: args + if (opts.verbose) { + 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) { + System.err.println("# Executing command line:") + cmd.foreach(a => System.err.println(if (a.contains(" ")) s""""$a"""" else a)) + } + Process(cmd).! + } + + def shutdownAll(javaCmd: String): Int = { + try { + 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) + .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) { + val a = residual(i) + if (a.startsWith("-J")) javaOpts = javaOpts :+ a.drop(2) + else if (a.startsWith("-X")) javaOpts = javaOpts :+ a + else if (a.startsWith("-D") && a.contains("=")) bootArgs = bootArgs :+ a + else if (a.startsWith("-D") && i + 1 < residual.size) { + bootArgs = bootArgs :+ s"$a=${residual(i + 1)}" + i += 1 + } else if (a.startsWith("-XX") && a.contains("=")) bootArgs = bootArgs :+ a + else if (a.startsWith("-XX") && i + 1 < residual.size) { + bootArgs = bootArgs :+ s"$a=${residual(i + 1)}" + i += 1 + } else bootArgs = bootArgs :+ a + i += 1 + } + (javaOpts, bootArgs) + } +} From 40a1cfd6d3a4df04e216015a06db3372d385baac Mon Sep 17 00:00:00 2001 From: bitloi Date: Sun, 15 Feb 2026 02:29:12 +0100 Subject: [PATCH 02/13] 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)).! From 3924567ee1a0bc0a53b2e4bb5ea4bf3b25f53278 Mon Sep 17 00:00:00 2001 From: bitloi Date: Sun, 15 Feb 2026 03:32:01 +0100 Subject: [PATCH 03/13] Fix native-image: create target/bin before building sbtw --- build.sbt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 9f1cd85e7..beb60ed1f 100644 --- a/build.sbt +++ b/build.sbt @@ -967,6 +967,7 @@ 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) @@ -980,7 +981,16 @@ lazy val sbtwProj = (project in file("sbtw")) libraryDependencies += "com.github.scopt" %% "scopt" % "4.1.0", nativeImageVersion := "23.0", nativeImageJvm := "graalvm-java23", - nativeImageOutput := (target.value / "bin" / "sbtw").toPath.toFile, + 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", From 11259109a423bfbee453586bea95d427dd83a9a1 Mon Sep 17 00:00:00 2001 From: bitloi Date: Sun, 15 Feb 2026 05:31:14 +0100 Subject: [PATCH 04/13] Fold sbtw into client-test.yml: build + smoke test; remove sbtw-release.yml --- .github/workflows/client-test.yml | 13 +++++--- .github/workflows/sbtw-release.yml | 53 ------------------------------ 2 files changed, 9 insertions(+), 57 deletions(-) delete mode 100644 .github/workflows/sbtw-release.yml diff --git a/.github/workflows/client-test.yml b/.github/workflows/client-test.yml index 2189aef13..5b065f06b 100644 --- a/.github/workflows/client-test.yml +++ b/.github/workflows/client-test.yml @@ -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,15 +86,18 @@ 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 + # smoke test sbtw (Windows launcher) + if [ -f sbtw/target/bin/sbtw.exe ]; then ./sbtw/target/bin/sbtw.exe --help; else ./sbtw/target/bin/sbtw --help; fi # test launcher script echo build using JDK 17 test using JDK 17 and JDK 25 launcher-package/bin/coursier.bat resolve diff --git a/.github/workflows/sbtw-release.yml b/.github/workflows/sbtw-release.yml deleted file mode 100644 index bbc66ecd1..000000000 --- a/.github/workflows/sbtw-release.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: sbtw Release -on: - release: - types: [published] - workflow_dispatch: - -permissions: - contents: write - -jobs: - build-and-upload: - runs-on: windows-latest - steps: - - uses: actions/checkout@v6 - - - name: Setup JDK - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: "23" - cache: sbt - - - name: Setup sbt - uses: sbt/setup-sbt@v1 - - - name: Setup Windows C++ toolchain - uses: ilammy/msvc-dev-cmd@v1 - - - name: Build sbtw native image - uses: nick-fields/retry@v3 - with: - timeout_minutes: 15 - max_attempts: 3 - shell: bash - command: sbt -Dsbt.io.virtual=false sbtw/nativeImage - env: - JAVA_OPTS: -Xms800M -Xmx2G -Xss6M -XX:ReservedCodeCacheSize=128M -server -Dfile.encoding=UTF-8 - - - name: Upload sbtw to Release - if: github.event_name == 'release' - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ github.event.release.tag_name }} - files: sbtw/target/bin/sbtw* - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Upload sbtw artifact (manual run) - if: github.event_name == 'workflow_dispatch' - uses: actions/upload-artifact@v4 - with: - name: sbtw-windows-${{ github.sha }} - path: sbtw/target/bin/sbtw* From c2a04b3953a073a1799192bb6d0be081b2367329 Mon Sep 17 00:00:00 2001 From: bitloi Date: Sun, 15 Feb 2026 05:48:21 +0100 Subject: [PATCH 05/13] CI: verify sbtw binary exists instead of running --help (scopt/Graal native image) --- .github/workflows/client-test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/client-test.yml b/.github/workflows/client-test.yml index 5b065f06b..16f32e62d 100644 --- a/.github/workflows/client-test.yml +++ b/.github/workflows/client-test.yml @@ -96,8 +96,8 @@ jobs: # 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 - # smoke test sbtw (Windows launcher) - if [ -f sbtw/target/bin/sbtw.exe ]; then ./sbtw/target/bin/sbtw.exe --help; else ./sbtw/target/bin/sbtw --help; fi + # sbtw native image built; skip running it (scopt lazy vals break under Graal native image) + 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 From f95cf024b98297bb6717d1b0edf952766665702a Mon Sep 17 00:00:00 2001 From: bitloi Date: Sun, 15 Feb 2026 07:59:26 +0100 Subject: [PATCH 06/13] Integration tests: sbtw as drop-in for sbt.bat (LauncherTestHelper, -Dsbt.test.useSbtw=true) - Add LauncherTestHelper: use sbtw (JVM) when -Dsbt.test.useSbtw=true on Windows - Wire RunnerScriptTest, RunnerMemoryScriptTest, ExtendedRunnerTest, BspConfigTest - launcherPackageIntegrationTest depends on sbtwProj - CI (Windows): run launcherPackageIntegrationTest twice (sbt.bat, then sbtw) - Workflow comment: verify sbtw native image built, do not run (scopt/Graal) --- .github/workflows/client-test.yml | 4 +++- build.sbt | 1 + .../src/test/scala/BspConfigTest.scala | 4 +++- .../src/test/scala/ExtendedRunnerTest.scala | 6 +++-- .../src/test/scala/LauncherTestHelper.scala | 22 +++++++++++++++++++ .../src/test/scala/ShellScriptUtil.scala | 3 ++- 6 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 launcher-package/integration-test/src/test/scala/LauncherTestHelper.scala diff --git a/.github/workflows/client-test.yml b/.github/workflows/client-test.yml index 16f32e62d..2b553695d 100644 --- a/.github/workflows/client-test.yml +++ b/.github/workflows/client-test.yml @@ -96,12 +96,14 @@ jobs: # 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 - # sbtw native image built; skip running it (scopt lazy vals break under Graal native image) + # verify sbtw native image was built (do not run: scopt lazy vals break under Graal native image) 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 diff --git a/build.sbt b/build.sbt index 08298f475..f99ce0ba2 100644 --- a/build.sbt +++ b/build.sbt @@ -1378,6 +1378,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, diff --git a/launcher-package/integration-test/src/test/scala/BspConfigTest.scala b/launcher-package/integration-test/src/test/scala/BspConfigTest.scala index 1a825074c..d4339eb3d 100644 --- a/launcher-package/integration-test/src/test/scala/BspConfigTest.scala +++ b/launcher-package/integration-test/src/test/scala/BspConfigTest.scala @@ -14,9 +14,11 @@ object BspConfigTest extends BasicTestSuite: if (isWindows) new File("launcher-package/target/universal/stage/bin/sbt.bat") else new File("launcher-package/target/universal/stage/bin/sbt") + 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" -> "" diff --git a/launcher-package/integration-test/src/test/scala/ExtendedRunnerTest.scala b/launcher-package/integration-test/src/test/scala/ExtendedRunnerTest.scala index 14dec7316..5d054a353 100755 --- a/launcher-package/integration-test/src/test/scala/ExtendedRunnerTest.scala +++ b/launcher-package/integration-test/src/test/scala/ExtendedRunnerTest.scala @@ -16,17 +16,19 @@ object ExtendedRunnerTest extends BasicTestSuite: if (isWindows) new File("launcher-package/target/universal/stage/bin/sbt.bat") else new File("launcher-package/target/universal/stage/bin/sbt") + 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, + launcherCmd ++ args, new File("launcher-package/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" -> "" diff --git a/launcher-package/integration-test/src/test/scala/LauncherTestHelper.scala b/launcher-package/integration-test/src/test/scala/LauncherTestHelper.scala new file mode 100644 index 000000000..711c18817 --- /dev/null +++ b/launcher-package/integration-test/src/test/scala/LauncherTestHelper.scala @@ -0,0 +1,22 @@ +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) + Seq("java", "-cp", System.getProperty("java.class.path"), "sbtw.Main") + else + Seq(scriptPath) +} diff --git a/launcher-package/integration-test/src/test/scala/ShellScriptUtil.scala b/launcher-package/integration-test/src/test/scala/ShellScriptUtil.scala index 22de0f5a3..ce4ec885c 100644 --- a/launcher-package/integration-test/src/test/scala/ShellScriptUtil.scala +++ b/launcher-package/integration-test/src/test/scala/ShellScriptUtil.scala @@ -159,9 +159,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* ) From a1150363788f59e6a33f9f5323e34798fa7437f0 Mon Sep 17 00:00:00 2001 From: bitloi Date: Sun, 15 Feb 2026 12:05:25 +0100 Subject: [PATCH 07/13] Fix sbtw integration tests on Windows: pass full test classpath When useSbtw=true, LauncherTestHelper runs 'java -cp ... sbtw.Main' using java.class.path, which is incomplete in the test JVM. Pass the full test classpath via -Dsbt.test.classpath in launcherPackageIntegrationTest (fork=true, javaOptions) and use it in LauncherTestHelper when set. --- build.sbt | 5 +++++ .../src/test/scala/LauncherTestHelper.scala | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/build.sbt b/build.sbt index f99ce0ba2..2a32db69d 100644 --- a/build.sbt +++ b/build.sbt @@ -1391,6 +1391,11 @@ 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 / test := { (Test / test) .dependsOn(launcherPackage / Universal / packageBin) diff --git a/launcher-package/integration-test/src/test/scala/LauncherTestHelper.scala b/launcher-package/integration-test/src/test/scala/LauncherTestHelper.scala index 711c18817..31da6d5d8 100644 --- a/launcher-package/integration-test/src/test/scala/LauncherTestHelper.scala +++ b/launcher-package/integration-test/src/test/scala/LauncherTestHelper.scala @@ -15,8 +15,9 @@ object LauncherTestHelper { /** Command prefix to run the launcher: either script path or java -cp sbtw.Main */ def launcherCommand(scriptPath: String): Seq[String] = - if (useSbtw) - Seq("java", "-cp", System.getProperty("java.class.path"), "sbtw.Main") - else + 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) } From 7c1958a9d701c8e6a0b6ce8c7eb439508637737e Mon Sep 17 00:00:00 2001 From: bitloi Date: Sun, 15 Feb 2026 12:12:36 +0100 Subject: [PATCH 08/13] Format build.sbt for scalafmtSbtCheck --- build.sbt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 2a32db69d..c36f316d2 100644 --- a/build.sbt +++ b/build.sbt @@ -1393,7 +1393,9 @@ lazy val launcherPackageIntegrationTest = testFrameworks += TestFramework("verify.runner.Framework"), Test / fork := true, Test / javaOptions += { - val cp = (Test / fullClasspath).value.map(_.data.getAbsolutePath).mkString(java.io.File.pathSeparator) + val cp = (Test / fullClasspath).value + .map(_.data.getAbsolutePath) + .mkString(java.io.File.pathSeparator) s"-Dsbt.test.classpath=$cp" }, Test / test := { From 817880c9c921b1ac1d7120994b326ea990022068 Mon Sep 17 00:00:00 2001 From: bitloi Date: Sun, 15 Feb 2026 13:17:26 +0100 Subject: [PATCH 09/13] Fix launcher integration test paths for forked JVM (Windows CI) Resolve sbt script and citest paths relative to integration-test base directory via sbt.test.integrationtest.basedir, so forked tests no longer depend on CWD. Fixes 'file not found' on Windows when CWD is not the integration-test project dir. --- build.sbt | 1 + .../src/test/scala/BspConfigTest.scala | 4 +-- .../src/test/scala/ExtendedRunnerTest.scala | 6 ++-- .../src/test/scala/IntegrationTestPaths.scala | 32 +++++++++++++++++++ .../src/test/scala/ShellScriptUtil.scala | 8 ++--- 5 files changed, 39 insertions(+), 12 deletions(-) create mode 100644 launcher-package/integration-test/src/test/scala/IntegrationTestPaths.scala diff --git a/build.sbt b/build.sbt index c36f316d2..8cc3cad33 100644 --- a/build.sbt +++ b/build.sbt @@ -1398,6 +1398,7 @@ lazy val launcherPackageIntegrationTest = .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) diff --git a/launcher-package/integration-test/src/test/scala/BspConfigTest.scala b/launcher-package/integration-test/src/test/scala/BspConfigTest.scala index d4339eb3d..70c179fdf 100644 --- a/launcher-package/integration-test/src/test/scala/BspConfigTest.scala +++ b/launcher-package/integration-test/src/test/scala/BspConfigTest.scala @@ -10,9 +10,7 @@ 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) diff --git a/launcher-package/integration-test/src/test/scala/ExtendedRunnerTest.scala b/launcher-package/integration-test/src/test/scala/ExtendedRunnerTest.scala index 5d054a353..2929df95f 100755 --- a/launcher-package/integration-test/src/test/scala/ExtendedRunnerTest.scala +++ b/launcher-package/integration-test/src/test/scala/ExtendedRunnerTest.scala @@ -12,9 +12,7 @@ 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) @@ -22,7 +20,7 @@ object ExtendedRunnerTest extends BasicTestSuite: def sbtProcessWithOpts(args: String*)(javaOpts: String, sbtOpts: String) = Process( launcherCmd ++ args, - new File("launcher-package/citest"), + IntegrationTestPaths.citestDir("citest"), "JAVA_OPTS" -> javaOpts, "SBT_OPTS" -> sbtOpts ) diff --git a/launcher-package/integration-test/src/test/scala/IntegrationTestPaths.scala b/launcher-package/integration-test/src/test/scala/IntegrationTestPaths.scala new file mode 100644 index 000000000..081cf8ec7 --- /dev/null +++ b/launcher-package/integration-test/src/test/scala/IntegrationTestPaths.scala @@ -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 + } +} diff --git a/launcher-package/integration-test/src/test/scala/ShellScriptUtil.scala b/launcher-package/integration-test/src/test/scala/ShellScriptUtil.scala index ce4ec885c..3d8f28f18 100644 --- a/launcher-package/integration-test/src/test/scala/ShellScriptUtil.scala +++ b/launcher-package/integration-test/src/test/scala/ShellScriptUtil.scala @@ -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()) { From b1c8c46a5b4ca42d82d66a53d9baa1d0da745c6b Mon Sep 17 00:00:00 2001 From: bitloi Date: Sun, 15 Feb 2026 23:38:50 +0100 Subject: [PATCH 10/13] Address PR review: optional braces, JProcess for interactive run, workflow comment - ArgParser: use optional brace / fewer braces style per sbt coding style - Runner.runJvm: use Java ProcessBuilder with inheritIO() for interactive sbt - client-test.yml: clarify why sbtw native image is not smoke-tested --- .github/workflows/client-test.yml | 2 +- sbtw/src/main/scala/sbtw/ArgParser.scala | 13 +++++-------- sbtw/src/main/scala/sbtw/Runner.scala | 17 +++++++++++------ 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/.github/workflows/client-test.yml b/.github/workflows/client-test.yml index 2b553695d..23453c215 100644 --- a/.github/workflows/client-test.yml +++ b/.github/workflows/client-test.yml @@ -96,7 +96,7 @@ jobs: # 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 (do not run: scopt lazy vals break under Graal native image) + # 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 diff --git a/sbtw/src/main/scala/sbtw/ArgParser.scala b/sbtw/src/main/scala/sbtw/ArgParser.scala index abf5ffbc9..65d980946 100644 --- a/sbtw/src/main/scala/sbtw/ArgParser.scala +++ b/sbtw/src/main/scala/sbtw/ArgParser.scala @@ -2,11 +2,11 @@ package sbtw import scopt.OParser -object ArgParser { +object ArgParser: - def parse(args: Array[String]): Option[LauncherOptions] = { + def parse(args: Array[String]): Option[LauncherOptions] = val b = OParser.builder[LauncherOptions] - val parser = { + val parser = import b.* OParser.sequence( programName("sbtw"), @@ -48,10 +48,7 @@ object ArgParser { .optional() .action((x, c) => c.copy(residual = c.residual :+ x)), ) - } - OParser.parse(parser, args, LauncherOptions()).map { opts => + OParser.parse(parser, args, LauncherOptions()).map: opts => val sbtNew = opts.residual.contains("new") || opts.residual.contains("init") opts.copy(sbtNew = sbtNew) - } - } -} +end ArgParser diff --git a/sbtw/src/main/scala/sbtw/Runner.scala b/sbtw/src/main/scala/sbtw/Runner.scala index 3ccffc4e9..4cde4f710 100644 --- a/sbtw/src/main/scala/sbtw/Runner.scala +++ b/sbtw/src/main/scala/sbtw/Runner.scala @@ -1,6 +1,7 @@ package sbtw import java.io.File +import java.lang.{ Process as JProcess, ProcessBuilder as JProcessBuilder } import scala.sys.process.* object Runner { @@ -91,18 +92,22 @@ object Runner { sbtJar: String, bootArgs: Seq[String], verbose: Boolean - ): Int = { + ): 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) { + if verbose then System.err.println("# Executing command line:") - cmd.foreach(a => System.err.println(if (a.contains(" ")) s""""$a"""" else a)) - } - Process(cmd).! - } + 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 { From 6e534d75cbff0299c6424f432ad9212cd121cf74 Mon Sep 17 00:00:00 2001 From: bitloi Date: Sun, 15 Feb 2026 23:50:44 +0100 Subject: [PATCH 11/13] Remove unused JProcess import in Runner.scala --- sbtw/src/main/scala/sbtw/Runner.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sbtw/src/main/scala/sbtw/Runner.scala b/sbtw/src/main/scala/sbtw/Runner.scala index 4cde4f710..f8f964b9d 100644 --- a/sbtw/src/main/scala/sbtw/Runner.scala +++ b/sbtw/src/main/scala/sbtw/Runner.scala @@ -1,7 +1,7 @@ package sbtw import java.io.File -import java.lang.{ Process as JProcess, ProcessBuilder as JProcessBuilder } +import java.lang.ProcessBuilder as JProcessBuilder import scala.sys.process.* object Runner { From a1ec294a6db2baa74ddca4417c90b6f643bd114f Mon Sep 17 00:00:00 2001 From: bitloi Date: Mon, 16 Feb 2026 00:12:18 +0100 Subject: [PATCH 12/13] Apply scalafmt to ArgParser.scala --- sbtw/src/main/scala/sbtw/ArgParser.scala | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/sbtw/src/main/scala/sbtw/ArgParser.scala b/sbtw/src/main/scala/sbtw/ArgParser.scala index 65d980946..719a5b9a4 100644 --- a/sbtw/src/main/scala/sbtw/ArgParser.scala +++ b/sbtw/src/main/scala/sbtw/ArgParser.scala @@ -48,7 +48,9 @@ object ArgParser: .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) + OParser + .parse(parser, args, LauncherOptions()) + .map: opts => + val sbtNew = opts.residual.contains("new") || opts.residual.contains("init") + opts.copy(sbtNew = sbtNew) end ArgParser From 72e3b14f30fb6c663fca69e4aae8c056297950c4 Mon Sep 17 00:00:00 2001 From: bitloi Date: Mon, 16 Feb 2026 01:16:55 +0100 Subject: [PATCH 13/13] Apply Scala 3 optional brace style to all sbtw sources Per contributing-docs/03_coding_style.md: object/trait with : and end, if/while with then/do, fewer braces, colon syntax for lambdas. --- sbtw/src/main/scala/sbtw/ConfigLoader.scala | 52 +++++------ .../src/main/scala/sbtw/LauncherOptions.scala | 4 +- sbtw/src/main/scala/sbtw/Main.scala | 91 ++++++++----------- sbtw/src/main/scala/sbtw/Memory.scala | 22 ++--- sbtw/src/main/scala/sbtw/Runner.scala | 89 ++++++++---------- 5 files changed, 111 insertions(+), 147 deletions(-) diff --git a/sbtw/src/main/scala/sbtw/ConfigLoader.scala b/sbtw/src/main/scala/sbtw/ConfigLoader.scala index fa1056028..f814ab064 100644 --- a/sbtw/src/main/scala/sbtw/ConfigLoader.scala +++ b/sbtw/src/main/scala/sbtw/ConfigLoader.scala @@ -4,20 +4,20 @@ import java.io.File import scala.io.Source import scala.util.Using -object ConfigLoader { +object ConfigLoader: def loadLines(file: File): Seq[String] = - if (!file.isFile) Nil + if !file.isFile then Nil else try - Using.resource(Source.fromFile(file))(_.getLines().toList.flatMap { line => + Using.resource(Source.fromFile(file))(_.getLines().toList.flatMap: line => val trimmed = line.trim - if (trimmed.isEmpty || trimmed.startsWith("#")) Nil + if trimmed.isEmpty || trimmed.startsWith("#") then Nil else Seq(trimmed) - }) + ) catch { case _: Exception => Nil } - def loadSbtOpts(cwd: File, sbtHome: File): Seq[String] = { + 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") @@ -28,38 +28,34 @@ object ConfigLoader { val fromEtcLines = loadLines(fromEtc) val fromSbtConfigLines = loadLines(fromSbtConfig) (fromEtcLines ++ fromConfigLines ++ fromSbtConfigLines ++ fromEnv ++ fromProjectLines).toSeq - } - def loadJvmOpts(cwd: File): Seq[String] = { + 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")) s.substring(2).trim else s + 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] = { + def sbtVersionFromBuildProperties(projectDir: File): Option[String] = val f = new File(projectDir, "project/build.properties") - if (!f.isFile) return None - 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) Some(line.substring(eq + 1).trim).filter(_.nonEmpty) - else None - } - } - catch { case _: Exception => None } - } + 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 diff --git a/sbtw/src/main/scala/sbtw/LauncherOptions.scala b/sbtw/src/main/scala/sbtw/LauncherOptions.scala index f535f0616..bc95e8f55 100644 --- a/sbtw/src/main/scala/sbtw/LauncherOptions.scala +++ b/sbtw/src/main/scala/sbtw/LauncherOptions.scala @@ -36,7 +36,7 @@ case class LauncherOptions( sbtNew: Boolean = false, ) -object LauncherOptions { +object LauncherOptions: val defaultMemMb = 1024 val initSbtVersion = "_to_be_replaced" -} +end LauncherOptions diff --git a/sbtw/src/main/scala/sbtw/Main.scala b/sbtw/src/main/scala/sbtw/Main.scala index feb26e2b4..52e2920b0 100644 --- a/sbtw/src/main/scala/sbtw/Main.scala +++ b/sbtw/src/main/scala/sbtw/Main.scala @@ -3,157 +3,141 @@ package sbtw import java.io.File import scala.sys.process.* -object Main { +object Main: - def main(args: Array[String]): Unit = { + 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 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 { + 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) 0 else 1) - } - } + System.exit(if exitCode == 0 then 0 else 1) - private def run(cwd: File, sbtHome: File, sbtBinDir: File, opts: LauncherOptions): Int = { - if (opts.help) return printUsage() - if (opts.version || opts.numericVersion || opts.scriptVersion) { + 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) { + if opts.shutdownAll then val javaCmd = Runner.findJavaCmd(opts.javaHome) return Runner.shutdownAll(javaCmd) - } - if (!opts.allowEmpty && !opts.sbtNew && !ConfigLoader.isSbtProjectDir(cwd)) { + 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) { + 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) { + 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) { + 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) javaOpts = ConfigLoader.defaultJavaOpts + 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) { + 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) - } + else Memory.addDefaultMemory(javaOpts, sbtOpts, javaVer, LauncherOptions.defaultMemMb) sbtOpts = finalSbt - if (!opts.noHideJdkWarnings && javaVer == 25) { + 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) return false - if (opts.jvmClient) return false + ): 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) !opts.server - else if (major >= 1 && minor >= 4) opts.client + 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) { + ): 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) { + if !new File(sbtJar).isFile then System.err.println("[error] Launcher jar not found for version check") return 1 - } - if (opts.numericVersion) { - try { + 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) { - if (ConfigLoader.isSbtProjectDir(cwd)) { + 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 = { + private def printUsage(): Int = println(""" |Usage: sbtw [options] | @@ -177,5 +161,4 @@ object Main { | -J-X pass -X to the JVM (-J is stripped) |""".stripMargin) 0 - } -} +end Main diff --git a/sbtw/src/main/scala/sbtw/Memory.scala b/sbtw/src/main/scala/sbtw/Memory.scala index 2096dfc07..c8b0dde3f 100644 --- a/sbtw/src/main/scala/sbtw/Memory.scala +++ b/sbtw/src/main/scala/sbtw/Memory.scala @@ -1,6 +1,6 @@ package sbtw -object Memory { +object Memory: private val memoryOptPrefixes = Set( "-Xmx", @@ -22,10 +22,10 @@ object Memory { def evictMemoryOpts(opts: Seq[String]): Seq[String] = opts.filter(o => !memoryOptPrefixes.exists(p => o.startsWith(p))) - def addMemory(memMb: Int, javaVersion: Int): Seq[String] = { + def addMemory(memMb: Int, javaVersion: Int): Seq[String] = var codecache = memMb / 8 - if (codecache > 512) codecache = 512 - if (codecache < 128) codecache = 128 + if codecache > 512 then codecache = 512 + if codecache < 128 then codecache = 128 val classMetadataSize = codecache * 2 val base = Seq( s"-Xms${memMb}m", @@ -33,28 +33,24 @@ object Memory { "-Xss4M", s"-XX:ReservedCodeCacheSize=${codecache}m" ) - if (javaVersion < 8) base :+ s"-XX:MaxPermSize=${classMetadataSize}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]) = { + ): (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) - (javaOpts, sbtOpts) - else { + 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 diff --git a/sbtw/src/main/scala/sbtw/Runner.scala b/sbtw/src/main/scala/sbtw/Runner.scala index f8f964b9d..06bc6d7e9 100644 --- a/sbtw/src/main/scala/sbtw/Runner.scala +++ b/sbtw/src/main/scala/sbtw/Runner.scala @@ -4,13 +4,13 @@ import java.io.File import java.lang.ProcessBuilder as JProcessBuilder import scala.sys.process.* -object Runner { +object Runner: - def findJavaCmd(javaHome: Option[String]): String = { - val cmd = javaHome match { + def findJavaCmd(javaHome: Option[String]): String = + val cmd = javaHome match case Some(h) => val exe = new File(h, "bin/java.exe") - if (exe.isFile) exe.getAbsolutePath + if exe.isFile then exe.getAbsolutePath else sys.env .get("JAVACMD") @@ -23,31 +23,28 @@ object Runner { .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 { + 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) + if quoted.startsWith("1.") && parts.nonEmpty then scala.util.Try(parts(0).toInt).toOption.getOrElse(major) else major - } catch { case _: Exception => 0 } - } + catch { case _: Exception => 0 } - def buildSbtOpts(opts: LauncherOptions): Seq[String] = { + def buildSbtOpts(opts: LauncherOptions): Seq[String] = var s: Seq[String] = Nil - if (opts.debug) s = s :+ "-debug" - 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) + 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", @@ -60,30 +57,27 @@ object Runner { 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) s = s ++ Seq("-Dsbt.task.timings=true", "-Dsbt.task.timings.on.shutdown=true") - if (opts.traces) s = s :+ "-Dsbt.traces=true" - if (opts.noServer) s = s ++ Seq("-Dsbt.io.virtual=false", "-Dsbt.server.autostart=false") - if (opts.jvmClient) s = s :+ "--client" + 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 = { + def runNativeClient(sbtBinDir: File, scriptPath: String, opts: LauncherOptions): Int = val sbtn = new File(sbtBinDir, "sbtn-x86_64-pc-win32.exe") - if (!sbtn.isFile) { + 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) Seq("-v") else Nil) ++ + (if opts.verbose then Seq("-v") else Nil) ++ opts.residual val cmd = sbtn.getAbsolutePath +: args - if (opts.verbose) { + 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, @@ -109,44 +103,39 @@ object Runner { p.exitValue() finally if p.isAlive then p.destroy() - def shutdownAll(javaCmd: String): Int = { - try { + def shutdownAll(javaCmd: String): Int = + try val jpsOut = Process(Seq("jps", "-lv")).!! val pids = jpsOut.linesIterator .filter(_.contains("xsbt.boot.Boot")) - .flatMap { line => + .flatMap: line => val pidStr = line.trim.takeWhile(_.isDigit) - if (pidStr.nonEmpty) scala.util.Try(pidStr.toLong).toOption else None - } + if pidStr.nonEmpty then scala.util.Try(pidStr.toLong).toOption else None .toList - pids.foreach { pid => + 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 } - } + catch { case _: Exception => 1 } - def splitResidual(residual: Seq[String]): (Seq[String], Seq[String]) = { + 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) { + while i < residual.size do val a = residual(i) - if (a.startsWith("-J")) javaOpts = javaOpts :+ a.drop(2) - else if (a.startsWith("-X")) javaOpts = javaOpts :+ a - else if (a.startsWith("-D") && a.contains("=")) bootArgs = bootArgs :+ a - else if (a.startsWith("-D") && i + 1 < residual.size) { + 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("=")) bootArgs = bootArgs :+ a - else if (a.startsWith("-XX") && i + 1 < residual.size) { + 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 + else bootArgs = bootArgs :+ a i += 1 - } (javaOpts, bootArgs) - } -} +end Runner