merged
authorwenzelm
Tue, 11 Oct 2016 14:29:39 +0200
changeset 64146 b2486964b823
parent 64122 74fde524799e (current diff)
parent 64145 69d5509768a9 (diff)
child 64147 92066f8c6a54
merged
--- a/Admin/Release/CHECKLIST	Mon Oct 10 15:45:41 2016 +0100
+++ b/Admin/Release/CHECKLIST	Tue Oct 11 14:29:39 2016 +0200
@@ -76,9 +76,9 @@
 
 - Linux: avoid some versions of Debian / Ubuntu (bitmap fonts for prog-prove);
 
-- fully-automated packaging:
+- fully-automated packaging (e.g. on macbroy2):
 
-  hg up -r DISTNAME && Admin/Release/build -O -l -r DISTNAME /home/isabelle/dist
+  hg up -r DISTNAME && Admin/Release/build -M macbroy30 -O -l -r DISTNAME /home/isabelle/dist
 
 
 Final release stage
--- a/Admin/Release/build	Mon Oct 10 15:45:41 2016 +0100
+++ b/Admin/Release/build	Tue Oct 11 14:29:39 2016 +0200
@@ -18,6 +18,7 @@
   echo "Usage: isabelle $PRG [OPTIONS] DIR [VERSION]"
   echo
   echo "  Options are:"
+  echo "    -M USER@HOST remote Mac OS X for dmg build"
   echo "    -O           official release (not release-candidate)"
   echo "    -j INT       maximum number of parallel jobs (default 1)"
   echo "    -l           build library"
@@ -47,14 +48,18 @@
 
 # options
 
+REMOTE_MAC=""
 OFFICIAL_RELEASE=""
 JOBS=""
 LIBRARY=""
 RELEASE=""
 
-while getopts "Oj:lr:" OPT
+while getopts "M:Oj:lr:" OPT
 do
   case "$OPT" in
+    M)
+      REMOTE_MAC="$OPTARG"
+      ;;
     O)
       OFFICIAL_RELEASE="-O"
       ;;
@@ -121,7 +126,11 @@
 echo
 echo "*** $PLATFORM_FAMILY ***"
 
-"$ISABELLE_TOOL" makedist_bundle "$DISTBASE/${DISTNAME}.tar.gz" "$PLATFORM_FAMILY"
+if [ -n "$REMOTE_MAC" ]; then
+  "$ISABELLE_TOOL" makedist_bundle "$DISTBASE/${DISTNAME}.tar.gz" "$PLATFORM_FAMILY" "$REMOTE_MAC"
+else
+  "$ISABELLE_TOOL" makedist_bundle "$DISTBASE/${DISTNAME}.tar.gz" "$PLATFORM_FAMILY"
+fi
 [ "$?" = 0 ] || exit "$?"
 
 done
@@ -155,4 +164,3 @@
 if [ -n "$LIBRARY" ]; then
   "$THIS/build_library" $JOBS "$DISTBASE/${DISTNAME}_${ISABELLE_PLATFORM_FAMILY}.tar.gz"
 fi
-
--- a/Admin/components/components.sha1	Mon Oct 10 15:45:41 2016 +0100
+++ b/Admin/components/components.sha1	Tue Oct 11 14:29:39 2016 +0200
@@ -153,6 +153,7 @@
 5659440f6b86db29f0c9c0de7249b7e24a647126  scala-2.9.2.tar.gz
 43b5afbcad575ab6817d2289756ca22fd2ef43a9  spass-3.8ds.tar.gz
 8d20968603f45a2c640081df1ace6a8b0527452a  sqlite-jdbc-3.8.11.2.tar.gz
+2369f06e8d095f9ba26df938b1a96000e535afff  ssh-java-20161009.tar.gz
 1f4a2053cc1f34fa36c4d9d2ac906ad4ebc863fd  sumatra_pdf-2.1.1.tar.gz
 601e08d048d8e50b0729429c8928b667d9b6bde9  sumatra_pdf-2.3.2.tar.gz
 14d46c2eb1a34821703da59d543433f581e91df3  sumatra_pdf-2.4.tar.gz
--- a/Admin/components/main	Mon Oct 10 15:45:41 2016 +0100
+++ b/Admin/components/main	Tue Oct 11 14:29:39 2016 +0200
@@ -12,6 +12,7 @@
 kodkodi-1.5.2
 polyml-5.6-1
 scala-2.11.8
+ssh-java-20161009
 spass-3.8ds
 sqlite-jdbc-3.8.11.2
 xz-java-1.5
