blob: 39a854d846bd0e9181c552b00fa1ecff227a9a5a [file] [log] [blame]
// 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);
}