mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 13:02:07 +03:00
Add uninstallation functionality to the launcher (#1089)
This commit is contained in:
parent
c979938527
commit
a6b0a96f97
18
build.sbt
18
build.sbt
@ -808,20 +808,20 @@ lazy val parser = (project in file("lib/scala/parser"))
|
||||
fork := true,
|
||||
Cargo.rustVersion := rustVersion,
|
||||
Compile / compile / compileInputs := (Compile / compile / compileInputs)
|
||||
.dependsOn(Cargo("build --project parser"))
|
||||
.value,
|
||||
.dependsOn(Cargo("build --project parser"))
|
||||
.value,
|
||||
javaOptions += {
|
||||
val root = baseDirectory.value.getParentFile.getParentFile.getParentFile
|
||||
s"-Djava.library.path=$root/target/rust/debug"
|
||||
},
|
||||
libraryDependencies ++= Seq(
|
||||
"com.storm-enroute" %% "scalameter" % scalameterVersion % "bench",
|
||||
"org.scalatest" %%% "scalatest" % scalatestVersion % Test,
|
||||
),
|
||||
"com.storm-enroute" %% "scalameter" % scalameterVersion % "bench",
|
||||
"org.scalatest" %%% "scalatest" % scalatestVersion % Test
|
||||
),
|
||||
testFrameworks := List(
|
||||
new TestFramework("org.scalatest.tools.Framework"),
|
||||
new TestFramework("org.scalameter.ScalaMeterFramework")
|
||||
),
|
||||
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)
|
||||
)
|
||||
|
@ -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.
|
||||
*
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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 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) =>
|
||||
removeOldExecutable(oldExecutablePath)
|
||||
sys.exit(0)
|
||||
case None =>
|
||||
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)
|
||||
}
|
||||
|
||||
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,38 +121,89 @@ 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
|
||||
@scala.annotation.tailrec
|
||||
def tryDeleting(retries: Int): Unit = {
|
||||
try {
|
||||
Files.delete(oldExecutablePath)
|
||||
} catch {
|
||||
case _: NoSuchFileException =>
|
||||
case e: IOException =>
|
||||
if (retries == retryBaseAmount) {
|
||||
System.err.println(
|
||||
s"Could not remove $oldExecutablePath, will retry several " +
|
||||
s"times for 15s..."
|
||||
)
|
||||
}
|
||||
private val retryBaseAmount = 30
|
||||
|
||||
if (retries > 0) {
|
||||
Thread.sleep(500)
|
||||
tryDeleting(retries - 1)
|
||||
} else {
|
||||
e.printStackTrace()
|
||||
System.err.println(
|
||||
s"Cannot delete old executable at $oldExecutablePath after " +
|
||||
s"multiple retries. Please try removing it manually."
|
||||
)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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
|
||||
private def tryDeleting(oldExecutablePath: Path, retries: Int = 30): Unit = {
|
||||
try {
|
||||
Files.delete(oldExecutablePath)
|
||||
} catch {
|
||||
case _: NoSuchFileException =>
|
||||
case e: IOException =>
|
||||
def firstTime = retries == retryBaseAmount
|
||||
if (firstTime) {
|
||||
System.err.println(
|
||||
s"Could not remove $oldExecutablePath, will retry several " +
|
||||
s"times for 15s..."
|
||||
)
|
||||
}
|
||||
|
||||
if (retries > 0) {
|
||||
Thread.sleep(500)
|
||||
tryDeleting(oldExecutablePath, retries - 1)
|
||||
} else {
|
||||
e.printStackTrace()
|
||||
System.err.println(
|
||||
s"Cannot delete old executable at $oldExecutablePath after " +
|
||||
s"multiple retries. Please try removing it manually."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = {
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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}```."
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
|
||||
|
@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
||||
|
@ -202,11 +202,10 @@ 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 firstLine = "Usage: "
|
||||
val padding = " " * firstLine.length
|
||||
val prefix = commandPrefix.mkString(" ")
|
||||
val usages = commandLines().map(s"$prefix\t" + _)
|
||||
val firstLine = "Usage: "
|
||||
val padding = " " * firstLine.length
|
||||
val usage =
|
||||
firstLine + usages.head +
|
||||
usages.tail.map("\n" + padding + _).mkString + "\n"
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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, _))
|
||||
}
|
||||
)
|
||||
|
@ -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,10 +70,11 @@ object EnvironmentCheck {
|
||||
try {
|
||||
val versionStr = cmd.!!.trim.substring(6)
|
||||
|
||||
if (versionStr != expectedVersion) log.error(
|
||||
s"Rust version mismatch. $expectedVersion is expected, " +
|
||||
if (versionStr != expectedVersion)
|
||||
log.error(
|
||||
s"Rust version mismatch. $expectedVersion is expected, " +
|
||||
s"but it seems $versionStr is installed."
|
||||
)
|
||||
)
|
||||
versionStr == expectedVersion
|
||||
} catch {
|
||||
case _ @(_: RuntimeException | _: IOException) =>
|
||||
|
Loading…
Reference in New Issue
Block a user