| // Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| /** |
| * Helper functionality to make working with IO easier. |
| */ |
| library io; |
| |
| import 'dart:io'; |
| import 'dart:isolate'; |
| import 'dart:uri'; |
| |
| // TODO(nweiz): Make this import better. |
| import '../../pkg/http/lib/http.dart' as http; |
| import 'curl_client.dart'; |
| import 'log.dart' as log; |
| import 'path.dart' as path; |
| import 'utils.dart'; |
| |
| bool _isGitInstalledCache; |
| |
| /// The cached Git command. |
| String _gitCommandCache; |
| |
| final NEWLINE_PATTERN = new RegExp("\r\n?|\n\r?"); |
| |
| /** |
| * Joins a number of path string parts into a single path. Handles |
| * platform-specific path separators. Parts can be [String], [Directory], or |
| * [File] objects. |
| */ |
| String join(part1, [part2, part3, part4, part5, part6, part7, part8]) { |
| var parts = [part1, part2, part3, part4, part5, part6, part7, part8] |
| .map((part) => part == null ? null : _getPath(part)); |
| |
| return path.join(parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], |
| parts[6], parts[7]); |
| } |
| |
| /// Gets the basename, the file name without any leading directory path, for |
| /// [file], which can either be a [String], [File], or [Directory]. |
| String basename(file) => path.basename(_getPath(file)); |
| |
| /// Gets the the leading directory path for [file], which can either be a |
| /// [String], [File], or [Directory]. |
| String dirname(file) => path.dirname(_getPath(file)); |
| |
| /// Splits [entry] into its individual components. |
| List<String> splitPath(entry) => path.split(_getPath(entry)); |
| |
| /// Returns whether or not [entry] is nested somewhere within [dir]. This just |
| /// performs a path comparison; it doesn't look at the actual filesystem. |
| bool isBeneath(entry, dir) { |
| var relative = relativeTo(entry, dir); |
| return !path.isAbsolute(relative) && splitPath(relative)[0] != '..'; |
| } |
| |
| /// Returns the path to [target] from [base]. |
| String relativeTo(target, base) => path.relative(target, from: base); |
| |
| /** |
| * Asynchronously determines if [path], which can be a [String] file path, a |
| * [File], or a [Directory] exists on the file system. Returns a [Future] that |
| * completes with the result. |
| */ |
| Future<bool> exists(path) { |
| path = _getPath(path); |
| return Futures.wait([fileExists(path), dirExists(path)]).transform((results) { |
| return results[0] || results[1]; |
| }); |
| } |
| |
| /** |
| * Asynchronously determines if [file], which can be a [String] file path or a |
| * [File], exists on the file system. Returns a [Future] that completes with |
| * the result. |
| */ |
| Future<bool> fileExists(file) { |
| var path = _getPath(file); |
| return log.ioAsync("Seeing if file $path exists.", |
| new File(path).exists(), |
| (exists) => "File $path ${exists ? 'exists' : 'does not exist'}."); |
| } |
| |
| /** |
| * Reads the contents of the text file [file], which can either be a [String] or |
| * a [File]. |
| */ |
| Future<String> readTextFile(file) { |
| var path = _getPath(file); |
| return log.ioAsync("Reading text file $path.", |
| new File(path).readAsString(Encoding.UTF_8), |
| (contents) { |
| // Sanity check: don't spew a huge file. |
| if (contents.length < 1024 * 1024) { |
| return "Read $path. Contents:\n$contents"; |
| } else { |
| return "Read ${contents.length} characters from $path."; |
| } |
| }); |
| } |
| |
| /** |
| * Creates [file] (which can either be a [String] or a [File]), and writes |
| * [contents] to it. Completes when the file is written and closed. |
| */ |
| Future<File> writeTextFile(file, String contents) { |
| var path = _getPath(file); |
| file = new File(path); |
| |
| // Sanity check: don't spew a huge file. |
| log.io("Writing ${contents.length} characters to text file $path."); |
| if (contents.length < 1024 * 1024) { |
| log.fine("Contents:\n$contents"); |
| } |
| |
| return file.open(FileMode.WRITE).chain((opened) { |
| return opened.writeString(contents).chain((ignore) { |
| return opened.close().transform((_) { |
| log.fine("Wrote text file $path."); |
| return file; |
| }); |
| }); |
| }); |
| } |
| |
| /** |
| * Asynchronously deletes [file], which can be a [String] or a [File]. Returns a |
| * [Future] that completes when the deletion is done. |
| */ |
| Future<File> deleteFile(file) { |
| var path = _getPath(file); |
| return log.ioAsync("delete file $path", |
| new File(path).delete()); |
| } |
| |
| /// Writes [stream] to a new file at [path], which may be a [String] or a |
| /// [File]. Will replace any file already at that path. Completes when the file |
| /// is done being written. |
| Future<File> createFileFromStream(InputStream stream, path) { |
| path = _getPath(path); |
| |
| log.io("Creating $path from stream."); |
| |
| var completer = new Completer<File>(); |
| var file = new File(path); |
| var outputStream = file.openOutputStream(); |
| stream.pipe(outputStream); |
| |
| outputStream.onClosed = () { |
| log.fine("Created $path from stream."); |
| completer.complete(file); |
| }; |
| |
| // TODO(nweiz): remove this when issue 4061 is fixed. |
| var stackTrace; |
| try { |
| throw ""; |
| } catch (_, localStackTrace) { |
| stackTrace = localStackTrace; |
| } |
| |
| completeError(error) { |
| if (!completer.isComplete) { |
| completer.completeException(error, stackTrace); |
| } else { |
| log.fine("Got error after stream was closed: $error"); |
| } |
| } |
| |
| stream.onError = completeError; |
| outputStream.onError = completeError; |
| |
| return completer.future; |
| } |
| |
| /** |
| * Creates a directory [dir]. Returns a [Future] that completes when the |
| * directory is created. |
| */ |
| Future<Directory> createDir(dir) { |
| dir = _getDirectory(dir); |
| return log.ioAsync("create directory ${dir.path}", |
| dir.create()); |
| } |
| |
| /** |
| * Ensures that [path] and all its parent directories exist. If they don't |
| * exist, creates them. Returns a [Future] that completes once all the |
| * directories are created. |
| */ |
| Future<Directory> ensureDir(path) { |
| path = _getPath(path); |
| log.fine("Ensuring directory $path exists."); |
| if (path == '.') return new Future.immediate(new Directory('.')); |
| |
| return dirExists(path).chain((exists) { |
| if (exists) { |
| log.fine("Directory $path already exists."); |
| return new Future.immediate(new Directory(path)); |
| } |
| |
| return ensureDir(dirname(path)).chain((_) { |
| var completer = new Completer<Directory>(); |
| var future = createDir(path); |
| future.handleException((error) { |
| if (error is! DirectoryIOException) return false; |
| // Error 17 means the directory already exists (or 183 on Windows). |
| if (error.osError.errorCode != 17 && |
| error.osError.errorCode != 183) { |
| log.fine("Got 'already exists' error when creating directory."); |
| return false; |
| } |
| |
| completer.complete(_getDirectory(path)); |
| return true; |
| }); |
| future.then(completer.complete); |
| return completer.future; |
| }); |
| }); |
| } |
| |
| /** |
| * Creates a temp directory whose name will be based on [dir] with a unique |
| * suffix appended to it. If [dir] is not provided, a temp directory will be |
| * created in a platform-dependent temporary location. Returns a [Future] that |
| * completes when the directory is created. |
| */ |
| Future<Directory> createTempDir([dir = '']) { |
| dir = _getDirectory(dir); |
| return log.ioAsync("create temp directory ${dir.path}", |
| dir.createTemp()); |
| } |
| |
| /** |
| * Asynchronously recursively deletes [dir], which can be a [String] or a |
| * [Directory]. Returns a [Future] that completes when the deletion is done. |
| */ |
| Future<Directory> deleteDir(dir) { |
| dir = _getDirectory(dir); |
| |
| return _attemptRetryable(() => log.ioAsync("delete directory ${dir.path}", |
| dir.delete(recursive: true))); |
| } |
| |
| /** |
| * Asynchronously lists the contents of [dir], which can be a [String] directory |
| * path or a [Directory]. If [recursive] is `true`, lists subdirectory contents |
| * (defaults to `false`). If [includeHiddenFiles] is `true`, includes files and |
| * directories beginning with `.` (defaults to `false`). |
| */ |
| Future<List<String>> listDir(dir, |
| {bool recursive: false, bool includeHiddenFiles: false}) { |
| final completer = new Completer<List<String>>(); |
| final contents = <String>[]; |
| |
| dir = _getDirectory(dir); |
| log.io("Listing directory ${dir.path}."); |
| var lister = dir.list(recursive: recursive); |
| |
| lister.onDone = (done) { |
| // TODO(rnystrom): May need to sort here if it turns out onDir and onFile |
| // aren't guaranteed to be called in a certain order. So far, they seem to. |
| if (done) { |
| log.fine("Listed directory ${dir.path}:\n" |
| "${Strings.join(contents, '\n')}"); |
| completer.complete(contents); |
| } |
| }; |
| |
| // TODO(nweiz): remove this when issue 4061 is fixed. |
| var stackTrace; |
| try { |
| throw ""; |
| } catch (_, localStackTrace) { |
| stackTrace = localStackTrace; |
| } |
| |
| lister.onError = (error) => completer.completeException(error, stackTrace); |
| lister.onDir = (file) { |
| if (!includeHiddenFiles && basename(file).startsWith('.')) return; |
| contents.add(file); |
| }; |
| lister.onFile = (file) { |
| if (!includeHiddenFiles && basename(file).startsWith('.')) return; |
| contents.add(file); |
| }; |
| |
| return completer.future; |
| } |
| |
| /** |
| * Asynchronously determines if [dir], which can be a [String] directory path |
| * or a [Directory], exists on the file system. Returns a [Future] that |
| * completes with the result. |
| */ |
| Future<bool> dirExists(dir) { |
| dir = _getDirectory(dir); |
| return log.ioAsync("Seeing if directory ${dir.path} exists.", |
| dir.exists(), |
| (exists) => "Directory ${dir.path} " |
| "${exists ? 'exists' : 'does not exist'}."); |
| } |
| |
| /** |
| * "Cleans" [dir]. If that directory already exists, it will be deleted. Then a |
| * new empty directory will be created. Returns a [Future] that completes when |
| * the new clean directory is created. |
| */ |
| Future<Directory> cleanDir(dir) { |
| return dirExists(dir).chain((exists) { |
| if (exists) { |
| // Delete it first. |
| return deleteDir(dir).chain((_) => createDir(dir)); |
| } else { |
| // Just create it. |
| return createDir(dir); |
| } |
| }); |
| } |
| |
| /// Renames (i.e. moves) the directory [from] to [to]. Returns a [Future] with |
| /// the destination directory. |
| Future<Directory> renameDir(from, String to) { |
| from = _getDirectory(from); |
| log.io("Renaming directory ${from.path} to $to."); |
| |
| return _attemptRetryable(() => from.rename(to)).transform((dir) { |
| log.fine("Renamed directory ${from.path} to $to."); |
| return dir; |
| }); |
| } |
| |
| /// On Windows, we sometimes get failures where the directory is still in use |
| /// when we try to do something with it. This is usually because the OS hasn't |
| /// noticed yet that a process using that directory has closed. To be a bit |
| /// more resilient, we wait and retry a few times. |
| /// |
| /// Takes a [callback] which returns a future for the operation being attempted. |
| /// If that future completes with an error, it will slepp and then [callback] |
| /// will be invoked again to retry the operation. It will try a few times before |
| /// giving up. |
| Future _attemptRetryable(Future callback()) { |
| // Only do lame retry logic on Windows. |
| if (Platform.operatingSystem != 'windows') return callback(); |
| |
| var attempts = 0; |
| makeAttempt(_) { |
| attempts++; |
| return callback().transformException((e) { |
| if (attempts >= 10) { |
| throw 'Could not complete operation. Gave up after $attempts attempts.'; |
| } |
| |
| // Wait a bit and try again. |
| log.fine("Operation failed, retrying (attempt $attempts)."); |
| return sleep(500).chain(makeAttempt); |
| }); |
| } |
| |
| return makeAttempt(null); |
| } |
| |
| /** |
| * Creates a new symlink that creates an alias from [from] to [to], both of |
| * which can be a [String], [File], or [Directory]. Returns a [Future] which |
| * completes to the symlink file (i.e. [to]). |
| */ |
| Future<File> createSymlink(from, to) { |
| from = _getPath(from); |
| to = _getPath(to); |
| |
| log.fine("Create symlink $from -> $to."); |
| |
| var command = 'ln'; |
| var args = ['-s', from, to]; |
| |
| if (Platform.operatingSystem == 'windows') { |
| // Call mklink on Windows to create an NTFS junction point. Only works on |
| // Vista or later. (Junction points are available earlier, but the "mklink" |
| // command is not.) I'm using a junction point (/j) here instead of a soft |
| // link (/d) because the latter requires some privilege shenanigans that |
| // I'm not sure how to specify from the command line. |
| command = 'mklink'; |
| args = ['/j', to, from]; |
| } |
| |
| return runProcess(command, args).transform((result) { |
| // TODO(rnystrom): Check exit code and output? |
| return new File(to); |
| }); |
| } |
| |
| /** |
| * Creates a new symlink that creates an alias from the `lib` directory of |
| * package [from] to [to], both of which can be a [String], [File], or |
| * [Directory]. Returns a [Future] which completes to the symlink file (i.e. |
| * [to]). If [from] does not have a `lib` directory, this shows a warning if |
| * appropriate and then does nothing. |
| */ |
| Future<File> createPackageSymlink(String name, from, to, |
| {bool isSelfLink: false}) { |
| // See if the package has a "lib" directory. |
| from = join(from, 'lib'); |
| return dirExists(from).chain((exists) { |
| log.fine("Creating ${isSelfLink ? "self" : ""}link for package '$name'."); |
| if (exists) return createSymlink(from, to); |
| |
| // It's OK for the self link (i.e. the root package) to not have a lib |
| // directory since it may just be a leaf application that only has |
| // code in bin or web. |
| if (!isSelfLink) { |
| log.warning('Warning: Package "$name" does not have a "lib" directory so ' |
| 'you will not be able to import any libraries from it.'); |
| } |
| |
| return new Future.immediate(to); |
| }); |
| } |
| |
| /// Given [entry] which may be a [String], [File], or [Directory] relative to |
| /// the current working directory, returns its full canonicalized path. |
| String getFullPath(entry) => path.absolute(_getPath(entry)); |
| |
| /// Returns whether or not [entry] is an absolute path. |
| bool isAbsolute(entry) => path.isAbsolute(_getPath(entry)); |
| |
| /// Resolves [target] relative to the location of pub.dart. |
| String relativeToPub(String target) { |
| var scriptPath = new File(new Options().script).fullPathSync(); |
| |
| // Walk up until we hit the "util(s)" directory. This lets us figure out where |
| // we are if this function is called from pub.dart, or one of the tests, |
| // which also live under "utils", or from the SDK where pub is in "util". |
| var utilDir = dirname(scriptPath); |
| while (basename(utilDir) != 'utils' && basename(utilDir) != 'util') { |
| if (basename(utilDir) == '') throw 'Could not find path to pub.'; |
| utilDir = dirname(utilDir); |
| } |
| |
| return path.normalize(join(utilDir, 'pub', target)); |
| } |
| |
| /// A StringInputStream reading from stdin. |
| final _stringStdin = new StringInputStream(stdin); |
| |
| /// Displays a message and reads a yes/no confirmation from the user. Returns |
| /// a [Future] that completes to `true` if the user confirms or `false` if they |
| /// do not. |
| /// |
| /// This will automatically append " (y/n)?" to the message, so [message] |
| /// should just be a fragment like, "Are you sure you want to proceed". |
| Future<bool> confirm(String message) { |
| log.fine('Showing confirm message: $message'); |
| stdout.writeString("$message (y/n)? "); |
| return readLine().transform((line) => new RegExp(r"^[yY]").hasMatch(line)); |
| } |
| |
| /// Returns a single line read from a [StringInputStream]. By default, reads |
| /// from stdin. |
| /// |
| /// A [StringInputStream] passed to this should have no callbacks registered. |
| Future<String> readLine([StringInputStream stream]) { |
| if (stream == null) stream = _stringStdin; |
| if (stream.closed) return new Future.immediate(''); |
| void removeCallbacks() { |
| stream.onClosed = null; |
| stream.onLine = null; |
| stream.onError = null; |
| } |
| |
| // TODO(nweiz): remove this when issue 4061 is fixed. |
| var stackTrace; |
| try { |
| throw ""; |
| } catch (_, localStackTrace) { |
| stackTrace = localStackTrace; |
| } |
| |
| var completer = new Completer(); |
| stream.onClosed = () { |
| removeCallbacks(); |
| completer.complete(''); |
| }; |
| |
| stream.onLine = () { |
| removeCallbacks(); |
| var line = stream.readLine(); |
| log.io('Read line: $line'); |
| completer.complete(line); |
| }; |
| |
| stream.onError = (e) { |
| removeCallbacks(); |
| completer.completeException(e, stackTrace); |
| }; |
| |
| return completer.future; |
| } |
| |
| // TODO(nweiz): make this configurable |
| /** |
| * The amount of time in milliseconds to allow HTTP requests before assuming |
| * they've failed. |
| */ |
| final HTTP_TIMEOUT = 30 * 1000; |
| |
| /// An HTTP client that transforms 40* errors and socket exceptions into more |
| /// user-friendly error messages. |
| class PubHttpClient extends http.BaseClient { |
| final http.Client _inner; |
| |
| PubHttpClient([http.Client inner]) |
| : _inner = inner == null ? new http.Client() : inner; |
| |
| Future<http.StreamedResponse> send(http.BaseRequest request) { |
| log.io("Sending HTTP request $request."); |
| // TODO(rnystrom): Log request body when it's available and plaintext. |
| |
| // TODO(nweiz): remove this when issue 4061 is fixed. |
| var stackTrace; |
| try { |
| throw null; |
| } catch (_, localStackTrace) { |
| stackTrace = localStackTrace; |
| } |
| |
| // TODO(nweiz): Ideally the timeout would extend to reading from the |
| // response input stream, but until issue 3657 is fixed that's not feasible. |
| return timeout(_inner.send(request).chain((streamedResponse) { |
| log.fine("Got response ${streamedResponse.statusCode} " |
| "${streamedResponse.reasonPhrase}."); |
| |
| var status = streamedResponse.statusCode; |
| // 401 responses should be handled by the OAuth2 client. It's very |
| // unlikely that they'll be returned by non-OAuth2 requests. |
| if (status < 400 || status == 401) { |
| return new Future.immediate(streamedResponse); |
| } |
| |
| return http.Response.fromStream(streamedResponse).transform((response) { |
| throw new PubHttpException(response); |
| }); |
| }).transformException((e) { |
| if (e is SocketIOException && |
| e.osError != null && |
| (e.osError.errorCode == 8 || |
| e.osError.errorCode == -2 || |
| e.osError.errorCode == -5 || |
| e.osError.errorCode == 11004)) { |
| throw 'Could not resolve URL "${request.url.origin}".'; |
| } |
| throw e; |
| }), HTTP_TIMEOUT, 'fetching URL "${request.url}"'); |
| } |
| } |
| |
| /// The HTTP client to use for all HTTP requests. |
| final httpClient = new PubHttpClient(); |
| |
| final curlClient = new PubHttpClient(new CurlClient()); |
| |
| /** |
| * Takes all input from [source] and writes it to [sink]. |
| * |
| * Returns a future that completes when [source] is closed. |
| */ |
| Future pipeInputToInput(InputStream source, ListInputStream sink) { |
| var completer = new Completer(); |
| source.onClosed = () { |
| sink.markEndOfStream(); |
| completer.complete(null); |
| }; |
| source.onData = () { |
| // Even if the sink is closed and we aren't going to do anything with more |
| // data, we still need to drain it from source to work around issue 7218. |
| var data = source.read(); |
| try { |
| if (!sink.closed) sink.write(data); |
| } on StreamException catch (e, stackTrace) { |
| // Ignore an exception to work around issue 4222. |
| log.io("Writing to an unclosed ListInputStream caused exception $e\n" |
| "$stackTrace"); |
| } |
| }; |
| // TODO(nweiz): propagate this error to the sink. See issue 3657. |
| source.onError = (e) { throw e; }; |
| return completer.future; |
| } |
| |
| /** |
| * Buffers all input from an InputStream and returns it as a future. |
| */ |
| Future<List<int>> consumeInputStream(InputStream stream) { |
| if (stream.closed) return new Future.immediate(<int>[]); |
| |
| // TODO(nweiz): remove this when issue 4061 is fixed. |
| var stackTrace; |
| try { |
| throw ""; |
| } catch (_, localStackTrace) { |
| stackTrace = localStackTrace; |
| } |
| |
| var completer = new Completer<List<int>>(); |
| var buffer = <int>[]; |
| stream.onClosed = () => completer.complete(buffer); |
| stream.onData = () => buffer.addAll(stream.read()); |
| stream.onError = (e) => completer.completeException(e, stackTrace); |
| return completer.future; |
| } |
| |
| /// Buffers all input from a StringInputStream and returns it as a future. |
| Future<String> consumeStringInputStream(StringInputStream stream) { |
| if (stream.closed) return new Future.immediate(''); |
| |
| // TODO(nweiz): remove this when issue 4061 is fixed. |
| var stackTrace; |
| try { |
| throw ""; |
| } catch (_, localStackTrace) { |
| stackTrace = localStackTrace; |
| } |
| |
| var completer = new Completer<String>(); |
| var buffer = new StringBuffer(); |
| stream.onClosed = () => completer.complete(buffer.toString()); |
| stream.onData = () => buffer.add(stream.read()); |
| stream.onError = (e) => completer.completeException(e, stackTrace); |
| return completer.future; |
| } |
| |
| /// Wrap an InputStream in a ListInputStream. This eagerly drains the [source] |
| /// input stream. This is useful for spawned processes which will not exit until |
| /// their output streams have been drained. |
| /// TODO(rnystrom): We should use this logic anywhere we spawn a process. |
| InputStream wrapInputStream(InputStream source) { |
| var sink = new ListInputStream(); |
| pipeInputToInput(source, sink); |
| return sink; |
| } |
| |
| /// Spawns and runs the process located at [executable], passing in [args]. |
| /// Returns a [Future] that will complete with the results of the process after |
| /// it has ended. |
| /// |
| /// The spawned process will inherit its parent's environment variables. If |
| /// [environment] is provided, that will be used to augment (not replace) the |
| /// the inherited variables. |
| Future<PubProcessResult> runProcess(String executable, List<String> args, |
| {workingDir, Map<String, String> environment}) { |
| return _doProcess(Process.run, executable, args, workingDir, environment) |
| .transform((result) { |
| // TODO(rnystrom): Remove this and change to returning one string. |
| List<String> toLines(String output) { |
| var lines = output.split(NEWLINE_PATTERN); |
| if (!lines.isEmpty && lines.last == "") lines.removeLast(); |
| return lines; |
| } |
| |
| var pubResult = new PubProcessResult(toLines(result.stdout), |
| toLines(result.stderr), |
| result.exitCode); |
| |
| log.processResult(executable, pubResult); |
| return pubResult; |
| }); |
| } |
| |
| /// Spawns the process located at [executable], passing in [args]. Returns a |
| /// [Future] that will complete with the [Process] once it's been started. |
| /// |
| /// The spawned process will inherit its parent's environment variables. If |
| /// [environment] is provided, that will be used to augment (not replace) the |
| /// the inherited variables. |
| Future<Process> startProcess(String executable, List<String> args, |
| {workingDir, Map<String, String> environment}) => |
| _doProcess(Process.start, executable, args, workingDir, environment) |
| .transform((process) => new _WrappedProcess(process)); |
| |
| /// A wrapper around [Process] that buffers the stdout and stderr to avoid |
| /// running into issue 7218. |
| class _WrappedProcess implements Process { |
| final Process _process; |
| final InputStream stderr; |
| final InputStream stdout; |
| |
| OutputStream get stdin => _process.stdin; |
| |
| void set onExit(void callback(int exitCode)) { |
| _process.onExit = callback; |
| } |
| |
| _WrappedProcess(Process process) |
| : _process = process, |
| stderr = wrapInputStream(process.stderr), |
| stdout = wrapInputStream(process.stdout); |
| |
| bool kill([ProcessSignal signal = ProcessSignal.SIGTERM]) => |
| _process.kill(signal); |
| } |
| |
| /// Calls [fn] with appropriately modified arguments. [fn] should have the same |
| /// signature as [Process.start], except that the returned [Future] may have a |
| /// type other than [Process]. |
| Future _doProcess(Function fn, String executable, List<String> args, workingDir, |
| Map<String, String> environment) { |
| // TODO(rnystrom): Should dart:io just handle this? |
| // Spawning a process on Windows will not look for the executable in the |
| // system path. So, if executable looks like it needs that (i.e. it doesn't |
| // have any path separators in it), then spawn it through a shell. |
| if ((Platform.operatingSystem == "windows") && |
| (executable.indexOf('\\') == -1)) { |
| args = flatten(["/c", executable, args]); |
| executable = "cmd"; |
| } |
| |
| final options = new ProcessOptions(); |
| if (workingDir != null) { |
| options.workingDirectory = _getDirectory(workingDir).path; |
| } |
| |
| if (environment != null) { |
| options.environment = new Map.from(Platform.environment); |
| environment.forEach((key, value) => options.environment[key] = value); |
| } |
| |
| log.process(executable, args); |
| |
| return fn(executable, args, options); |
| } |
| |
| /** |
| * Wraps [input] to provide a timeout. If [input] completes before |
| * [milliseconds] have passed, then the return value completes in the same way. |
| * However, if [milliseconds] pass before [input] has completed, it completes |
| * with a [TimeoutException] with [description] (which should be a fragment |
| * describing the action that timed out). |
| * |
| * Note that timing out will not cancel the asynchronous operation behind |
| * [input]. |
| */ |
| Future timeout(Future input, int milliseconds, String description) { |
| var completer = new Completer(); |
| var timer = new Timer(milliseconds, (_) { |
| if (completer.future.isComplete) return; |
| completer.completeException(new TimeoutException( |
| 'Timed out while $description.')); |
| }); |
| input.handleException((e) { |
| if (completer.future.isComplete) return false; |
| timer.cancel(); |
| completer.completeException(e, input.stackTrace); |
| return true; |
| }); |
| input.then((value) { |
| if (completer.future.isComplete) return; |
| timer.cancel(); |
| completer.complete(value); |
| }); |
| return completer.future; |
| } |
| |
| /// Creates a temporary directory and passes its path to [fn]. Once the [Future] |
| /// returned by [fn] completes, the temporary directory and all its contents |
| /// will be deleted. |
| Future withTempDir(Future fn(String path)) { |
| var tempDir; |
| var future = createTempDir().chain((dir) { |
| tempDir = dir; |
| return fn(tempDir.path); |
| }); |
| future.onComplete((_) { |
| log.fine('Cleaning up temp directory ${tempDir.path}.'); |
| deleteDir(tempDir); |
| }); |
| return future; |
| } |
| |
| /// Tests whether or not the git command-line app is available for use. |
| Future<bool> get isGitInstalled { |
| if (_isGitInstalledCache != null) { |
| // TODO(rnystrom): The sleep is to pump the message queue. Can use |
| // Future.immediate() when #3356 is fixed. |
| return sleep(0).transform((_) => _isGitInstalledCache); |
| } |
| |
| return _gitCommand.transform((git) => git != null); |
| } |
| |
| /// Run a git process with [args] from [workingDir]. |
| Future<PubProcessResult> runGit(List<String> args, |
| {String workingDir, Map<String, String> environment}) { |
| return _gitCommand.chain((git) => runProcess(git, args, |
| workingDir: workingDir, environment: environment)); |
| } |
| |
| /// Returns the name of the git command-line app, or null if Git could not be |
| /// found on the user's PATH. |
| Future<String> get _gitCommand { |
| // TODO(nweiz): Just use Future.immediate once issue 3356 is fixed. |
| if (_gitCommandCache != null) { |
| return sleep(0).transform((_) => _gitCommandCache); |
| } |
| |
| return _tryGitCommand("git").chain((success) { |
| if (success) return new Future.immediate("git"); |
| |
| // Git is sometimes installed on Windows as `git.cmd` |
| return _tryGitCommand("git.cmd").transform((success) { |
| if (success) return "git.cmd"; |
| return null; |
| }); |
| }).transform((command) { |
| _gitCommandCache = command; |
| return command; |
| }); |
| } |
| |
| /// Checks whether [command] is the Git command for this computer. |
| Future<bool> _tryGitCommand(String command) { |
| var completer = new Completer<bool>(); |
| |
| // If "git --version" prints something familiar, git is working. |
| var future = runProcess(command, ["--version"]); |
| |
| future.then((results) { |
| var regex = new RegExp("^git version"); |
| completer.complete(results.stdout.length == 1 && |
| regex.hasMatch(results.stdout[0])); |
| }); |
| |
| future.handleException((err) { |
| // If the process failed, they probably don't have it. |
| completer.complete(false); |
| return true; |
| }); |
| |
| return completer.future; |
| } |
| |
| /** |
| * Extracts a `.tar.gz` file from [stream] to [destination], which can be a |
| * directory or a path. Returns whether or not the extraction was successful. |
| */ |
| Future<bool> extractTarGz(InputStream stream, destination) { |
| destination = _getPath(destination); |
| |
| log.fine("Extracting .tar.gz stream to $destination."); |
| |
| if (Platform.operatingSystem == "windows") { |
| return _extractTarGzWindows(stream, destination); |
| } |
| |
| var completer = new Completer<int>(); |
| var processFuture = startProcess("tar", |
| ["--extract", "--gunzip", "--directory", destination]); |
| processFuture.then((process) { |
| process.onExit = completer.complete; |
| stream.pipe(process.stdin); |
| process.stdout.pipe(stdout, close: false); |
| process.stderr.pipe(stderr, close: false); |
| }); |
| processFuture.handleException((error) { |
| completer.completeException(error, processFuture.stackTrace); |
| return true; |
| }); |
| |
| return completer.future.transform((exitCode) { |
| log.fine("Extracted .tar.gz stream to $destination. Exit code $exitCode."); |
| // TODO(rnystrom): Does anything check this result value? If not, it should |
| // throw on a bad exit code. |
| return exitCode == 0; |
| }); |
| } |
| |
| Future<bool> _extractTarGzWindows(InputStream stream, String destination) { |
| // TODO(rnystrom): In the repo's history, there is an older implementation of |
| // this that does everything in memory by piping streams directly together |
| // instead of writing out temp files. The code is simpler, but unfortunately, |
| // 7zip seems to periodically fail when we invoke it from Dart and tell it to |
| // read from stdin instead of a file. Consider resurrecting that version if |
| // we can figure out why it fails. |
| |
| // Note: This line of code gets munged by create_sdk.py to be the correct |
| // relative path to 7zip in the SDK. |
| var pathTo7zip = '../../third_party/7zip/7za.exe'; |
| var command = relativeToPub(pathTo7zip); |
| |
| var tempDir; |
| |
| // TODO(rnystrom): Use withTempDir(). |
| return createTempDir().chain((temp) { |
| // Write the archive to a temp file. |
| tempDir = temp; |
| return createFileFromStream(stream, join(tempDir, 'data.tar.gz')); |
| }).chain((_) { |
| // 7zip can't unarchive from gzip -> tar -> destination all in one step |
| // first we un-gzip it to a tar file. |
| // Note: Setting the working directory instead of passing in a full file |
| // path because 7zip says "A full path is not allowed here." |
| return runProcess(command, ['e', 'data.tar.gz'], workingDir: tempDir); |
| }).chain((result) { |
| if (result.exitCode != 0) { |
| throw 'Could not un-gzip (exit code ${result.exitCode}). Error:\n' |
| '${Strings.join(result.stdout, "\n")}\n' |
| '${Strings.join(result.stderr, "\n")}'; |
| } |
| // Find the tar file we just created since we don't know its name. |
| return listDir(tempDir); |
| }).chain((files) { |
| var tarFile; |
| for (var file in files) { |
| if (path.extension(file) == '.tar') { |
| tarFile = file; |
| break; |
| } |
| } |
| |
| if (tarFile == null) throw 'The gzip file did not contain a tar file.'; |
| |
| // Untar the archive into the destination directory. |
| return runProcess(command, ['x', tarFile], workingDir: destination); |
| }).chain((result) { |
| if (result.exitCode != 0) { |
| throw 'Could not un-tar (exit code ${result.exitCode}). Error:\n' |
| '${Strings.join(result.stdout, "\n")}\n' |
| '${Strings.join(result.stderr, "\n")}'; |
| } |
| |
| log.fine('Clean up 7zip temp directory ${tempDir.path}.'); |
| // TODO(rnystrom): Should also delete this if anything fails. |
| return deleteDir(tempDir); |
| }).transform((_) => true); |
| } |
| |
| /// Create a .tar.gz archive from a list of entries. Each entry can be a |
| /// [String], [Directory], or [File] object. The root of the archive is |
| /// considered to be [baseDir], which defaults to the current working directory. |
| /// Returns an [InputStream] that will emit the contents of the archive. |
| InputStream createTarGz(List contents, {baseDir}) { |
| var buffer = new StringBuffer(); |
| buffer.add('Creating .tag.gz stream containing:\n'); |
| contents.forEach((file) => buffer.add('$file\n')); |
| log.fine(buffer.toString()); |
| |
| // TODO(nweiz): Propagate errors to the returned stream (including non-zero |
| // exit codes). See issue 3657. |
| var stream = new ListInputStream(); |
| |
| if (baseDir == null) baseDir = path.current; |
| baseDir = getFullPath(baseDir); |
| contents = contents.map((entry) { |
| entry = getFullPath(entry); |
| if (!isBeneath(entry, baseDir)) { |
| throw 'Entry $entry is not inside $baseDir.'; |
| } |
| return relativeTo(entry, baseDir); |
| }); |
| |
| if (Platform.operatingSystem != "windows") { |
| var args = ["--create", "--gzip", "--directory", baseDir]; |
| args.addAll(contents.map(_getPath)); |
| // TODO(nweiz): It's possible that enough command-line arguments will make |
| // the process choke, so at some point we should save the arguments to a |
| // file and pass them in via --files-from for tar and -i@filename for 7zip. |
| startProcess("tar", args).then((process) { |
| pipeInputToInput(process.stdout, stream); |
| |
| // Drain and discard 7zip's stderr. 7zip writes its normal output to |
| // stderr. We don't want to show that since it's meaningless. |
| // TODO(rnystrom): Should log this and display it if an actual error |
| // occurs. |
| consumeInputStream(process.stderr); |
| }); |
| return stream; |
| } |
| |
| withTempDir((tempDir) { |
| // Create the tar file. |
| var tarFile = join(tempDir, "intermediate.tar"); |
| var args = ["a", "-w$baseDir", tarFile]; |
| args.addAll(contents.map((entry) => '-i!"$entry"')); |
| |
| // Note: This line of code gets munged by create_sdk.py to be the correct |
| // relative path to 7zip in the SDK. |
| var pathTo7zip = '../../third_party/7zip/7za.exe'; |
| var command = relativeToPub(pathTo7zip); |
| |
| // We're passing 'baseDir' both as '-w' and setting it as the working |
| // directory explicitly here intentionally. The former ensures that the |
| // files added to the archive have the correct relative path in the archive. |
| // The latter enables relative paths in the "-i" args to be resolved. |
| return runProcess(command, args, workingDir: baseDir).chain((_) { |
| // GZIP it. 7zip doesn't support doing both as a single operation. Send |
| // the output to stdout. |
| args = ["a", "unused", "-tgzip", "-so", tarFile]; |
| return startProcess(command, args); |
| }).chain((process) { |
| // Drain and discard 7zip's stderr. 7zip writes its normal output to |
| // stderr. We don't want to show that since it's meaningless. |
| // TODO(rnystrom): Should log this and display it if an actual error |
| // occurs. |
| consumeInputStream(process.stderr); |
| return pipeInputToInput(process.stdout, stream); |
| }); |
| }); |
| return stream; |
| } |
| |
| /** |
| * Exception thrown when an HTTP operation fails. |
| */ |
| class PubHttpException implements Exception { |
| final http.Response response; |
| |
| const PubHttpException(this.response); |
| |
| String toString() => 'HTTP error ${response.statusCode}: ' |
| '${response.reasonPhrase}'; |
| } |
| |
| /** |
| * Exception thrown when an operation times out. |
| */ |
| class TimeoutException implements Exception { |
| final String message; |
| |
| const TimeoutException(this.message); |
| |
| String toString() => message; |
| } |
| |
| /** |
| * Contains the results of invoking a [Process] and waiting for it to complete. |
| */ |
| class PubProcessResult { |
| final List<String> stdout; |
| final List<String> stderr; |
| final int exitCode; |
| |
| const PubProcessResult(this.stdout, this.stderr, this.exitCode); |
| |
| bool get success => exitCode == 0; |
| } |
| |
| /** |
| * Gets the path string for [entry], which can either already be a path string, |
| * or be a [File] or [Directory]. Allows working generically with "file-like" |
| * objects. |
| */ |
| String _getPath(entry) { |
| if (entry is String) return entry; |
| if (entry is File) return entry.name; |
| if (entry is Directory) return entry.path; |
| throw 'Entry $entry is not a supported type.'; |
| } |
| |
| /** |
| * Gets a [Directory] for [entry], which can either already be one, or be a |
| * [String]. |
| */ |
| Directory _getDirectory(entry) { |
| if (entry is Directory) return entry; |
| return new Directory(entry); |
| } |
| |
| /** |
| * Gets a [Uri] for [uri], which can either already be one, or be a [String]. |
| */ |
| Uri _getUri(uri) { |
| if (uri is Uri) return uri; |
| return new Uri.fromString(uri); |
| } |