--- a/Admin/lib/Tools/makedist_bundle	Mon Oct 10 15:45:41 2016 +0100
+++ b/Admin/lib/Tools/makedist_bundle	Tue Oct 11 14:29:39 2016 +0200
@@ -9,11 +9,13 @@
 function usage()
 {
   echo
-  echo "Usage: isabelle $PRG ARCHIVE PLATFORM_FAMILY"
+  echo "Usage: isabelle $PRG ARCHIVE PLATFORM_FAMILY [REMOTE_MAC]"
   echo
   echo "  Re-package Isabelle source distribution with add-on components and"
   echo "  post-hoc patches for platform family linux, windows, windows64, macos."
   echo
+  echo "  The optional remote Mac OS X system is used for dmg build."
+  echo
   echo "  Add-on components are that of the running Isabelle version!"
   echo
   exit 1
@@ -28,10 +30,11 @@
 
 ## arguments
 
-[ "$#" -ne 2 ] && usage
+[ "$#" -ne 2 -a "$#" -ne 3 ] && usage
 
 ARCHIVE="$1"; shift
 PLATFORM_FAMILY="$1"; shift
+REMOTE_MAC="$1"; shift
 
 if [ "$PLATFORM_FAMILY" = windows64 ]; then
   PLATFORM_FAM="windows"
@@ -84,8 +87,6 @@
 
 # bundled components
 
-init_component "$JEDIT_HOME"
-
 if [ ! -e "$ARCHIVE_DIR/contrib" ]; then
   if [ ! -e "$ARCHIVE_DIR/../contrib" ]; then
     mkdir -p "$ARCHIVE_DIR/contrib"
@@ -201,6 +202,8 @@
     for PLATFORM in 32 64
     do
       (
+        init_component "$JEDIT_HOME"
+
         echo "# Java runtime options for ${PLATFORM}bit platform"
         declare -a JAVA_ARGS
         if [ "$PLATFORM" = 32 ]; then
@@ -268,6 +271,8 @@
       "$ISABELLE_TARGET/src/Tools/jEdit/dist/properties/jEdit.props"
 
     (
+      init_component "$JEDIT_HOME"
+
       declare -a JAVA_ARGS=()
       if [ "$PLATFORM_FAMILY" = windows ]; then
         echo -e "# Java runtime options for 32bit platform\r"
@@ -355,97 +360,99 @@
 
 # platform-specific setup (outside archive)
 
-if [ "$ISABELLE_PLATFORM_FAMILY" = linux -a "$PLATFORM_FAMILY" != macos -o "$ISABELLE_PLATFORM_FAMILY" = macos ]
-then
-  case "$PLATFORM_FAM" in
-    linux)
-      echo "application for $PLATFORM_FAMILY"
-      ln -s "${ISABELLE_NAME}_linux.tar.gz" "${ARCHIVE_DIR}/${ISABELLE_NAME}_app.tar.gz"
-      ;;
-    macos)
+case "$PLATFORM_FAM" in
+  linux)
+    echo "application for $PLATFORM_FAMILY"
+    ln -s "${ISABELLE_NAME}_linux.tar.gz" "${ARCHIVE_DIR}/${ISABELLE_NAME}_app.tar.gz"
+    ;;
+  macos)
+    echo "application for $PLATFORM_FAMILY"
+    (
+      cd "$TMP"
+
+      APP_TEMPLATE="$ISABELLE_HOME/Admin/MacOS"
+      APP="dmg/${ISABELLE_NAME}.app"
+
+      mkdir -p "dmg/.background"
+      cp "$APP_TEMPLATE/dmg/background.png" "dmg/.background/"
+      cp "$APP_TEMPLATE/dmg/DS_Store" "dmg/.DS_Store"
+      ln -s /Applications "dmg/."
+
+      for NAME in Java MacOS PlugIns Resources
+      do
+        mkdir -p "$APP/Contents/$NAME"
+      done
+
+      (
+        init_component "$JEDIT_HOME"
+
+        cat "$APP_TEMPLATE/Info.plist-part1"
+
+        declare -a OPTIONS=()
+        eval "OPTIONS=($ISABELLE_JAVA_SYSTEM_OPTIONS $JEDIT_JAVA_SYSTEM_OPTIONS $JEDIT_JAVA_OPTIONS64)"
+        for OPT in "${OPTIONS[@]}"
+        do
+          echo "<string>$OPT</string>"
+        done
+        echo "<string>-Disabelle.jedit_server={ISABELLE_NAME}</string>"
+        echo "<string>-Dapple.awt.application.name={ISABELLE_NAME}</string>"
+
+        cat "$APP_TEMPLATE/Info.plist-part2"
+      ) | perl -p -e "s,{ISABELLE_NAME},${ISABELLE_NAME},g;" > "$APP/Contents/Info.plist"
+
+      for ENTRY in "${DISTRIBITION_CLASSPATH[@]}"
+      do
+        ln -sf "../Resources/${ISABELLE_NAME}/$ENTRY" "$APP/Contents/Java"
+      done
+
+      cp -R "$APP_TEMPLATE/Resources/." "$APP/Contents/Resources/."
+
+      ln -sf "../Resources/${ISABELLE_NAME}/contrib/jdk/x86_64-darwin" \
+        "$APP/Contents/PlugIns/bundled.jdk"
+
+      cp macos_app/JavaAppLauncher "$APP/Contents/MacOS/." && \
+        chmod +x "$APP/Contents/MacOS/JavaAppLauncher"
+
+      mv "$ISABELLE_NAME" "$APP/Contents/Resources/."
+      ln -sf "../../Info.plist" "$APP/Contents/Resources/$ISABELLE_NAME/${ISABELLE_NAME}.plist"
+      ln -sf "Contents/Resources/$ISABELLE_NAME" "$APP/Isabelle"
+
+      rm -f "${ARCHIVE_DIR}/${ISABELLE_NAME}.dmg"
+      tar -C dmg -czf "${ARCHIVE_DIR}/${ISABELLE_NAME}_dmg.tar.gz" .
+
+      if [ -n "$REMOTE_MAC" ]
+      then
+        echo "$REMOTE_MAC: dmg for $PLATFORM_FAMILY"
+        isabelle remote_dmg -V Isabelle "$REMOTE_MAC" \
+          "${ARCHIVE_DIR}/${ISABELLE_NAME}_dmg.tar.gz" "${ARCHIVE_DIR}/${ISABELLE_NAME}.dmg"
+      fi
+    )
+    ;;
+  windows)
+    (
+      if [ "$PLATFORM_FAMILY" = windows ]; then
+        PLATFORM_SUFFIX="-win32"
+      else
+        PLATFORM_SUFFIX="-win64"
+      fi
+
+      cd "$TMP"
+      rm -f "${ARCHIVE_DIR}/${ISABELLE_NAME}.7z"
+      7z -y -bd a "$TMP/${ISABELLE_NAME}.7z" "$ISABELLE_NAME" || exit 2
+
       echo "application for $PLATFORM_FAMILY"
       (
-        cd "$TMP"
-
-        APP_TEMPLATE="$ISABELLE_HOME/Admin/MacOS"
-        APP="dmg/${ISABELLE_NAME}.app"
-
-        mkdir -p "dmg/.background"
-        cp "$APP_TEMPLATE/dmg/background.png" "dmg/.background/"
-        cp "$APP_TEMPLATE/dmg/DS_Store" "dmg/.DS_Store"
-        ln -s /Applications "dmg/."
-
-        for NAME in Java MacOS PlugIns Resources
-        do
-          mkdir -p "$APP/Contents/$NAME"
-        done
-
-        (
-          cat "$APP_TEMPLATE/Info.plist-part1"
-
-          declare -a OPTIONS=()
-          eval "OPTIONS=($ISABELLE_JAVA_SYSTEM_OPTIONS $JEDIT_JAVA_SYSTEM_OPTIONS $JEDIT_JAVA_OPTIONS64)"
-          for OPT in "${OPTIONS[@]}"
-          do
-            echo "<string>$OPT</string>"
-          done
-          echo "<string>-Disabelle.jedit_server={ISABELLE_NAME}</string>"
-          echo "<string>-Dapple.awt.application.name={ISABELLE_NAME}</string>"
-
-          cat "$APP_TEMPLATE/Info.plist-part2"
-        ) | perl -p -e "s,{ISABELLE_NAME},${ISABELLE_NAME},g;" > "$APP/Contents/Info.plist"
-
-        for ENTRY in "${DISTRIBITION_CLASSPATH[@]}"
-        do
-          ln -sf "../Resources/${ISABELLE_NAME}/$ENTRY" "$APP/Contents/Java"
-        done
-
-        cp -R "$APP_TEMPLATE/Resources/." "$APP/Contents/Resources/."
-
-        ln -sf "../Resources/${ISABELLE_NAME}/contrib/jdk/x86_64-darwin" \
-          "$APP/Contents/PlugIns/bundled.jdk"
-
-        cp macos_app/JavaAppLauncher "$APP/Contents/MacOS/." && \
-          chmod +x "$APP/Contents/MacOS/JavaAppLauncher"
-
-        mv "$ISABELLE_NAME" "$APP/Contents/Resources/."
-        ln -sf "../../Info.plist" "$APP/Contents/Resources/$ISABELLE_NAME/${ISABELLE_NAME}.plist"
-        ln -sf "Contents/Resources/$ISABELLE_NAME" "$APP/Isabelle"
-
-        rm -f "${ARCHIVE_DIR}/${ISABELLE_NAME}.dmg"
-
-        cd dmg
-        hdiutil create -srcfolder . -volname Isabelle "${ARCHIVE_DIR}/${ISABELLE_NAME}.dmg"
-      )
-      ;;
-    windows)
-      (
-        if [ "$PLATFORM_FAMILY" = windows ]; then
-          PLATFORM_SUFFIX="-win32"
-        else
-          PLATFORM_SUFFIX="-win64"
-        fi
-
-        cd "$TMP"
-        rm -f "${ARCHIVE_DIR}/${ISABELLE_NAME}.7z"
-        7z -y -bd a "$TMP/${ISABELLE_NAME}.7z" "$ISABELLE_NAME" || exit 2
-
-        echo "application for $PLATFORM_FAMILY"
-        (
-          cat "windows_app/7zsd_All.sfx"
-          cat "$ISABELLE_HOME/Admin/Windows/Installer/sfx.txt" | \
-            perl -p -e "s,{ISABELLE_NAME},${ISABELLE_NAME},g;"
-          cat "$TMP/${ISABELLE_NAME}.7z"
-        ) > "${ARCHIVE_DIR}/${ISABELLE_NAME}${PLATFORM_SUFFIX}.exe"
-        chmod +x "${ARCHIVE_DIR}/${ISABELLE_NAME}${PLATFORM_SUFFIX}.exe"
-      )
-      ;;
-    *)
-      ;;
-  esac
-else
-  echo "### Cannot build application for $PLATFORM_FAMILY on $ISABELLE_PLATFORM_FAMILY"
-fi
+        cat "windows_app/7zsd_All.sfx"
+        cat "$ISABELLE_HOME/Admin/Windows/Installer/sfx.txt" | \
+          perl -p -e "s,{ISABELLE_NAME},${ISABELLE_NAME},g;"
+        cat "$TMP/${ISABELLE_NAME}.7z"
+      ) > "${ARCHIVE_DIR}/${ISABELLE_NAME}${PLATFORM_SUFFIX}.exe"
+      chmod +x "${ARCHIVE_DIR}/${ISABELLE_NAME}${PLATFORM_SUFFIX}.exe"
+    )
+    ;;
+  *)
+    ;;
+esac
 
 
 # clean up
