From 8c0d7cb01f4b896d68404cb6b07012b3955971c5 Mon Sep 17 00:00:00 2001
From: Gabriel Terwesten <gabriel@terwesten.net>
Date: Mon, 6 Jun 2022 14:33:53 +0200
Subject: [PATCH] feat: simplify writing scripts that use `melos exec`

Fixes #266
---
 docs/configuration/scripts.mdx                |  43 ++++-
 docs/getting-started.mdx                      |   3 +-
 packages/melos/lib/src/commands/run.dart      |   4 +-
 packages/melos/lib/src/scripts.dart           | 166 +++++++++++++++---
 packages/melos/test/commands/run_test.dart    |  69 ++++++++
 .../melos/test/workspace_config_test.dart     | 105 +++++++++--
 6 files changed, 344 insertions(+), 46 deletions(-)

diff --git a/docs/configuration/scripts.mdx b/docs/configuration/scripts.mdx
index 9c8633c5e..0a2499025 100644
--- a/docs/configuration/scripts.mdx
+++ b/docs/configuration/scripts.mdx
@@ -50,6 +50,47 @@ A short description, shown when using `melos run` with no argument.
 
 The command to execute.
 
+## `scripts/*/exec`
+
+Execute a script in multiple packages through `melos exec`.
+
+This options must either contain the command to execute in multiple packages or the options for the
+`melos exec` command.
+
+When using the default options for `melos exec`, it's easiest to specify the command in the `exec`
+option:
+
+```yaml
+scripts:
+  hello:
+    exec: echo 'Hello $(dirname $PWD)'
+```
+
+If you need to provide options for the `exec` command, specify them in the `exec` option and
+specify the command in the `run` option:
+
+```yaml
+scripts:
+  hello:
+    run: echo 'Hello $(dirname $PWD)'
+    exec:
+      concurrency: 1
+```
+
+See the [`select-package`](#scriptsselect-package) option for filtering the packages to execute
+the command in.
+
+## `scripts/*/exec/concurrency`
+
+Defines the max concurrency value of how many packages will execute the command in at any one time.
+Defaults to `5`.
+
+## `scripts/*/exec/failFast`
+
+Whether `exec` should fail fast and not execute the script in further packages if the script fails
+in an individual package.
+Defaults to `false`.
+
 ## `scripts/*/env`
 
 A map of environment variables that will be passed to the executed command.
@@ -64,7 +105,7 @@ The `hello_flutter` script below is only executed in Flutter packages:
 ```yaml
 scripts:
   hello_flutter:
-    run: melos exec -- "echo 'Hello $(dirname $PWD)'"
+    exec: echo 'Hello $(dirname $PWD)'
     select-package:
       flutter: true
 ```
diff --git a/docs/getting-started.mdx b/docs/getting-started.mdx
index a525591b2..ae8c25714 100644
--- a/docs/getting-started.mdx
+++ b/docs/getting-started.mdx
@@ -77,7 +77,8 @@ packages:
   - packages/**
 
 scripts:
-  analyze: melos exec -- "dart analyze ."
+  analyze:
+    exec: dart analyze .
 ```
 
 Then execute the command by running `melos run analyze`.
diff --git a/packages/melos/lib/src/commands/run.dart b/packages/melos/lib/src/commands/run.dart
index 390886494..e4239db85 100644
--- a/packages/melos/lib/src/commands/run.dart
+++ b/packages/melos/lib/src/commands/run.dart
@@ -31,7 +31,7 @@ mixin _RunMixin on _Melos {
     logger?.stdout('');
     logger?.stdout(AnsiStyles.yellow.bold('melos run ${script.name}'));
     logger?.stdout(
-      '   └> ${AnsiStyles.cyan.bold(script.run.replaceAll('\n', ''))}',
+      '   └> ${AnsiStyles.cyan.bold(script.effectiveRun.replaceAll('\n', ''))}',
     );
 
     if (exitCode != 0) {
@@ -146,7 +146,7 @@ mixin _RunMixin on _Melos {
       environment[envKeyMelosPackages] = packagesEnv;
     }
 
-    final scriptSource = script.run;
+    final scriptSource = script.effectiveRun;
     final scriptParts = scriptSource.split(' ');
 
     logger?.stdout(AnsiStyles.yellow.bold('melos run ${script.name}'));
diff --git a/packages/melos/lib/src/scripts.dart b/packages/melos/lib/src/scripts.dart
index 8977ea216..c8288136e 100644
--- a/packages/melos/lib/src/scripts.dart
+++ b/packages/melos/lib/src/scripts.dart
@@ -91,6 +91,40 @@ class Scripts extends MapView<String, Script> {
   }
 }
 
+@immutable
+class ExecOptions {
+  ExecOptions({
+    this.concurrency,
+    this.failFast,
+  });
+
+  final int? concurrency;
+  final bool? failFast;
+
+  Map<String, Object?> toJson() => {
+        if (concurrency != null) 'concurrency': concurrency,
+        if (failFast != null) 'failFast': failFast,
+      };
+
+  @override
+  bool operator ==(Object other) =>
+      other is ExecOptions &&
+      runtimeType == other.runtimeType &&
+      concurrency == other.concurrency &&
+      failFast == other.failFast;
+
+  @override
+  int get hashCode =>
+      runtimeType.hashCode ^ concurrency.hashCode ^ failFast.hashCode;
+
+  @override
+  String toString() => '''
+ExecOptions(
+  concurrency: $concurrency,
+  failFast: $failFast,
+)''';
+}
+
 class Script {
   Script({
     required this.name,
@@ -98,6 +132,7 @@ class Script {
     this.description,
     this.env = const {},
     this.filter,
+    this.exec,
   });
 
   factory Script.fromYaml(
@@ -110,15 +145,28 @@ class Script {
     String? description;
     var env = <String, String>{};
     PackageFilter? packageFilter;
+    ExecOptions? exec;
 
     if (yaml is String) {
       run = yaml;
     } else if (yaml is Map<Object?, Object?>) {
-      run = assertKeyIsA<String>(
-        key: 'run',
-        map: yaml,
-        path: scriptPath,
-      );
+      final execYaml = yaml['exec'];
+      if (execYaml is String) {
+        if (yaml['run'] is String) {
+          throw MelosConfigException(
+            'The script $name specifies a command in both "run" and "exec". '
+            'Remove one of them.',
+          );
+        }
+        run = execYaml;
+      } else {
+        run = assertKeyIsA<String>(
+          key: 'run',
+          map: yaml,
+          path: scriptPath,
+        );
+      }
+
       description = assertKeyIsA<String?>(
         key: 'description',
         map: yaml,
@@ -153,6 +201,20 @@ class Script {
               scriptName: name,
               workspacePath: workspacePath,
             );
+
+      if (execYaml is String) {
+        exec = ExecOptions();
+      } else {
+        final execMap = assertKeyIsA<Map<Object?, Object?>?>(
+          key: 'exec',
+          map: yaml,
+          path: scriptPath,
+        );
+
+        exec = execMap == null
+            ? null
+            : execOptionsFromYaml(execMap, scriptName: name);
+      }
     } else {
       throw MelosConfigException('Unsupported value for script $name');
     }
@@ -163,6 +225,7 @@ class Script {
       description: description,
       env: env,
       filter: packageFilter,
+      exec: exec,
     );
   }
 
@@ -175,54 +238,54 @@ class Script {
     // necessary for the glob workaround
     required String workspacePath,
   }) {
-    final packagePath = 'scripts/$scriptName/select-package';
+    final filtersPath = 'scripts/$scriptName/select-package';
 
     final scope = assertListOrString(
       key: filterOptionScope,
       map: yaml,
-      path: packagePath,
+      path: filtersPath,
     );
     final ignore = assertListOrString(
       key: filterOptionIgnore,
       map: yaml,
-      path: packagePath,
+      path: filtersPath,
     );
     final dirExists = assertListOrString(
       key: filterOptionDirExists,
       map: yaml,
-      path: packagePath,
+      path: filtersPath,
     );
     final fileExists = assertListOrString(
       key: filterOptionFileExists,
       map: yaml,
-      path: packagePath,
+      path: filtersPath,
     );
     final dependsOn = assertListOrString(
       key: filterOptionDependsOn,
       map: yaml,
-      path: packagePath,
+      path: filtersPath,
     );
     final noDependsOn = assertListOrString(
       key: filterOptionNoDependsOn,
       map: yaml,
-      path: packagePath,
+      path: filtersPath,
     );
 
     final updatedSince = assertIsA<String?>(
       value: yaml[filterOptionSince],
       key: filterOptionSince,
-      path: packagePath,
+      path: filtersPath,
     );
 
     final excludePrivatePackagesTmp = assertIsA<bool?>(
       value: yaml[filterOptionNoPrivate],
       key: filterOptionNoPrivate,
-      path: packagePath,
+      path: filtersPath,
     );
     final includePrivatePackagesTmp = assertIsA<bool?>(
       value: yaml[filterOptionPrivate],
       key: filterOptionNoPrivate,
-      path: packagePath,
+      path: filtersPath,
     );
     if (includePrivatePackagesTmp != null &&
         excludePrivatePackagesTmp != null) {
@@ -241,17 +304,17 @@ class Script {
     final published = assertIsA<bool?>(
       value: yaml[filterOptionPublished],
       key: filterOptionPublished,
-      path: packagePath,
+      path: filtersPath,
     );
     final nullSafe = assertIsA<bool?>(
       value: yaml[filterOptionNullsafety],
       key: filterOptionNullsafety,
-      path: packagePath,
+      path: filtersPath,
     );
     final flutter = assertIsA<bool?>(
       value: yaml[filterOptionFlutter],
       key: filterOptionFlutter,
-      path: packagePath,
+      path: filtersPath,
     );
 
     return PackageFilter(
@@ -273,22 +336,75 @@ class Script {
     );
   }
 
-  /// A unique identifier for the script
+  @visibleForTesting
+  static ExecOptions execOptionsFromYaml(
+    Map<Object?, Object?> yaml, {
+    required String scriptName,
+  }) {
+    final execPath = 'scripts/$scriptName/exec';
+
+    final concurrency = assertKeyIsA<int?>(
+      key: 'concurrency',
+      map: yaml,
+      path: execPath,
+    );
+
+    final failFast = assertKeyIsA<bool?>(
+      key: 'failFast',
+      map: yaml,
+      path: execPath,
+    );
+
+    return ExecOptions(
+      concurrency: concurrency,
+      failFast: failFast,
+    );
+  }
+
+  /// A unique identifier for the script.
   final String name;
 
-  /// The command to execute
+  /// The command specified by the user.
   final String run;
 
+  /// The command to run when executing this script.
+  late final effectiveRun = _buildEffectiveCommand();
+
   /// A short description, shown when using `melos run` with no argument.
   final String? description;
 
-  /// Environment variables that will be passed to[run].
+  /// Environment variables that will be passed to [run].
   final Map<String, String> env;
 
   /// If the [run] command is a melos command, allows filtering packages
   /// that will execute the command.
   final PackageFilter? filter;
 
+  /// The options for `melos exec`, if [run] should be executed in multiple
+  /// packages.
+  final ExecOptions? exec;
+
+  String _buildEffectiveCommand() {
+    String _quoteScript(String script) => '"${script.replaceAll('"', r'\"')}"';
+
+    final exec = this.exec;
+    if (exec != null) {
+      final parts = ['melos', 'exec'];
+
+      if (exec.concurrency != null) {
+        parts.addAll(['--concurrency', '${exec.concurrency}']);
+      }
+      if (exec.failFast != null) {
+        parts.addAll(['--fail-fast', '${exec.failFast}']);
+      }
+
+      parts.addAll(['--', _quoteScript(run)]);
+
+      return parts.join(' ');
+    }
+    return run;
+  }
+
   Map<Object?, Object?> toJson() {
     return {
       'name': name,
@@ -296,6 +412,7 @@ class Script {
       if (description != null) 'description': description,
       if (env.isNotEmpty) 'env': env,
       if (filter != null) 'select-package': filter!.toJson(),
+      if (exec != null) 'exec': exec!.toJson(),
     };
   }
 
@@ -307,7 +424,8 @@ class Script {
       other.run == run &&
       other.description == description &&
       const DeepCollectionEquality().equals(other.env, env) &&
-      other.filter == filter;
+      other.filter == filter &&
+      other.exec == exec;
 
   @override
   int get hashCode =>
@@ -316,7 +434,8 @@ class Script {
       run.hashCode ^
       description.hashCode ^
       const DeepCollectionEquality().hash(env) ^
-      filter.hashCode;
+      filter.hashCode ^
+      exec.hashCode;
 
   @override
   String toString() {
@@ -327,6 +446,7 @@ Script(
   description: $description,
   env: $env,
   packageFilter: ${filter.toString().indent('  ')},
+  exec: ${exec.toString().indent('  ')},
 )''';
   }
 }
diff --git a/packages/melos/test/commands/run_test.dart b/packages/melos/test/commands/run_test.dart
index fc8f75f11..759c712ff 100644
--- a/packages/melos/test/commands/run_test.dart
+++ b/packages/melos/test/commands/run_test.dart
@@ -140,6 +140,75 @@ melos run test_script
 melos run test_script
    └> echo $0 $1 $2
        └> SUCCESS
+''',
+          ),
+        );
+      },
+      // TODO test is not compatible with Windows
+      skip: currentPlatform.isWindows,
+    );
+
+    test(
+      'supports running "melos exec" script with "exec" options',
+      () async {
+        final workspaceDir = createTemporaryWorkspaceDirectory(
+          configBuilder: (path) => MelosWorkspaceConfig(
+            path: path,
+            name: 'test_package',
+            packages: [
+              createGlob('packages/**', currentDirectoryPath: path),
+            ],
+            scripts: Scripts({
+              'test_script': Script(
+                name: 'test_script',
+                run: 'echo "hello"',
+                exec: ExecOptions(
+                  concurrency: 1,
+                ),
+              )
+            }),
+          ),
+        );
+
+        await createProject(
+          workspaceDir,
+          const PubSpec(name: 'a'),
+        );
+
+        final logger = TestLogger();
+        final config = await MelosWorkspaceConfig.fromDirectory(workspaceDir);
+        final melos = Melos(
+          logger: logger,
+          config: config,
+        );
+
+        await melos.run(scriptName: 'test_script', noSelect: true);
+
+        expect(
+          logger.output,
+          ignoringAnsii(
+            '''
+melos run test_script
+   └> melos exec --concurrency 1 -- "echo \\"hello\\""
+       └> RUNNING
+
+\$ melos exec
+   └> echo "hello"
+       └> RUNNING (in 1 packages)
+
+${'-' * terminalWidth}
+a:
+hello
+a: SUCCESS
+${'-' * terminalWidth}
+
+\$ melos exec
+   └> echo "hello"
+       └> SUCCESS
+
+melos run test_script
+   └> melos exec --concurrency 1 -- "echo \\"hello\\""
+       └> SUCCESS
 ''',
           ),
         );
diff --git a/packages/melos/test/workspace_config_test.dart b/packages/melos/test/workspace_config_test.dart
index 78d7d32e7..e7d53382c 100644
--- a/packages/melos/test/workspace_config_test.dart
+++ b/packages/melos/test/workspace_config_test.dart
@@ -14,9 +14,10 @@
  * limitations under the License.
  */
 
+import 'package:melos/melos.dart';
 import 'package:melos/src/common/git_repository.dart';
 import 'package:melos/src/common/platform.dart';
-import 'package:melos/src/workspace_configs.dart';
+import 'package:melos/src/scripts.dart';
 import 'package:test/test.dart';
 
 import 'matchers.dart';
@@ -274,6 +275,69 @@ void main() {
     });
   });
 
