blob: c48dedcd795732fc1bdcce530652cf48cb76630d [file] [log] [blame]
// Copyright 2013 The Flutter Authors. 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' as io;
import 'package:file/file.dart';
import 'package:http/http.dart' as http;
import 'package:pub_semver/pub_semver.dart';
import 'package:pubspec_parse/pubspec_parse.dart';
import 'package:yaml_edit/yaml_edit.dart';
import 'common/core.dart';
import 'common/output_utils.dart';
import 'common/package_looping_command.dart';
import 'common/pub_utils.dart';
import 'common/pub_version_finder.dart';
import 'common/repository_package.dart';
const int _exitIncorrectTargetDependency = 3;
const int _exitNoTargetVersion = 4;
const int _exitInvalidTargetVersion = 5;
/// A command to update a dependency in packages.
///
/// This is intended to expand over time to support any sort of dependency that
/// packages use, including pub packages and native dependencies, and should
/// include any tasks related to the dependency (e.g., regenerating files when
/// updating a dependency that is responsible for code generation).
class UpdateDependencyCommand extends PackageLoopingCommand {
/// Creates an instance of the version check command.
UpdateDependencyCommand(
super.packagesDir, {
super.processRunner,
http.Client? httpClient,
}) : _pubVersionFinder =
PubVersionFinder(httpClient: httpClient ?? http.Client()) {
argParser.addOption(
_pubPackageFlag,
help: 'A pub package to update.',
);
argParser.addOption(_androidDependency,
help: 'An Android dependency to update.',
allowed: <String>[
_AndroidDepdencyType.gradle,
_AndroidDepdencyType.compileSdk,
_AndroidDepdencyType.compileSdkForExamples,
],
allowedHelp: <String, String>{
_AndroidDepdencyType.gradle:
'Updates Gradle version used in plugin example apps.',
_AndroidDepdencyType.compileSdk:
'Updates compileSdk version used to compile plugins.',
_AndroidDepdencyType.compileSdkForExamples:
'Updates compileSdk version used to compile plugin examples.',
});
argParser.addOption(
_versionFlag,
help: 'The version to update to.\n\n'
'- For pub, defaults to the latest published version if not '
'provided. This can be any constraint that pubspec.yaml allows; a '
'specific version will be treated as the exact version for '
'dependencies that are alread pinned, or a ^ range for those that '
'are unpinned.\n'
'- For Android dependencies, a version must be provided.',
);
}
static const String _pubPackageFlag = 'pub-package';
static const String _androidDependency = 'android-dependency';
static const String _versionFlag = 'version';
final PubVersionFinder _pubVersionFinder;
late final String? _targetPubPackage;
late final String? _targetAndroidDependency;
late final String _targetVersion;
@override
final String name = 'update-dependency';
@override
final String description = 'Updates a dependency in a package.';
@override
bool get hasLongOutput => false;
@override
PackageLoopingType get packageLoopingType =>
PackageLoopingType.includeAllSubpackages;
@override
Future<void> initializeRun() async {
const Set<String> targetFlags = <String>{
_pubPackageFlag,
_androidDependency
};
final Set<String> passedTargetFlags =
targetFlags.where((String flag) => argResults![flag] != null).toSet();
if (passedTargetFlags.length != 1) {
printError(
'Exactly one of the target flags must be provided: (${targetFlags.join(', ')})');
throw ToolExit(_exitIncorrectTargetDependency);
}
// Setup for updating pub dependency.
_targetPubPackage = getNullableStringArg(_pubPackageFlag);
if (_targetPubPackage != null) {
final String? version = getNullableStringArg(_versionFlag);
if (version == null) {
final PubVersionFinderResponse response = await _pubVersionFinder
.getPackageVersion(packageName: _targetPubPackage!);
switch (response.result) {
case PubVersionFinderResult.success:
_targetVersion = response.versions.first.toString();
case PubVersionFinderResult.fail:
printError('''
Error fetching $_targetPubPackage version from pub: ${response.httpResponse.statusCode}:
${response.httpResponse.body}
''');
throw ToolExit(_exitNoTargetVersion);
case PubVersionFinderResult.noPackageFound:
printError('$_targetPubPackage does not exist on pub');
throw ToolExit(_exitNoTargetVersion);
}
} else {
_targetVersion = version;
return;
}
}
// Setup for updating Android dependency.
_targetAndroidDependency = getNullableStringArg(_androidDependency);
if (_targetAndroidDependency != null) {
final String? version = getNullableStringArg(_versionFlag);
if (version == null) {
printError('A version must be provided to update this dependency.');
throw ToolExit(_exitNoTargetVersion);
} else if (_targetAndroidDependency == _AndroidDepdencyType.gradle) {
final RegExp validGradleVersionPattern = RegExp(r'^\d+(?:\.\d+){1,2}$');
final bool isValidGradleVersion =
validGradleVersionPattern.stringMatch(version) == version;
if (!isValidGradleVersion) {
printError(
'A version with a valid format (maximum 2-3 numbers separated by period) must be provided.');
throw ToolExit(_exitInvalidTargetVersion);
}
} else if (_targetAndroidDependency == _AndroidDepdencyType.compileSdk ||
_targetAndroidDependency ==
_AndroidDepdencyType.compileSdkForExamples) {
final RegExp validSdkVersion = RegExp(r'^\d{1,2}$');
final bool isValidSdkVersion =
validSdkVersion.stringMatch(version) == version;
if (!isValidSdkVersion) {
printError(
'A valid Android SDK version number (1-2 digit numbers) must be provided.');
throw ToolExit(_exitInvalidTargetVersion);
}
} else {
// TODO(camsim99): Add other supported Android dependencies like the min/target Android SDK and AGP.
printError(
'Target Android dependency $_targetAndroidDependency is unrecognized.');
throw ToolExit(_exitIncorrectTargetDependency);
}
_targetVersion = version;
}
}
@override
Future<void> completeRun() async {
_pubVersionFinder.httpClient.close();
}
@override
Future<PackageResult> runForPackage(RepositoryPackage package) async {
if (_targetPubPackage != null) {
return _runForPubDependency(package, _targetPubPackage!);
}
if (_targetAndroidDependency != null) {
return _runForAndroidDependency(package);
}
// TODO(stuartmorgan): Add other dependency types here (e.g., maven).
return PackageResult.fail();
}
/// Handles all of the updates for [package] when the target dependency is
/// a pub dependency.
Future<PackageResult> _runForPubDependency(
RepositoryPackage package, String dependency) async {
final _PubDependencyInfo? dependencyInfo =
_getPubDependencyInfo(package, dependency);
if (dependencyInfo == null) {
return PackageResult.skip('Does not depend on $dependency');
} else if (!dependencyInfo.hosted) {
return PackageResult.skip('$dependency in not a hosted dependency');
}
// Determine the target version constraint.
final String sectionKey = dependencyInfo.type == _PubDependencyType.dev
? 'dev_dependencies'
: 'dependencies';
final String versionString;
final VersionConstraint parsedConstraint =
VersionConstraint.parse(_targetVersion);
// If the provided string was a constraint, or if it's a specific
// version but the package has a pinned dependency, use it as-is.
if (dependencyInfo.pinned ||
parsedConstraint is! VersionRange ||
parsedConstraint.min != parsedConstraint.max) {
versionString = _targetVersion;
} else {
// Otherwise, it's a specific version; treat it as '^version'.
final Version minVersion = parsedConstraint.min!;
versionString = '^$minVersion';
}
// Update pubspec.yaml with the new version.
print('${indentation}Updating to "$versionString"');
if (versionString == dependencyInfo.constraintString) {
return PackageResult.skip('Already depends on $versionString');
}
final YamlEditor editablePubspec =
YamlEditor(package.pubspecFile.readAsStringSync());
editablePubspec.update(
<String>[sectionKey, dependency],
versionString,
);
package.pubspecFile.writeAsStringSync(editablePubspec.toString());
// Do any dependency-specific extra processing.
if (dependency == 'pigeon') {
if (!await _regeneratePigeonFiles(package)) {
return PackageResult.fail(<String>['Failed to update pigeon files']);
}
} else if (dependency == 'mockito') {
if (!await _regenerateMocks(package)) {
return PackageResult.fail(<String>['Failed to update mocks']);
}
}
// TODO(stuartmorgan): Add additional handling of known packages that
// do file generation.
return PackageResult.success();
}
/// Handles all of the updates for [package] when the target dependency is
/// an Android dependency.
Future<PackageResult> _runForAndroidDependency(
RepositoryPackage package) async {
if (_targetAndroidDependency == _AndroidDepdencyType.compileSdk) {
return _runForCompileSdkVersion(package);
} else if (_targetAndroidDependency == _AndroidDepdencyType.gradle ||
_targetAndroidDependency ==
_AndroidDepdencyType.compileSdkForExamples) {
return _runForAndroidDependencyOnExamples(package);
}
return PackageResult.fail(<String>[
'Target Android dependency $_androidDependency is unrecognized.'
]);
}
Future<PackageResult> _runForAndroidDependencyOnExamples(
RepositoryPackage package) async {
final Iterable<RepositoryPackage> packageExamples = package.getExamples();
bool updateRanForExamples = false;
for (final RepositoryPackage example in packageExamples) {
if (!example.platformDirectory(FlutterPlatform.android).existsSync()) {
continue;
}
updateRanForExamples = true;
final Directory androidDirectory =
example.platformDirectory(FlutterPlatform.android);
final List<File> filesToUpdate = <File>[];
final RegExp dependencyVersionPattern;
final String newDependencyVersionEntry;
if (_targetAndroidDependency == _AndroidDepdencyType.gradle) {
if (androidDirectory
.childDirectory('gradle')
.childDirectory('wrapper')
.existsSync()) {
filesToUpdate.add(androidDirectory
.childDirectory('gradle')
.childDirectory('wrapper')
.childFile('gradle-wrapper.properties'));
}
if (androidDirectory
.childDirectory('app')
.childDirectory('gradle')
.childDirectory('wrapper')
.existsSync()) {
filesToUpdate.add(androidDirectory
.childDirectory('app')
.childDirectory('gradle')
.childDirectory('wrapper')
.childFile('gradle-wrapper.properties'));
}
dependencyVersionPattern =
RegExp(r'^\s*distributionUrl\s*=\s*.*\.zip', multiLine: true);
// TODO(camsim99): Validate current AGP version against target Gradle
// version: https://github.com/flutter/flutter/issues/133887.
newDependencyVersionEntry =
'distributionUrl=https\\://services.gradle.org/distributions/gradle-$_targetVersion-all.zip';
} else if (_targetAndroidDependency ==
_AndroidDepdencyType.compileSdkForExamples) {
filesToUpdate.add(
androidDirectory.childDirectory('app').childFile('build.gradle'));
dependencyVersionPattern = RegExp(
r'(compileSdk|compileSdkVersion) (\d{1,2}|flutter.compileSdkVersion)');
newDependencyVersionEntry = 'compileSdk $_targetVersion';
} else {
printError(
'Target Android dependency $_targetAndroidDependency is unrecognized.');
throw ToolExit(_exitIncorrectTargetDependency);
}
for (final File fileToUpdate in filesToUpdate) {
final String oldFileToUpdateContents = fileToUpdate.readAsStringSync();
if (!dependencyVersionPattern.hasMatch(oldFileToUpdateContents)) {
return PackageResult.fail(<String>[
'Unable to find a $_targetAndroidDependency version entry to update for ${example.displayName}.'
]);
}
print(
'${indentation}Updating ${getRelativePosixPath(example.directory, from: package.directory)} to "$_targetVersion"');
final String newGradleWrapperPropertiesContents =
oldFileToUpdateContents.replaceFirst(
dependencyVersionPattern, newDependencyVersionEntry);
fileToUpdate.writeAsStringSync(newGradleWrapperPropertiesContents);
}
}
return updateRanForExamples
? PackageResult.success()
: PackageResult.skip('No example apps run on Android.');
}
Future<PackageResult> _runForCompileSdkVersion(
RepositoryPackage package) async {
if (!package.platformDirectory(FlutterPlatform.android).existsSync()) {
return PackageResult.skip(
'Package ${package.displayName} does not run on Android.');
} else if (package.isExample) {
// We skip examples for this command.
return PackageResult.skip(
'Package ${package.displayName} is not a top-level package; run with "compileSdkForExamples" to update.');
}
final File buildConfigurationFile = package
.platformDirectory(FlutterPlatform.android)
.childFile('build.gradle');
final String buildConfigurationContents =
buildConfigurationFile.readAsStringSync();
final RegExp validCompileSdkVersion =
RegExp(r'(compileSdk|compileSdkVersion) \d{1,2}');
if (!validCompileSdkVersion.hasMatch(buildConfigurationContents)) {
return PackageResult.fail(<String>[
'Unable to find a compileSdk version entry to update for ${package.displayName}.'
]);
}
print('${indentation}Updating ${package.directory} to "$_targetVersion"');
final String newBuildConfigurationContents = buildConfigurationContents
.replaceFirst(validCompileSdkVersion, 'compileSdk $_targetVersion');
buildConfigurationFile.writeAsStringSync(newBuildConfigurationContents);
return PackageResult.success();
}
/// Returns information about the current dependency of [package] on
/// the package named [dependencyName], or null if there is no dependency.
_PubDependencyInfo? _getPubDependencyInfo(
RepositoryPackage package, String dependencyName) {
final Pubspec pubspec = package.parsePubspec();
Dependency? dependency;
final _PubDependencyType type;
if (pubspec.dependencies.containsKey(dependencyName)) {
dependency = pubspec.dependencies[dependencyName];
type = _PubDependencyType.normal;
} else if (pubspec.devDependencies.containsKey(dependencyName)) {
dependency = pubspec.devDependencies[dependencyName];
type = _PubDependencyType.dev;
} else {
return null;
}
if (dependency != null && dependency is HostedDependency) {
final VersionConstraint version = dependency.version;
return _PubDependencyInfo(
type,
pinned: version is VersionRange && version.min == version.max,
hosted: true,
constraintString: version.toString(),
);
}
return _PubDependencyInfo(type, pinned: false, hosted: false);
}
/// Returns all of the files in [package] that are, according to repository
/// convention, Pigeon input files.
Iterable<File> _getPigeonInputFiles(RepositoryPackage package) {
// Repo convention is that the Pigeon input files are the Dart files in a
// top-level "pigeons" directory.
final Directory pigeonsDir = package.directory.childDirectory('pigeons');
if (!pigeonsDir.existsSync()) {
return <File>[];
}
return pigeonsDir
.listSync()
.whereType<File>()
.where((File file) => file.basename.endsWith('.dart'));
}
/// Re-runs Pigeon generation for [package].
///
/// This assumes that all output configuration is set in the input files, so
/// no additional arguments are needed. If that assumption stops holding true,
/// the tooling will need a way for packages to control the generation (e.g.,
/// with a script file with a known name in the pigeons/ directory.)
Future<bool> _regeneratePigeonFiles(RepositoryPackage package) async {
final Iterable<File> inputs = _getPigeonInputFiles(package);
if (inputs.isEmpty) {
logWarning('No pigeon input files found.');
return true;
}
print('${indentation}Running pub get...');
if (!await runPubGet(package, processRunner, platform,
streamOutput: false)) {
printError('${indentation}Fetching dependencies failed');
return false;
}
print('${indentation}Updating Pigeon files...');
for (final File input in inputs) {
final String relativePath =
getRelativePosixPath(input, from: package.directory);
final io.ProcessResult pigeonResult = await processRunner.run(
'dart', <String>['run', 'pigeon', '--input', relativePath],
workingDir: package.directory);
if (pigeonResult.exitCode != 0) {
printError('dart run pigeon failed (${pigeonResult.exitCode}):\n'
'${pigeonResult.stdout}\n${pigeonResult.stderr}\n');
return false;
}
}
return true;
}
/// Re-runs Mockito mock generation for [package] if necessary.
Future<bool> _regenerateMocks(RepositoryPackage package) async {
final Pubspec pubspec = package.parsePubspec();
if (!pubspec.devDependencies.keys.contains('build_runner')) {
print(
'${indentation}No build_runner dependency; skipping mock regeneration.');
return true;
}
print('${indentation}Running pub get...');
if (!await runPubGet(package, processRunner, platform,
streamOutput: false)) {
printError('${indentation}Fetching dependencies failed');
return false;
}
print('${indentation}Updating mocks...');
final io.ProcessResult buildRunnerResult = await processRunner.run(
'dart',
<String>[
'run',
'build_runner',
'build',
'--delete-conflicting-outputs'
],
workingDir: package.directory);
if (buildRunnerResult.exitCode != 0) {
printError(
'"dart run build_runner build" failed (${buildRunnerResult.exitCode}):\n'
'${buildRunnerResult.stdout}\n${buildRunnerResult.stderr}\n');
return false;
}
return true;
}
}
class _PubDependencyInfo {
const _PubDependencyInfo(this.type,
{required this.pinned, required this.hosted, this.constraintString});
final _PubDependencyType type;
final bool pinned;
final bool hosted;
final String? constraintString;
}
enum _PubDependencyType { normal, dev }
class _AndroidDepdencyType {
static const String gradle = 'gradle';
static const String compileSdk = 'compileSdk';
static const String compileSdkForExamples = 'compileSdkForExamples';
}