--- a/etc/options	Mon Oct 10 15:45:41 2016 +0100
+++ b/etc/options	Tue Oct 11 14:29:39 2016 +0200
@@ -185,3 +185,21 @@
 
 public option completion_limit : int = 40
   -- "limit for completion within the formal context"
+
+
+section "Secure Shell"
+
+option ssh_config_dir : string = "~/.ssh"
+  -- "SSH configuration directory"
+
+option ssh_config_file : string = "~/.ssh/config"
+  -- "main SSH configuration file"
+
+option ssh_identity_files : string = "~/.ssh/id_dsa:~/.ssh/id_ecdsa:~/.ssh/id_rsa"
+  -- "possible SSH identity files (separated by colons)"
+
+option ssh_compression : bool = true
+  -- "enable SSH compression"
+
+option ssh_connect_timeout : real = 60
+  -- "SSH connection timeout (seconds)"
--- a/src/Pure/General/mercurial.scala	Mon Oct 10 15:45:41 2016 +0100
+++ b/src/Pure/General/mercurial.scala	Tue Oct 11 14:29:39 2016 +0200
@@ -47,7 +47,7 @@
       command("manifest " + options + opt_rev(rev)).check.out_lines
 
     def log(rev: String = "", template: String = "", options: String = ""): String =
