blob: 5b2a37957a5350356fb7512f3ff8beb309e40e67 [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.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import '../ascii_tree.dart' as tree;
import '../authentication/client.dart';
import '../command.dart';
import '../command_runner.dart';
import '../exceptions.dart' show DataException;
import '../exit_codes.dart';
import '../http.dart';
import '../io.dart';
import '../log.dart' as log;
import '../oauth2.dart' as oauth2;
import '../pubspec.dart';
import '../solver/type.dart';
import '../source/hosted.dart' show validateAndNormalizeHostedUrl;
import '../source/root.dart';
import '../utils.dart';
import '../validator.dart';
/// Handles the `lish` and `publish` pub commands.
class LishCommand extends PubCommand {
@override
String get name => 'publish';
@override
String get description => 'Publish the current package to pub.dev.';
@override
String get argumentsDescription => '[options]';
@override
String get docUrl => 'https://dart.dev/tools/pub/cmd/pub-lish';
@override
bool get takesArguments => false;
/// The URL of the server to which to upload the package.
Uri computeHost(Pubspec pubspec) {
// An explicit argument takes precedence.
if (argResults.wasParsed('server')) {
try {
return validateAndNormalizeHostedUrl(
argResults.optionWithDefault('server'),
);
} on FormatException catch (e) {
usageException('Invalid server: $e');
}
}
// Otherwise, use the one specified in the pubspec.
final publishTo = pubspec.publishTo;
if (publishTo != null && publishTo != 'none') {
try {
return validateAndNormalizeHostedUrl(publishTo);
} on FormatException catch (e) {
throw DataException('Invalid publish_to: $e');
}
}
// Use the default server if nothing else is specified
return Uri.parse(cache.hosted.defaultUrl);
}
/// Whether the publish is just a preview.
bool get dryRun => argResults.flag('dry-run');
/// Whether the publish requires confirmation.
bool get force => argResults.flag('force');
bool get skipValidation => argResults.flag('skip-validation');
late final String? _fromArchive =
argResults.optionWithoutDefault('from-archive');
late final String? _toArchive = argResults.optionWithoutDefault('to-archive');
LishCommand() {
argParser.addFlag(
'dry-run',
abbr: 'n',
negatable: false,
help: 'Validate but do not publish the package.',
);
argParser.addFlag(
'force',
abbr: 'f',
negatable: false,
help: 'Publish without confirmation if there are no errors.',
);
argParser.addFlag(
'skip-validation',
negatable: false,
help:
'Publish without validation and resolution (this will ignore errors).',
);
argParser.addOption(
'server',
help: 'The package server to which to upload this package.',
hide: true,
);
argParser.addOption(
'to-archive',
help: 'Create a .tar.gz archive instead of publishing to server',
valueHelp: '[archive.tar.gz]',
hide: true,
);
argParser.addOption(
'from-archive',
help:
'Publish from a .tar.gz archive instead of current folder. Implies `--skip-validation`.',
valueHelp: '[archive.tar.gz]',
hide: true,
);
argParser.addOption(
'directory',
abbr: 'C',
help: 'Run this in the directory <dir>.',
valueHelp: 'dir',
);
}
Future<void> _publishUsingClient(
List<int> packageBytes,
http.Client client,
Uri host,
) async {
Uri? cloudStorageUrl;
try {
await log.progress('Uploading', () async {
/// 1. Initiate upload
final parametersResponse =
await retryForHttp('initiating upload', () async {
final request =
http.Request('GET', host.resolve('api/packages/versions/new'));
request.attachPubApiHeaders();
request.attachMetadataHeaders();
return await client.fetch(request);
});
final parameters = parseJsonResponse(parametersResponse);
/// 2. Upload package
var url = _expectField(parameters, 'url', parametersResponse);
if (url is! String) invalidServerResponse(parametersResponse);
cloudStorageUrl = Uri.parse(url);
final uploadResponse =
await retryForHttp('uploading package', () async {
// TODO(nweiz): Cloud Storage can provide an XML-formatted error. We
// should report that error and exit.
var request = http.MultipartRequest('POST', cloudStorageUrl!);
var fields = _expectField(parameters, 'fields', parametersResponse);
if (fields is! Map) invalidServerResponse(parametersResponse);
fields.forEach((key, value) {
if (value is! String) invalidServerResponse(parametersResponse);
request.fields[key as String] = value;
});
request.followRedirects = false;
request.files.add(
http.MultipartFile.fromBytes(
'file',
packageBytes,
filename: 'package.tar.gz',
),
);
return await client.fetch(request);
});
/// 3. Finalize publish
var location = uploadResponse.headers['location'];
if (location == null) throw PubHttpResponseException(uploadResponse);
final finalizeResponse =
await retryForHttp('finalizing publish', () async {
final request = http.Request('GET', Uri.parse(location));
request.attachPubApiHeaders();
request.attachMetadataHeaders();
return await client.fetch(request);
});
handleJsonSuccess(finalizeResponse);
});
} on AuthenticationException catch (error) {
var msg = '';
if (error.statusCode == 401) {
msg += '$host package repository requested authentication!\n'
'You can provide credentials using:\n'
' $topLevelProgram pub token add $host\n';
}
if (error.statusCode == 403) {
msg += 'Insufficient permissions to the resource at the $host '
'package repository.\nYou can modify credentials using:\n'
' $topLevelProgram pub token add $host\n';
}
if (error.serverMessage != null) {
msg += '\n${error.serverMessage!}\n';
}
dataError(msg + log.red('Authentication failed!'));
} on PubHttpResponseException catch (error) {
var url = error.response.request!.url;
if (url == cloudStorageUrl) {
handleGCSError(error.response);
fail(log.red('Failed to upload the package.'));
} else if (Uri.parse(url.origin) == Uri.parse(host.origin)) {
handleJsonError(error.response);
} else {
rethrow;
}
}
}
Future<void> _publish(List<int> packageBytes, Uri host) async {
try {
final officialPubServers = {
'https://pub.dev',
// [validateAndNormalizeHostedUrl] normalizes https://pub.dartlang.org
// to https://pub.dev, so we don't need to do allow that here.
// Pub uses oauth2 credentials only for authenticating official pub
// servers for security purposes (to not expose pub.dev access token to
// 3rd party servers).
// For testing publish command we're using mock servers hosted on
// localhost address which is not a known pub server address. So we
// explicitly have to define mock servers as official server to test
// publish command with oauth2 credentials.
if (runningFromTest &&
Platform.environment.containsKey('_PUB_TEST_DEFAULT_HOSTED_URL'))
Platform.environment['_PUB_TEST_DEFAULT_HOSTED_URL'],
};
// Using OAuth2 authentication client for the official pub servers
final isOfficialServer = officialPubServers.contains(host.toString());
if (isOfficialServer && !cache.tokenStore.hasCredential(host)) {
// Using OAuth2 authentication client for the official pub servers, when
// we don't have an explicit token from [TokenStore] to use instead.
//
// This allows us to use `dart pub token add` to inject a token for use
// with the official servers.
await oauth2.withClient((client) {
return _publishUsingClient(packageBytes, client, host);
});
} else {
// For third party servers using bearer authentication client
await withAuthenticatedClient(cache, host, (client) {
return _publishUsingClient(packageBytes, client, host);
});
}
} on PubHttpResponseException catch (error) {
var url = error.response.request!.url;
if (Uri.parse(url.origin) == Uri.parse(host.origin)) {
handleJsonError(error.response);
} else {
rethrow;
}
}
}
Future<void> _validateArgs() async {
if (argResults.wasParsed('server')) {
await log.errorsOnlyUnlessTerminal(() {
log.message(
'''
The --server option is deprecated. Use `publish_to` in your pubspec.yaml or set
the \$PUB_HOSTED_URL environment variable.''',
);
});
}
if (force && dryRun) {
usageException('Cannot use both --force and --dry-run.');
}
if (_fromArchive != null && _toArchive != null) {
usageException('Cannot use both --from-archive and --to-archive.');
}
if (_fromArchive != null && dryRun) {
usageException('Cannot use both --from-archive and --dry-run.');
}
if (_toArchive != null && force) {
usageException('Cannot use both --to-archive and --force.');
}
}
Future<_Publication> _publicationFromEntrypoint() async {
if (!dryRun &&
_toArchive == null &&
entrypoint.workPackage.pubspec.isPrivate) {
dataError('A private package cannot be published.\n'
'You can enable this by changing the "publish_to" field in your '
'pubspec.');
}
if (skipValidation) {
log.warning(
'Running with `skip-validation`. No client-side validation is done.',
);
} else {
await entrypoint.acquireDependencies(SolveType.get);
}
var files = entrypoint.workPackage.listFiles();
log.fine('Archiving and publishing ${entrypoint.workPackage.name}.');
// Show the package contents so the user can verify they look OK.
var package = entrypoint.workPackage;
final host = computeHost(package.pubspec);
log.message(
'Publishing ${package.name} ${package.version} to $host:\n'
'${tree.fromFiles(files, baseDir: entrypoint.workPackage.dir, showFileSizes: true)}',
);
final packageBytes =
await createTarGz(files, baseDir: entrypoint.workPackage.dir).toBytes();
log.message(
'\nTotal compressed archive size: ${_readableFileSize(packageBytes.length)}.\n',
);
final validationResult =
skipValidation ? null : await _validate(packageBytes, files, host);
if (dryRun) {
log.message('The server may enforce additional checks.');
}
return _Publication(
packageBytes: packageBytes,
warningCount: validationResult?.warningsCount ?? 0,
hintCount: validationResult?.hintsCount ?? 0,
pubspec: package.pubspec,
);
}
Future<_Publication> _publicationFromArchive(String archive) async {
final Uint8List packageBytes;
try {
log.message('Publishing from archive: $_fromArchive');
packageBytes = readBinaryFile(archive);
} on FileSystemException catch (e) {
dataError(
'Failed reading archive file: $e)',
);
}
final Pubspec pubspec;
try {
pubspec = Pubspec.parse(
utf8.decode(
await extractFileFromTarGz(
Stream.fromIterable([packageBytes]),
'pubspec.yaml',
),
),
cache.sources,
containingDescription: RootDescription(p.dirname(archive)),
);
} on FormatException catch (e) {
dataError('Failed to read pubspec.yaml from archive: ${e.message}');
}
if (!dryRun && _toArchive == null && pubspec.isPrivate) {
dataError('A private package cannot be published.\n'
'You can enable this by changing the "publish_to" field in your '
'pubspec.');
}
final host = computeHost(pubspec);
log.message('Publishing ${pubspec.name} ${pubspec.version} to $host.');
return _Publication(
packageBytes: packageBytes,
warningCount: 0,
hintCount: 0,
pubspec: pubspec,
);
}
/// Validates the package.
///
/// Throws if there are errors and the upload should not
/// proceed.
///
/// Returns a summary of warnings and hints if there are any, otherwise `null`.
Future<({int warningsCount, int hintsCount})> _validate(
Uint8List packageBytes,
List<String> files,
Uri host,
) async {
final hints = <String>[];
final warnings = <String>[];
final errors = <String>[];
await log.spinner(
'Validating package',
() async => await Validator.runAll(
entrypoint,
packageBytes.length,
host,
files,
hints: hints,
warnings: warnings,
errors: errors,
),
);
if (errors.isNotEmpty) {
dataError('Sorry, your package is missing '
"${(errors.length > 1) ? 'some requirements' : 'a requirement'} "
"and can't be published yet.\nFor more information, see: "
'https://dart.dev/tools/pub/cmd/pub-lish.\n');
}
return (warningsCount: warnings.length, hintsCount: hints.length);
}
/// Asks the user for confirmation of uploading [package].
///
/// Skips asking if [force].
/// Throws if user didn't confirm.
Future<void> _confirmUpload(_Publication package, Uri host) async {
if (force) return;
log.message('\nPublishing is forever; packages cannot be unpublished.'
'\nPolicy details are available at https://pub.dev/policy\n');
var message =
'Do you want to publish ${package.pubspec.name} ${package.pubspec.version} to $host';
if (package.hintCount != 0 || package.warningCount != 0) {
message = '${package.warningsCountMessage}. $message';
}
if (!await confirm('\n$message')) {
dataError('Package upload canceled.');
}
}
@override
Future runProtected() async {
await _validateArgs();
final publication = await (_fromArchive == null
? _publicationFromEntrypoint()
: _publicationFromArchive(_fromArchive));
if (dryRun) {
log.warning(publication.warningsCountMessage);
if (publication.warningCount != 0) {
overrideExitCode(DATA);
}
return;
}
if (_toArchive == null) {
final host = computeHost(publication.pubspec);
await _confirmUpload(publication, host);
await _publish(publication.packageBytes, host);
} else {
if (dryRun) {
log.message('Would have written to $_toArchive.');
} else {
_writeUploadToArchive(publication, _toArchive);
}
}
}
void _writeUploadToArchive(_Publication publication, String archive) {
try {
writeBinaryFile(archive, publication.packageBytes);
} on FileSystemException catch (e) {
dataError('Failed writing archive: $e');
}
log.message('Wrote package archive at $_toArchive');
}
/// Returns the value associated with [key] in [map]. Throws a user-friendly
/// error if [map] doesn't contain [key].
dynamic _expectField(Map map, String key, http.Response response) {
if (map.containsKey(key)) return map[key];
invalidServerResponse(response);
}
}
String _readableFileSize(int size) {
if (size >= 1 << 30) {
return '${size ~/ (1 << 30)} GB';
} else if (size >= 1 << 20) {
return '${size ~/ (1 << 20)} MB';
} else if (size >= 1 << 10) {
return '${size ~/ (1 << 10)} KB';
} else {
return '<1 KB';
}
}
class _Publication {
Uint8List packageBytes;
int warningCount;
int hintCount;
Pubspec pubspec;
String get warningsCountMessage {
final hintText =
hintCount == 0 ? '' : ' and $hintCount ${pluralize('hint', hintCount)}';
return '\nPackage has $warningCount '
'${pluralize('warning', warningCount)}$hintText.';
}
_Publication({
required this.packageBytes,
required this.warningCount,
required this.hintCount,
required this.pubspec,
});
}