+  group('Scripts', () {
+    group('exec', () {
+      test('supports specifying command through "exec"', () {
+        final scripts = Scripts.fromYaml(
+          createYamlMap({
+            'a': {
+              'exec': 'b',
+            },
+          }),
+          workspacePath: testWorkspacePath,
+        );
+        expect(scripts['a']!.run, 'b');
+        expect(scripts['a']!.exec, ExecOptions());
+      });
+
+      test('supports specifying command through "run"', () {
+        final scripts = Scripts.fromYaml(
+          createYamlMap({
+            'a': {
+              'run': 'b',
+              'exec': <String, Object?>{},
+            },
+          }),
+          workspacePath: testWorkspacePath,
+        );
+        expect(scripts['a']!.run, 'b');
+        expect(scripts['a']!.exec, ExecOptions());
+      });
+
+      test('supports specifying exec options', () {
+        final scripts = Scripts.fromYaml(
+          createYamlMap({
+            'a': {
+              'run': 'b',
+              'exec': {
+                'concurrency': 1,
+                'failFast': true,
+              },
+            },
+          }),
+          workspacePath: testWorkspacePath,
+        );
+        expect(scripts['a']!.run, 'b');
+        expect(scripts['a']!.exec, ExecOptions(concurrency: 1, failFast: true));
+      });
+
+      test('throws when specifying command in "run" and "exec"', () {
+        expect(
+          () => Scripts.fromYaml(
+            createYamlMap({
+              'a': {
+                'exec': 'b',
+                'run': 'c',
+              },
+            }),
+            workspacePath: testWorkspacePath,
+          ),
+          throwsA(isA<MelosConfigException>()),
+        );
+      });
+    });
+  });
+
   group('MelosWorkspaceConfig', () {
     test(
         'throws if commands.version.linkToCommits == true but repository is missing',
@@ -285,7 +349,7 @@ void main() {
           commands: const CommandConfigs(
             version: VersionCommandConfigs(linkToCommits: true),
           ),
-          path: currentPlatform.isWindows ? r'\\workspace' : '/workspace',
+          path: testWorkspacePath,
         ),
         throwsMelosConfigException(),
       );
@@ -301,7 +365,7 @@ void main() {
           commands: const CommandConfigs(
             version: VersionCommandConfigs(linkToCommits: true),
           ),
-          path: currentPlatform.isWindows ? r'\\workspace' : '/workspace',
+          path: testWorkspacePath,
         ),
         returnsNormally,
       );
@@ -314,7 +378,7 @@ void main() {
             createYamlMap({
               'packages': <Object?>['*']
             }),
-            path: currentPlatform.isWindows ? r'\\workspace' : '/workspace',
+            path: testWorkspacePath,
           ),
           throwsMelosConfigException(),
         );