-      Library.trim_line(command("log " + options + opt_rev(rev) + opt_template(template)).check.out)
+      command("log " + options + opt_rev(rev) + opt_template(template)).check.out
 
     def pull(remote: String = "", rev: String = "", options: String = ""): Unit =
       command("pull " + options + opt_rev(rev) + optional(remote)).check
--- a/src/Pure/General/sqlite.scala	Mon Oct 10 15:45:41 2016 +0100
+++ b/src/Pure/General/sqlite.scala	Tue Oct 11 14:29:39 2016 +0200
@@ -27,7 +27,7 @@
   {
     override def toString: String = path.toString
 
-    def close { connection.close }
+    def close() { connection.close }
 
     def rebuild { using(statement("VACUUM"))(_.execute()) }
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/Pure/General/ssh.scala	Tue Oct 11 14:29:39 2016 +0200
@@ -0,0 +1,338 @@
+/*  Title:      Pure/General/ssh.scala
+    Author:     Makarius
+
+SSH client based on JSch (see also http://www.jcraft.com/jsch/examples).
+*/
+
+package isabelle
+
+
+import java.io.{InputStream, OutputStream, ByteArrayOutputStream}
+
+import scala.collection.{mutable, JavaConversions}
+
+import com.jcraft.jsch.{JSch, Logger => JSch_Logger, Session => JSch_Session,
+  OpenSSHConfig, UserInfo, Channel => JSch_Channel, ChannelExec, ChannelSftp, SftpATTRS}
+
+
+object SSH
+{
+  /* user@host syntax */
+
+  object User_Host
+  {
+    val Pattern = "^([^@]+)@(.+)$".r
+
+    def parse(s: String): (String, String) =
+      s match {
+        case Pattern(user, host) => (user, host)
+        case _ => ("", s)
+      }
+
+    def unapplySeq(s: String): Option[List[String]] =
+    {
+      val (user, host) = parse(s)
+      Some(List(user, host))
+    }
+  }
+
+  val default_port = 22
+
+
+  /* init */
+
+  def init(options: Options): SSH =
+  {
+    val config_dir = Path.explode(options.string("ssh_config_dir"))
+    if (!config_dir.is_dir) error("Bad ssh config directory: " + config_dir)
+
+    val jsch = new JSch
+
+    val config_file = Path.explode(options.string("ssh_config_file"))
+    if (config_file.is_file)
+      jsch.setConfigRepository(OpenSSHConfig.parseFile(File.platform_path(config_file)))
+
+    val known_hosts = config_dir + Path.explode("known_hosts")
+    if (!known_hosts.is_file) known_hosts.file.createNewFile
+    jsch.setKnownHosts(File.platform_path(known_hosts))
+
+    val identity_files =
+      Library.space_explode(':', options.string("ssh_identity_files")).map(Path.explode(_))
+    for (identity_file <- identity_files if identity_file.is_file)
+      jsch.addIdentity(File.platform_path(identity_file))
+
+    new SSH(options, jsch)
+  }
+
+  def connect_timeout(options: Options): Int =
+    options.seconds("ssh_connect_timeout").ms.toInt
+
+
+  /* logging */
+
+  def logging(verbose: Boolean = true, debug: Boolean = false)
+  {
+    JSch.setLogger(if (verbose) new Logger(debug) else null)
+  }
+
+  private class Logger(debug: Boolean) extends JSch_Logger
+  {
+    def isEnabled(level: Int): Boolean = level != JSch_Logger.DEBUG || debug
+
+    def log(level: Int, msg: String)
+    {
+      level match {
+        case JSch_Logger.ERROR | JSch_Logger.FATAL => Output.error_message(msg)
+        case JSch_Logger.WARN => Output.warning(msg)
+        case _ => Output.writeln(msg)
+      }
+    }
+  }
+
+
+  /* user info */
+
+  object No_User_Info extends UserInfo
+  {
+    def getPassphrase: String = null
+    def getPassword: String = null
+    def promptPassword(msg: String): Boolean = false
+    def promptPassphrase(msg: String): Boolean = false
+    def promptYesNo(msg: String): Boolean = false
+    def showMessage(msg: String): Unit = Output.writeln(msg)
+  }
+
+
+  /* channel */
+
+  class Channel[C <: JSch_Channel] private[SSH](
+    val session: Session, val kind: String, val options: Options, val channel: C)
+  {
+    override def toString: String = kind + " " + session.toString
+
+    def close() { channel.disconnect }
+  }
+
+
+  /* exec channel */
+
+  private val exec_wait_delay = Time.seconds(0.3)
+
+  class Exec private[SSH](
+    session: Session, kind: String, options: Options, channel: ChannelExec)
+    extends Channel[ChannelExec](session, kind, options, channel)
+  {
+    def kill(signal: String) { channel.sendSignal(signal) }
+
+    val exit_status: Future[Int] =
+      Future.thread("ssh_wait") {
+        while (!channel.isClosed) Thread.sleep(exec_wait_delay.ms)
+        channel.getExitStatus
+      }
+
+    val stdin: OutputStream = channel.getOutputStream
+    val stdout: InputStream = channel.getInputStream
+    val stderr: InputStream = channel.getErrStream
+
+    // after preparing streams
+    channel.connect(connect_timeout(options))
+
+    def result(
+      progress_stdout: String => Unit = (_: String) => (),
+      progress_stderr: String => Unit = (_: String) => (),
+      strict: Boolean = true): Process_Result =
+    {
+      stdin.close
+
+      def read_lines(stream: InputStream, progress: String => Unit): List[String] =
+      {
+        val result = new mutable.ListBuffer[String]
+        val line_buffer = new ByteArrayOutputStream(100)
+        def line_flush()
+        {
+          val line = line_buffer.toString(UTF8.charset_name)
+          progress(line)
+          result += line
+          line_buffer.reset
+        }
+
+        var c = 0
+        var finished = false
+        while (!finished) {
+          while ({ c = stream.read; c != -1 && c != 10 }) line_buffer.write(c)
+          if (c == 10) line_flush()
+          else if (channel.isClosed) {
+            if (line_buffer.size > 0) line_flush()
+            finished = true
+          }
+          else Thread.sleep(exec_wait_delay.ms)
+        }
+
+        result.toList
+      }
+
+      val out_lines = Future.thread("ssh_stdout") { read_lines(stdout, progress_stdout) }
+      val err_lines = Future.thread("ssh_stderr") { read_lines(stderr, progress_stderr) }
+
+      def terminate()
+      {
+        close
+        out_lines.join
+        err_lines.join
+        exit_status.join
+      }
+
+      val rc =
+        try { exit_status.join }
+        catch { case Exn.Interrupt() => terminate(); Exn.Interrupt.return_code }
+
+      close
+      if (strict && rc == Exn.Interrupt.return_code) throw Exn.Interrupt()
+
+      Process_Result(rc, out_lines.join, err_lines.join)
+    }
+  }
+
+
+  /* Sftp channel */
+
+  type Attrs = SftpATTRS
+
+  sealed case class Dir_Entry(name: String, attrs: Attrs)
+  {
+    def is_file: Boolean = attrs.isReg
+    def is_dir: Boolean = attrs.isDir
+  }
+
+  class Sftp private[SSH](
+    session: Session, kind: String, options: Options, channel: ChannelSftp)
+    extends Channel[ChannelSftp](session, kind, options, channel)
+  {
+    channel.connect(connect_timeout(options))
+
+    def home: String = channel.getHome()
+
+    def chmod(permissions: Int, remote_path: String) { channel.chmod(permissions, remote_path) }
+    def mv(remote_path1: String, remote_path2: String): Unit =
+      channel.rename(remote_path1, remote_path2)
+    def rm(remote_path: String) { channel.rm(remote_path) }
+    def mkdir(remote_path: String) { channel.mkdir(remote_path) }
+    def rmdir(remote_path: String) { channel.rmdir(remote_path) }
+
+    def stat(remote_path: String): Dir_Entry =
+      Dir_Entry(remote_path, channel.stat(remote_path))
+
+    def read_dir(remote_path: String): List[Dir_Entry] =
+    {
+      val dir = channel.ls(remote_path)
+      (for {
+        i <- (0 until dir.size).iterator
+        a = dir.get(i).asInstanceOf[AnyRef]
+        name = Untyped.get[String](a, "filename")
+        attrs = Untyped.get[Attrs](a, "attrs")
+        if name != "." && name != ".."
+      } yield Dir_Entry(name, attrs)).toList
+    }
+
+    def find_files(remote_path: String, pred: Dir_Entry => Boolean = _ => true): List[Dir_Entry] =
+    {
+      def find(dir: String): List[Dir_Entry] =
+        read_dir(dir).flatMap(entry =>
+          {
+            val file = dir + "/" + entry.name
+            if (entry.is_dir) find(file)
+            else if (pred(entry)) List(entry.copy(name = file))
+            else Nil
+          })
+      find(remote_path)
+    }
+
+    def open_input(remote_path: String): InputStream = channel.get(remote_path)
+    def open_output(remote_path: String): OutputStream = channel.put(remote_path)
+
+    def read_file(remote_path: String, local_path: Path): Unit =
+      channel.get(remote_path, File.platform_path(local_path))
+    def read_bytes(remote_path: String): Bytes =
+      using(open_input(remote_path))(Bytes.read_stream(_))
+    def read(remote_path: String): String =
+      using(open_input(remote_path))(File.read_stream(_))
+
+    def write_file(remote_path: String, local_path: Path): Unit =
+      channel.put(File.platform_path(local_path), remote_path)
+    def write_bytes(remote_path: String, bytes: Bytes): Unit =
+      using(open_output(remote_path))(bytes.write_stream(_))
+    def write(remote_path: String, text: String): Unit =
+      using(open_output(remote_path))(stream => Bytes(text).write_stream(stream))
+  }
+
+
+  /* session */
+
+  class Session private[SSH](val session_options: Options, val session: JSch_Session)
+  {
+    override def toString: String =
+      (if (session.getUserName == null) "" else session.getUserName + "@") +
+      (if (session.getHost == null) "" else session.getHost) +
+      (if (session.getPort == default_port) "" else ":" + session.getPort) +
+      (if (session.isConnected) "" else " (disconnected)")
+
+    def close() { session.disconnect }
+
+    def execute(command: String,
+        options: Options = session_options,
+        progress_stdout: String => Unit = (_: String) => (),
+        progress_stderr: String => Unit = (_: String) => (),
+        strict: Boolean = true): Process_Result =
+      exec(command, options).result(progress_stdout, progress_stderr, strict)
+
+    def exec(command: String, options: Options = session_options): Exec =
+    {
+      val kind = "exec"
+      val channel = session.openChannel(kind).asInstanceOf[ChannelExec]
+      channel.setCommand(command)
+      new Exec(this, kind, options, channel)
+    }
+
+    def sftp(options: Options = session_options): Sftp =
+    {
+      val kind = "sftp"
+      val channel = session.openChannel(kind).asInstanceOf[ChannelSftp]
+      new Sftp(this, kind, options, channel)
+    }
+
+
+    /* tmp dirs */
+
+    def rm_tree(remote_dir: String): Unit =
+      execute("rm -r -f " + File.bash_string(remote_dir)).check
+
+    def tmp_dir(): String =
+      execute("mktemp -d -t tmp.XXXXXXXXXX").check.out
+
+    def with_tmp_dir[A](body: String => A): A =
+    {
+      val remote_dir = tmp_dir()
+      try { body(remote_dir) } finally { rm_tree(remote_dir) }
+    }
+  }
+}
+
+class SSH private(val options: Options, val jsch: JSch)
+{
+  def open_session(host: String, port: Int = SSH.default_port, user: String = ""): SSH.Session =
+  {
+    val session = jsch.getSession(if (user == "") null else user, host, port)
+
+    session.setUserInfo(SSH.No_User_Info)
+    session.setConfig("MaxAuthTries", "3")
+
+    if (options.bool("ssh_compression")) {
+      session.setConfig("compression.s2c", "zlib@openssh.com,zlib,none")
+      session.setConfig("compression.c2s", "zlib@openssh.com,zlib,none")
+      session.setConfig("compression_level", "9")
+    }
+
+    session.connect(SSH.connect_timeout(options))
+    new SSH.Session(options, session)
+  }
+}
--- a/src/Pure/System/isabelle_system.scala	Mon Oct 10 15:45:41 2016 +0100
+++ b/src/Pure/System/isabelle_system.scala	Tue Oct 11 14:29:39 2016 +0200
@@ -309,6 +309,8 @@
       result(progress_stdout, progress_stderr, progress_limit, strict)
   }
 
