diff --git a/bootstrap/src/main/scala/coursier/Bootstrap.scala b/bootstrap/src/main/scala/coursier/Bootstrap.scala new file mode 100644 index 000000000..787d3d12f --- /dev/null +++ b/bootstrap/src/main/scala/coursier/Bootstrap.scala @@ -0,0 +1,143 @@ +package coursier + +import java.io.{ ByteArrayOutputStream, InputStream, File } +import java.net.{ URI, URLClassLoader } +import java.nio.file.Files +import java.util.concurrent.{ Executors, ThreadFactory } + +import scala.concurrent.duration.Duration +import scala.concurrent.{ ExecutionContext, Future, Await } + +import scala.util.{ Try, Success, Failure } + +object Bootstrap extends App { + + val concurrentDownloadCount = 6 + val threadFactory = new ThreadFactory { + // from scalaz Strategy.DefaultDaemonThreadFactory + val defaultThreadFactory = Executors.defaultThreadFactory() + def newThread(r: Runnable) = { + val t = defaultThreadFactory.newThread(r) + t.setDaemon(true) + t + } + } + val defaultPool = Executors.newFixedThreadPool(concurrentDownloadCount, threadFactory) + implicit val ec = ExecutionContext.fromExecutorService(defaultPool) + + private def readFullySync(is: InputStream) = { + val buffer = new ByteArrayOutputStream() + val data = Array.ofDim[Byte](16384) + + var nRead = is.read(data, 0, data.length) + while (nRead != -1) { + buffer.write(data, 0, nRead) + nRead = is.read(data, 0, data.length) + } + + buffer.flush() + buffer.toByteArray + } + + private def errPrintln(s: String): Unit = + Console.err.println(s) + + private def exit(msg: String = ""): Nothing = { + if (msg.nonEmpty) + errPrintln(msg) + sys.exit(255) + } + + args match { + case Array(mainClass0, jarDir0, remainingArgs @ _*) => + val jarDir = new File(jarDir0) + + if (jarDir.exists()) { + if (!jarDir.isDirectory) + exit(s"Error: $jarDir0 is not a directory") + } else if (!jarDir.mkdirs()) + errPrintln(s"Warning: cannot create $jarDir0, continuing anyway.") + + val splitIdx = remainingArgs.indexOf("--") + val (jarStrUrls, userArgs) = + if (splitIdx < 0) + (remainingArgs, Nil) + else + (remainingArgs.take(splitIdx), remainingArgs.drop(splitIdx + 1)) + + val tryUrls = jarStrUrls.map(urlStr => urlStr -> Try(URI.create(urlStr).toURL)) + + val failedUrls = tryUrls.collect { + case (strUrl, Failure(t)) => strUrl -> t + } + if (failedUrls.nonEmpty) + exit( + s"Error parsing ${failedUrls.length} URL(s):\n" + + failedUrls.map { case (s, t) => s"$s: ${t.getMessage}" }.mkString("\n") + ) + + val jarUrls = tryUrls.collect { + case (_, Success(url)) => url + } + + val jarLocalUrlFutures = jarUrls.map { url => + if (url.getProtocol == "file") + Future.successful(url) + else + Future { + val path = url.getPath + val idx = path.lastIndexOf('/') + // FIXME Add other components in path to prevent conflicts? + val fileName = path.drop(idx + 1) + val dest = new File(jarDir, fileName) + + // FIXME If dest exists, do a HEAD request and check that its size or last modified time is OK? + + if (!dest.exists()) { + Console.err.println(s"Downloading $url") + try { + val conn = url.openConnection() + val lastModified = conn.getLastModified + val s = conn.getInputStream + val b = readFullySync(s) + Files.write(dest.toPath, b) + dest.setLastModified(lastModified) + } catch { case e: Exception => + Console.err.println(s"Error while downloading $url: ${e.getMessage}, ignoring it") + } + } + + dest.toURI.toURL + } + } + + val jarLocalUrls = Await.result(Future.sequence(jarLocalUrlFutures), Duration.Inf) + + val thread = Thread.currentThread() + val parentClassLoader = thread.getContextClassLoader + + val classLoader = new URLClassLoader(jarLocalUrls.toArray, parentClassLoader) + + val mainClass = + try classLoader.loadClass(mainClass0) + catch { case e: ClassNotFoundException => + exit(s"Error: class $mainClass0 not found") + } + + val mainMethod = + try mainClass.getMethod("main", classOf[Array[String]]) + catch { case e: NoSuchMethodException => + exit(s"Error: main method not found in class $mainClass0") + } + + thread.setContextClassLoader(classLoader) + try mainMethod.invoke(null, userArgs.toArray) + finally { + thread.setContextClassLoader(parentClassLoader) + } + + case _ => + exit("Usage: bootstrap main-class JAR-directory JAR-URLs...") + } + +} \ No newline at end of file diff --git a/build.sbt b/build.sbt index 7964a1f79..8ac735cbb 100644 --- a/build.sbt +++ b/build.sbt @@ -34,6 +34,12 @@ lazy val publishingSettings = Seq( publishArtifactsAction := PgpKeys.publishSigned.value ) ++ releaseSettings +lazy val noPublishSettings = Seq( + publish := (), + publishLocal := (), + publishArtifact := false +) + lazy val commonSettings = Seq( organization := "com.github.alexarchambault", scalaVersion := "2.11.7", @@ -42,7 +48,13 @@ lazy val commonSettings = Seq( "Scalaz Bintray Repo" at "http://dl.bintray.com/scalaz/releases", Resolver.sonatypeRepo("releases"), Resolver.sonatypeRepo("snapshots") - ) + ), + libraryDependencies ++= { + if (scalaVersion.value startsWith "2.10.") + Seq(compilerPlugin("org.scalamacros" % "paradise" % "2.0.1" cross CrossVersion.full)) + else + Seq() + } ) lazy val core = crossProject @@ -107,18 +119,17 @@ lazy val cli = project libraryDependencies ++= Seq( "com.github.alexarchambault" %% "case-app" % "1.0.0-SNAPSHOT", "ch.qos.logback" % "logback-classic" % "1.1.3" - ) ++ { - if (scalaVersion.value startsWith "2.10.") - Seq(compilerPlugin("org.scalamacros" % "paradise" % "2.0.1" cross CrossVersion.full)) - else - Seq() - } + ), + resourceGenerators in Compile += assembly.in(bootstrap).in(assembly).map { jar => + Seq(jar) + }.taskValue ) lazy val web = project .enablePlugins(ScalaJSPlugin) .dependsOn(coreJs) .settings(commonSettings) + .settings(noPublishSettings) .settings( libraryDependencies ++= { if (scalaVersion.value startsWith "2.10.") @@ -134,8 +145,6 @@ lazy val web = project else dir }, - publish := (), - publishLocal := (), test in Test := (), testOnly in Test := (), resolvers += "Webjars Bintray" at "https://dl.bintray.com/webjars/maven/", @@ -147,12 +156,19 @@ lazy val web = project ) ) -lazy val `coursier` = project.in(file(".")) - .aggregate(coreJvm, coreJs, files, cli, web) +lazy val bootstrap = project .settings(commonSettings) + .settings(noPublishSettings) + .settings( + name := "coursier-bootstrap", + assemblyJarName in assembly := s"bootstrap.jar" + ) + +lazy val `coursier` = project.in(file(".")) + .aggregate(coreJvm, coreJs, files, cli, web, bootstrap) + .settings(commonSettings) + .settings(noPublishSettings) .settings( (unmanagedSourceDirectories in Compile) := Nil, - (unmanagedSourceDirectories in Test) := Nil, - publish := (), - publishLocal := () + (unmanagedSourceDirectories in Test) := Nil ) diff --git a/cli/src/main/scala/coursier/cli/Coursier.scala b/cli/src/main/scala/coursier/cli/Coursier.scala index 4b4fcfee4..d67a99f98 100644 --- a/cli/src/main/scala/coursier/cli/Coursier.scala +++ b/cli/src/main/scala/coursier/cli/Coursier.scala @@ -1,8 +1,9 @@ package coursier package cli -import java.io.File +import java.io.{ File, IOException } import java.net.URLClassLoader +import java.nio.file.{ Files => NIOFiles } import caseapp._ @@ -34,6 +35,8 @@ case class CommonOptions( val verbose0 = verbose.length + (if (quiet) 1 else 0) } +@AppName("Coursier") +@ProgName("coursier") sealed trait CoursierCommand extends Command case class Fetch( @@ -60,7 +63,6 @@ case class Fetch( } case class Launch( - @HelpMessage("If -L or --launch is specified, the main class to launch") @ExtraName("M") @ExtraName("main") mainClass: String, @@ -95,7 +97,10 @@ case class Launch( import scala.collection.JavaConverters._ val cl = new URLClassLoader( files0.map(_.toURI.toURL).toArray, - Thread.currentThread().getContextClassLoader // setting this to null provokes strange things (wrt terminal, ...) + // setting this to null provokes strange things (wrt terminal, ...) + // but this is far from perfect: this puts all our dependencies along with the user's, + // and with a higher priority + Thread.currentThread().getContextClassLoader ) val mainClass0 = @@ -238,4 +243,79 @@ case class Repository( } +case class Bootstrap( + @ExtraName("M") + @ExtraName("main") + mainClass: String, + @ExtraName("o") + output: String, + @ExtraName("D") + downloadDir: String, + @ExtraName("f") + force: Boolean, + @Recurse + common: CommonOptions +) extends CoursierCommand { + + if (mainClass.isEmpty) { + Console.err.println(s"Error: no main class specified. Specify one with -M or --main") + sys.exit(255) + } + + if (downloadDir.isEmpty) { + Console.err.println(s"Error: no download dir specified. Specify one with -D or --download-dir") + Console.err.println("E.g. -D \"\\$HOME/.app-name/jars\"") + sys.exit(255) + } + + val downloadDir0 = + if (downloadDir.isEmpty) + "$HOME/" + else + downloadDir + + val bootstrapJar = + Option(Thread.currentThread().getContextClassLoader.getResourceAsStream("bootstrap.jar")) match { + case Some(is) => Files.readFullySync(is) + case None => + Console.err.println(s"Error: bootstrap JAR not found") + sys.exit(1) + } + + // scala-library version in the resulting JARs has to match the one in the bootstrap JAR + // This should be enforced more strictly (possibly by having one bootstrap JAR per scala version). + + val helper = new Helper( + common, + remainingArgs :+ s"org.scala-lang:scala-library:${scala.util.Properties.versionNumberString}" + ) + + val artifacts = helper.res.artifacts + + val urls = artifacts.map(_.url) + + val unrecognized = urls.filter(s => !s.startsWith("http://") && !s.startsWith("https://")) + if (unrecognized.nonEmpty) + Console.err.println(s"Warning: non HTTP URLs:\n${unrecognized.mkString("\n")}") + + val output0 = new File(output) + if (!force && output0.exists()) { + Console.err.println(s"Error: $output already exists, use -f option to force erasing it.") + sys.exit(1) + } + + val shellPreamble = Seq( + "#!/usr/bin/env sh", + "exec java -jar \"$0\" \"" + mainClass + "\" \"" + downloadDir + "\" " + urls.map("\"" + _ + "\"").mkString(" ") + " -- \"$@\"", + "" + ).mkString("\n") + + try NIOFiles.write(output0.toPath, shellPreamble.getBytes("UTF-8") ++ bootstrapJar) + catch { case e: IOException => + Console.err.println(s"Error while writing $output0: ${e.getMessage}") + sys.exit(1) + } + +} + object Coursier extends CommandAppOf[CoursierCommand] diff --git a/project/plugins.sbt b/project/plugins.sbt index d26d5c6bc..591cf84ea 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -3,3 +3,4 @@ addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.5") addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.0.0") addSbtPlugin("com.github.gseitz" % "sbt-release" % "0.8.5") addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.1.0") +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.0")