@@ -324,7 +388,7 @@ void main() {
         expect(
           () => MelosWorkspaceConfig.fromYaml(
             createYamlMap({'name': <Object?>[]}, defaults: configMapDefaults),
-            path: currentPlatform.isWindows ? r'\\workspace' : '/workspace',
+            path: testWorkspacePath,
           ),
           throwsMelosConfigException(),
         );
@@ -335,7 +399,7 @@ void main() {
           expect(
             () => MelosWorkspaceConfig.fromYaml(
               createYamlMap({'name': name}, defaults: configMapDefaults),
-              path: currentPlatform.isWindows ? r'\\workspace' : '/workspace',
+              path: testWorkspacePath,
             ),
             throwsMelosConfigException(),
           );
@@ -365,19 +429,19 @@ void main() {
       test('accepts valid dart package name', () {
         MelosWorkspaceConfig.fromYaml(
           createYamlMap({'name': 'hello_world'}, defaults: configMapDefaults),
-          path: currentPlatform.isWindows ? r'\\workspace' : '/workspace',
+          path: testWorkspacePath,
         );
         MelosWorkspaceConfig.fromYaml(
           createYamlMap({'name': 'hello2'}, defaults: configMapDefaults),
-          path: currentPlatform.isWindows ? r'\\workspace' : '/workspace',
+          path: testWorkspacePath,
         );
         MelosWorkspaceConfig.fromYaml(
           createYamlMap({'name': 'HELLO'}, defaults: configMapDefaults),
-          path: currentPlatform.isWindows ? r'\\workspace' : '/workspace',
+          path: testWorkspacePath,
         );
         MelosWorkspaceConfig.fromYaml(
           createYamlMap({'name': 'hello-world'}, defaults: configMapDefaults),
-          path: currentPlatform.isWindows ? r'\\workspace' : '/workspace',
+          path: testWorkspacePath,
         );
       });
 
@@ -385,7 +449,7 @@ void main() {
         expect(
           () => MelosWorkspaceConfig.fromYaml(
             createYamlMap({'name': 'package_name'}),
-            path: currentPlatform.isWindows ? r'\\workspace' : '/workspace',
+            path: testWorkspacePath,
           ),
           throwsMelosConfigException(),
         );
@@ -398,7 +462,7 @@ void main() {
               {'packages': <Object?, Object?>{}},
               defaults: configMapDefaults,
             ),
-            path: currentPlatform.isWindows ? r'\\workspace' : '/workspace',
+            path: testWorkspacePath,
           ),
           throwsMelosConfigException(),
         );
@@ -413,7 +477,7 @@ void main() {
               },
               defaults: configMapDefaults,
             ),
-            path: currentPlatform.isWindows ? r'\\workspace' : '/workspace',
+            path: testWorkspacePath,
           ),
           throwsMelosConfigException(),
         );