+  def hostname(): String = bash("hostname").check.out
+
   def open(arg: String): Unit =
     bash("exec \"$ISABELLE_OPEN\" " + File.bash_string(arg) + " >/dev/null 2>/dev/null &")
 
--- a/src/Pure/System/isabelle_tool.scala	Mon Oct 10 15:45:41 2016 +0100
+++ b/src/Pure/System/isabelle_tool.scala	Tue Oct 11 14:29:39 2016 +0200
@@ -106,6 +106,7 @@
       Doc.isabelle_tool,
       ML_Process.isabelle_tool,
       Options.isabelle_tool,
+      Remote_DMG.isabelle_tool,
       Update_Cartouches.isabelle_tool,
       Update_Header.isabelle_tool,
       Update_Then.isabelle_tool,
--- a/src/Pure/System/process_result.scala	Mon Oct 10 15:45:41 2016 +0100
+++ b/src/Pure/System/process_result.scala	Tue Oct 11 14:29:39 2016 +0200
@@ -29,15 +29,15 @@
 
   def print: Process_Result =
   {
-    Output.warning(Library.trim_line(err))
-    Output.writeln(Library.trim_line(out))
+    Output.warning(err)
+    Output.writeln(out)
     copy(out_lines = Nil, err_lines = Nil)
   }
 
   def print_stdout: Process_Result =
   {
-    Output.warning(Library.trim_line(err), stdout = true)
-    Output.writeln(Library.trim_line(out), stdout = true)
+    Output.warning(err, stdout = true)
+    Output.writeln(out, stdout = true)
     copy(out_lines = Nil, err_lines = Nil)
   }
 
