Move plugin tools code (#3544)
diff --git a/.gitmodules b/.gitmodules
deleted file mode 100644
index d83ab14..0000000
--- a/.gitmodules
+++ /dev/null
@@ -1,3 +0,0 @@
-[submodule "script/plugin_tools"]
- path = script/plugin_tools
- url = https://github.com/flutter/plugin_tools.git
diff --git a/script/common.sh b/script/common.sh
index 4c8aff9..28c3754 100644
--- a/script/common.sh
+++ b/script/common.sh
@@ -48,6 +48,6 @@
# Runs the plugin tools from the plugin_tools git submodule.
function plugin_tools() {
- (pushd "$REPO_DIR/script/plugin_tools" && dart pub get && popd) >/dev/null
- dart run "$REPO_DIR/script/plugin_tools/lib/src/main.dart" "$@"
+ (pushd "$REPO_DIR/script/tool" && dart pub get && popd) >/dev/null
+ dart run "$REPO_DIR/script/tool/lib/src/main.dart" "$@"
}
diff --git a/script/plugin_tools b/script/plugin_tools
deleted file mode 160000
index 432c56d..0000000
--- a/script/plugin_tools
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 432c56da35880e95f6cbb02c40e9da0361771f48
diff --git a/script/tool/README.md b/script/tool/README.md
new file mode 100644
index 0000000..162ca0d
--- /dev/null
+++ b/script/tool/README.md
@@ -0,0 +1,8 @@
+# Flutter Plugin Tools
+
+To run the tool:
+
+```sh
+dart pub get
+dart run lib/src/main.dart <args>
+```
diff --git a/script/tool/lib/src/analyze_command.dart b/script/tool/lib/src/analyze_command.dart
new file mode 100644
index 0000000..8cd57fa
--- /dev/null
+++ b/script/tool/lib/src/analyze_command.dart
@@ -0,0 +1,94 @@
+// Copyright 2017 The Chromium 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:async';
+
+import 'package:file/file.dart';
+import 'package:path/path.dart' as p;
+
+import 'common.dart';
+
+class AnalyzeCommand extends PluginCommand {
+ AnalyzeCommand(
+ Directory packagesDir,
+ FileSystem fileSystem, {
+ ProcessRunner processRunner = const ProcessRunner(),
+ }) : super(packagesDir, fileSystem, processRunner: processRunner) {
+ argParser.addMultiOption(_customAnalysisFlag,
+ help:
+ 'Directories (comma seperated) that are allowed to have their own analysis options.',
+ defaultsTo: <String>[]);
+ }
+
+ static const String _customAnalysisFlag = 'custom-analysis';
+
+ @override
+ final String name = 'analyze';
+
+ @override
+ final String description = 'Analyzes all packages using package:tuneup.\n\n'
+ 'This command requires "pub" and "flutter" to be in your path.';
+
+ @override
+ Future<Null> run() async {
+ checkSharding();
+
+ print('Verifying analysis settings...');
+ final List<FileSystemEntity> files = packagesDir.listSync(recursive: true);
+ for (final FileSystemEntity file in files) {
+ if (file.basename != 'analysis_options.yaml' &&
+ file.basename != '.analysis_options') {
+ continue;
+ }
+
+ final bool whitelisted = argResults[_customAnalysisFlag].any(
+ (String directory) =>
+ p.isWithin(p.join(packagesDir.path, directory), file.path));
+ if (whitelisted) {
+ continue;
+ }
+
+ print('Found an extra analysis_options.yaml in ${file.absolute.path}.');
+ print(
+ 'If this was deliberate, pass the package to the analyze command with the --$_customAnalysisFlag flag and try again.');
+ throw ToolExit(1);
+ }
+
+ print('Activating tuneup package...');
+ await processRunner.runAndStream(
+ 'pub', <String>['global', 'activate', 'tuneup'],
+ workingDir: packagesDir, exitOnError: true);
+
+ await for (Directory package in getPackages()) {
+ if (isFlutterPackage(package, fileSystem)) {
+ await processRunner.runAndStream('flutter', <String>['packages', 'get'],
+ workingDir: package, exitOnError: true);
+ } else {
+ await processRunner.runAndStream('pub', <String>['get'],
+ workingDir: package, exitOnError: true);
+ }
+ }
+
+ final List<String> failingPackages = <String>[];
+ await for (Directory package in getPlugins()) {
+ final int exitCode = await processRunner.runAndStream(
+ 'pub', <String>['global', 'run', 'tuneup', 'check'],
+ workingDir: package);
+ if (exitCode != 0) {
+ failingPackages.add(p.basename(package.path));
+ }
+ }
+
+ print('\n\n');
+ if (failingPackages.isNotEmpty) {
+ print('The following packages have analyzer errors (see above):');
+ failingPackages.forEach((String package) {
+ print(' * $package');
+ });
+ throw ToolExit(1);
+ }
+
+ print('No analyzer errors found!');
+ }
+}
diff --git a/script/tool/lib/src/build_examples_command.dart b/script/tool/lib/src/build_examples_command.dart
new file mode 100644
index 0000000..53da908
--- /dev/null
+++ b/script/tool/lib/src/build_examples_command.dart
@@ -0,0 +1,188 @@
+// Copyright 2017 The Chromium 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:async';
+import 'dart:io' as io;
+
+import 'package:file/file.dart';
+import 'package:path/path.dart' as p;
+import 'package:platform/platform.dart';
+
+import 'common.dart';
+
+class BuildExamplesCommand extends PluginCommand {
+ BuildExamplesCommand(
+ Directory packagesDir,
+ FileSystem fileSystem, {
+ ProcessRunner processRunner = const ProcessRunner(),
+ }) : super(packagesDir, fileSystem, processRunner: processRunner) {
+ argParser.addFlag(kLinux, defaultsTo: false);
+ argParser.addFlag(kMacos, defaultsTo: false);
+ argParser.addFlag(kWindows, defaultsTo: false);
+ argParser.addFlag(kIpa, defaultsTo: io.Platform.isMacOS);
+ argParser.addFlag(kApk);
+ argParser.addOption(
+ kEnableExperiment,
+ defaultsTo: '',
+ help: 'Enables the given Dart SDK experiments.',
+ );
+ }
+
+ @override
+ final String name = 'build-examples';
+
+ @override
+ final String description =
+ 'Builds all example apps (IPA for iOS and APK for Android).\n\n'
+ 'This command requires "flutter" to be in your path.';
+
+ @override
+ Future<Null> run() async {
+ if (!argResults[kIpa] &&
+ !argResults[kApk] &&
+ !argResults[kLinux] &&
+ !argResults[kMacos] &&
+ !argResults[kWindows]) {
+ print(
+ 'None of --linux, --macos, --windows, --apk nor --ipa were specified, '
+ 'so not building anything.');
+ return;
+ }
+ final String flutterCommand =
+ LocalPlatform().isWindows ? 'flutter.bat' : 'flutter';
+
+ final String enableExperiment = argResults[kEnableExperiment];
+
+ checkSharding();
+ final List<String> failingPackages = <String>[];
+ await for (Directory plugin in getPlugins()) {
+ for (Directory example in getExamplesForPlugin(plugin)) {
+ final String packageName =
+ p.relative(example.path, from: packagesDir.path);
+
+ if (argResults[kLinux]) {
+ print('\nBUILDING Linux for $packageName');
+ if (isLinuxPlugin(plugin, fileSystem)) {
+ int buildExitCode = await processRunner.runAndStream(
+ flutterCommand,
+ <String>[
+ 'build',
+ kLinux,
+ if (enableExperiment.isNotEmpty)
+ '--enable-experiment=$enableExperiment',
+ ],
+ workingDir: example);
+ if (buildExitCode != 0) {
+ failingPackages.add('$packageName (linux)');
+ }
+ } else {
+ print('Linux is not supported by this plugin');
+ }
+ }
+
+ if (argResults[kMacos]) {
+ print('\nBUILDING macOS for $packageName');
+ if (isMacOsPlugin(plugin, fileSystem)) {
+ // TODO(https://github.com/flutter/flutter/issues/46236):
+ // Builing macos without running flutter pub get first results
+ // in an error.
+ int exitCode = await processRunner.runAndStream(
+ flutterCommand, <String>['pub', 'get'],
+ workingDir: example);
+ if (exitCode != 0) {
+ failingPackages.add('$packageName (macos)');
+ } else {
+ exitCode = await processRunner.runAndStream(
+ flutterCommand,
+ <String>[
+ 'build',
+ kMacos,
+ if (enableExperiment.isNotEmpty)
+ '--enable-experiment=$enableExperiment',
+ ],
+ workingDir: example);
+ if (exitCode != 0) {
+ failingPackages.add('$packageName (macos)');
+ }
+ }
+ } else {
+ print('macOS is not supported by this plugin');
+ }
+ }
+
+ if (argResults[kWindows]) {
+ print('\nBUILDING Windows for $packageName');
+ if (isWindowsPlugin(plugin, fileSystem)) {
+ int buildExitCode = await processRunner.runAndStream(
+ flutterCommand,
+ <String>[
+ 'build',
+ kWindows,
+ if (enableExperiment.isNotEmpty)
+ '--enable-experiment=$enableExperiment',
+ ],
+ workingDir: example);
+ if (buildExitCode != 0) {
+ failingPackages.add('$packageName (windows)');
+ }
+ } else {
+ print('Windows is not supported by this plugin');
+ }
+ }
+
+ if (argResults[kIpa]) {
+ print('\nBUILDING IPA for $packageName');
+ if (isIosPlugin(plugin, fileSystem)) {
+ final int exitCode = await processRunner.runAndStream(
+ flutterCommand,
+ <String>[
+ 'build',
+ 'ios',
+ '--no-codesign',
+ if (enableExperiment.isNotEmpty)
+ '--enable-experiment=$enableExperiment',
+ ],
+ workingDir: example);
+ if (exitCode != 0) {
+ failingPackages.add('$packageName (ipa)');
+ }
+ } else {
+ print('iOS is not supported by this plugin');
+ }
+ }
+
+ if (argResults[kApk]) {
+ print('\nBUILDING APK for $packageName');
+ if (isAndroidPlugin(plugin, fileSystem)) {
+ final int exitCode = await processRunner.runAndStream(
+ flutterCommand,
+ <String>[
+ 'build',
+ 'apk',
+ if (enableExperiment.isNotEmpty)
+ '--enable-experiment=$enableExperiment',
+ ],
+ workingDir: example);
+ if (exitCode != 0) {
+ failingPackages.add('$packageName (apk)');
+ }
+ } else {
+ print('Android is not supported by this plugin');
+ }
+ }
+ }
+ }
+ print('\n\n');
+
+ if (failingPackages.isNotEmpty) {
+ print('The following build are failing (see above for details):');
+ for (String package in failingPackages) {
+ print(' * $package');
+ }
+ throw ToolExit(1);
+ }
+
+ print('All builds successful!');
+ }
+}
diff --git a/script/tool/lib/src/common.dart b/script/tool/lib/src/common.dart
new file mode 100644
index 0000000..78b91ee
--- /dev/null
+++ b/script/tool/lib/src/common.dart
@@ -0,0 +1,466 @@
+// Copyright 2017 The Chromium 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:async';
+import 'dart:io' as io;
+import 'dart:math';
+
+import 'package:args/command_runner.dart';
+import 'package:file/file.dart';
+import 'package:path/path.dart' as p;
+import 'package:yaml/yaml.dart';
+
+typedef void Print(Object object);
+
+/// Key for windows platform.
+const String kWindows = 'windows';
+
+/// Key for macos platform.
+const String kMacos = 'macos';
+
+/// Key for linux platform.
+const String kLinux = 'linux';
+
+/// Key for IPA (iOS) platform.
+const String kIos = 'ios';
+
+/// Key for APK (Android) platform.
+const String kAndroid = 'android';
+
+/// Key for Web platform.
+const String kWeb = 'web';
+
+/// Key for IPA.
+const String kIpa = 'ipa';
+
+/// Key for APK.
+const String kApk = 'apk';
+
+/// Key for enable experiment.
+const String kEnableExperiment = 'enable-experiment';
+
+/// Returns whether the given directory contains a Flutter package.
+bool isFlutterPackage(FileSystemEntity entity, FileSystem fileSystem) {
+ if (entity == null || entity is! Directory) {
+ return false;
+ }
+
+ try {
+ final File pubspecFile =
+ fileSystem.file(p.join(entity.path, 'pubspec.yaml'));
+ final YamlMap pubspecYaml = loadYaml(pubspecFile.readAsStringSync());
+ final YamlMap dependencies = pubspecYaml['dependencies'];
+ if (dependencies == null) {
+ return false;
+ }
+ return dependencies.containsKey('flutter');
+ } on FileSystemException {
+ return false;
+ } on YamlException {
+ return false;
+ }
+}
+
+/// Returns whether the given directory contains a Flutter [platform] plugin.
+///
+/// It checks this by looking for the following pattern in the pubspec:
+///
+/// flutter:
+/// plugin:
+/// platforms:
+/// [platform]:
+bool pluginSupportsPlatform(
+ String platform, FileSystemEntity entity, FileSystem fileSystem) {
+ assert(platform == kIos ||
+ platform == kAndroid ||
+ platform == kWeb ||
+ platform == kMacos ||
+ platform == kWindows ||
+ platform == kLinux);
+ if (entity == null || entity is! Directory) {
+ return false;
+ }
+
+ try {
+ final File pubspecFile =
+ fileSystem.file(p.join(entity.path, 'pubspec.yaml'));
+ final YamlMap pubspecYaml = loadYaml(pubspecFile.readAsStringSync());
+ final YamlMap flutterSection = pubspecYaml['flutter'];
+ if (flutterSection == null) {
+ return false;
+ }
+ final YamlMap pluginSection = flutterSection['plugin'];
+ if (pluginSection == null) {
+ return false;
+ }
+ final YamlMap platforms = pluginSection['platforms'];
+ if (platforms == null) {
+ // Legacy plugin specs are assumed to support iOS and Android.
+ if (!pluginSection.containsKey('platforms')) {
+ return platform == kIos || platform == kAndroid;
+ }
+ return false;
+ }
+ return platforms.containsKey(platform);
+ } on FileSystemException {
+ return false;
+ } on YamlException {
+ return false;
+ }
+}
+
+/// Returns whether the given directory contains a Flutter Android plugin.
+bool isAndroidPlugin(FileSystemEntity entity, FileSystem fileSystem) {
+ return pluginSupportsPlatform(kAndroid, entity, fileSystem);
+}
+
+/// Returns whether the given directory contains a Flutter iOS plugin.
+bool isIosPlugin(FileSystemEntity entity, FileSystem fileSystem) {
+ return pluginSupportsPlatform(kIos, entity, fileSystem);
+}
+
+/// Returns whether the given directory contains a Flutter web plugin.
+bool isWebPlugin(FileSystemEntity entity, FileSystem fileSystem) {
+ return pluginSupportsPlatform(kWeb, entity, fileSystem);
+}
+
+/// Returns whether the given directory contains a Flutter Windows plugin.
+bool isWindowsPlugin(FileSystemEntity entity, FileSystem fileSystem) {
+ return pluginSupportsPlatform(kWindows, entity, fileSystem);
+}
+
+/// Returns whether the given directory contains a Flutter macOS plugin.
+bool isMacOsPlugin(FileSystemEntity entity, FileSystem fileSystem) {
+ return pluginSupportsPlatform(kMacos, entity, fileSystem);
+}
+
+/// Returns whether the given directory contains a Flutter linux plugin.
+bool isLinuxPlugin(FileSystemEntity entity, FileSystem fileSystem) {
+ return pluginSupportsPlatform(kLinux, entity, fileSystem);
+}
+
+/// Error thrown when a command needs to exit with a non-zero exit code.
+class ToolExit extends Error {
+ ToolExit(this.exitCode);
+
+ final int exitCode;
+}
+
+abstract class PluginCommand extends Command<Null> {
+ PluginCommand(
+ this.packagesDir,
+ this.fileSystem, {
+ this.processRunner = const ProcessRunner(),
+ }) {
+ argParser.addMultiOption(
+ _pluginsArg,
+ splitCommas: true,
+ help:
+ 'Specifies which plugins the command should run on (before sharding).',
+ valueHelp: 'plugin1,plugin2,...',
+ );
+ argParser.addOption(
+ _shardIndexArg,
+ help: 'Specifies the zero-based index of the shard to '
+ 'which the command applies.',
+ valueHelp: 'i',
+ defaultsTo: '0',
+ );
+ argParser.addOption(
+ _shardCountArg,
+ help: 'Specifies the number of shards into which plugins are divided.',
+ valueHelp: 'n',
+ defaultsTo: '1',
+ );
+ argParser.addMultiOption(
+ _excludeArg,
+ abbr: 'e',
+ help: 'Exclude packages from this command.',
+ defaultsTo: <String>[],
+ );
+ }
+
+ static const String _pluginsArg = 'plugins';
+ static const String _shardIndexArg = 'shardIndex';
+ static const String _shardCountArg = 'shardCount';
+ static const String _excludeArg = 'exclude';
+
+ /// The directory containing the plugin packages.
+ final Directory packagesDir;
+
+ /// The file system.
+ ///
+ /// This can be overridden for testing.
+ final FileSystem fileSystem;
+
+ /// The process runner.
+ ///
+ /// This can be overridden for testing.
+ final ProcessRunner processRunner;
+
+ int _shardIndex;
+ int _shardCount;
+
+ int get shardIndex {
+ if (_shardIndex == null) {
+ checkSharding();
+ }
+ return _shardIndex;
+ }
+
+ int get shardCount {
+ if (_shardCount == null) {
+ checkSharding();
+ }
+ return _shardCount;
+ }
+
+ void checkSharding() {
+ final int shardIndex = int.tryParse(argResults[_shardIndexArg]);
+ final int shardCount = int.tryParse(argResults[_shardCountArg]);
+ if (shardIndex == null) {
+ usageException('$_shardIndexArg must be an integer');
+ }
+ if (shardCount == null) {
+ usageException('$_shardCountArg must be an integer');
+ }
+ if (shardCount < 1) {
+ usageException('$_shardCountArg must be positive');
+ }
+ if (shardIndex < 0 || shardCount <= shardIndex) {
+ usageException(
+ '$_shardIndexArg must be in the half-open range [0..$shardCount[');
+ }
+ _shardIndex = shardIndex;
+ _shardCount = shardCount;
+ }
+
+ /// Returns the root Dart package folders of the plugins involved in this
+ /// command execution.
+ Stream<Directory> getPlugins() async* {
+ // To avoid assuming consistency of `Directory.list` across command
+ // invocations, we collect and sort the plugin folders before sharding.
+ // This is considered an implementation detail which is why the API still
+ // uses streams.
+ final List<Directory> allPlugins = await _getAllPlugins().toList();
+ allPlugins.sort((Directory d1, Directory d2) => d1.path.compareTo(d2.path));
+ // Sharding 10 elements into 3 shards should yield shard sizes 4, 4, 2.
+ // Sharding 9 elements into 3 shards should yield shard sizes 3, 3, 3.
+ // Sharding 2 elements into 3 shards should yield shard sizes 1, 1, 0.
+ final int shardSize = allPlugins.length ~/ shardCount +
+ (allPlugins.length % shardCount == 0 ? 0 : 1);
+ final int start = min(shardIndex * shardSize, allPlugins.length);
+ final int end = min(start + shardSize, allPlugins.length);
+
+ for (Directory plugin in allPlugins.sublist(start, end)) {
+ yield plugin;
+ }
+ }
+
+ /// Returns the root Dart package folders of the plugins involved in this
+ /// command execution, assuming there is only one shard.
+ ///
+ /// Plugin packages can exist in one of two places relative to the packages
+ /// directory.
+ ///
+ /// 1. As a Dart package in a directory which is a direct child of the
+ /// packages directory. This is a plugin where all of the implementations
+ /// exist in a single Dart package.
+ /// 2. Several plugin packages may live in a directory which is a direct
+ /// child of the packages directory. This directory groups several Dart
+ /// packages which implement a single plugin. This directory contains a
+ /// "client library" package, which declares the API for the plugin, as
+ /// well as one or more platform-specific implementations.
+ Stream<Directory> _getAllPlugins() async* {
+ final Set<String> plugins = Set<String>.from(argResults[_pluginsArg]);
+ final Set<String> excludedPlugins =
+ Set<String>.from(argResults[_excludeArg]);
+
+ await for (FileSystemEntity entity
+ in packagesDir.list(followLinks: false)) {
+ // A top-level Dart package is a plugin package.
+ if (_isDartPackage(entity)) {
+ if (!excludedPlugins.contains(entity.basename) &&
+ (plugins.isEmpty || plugins.contains(p.basename(entity.path)))) {
+ yield entity;
+ }
+ } else if (entity is Directory) {
+ // Look for Dart packages under this top-level directory.
+ await for (FileSystemEntity subdir in entity.list(followLinks: false)) {
+ if (_isDartPackage(subdir)) {
+ // If --plugin=my_plugin is passed, then match all federated
+ // plugins under 'my_plugin'. Also match if the exact plugin is
+ // passed.
+ final String relativePath =
+ p.relative(subdir.path, from: packagesDir.path);
+ final String basenamePath = p.basename(entity.path);
+ if (!excludedPlugins.contains(basenamePath) &&
+ !excludedPlugins.contains(relativePath) &&
+ (plugins.isEmpty ||
+ plugins.contains(relativePath) ||
+ plugins.contains(basenamePath))) {
+ yield subdir;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /// Returns the example Dart package folders of the plugins involved in this
+ /// command execution.
+ Stream<Directory> getExamples() =>
+ getPlugins().expand<Directory>(getExamplesForPlugin);
+
+ /// Returns all Dart package folders (typically, plugin + example) of the
+ /// plugins involved in this command execution.
+ Stream<Directory> getPackages() async* {
+ await for (Directory plugin in getPlugins()) {
+ yield plugin;
+ yield* plugin
+ .list(recursive: true, followLinks: false)
+ .where(_isDartPackage)
+ .cast<Directory>();
+ }
+ }
+
+ /// Returns the files contained, recursively, within the plugins
+ /// involved in this command execution.
+ Stream<File> getFiles() {
+ return getPlugins().asyncExpand<File>((Directory folder) => folder
+ .list(recursive: true, followLinks: false)
+ .where((FileSystemEntity entity) => entity is File)
+ .cast<File>());
+ }
+
+ /// Returns whether the specified entity is a directory containing a
+ /// `pubspec.yaml` file.
+ bool _isDartPackage(FileSystemEntity entity) {
+ return entity is Directory &&
+ fileSystem.file(p.join(entity.path, 'pubspec.yaml')).existsSync();
+ }
+
+ /// Returns the example Dart packages contained in the specified plugin, or
+ /// an empty List, if the plugin has no examples.
+ Iterable<Directory> getExamplesForPlugin(Directory plugin) {
+ final Directory exampleFolder =
+ fileSystem.directory(p.join(plugin.path, 'example'));
+ if (!exampleFolder.existsSync()) {
+ return <Directory>[];
+ }
+ if (isFlutterPackage(exampleFolder, fileSystem)) {
+ return <Directory>[exampleFolder];
+ }
+ // Only look at the subdirectories of the example directory if the example
+ // directory itself is not a Dart package, and only look one level below the
+ // example directory for other dart packages.
+ return exampleFolder
+ .listSync()
+ .where(
+ (FileSystemEntity entity) => isFlutterPackage(entity, fileSystem))
+ .cast<Directory>();
+ }
+}
+
+/// A class used to run processes.
+///
+/// We use this instead of directly running the process so it can be overridden
+/// in tests.
+class ProcessRunner {
+ const ProcessRunner();
+
+ /// Run the [executable] with [args] and stream output to stderr and stdout.
+ ///
+ /// The current working directory of [executable] can be overridden by
+ /// passing [workingDir].
+ ///
+ /// If [exitOnError] is set to `true`, then this will throw an error if
+ /// the [executable] terminates with a non-zero exit code.
+ ///
+ /// Returns the exit code of the [executable].
+ Future<int> runAndStream(
+ String executable,
+ List<String> args, {
+ Directory workingDir,
+ bool exitOnError = false,
+ }) async {
+ print(
+ 'Running command: "$executable ${args.join(' ')}" in ${workingDir?.path ?? io.Directory.current.path}');
+ final io.Process process = await io.Process.start(executable, args,
+ workingDirectory: workingDir?.path);
+ await io.stdout.addStream(process.stdout);
+ await io.stderr.addStream(process.stderr);
+ if (exitOnError && await process.exitCode != 0) {
+ final String error =
+ _getErrorString(executable, args, workingDir: workingDir);
+ print('$error See above for details.');
+ throw ToolExit(await process.exitCode);
+ }
+ return process.exitCode;
+ }
+
+ /// Run the [executable] with [args].
+ ///
+ /// The current working directory of [executable] can be overridden by
+ /// passing [workingDir].
+ ///
+ /// If [exitOnError] is set to `true`, then this will throw an error if
+ /// the [executable] terminates with a non-zero exit code.
+ ///
+ /// Returns the [io.ProcessResult] of the [executable].
+ Future<io.ProcessResult> run(String executable, List<String> args,
+ {Directory workingDir,
+ bool exitOnError = false,
+ stdoutEncoding = io.systemEncoding,
+ stderrEncoding = io.systemEncoding}) async {
+ return io.Process.run(executable, args,
+ workingDirectory: workingDir?.path,
+ stdoutEncoding: stdoutEncoding,
+ stderrEncoding: stderrEncoding);
+ }
+
+ /// Starts the [executable] with [args].
+ ///
+ /// The current working directory of [executable] can be overridden by
+ /// passing [workingDir].
+ ///
+ /// Returns the started [io.Process].
+ Future<io.Process> start(String executable, List<String> args,
+ {Directory workingDirectory}) async {
+ final io.Process process = await io.Process.start(executable, args,
+ workingDirectory: workingDirectory?.path);
+ return process;
+ }
+
+ /// Run the [executable] with [args], throwing an error on non-zero exit code.
+ ///
+ /// Unlike [runAndStream], this does not stream the process output to stdout.
+ /// It also unconditionally throws an error on a non-zero exit code.
+ ///
+ /// The current working directory of [executable] can be overridden by
+ /// passing [workingDir].
+ ///
+ /// Returns the [io.ProcessResult] of running the [executable].
+ Future<io.ProcessResult> runAndExitOnError(
+ String executable,
+ List<String> args, {
+ Directory workingDir,
+ }) async {
+ final io.ProcessResult result = await io.Process.run(executable, args,
+ workingDirectory: workingDir?.path);
+ if (result.exitCode != 0) {
+ final String error =
+ _getErrorString(executable, args, workingDir: workingDir);
+ print('$error Stderr:\n${result.stdout}');
+ throw ToolExit(result.exitCode);
+ }
+ return result;
+ }
+
+ String _getErrorString(String executable, List<String> args,
+ {Directory workingDir}) {
+ final String workdir = workingDir == null ? '' : ' in ${workingDir.path}';
+ return 'ERROR: Unable to execute "$executable ${args.join(' ')}"$workdir.';
+ }
+}
diff --git a/script/tool/lib/src/create_all_plugins_app_command.dart b/script/tool/lib/src/create_all_plugins_app_command.dart
new file mode 100644
index 0000000..0f1431c
--- /dev/null
+++ b/script/tool/lib/src/create_all_plugins_app_command.dart
@@ -0,0 +1,200 @@
+// Copyright 2019 The Chromium 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:async';
+import 'dart:io' as io;
+
+import 'package:file/file.dart';
+import 'package:path/path.dart' as p;
+import 'package:pub_semver/pub_semver.dart';
+import 'package:pubspec_parse/pubspec_parse.dart';
+
+import 'common.dart';
+
+// TODO(cyanglaz): Add tests for this command.
+// https://github.com/flutter/flutter/issues/61049
+class CreateAllPluginsAppCommand extends PluginCommand {
+ CreateAllPluginsAppCommand(Directory packagesDir, FileSystem fileSystem)
+ : super(packagesDir, fileSystem);
+
+ @override
+ String get description =>
+ 'Generate Flutter app that includes all plugins in packages.';
+
+ @override
+ String get name => 'all-plugins-app';
+
+ @override
+ Future<Null> run() async {
+ final int exitCode = await _createPlugin();
+ if (exitCode != 0) {
+ throw ToolExit(exitCode);
+ }
+
+ await Future.wait(<Future<void>>[
+ _genPubspecWithAllPlugins(),
+ _updateAppGradle(),
+ _updateManifest(),
+ ]);
+ }
+
+ Future<int> _createPlugin() async {
+ final io.ProcessResult result = io.Process.runSync(
+ 'flutter',
+ <String>[
+ 'create',
+ '--template=app',
+ '--project-name=all_plugins',
+ '--android-language=java',
+ './all_plugins',
+ ],
+ );
+
+ print(result.stdout);
+ print(result.stderr);
+ return result.exitCode;
+ }
+
+ Future<void> _updateAppGradle() async {
+ final File gradleFile = fileSystem.file(p.join(
+ 'all_plugins',
+ 'android',
+ 'app',
+ 'build.gradle',
+ ));
+ if (!gradleFile.existsSync()) {
+ throw ToolExit(64);
+ }
+
+ final StringBuffer newGradle = StringBuffer();
+ for (String line in gradleFile.readAsLinesSync()) {
+ newGradle.writeln(line);
+ if (line.contains('defaultConfig {')) {
+ newGradle.writeln(' multiDexEnabled true');
+ } else if (line.contains('dependencies {')) {
+ newGradle.writeln(
+ ' implementation \'com.google.guava:guava:27.0.1-android\'\n',
+ );
+ // Tests for https://github.com/flutter/flutter/issues/43383
+ newGradle.writeln(
+ " implementation 'androidx.lifecycle:lifecycle-runtime:2.2.0-rc01'\n",
+ );
+ }
+ }
+ gradleFile.writeAsStringSync(newGradle.toString());
+ }
+
+ Future<void> _updateManifest() async {
+ final File manifestFile = fileSystem.file(p.join(
+ 'all_plugins',
+ 'android',
+ 'app',
+ 'src',
+ 'main',
+ 'AndroidManifest.xml',
+ ));
+ if (!manifestFile.existsSync()) {
+ throw ToolExit(64);
+ }
+
+ final StringBuffer newManifest = StringBuffer();
+ for (String line in manifestFile.readAsLinesSync()) {
+ if (line.contains('package="com.example.all_plugins"')) {
+ newManifest
+ ..writeln('package="com.example.all_plugins"')
+ ..writeln('xmlns:tools="http://schemas.android.com/tools">')
+ ..writeln()
+ ..writeln(
+ '<uses-sdk tools:overrideLibrary="io.flutter.plugins.camera"/>',
+ );
+ } else {
+ newManifest.writeln(line);
+ }
+ }
+ manifestFile.writeAsStringSync(newManifest.toString());
+ }
+
+ Future<void> _genPubspecWithAllPlugins() async {
+ final Map<String, PathDependency> pluginDeps =
+ await _getValidPathDependencies();
+ final Pubspec pubspec = Pubspec(
+ 'all_plugins',
+ description: 'Flutter app containing all 1st party plugins.',
+ version: Version.parse('1.0.0+1'),
+ environment: <String, VersionConstraint>{
+ 'sdk': VersionConstraint.compatibleWith(
+ Version.parse('2.0.0'),
+ ),
+ },
+ dependencies: <String, Dependency>{
+ 'flutter': SdkDependency('flutter'),
+ }..addAll(pluginDeps),
+ devDependencies: <String, Dependency>{
+ 'flutter_test': SdkDependency('flutter'),
+ },
+ dependencyOverrides: pluginDeps,
+ );
+ final File pubspecFile =
+ fileSystem.file(p.join('all_plugins', 'pubspec.yaml'));
+ pubspecFile.writeAsStringSync(_pubspecToString(pubspec));
+ }
+
+ Future<Map<String, PathDependency>> _getValidPathDependencies() async {
+ final Map<String, PathDependency> pathDependencies =
+ <String, PathDependency>{};
+
+ await for (Directory package in getPlugins()) {
+ final String pluginName = package.path.split('/').last;
+ final File pubspecFile =
+ fileSystem.file(p.join(package.path, 'pubspec.yaml'));
+ final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync());
+
+ if (pubspec.publishTo != 'none') {
+ pathDependencies[pluginName] = PathDependency(package.path);
+ }
+ }
+ return pathDependencies;
+ }
+
+ String _pubspecToString(Pubspec pubspec) {
+ return '''
+### Generated file. Do not edit. Run `pub global run flutter_plugin_tools gen-pubspec` to update.
+name: ${pubspec.name}
+description: ${pubspec.description}
+
+version: ${pubspec.version}
+
+environment:${_pubspecMapString(pubspec.environment)}
+
+dependencies:${_pubspecMapString(pubspec.dependencies)}
+
+dependency_overrides:${_pubspecMapString(pubspec.dependencyOverrides)}
+
+dev_dependencies:${_pubspecMapString(pubspec.devDependencies)}
+###''';
+ }
+
+ String _pubspecMapString(Map<String, dynamic> values) {
+ final StringBuffer buffer = StringBuffer();
+
+ for (MapEntry<String, dynamic> entry in values.entries) {
+ buffer.writeln();
+ if (entry.value is VersionConstraint) {
+ buffer.write(' ${entry.key}: ${entry.value}');
+ } else if (entry.value is SdkDependency) {
+ final SdkDependency dep = entry.value;
+ buffer.write(' ${entry.key}: \n sdk: ${dep.sdk}');
+ } else if (entry.value is PathDependency) {
+ final PathDependency dep = entry.value;
+ buffer.write(' ${entry.key}: \n path: ${dep.path}');
+ } else {
+ throw UnimplementedError(
+ 'Not available for type: ${entry.value.runtimeType}',
+ );
+ }
+ }
+
+ return buffer.toString();
+ }
+}
diff --git a/script/tool/lib/src/drive_examples_command.dart b/script/tool/lib/src/drive_examples_command.dart
new file mode 100644
index 0000000..59c6422
--- /dev/null
+++ b/script/tool/lib/src/drive_examples_command.dart
@@ -0,0 +1,210 @@
+// Copyright 2019 The Chromium 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:async';
+import 'package:file/file.dart';
+import 'package:path/path.dart' as p;
+import 'package:platform/platform.dart';
+import 'common.dart';
+
+class DriveExamplesCommand extends PluginCommand {
+ DriveExamplesCommand(
+ Directory packagesDir,
+ FileSystem fileSystem, {
+ ProcessRunner processRunner = const ProcessRunner(),
+ }) : super(packagesDir, fileSystem, processRunner: processRunner) {
+ argParser.addFlag(kLinux,
+ help: 'Runs the Linux implementation of the examples');
+ argParser.addFlag(kMacos,
+ help: 'Runs the macOS implementation of the examples');
+ argParser.addFlag(kWindows,
+ help: 'Runs the Windows implementation of the examples');
+ argParser.addFlag(kIos,
+ help: 'Runs the iOS implementation of the examples');
+ argParser.addFlag(kAndroid,
+ help: 'Runs the Android implementation of the examples');
+ argParser.addOption(
+ kEnableExperiment,
+ defaultsTo: '',
+ help:
+ 'Runs the driver tests in Dart VM with the given experiments enabled.',
+ );
+ }
+
+ @override
+ final String name = 'drive-examples';
+
+ @override
+ final String description = 'Runs driver tests for plugin example apps.\n\n'
+ 'For each *_test.dart in test_driver/ it drives an application with a '
+ 'corresponding name in the test/ or test_driver/ directories.\n\n'
+ 'For example, test_driver/app_test.dart would match test/app.dart.\n\n'
+ 'This command requires "flutter" to be in your path.\n\n'
+ 'If a file with a corresponding name cannot be found, this driver file'
+ 'will be used to drive the tests that match '
+ 'integration_test/*_test.dart.';
+
+ @override
+ Future<Null> run() async {
+ checkSharding();
+ final List<String> failingTests = <String>[];
+ final bool isLinux = argResults[kLinux];
+ final bool isMacos = argResults[kMacos];
+ final bool isWindows = argResults[kWindows];
+ await for (Directory plugin in getPlugins()) {
+ final String flutterCommand =
+ LocalPlatform().isWindows ? 'flutter.bat' : 'flutter';
+ for (Directory example in getExamplesForPlugin(plugin)) {
+ final String packageName =
+ p.relative(example.path, from: packagesDir.path);
+ if (!(await pluginSupportedOnCurrentPlatform(plugin, fileSystem))) {
+ continue;
+ }
+ final Directory driverTests =
+ fileSystem.directory(p.join(example.path, 'test_driver'));
+ if (!driverTests.existsSync()) {
+ // No driver tests available for this example
+ continue;
+ }
+ // Look for driver tests ending in _test.dart in test_driver/
+ await for (FileSystemEntity test in driverTests.list()) {
+ final String driverTestName =
+ p.relative(test.path, from: driverTests.path);
+ if (!driverTestName.endsWith('_test.dart')) {
+ continue;
+ }
+ // Try to find a matching app to drive without the _test.dart
+ final String deviceTestName = driverTestName.replaceAll(
+ RegExp(r'_test.dart$'),
+ '.dart',
+ );
+ String deviceTestPath = p.join('test', deviceTestName);
+ if (!fileSystem
+ .file(p.join(example.path, deviceTestPath))
+ .existsSync()) {
+ // If the app isn't in test/ folder, look in test_driver/ instead.
+ deviceTestPath = p.join('test_driver', deviceTestName);
+ }
+
+ final List<String> targetPaths = <String>[];
+ if (fileSystem
+ .file(p.join(example.path, deviceTestPath))
+ .existsSync()) {
+ targetPaths.add(deviceTestPath);
+ } else {
+ final Directory integrationTests =
+ fileSystem.directory(p.join(example.path, 'integration_test'));
+
+ if (await integrationTests.exists()) {
+ await for (FileSystemEntity integration_test
+ in integrationTests.list()) {
+ if (!integration_test.basename.endsWith('_test.dart')) {
+ continue;
+ }
+ targetPaths
+ .add(p.relative(integration_test.path, from: example.path));
+ }
+ }
+
+ if (targetPaths.isEmpty) {
+ print('''
+Unable to infer a target application for $driverTestName to drive.
+Tried searching for the following:
+1. test/$deviceTestName
+2. test_driver/$deviceTestName
+3. test_driver/*_test.dart
+''');
+ failingTests.add(p.relative(test.path, from: example.path));
+ continue;
+ }
+ }
+
+ final List<String> driveArgs = <String>['drive'];
+
+ final String enableExperiment = argResults[kEnableExperiment];
+ if (enableExperiment.isNotEmpty) {
+ driveArgs.add('--enable-experiment=$enableExperiment');
+ }
+
+ if (isLinux && isLinuxPlugin(plugin, fileSystem)) {
+ driveArgs.addAll(<String>[
+ '-d',
+ 'linux',
+ ]);
+ }
+ if (isMacos && isMacOsPlugin(plugin, fileSystem)) {
+ driveArgs.addAll(<String>[
+ '-d',
+ 'macos',
+ ]);
+ }
+ if (isWindows && isWindowsPlugin(plugin, fileSystem)) {
+ driveArgs.addAll(<String>[
+ '-d',
+ 'windows',
+ ]);
+ }
+
+ for (final targetPath in targetPaths) {
+ final int exitCode = await processRunner.runAndStream(
+ flutterCommand,
+ [
+ ...driveArgs,
+ '--driver',
+ p.join('test_driver', driverTestName),
+ '--target',
+ targetPath,
+ ],
+ workingDir: example,
+ exitOnError: true);
+ if (exitCode != 0) {
+ failingTests.add(p.join(packageName, deviceTestPath));
+ }
+ }
+ }
+ }
+ }
+ print('\n\n');
+
+ if (failingTests.isNotEmpty) {
+ print('The following driver tests are failing (see above for details):');
+ for (String test in failingTests) {
+ print(' * $test');
+ }
+ throw ToolExit(1);
+ }
+
+ print('All driver tests successful!');
+ }
+
+ Future<bool> pluginSupportedOnCurrentPlatform(
+ FileSystemEntity plugin, FileSystem fileSystem) async {
+ final bool isLinux = argResults[kLinux];
+ final bool isMacos = argResults[kMacos];
+ final bool isWindows = argResults[kWindows];
+ final bool isIOS = argResults[kIos];
+ final bool isAndroid = argResults[kAndroid];
+ if (isLinux) {
+ return isLinuxPlugin(plugin, fileSystem);
+ }
+ if (isMacos) {
+ return isMacOsPlugin(plugin, fileSystem);
+ }
+ if (isWindows) {
+ return isWindowsPlugin(plugin, fileSystem);
+ }
+ if (isIOS) {
+ return isIosPlugin(plugin, fileSystem);
+ }
+ if (isAndroid) {
+ return (isAndroidPlugin(plugin, fileSystem));
+ }
+ // When we are here, no flags are specified. Only return true if the plugin supports mobile for legacy command support.
+ // TODO(cyanglaz): Make mobile platforms flags also required like other platforms (breaking change).
+ // https://github.com/flutter/flutter/issues/58285
+ final bool isMobilePlugin =
+ isIosPlugin(plugin, fileSystem) || isAndroidPlugin(plugin, fileSystem);
+ return isMobilePlugin;
+ }
+}
diff --git a/script/tool/lib/src/firebase_test_lab_command.dart b/script/tool/lib/src/firebase_test_lab_command.dart
new file mode 100644
index 0000000..0b4b2a4
--- /dev/null
+++ b/script/tool/lib/src/firebase_test_lab_command.dart
@@ -0,0 +1,264 @@
+// Copyright 2018 The Chromium 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:async';
+import 'dart:io' as io;
+
+import 'package:file/file.dart';
+import 'package:path/path.dart' as p;
+import 'package:uuid/uuid.dart';
+
+import 'common.dart';
+
+class FirebaseTestLabCommand extends PluginCommand {
+ FirebaseTestLabCommand(
+ Directory packagesDir,
+ FileSystem fileSystem, {
+ ProcessRunner processRunner = const ProcessRunner(),
+ Print print = print,
+ }) : _print = print,
+ super(packagesDir, fileSystem, processRunner: processRunner) {
+ argParser.addOption(
+ 'project',
+ defaultsTo: 'flutter-infra',
+ help: 'The Firebase project name.',
+ );
+ argParser.addOption('service-key',
+ defaultsTo:
+ p.join(io.Platform.environment['HOME'], 'gcloud-service-key.json'));
+ argParser.addOption('test-run-id',
+ defaultsTo: Uuid().v4(),
+ help:
+ 'Optional string to append to the results path, to avoid conflicts. '
+ 'Randomly chosen on each invocation if none is provided. '
+ 'The default shown here is just an example.');
+ argParser.addMultiOption('device',
+ splitCommas: false,
+ defaultsTo: <String>[
+ 'model=walleye,version=26',
+ 'model=flame,version=29'
+ ],
+ help:
+ 'Device model(s) to test. See https://cloud.google.com/sdk/gcloud/reference/firebase/test/android/run for more info');
+ argParser.addOption('results-bucket',
+ defaultsTo: 'gs://flutter_firebase_testlab');
+ argParser.addOption(
+ kEnableExperiment,
+ defaultsTo: '',
+ help: 'Enables the given Dart SDK experiments.',
+ );
+ }
+
+ @override
+ final String name = 'firebase-test-lab';
+
+ @override
+ final String description = 'Runs the instrumentation tests of the example '
+ 'apps on Firebase Test Lab.\n\n'
+ 'Runs tests in test_instrumentation folder using the '
+ 'instrumentation_test package.';
+
+ static const String _gradleWrapper = 'gradlew';
+
+ final Print _print;
+
+ Completer<void> _firebaseProjectConfigured;
+
+ Future<void> _configureFirebaseProject() async {
+ if (_firebaseProjectConfigured != null) {
+ return _firebaseProjectConfigured.future;
+ } else {
+ _firebaseProjectConfigured = Completer<void>();
+ }
+ await processRunner.runAndExitOnError('gcloud', <String>[
+ 'auth',
+ 'activate-service-account',
+ '--key-file=${argResults['service-key']}',
+ ]);
+ int exitCode = await processRunner.runAndStream('gcloud', <String>[
+ 'config',
+ 'set',
+ 'project',
+ argResults['project'],
+ ]);
+ if (exitCode == 0) {
+ _print('\nFirebase project configured.');
+ return;
+ } else {
+ _print(
+ '\nWarning: gcloud config set returned a non-zero exit code. Continuing anyway.');
+ }
+ _firebaseProjectConfigured.complete(null);
+ }
+
+ @override
+ Future<Null> run() async {
+ checkSharding();
+ final Stream<Directory> packagesWithTests = getPackages().where(
+ (Directory d) =>
+ isFlutterPackage(d, fileSystem) &&
+ fileSystem
+ .directory(p.join(
+ d.path, 'example', 'android', 'app', 'src', 'androidTest'))
+ .existsSync());
+
+ final List<String> failingPackages = <String>[];
+ final List<String> missingFlutterBuild = <String>[];
+ int resultsCounter =
+ 0; // We use a unique GCS bucket for each Firebase Test Lab run
+ await for (Directory package in packagesWithTests) {
+ // See https://github.com/flutter/flutter/issues/38983
+
+ final Directory exampleDirectory =
+ fileSystem.directory(p.join(package.path, 'example'));
+ final String packageName =
+ p.relative(package.path, from: packagesDir.path);
+ _print('\nRUNNING FIREBASE TEST LAB TESTS for $packageName');
+
+ final Directory androidDirectory =
+ fileSystem.directory(p.join(exampleDirectory.path, 'android'));
+
+ final String enableExperiment = argResults[kEnableExperiment];
+ final String encodedEnableExperiment =
+ Uri.encodeComponent('--enable-experiment=$enableExperiment');
+
+ // Ensures that gradle wrapper exists
+ if (!fileSystem
+ .file(p.join(androidDirectory.path, _gradleWrapper))
+ .existsSync()) {
+ final int exitCode = await processRunner.runAndStream(
+ 'flutter',
+ <String>[
+ 'build',
+ 'apk',
+ if (enableExperiment.isNotEmpty)
+ '--enable-experiment=$enableExperiment',
+ ],
+ workingDir: androidDirectory);
+
+ if (exitCode != 0) {
+ failingPackages.add(packageName);
+ continue;
+ }
+ continue;
+ }
+
+ await _configureFirebaseProject();
+
+ int exitCode = await processRunner.runAndStream(
+ p.join(androidDirectory.path, _gradleWrapper),
+ <String>[
+ 'app:assembleAndroidTest',
+ '-Pverbose=true',
+ if (enableExperiment.isNotEmpty)
+ '-Pextra-front-end-options=$encodedEnableExperiment',
+ if (enableExperiment.isNotEmpty)
+ '-Pextra-gen-snapshot-options=$encodedEnableExperiment',
+ ],
+ workingDir: androidDirectory);
+
+ if (exitCode != 0) {
+ failingPackages.add(packageName);
+ continue;
+ }
+
+ // Look for tests recursively in folders that start with 'test' and that
+ // live in the root or example folders.
+ bool isTestDir(FileSystemEntity dir) {
+ return p.basename(dir.path).startsWith('test') ||
+ p.basename(dir.path) == 'integration_test';
+ }
+
+ final List<FileSystemEntity> testDirs =
+ package.listSync().where(isTestDir).toList();
+ final Directory example =
+ fileSystem.directory(p.join(package.path, 'example'));
+ testDirs.addAll(example.listSync().where(isTestDir).toList());
+ for (Directory testDir in testDirs) {
+ bool isE2ETest(FileSystemEntity file) {
+ return file.path.endsWith('_e2e.dart') ||
+ (file.parent.basename == 'integration_test' &&
+ file.path.endsWith('_test.dart'));
+ }
+
+ final List<FileSystemEntity> testFiles = testDir
+ .listSync(recursive: true, followLinks: true)
+ .where(isE2ETest)
+ .toList();
+ for (FileSystemEntity test in testFiles) {
+ exitCode = await processRunner.runAndStream(
+ p.join(androidDirectory.path, _gradleWrapper),
+ <String>[
+ 'app:assembleDebug',
+ '-Pverbose=true',
+ '-Ptarget=${test.path}',
+ if (enableExperiment.isNotEmpty)
+ '-Pextra-front-end-options=$encodedEnableExperiment',
+ if (enableExperiment.isNotEmpty)
+ '-Pextra-gen-snapshot-options=$encodedEnableExperiment',
+ ],
+ workingDir: androidDirectory);
+
+ if (exitCode != 0) {
+ failingPackages.add(packageName);
+ continue;
+ }
+ final String buildId = io.Platform.environment['CIRRUS_BUILD_ID'];
+ final String testRunId = argResults['test-run-id'];
+ final String resultsDir =
+ 'plugins_android_test/$packageName/$buildId/$testRunId/${resultsCounter++}/';
+ final List<String> args = <String>[
+ 'firebase',
+ 'test',
+ 'android',
+ 'run',
+ '--type',
+ 'instrumentation',
+ '--app',
+ 'build/app/outputs/apk/debug/app-debug.apk',
+ '--test',
+ 'build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk',
+ '--timeout',
+ '5m',
+ '--results-bucket=${argResults['results-bucket']}',
+ '--results-dir=${resultsDir}',
+ ];
+ for (String device in argResults['device']) {
+ args.addAll(<String>['--device', device]);
+ }
+ exitCode = await processRunner.runAndStream('gcloud', args,
+ workingDir: exampleDirectory);
+
+ if (exitCode != 0) {
+ failingPackages.add(packageName);
+ continue;
+ }
+ }
+ }
+ }
+
+ _print('\n\n');
+ if (failingPackages.isNotEmpty) {
+ _print(
+ 'The instrumentation tests for the following packages are failing (see above for'
+ 'details):');
+ for (String package in failingPackages) {
+ _print(' * $package');
+ }
+ }
+ if (missingFlutterBuild.isNotEmpty) {
+ _print('Run "pub global run flutter_plugin_tools build-examples --apk" on'
+ 'the following packages before executing tests again:');
+ for (String package in missingFlutterBuild) {
+ _print(' * $package');
+ }
+ }
+
+ if (failingPackages.isNotEmpty || missingFlutterBuild.isNotEmpty) {
+ throw ToolExit(1);
+ }
+
+ _print('All Firebase Test Lab tests successful!');
+ }
+}
diff --git a/script/tool/lib/src/format_command.dart b/script/tool/lib/src/format_command.dart
new file mode 100644
index 0000000..ec326b9
--- /dev/null
+++ b/script/tool/lib/src/format_command.dart
@@ -0,0 +1,147 @@
+// Copyright 2017 The Chromium 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:async';
+import 'dart:convert';
+import 'dart:io' as io;
+
+import 'package:file/file.dart';
+import 'package:http/http.dart' as http;
+import 'package:path/path.dart' as p;
+import 'package:quiver/iterables.dart';
+
+import 'common.dart';
+
+const String _googleFormatterUrl =
+ 'https://github.com/google/google-java-format/releases/download/google-java-format-1.3/google-java-format-1.3-all-deps.jar';
+
+class FormatCommand extends PluginCommand {
+ FormatCommand(
+ Directory packagesDir,
+ FileSystem fileSystem, {
+ ProcessRunner processRunner = const ProcessRunner(),
+ }) : super(packagesDir, fileSystem, processRunner: processRunner) {
+ argParser.addFlag('travis', hide: true);
+ argParser.addOption('clang-format',
+ defaultsTo: 'clang-format',
+ help: 'Path to executable of clang-format v5.');
+ }
+
+ @override
+ final String name = 'format';
+
+ @override
+ final String description =
+ 'Formats the code of all packages (Java, Objective-C, C++, and Dart).\n\n'
+ 'This command requires "git", "flutter" and "clang-format" v5 to be in '
+ 'your path.';
+
+ @override
+ Future<Null> run() async {
+ checkSharding();
+ final String googleFormatterPath = await _getGoogleFormatterPath();
+
+ await _formatDart();
+ await _formatJava(googleFormatterPath);
+ await _formatCppAndObjectiveC();
+
+ if (argResults['travis']) {
+ final bool modified = await _didModifyAnything();
+ if (modified) {
+ throw ToolExit(1);
+ }
+ }
+ }
+
+ Future<bool> _didModifyAnything() async {
+ final io.ProcessResult modifiedFiles = await processRunner
+ .runAndExitOnError('git', <String>['ls-files', '--modified'],
+ workingDir: packagesDir);
+
+ print('\n\n');
+
+ if (modifiedFiles.stdout.isEmpty) {
+ print('All files formatted correctly.');
+ return false;
+ }
+
+ print('These files are not formatted correctly (see diff below):');
+ LineSplitter.split(modifiedFiles.stdout)
+ .map((String line) => ' $line')
+ .forEach(print);
+
+ print('\nTo fix run "pub global activate flutter_plugin_tools && '
+ 'pub global run flutter_plugin_tools format" or copy-paste '
+ 'this command into your terminal:');
+
+ print('patch -p1 <<DONE');
+ final io.ProcessResult diff = await processRunner
+ .runAndExitOnError('git', <String>['diff'], workingDir: packagesDir);
+ print(diff.stdout);
+ print('DONE');
+ return true;
+ }
+
+ Future<Null> _formatCppAndObjectiveC() async {
+ print('Formatting all .cc, .cpp, .mm, .m, and .h files...');
+ final Iterable<String> allFiles = <String>[]
+ ..addAll(await _getFilesWithExtension('.h'))
+ ..addAll(await _getFilesWithExtension('.m'))
+ ..addAll(await _getFilesWithExtension('.mm'))
+ ..addAll(await _getFilesWithExtension('.cc'))
+ ..addAll(await _getFilesWithExtension('.cpp'));
+ // Split this into multiple invocations to avoid a
+ // 'ProcessException: Argument list too long'.
+ final Iterable<List<String>> batches = partition(allFiles, 100);
+ for (List<String> batch in batches) {
+ await processRunner.runAndStream(argResults['clang-format'],
+ <String>['-i', '--style=Google']..addAll(batch),
+ workingDir: packagesDir, exitOnError: true);
+ }
+ }
+
+ Future<Null> _formatJava(String googleFormatterPath) async {
+ print('Formatting all .java files...');
+ final Iterable<String> javaFiles = await _getFilesWithExtension('.java');
+ await processRunner.runAndStream('java',
+ <String>['-jar', googleFormatterPath, '--replace']..addAll(javaFiles),
+ workingDir: packagesDir, exitOnError: true);
+ }
+
+ Future<Null> _formatDart() async {
+ // This actually should be fine for non-Flutter Dart projects, no need to
+ // specifically shell out to dartfmt -w in that case.
+ print('Formatting all .dart files...');
+ final Iterable<String> dartFiles = await _getFilesWithExtension('.dart');
+ if (dartFiles.isEmpty) {
+ print(
+ 'No .dart files to format. If you set the `--exclude` flag, most likey they were skipped');
+ } else {
+ await processRunner.runAndStream(
+ 'flutter', <String>['format']..addAll(dartFiles),
+ workingDir: packagesDir, exitOnError: true);
+ }
+ }
+
+ Future<List<String>> _getFilesWithExtension(String extension) async =>
+ getFiles()
+ .where((File file) => p.extension(file.path) == extension)
+ .map((File file) => file.path)
+ .toList();
+
+ Future<String> _getGoogleFormatterPath() async {
+ final String javaFormatterPath = p.join(
+ p.dirname(p.fromUri(io.Platform.script)),
+ 'google-java-format-1.3-all-deps.jar');
+ final File javaFormatterFile = fileSystem.file(javaFormatterPath);
+
+ if (!javaFormatterFile.existsSync()) {
+ print('Downloading Google Java Format...');
+ final http.Response response = await http.get(_googleFormatterUrl);
+ javaFormatterFile.writeAsBytesSync(response.bodyBytes);
+ }
+
+ return javaFormatterPath;
+ }
+}
diff --git a/script/tool/lib/src/java_test_command.dart b/script/tool/lib/src/java_test_command.dart
new file mode 100644
index 0000000..cf605bf
--- /dev/null
+++ b/script/tool/lib/src/java_test_command.dart
@@ -0,0 +1,89 @@
+// Copyright 2018 The Chromium 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:async';
+
+import 'package:file/file.dart';
+import 'package:path/path.dart' as p;
+
+import 'common.dart';
+
+class JavaTestCommand extends PluginCommand {
+ JavaTestCommand(
+ Directory packagesDir,
+ FileSystem fileSystem, {
+ ProcessRunner processRunner = const ProcessRunner(),
+ }) : super(packagesDir, fileSystem, processRunner: processRunner);
+
+ @override
+ final String name = 'java-test';
+
+ @override
+ final String description = 'Runs the Java tests of the example apps.\n\n'
+ 'Building the apks of the example apps is required before executing this'
+ 'command.';
+
+ static const String _gradleWrapper = 'gradlew';
+
+ @override
+ Future<Null> run() async {
+ checkSharding();
+ final Stream<Directory> examplesWithTests = getExamples().where(
+ (Directory d) =>
+ isFlutterPackage(d, fileSystem) &&
+ fileSystem
+ .directory(p.join(d.path, 'android', 'app', 'src', 'test'))
+ .existsSync());
+
+ final List<String> failingPackages = <String>[];
+ final List<String> missingFlutterBuild = <String>[];
+ await for (Directory example in examplesWithTests) {
+ final String packageName =
+ p.relative(example.path, from: packagesDir.path);
+ print('\nRUNNING JAVA TESTS for $packageName');
+
+ final Directory androidDirectory =
+ fileSystem.directory(p.join(example.path, 'android'));
+ if (!fileSystem
+ .file(p.join(androidDirectory.path, _gradleWrapper))
+ .existsSync()) {
+ print('ERROR: Run "flutter build apk" on example app of $packageName'
+ 'before executing tests.');
+ missingFlutterBuild.add(packageName);
+ continue;
+ }
+
+ final int exitCode = await processRunner.runAndStream(
+ p.join(androidDirectory.path, _gradleWrapper),
+ <String>['testDebugUnitTest', '--info'],
+ workingDir: androidDirectory);
+ if (exitCode != 0) {
+ failingPackages.add(packageName);
+ }
+ }
+
+ print('\n\n');
+ if (failingPackages.isNotEmpty) {
+ print(
+ 'The Java tests for the following packages are failing (see above for'
+ 'details):');
+ for (String package in failingPackages) {
+ print(' * $package');
+ }
+ }
+ if (missingFlutterBuild.isNotEmpty) {
+ print('Run "pub global run flutter_plugin_tools build-examples --apk" on'
+ 'the following packages before executing tests again:');
+ for (String package in missingFlutterBuild) {
+ print(' * $package');
+ }
+ }
+
+ if (failingPackages.isNotEmpty || missingFlutterBuild.isNotEmpty) {
+ throw ToolExit(1);
+ }
+
+ print('All Java tests successful!');
+ }
+}
diff --git a/script/tool/lib/src/lint_podspecs_command.dart b/script/tool/lib/src/lint_podspecs_command.dart
new file mode 100644
index 0000000..68fd4b6
--- /dev/null
+++ b/script/tool/lib/src/lint_podspecs_command.dart
@@ -0,0 +1,146 @@
+// Copyright 2017 The Chromium 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:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:file/file.dart';
+import 'package:path/path.dart' as p;
+import 'package:platform/platform.dart';
+
+import 'common.dart';
+
+typedef void Print(Object object);
+
+/// Lint the CocoaPod podspecs, run the static analyzer on iOS/macOS plugin
+/// platform code, and run unit tests.
+///
+/// See https://guides.cocoapods.org/terminal/commands.html#pod_lib_lint.
+class LintPodspecsCommand extends PluginCommand {
+ LintPodspecsCommand(
+ Directory packagesDir,
+ FileSystem fileSystem, {
+ ProcessRunner processRunner = const ProcessRunner(),
+ this.platform = const LocalPlatform(),
+ Print print = print,
+ }) : _print = print,
+ super(packagesDir, fileSystem, processRunner: processRunner) {
+ argParser.addMultiOption('skip',
+ help:
+ 'Skip all linting for podspecs with this basename (example: federated plugins with placeholder podspecs)',
+ valueHelp: 'podspec_file_name');
+ argParser.addMultiOption('ignore-warnings',
+ help:
+ 'Do not pass --allow-warnings flag to "pod lib lint" for podspecs with this basename (example: plugins with known warnings)',
+ valueHelp: 'podspec_file_name');
+ argParser.addMultiOption('no-analyze',
+ help:
+ 'Do not pass --analyze flag to "pod lib lint" for podspecs with this basename (example: plugins with known analyzer warnings)',
+ valueHelp: 'podspec_file_name');
+ }
+
+ @override
+ final String name = 'podspecs';
+
+ @override
+ List<String> get aliases => <String>['podspec'];
+
+ @override
+ final String description =
+ 'Runs "pod lib lint" on all iOS and macOS plugin podspecs.\n\n'
+ 'This command requires "pod" and "flutter" to be in your path. Runs on macOS only.';
+
+ final Platform platform;
+
+ final Print _print;
+
+ @override
+ Future<Null> run() async {
+ if (!platform.isMacOS) {
+ _print('Detected platform is not macOS, skipping podspec lint');
+ return;
+ }
+
+ checkSharding();
+
+ await processRunner.runAndExitOnError('which', <String>['pod'],
+ workingDir: packagesDir);
+
+ _print('Starting podspec lint test');
+
+ final List<String> failingPlugins = <String>[];
+ for (File podspec in await _podspecsToLint()) {
+ if (!await _lintPodspec(podspec)) {
+ failingPlugins.add(p.basenameWithoutExtension(podspec.path));
+ }
+ }
+
+ _print('\n\n');
+ if (failingPlugins.isNotEmpty) {
+ _print('The following plugins have podspec errors (see above):');
+ failingPlugins.forEach((String plugin) {
+ _print(' * $plugin');
+ });
+ throw ToolExit(1);
+ }
+ }
+
+ Future<List<File>> _podspecsToLint() async {
+ final List<File> podspecs = await getFiles().where((File entity) {
+ final String filePath = entity.path;
+ return p.extension(filePath) == '.podspec' &&
+ !argResults['skip'].contains(p.basenameWithoutExtension(filePath));
+ }).toList();
+
+ podspecs.sort(
+ (File a, File b) => p.basename(a.path).compareTo(p.basename(b.path)));
+ return podspecs;
+ }
+
+ Future<bool> _lintPodspec(File podspec) async {
+ // Do not run the static analyzer on plugins with known analyzer issues.
+ final String podspecPath = podspec.path;
+ final bool runAnalyzer = !argResults['no-analyze']
+ .contains(p.basenameWithoutExtension(podspecPath));
+
+ final String podspecBasename = p.basename(podspecPath);
+ if (runAnalyzer) {
+ _print('Linting and analyzing $podspecBasename');
+ } else {
+ _print('Linting $podspecBasename');
+ }
+
+ // Lint plugin as framework (use_frameworks!).
+ final ProcessResult frameworkResult = await _runPodLint(podspecPath,
+ runAnalyzer: runAnalyzer, libraryLint: true);
+ _print(frameworkResult.stdout);
+ _print(frameworkResult.stderr);
+
+ // Lint plugin as library.
+ final ProcessResult libraryResult = await _runPodLint(podspecPath,
+ runAnalyzer: runAnalyzer, libraryLint: false);
+ _print(libraryResult.stdout);
+ _print(libraryResult.stderr);
+
+ return frameworkResult.exitCode == 0 && libraryResult.exitCode == 0;
+ }
+
+ Future<ProcessResult> _runPodLint(String podspecPath,
+ {bool runAnalyzer, bool libraryLint}) async {
+ final bool allowWarnings = argResults['ignore-warnings']
+ .contains(p.basenameWithoutExtension(podspecPath));
+ final List<String> arguments = <String>[
+ 'lib',
+ 'lint',
+ podspecPath,
+ if (allowWarnings) '--allow-warnings',
+ if (runAnalyzer) '--analyze',
+ if (libraryLint) '--use-libraries'
+ ];
+
+ return processRunner.run('pod', arguments,
+ workingDir: packagesDir, stdoutEncoding: utf8, stderrEncoding: utf8);
+ }
+}
diff --git a/script/tool/lib/src/list_command.dart b/script/tool/lib/src/list_command.dart
new file mode 100644
index 0000000..7f94daa
--- /dev/null
+++ b/script/tool/lib/src/list_command.dart
@@ -0,0 +1,60 @@
+// Copyright 2018 The Chromium 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:async';
+
+import 'package:file/file.dart';
+
+import 'common.dart';
+
+class ListCommand extends PluginCommand {
+ ListCommand(Directory packagesDir, FileSystem fileSystem)
+ : super(packagesDir, fileSystem) {
+ argParser.addOption(
+ _type,
+ defaultsTo: _plugin,
+ allowed: <String>[_plugin, _example, _package, _file],
+ help: 'What type of file system content to list.',
+ );
+ }
+
+ static const String _type = 'type';
+ static const String _plugin = 'plugin';
+ static const String _example = 'example';
+ static const String _package = 'package';
+ static const String _file = 'file';
+
+ @override
+ final String name = 'list';
+
+ @override
+ final String description = 'Lists packages or files';
+
+ @override
+ Future<Null> run() async {
+ checkSharding();
+ switch (argResults[_type]) {
+ case _plugin:
+ await for (Directory package in getPlugins()) {
+ print(package.path);
+ }
+ break;
+ case _example:
+ await for (Directory package in getExamples()) {
+ print(package.path);
+ }
+ break;
+ case _package:
+ await for (Directory package in getPackages()) {
+ print(package.path);
+ }
+ break;
+ case _file:
+ await for (File file in getFiles()) {
+ print(file.path);
+ }
+ break;
+ }
+ }
+}
diff --git a/script/tool/lib/src/main.dart b/script/tool/lib/src/main.dart
new file mode 100644
index 0000000..bb3f67c
--- /dev/null
+++ b/script/tool/lib/src/main.dart
@@ -0,0 +1,63 @@
+// Copyright 2017 The Chromium 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:args/command_runner.dart';
+import 'package:file/file.dart';
+import 'package:file/local.dart';
+import 'package:flutter_plugin_tools/src/publish_plugin_command.dart';
+import 'package:path/path.dart' as p;
+
+import 'analyze_command.dart';
+import 'build_examples_command.dart';
+import 'common.dart';
+import 'create_all_plugins_app_command.dart';
+import 'drive_examples_command.dart';
+import 'firebase_test_lab_command.dart';
+import 'format_command.dart';
+import 'java_test_command.dart';
+import 'lint_podspecs_command.dart';
+import 'list_command.dart';
+import 'test_command.dart';
+import 'version_check_command.dart';
+import 'xctest_command.dart';
+
+void main(List<String> args) {
+ final FileSystem fileSystem = const LocalFileSystem();
+
+ Directory packagesDir = fileSystem
+ .directory(p.join(fileSystem.currentDirectory.path, 'packages'));
+
+ if (!packagesDir.existsSync()) {
+ if (p.basename(fileSystem.currentDirectory.path) == 'packages') {
+ packagesDir = fileSystem.currentDirectory;
+ } else {
+ print('Error: Cannot find a "packages" sub-directory');
+ io.exit(1);
+ }
+ }
+
+ final CommandRunner<Null> commandRunner = CommandRunner<Null>(
+ 'pub global run flutter_plugin_tools',
+ 'Productivity utils for hosting multiple plugins within one repository.')
+ ..addCommand(AnalyzeCommand(packagesDir, fileSystem))
+ ..addCommand(BuildExamplesCommand(packagesDir, fileSystem))
+ ..addCommand(CreateAllPluginsAppCommand(packagesDir, fileSystem))
+ ..addCommand(DriveExamplesCommand(packagesDir, fileSystem))
+ ..addCommand(FirebaseTestLabCommand(packagesDir, fileSystem))
+ ..addCommand(FormatCommand(packagesDir, fileSystem))
+ ..addCommand(JavaTestCommand(packagesDir, fileSystem))
+ ..addCommand(LintPodspecsCommand(packagesDir, fileSystem))
+ ..addCommand(ListCommand(packagesDir, fileSystem))
+ ..addCommand(PublishPluginCommand(packagesDir, fileSystem))
+ ..addCommand(TestCommand(packagesDir, fileSystem))
+ ..addCommand(VersionCheckCommand(packagesDir, fileSystem))
+ ..addCommand(XCTestCommand(packagesDir, fileSystem));
+
+ commandRunner.run(args).catchError((Object e) {
+ final ToolExit toolExit = e;
+ io.exit(toolExit.exitCode);
+ }, test: (Object e) => e is ToolExit);
+}
diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart
new file mode 100644
index 0000000..f7e3b5d
--- /dev/null
+++ b/script/tool/lib/src/publish_plugin_command.dart
@@ -0,0 +1,223 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:file/file.dart';
+import 'package:git/git.dart';
+import 'package:meta/meta.dart';
+import 'package:path/path.dart' as p;
+import 'package:yaml/yaml.dart';
+
+import 'common.dart';
+
+/// Wraps pub publish with a few niceties used by the flutter/plugin team.
+///
+/// 1. Checks for any modified files in git and refuses to publish if there's an
+/// issue.
+/// 2. Tags the release with the format <package-name>-v<package-version>.
+/// 3. Pushes the release to a remote.
+///
+/// Both 2 and 3 are optional, see `plugin_tools help publish-plugin` for full
+/// usage information.
+///
+/// [processRunner], [print], and [stdin] can be overriden for easier testing.
+class PublishPluginCommand extends PluginCommand {
+ PublishPluginCommand(
+ Directory packagesDir,
+ FileSystem fileSystem, {
+ ProcessRunner processRunner = const ProcessRunner(),
+ Print print = print,
+ Stdin stdinput,
+ }) : _print = print,
+ _stdin = stdinput ?? stdin,
+ super(packagesDir, fileSystem, processRunner: processRunner) {
+ argParser.addOption(
+ _packageOption,
+ help: 'The package to publish.'
+ 'If the package directory name is different than its pubspec.yaml name, then this should specify the directory.',
+ );
+ argParser.addMultiOption(_pubFlagsOption,
+ help:
+ 'A list of options that will be forwarded on to pub. Separate multiple flags with commas.');
+ argParser.addFlag(
+ _tagReleaseOption,
+ help: 'Whether or not to tag the release.',
+ defaultsTo: true,
+ negatable: true,
+ );
+ argParser.addFlag(
+ _pushTagsOption,
+ help:
+ 'Whether or not tags should be pushed to a remote after creation. Ignored if tag-release is false.',
+ defaultsTo: true,
+ negatable: true,
+ );
+ argParser.addOption(
+ _remoteOption,
+ help:
+ 'The name of the remote to push the tags to. Ignored if push-tags or tag-release is false.',
+ // Flutter convention is to use "upstream" for the single source of truth, and "origin" for personal forks.
+ defaultsTo: 'upstream',
+ );
+ }
+
+ static const String _packageOption = 'package';
+ static const String _tagReleaseOption = 'tag-release';
+ static const String _pushTagsOption = 'push-tags';
+ static const String _pubFlagsOption = 'pub-publish-flags';
+ static const String _remoteOption = 'remote';
+
+ // Version tags should follow <package-name>-v<semantic-version>. For example,
+ // `flutter_plugin_tools-v0.0.24`.
+ static const String _tagFormat = '%PACKAGE%-v%VERSION%';
+
+ @override
+ final String name = 'publish-plugin';
+
+ @override
+ final String description =
+ 'Attempts to publish the given plugin and tag its release on GitHub.';
+
+ final Print _print;
+ final Stdin _stdin;
+ // The directory of the actual package that we are publishing.
+ Directory _packageDir;
+ StreamSubscription<String> _stdinSubscription;
+
+ @override
+ Future<Null> run() async {
+ checkSharding();
+ _print('Checking local repo...');
+ _packageDir = _checkPackageDir();
+ await _checkGitStatus();
+ final bool shouldPushTag = argResults[_pushTagsOption];
+ final String remote = argResults[_remoteOption];
+ String remoteUrl;
+ if (shouldPushTag) {
+ remoteUrl = await _verifyRemote(remote);
+ }
+ _print('Local repo is ready!');
+
+ await _publish();
+ _print('Package published!');
+ if (!argResults[_tagReleaseOption]) {
+ return await _finishSuccesfully();
+ }
+
+ _print('Tagging release...');
+ final String tag = _getTag();
+ await processRunner.runAndExitOnError('git', <String>['tag', tag],
+ workingDir: _packageDir);
+ if (!shouldPushTag) {
+ return await _finishSuccesfully();
+ }
+
+ _print('Pushing tag to $remote...');
+ await _pushTagToRemote(remote: remote, tag: tag, remoteUrl: remoteUrl);
+ await _finishSuccesfully();
+ }
+
+ Future<void> _finishSuccesfully() async {
+ await _stdinSubscription.cancel();
+ _print('Done!');
+ }
+
+ Directory _checkPackageDir() {
+ final String package = argResults[_packageOption];
+ if (package == null) {
+ _print(
+ 'Must specify a package to publish. See `plugin_tools help publish-plugin`.');
+ throw ToolExit(1);
+ }
+ final Directory _packageDir = packagesDir.childDirectory(package);
+ if (!_packageDir.existsSync()) {
+ _print('${_packageDir.absolute.path} does not exist.');
+ throw ToolExit(1);
+ }
+ return _packageDir;
+ }
+
+ Future<void> _checkGitStatus() async {
+ if (!await GitDir.isGitDir(packagesDir.path)) {
+ _print('$packagesDir is not a valid Git repository.');
+ throw ToolExit(1);
+ }
+
+ final ProcessResult statusResult = await processRunner.runAndExitOnError(
+ 'git',
+ <String>[
+ 'status',
+ '--porcelain',
+ '--ignored',
+ _packageDir.absolute.path
+ ],
+ workingDir: _packageDir);
+ final String statusOutput = statusResult.stdout;
+ if (statusOutput.isNotEmpty) {
+ _print(
+ "There are files in the package directory that haven't been saved in git. Refusing to publish these files:\n\n"
+ '$statusOutput\n'
+ 'If the directory should be clean, you can run `git clean -xdf && git reset --hard HEAD` to wipe all local changes.');
+ throw ToolExit(1);
+ }
+ }
+
+ Future<String> _verifyRemote(String remote) async {
+ final ProcessResult remoteInfo = await processRunner.runAndExitOnError(
+ 'git', <String>['remote', 'get-url', remote],
+ workingDir: _packageDir);
+ return remoteInfo.stdout;
+ }
+
+ Future<void> _publish() async {
+ final List<String> publishFlags = argResults[_pubFlagsOption];
+ _print(
+ 'Running `pub publish ${publishFlags.join(' ')}` in ${_packageDir.absolute.path}...\n');
+ final Process publish = await processRunner.start(
+ 'flutter', <String>['pub', 'publish'] + publishFlags,
+ workingDirectory: _packageDir);
+ publish.stdout
+ .transform(utf8.decoder)
+ .listen((String data) => _print(data));
+ publish.stderr
+ .transform(utf8.decoder)
+ .listen((String data) => _print(data));
+ _stdinSubscription = _stdin
+ .transform(utf8.decoder)
+ .listen((String data) => publish.stdin.writeln(data));
+ final int result = await publish.exitCode;
+ if (result != 0) {
+ _print('Publish failed. Exiting.');
+ throw ToolExit(result);
+ }
+ }
+
+ String _getTag() {
+ final File pubspecFile =
+ fileSystem.file(p.join(_packageDir.path, 'pubspec.yaml'));
+ final YamlMap pubspecYaml = loadYaml(pubspecFile.readAsStringSync());
+ final String name = pubspecYaml['name'];
+ final String version = pubspecYaml['version'];
+ // We should have failed to publish if these were unset.
+ assert(name.isNotEmpty && version.isNotEmpty);
+ return _tagFormat
+ .replaceAll('%PACKAGE%', name)
+ .replaceAll('%VERSION%', version);
+ }
+
+ Future<void> _pushTagToRemote(
+ {@required String remote,
+ @required String tag,
+ @required String remoteUrl}) async {
+ assert(remote != null && tag != null && remoteUrl != null);
+ _print('Ready to push $tag to $remoteUrl (y/n)?');
+ final String input = _stdin.readLineSync();
+ if (input.toLowerCase() != 'y') {
+ _print('Tag push canceled.');
+ throw ToolExit(1);
+ }
+
+ await processRunner.runAndExitOnError('git', <String>['push', remote, tag],
+ workingDir: packagesDir);
+ }
+}
diff --git a/script/tool/lib/src/test_command.dart b/script/tool/lib/src/test_command.dart
new file mode 100644
index 0000000..e938168
--- /dev/null
+++ b/script/tool/lib/src/test_command.dart
@@ -0,0 +1,101 @@
+// Copyright 2017 The Chromium 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:async';
+
+import 'package:file/file.dart';
+import 'package:path/path.dart' as p;
+
+import 'common.dart';
+
+class TestCommand extends PluginCommand {
+ TestCommand(
+ Directory packagesDir,
+ FileSystem fileSystem, {
+ ProcessRunner processRunner = const ProcessRunner(),
+ }) : super(packagesDir, fileSystem, processRunner: processRunner) {
+ argParser.addOption(
+ kEnableExperiment,
+ defaultsTo: '',
+ help: 'Runs the tests in Dart VM with the given experiments enabled.',
+ );
+ }
+
+ @override
+ final String name = 'test';
+
+ @override
+ final String description = 'Runs the Dart tests for all packages.\n\n'
+ 'This command requires "flutter" to be in your path.';
+
+ @override
+ Future<Null> run() async {
+ checkSharding();
+ final List<String> failingPackages = <String>[];
+ await for (Directory packageDir in getPackages()) {
+ final String packageName =
+ p.relative(packageDir.path, from: packagesDir.path);
+ if (!fileSystem.directory(p.join(packageDir.path, 'test')).existsSync()) {
+ print('SKIPPING $packageName - no test subdirectory');
+ continue;
+ }
+
+ print('RUNNING $packageName tests...');
+
+ final String enableExperiment = argResults[kEnableExperiment];
+
+ // `flutter test` automatically gets packages. `pub run test` does not. :(
+ int exitCode = 0;
+ if (isFlutterPackage(packageDir, fileSystem)) {
+ final List<String> args = <String>[
+ 'test',
+ '--color',
+ if (enableExperiment.isNotEmpty)
+ '--enable-experiment=$enableExperiment',
+ ];
+
+ if (isWebPlugin(packageDir, fileSystem)) {
+ args.add('--platform=chrome');
+ }
+ exitCode = await processRunner.runAndStream(
+ 'flutter',
+ args,
+ workingDir: packageDir,
+ );
+ } else {
+ exitCode = await processRunner.runAndStream(
+ 'pub',
+ <String>['get'],
+ workingDir: packageDir,
+ );
+ if (exitCode == 0) {
+ exitCode = await processRunner.runAndStream(
+ 'pub',
+ <String>[
+ 'run',
+ if (enableExperiment.isNotEmpty)
+ '--enable-experiment=$enableExperiment',
+ 'test',
+ ],
+ workingDir: packageDir,
+ );
+ }
+ }
+ if (exitCode != 0) {
+ failingPackages.add(packageName);
+ }
+ }
+
+ print('\n\n');
+ if (failingPackages.isNotEmpty) {
+ print('Tests for the following packages are failing (see above):');
+ failingPackages.forEach((String package) {
+ print(' * $package');
+ });
+ throw ToolExit(1);
+ }
+
+ print('All tests are passing!');
+ }
+}
diff --git a/script/tool/lib/src/version_check_command.dart b/script/tool/lib/src/version_check_command.dart
new file mode 100644
index 0000000..2c6b92b
--- /dev/null
+++ b/script/tool/lib/src/version_check_command.dart
@@ -0,0 +1,220 @@
+// Copyright 2017 The Chromium 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:async';
+import 'dart:io' as io;
+
+import 'package:meta/meta.dart';
+import 'package:colorize/colorize.dart';
+import 'package:file/file.dart';
+import 'package:git/git.dart';
+import 'package:pub_semver/pub_semver.dart';
+import 'package:pubspec_parse/pubspec_parse.dart';
+import 'package:yaml/yaml.dart';
+
+import 'common.dart';
+
+const String _kBaseSha = 'base_sha';
+
+class GitVersionFinder {
+ GitVersionFinder(this.baseGitDir, this.baseSha);
+
+ final GitDir baseGitDir;
+ final String baseSha;
+
+ static bool isPubspec(String file) {
+ return file.trim().endsWith('pubspec.yaml');
+ }
+
+ Future<List<String>> getChangedPubSpecs() async {
+ final io.ProcessResult changedFilesCommand = await baseGitDir
+ .runCommand(<String>['diff', '--name-only', '$baseSha', 'HEAD']);
+ final List<String> changedFiles =
+ changedFilesCommand.stdout.toString().split('\n');
+ return changedFiles.where(isPubspec).toList();
+ }
+
+ Future<Version> getPackageVersion(String pubspecPath, String gitRef) async {
+ final io.ProcessResult gitShow =
+ await baseGitDir.runCommand(<String>['show', '$gitRef:$pubspecPath']);
+ final String fileContent = gitShow.stdout;
+ final String versionString = loadYaml(fileContent)['version'];
+ return versionString == null ? null : Version.parse(versionString);
+ }
+}
+
+enum NextVersionType {
+ BREAKING_MAJOR,
+ MAJOR_NULLSAFETY_PRE_RELEASE,
+ MINOR_NULLSAFETY_PRE_RELEASE,
+ MINOR,
+ PATCH,
+ RELEASE,
+}
+
+Version getNextNullSafetyPreRelease(Version current, Version next) {
+ String nextNullsafetyPrerelease = 'nullsafety';
+ if (current.isPreRelease &&
+ current.preRelease.first is String &&
+ current.preRelease.first == 'nullsafety') {
+ if (current.preRelease.length == 1) {
+ nextNullsafetyPrerelease = 'nullsafety.1';
+ } else if (current.preRelease.length == 2 &&
+ current.preRelease.last is int) {
+ nextNullsafetyPrerelease = 'nullsafety.${current.preRelease.last + 1}';
+ }
+ }
+ return Version(
+ next.major,
+ next.minor,
+ next.patch,
+ pre: nextNullsafetyPrerelease,
+ );
+}
+
+@visibleForTesting
+Map<Version, NextVersionType> getAllowedNextVersions(
+ Version masterVersion, Version headVersion) {
+ final Version nextNullSafetyMajor =
+ getNextNullSafetyPreRelease(masterVersion, masterVersion.nextMajor);
+ final Version nextNullSafetyMinor =
+ getNextNullSafetyPreRelease(masterVersion, masterVersion.nextMinor);
+ final Map<Version, NextVersionType> allowedNextVersions =
+ <Version, NextVersionType>{
+ masterVersion.nextMajor: NextVersionType.BREAKING_MAJOR,
+ nextNullSafetyMajor: NextVersionType.MAJOR_NULLSAFETY_PRE_RELEASE,
+ nextNullSafetyMinor: NextVersionType.MINOR_NULLSAFETY_PRE_RELEASE,
+ masterVersion.nextMinor: NextVersionType.MINOR,
+ masterVersion.nextPatch: NextVersionType.PATCH,
+ };
+
+ if (masterVersion.major < 1 && headVersion.major < 1) {
+ int nextBuildNumber = -1;
+ if (masterVersion.build.isEmpty) {
+ nextBuildNumber = 1;
+ } else {
+ final int currentBuildNumber = masterVersion.build.first;
+ nextBuildNumber = currentBuildNumber + 1;
+ }
+ final Version preReleaseVersion = Version(
+ masterVersion.major,
+ masterVersion.minor,
+ masterVersion.patch,
+ build: nextBuildNumber.toString(),
+ );
+ allowedNextVersions.clear();
+ allowedNextVersions[masterVersion.nextMajor] = NextVersionType.RELEASE;
+ allowedNextVersions[masterVersion.nextMinor] =
+ NextVersionType.BREAKING_MAJOR;
+ allowedNextVersions[masterVersion.nextPatch] = NextVersionType.MINOR;
+ allowedNextVersions[preReleaseVersion] = NextVersionType.PATCH;
+
+ final Version nextNullSafetyMajor =
+ getNextNullSafetyPreRelease(masterVersion, masterVersion.nextMinor);
+ final Version nextNullSafetyMinor =
+ getNextNullSafetyPreRelease(masterVersion, masterVersion.nextPatch);
+
+ allowedNextVersions[nextNullSafetyMajor] =
+ NextVersionType.MAJOR_NULLSAFETY_PRE_RELEASE;
+ allowedNextVersions[nextNullSafetyMinor] =
+ NextVersionType.MINOR_NULLSAFETY_PRE_RELEASE;
+ }
+ return allowedNextVersions;
+}
+
+class VersionCheckCommand extends PluginCommand {
+ VersionCheckCommand(
+ Directory packagesDir,
+ FileSystem fileSystem, {
+ ProcessRunner processRunner = const ProcessRunner(),
+ this.gitDir,
+ }) : super(packagesDir, fileSystem, processRunner: processRunner) {
+ argParser.addOption(_kBaseSha);
+ }
+
+ /// The git directory to use. By default it uses the parent directory.
+ ///
+ /// This can be mocked for testing.
+ final GitDir gitDir;
+
+ @override
+ final String name = 'version-check';
+
+ @override
+ final String description =
+ 'Checks if the versions of the plugins have been incremented per pub specification.\n\n'
+ 'This command requires "pub" and "flutter" to be in your path.';
+
+ @override
+ Future<Null> run() async {
+ checkSharding();
+
+ final String rootDir = packagesDir.parent.absolute.path;
+ final String baseSha = argResults[_kBaseSha];
+
+ GitDir baseGitDir = gitDir;
+ if (baseGitDir == null) {
+ if (!await GitDir.isGitDir(rootDir)) {
+ print('$rootDir is not a valid Git repository.');
+ throw ToolExit(2);
+ }
+ baseGitDir = await GitDir.fromExisting(rootDir);
+ }
+
+ final GitVersionFinder gitVersionFinder =
+ GitVersionFinder(baseGitDir, baseSha);
+
+ final List<String> changedPubspecs =
+ await gitVersionFinder.getChangedPubSpecs();
+
+ for (final String pubspecPath in changedPubspecs) {
+ try {
+ final File pubspecFile = fileSystem.file(pubspecPath);
+ if (!pubspecFile.existsSync()) {
+ continue;
+ }
+ final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync());
+ if (pubspec.publishTo == 'none') {
+ continue;
+ }
+
+ final Version masterVersion =
+ await gitVersionFinder.getPackageVersion(pubspecPath, baseSha);
+ final Version headVersion =
+ await gitVersionFinder.getPackageVersion(pubspecPath, 'HEAD');
+ if (headVersion == null) {
+ continue; // Example apps don't have versions
+ }
+
+ final Map<Version, NextVersionType> allowedNextVersions =
+ getAllowedNextVersions(masterVersion, headVersion);
+
+ if (!allowedNextVersions.containsKey(headVersion)) {
+ final String error = '$pubspecPath incorrectly updated version.\n'
+ 'HEAD: $headVersion, master: $masterVersion.\n'
+ 'Allowed versions: $allowedNextVersions';
+ final Colorize redError = Colorize(error)..red();
+ print(redError);
+ throw ToolExit(1);
+ }
+
+ bool isPlatformInterface = pubspec.name.endsWith("_platform_interface");
+ if (isPlatformInterface &&
+ allowedNextVersions[headVersion] ==
+ NextVersionType.BREAKING_MAJOR) {
+ final String error = '$pubspecPath breaking change detected.\n'
+ 'Breaking changes to platform interfaces are strongly discouraged.\n';
+ final Colorize redError = Colorize(error)..red();
+ print(redError);
+ throw ToolExit(1);
+ }
+ } on io.ProcessException {
+ print('Unable to find pubspec in master for $pubspecPath.'
+ ' Safe to ignore if the project is new.');
+ }
+ }
+
+ print('No version check errors found!');
+ }
+}
diff --git a/script/tool/lib/src/xctest_command.dart b/script/tool/lib/src/xctest_command.dart
new file mode 100644
index 0000000..d90b7a8
--- /dev/null
+++ b/script/tool/lib/src/xctest_command.dart
@@ -0,0 +1,216 @@
+// Copyright 2017 The Chromium 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:async';
+import 'dart:convert';
+import 'dart:io' as io;
+
+import 'package:file/file.dart';
+import 'package:path/path.dart' as p;
+
+import 'common.dart';
+
+const String _kiOSDestination = 'ios-destination';
+const String _kTarget = 'target';
+const String _kSkip = 'skip';
+const String _kXcodeBuildCommand = 'xcodebuild';
+const String _kXCRunCommand = 'xcrun';
+const String _kFoundNoSimulatorsMessage =
+ 'Cannot find any available simulators, tests failed';
+
+/// The command to run iOS' XCTests in plugins, this should work for both XCUnitTest and XCUITest targets.
+/// The tests target have to be added to the xcode project of the example app. Usually at "example/ios/Runner.xcodeproj".
+/// The command takes a "-target" argument which has to match the target of the test target.
+/// For information on how to add test target in an xcode project, see https://developer.apple.com/library/archive/documentation/ToolsLanguages/Conceptual/Xcode_Overview/UnitTesting.html
+class XCTestCommand extends PluginCommand {
+ XCTestCommand(
+ Directory packagesDir,
+ FileSystem fileSystem, {
+ ProcessRunner processRunner = const ProcessRunner(),
+ }) : super(packagesDir, fileSystem, processRunner: processRunner) {
+ argParser.addOption(
+ _kiOSDestination,
+ help:
+ 'Specify the destination when running the test, used for -destination flag for xcodebuild command.\n'
+ 'this is passed to the `-destination` argument in xcodebuild command.\n'
+ 'See https://developer.apple.com/library/archive/technotes/tn2339/_index.html#//apple_ref/doc/uid/DTS40014588-CH1-UNIT for details on how to specify the destination.',
+ );
+ argParser.addOption(_kTarget,
+ help: 'The test target.\n'
+ 'This is the xcode project test target. This is passed to the `-scheme` argument in the xcodebuild command. \n'
+ 'See https://developer.apple.com/library/archive/technotes/tn2339/_index.html#//apple_ref/doc/uid/DTS40014588-CH1-UNIT for details on how to specify the scheme');
+ argParser.addMultiOption(_kSkip,
+ help: 'Plugins to skip while running this command. \n');
+ }
+
+ @override
+ final String name = 'xctest';
+
+ @override
+ final String description = 'Runs the xctests in the iOS example apps.\n\n'
+ 'This command requires "flutter" to be in your path.';
+
+ @override
+ Future<Null> run() async {
+ if (argResults[_kTarget] == null) {
+ // TODO(cyanglaz): Automatically find all the available testing schemes if this argument is not specified.
+ // https://github.com/flutter/flutter/issues/68419
+ print('--$_kTarget must be specified');
+ throw ToolExit(1);
+ }
+
+ String destination = argResults[_kiOSDestination];
+ if (destination == null) {
+ String simulatorId = await _findAvailableIphoneSimulator();
+ if (simulatorId == null) {
+ print(_kFoundNoSimulatorsMessage);
+ throw ToolExit(1);
+ }
+ destination = 'id=$simulatorId';
+ }
+
+ checkSharding();
+
+ final String target = argResults[_kTarget];
+ final List<String> skipped = argResults[_kSkip];
+
+ List<String> failingPackages = <String>[];
+ await for (Directory plugin in getPlugins()) {
+ // Start running for package.
+ final String packageName =
+ p.relative(plugin.path, from: packagesDir.path);
+ print('Start running for $packageName ...');
+ if (!isIosPlugin(plugin, fileSystem)) {
+ print('iOS is not supported by this plugin.');
+ print('\n\n');
+ continue;
+ }
+ if (skipped.contains(packageName)) {
+ print('$packageName was skipped with the --skip flag.');
+ print('\n\n');
+ continue;
+ }
+ for (Directory example in getExamplesForPlugin(plugin)) {
+ // Look for the test scheme in the example app.
+ print('Look for target named: $_kTarget ...');
+ final List<String> findSchemeArgs = <String>[
+ '-project',
+ 'ios/Runner.xcodeproj',
+ '-list',
+ '-json'
+ ];
+ final String completeFindSchemeCommand =
+ '$_kXcodeBuildCommand ${findSchemeArgs.join(' ')}';
+ print(completeFindSchemeCommand);
+ final io.ProcessResult xcodeprojListResult = await processRunner
+ .run(_kXcodeBuildCommand, findSchemeArgs, workingDir: example);
+ if (xcodeprojListResult.exitCode != 0) {
+ print('Error occurred while running "$completeFindSchemeCommand":\n'
+ '${xcodeprojListResult.stderr}');
+ failingPackages.add(packageName);
+ print('\n\n');
+ continue;
+ }
+
+ final String xcodeprojListOutput = xcodeprojListResult.stdout;
+ Map<String, dynamic> xcodeprojListOutputJson =
+ jsonDecode(xcodeprojListOutput);
+ if (!xcodeprojListOutputJson['project']['targets'].contains(target)) {
+ failingPackages.add(packageName);
+ print('$target not configured for $packageName, test failed.');
+ print(
+ 'Please check the scheme for the test target if it matches the name $target.\n'
+ 'If this plugin does not have an XCTest target, use the $_kSkip flag in the $name command to skip the plugin.');
+ print('\n\n');
+ continue;
+ }
+ // Found the scheme, running tests
+ print('Running XCTests:$target for $packageName ...');
+ final List<String> xctestArgs = <String>[
+ 'test',
+ '-workspace',
+ 'ios/Runner.xcworkspace',
+ '-scheme',
+ target,
+ '-destination',
+ destination,
+ 'CODE_SIGN_IDENTITY=""',
+ 'CODE_SIGNING_REQUIRED=NO'
+ ];
+ final String completeTestCommand =
+ '$_kXcodeBuildCommand ${xctestArgs.join(' ')}';
+ print(completeTestCommand);
+ final int exitCode = await processRunner
+ .runAndStream(_kXcodeBuildCommand, xctestArgs, workingDir: example);
+ if (exitCode == 0) {
+ print('Successfully ran xctest for $packageName');
+ } else {
+ failingPackages.add(packageName);
+ }
+ }
+ }
+
+ // Command end, print reports.
+ if (failingPackages.isEmpty) {
+ print("All XCTests have passed!");
+ } else {
+ print(
+ 'The following packages are failing XCTests (see above for details):');
+ for (String package in failingPackages) {
+ print(' * $package');
+ }
+ throw ToolExit(1);
+ }
+ }
+
+ Future<String> _findAvailableIphoneSimulator() async {
+ // Find the first available destination if not specified.
+ final List<String> findSimulatorsArguments = <String>[
+ 'simctl',
+ 'list',
+ '--json'
+ ];
+ final String findSimulatorCompleteCommand =
+ '$_kXCRunCommand ${findSimulatorsArguments.join(' ')}';
+ print('Looking for available simulators...');
+ print(findSimulatorCompleteCommand);
+ final io.ProcessResult findSimulatorsResult =
+ await processRunner.run(_kXCRunCommand, findSimulatorsArguments);
+ if (findSimulatorsResult.exitCode != 0) {
+ print('Error occurred while running "$findSimulatorCompleteCommand":\n'
+ '${findSimulatorsResult.stderr}');
+ throw ToolExit(1);
+ }
+ final Map<String, dynamic> simulatorListJson =
+ jsonDecode(findSimulatorsResult.stdout);
+ final List<dynamic> runtimes = simulatorListJson['runtimes'];
+ final Map<String, dynamic> devices = simulatorListJson['devices'];
+ if (runtimes.isEmpty || devices.isEmpty) {
+ return null;
+ }
+ String id;
+ // Looking for runtimes, trying to find one with highest OS version.
+ for (Map<String, dynamic> runtimeMap in runtimes.reversed) {
+ if (!runtimeMap['name'].contains('iOS')) {
+ continue;
+ }
+ final String runtimeID = runtimeMap['identifier'];
+ final List<dynamic> devicesForRuntime = devices[runtimeID];
+ if (devicesForRuntime.isEmpty) {
+ continue;
+ }
+ // Looking for runtimes, trying to find latest version of device.
+ for (Map<String, dynamic> device in devicesForRuntime.reversed) {
+ if (device['availabilityError'] != null ||
+ (device['isAvailable'] as bool == false)) {
+ continue;
+ }
+ id = device['udid'];
+ print('device selected: $device');
+ return id;
+ }
+ }
+ return null;
+ }
+}
diff --git a/script/tool/pubspec.yaml b/script/tool/pubspec.yaml
new file mode 100644
index 0000000..d9fce4a
--- /dev/null
+++ b/script/tool/pubspec.yaml
@@ -0,0 +1,25 @@
+name: flutter_plugin_tools
+description: Productivity utils for hosting multiple plugins within one repository.
+publish_to: 'none'
+
+dependencies:
+ args: "^1.4.3"
+ path: "^1.6.1"
+ http: "^0.12.1"
+ async: "^2.0.7"
+ yaml: "^2.1.15"
+ quiver: "^2.0.2"
+ pub_semver: ^1.4.2
+ colorize: ^2.0.0
+ git: ^1.0.0
+ platform: ^2.2.0
+ pubspec_parse: "^0.1.4"
+ test: ^1.6.4
+ meta: ^1.1.7
+ file: ^5.0.10
+ uuid: ^2.0.4
+ http_multi_server: ^2.2.0
+ collection: 1.14.13
+
+environment:
+ sdk: ">=2.3.0 <3.0.0"