| // Copyright 2013 The Flutter Authors |
| // 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, |
| super.gitDir, |
| 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>[ |
| _AndroidDependencyType.gradle, |
| _AndroidDependencyType.androidGradlePlugin, |
| _AndroidDependencyType.kotlinGradlePlugin, |
| _AndroidDependencyType.compileSdk, |
| _AndroidDependencyType.compileSdkForExamples, |
| ], |
| allowedHelp: <String, String>{ |
| _AndroidDependencyType.gradle: |
| 'Updates Gradle version used in plugin example apps.', |
| _AndroidDependencyType.androidGradlePlugin: |
| 'Updates AGP version used in plugin example apps.', |
| _AndroidDependencyType.kotlinGradlePlugin: |
| 'Updates KGP version used in plugin example apps.', |
| _AndroidDependencyType.compileSdk: |
| 'Updates compileSdk version used to compile plugins.', |
| _AndroidDependencyType.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 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 == _AndroidDependencyType.gradle || |
| _targetAndroidDependency == |
| _AndroidDependencyType.androidGradlePlugin) { |
| final validGradleAGPVersionPattern = RegExp( |
| r'^\d{1,2}\.\d{1,2}(?:\.\d)?$', |
| ); |
| final isValidGradleAGPVersion = |
| validGradleAGPVersionPattern.stringMatch(version) == version; |
| if (!isValidGradleAGPVersion) { |
| printError(''' |
| A version with a valid format (maximum 2-3 numbers separated by 1-2 periods) must be provided. |
| 1. The first number must have one or two digits |
| 2. The second number must have one or two digits |
| 3. If present, the third number must have a single digit'''); |
| throw ToolExit(_exitInvalidTargetVersion); |
| } |
| } else if (_targetAndroidDependency == |
| _AndroidDependencyType.kotlinGradlePlugin) { |
| final validKgpVersionPattern = RegExp(r'^\d\.\d\.\d{1,2}$'); |
| final isValidKgpVersion = |
| validKgpVersionPattern.stringMatch(version) == version; |
| if (!isValidKgpVersion) { |
| printError(''' |
| A version with a valid format (3 numbers separated by 2 periods) must be provided. |
| 1. The first number must have one digit |
| 2. The second number must have one digit |
| 3. The third number must have one or two digits'''); |
| throw ToolExit(_exitInvalidTargetVersion); |
| } |
| } else if (_targetAndroidDependency == |
| _AndroidDependencyType.compileSdk || |
| _targetAndroidDependency == |
| _AndroidDependencyType.compileSdkForExamples) { |
| final validSdkVersion = RegExp(r'^\d{1,2}$'); |
| final 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 sectionKey = dependencyInfo.type == _PubDependencyType.dev |
| ? 'dev_dependencies' |
| : 'dependencies'; |
| final String versionString; |
| final 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 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 == _AndroidDependencyType.compileSdk) { |
| return _runForCompileSdkVersion(package); |
| } else if (_targetAndroidDependency == _AndroidDependencyType.gradle || |
| _targetAndroidDependency == |
| _AndroidDependencyType.compileSdkForExamples || |
| _targetAndroidDependency == |
| _AndroidDependencyType.androidGradlePlugin || |
| _targetAndroidDependency == _AndroidDependencyType.kotlinGradlePlugin) { |
| 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(); |
| var updateRanForExamples = false; |
| for (final example in packageExamples) { |
| if (!example.platformDirectory(FlutterPlatform.android).existsSync()) { |
| continue; |
| } |
| |
| updateRanForExamples = true; |
| final Directory androidDirectory = example.platformDirectory( |
| FlutterPlatform.android, |
| ); |
| final filesToUpdate = <File>[]; |
| final RegExp dependencyVersionPattern; |
| final String newDependencyVersionEntry; |
| |
| if (_targetAndroidDependency == _AndroidDependencyType.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 == |
| _AndroidDependencyType.compileSdkForExamples) { |
| filesToUpdate.add( |
| androidDirectory.childDirectory('app').childFile('build.gradle'), |
| ); |
| dependencyVersionPattern = RegExp( |
| r'(compileSdk|compileSdkVersion) (\d{1,2}|flutter.compileSdkVersion)', |
| ); |
| newDependencyVersionEntry = 'compileSdk $_targetVersion'; |
| } else if (_targetAndroidDependency == |
| _AndroidDependencyType.androidGradlePlugin) { |
| if (androidDirectory.childFile('settings.gradle').existsSync()) { |
| filesToUpdate.add(androidDirectory.childFile('settings.gradle')); |
| } |
| dependencyVersionPattern = RegExp( |
| r'^\s*id\s+"com\.android\.application"\s+version\s+"(\d{1,2}\.\d{1,2}(?:\.\d)?)"\s+apply\s+false\s*$', |
| multiLine: true, |
| ); |
| newDependencyVersionEntry = |
| 'id "com.android.application" version "$_targetVersion" apply false'; |
| } else if (_targetAndroidDependency == |
| _AndroidDependencyType.kotlinGradlePlugin) { |
| if (androidDirectory.childFile('settings.gradle').existsSync()) { |
| filesToUpdate.add(androidDirectory.childFile('settings.gradle')); |
| } |
| dependencyVersionPattern = RegExp( |
| r'^\s*id\s+"org\.jetbrains\.kotlin\.android"\s+version\s+"(\d\.\d\.\d{1,2})"\s+apply\s+false\s*$', |
| multiLine: true, |
| ); |
| newDependencyVersionEntry = |
| ' id "org.jetbrains.kotlin.android" version "$_targetVersion" apply false'; |
| } else { |
| printError( |
| 'Target Android dependency $_targetAndroidDependency is unrecognized.', |
| ); |
| throw ToolExit(_exitIncorrectTargetDependency); |
| } |
| |
| for (final 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 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 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 _AndroidDependencyType { |
| static const String gradle = 'gradle'; |
| static const String androidGradlePlugin = 'androidGradlePlugin'; |
| static const String kotlinGradlePlugin = 'kotlinGradlePlugin'; |
| static const String compileSdk = 'compileSdk'; |
| static const String compileSdkForExamples = 'compileSdkForExamples'; |
| } |