// 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. library command_lish; import 'dart:async'; import 'dart:io'; import 'dart:json'; import 'dart:uri'; import '../../pkg/args/lib/args.dart'; import '../../pkg/http/lib/http.dart' as http; import '../../pkg/path/lib/path.dart' as path; import 'directory_tree.dart'; import 'exit_codes.dart' as exit_codes; import 'git.dart' as git; import 'http.dart'; import 'io.dart'; import 'log.dart' as log; import 'oauth2.dart' as oauth2; import 'pub.dart'; import 'utils.dart'; import 'validator.dart'; /// Handles the `lish` and `publish` pub commands. class LishCommand extends PubCommand { final description = "Publish the current package to pub.dartlang.org."; final usage = "pub publish [options]"; final aliases = const ["lish", "lush"]; ArgParser get commandParser { var parser = new ArgParser(); // TODO(nweiz): Use HostedSource.defaultUrl as the default value once we use // dart:io for HTTPS requests. parser.addFlag('dry-run', abbr: 'n', negatable: false, help: 'Validate but do not publish the package'); parser.addFlag('force', abbr: 'f', negatable: false, help: 'Publish without confirmation if there are no errors'); parser.addOption('server', defaultsTo: 'https://pub.dartlang.org', help: 'The package server to which to upload this package'); return parser; } /// The URL of the server to which to upload the package. Uri get server => Uri.parse(commandOptions['server']); /// Whether the publish is just a preview. bool get dryRun => commandOptions['dry-run']; /// Whether the publish requires confirmation. bool get force => commandOptions['force']; Future _publish(packageBytes) { var cloudStorageUrl; return oauth2.withClient(cache, (client) { // TODO(nweiz): Cloud Storage can provide an XML-formatted error. We // should report that error and exit. var newUri = server.resolve("/packages/versions/new.json"); return client.get(newUri).then((response) { var parameters = parseJsonResponse(response); var url = _expectField(parameters, 'url', response); if (url is! String) invalidServerResponse(response); cloudStorageUrl = Uri.parse(url); var request = new http.MultipartRequest('POST', cloudStorageUrl); var fields = _expectField(parameters, 'fields', response); if (fields is! Map) invalidServerResponse(response); fields.forEach((key, value) { if (value is! String) invalidServerResponse(response); request.fields[key] = value; }); request.followRedirects = false; request.files.add(new http.MultipartFile.fromBytes( 'file', packageBytes, filename: 'package.tar.gz')); return client.send(request); }).then(http.Response.fromStream).then((response) { var location = response.headers['location']; if (location == null) throw new PubHttpException(response); return location; }).then((location) => client.get(location)) .then(handleJsonSuccess); }).catchError((asyncError) { if (asyncError.error is! PubHttpException) throw asyncError; var url = asyncError.error.response.request.url; if (url.toString() == cloudStorageUrl.toString()) { // TODO(nweiz): the response may have XML-formatted information about // the error. Try to parse that out once we have an easily-accessible // XML parser. throw 'Failed to upload the package.'; } else if (url.origin == server.origin) { handleJsonError(asyncError.error.response); } }); } Future onRun() { if (force && dryRun) { log.error('Cannot use both --force and --dry-run.'); this.printUsage(); exit(exit_codes.USAGE); } var packageBytesFuture = _filesToPublish.then((files) { log.fine('Archiving and publishing ${entrypoint.root}.'); // Show the package contents so the user can verify they look OK. var package = entrypoint.root; log.message( 'Publishing "${package.name}" ${package.version}:\n' '${generateTree(files)}'); return createTarGz(files, baseDir: entrypoint.root.dir); }).then((stream) => stream.toBytes()); // Validate the package. return _validate(packageBytesFuture.then((bytes) => bytes.length)) .then((isValid) { if (isValid) return packageBytesFuture.then(_publish); }); } /// The basenames of files that are automatically excluded from archives. final _BLACKLISTED_FILES = const ['pubspec.lock']; /// The basenames of directories that are automatically excluded from /// archives. final _BLACKLISTED_DIRS = const ['packages']; /// Returns a list of files that should be included in the published package. /// If this is a Git repository, this will respect .gitignore; otherwise, it /// will return all non-hidden files. Future> get _filesToPublish { var rootDir = entrypoint.root.dir; return git.isInstalled.then((gitInstalled) { if (dirExists(path.join(rootDir, '.git')) && gitInstalled) { // List all files that aren't gitignored, including those not checked // in to Git. return git.run(["ls-files", "--cached", "--others", "--exclude-standard"]); } return listDir(rootDir, recursive: true).then((entries) { return entries .where(fileExists) // Skip directories. .map((entry) => path.relative(entry, from: rootDir)); }); }).then((files) => files.where(_shouldPublish).toList()); } /// Returns `true` if [file] should be published. bool _shouldPublish(String file) { if (_BLACKLISTED_FILES.contains(path.basename(file))) return false; return !path.split(file).any(_BLACKLISTED_DIRS.contains); } /// Returns the value associated with [key] in [map]. Throws a user-friendly /// error if [map] doens't contain [key]. _expectField(Map map, String key, http.Response response) { if (map.containsKey(key)) return map[key]; invalidServerResponse(response); } /// Validates the package. Completes to false if the upload should not /// proceed. Future _validate(Future packageSize) { return Validator.runAll(entrypoint, packageSize).then((pair) { var errors = pair.first; var warnings = pair.last; if (!errors.isEmpty) { log.error("Sorry, your package is missing " "${(errors.length > 1) ? 'some requirements' : 'a requirement'} " "and can't be published yet.\nFor more information, see: " "http://pub.dartlang.org/doc/pub-lish.html.\n"); return false; } if (force) return true; if (dryRun) { var s = warnings.length == 1 ? '' : 's'; log.warning("Package has ${warnings.length} warning$s."); return false; } var message = 'Looks great! Are you ready to upload your package'; if (!warnings.isEmpty) { var s = warnings.length == 1 ? '' : 's'; message = "Package has ${warnings.length} warning$s. Upload anyway"; } return confirm(message).then((confirmed) { if (!confirmed) { log.error("Package upload canceled."); return false; } return true; }); }); } }