[flutter_plugin_tools] Migrate build-examples to new base command (#4087)

Switches build-examples to the new base command that handles the boilerplate of looping over target packages.

While modifying the command, also does some minor cleanup:
- Extracts a helper to reduce duplicated details of calling `flutter build`
- Switches the flag for iOS to `--ios` rather than `--ipa` since `ios` is what is actually passed to the build command
- iOS no longer defaults to on, so that it behaves like all the other platform flags
- Passing no platform flags is now an error rather than a silent pass, to ensure that we never accidentally have CI doing a no-op run without noticing.
- Rewords the logging slightly for the versions where the label for what is being built is a platform, not an artifact (which is now everything but Android).

Part of flutter/flutter#83413
diff --git a/.cirrus.yml b/.cirrus.yml
index 45f9442..4ad5e8e 100644
--- a/.cirrus.yml
+++ b/.cirrus.yml
@@ -220,7 +220,7 @@
         - xcrun simctl list
         - xcrun simctl create Flutter-iPhone com.apple.CoreSimulator.SimDeviceType.iPhone-11 com.apple.CoreSimulator.SimRuntime.iOS-14-5 | xargs xcrun simctl boot
       build_script:
-        - ./script/tool_runner.sh build-examples --ipa
+        - ./script/tool_runner.sh build-examples --ios
       xctest_script:
         - ./script/tool_runner.sh xctest --ios --ios-destination "platform=iOS Simulator,name=iPhone 11,OS=latest"
       drive_script:
@@ -248,7 +248,7 @@
         PATH: $PATH:/usr/local/bin
       build_script:
         - flutter config --enable-macos-desktop
-        - ./script/tool_runner.sh build-examples --macos --no-ipa
+        - ./script/tool_runner.sh build-examples --macos
       xctest_script:
         - ./script/tool_runner.sh xctest --macos --exclude $PLUGINS_TO_EXCLUDE_MACOS_XCTESTS
       drive_script:
diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md
index 94514e3..a2716cb 100644
--- a/script/tool/CHANGELOG.md
+++ b/script/tool/CHANGELOG.md
@@ -5,6 +5,8 @@
   compatibility.
 - `xctest` now supports running macOS tests in addition to iOS
   - **Breaking change**: it now requires an `--ios` and/or `--macos` flag.
+- **Breaking change**: `build-examples` for iOS now uses `--ios` rather than
+  `--ipa`.
 - The tooling now runs in strong null-safe mode.
 - `publish plugins` check against pub.dev to determine if a release should happen.
 - Modified the output format of many commands
diff --git a/script/tool/lib/src/build_examples_command.dart b/script/tool/lib/src/build_examples_command.dart
index aff5ecb..c8280f4 100644
--- a/script/tool/lib/src/build_examples_command.dart
+++ b/script/tool/lib/src/build_examples_command.dart
@@ -3,36 +3,34 @@
 // 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/core.dart';
-import 'common/plugin_command.dart';
+import 'common/package_looping_command.dart';
 import 'common/plugin_utils.dart';
 import 'common/process_runner.dart';
 
-/// Key for IPA.
-const String kIpa = 'ipa';
-
 /// Key for APK.
-const String kApk = 'apk';
+const String _platformFlagApk = 'apk';
+
+const int _exitNoPlatformFlags = 2;
 
 /// A command to build the example applications for packages.
-class BuildExamplesCommand extends PluginCommand {
+class BuildExamplesCommand extends PackageLoopingCommand {
   /// Creates an instance of the build command.
   BuildExamplesCommand(
     Directory packagesDir, {
     ProcessRunner processRunner = const ProcessRunner(),
   }) : super(packagesDir, processRunner: processRunner) {
-    argParser.addFlag(kPlatformLinux, defaultsTo: false);
-    argParser.addFlag(kPlatformMacos, defaultsTo: false);
-    argParser.addFlag(kPlatformWeb, defaultsTo: false);
-    argParser.addFlag(kPlatformWindows, defaultsTo: false);
-    argParser.addFlag(kIpa, defaultsTo: io.Platform.isMacOS);
-    argParser.addFlag(kApk);
+    argParser.addFlag(kPlatformLinux);
+    argParser.addFlag(kPlatformMacos);
+    argParser.addFlag(kPlatformWeb);
+    argParser.addFlag(kPlatformWindows);
+    argParser.addFlag(kPlatformIos);
+    argParser.addFlag(_platformFlagApk);
     argParser.addOption(
       kEnableExperiment,
       defaultsTo: '',
@@ -49,164 +47,125 @@
       'This command requires "flutter" to be in your path.';
 
   @override
-  Future<void> run() async {
+  Future<void> initializeRun() async {
     final List<String> platformSwitches = <String>[
-      kApk,
-      kIpa,
+      _platformFlagApk,
+      kPlatformIos,
       kPlatformLinux,
       kPlatformMacos,
       kPlatformWeb,
       kPlatformWindows,
     ];
     if (!platformSwitches.any((String platform) => getBoolArg(platform))) {
-      print(
+      printError(
           'None of ${platformSwitches.map((String platform) => '--$platform').join(', ')} '
-          'were specified, so not building anything.');
-      return;
+          'were specified. At least one platform must be provided.');
+      throw ToolExit(_exitNoPlatformFlags);
     }
+  }
+
+  @override
+  Future<List<String>> runForPackage(Directory package) async {
+    final List<String> errors = <String>[];
+
+    for (final Directory example in getExamplesForPlugin(package)) {
+      final String packageName =
+          p.relative(example.path, from: packagesDir.path);
+
+      if (getBoolArg(kPlatformLinux)) {
+        print('\nBUILDING $packageName for Linux');
+        if (isLinuxPlugin(package)) {
+          if (!await _buildExample(example, kPlatformLinux)) {
+            errors.add('$packageName (Linux)');
+          }
+        } else {
+          printSkip('Linux is not supported by this plugin');
+        }
+      }
+
+      if (getBoolArg(kPlatformMacos)) {
+        print('\nBUILDING $packageName for macOS');
+        if (isMacOsPlugin(package)) {
+          if (!await _buildExample(example, kPlatformMacos)) {
+            errors.add('$packageName (macOS)');
+          }
+        } else {
+          printSkip('macOS is not supported by this plugin');
+        }
+      }
+
+      if (getBoolArg(kPlatformWeb)) {
+        print('\nBUILDING $packageName for web');
+        if (isWebPlugin(package)) {
+          if (!await _buildExample(example, kPlatformWeb)) {
+            errors.add('$packageName (web)');
+          }
+        } else {
+          printSkip('Web is not supported by this plugin');
+        }
+      }
+
+      if (getBoolArg(kPlatformWindows)) {
+        print('\nBUILDING $packageName for Windows');
+        if (isWindowsPlugin(package)) {
+          if (!await _buildExample(example, kPlatformWindows)) {
+            errors.add('$packageName (Windows)');
+          }
+        } else {
+          printSkip('Windows is not supported by this plugin');
+        }
+      }
+
+      if (getBoolArg(kPlatformIos)) {
+        print('\nBUILDING $packageName for iOS');
+        if (isIosPlugin(package)) {
+          if (!await _buildExample(
+            example,
+            kPlatformIos,
+            extraBuildFlags: <String>['--no-codesign'],
+          )) {
+            errors.add('$packageName (iOS)');
+          }
+        } else {
+          printSkip('iOS is not supported by this plugin');
+        }
+      }
+
+      if (getBoolArg(_platformFlagApk)) {
+        print('\nBUILDING APK for $packageName');
+        if (isAndroidPlugin(package)) {
+          if (!await _buildExample(example, _platformFlagApk)) {
+            errors.add('$packageName (apk)');
+          }
+        } else {
+          printSkip('Android is not supported by this plugin');
+        }
+      }
+    }
+
+    return errors;
+  }
+
+  Future<bool> _buildExample(
+    Directory example,
+    String flutterBuildType, {
+    List<String> extraBuildFlags = const <String>[],
+  }) async {
     final String flutterCommand =
         const LocalPlatform().isWindows ? 'flutter.bat' : 'flutter';
-
     final String enableExperiment = getStringArg(kEnableExperiment);
 
-    final List<String> failingPackages = <String>[];
-    await for (final Directory plugin in getPlugins()) {
-      for (final Directory example in getExamplesForPlugin(plugin)) {
-        final String packageName =
-            p.relative(example.path, from: packagesDir.path);
-
-        if (getBoolArg(kPlatformLinux)) {
-          print('\nBUILDING Linux for $packageName');
-          if (isLinuxPlugin(plugin)) {
-            final int buildExitCode = await processRunner.runAndStream(
-                flutterCommand,
-                <String>[
-                  'build',
-                  kPlatformLinux,
-                  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 (getBoolArg(kPlatformMacos)) {
-          print('\nBUILDING macOS for $packageName');
-          if (isMacOsPlugin(plugin)) {
-            final int exitCode = await processRunner.runAndStream(
-                flutterCommand,
-                <String>[
-                  'build',
-                  kPlatformMacos,
-                  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 (getBoolArg(kPlatformWeb)) {
-          print('\nBUILDING web for $packageName');
-          if (isWebPlugin(plugin)) {
-            final int buildExitCode = await processRunner.runAndStream(
-                flutterCommand,
-                <String>[
-                  'build',
-                  kPlatformWeb,
-                  if (enableExperiment.isNotEmpty)
-                    '--enable-experiment=$enableExperiment',
-                ],
-                workingDir: example);
-            if (buildExitCode != 0) {
-              failingPackages.add('$packageName (web)');
-            }
-          } else {
-            print('Web is not supported by this plugin');
-          }
-        }
-
-        if (getBoolArg(kPlatformWindows)) {
-          print('\nBUILDING Windows for $packageName');
-          if (isWindowsPlugin(plugin)) {
-            final int buildExitCode = await processRunner.runAndStream(
-                flutterCommand,
-                <String>[
-                  'build',
-                  kPlatformWindows,
-                  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 (getBoolArg(kIpa)) {
-          print('\nBUILDING IPA for $packageName');
-          if (isIosPlugin(plugin)) {
-            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 (getBoolArg(kApk)) {
-          print('\nBUILDING APK for $packageName');
-          if (isAndroidPlugin(plugin)) {
-            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 (final String package in failingPackages) {
-        print(' * $package');
-      }
-      throw ToolExit(1);
-    }
-
-    print('All builds successful!');
+    final int exitCode = await processRunner.runAndStream(
+      flutterCommand,
+      <String>[
+        'build',
+        flutterBuildType,
+        ...extraBuildFlags,
+        if (enableExperiment.isNotEmpty)
+          '--enable-experiment=$enableExperiment',
+      ],
+      workingDir: example,
+    );
+    return exitCode == 0;
   }
 }
diff --git a/script/tool/test/build_examples_command_test.dart b/script/tool/test/build_examples_command_test.dart
index 7fc9783..c6febdc 100644
--- a/script/tool/test/build_examples_command_test.dart
+++ b/script/tool/test/build_examples_command_test.dart
@@ -15,7 +15,7 @@
 import 'util.dart';
 
 void main() {
-  group('test build_example_command', () {
+  group('build-example', () {
     late FileSystem fileSystem;
     late Directory packagesDir;
     late CommandRunner<void> runner;
@@ -35,6 +35,13 @@
       runner.addCommand(command);
     });
 
+    test('fails if no plaform flags are passed', () async {
+      expect(
+        () => runCapturingPrint(runner, <String>['build-examples']),
+        throwsA(isA<ToolExit>()),
+      );
+    });
+
     test('building for iOS when plugin is not set up for iOS results in no-op',
         () async {
       final Directory pluginDirectory = createFakePlugin('plugin', packagesDir,
@@ -43,18 +50,16 @@
       final Directory pluginExampleDirectory =
           pluginDirectory.childDirectory('example');
 
-      final List<String> output = await runCapturingPrint(
-          runner, <String>['build-examples', '--ipa', '--no-macos']);
+      final List<String> output =
+          await runCapturingPrint(runner, <String>['build-examples', '--ios']);
       final String packageName =
           p.relative(pluginExampleDirectory.path, from: packagesDir.path);
 
       expect(
         output,
-        orderedEquals(<String>[
-          '\nBUILDING IPA for $packageName',
-          'iOS is not supported by this plugin',
-          '\n\n',
-          'All builds successful!',
+        containsAllInOrder(<Matcher>[
+          contains('BUILDING $packageName for iOS'),
+          contains('iOS is not supported by this plugin'),
         ]),
       );
 
@@ -78,21 +83,15 @@
       final Directory pluginExampleDirectory =
           pluginDirectory.childDirectory('example');
 
-      final List<String> output = await runCapturingPrint(runner, <String>[
-        'build-examples',
-        '--ipa',
-        '--no-macos',
-        '--enable-experiment=exp1'
-      ]);
+      final List<String> output = await runCapturingPrint(runner,
+          <String>['build-examples', '--ios', '--enable-experiment=exp1']);
       final String packageName =
           p.relative(pluginExampleDirectory.path, from: packagesDir.path);
 
       expect(
         output,
-        orderedEquals(<String>[
-          '\nBUILDING IPA for $packageName',
-          '\n\n',
-          'All builds successful!',
+        containsAllInOrder(<String>[
+          '\nBUILDING $packageName for iOS',
         ]),
       );
 
@@ -123,17 +122,15 @@
           pluginDirectory.childDirectory('example');
 
       final List<String> output = await runCapturingPrint(
-          runner, <String>['build-examples', '--no-ipa', '--linux']);
+          runner, <String>['build-examples', '--linux']);
       final String packageName =
           p.relative(pluginExampleDirectory.path, from: packagesDir.path);
 
       expect(
         output,
-        orderedEquals(<String>[
-          '\nBUILDING Linux for $packageName',
-          'Linux is not supported by this plugin',
-          '\n\n',
-          'All builds successful!',
+        containsAllInOrder(<Matcher>[
+          contains('BUILDING $packageName for Linux'),
+          contains('Linux is not supported by this plugin'),
         ]),
       );
 
@@ -158,16 +155,14 @@
           pluginDirectory.childDirectory('example');
 
       final List<String> output = await runCapturingPrint(
-          runner, <String>['build-examples', '--no-ipa', '--linux']);
+          runner, <String>['build-examples', '--linux']);
       final String packageName =
           p.relative(pluginExampleDirectory.path, from: packagesDir.path);
 
       expect(
         output,
-        orderedEquals(<String>[
-          '\nBUILDING Linux for $packageName',
-          '\n\n',
-          'All builds successful!',
+        containsAllInOrder(<String>[
+          '\nBUILDING $packageName for Linux',
         ]),
       );
 
@@ -190,17 +185,15 @@
           pluginDirectory.childDirectory('example');
 
       final List<String> output = await runCapturingPrint(
-          runner, <String>['build-examples', '--no-ipa', '--macos']);
+          runner, <String>['build-examples', '--macos']);
       final String packageName =
           p.relative(pluginExampleDirectory.path, from: packagesDir.path);
 
       expect(
         output,
-        orderedEquals(<String>[
-          '\nBUILDING macOS for $packageName',
-          'macOS is not supported by this plugin',
-          '\n\n',
-          'All builds successful!',
+        containsAllInOrder(<Matcher>[
+          contains('BUILDING $packageName for macOS'),
+          contains('macOS is not supported by this plugin'),
         ]),
       );
 
@@ -226,16 +219,14 @@
           pluginDirectory.childDirectory('example');
 
       final List<String> output = await runCapturingPrint(
-          runner, <String>['build-examples', '--no-ipa', '--macos']);
+          runner, <String>['build-examples', '--macos']);
       final String packageName =
           p.relative(pluginExampleDirectory.path, from: packagesDir.path);
 
       expect(
         output,
-        orderedEquals(<String>[
-          '\nBUILDING macOS for $packageName',
-          '\n\n',
-          'All builds successful!',
+        containsAllInOrder(<String>[
+          '\nBUILDING $packageName for macOS',
         ]),
       );
 
@@ -256,18 +247,16 @@
       final Directory pluginExampleDirectory =
           pluginDirectory.childDirectory('example');
 
-      final List<String> output = await runCapturingPrint(
-          runner, <String>['build-examples', '--no-ipa', '--web']);
+      final List<String> output =
+          await runCapturingPrint(runner, <String>['build-examples', '--web']);
       final String packageName =
           p.relative(pluginExampleDirectory.path, from: packagesDir.path);
 
       expect(
         output,
-        orderedEquals(<String>[
-          '\nBUILDING web for $packageName',
-          'Web is not supported by this plugin',
-          '\n\n',
-          'All builds successful!',
+        containsAllInOrder(<Matcher>[
+          contains('BUILDING $packageName for web'),
+          contains('Web is not supported by this plugin'),
         ]),
       );
 
@@ -292,17 +281,15 @@
       final Directory pluginExampleDirectory =
           pluginDirectory.childDirectory('example');
 
-      final List<String> output = await runCapturingPrint(
-          runner, <String>['build-examples', '--no-ipa', '--web']);
+      final List<String> output =
+          await runCapturingPrint(runner, <String>['build-examples', '--web']);
       final String packageName =
           p.relative(pluginExampleDirectory.path, from: packagesDir.path);
 
       expect(
         output,
-        orderedEquals(<String>[
-          '\nBUILDING web for $packageName',
-          '\n\n',
-          'All builds successful!',
+        containsAllInOrder(<String>[
+          '\nBUILDING $packageName for web',
         ]),
       );
 
@@ -326,17 +313,15 @@
           pluginDirectory.childDirectory('example');
 
       final List<String> output = await runCapturingPrint(
-          runner, <String>['build-examples', '--no-ipa', '--windows']);
+          runner, <String>['build-examples', '--windows']);
       final String packageName =
           p.relative(pluginExampleDirectory.path, from: packagesDir.path);
 
       expect(
         output,
-        orderedEquals(<String>[
-          '\nBUILDING Windows for $packageName',
-          'Windows is not supported by this plugin',
-          '\n\n',
-          'All builds successful!',
+        containsAllInOrder(<Matcher>[
+          contains('BUILDING $packageName for Windows'),
+          contains('Windows is not supported by this plugin'),
         ]),
       );
 
@@ -361,16 +346,14 @@
           pluginDirectory.childDirectory('example');
 
       final List<String> output = await runCapturingPrint(
-          runner, <String>['build-examples', '--no-ipa', '--windows']);
+          runner, <String>['build-examples', '--windows']);
       final String packageName =
           p.relative(pluginExampleDirectory.path, from: packagesDir.path);
 
       expect(
         output,
-        orderedEquals(<String>[
-          '\nBUILDING Windows for $packageName',
-          '\n\n',
-          'All builds successful!',
+        containsAllInOrder(<String>[
+          '\nBUILDING $packageName for Windows',
         ]),
       );
 
@@ -393,18 +376,16 @@
       final Directory pluginExampleDirectory =
           pluginDirectory.childDirectory('example');
 
-      final List<String> output = await runCapturingPrint(
-          runner, <String>['build-examples', '--apk', '--no-ipa']);
+      final List<String> output =
+          await runCapturingPrint(runner, <String>['build-examples', '--apk']);
       final String packageName =
           p.relative(pluginExampleDirectory.path, from: packagesDir.path);
 
       expect(
         output,
-        orderedEquals(<String>[
-          '\nBUILDING APK for $packageName',
-          'Android is not supported by this plugin',
-          '\n\n',
-          'All builds successful!',
+        containsAllInOrder(<Matcher>[
+          contains('\nBUILDING APK for $packageName'),
+          contains('Android is not supported by this plugin'),
         ]),
       );
 
@@ -431,18 +412,14 @@
       final List<String> output = await runCapturingPrint(runner, <String>[
         'build-examples',
         '--apk',
-        '--no-ipa',
-        '--no-macos',
       ]);
       final String packageName =
           p.relative(pluginExampleDirectory.path, from: packagesDir.path);
 
       expect(
         output,
-        orderedEquals(<String>[
+        containsAllInOrder(<String>[
           '\nBUILDING APK for $packageName',
-          '\n\n',
-          'All builds successful!',
         ]),
       );
 
@@ -469,13 +446,8 @@
       final Directory pluginExampleDirectory =
           pluginDirectory.childDirectory('example');
 
-      await runCapturingPrint(runner, <String>[
-        'build-examples',
-        '--apk',
-        '--no-ipa',
-        '--no-macos',
-        '--enable-experiment=exp1'
-      ]);
+      await runCapturingPrint(runner,
+          <String>['build-examples', '--apk', '--enable-experiment=exp1']);
 
       expect(
           processRunner.recordedCalls,
@@ -502,12 +474,8 @@
       final Directory pluginExampleDirectory =
           pluginDirectory.childDirectory('example');
 
-      await runCapturingPrint(runner, <String>[
-        'build-examples',
-        '--ipa',
-        '--no-macos',
-        '--enable-experiment=exp1'
-      ]);
+      await runCapturingPrint(runner,
+          <String>['build-examples', '--ios', '--enable-experiment=exp1']);
       expect(
           processRunner.recordedCalls,
           orderedEquals(<ProcessCall>[