New 'sbt-update' action enabled by mixing in UpdateSbt:

Changes 'sbt.version' after verifying the version is valid by downloading that version of sbt.
 Updates the current launcher jar or the location specified in the second argument with either a downloaded sbt-launch-<version>.jar if available or a copy of the current launcher jar that uses <version> by default when starting new projects.
This commit is contained in:
Mark Harrah 2010-02-16 19:12:18 -05:00
parent a9b69e4425
commit ff6395fc35
1 changed files with 213 additions and 0 deletions

View File

@ -0,0 +1,213 @@
/* sbt -- Simple Build Tool
* Copyright 2010 Mark Harrah
*/
package sbt
/** A trait that provides a task for updating sbt. */
trait UpdateSbt extends Project
{
/** The first argument is the version to update to and is mandatory.
* The second argument is the location of the launcher jar. If omitted, the launcher used to launch the currently running instance of sbt is used.*/
lazy val sbtUpdate = task { args => task { (new Update(this))(args) } } describedAs("Updates the version of sbt used to build this project and updates the launcher jar.")
}
import java.io.{File, InputStream, IOException}
import java.net.{HttpURLConnection, URL}
import HttpURLConnection.{HTTP_NOT_FOUND , HTTP_OK}
import SimpleReader.readLine
import xsbt.FileUtilities.{classLocationFile, copyFile, readLines, transfer, unzip, withTemporaryDirectory, write, zip}
import xsbt.PathMapper.relativeTo
import xsbt.Paths._
import xsbt.OpenResource.{fileOutputStream, urlInputStream}
private class Update(project: Project)
{
val info = project.info
val app = info.app
val log = project.log
/** The location of the jar used to launch the currently running instance of sbt.*/
lazy val launcherJar = classLocationFile[xsbti.AppProvider]
/** A temporary jar file to use in the given directory. */
def tempJar(dir: File) = dir / launcherJar.getName
/** Implementation of the sbt-update task: reads arguments and hands off to the other `apply`.*/
def apply(args: Array[String]): Option[String] =
args match
{
case Array(version) if validVersion(version) => apply(version, None)
case Array(version, temporaryJar) if validVersion(version) => apply(version, Some(new File(temporaryJar) getAbsoluteFile))
case _ => Some("Expected '<version>' or '<version> <new-launcher-file>', got '" + args.mkString(" ") + "'")
}
def validVersion(version: String) = !version.trim.isEmpty
/** Implementation of the sbt-update task after arguments have checked. Gives user a chance to cancel and continues with `doUpdate`.*/
def apply(version: String, temporaryJar: Option[File]): Option[String] =
{
readLine("Updating the sbt version requires a restart. Continue? (Y/n) ") match
{
case Some(line) if(isYes(line)) => doUpdate(version, temporaryJar)
case _ => Some("Update declined.")
}
}
/** Implementation of the sbt-update task: high-level control after initial verification.*/
def doUpdate(version: String, temporaryJar: Option[File]): Option[String] =
{
retrieveNewVersion(version)
log.info("Version is valid. Setting 'sbt.version' to " + version + "...")
setNewVersion(version)
log.info("'sbt.version' updated.")
if(temporaryJar.isDefined || updateInPlace(version))
{
log.info("Downloading new launcher ...")
if(downloadLauncher(version, temporaryJar))
log.info("Downloaded launcher.")
else
tryUpdateLauncher(version, temporaryJar)
}
else
log.info("Launcher update declined.")
log.info("Please restart sbt.")
System.exit(0)
None
}
/** Updates 'sbt.version' in `project/build.properties`.*/
def setNewVersion(version: String)
{
project.sbtVersion() = version
project.saveEnvironment()
}
/** Retrieves the given `version` of sbt in order to verify the version is valid.*/
def retrieveNewVersion(version: String)
{
val newAppID = changeVersion(app.id, version)
log.info("Checking repositories for sbt " + version + " ...")
app.scalaProvider.app(newAppID)
}
/** Asks the user whether the current launcher should be overrwritten. Called when no file is explicitly specified as an argument. */
def updateInPlace(version: String) =
{
val input = readLine(" The current launcher (" + launcherJar + ") will be updated in place. Continue? (Y/n) ")
isYes(input)
}
def isYes(line: Option[String]): Boolean = line.filter(isYes).isDefined
/** Updates the launcher as in `updateLauncher` but performs various checks and logging around it. */
def tryUpdateLauncher(version: String, temporaryJar: Option[File])
{
log.warn("No launcher found for '" + version + "'")
def promptStart = if(temporaryJar.isDefined) " Copy current launcher but with " else " Modify current launcher to use "
val input = readLine(promptStart + version + " as the default for new projects? (Y/n) ")
val updated = isYes(input)
if(updated) updateLauncher(version, temporaryJar)
def extra = if(temporaryJar.isDefined) " at " + temporaryJar.get + "." else "."
log.info(if(updated) "Launcher updated" + extra else "Launcher not updated.")
}
/** The jar to copy/download to. If `userJar` is not defined, it is a temporary file in `tmpDir` that should then be moved to the current launcher file.
* If it is defined and is a directory, the jar is defined in that directory. If it is a file, that file is returned. */
def targetJar(tmpDir: File, userJar: Option[File]): File =
userJar match { case Some(file) => if(file.isDirectory) tempJar(file) else file; case None => tempJar(tmpDir) }
/** Gets the given `version` of the launcher from Google Code. If `userProvidedJar` is defined,
* this updated launcher is downloaded there, otherwise it overwrites the current launcher. */
def downloadLauncher(version: String, userProvidedJar: Option[File]): Boolean =
{
def getLauncher(tmp: File): Boolean =
{
val temporaryJar = targetJar(tmp, userProvidedJar)
temporaryJar.getParentFile.mkdirs()
val url = launcherURL(version)
val connection = url.openConnection.asInstanceOf[HttpURLConnection]
connection.setInstanceFollowRedirects(false)
def download(in: InputStream): Unit = fileOutputStream(false)(temporaryJar) { out => transfer(in, out) }
def checkAndRetrieve(in: InputStream): Boolean = (connection.getResponseCode == HTTP_OK) && { download(in); true }
def handleError(e: IOException) = if(connection.getResponseCode == HTTP_NOT_FOUND ) false else throw e
def retrieve() =
{
val in = connection.getInputStream
try { checkAndRetrieve(in) } finally { in.close() }
}
val success = try { retrieve() } catch { case e: IOException => handleError(e)} finally { connection.disconnect() }
if(success && userProvidedJar.isEmpty)
move(temporaryJar, launcherJar)
success
}
withTemporaryDirectory(getLauncher)
}
/** The location of the launcher for the given version, if it exists. */
def launcherURL(version: String): URL =
new URL("http://simple-build-tool.googlecode.com/files/sbt-launch-" + version + ".jar")
/** True iff the given user input is empty, 'y' or 'yes' (case-insensitive).*/
def isYes(line: String) =
{
val lower = line.toLowerCase
lower.isEmpty || lower == "y" || lower == "yes"
}
/** Copies the current launcher but with the default 'sbt.version' set to `version`. If `userProvidedJar` is defined,
* the updated launcher is copied there, otherwise the copy overwrites the current launcher. */
def updateLauncher(version: String, userProvidedJar: Option[File])
{
def makeUpdated(base: File, newJar: File)
{
val files = unzip(launcherJar, base)
updateBootProperties(files, version)
zip(relativeTo(base)( files ), newJar)
}
def updateLauncher(tmp: File)
{
val basePath = tmp / "launcher-jar"
val temporaryJar = targetJar(tmp, userProvidedJar)
makeUpdated(basePath, temporaryJar)
if(userProvidedJar.isEmpty)
move(temporaryJar, launcherJar)
}
withTemporaryDirectory(updateLauncher)
}
/** Copies the `src` file to the `dest` file, preferably by renaming. The `src` file may or may not be removed.*/
def move(src: File, dest: File)
{
val renameSuccess = src renameTo dest
if(!renameSuccess)
copyFile(src, dest)
}
/** Updates the default value used for 'sbt.version' in the 'sbt.boot.properties' file in the launcher `files` to be `version`.*/
def updateBootProperties(files: Iterable[File], version: String): Unit =
files.find(_.getName == "sbt.boot.properties").foreach(updateBootProperties(version))
/** Updates the default value used for 'sbt.version' in the given `file` to be `version`.*/
def updateBootProperties(version: String)(file: File)
{
val newContent = readLines(file) map updateSbtVersion(version)
write(file, newContent.mkString("\n"))
}
/** If the given `line` is the 'sbt.version' configuration, update it to use the `newVersion`.*/
def updateSbtVersion(newVersion: String)(line: String) =
if(line.trim.startsWith("sbt.version")) sbtVersion(newVersion) else line
/** The configuration line that defines the 'sbt.version' property, using the provided `version` for defaults.*/
def sbtVersion(version: String) =
" sbt.version: quick=set(" + version + "), new=prompt(sbt version)[" + version + "], fill=prompt(sbt version)[" + version + "]"
/** Copies the given ApplicationID but with the specified version.*/
def changeVersion(appID: xsbti.ApplicationID, versionA: String): xsbti.ApplicationID =
new xsbti.ApplicationID {
def groupID = appID.groupID
def name = appID.name
def version = versionA
def mainClass = appID.mainClass
def mainComponents = appID.mainComponents
def crossVersioned = appID.crossVersioned
def classpathExtra = appID.classpathExtra
}
}