Add uninstallation functionality to the launcher (#1089)

This commit is contained in:
Radosław Waśko 2020-08-20 13:50:26 +02:00 committed by GitHub
parent c979938527
commit a6b0a96f97
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 621 additions and 71 deletions

View File

@ -816,12 +816,12 @@ lazy val parser = (project in file("lib/scala/parser"))
},
libraryDependencies ++= Seq(
"com.storm-enroute" %% "scalameter" % scalameterVersion % "bench",
"org.scalatest" %%% "scalatest" % scalatestVersion % Test,
"org.scalatest" %%% "scalatest" % scalatestVersion % Test
),
testFrameworks := List(
new TestFramework("org.scalatest.tools.Framework"),
new TestFramework("org.scalameter.ScalaMeterFramework")
),
)
)
.dependsOn(ast)
@ -870,7 +870,7 @@ lazy val runtime = (project in file("engine/runtime"))
),
bootstrap := CopyTruffleJAR.bootstrapJARs.value,
Global / onLoad := EnvironmentCheck.addVersionCheck(
graalVersion,
s"GraalVM CE $graalVersion",
javaVersion
)((Global / onLoad).value)
)

View File

@ -2,7 +2,12 @@ package org.enso.launcher
import java.io.PrintWriter
import java.nio.file.attribute.{PosixFilePermission, PosixFilePermissions}
import java.nio.file.{Files, Path, StandardCopyOption}
import java.nio.file.{
DirectoryNotEmptyException,
Files,
Path,
StandardCopyOption
}
import java.util
import org.apache.commons.io.FileUtils
@ -144,6 +149,34 @@ object FileSystem {
def removeDirectory(dir: Path): Unit =
FileUtils.deleteDirectory(dir.toFile)
/**
* Removes a directory recursively, does not fail if it does not exist.
*/
def removeDirectoryIfExists(dir: Path): Unit =
if (Files.exists(dir))
FileUtils.deleteDirectory(dir.toFile)
/**
* Removes a directory only if it is empty.
*
* Returned value indicates if the directory has been removed.
*/
def removeDirectoryIfEmpty(dir: Path): Boolean =
try {
Files.delete(dir)
true
} catch {
case _: DirectoryNotEmptyException =>
false
}
/**
* Removes a file, if it exists, does not fail if it does not exist.
*/
def removeFileIfExists(path: Path): Unit =
if (Files.exists(path))
Files.delete(path)
/**
* Registers the directory to be removed when the program exits normally.
*

View File

@ -39,5 +39,5 @@ object GlobalConfigurationManager {
/**
* Name of the main global configuration file.
*/
val globalConfigName: String = "global-config.yml"
val globalConfigName: String = "global-config.yaml"
}

View File