--- a/src/Pure/Tools/build.scala	Mon Oct 10 15:45:41 2016 +0100
+++ b/src/Pure/Tools/build.scala	Tue Oct 11 14:29:39 2016 +0200
@@ -752,15 +752,15 @@
 
     val progress = new Console_Progress(verbose = verbose)
 
+    val start_date = Date.now()
+
     if (verbose) {
       progress.echo(
-        Library.trim_line(
-          Isabelle_System.bash(
-            """echo "Started at $(date) ($ML_IDENTIFIER on $(hostname))" """).out) + "\n")
+        "Started at " + Build_Log.Log_File.Date_Format(start_date) +
+          " (" + Isabelle_System.getenv("ML_IDENTIFIER") + " on " + Isabelle_System.hostname() +")")
       progress.echo(Build_Log.Settings.show() + "\n")
     }
 
-    val start_time = Time.now()
     val results =
       progress.interrupt_handler {
         build(options, progress,
@@ -781,12 +781,11 @@
           session_groups = session_groups,
           sessions = sessions)
       }
-    val elapsed_time = Time.now() - start_time
+    val end_date = Date.now()
+    val elapsed_time = end_date.time - start_date.time
 
     if (verbose) {
-      progress.echo("\n" +
-        Library.trim_line(
-          Isabelle_System.bash("""echo -n "Finished at "; date""").out))
+      progress.echo("\nFinished at " + Build_Log.Log_File.Date_Format(end_date))
     }
 
     val total_timing =
