blob: a3105150dd24b9c470e4aecbfe4e39d6fd78f59a [file] [log] [blame]
// Copyright (c) 2020, 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:io';
import 'package:args/args.dart';
import 'package:collection/collection.dart';
import 'package:path/path.dart' as p;
import 'package:pub_semver/pub_semver.dart';
import 'package:yaml/yaml.dart';
import 'package:yaml_edit/yaml_edit.dart';
import '../command.dart';
import '../command_runner.dart';
import '../exceptions.dart';
import '../git.dart';
import '../io.dart';
import '../log.dart' as log;
import '../package_name.dart';
import '../pubspec.dart';
import '../pubspec_utils.dart';
import '../sdk.dart';
import '../solver.dart';
import '../source/git.dart';
import '../source/hosted.dart';
import '../source/path.dart';
import '../source/root.dart';
import '../utils.dart';
/// Handles the `add` pub command. Adds a dependency to `pubspec.yaml` and gets
/// the package. The user may pass in a git constraint, host url, or path as
/// requirements. If no such options are passed in, this command will do a
/// resolution to find the latest version of the package that is compatible with
/// the other dependencies in `pubspec.yaml`, and then enter that as the lower
/// bound in a ^x.y.z constraint.
///
/// The descriptor used to be given with args like --path, --sdk,
/// --git-<option>.
///
/// We still support these arguments, but now the documented way to give the
/// descriptor is to give a yaml-descriptor as in pubspec.yaml.
class AddCommand extends PubCommand {
@override
String get name => 'add';
@override
String get description => '''
Add dependencies to `pubspec.yaml`.
Invoking `dart pub add foo bar` will add `foo` and `bar` to `pubspec.yaml`
with a default constraint derived from latest compatible version.
Add to dev_dependencies by prefixing with "dev:".
Make dependency overrides by prefixing with "override:".
Add packages with specific constraints or other sources by giving a descriptor
after a colon.
For example:
* Add a hosted dependency at newest compatible stable version:
`$topLevelProgram pub add foo`
* Add a hosted dev dependency at newest compatible stable version:
`$topLevelProgram pub add dev:foo`
* Add a hosted dependency with the given constraint
`$topLevelProgram pub add foo:^1.2.3`
* Add multiple dependencies:
`$topLevelProgram pub add foo dev:bar`
* Add a path dependency:
`$topLevelProgram pub add 'foo:{"path":"../foo"}'`
* Add a hosted dependency:
`$topLevelProgram pub add 'foo:{"hosted":"my-pub.dev"}'`
* Add an sdk dependency:
`$topLevelProgram pub add 'foo:{"sdk":"flutter"}'`
* Add a git dependency:
`$topLevelProgram pub add 'foo:{"git":"https://github.com/foo/foo"}'`
* Add a dependency override:
`$topLevelProgram pub add 'override:foo:1.0.0'`
* Add a git dependency with a path and ref specified:
`$topLevelProgram pub add \\
'foo:{"git":{"url":"../foo.git","ref":"<branch>","path":"<subdir>"}}'`''';
@override
String get argumentsDescription =>
'[options] [<section>:]<package>[:descriptor] '
'[<section>:]<package2>[:descriptor] ...]';
@override
String get docUrl => 'https://dart.dev/tools/pub/cmd/pub-add';
AddCommand() {
argParser.addFlag(
'dev',
abbr: 'd',
negatable: false,
help: 'Adds to the development dependencies instead.',
hide: true,
);
// Following options are hidden/deprecated in favor of the new syntax: [dev:]<package>[:descriptor] ...
// To avoid breaking changes we keep supporting them, but hide them from --help to discourage
// further use. Combining these with new syntax will fail.
argParser.addOption(
'git-url',
help: 'Git URL of the package',
hide: true,
);
argParser.addOption(
'git-ref',
help: 'Git branch or commit to be retrieved',
hide: true,
);
argParser.addOption(
'git-path',
help: 'Path of git package in repository',
hide: true,
);
argParser.addOption(
'hosted-url',
help: 'URL of package host server',
hide: true,
);
argParser.addOption(
'path',
help: 'Add package from local path',
hide: true,
);
argParser.addOption(
'sdk',
help: 'add package from SDK source',
allowed: ['flutter'],
valueHelp: '[flutter]',
hide: true,
);
argParser.addFlag(
'offline',
help: 'Use cached packages instead of accessing the network.',
);
argParser.addFlag(
'dry-run',
abbr: 'n',
negatable: false,
help: "Report what dependencies would change but don't change any.",
);
argParser.addFlag(
'precompile',
help: 'Build executables in immediate dependencies.',
);
argParser.addOption(
'directory',
abbr: 'C',
help: 'Run this in the directory <dir>.',
valueHelp: 'dir',
);
argParser.addFlag(
'example',
defaultsTo: true,
help:
'Also update dependencies in `example/` after modifying pubspec.yaml in the root package (if it exists).',
hide: true,
);
}
@override
Future<void> runProtected() async {
if (argResults.rest.length > 1) {
if (argResults.gitUrl != null) {
usageException('''
--git-url cannot be used with multiple packages.
Specify multiple git packages with descriptors.''');
} else if (argResults.path != null) {
usageException('''
--path cannot be used with multiple packages.
Specify multiple path packages with descriptors.''');
} else if (argResults.sdk != null) {
usageException('''
--sdk cannot be used with multiple packages.
Specify multiple sdk packages with descriptors.''');
}
}
if (argResults.rest.isEmpty) {
usageException('Must specify at least one package to be added.');
}
final updates =
argResults.rest.map((p) => _parsePackage(p, argResults)).toList();
/// Compute a pubspec that will depend on all the given packages, but the
/// actual constraint will only be determined after a resolution decides the
/// best version.
var resolutionPubspec = entrypoint.workPackage.pubspec;
for (final update in updates) {
/// Perform version resolution in-memory.
resolutionPubspec = await _addPackageToPubspec(resolutionPubspec, update);
}
late SolveResult solveResult;
try {
/// Use [SolveType.UPGRADE] to solve for the highest version of [package]
/// in case [package] was already a transitive dependency. In the case
/// where the user specifies a version constraint, this serves to ensure
/// that a resolution exists before we update pubspec.yaml.
// TODO(sigurdm): We should really use a spinner here.
solveResult = await resolveVersions(
SolveType.upgrade,
cache,
entrypoint.withWorkPubspec(resolutionPubspec).workspaceRoot,
);
} on GitException {
final name = updates.first.ref.name;
dataError('Unable to resolve package "$name" with the given '
'git parameters.');
} on SolveFailure catch (e) {
dataError(e.message);
} on WrappedException catch (e) {
/// [WrappedException]s may appear if an invalid [hostUrl] is passed in.
dataError(e.message);
}
/// Verify the results for each package.
for (final update in updates) {
final ref = update.ref;
final name = ref.name;
final resultPackage = solveResult.packages
.firstWhere((packageId) => packageId.name == name);
/// Assert that [resultPackage] is within the original user's expectations.
final constraint = update.constraint;
if (constraint != null && !constraint.allows(resultPackage.version)) {
final dependencyOverrides = resolutionPubspec.dependencyOverrides;
if (dependencyOverrides.isNotEmpty) {
dataError('"$name" resolved to "${resultPackage.version}" which '
'does not satisfy constraint "$constraint". This could be '
'caused by "dependency_overrides".');
}
}
}
final newPubspecText = _updatePubspec(solveResult.packages, updates);
if (!argResults.isDryRun) {
/// Update the `pubspec.yaml` before calling [acquireDependencies] to
/// ensure that the modification timestamp on `pubspec.lock` and
/// `.dart_tool/package_config.json` is newer than `pubspec.yaml`,
/// ensuring that [entrypoint.assertUptoDate] will pass.
writeTextFile(entrypoint.workPackage.pubspecPath, newPubspecText);
}
String? overridesFileContents;
final overridesPath = entrypoint.workPackage.pubspecOverridesPath;
try {
overridesFileContents = readTextFile(overridesPath);
} on IOException {
overridesFileContents = null;
}
/// Even if it is a dry run, run `acquireDependencies` so that the user
/// gets a report on the other packages that might change version due
/// to this new dependency.
await entrypoint
.withWorkPubspec(
Pubspec.parse(
newPubspecText,
cache.sources,
location: Uri.parse(entrypoint.workPackage.pubspecPath),
overridesFileContents: overridesFileContents,
overridesLocation: Uri.file(overridesPath),
containingDescription: RootDescription(entrypoint.workPackage.dir),
),
)
.acquireDependencies(
SolveType.get,
dryRun: argResults.isDryRun,
precompile: !argResults.isDryRun && argResults.shouldPrecompile,
);
if (!argResults.isDryRun &&
argResults.example &&
entrypoint.example != null) {
await entrypoint.example!.acquireDependencies(
SolveType.get,
precompile: argResults.shouldPrecompile,
summaryOnly: true,
);
}
if (isOffline) {
log.warning('Warning: Packages added when offline may not resolve to '
'the latest compatible version available.');
}
}
/// Creates a new in-memory [Pubspec] by adding [package] to the
/// dependencies of [original].
Future<Pubspec> _addPackageToPubspec(
Pubspec original,
_ParseResult package,
) async {
final name = package.ref.name;
final dependencies = [...original.dependencies.values];
var devDependencies = [...original.devDependencies.values];
var dependencyOverrides = [...original.dependencyOverrides.values];
final dependencyNames = dependencies.map((dependency) => dependency.name);
final devDependencyNames =
devDependencies.map((devDependency) => devDependency.name);
final range =
package.ref.withConstraint(package.constraint ?? VersionConstraint.any);
if (package.isOverride) {
dependencyOverrides.add(range);
} else if (package.isDev) {
if (devDependencyNames.contains(name)) {
log.message('"$name" is already in "dev_dependencies". '
'Will try to update the constraint.');
devDependencies.removeWhere((element) => element.name == name);
}
/// If package is originally in dependencies and we wish to add it to
/// dev_dependencies, this is a redundant change, and we should not
/// remove the package from dependencies, since it might cause the user's
/// code to break.
if (dependencyNames.contains(name)) {
dataError('"$name" is already in "dependencies". '
'Use "pub remove $name" to remove it before adding it '
'to "dev_dependencies"');
}
devDependencies.add(range);
} else {
if (dependencyNames.contains(name)) {
log.message(
'"$name" is already in "dependencies". Will try to update the constraint.',
);
dependencies.removeWhere((element) => element.name == name);
}
/// If package is originally in dev_dependencies and we wish to add it to
/// dependencies, we remove the package from dev_dependencies, since it is
/// now redundant.
if (devDependencyNames.contains(name)) {
log.message('"$name" was found in dev_dependencies. '
'Removing "$name" and adding it to dependencies instead.');
devDependencies.removeWhere((element) => element.name == name);
}
dependencies.add(range);
}
return original.copyWith(
dependencies: dependencies,
devDependencies: devDependencies,
dependencyOverrides: dependencyOverrides,
);
}
static final _argRegExp = RegExp(
r'^(?:(?<prefix>dev|override):)?'
r'(?<name>[a-zA-Z0-9_.]+)'
r'(?::(?<descriptor>.*))?$',
);
static final _lenientArgRegExp = RegExp(
r'^(?:(?<prefix>[^:]*):)?'
r'(?<name>[^:]*)'
r'(?::(?<descriptor>.*))?$',
);
/// Split [arg] on ':' and interpret it with the flags in [argResult] either as
/// an old-style or a new-style descriptor to produce a PackageRef].
_ParseResult _parsePackage(String arg, ArgResults argResults) {
var isDev = argResults.flag('dev');
var isOverride = false;
final match = _argRegExp.firstMatch(arg);
if (match == null) {
final match2 = _lenientArgRegExp.firstMatch(arg);
if (match2 == null) {
usageException('Could not parse $arg');
} else {
if (match2.namedGroup('prefix') != null &&
match2.namedGroup('descriptor') != null) {
usageException(
'The only allowed prefixes are "dev:" and "override:"',
);
} else {
final packageName = match2.namedGroup('descriptor') == null
? match2.namedGroup('prefix')
: match2.namedGroup('name');
usageException('Not a valid package name: "$packageName"');
}
}
} else if (match.namedGroup('prefix') == 'dev') {
if (argResults.isDev) {
usageException("Cannot combine 'dev:' with --dev");
}
isDev = true;
} else if (match.namedGroup('prefix') == 'override') {
if (argResults.isDev) {
usageException("Cannot combine 'override:' with --dev");
}
isOverride = true;
}
final packageName = match.namedGroup('name')!;
if (!packageNameRegExp.hasMatch(packageName)) {
usageException('Not a valid package name: "$packageName"');
}
final descriptor = match.namedGroup('descriptor');
if (isOverride && descriptor == null) {
usageException('A dependency override needs an explicit descriptor.');
}
final _PartialParseResult partial;
if (argResults.hasOldStyleOptions) {
partial = _parseDescriptorOldStyleArgs(
packageName,
descriptor,
argResults,
);
} else {
partial = _parseDescriptorNewStyle(packageName, descriptor);
}
return _ParseResult(
partial.ref,
partial.constraint,
isDev: isDev,
isOverride: isOverride,
);
}
/// Parse [descriptor] to return the corresponding [_ParseResult] using the
/// arguments given in [argResults] to configure the description.
///
/// [descriptor] should be a constraint as parsed by
/// [VersionConstraint.parse]. If it fails to parse as a version constraint
/// but could parse with [_parseDescriptorNewStyle()] a specific usage
/// description is issued.
///
/// Examples:
/// ```
/// retry
/// retry:2.0.0
/// retry:^2.0.0
/// retry:'>=2.0.0'
/// retry:'>2.0.0 <3.0.1'
/// 'retry:>2.0.0 <3.0.1'
/// retry:any
/// ```
///
/// If a version constraint is provided when the `--path` or any of the
/// `--git-<option>` options are used, a [UsageException] will be thrown.
///
/// Packages must either be a git, hosted, sdk, or path package. Mixing of
/// options is not allowed and will cause a [UsageException] to be thrown.
///
/// If any of the other git options are defined when `--git-url` is not
/// defined, an error will be thrown.
///
/// The returned [_PartialParseResult] will always have `ref!=null`.
_PartialParseResult _parseDescriptorOldStyleArgs(
String packageName,
String? descriptor,
ArgResults argResults,
) {
final conflictingFlagSets = [
['git-url', 'git-ref', 'git-path'],
['hosted-url'],
['path'],
['sdk'],
];
for (final flag
in conflictingFlagSets.expand((s) => s).where(argResults.wasParsed)) {
final conflictingFlag = conflictingFlagSets
.where((s) => !s.contains(flag))
.expand((s) => s)
.firstWhereOrNull(argResults.wasParsed);
if (conflictingFlag != null) {
usageException(
'Packages can only have one source, "pub add" flags "--$flag" and '
'"--$conflictingFlag" are conflicting.');
}
}
/// We want to allow for [constraint] to take on a `null` value here to
/// preserve the fact that the user did not specify a constraint.
VersionConstraint? constraint;
try {
constraint =
descriptor == null ? null : VersionConstraint.parse(descriptor);
} on FormatException catch (e) {
var couldParseAsNewStyle = true;
try {
_parseDescriptorNewStyle(packageName, descriptor);
// If parsing the descriptor as a new-style descriptor succeeds we
// can give this more specific error message.
} catch (_) {
couldParseAsNewStyle = false;
}
if (couldParseAsNewStyle) {
usageException(
'--dev, --path, --sdk, --git-url, --git-path and --git-ref cannot be combined with a descriptor.',
);
} else {
usageException('Invalid version constraint: ${e.message}');
}
}
/// The package to be added.
late final PackageRef ref;
final path = argResults.path;
if (argResults.hasGitOptions) {
final gitUrl = argResults.gitUrl;
if (gitUrl == null) {
usageException('The `--git-url` is required for git dependencies.');
}
/// Process the git options to return the simplest representation to be
/// added to the pubspec.
try {
ref = PackageRef(
packageName,
GitDescription(
url: gitUrl.toString(),
containingDir: p.current,
ref: argResults.gitRef,
path: argResults.gitPath,
),
);
} on FormatException catch (e) {
usageException('The --git-url must be a valid url: ${e.message}.');
}
} else if (path != null) {
ref = PackageRef(
packageName,
PathDescription(p.absolute(path), p.isRelative(path)),
);
} else if (argResults.sdk != null) {
ref = cache.sdk.parseRef(
packageName,
argResults.sdk,
containingDescription: RootDescription(p.current),
);
} else {
ref = PackageRef(
packageName,
HostedDescription(
packageName,
argResults.hostedUrl ?? cache.hosted.defaultUrl,
),
);
}
return _PartialParseResult(ref, constraint);
}
/// Parse [package] to return the corresponding [_ParseResult].
///
/// [package] must be written in the format
/// `<package-name>[:descriptor>]`, where quotations should be used if
/// necessary.
///
/// `descriptor` is what you would put in a pubspec.yaml in the dependencies
/// section.
///
/// Assumes that none of '--git-url', '--git-ref', '--git-path', '--path' and
/// '--sdk' are present in [argResults].
///
///
/// Examples:
/// ```
/// retry
/// retry:2.0.0
/// dev:retry:^2.0.0
/// retry:'>=2.0.0'
/// retry:'>2.0.0 <3.0.1'
/// 'retry:>2.0.0 <3.0.1'
/// retry:any
/// 'retry:{"path":"../foo"}'
/// 'retry:{"git":{"url":"../foo","ref":"branchname"},"version":"^1.2.3"}'
/// 'retry:{"sdk":"flutter"}'
/// 'retry:{"hosted":"mypub.dev"}'
/// ```
///
/// The --path --sdk and --git-<option> arguments cannot be combined with a
/// non-string descriptor.
///
/// If a version constraint is provided when the `--path` or any of the
/// `--git-<option>` options are used, a [PackageParseError] will be thrown.
///
/// Packages must either be a git, hosted, sdk, or path package. Mixing of
/// options is not allowed and will cause a [PackageParseError] to be thrown.
///
/// If any of the other git options are defined when `--git-url` is not
/// defined, an error will be thrown.
///
/// Returns a `ref` of `null` if the descriptor did not specify a source.
/// Then the source will be determined by the old-style arguments.
_PartialParseResult _parseDescriptorNewStyle(
String packageName,
String? descriptor,
) {
/// We want to allow for [constraint] to take on a `null` value here to
/// preserve the fact that the user did not specify a constraint.
VersionConstraint? constraint;
/// The package to be added.
PackageRef? ref;
if (descriptor != null) {
try {
// An unquoted version constraint is not always valid yaml.
// But we want to allow it here anyways.
constraint = VersionConstraint.parse(descriptor);
} on FormatException {
final parsedDescriptor = loadYaml(descriptor);
// Use the pubspec parsing mechanism for parsing the descriptor.
final Pubspec dummyPubspec;
try {
dummyPubspec = Pubspec.fromMap(
{
'dependencies': {
packageName: parsedDescriptor,
},
'environment': {
'sdk': sdk.version.toString(),
},
},
cache.sources,
// Resolve relative paths relative to current, not where the pubspec.yaml is.
containingDescription: RootDescription(p.current),
);
} on FormatException catch (e) {
usageException('Failed parsing package specification: ${e.message}');
}
final range = dummyPubspec.dependencies[packageName]!;
if (parsedDescriptor is String) {
// Ref will be constructed by the default behavior below.
ref = null;
} else {
ref = range.toRef();
}
final hasExplicitConstraint = parsedDescriptor is String ||
(parsedDescriptor is Map &&
parsedDescriptor.containsKey('version'));
// If the descriptor has an explicit constraint, use that. Otherwise we
// infer it.
if (hasExplicitConstraint) {
constraint = range.constraint;
}
}
}
return _PartialParseResult(
ref ??
PackageRef(
packageName,
HostedDescription(
packageName,
argResults.hostedUrl ?? cache.hosted.defaultUrl,
),
),
constraint,
);
}
/// Calculates the updates to the pubspec file.
String _updatePubspec(
List<PackageId> resultPackages,
List<_ParseResult> updates,
) {
final yamlEditor =
YamlEditor(readTextFile(entrypoint.workPackage.pubspecPath));
log.io('Reading ${entrypoint.workPackage.pubspecPath}.');
log.fine('Contents:\n$yamlEditor');
for (final update in updates) {
final dependencyKey = update.isDev
? 'dev_dependencies'
: (update.isOverride ? 'dependency_overrides' : 'dependencies');
final constraint = update.constraint;
final ref = update.ref;
final name = ref.name;
final resultId = resultPackages.firstWhere((id) => id.name == name);
final description = pubspecDescription(
ref.withConstraint(
constraint ??
(ref.source is HostedSource
? VersionConstraint.compatibleWith(resultId.version)
: VersionConstraint.any),
),
cache,
entrypoint.workPackage,
);
if (yamlEditor.parseAt(
[dependencyKey],
orElse: () => YamlScalar.wrap(null),
).value ==
null) {
// Handle the case where [dependencyKey] does not already exist.
// We ensure it is in Block-style by default.
yamlEditor.update(
[dependencyKey],
wrapAsYamlNode(
{name: description},
collectionStyle: CollectionStyle.BLOCK,
),
);
} else {
final packagePath = [dependencyKey, name];
yamlEditor.update(packagePath, description);
}
/// Remove the package from dev_dependencies if we are adding it to
/// dependencies. Refer to [_addPackageToPubspec] for additional discussion.
if (!update.isDev && !update.isOverride) {
final devDependenciesNode = yamlEditor
.parseAt(['dev_dependencies'], orElse: () => YamlScalar.wrap(null));
if (devDependenciesNode is YamlMap &&
devDependenciesNode.containsKey(name)) {
if (devDependenciesNode.length == 1) {
yamlEditor.remove(['dev_dependencies']);
} else {
yamlEditor.remove(['dev_dependencies', name]);
}
log.fine('Removed $name from "dev_dependencies".');
}
}
}
return yamlEditor.toString();
}
}
class _PartialParseResult {
final PackageRef ref;
final VersionConstraint? constraint;
_PartialParseResult(this.ref, this.constraint);
}
class _ParseResult {
final PackageRef ref;
final VersionConstraint? constraint;
final bool isDev;
final bool isOverride;
_ParseResult(
this.ref,
this.constraint, {
required this.isDev,
required this.isOverride,
});
}
extension on ArgResults {
bool get isDev => flag('dev');
bool get isDryRun => flag('dry-run');
String? get gitUrl => this['git-url'] as String?;
String? get gitPath => this['git-path'] as String?;
String? get gitRef => this['git-ref'] as String?;
String? get hostedUrl => this['hosted-url'] as String?;
String? get path => this['path'] as String?;
String? get sdk => this['sdk'] as String?;
bool get hasOldStyleOptions =>
hasGitOptions ||
path != null ||
sdk != null ||
hostedUrl != null ||
isDev;
bool get shouldPrecompile => flag('precompile');
bool get example => flag('example');
bool get hasGitOptions => gitUrl != null || gitRef != null || gitPath != null;
}