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"