--- a/src/Pure/Tools/build_history.scala	Mon Oct 10 15:45:41 2016 +0100
+++ b/src/Pure/Tools/build_history.scala	Tue Oct 11 14:29:39 2016 +0200
@@ -217,7 +217,7 @@
     /* main */
 
     val build_history_date = Date.now()
-    val build_host = Library.trim_line(Isabelle_System.bash("hostname").check.out)
+    val build_host = Isabelle_System.hostname()
 
     var first_build = true
     for (threads <- threads_list) yield
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/Pure/Tools/remote_dmg.scala	Tue Oct 11 14:29:39 2016 +0200
@@ -0,0 +1,64 @@
+/*  Title:      Pure/Tools/remote_dmg.scala
+    Author:     Makarius
+
+Build dmg on remote Mac OS X system.
+*/
+
+package isabelle
+
+
+object Remote_DMG
+{
+  def remote_dmg(session: SSH.Session, tar_gz_file: Path, dmg_file: Path, volume_name: String = "")
+  {
+    session.with_tmp_dir(remote_dir =>
+      using(session.sftp())(sftp =>
+        {
+          val cd = "cd " + File.bash_string(remote_dir) + "; "
+
+          sftp.write_file(remote_dir + "/dmg.tar.gz", tar_gz_file)
+          session.execute(cd + "mkdir root && tar -C root -xzf dmg.tar.gz").check
+          session.execute(
+            cd + "hdiutil create -srcfolder root" +
+              (if (volume_name == "") "" else " -volname " + File.bash_string(volume_name)) +
+              " dmg.dmg").check
+          sftp.read_file(remote_dir + "/dmg.dmg", dmg_file)
+        }))
+  }
+
+
+  /* Isabelle tool wrapper */
+
+  val isabelle_tool = Isabelle_Tool("remote_dmg", "build dmg on remote Mac OS X system", args =>
+  {
+    Command_Line.tool0 {
+      var port = SSH.default_port
+      var volume_name = ""
+
+      val getopts = Getopts("""
+Usage: isabelle remote_dmg [OPTIONS] USER@HOST TAR_GZ_FILE DMG_FILE
+
+  Options are:
+    -p PORT      alternative SSH port (default: """ + SSH.default_port + """)
+    -V NAME      specify volume name
+
+  Turn the contents of a tar.gz file into a dmg file -- produced on a remote
+  Mac OS X system.
+""",
+        "p:" -> (arg => port = Value.Int.parse(arg)),
+        "V:" -> (arg => volume_name = arg))
+
+      val more_args = getopts(args)
+      val (user, host, tar_gz_file, dmg_file) =
+        more_args match {
+          case List(SSH.User_Host(user, host), tar_gz_file, dmg_file) =>
+            (user, host, Path.explode(tar_gz_file), Path.explode(dmg_file))
+          case _ => getopts.usage()
+        }
+
+      val ssh = SSH.init(Options.init)
+      using(ssh.open_session(user = user, host = host, port = port))(
+        remote_dmg(_, tar_gz_file, dmg_file, volume_name))
+    }
+  })
+}
--- a/src/Pure/build-jars	Mon Oct 10 15:45:41 2016 +0100
+++ b/src/Pure/build-jars	Tue Oct 11 14:29:39 2016 +0200
@@ -48,6 +48,7 @@
   General/sha1.scala
   General/sql.scala
   General/sqlite.scala
+  General/ssh.scala
   General/symbol.scala
   General/time.scala
   General/timing.scala
@@ -123,6 +124,7 @@
   Tools/ml_statistics.scala
   Tools/news.scala
   Tools/print_operation.scala
+  Tools/remote_dmg.scala
   Tools/simplifier_trace.scala
   Tools/task_statistics.scala
   Tools/update_cartouches.scala