@@ -426,7 +490,7 @@ void main() {
               {'packages': <Object?>[]},
               defaults: configMapDefaults,
             ),
-            path: currentPlatform.isWindows ? r'\\workspace' : '/workspace',
+            path: testWorkspacePath,
           ),
           throwsMelosConfigException(),
         );
@@ -439,7 +503,7 @@ void main() {
               {'ignore': <Object?, Object?>{}},
               defaults: configMapDefaults,
             ),
-            path: currentPlatform.isWindows ? r'\\workspace' : '/workspace',
+            path: testWorkspacePath,
           ),
           throwsMelosConfigException(),
         );
@@ -454,7 +518,7 @@ void main() {
               },
               defaults: configMapDefaults,
             ),
-            path: currentPlatform.isWindows ? r'\\workspace' : '/workspace',
+            path: testWorkspacePath,
           ),
           throwsMelosConfigException(),
         );
@@ -467,7 +531,7 @@ void main() {
               {'repository': 42},
               defaults: configMapDefaults,
             ),
-            path: currentPlatform.isWindows ? r'\\workspace' : '/workspace',
+            path: testWorkspacePath,
           ),
           throwsMelosConfigException(),
         );
@@ -481,7 +545,7 @@ void main() {
               {'repository': 'https://example.com'},
               defaults: configMapDefaults,
             ),
-            path: currentPlatform.isWindows ? r'\\workspace' : '/workspace',
+            path: testWorkspacePath,
           ),
           throwsMelosConfigException(),
         );
@@ -493,7 +557,7 @@ void main() {
             {'repository': 'https://github.com/invertase/melos'},
             defaults: configMapDefaults,
           ),
-          path: currentPlatform.isWindows ? r'\\workspace' : '/workspace',
+          path: testWorkspacePath,
         );
         final repository = config.repository! as GitHubRepository;
 
@@ -504,6 +568,9 @@ void main() {
   });
 }
 
+final testWorkspacePath =
+    currentPlatform.isWindows ? r'\\workspace' : '/workspace';
+
 Map<String, Object?> createYamlMap(
   Map<String, Object?> source, {
   Map<String, Object?>? defaults,