@ -6,7 +6,8 @@ import java.nio.file.{Files, NoSuchFileException, Path}
import org.enso.cli.Opts
import org.enso.cli.Opts.implicits._
import cats.implicits._
import org.enso.launcher.OS
import org.enso.launcher.{FileSystem, OS}
import org.enso.launcher.FileSystem.PathSyntax
/**
* Implements internal options that the launcher may use when running another
@ -28,9 +29,18 @@ import org.enso.launcher.OS
* launcher attempts several retries, because it may take some time for the
* executable to be unlocked (especially as on Windows software like an
* antivirus may block it for some more time after terminating).
* 2. Finish Uninstall
* After uninstalling the distribution, we need to remove the executable,
* but for the same reasons as above, we need a workaround. The uninstaller
* creates a copy of itself in a temporary directory (which will be removed
* when the system removes temporary files) and uses it to remove the
* original binary. It then can also remove the (now empty) installation
* directory if necessary.
*/
object InternalOpts {
private val REMOVE_OLD_EXECUTABLE = "internal-remove-old-executable"
private val FINISH_UNINSTALL = "internal-finish-uninstall"
private val FINISH_UNINSTALL_PARENT = "internal-finish-uninstall-parent"
/**
* Additional top level options that are internal to the launcher and should
@ -48,11 +58,38 @@ object InternalOpts {
)
.hidden
removeOldExecutableOpt map {
case Some(oldExecutablePath) =>
val finishUninstallOpt = Opts
.optionalParameter[Path](
FINISH_UNINSTALL,
"PATH",
"Removes the old executable."
)
.hidden
val finishUninstallParentOpt = Opts
.optionalParameter[Path](
FINISH_UNINSTALL_PARENT,
"PATH",
s"Removes the possible parent directories of the old executable. " +
s"To be used only in conjunction with $FINISH_UNINSTALL. Has no " +
s"effect if used without that option."
)
.hidden
(
removeOldExecutableOpt,
finishUninstallOpt,
finishUninstallParentOpt
) mapN {
(removeOldExecutableOpt, finishUninstallOpt, finishUninstallParentOpt) =>
removeOldExecutableOpt.foreach { oldExecutablePath =>
removeOldExecutable(oldExecutablePath)
sys.exit(0)
case None =>
}
finishUninstallOpt.foreach { executablePath =>
finishUninstall(executablePath, finishUninstallParentOpt)
sys.exit(0)
}
}
}
@ -63,6 +100,10 @@ object InternalOpts {
def runWithNewLauncher(pathToNewLauncher: Path): Runner =
new Runner(pathToNewLauncher)
/**
* A helper class used for running the workarounds using another launcher
* executable.
*/
class Runner private[InternalOpts] (pathToNewLauncher: Path) {
/**
@ -80,18 +121,56 @@ object InternalOpts {
)
runDetachedAndExit(command)
}
/**
* Tells the temporary launcher to remove the original launcher executable
* and possibly its parent directory.
*
* The parent directory is removed if it is empty or only contains an empty
* `bin` directory. This is used for cases when the executable is inside of
* the data directory and the data directory cannot be fully removed before
* the executable has been removed.
*
* @param executablePath path to the old executable
* @param parentToRemove path to the parent directory
*/
def finishUninstall(
executablePath: Path,
parentToRemove: Option[Path]
): Nothing = {
val parentParam = parentToRemove.map(parent =>
Seq(s"--$FINISH_UNINSTALL_PARENT", parent.toAbsolutePath.toString)
)
val command = Seq(
pathToNewLauncher.toAbsolutePath.toString,
s"--$FINISH_UNINSTALL",
executablePath.toAbsolutePath.toString
) ++ parentParam.getOrElse(Seq())
runDetachedAndExit(command)
}
}
private def removeOldExecutable(oldExecutablePath: Path): Unit = {
val retryBaseAmount = 30
private val retryBaseAmount = 30
/**
* Tries to remove the file at `oldExecutablePath`, retrying several times if
* needed.
*
* On failure retries every 0.5s for 15s in total. That retry mechanism is in
* place, because a running executable cannot be removed and it may take some
* time for the process to fully terminate (theoretically this time can be
* extended indefinitely, for example if anti-virus software blocks the
* executable for scanning, so this may still fail).
*/
@scala.annotation.tailrec
def tryDeleting(retries: Int): Unit = {
private def tryDeleting(oldExecutablePath: Path, retries: Int = 30): Unit = {
try {
Files.delete(oldExecutablePath)
} catch {
case _: NoSuchFileException =>
case e: IOException =>
if (retries == retryBaseAmount) {
def firstTime = retries == retryBaseAmount
if (firstTime) {
System.err.println(
s"Could not remove $oldExecutablePath, will retry several " +
s"times for 15s..."
@ -100,7 +179,7 @@ object InternalOpts {
if (retries > 0) {
Thread.sleep(500)
tryDeleting(retries - 1)
tryDeleting(oldExecutablePath, retries - 1)
} else {
e.printStackTrace()
System.err.println(
@ -111,7 +190,20 @@ object InternalOpts {
}
}
tryDeleting(retryBaseAmount)
private def removeOldExecutable(oldExecutablePath: Path): Unit =
tryDeleting(oldExecutablePath)
private def finishUninstall(
executablePath: Path,
parentToRemove: Option[Path]
): Unit = {
tryDeleting(executablePath)
parentToRemove.foreach { parent =>
val bin = parent / "bin"
if (Files.exists(bin))
FileSystem.removeDirectoryIfEmpty(bin)
FileSystem.removeDirectoryIfEmpty(parent)
}
}
private def runDetachedAndExit(command: Seq[String]): Nothing = {

View File

@ -12,7 +12,8 @@ import org.enso.launcher.components.runner.LanguageServerOptions
import org.enso.launcher.installation.DistributionInstaller.BundleAction
import org.enso.launcher.installation.{
DistributionInstaller,
DistributionManager
DistributionManager,
DistributionUninstaller
}
import org.enso.launcher.{Launcher, Logger}
@ -251,7 +252,11 @@ object Main {
}
private def installEngineCommand: Subcommand[Config => Unit] =
Subcommand("engine") {
Subcommand(
"engine",
"Installs the specified engine version, defaulting to the latest if " +
"unspecified."
) {
val version = Opts.optionalArgument[SemVer](
"VERSION",
"VERSION specifies the engine version to install. If not provided, the" +
@ -268,7 +273,10 @@ object Main {
}
private def installDistributionCommand: Subcommand[Config => Unit] =
Subcommand("distribution") {
Subcommand(
"distribution",
"Installs Enso on the system, deactivating portable mode."
) {
implicit val bundleActionParser: Argument[BundleAction] = {
case "move" => DistributionInstaller.MoveBundles.asRight
@ -319,7 +327,11 @@ object Main {
}
private def uninstallEngineCommand: Subcommand[Config => Unit] =
Subcommand("engine") {
Subcommand(
"engine",
"Uninstalls the provided engine version. If the corresponding runtime " +
"is not used by any remaining engine installations, it is also removed."
) {
val version = Opts.positionalArgument[SemVer]("VERSION")
version map { version => (config: Config) =>
Launcher(config).uninstallEngine(version)
@ -327,10 +339,18 @@ object Main {
}
private def uninstallDistributionCommand: Subcommand[Config => Unit] =
Subcommand("distribution") {
Opts.pure(()) map { (_: Unit) => (_: Config) =>
Logger.error("Not implemented yet.")
sys.exit(1)
Subcommand(
"distribution",
"Uninstalls whole Enso distribution and all components managed by " +
"it. If `auto-confirm` is set, it will not attempt to remove the " +
"ENSO_DATA_DIRECTORY and ENSO_CONFIG_DIRECTORY if they contain any " +
"unexpected files."
) {
Opts.pure(()) map { (_: Unit) => (config: Config) =>
new DistributionUninstaller(
DistributionManager,
autoConfirm = config.autoConfirm
).uninstall()
}
}

View File

@ -0,0 +1,281 @@
package org.enso.launcher.installation
import java.nio.file.{Files, Path}
import org.apache.commons.io.FileUtils
import org.enso.cli.CLIOutput
import org.enso.launcher.FileSystem.PathSyntax
import org.enso.launcher.cli.InternalOpts
import org.enso.launcher.{FileSystem, GlobalConfigurationManager, Logger, OS}
/**
* Allows to [[uninstall]] an installed distribution.
*
* @param manager a distribution manager instance which defines locations for
* the distribution that will be uninstalled
* @param autoConfirm if set to true, the uninstaller will use defaults
* instead of asking questions
*/
class DistributionUninstaller(
manager: DistributionManager,
autoConfirm: Boolean
) {
/**
* Uninstalls a locally installed (non-portable) distribution.
*
* Removes the launcher executable and the ENSO_DATA_DIRECTORY and
* ENSO_CONFIG_DIRECTORY directories (unless they contain unexpected files).
* If unexpected files are encountered and [[autoConfirm]] is set, the files
* are preserved and the directories are not removed (but the expected
* contents are cleaned anyway), otherwise the program asks if the unexpected
* files should be removed.
*/
def uninstall(): Unit = {
checkPortable()
askConfirmation()
if (OS.isWindows) uninstallWindows()
else uninstallUNIX()
}
/**
* Uninstall strategy for OSes that can remove running executables.
*
* Simply removes each component, starting with the ones that can potentially
* be nested (as config and the binary can be inside of the data directory
* if the user wishes so).
*/
private def uninstallUNIX(): Unit = {
uninstallConfig()
uninstallExecutableUNIX()
uninstallDataContents(deferDataRootRemoval = false)
Logger.info("Successfully uninstalled the distribution.")
}
/**
* Uninstall strategy for Windows, where it is not possible to remove a
* running executable.
*
* The executable has to be removed last as the program must terminate to do
* so. If the executable is inside of the data directory, the directory is
* cleaned, but its removal is deferred and it will be removed just after the
* executable at the end.
*/
private def uninstallWindows(): Unit = {
val deferRootRemoval = isBinaryInsideData
uninstallConfig()
uninstallDataContents(deferRootRemoval)
Logger.info(
"Successfully uninstalled the distribution but for the launcher " +
"executable. It will be removed in a moment after this program " +
"terminates."
)
uninstallExecutableWindows(
if (deferRootRemoval) Some(manager.paths.dataRoot) else None
)
}
/**
* Checks if the launcher is running in portable mode and terminates
* execution if it does.
*
* It prints an explanation that uninstall can only be used with non-portable
* mode and a tip on how the portable distribution can be easily removed
* manually.
*/
private def checkPortable(): Unit = {
if (manager.isRunningPortable) {
Logger.warn(
"The Enso distribution you are currently running is in portable " +
"mode, so it cannot be uninstalled."
)
Logger.info(
s"If you still want to remove it, you can just remove the " +
s"`${manager.paths.dataRoot}` directory."
)
sys.exit(1)
}
}
/**
* Prints an explanation of what will be uninstalled and which directories
* will be removed and asks the user if they want to proceed.
*/
private def askConfirmation(): Unit = {
Logger.info(
s"Uninstalling this distribution will remove the launcher located at " +
s"`${manager.env.getPathToRunningExecutable}`, all engine and runtime " +
s"components and configuration managed by this distribution."
)
Logger.info(
s"ENSO_DATA_DIRECTORY (${manager.paths.dataRoot}) and " +
s"ENSO_CONFIG_DIRECTORY (${manager.paths.config}) will be removed " +
s"unless they contain unexpected files."
)
if (!autoConfirm) {
val proceed =
CLIOutput.askConfirmation("Do you want to proceed?", yesDefault = true)
if (!proceed) {
Logger.warn("Installation has been cancelled on user request.")
sys.exit(1)
}
}
}
/**
* True if the currently running executable is inside of the data root.
*
* This is checked, because on Windows this will make removing data root more
* complicated.
*/
private def isBinaryInsideData: Boolean = {
val binaryPath =
manager.env.getPathToRunningExecutable.toAbsolutePath.normalize
val dataPath = manager.paths.dataRoot.toAbsolutePath.normalize
binaryPath.startsWith(dataPath)
}
/**
* Removes the configuration file and the ENSO_CONFIG_DIRECTORY if it does
* not contain any other files (or if the user agreed to remove them too).
*/
private def uninstallConfig(): Unit = {
FileSystem.removeFileIfExists(
manager.paths.config / GlobalConfigurationManager.globalConfigName
)
val remaining =
FileSystem.listDirectory(manager.paths.config).map(_.getFileName.toString)
handleRemainingFiles(
manager.LocallyInstalledDirectories.ENSO_CONFIG_DIRECTORY,
manager.paths.config,
remaining
)
FileSystem.removeDirectoryIfEmpty(manager.paths.config)
}
/**
* Files that are expected to be inside of the data root.
*/
private val knownDataFiles = Seq("README.md", "NOTICE")
/**
* Directories that are expected to be inside of the data root.
*/
private val knownDataDirectories = Seq("tmp", "components-licences", "config")
/**
* Removes all files contained in the ENSO_DATA_DIRECTORY and possibly the
* directory itself.
*
* If `deferDataRootRemoval` is set, the directory itself is not removed
* because removing the `bin` directory may block this action. Other files
* and directories are removed nonetheless,so only the `bin` directory and
* the root itself are removed at the end.
*/
private def uninstallDataContents(deferDataRootRemoval: Boolean): Unit = {
FileSystem.removeDirectory(manager.paths.engines)
FileSystem.removeDirectory(manager.paths.runtimes)
val dataRoot = manager.paths.dataRoot
for (dirName <- knownDataDirectories) {
FileSystem.removeDirectoryIfExists(dataRoot / dirName)
}
for (fileName <- knownDataFiles) {
FileSystem.removeFileIfExists(dataRoot / fileName)
}
if (!deferDataRootRemoval) {
val nestedBinDirectory = dataRoot / "bin"
if (Files.exists(nestedBinDirectory))
FileSystem.removeDirectoryIfEmpty(nestedBinDirectory)
}
val ignoredFiles = if (deferDataRootRemoval) Set("bin") else Set()
val remainingFiles = FileSystem
.listDirectory(dataRoot)
.map(_.getFileName.toString)
.toSet -- ignoredFiles
if (remainingFiles.nonEmpty) {
handleRemainingFiles(
manager.LocallyInstalledDirectories.ENSO_DATA_DIRECTORY,
dataRoot.toAbsolutePath.normalize,
remainingFiles.toSeq
)
}
if (!deferDataRootRemoval) {
FileSystem.removeDirectoryIfEmpty(dataRoot)
}
}
/**
* Common logic for handling unexpected files in ENSO_DATA_DIRECTORY and
* ENSO_CONFIG_DIRECTORY.
*
* It asks the user if they want to remove these files unless `auto-confirm`
* is set, in which case it just prints a warning.
*
* @param directoryName name of the directory
* @param path path to the directory
* @param remainingFiles sequence of filenames that are present in that
* directory but are not expected
*/
private def handleRemainingFiles(
directoryName: String,
path: Path,
remainingFiles: Seq[String]
): Unit =
if (remainingFiles.nonEmpty) {
def remainingFilesList =
remainingFiles.map(fileName => s"`$fileName`").mkString(", ")
if (autoConfirm) {
Logger.warn(
s"$directoryName ($path) contains unexpected files: " +
s"$remainingFilesList, so it will not be removed."
)
} else {
Logger.warn(
s"$directoryName ($path) contains unexpected files: " +
s"$remainingFilesList."
)
def confirmation =
CLIOutput.askConfirmation(
s"Do you want to remove the $directoryName containing these files?"
)
if (confirmation) {
for (fileName <- remainingFiles) {
FileUtils.forceDelete((path / fileName).toFile)
}
}
}
}
/**
* Uninstalls the executable on platforms that allow for removing running
* files.
*
* Simply removes the file.
*/
private def uninstallExecutableUNIX(): Unit = {
FileSystem.removeFileIfExists(manager.env.getPathToRunningExecutable)
}
/**
* Uninstalls the executable on Windows where it is impossible to remove an
* executable that is running.
*
* Uses a workaround implemented in [[InternalOpts]]. Has to be run at the
* very end as it has to terminate the current executable.
*/
private def uninstallExecutableWindows(
parentToRemove: Option[Path]
): Nothing = {
val temporaryLauncher =
Files.createTempDirectory("enso-uninstall") / OS.executableName("enso")
val oldLauncher = manager.env.getPathToRunningExecutable
Files.copy(oldLauncher, temporaryLauncher)
InternalOpts
.runWithNewLauncher(temporaryLauncher)
.finishUninstall(oldLauncher, parentToRemove)
}
}

View File

@ -37,8 +37,10 @@ trait NativeTest extends AnyWordSpec with Matchers with TimeLimitedTests {
override def apply(left: RunResult): MatchResult =
MatchResult(
left.exitCode == 0,
s"Run did not exit with success but exit with code ${left.exitCode}.",
s"Run did not fail as expected."
s"Run did not exit with success but exit with code ${left.exitCode}.\n" +
s"Its stderr was: ```${left.stderr}```.\n" +
s"And stdout was: ```${left.stdout}```.",
s"Run did not fail as expected. It printed ```${left.stdout}```."
)
}

View File

@ -17,7 +17,7 @@ class InstallerSpec extends NativeTest with WithTemporaryDirectory {
FileSystem.writeTextFile(portableRoot / ".enso.portable", "mark")
Files.createDirectories(portableRoot / "config")
FileSystem.writeTextFile(
portableRoot / "config" / "global-config.yml",
portableRoot / "config" / "global-config.yaml",
"what: ever"
)
}
@ -80,7 +80,7 @@ class InstallerSpec extends NativeTest with WithTemporaryDirectory {
"The installed file should be executable."
)
val config = installedRoot / "config" / "global-config.yml"
val config = installedRoot / "config" / "global-config.yaml"
config.toFile should exist
readFileContent(config).stripTrailing() shouldEqual "what: ever"

View File

@ -0,0 +1,116 @@
package org.enso.launcher.installation
import java.nio.file.{Files, Path}
import org.enso.launcher.FileSystem.PathSyntax
import org.enso.launcher.{FileSystem, NativeTest, OS, WithTemporaryDirectory}
class UninstallerSpec extends NativeTest with WithTemporaryDirectory {
def installedRoot: Path = getTestDirectory / "installed"
/**
* Prepares an installed distribution for the purposes of testing
* uninstallation.
*
* @param everythingInsideData if true, config and binary directory are put
* inside the data root
* @return returns the path to the created launcher and a mapping of
* environment overrides that need to be used for it to use the
* correct installation directory
*/
def prepareInstalledDistribution(
everythingInsideData: Boolean = false
): (Path, Map[String, String]) = {
val binDirectory =
if (everythingInsideData) installedRoot / "bin" else getTestDirectory
val configDirectory =
if (everythingInsideData) installedRoot / "config"
else getTestDirectory / "enso-config"
val dataDirectory = installedRoot
val portableLauncher = binDirectory / OS.executableName("enso")
copyLauncherTo(portableLauncher)
Files.createDirectories(dataDirectory / "dist")
Files.createDirectories(configDirectory)
FileSystem.writeTextFile(
configDirectory / "global-config.yaml",
"what: ever"
)
FileSystem.writeTextFile(dataDirectory / "README.md", "content")
Files.createDirectories(dataDirectory / "tmp")
val env = Map(
"ENSO_DATA_DIRECTORY" -> dataDirectory.toAbsolutePath.normalize.toString,
"ENSO_BIN_DIRECTORY" -> binDirectory.toAbsolutePath.normalize.toString,
"ENSO_CONFIG_DIRECTORY" -> configDirectory.toAbsolutePath.normalize.toString
)
(portableLauncher, env)
}
"enso uninstall distribution" should {
"uninstall a simple distribution" in {
val (launcher, env) = prepareInstalledDistribution()
runLauncherAt(
launcher,
Seq("--auto-confirm", "uninstall", "distribution"),
env
) should returnSuccess
assert(Files.notExists(installedRoot), "Should remove the data root.")
assert(
Files.notExists(getTestDirectory / "enso-config"),
"Should remove the configuration directory."
)
assert(Files.notExists(launcher), "Should remove the executable.")
}
"uninstall a distribution with config and bin inside of data" in {
val (launcher, env) =
prepareInstalledDistribution(everythingInsideData = true)
runLauncherAt(
launcher,
Seq("--auto-confirm", "uninstall", "distribution"),
env
) should returnSuccess
assert(Files.notExists(installedRoot), "Should remove the data root.")
}
"not remove unknown files by default when uninstalling" in {
val (launcher, env) = prepareInstalledDistribution()
val configFile = getTestDirectory / "enso-config" / "unknown-file"
FileSystem.writeTextFile(configFile, "mark")
val dataFile = installedRoot / "unknown-file2"
FileSystem.writeTextFile(dataFile, "mark")
runLauncherAt(
launcher,
Seq("--auto-confirm", "uninstall", "distribution"),
env
) should returnSuccess
assert(
Files.exists(installedRoot),
"Should not remove the data root with extra files."
)
assert(
Files.exists(getTestDirectory / "enso-config"),
"Should not remove the configuration root with extra files."
)
assert(Files.exists(dataFile), "Should not remove unknown files.")
assert(Files.exists(configFile), "Should not remove unknown files.")
assert(
Files.notExists(
getTestDirectory / "enso-config" / "global-config.yaml"
),
"But the known ones should be removed."
)
assert(
Files.notExists(installedRoot / "dist"),
"But the known ones should be removed."
)
assert(Files.notExists(launcher), "Should remove the executable.")
}
}
}

View File

@ -38,10 +38,11 @@ case class Command[A](
* [[Opts.subcommands]].
*
* @param name name of the subcommand
* @param comment a help comment displayed in the commands help text
* @param opts parsing logic for the subcommand's options
* @tparam A type returned by the command
*/
case class Subcommand[A](name: String)(val opts: Opts[A])
case class Subcommand[A](name: String, comment: String)(val opts: Opts[A])
object Command {

View File

@ -202,9 +202,8 @@ trait Opts[A] {
* entries are commands/subcommands.
*/
def help(commandPrefix: Seq[String]): String = {
val tableDivider = "\t"
val prefix = commandPrefix.mkString(" ")
val usages = commandLines().map(s"$prefix$tableDivider" + _)
val usages = commandLines().map(s"$prefix\t" + _)
val firstLine = "Usage: "
val padding = " " * firstLine.length
val usage =

View File

@ -87,8 +87,13 @@ class SubcommandOpt[A](subcommands: NonEmptyList[Subcommand[A]])
subcommands.toList.flatMap(_.opts.additionalHelp()).distinct
override def commandLines(): NonEmptyList[String] = {
def prefixedCommandLines(command: Subcommand[_]): NonEmptyList[String] =
command.opts.commandLines().map(command.name + " " + _)
def prefixedCommandLines(command: Subcommand[_]): NonEmptyList[String] = {
val prefix = command.name + " "
val suffix = s"\n\t${command.comment}"
command.opts
.commandLines()
.map(commandLine => prefix + commandLine + suffix)
}
subcommands.flatMap(prefixedCommandLines)
}

View File

@ -242,10 +242,10 @@ class OptsSpec
"subcommands" should {
val opt = Opts.subcommands(
Subcommand("cmd1") {
Subcommand("cmd1", "cmd1 help") {
Opts.flag("flag1", "", showInUsage = true).map((1, _))
},
Subcommand("cmd2") {
Subcommand("cmd2", "cmd1 help") {
Opts.flag("flag2", "", showInUsage = true).map((2, _))
}
)

View File

@ -24,7 +24,7 @@ object EnvironmentCheck {
val javaSpecificationVersion =
System.getProperty("java.vm.specification.version")
val graalVersion =
System.getProperty("org.graalvm.version")
System.getProperty("java.vendor.version")
val graalOk =
if (graalVersion == null) {
@ -70,7 +70,8 @@ object EnvironmentCheck {
try {
val versionStr = cmd.!!.trim.substring(6)
if (versionStr != expectedVersion) log.error(
if (versionStr != expectedVersion)
log.error(
s"Rust version mismatch. $expectedVersion is expected, " +
s"but it seems $versionStr is installed."
)