From 3e5807ddde999479c6d9937a131dd0919ad7dae8 Mon Sep 17 00:00:00 2001 From: Gabriel Terwesten Date: Wed, 8 Jun 2022 12:12:27 +0200 Subject: [PATCH] feat: simplify writing scripts that use `melos exec` (#315) 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 { } } +@immutable +class ExecOptions { + ExecOptions({ + this.concurrency, + this.failFast, + }); + + final int? concurrency; + final bool? failFast; + + Map 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 = {}; PackageFilter? packageFilter; + ExecOptions? exec; if (yaml is String) { run = yaml; } else if (yaml is Map) { - run = assertKeyIsA( - 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( + key: 'run', + map: yaml, + path: scriptPath, + ); + } + description = assertKeyIsA( key: 'description', map: yaml, @@ -153,6 +201,20 @@ class Script { scriptName: name, workspacePath: workspacePath, ); + + if (execYaml is String) { + exec = ExecOptions(); + } else { + final execMap = assertKeyIsA?>( + 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( value: yaml[filterOptionSince], key: filterOptionSince, - path: packagePath, + path: filtersPath, ); final excludePrivatePackagesTmp = assertIsA( value: yaml[filterOptionNoPrivate], key: filterOptionNoPrivate, - path: packagePath, + path: filtersPath, ); final includePrivatePackagesTmp = assertIsA( value: yaml[filterOptionPrivate], key: filterOptionNoPrivate, - path: packagePath, + path: filtersPath, ); if (includePrivatePackagesTmp != null && excludePrivatePackagesTmp != null) { @@ -241,17 +304,17 @@ class Script { final published = assertIsA( value: yaml[filterOptionPublished], key: filterOptionPublished, - path: packagePath, + path: filtersPath, ); final nullSafe = assertIsA( value: yaml[filterOptionNullsafety], key: filterOptionNullsafety, - path: packagePath, + path: filtersPath, ); final flutter = assertIsA( 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 yaml, { + required String scriptName, + }) { + final execPath = 'scripts/$scriptName/exec'; + + final concurrency = assertKeyIsA( + key: 'concurrency', + map: yaml, + path: execPath, + ); + + final failFast = assertKeyIsA( + 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 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 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': {}, + }, + }), + 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()), + ); + }); + }); + }); + 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': ['*'] }), - path: currentPlatform.isWindows ? r'\\workspace' : '/workspace', + path: testWorkspacePath, ), throwsMelosConfigException(), ); @@ -324,7 +388,7 @@ void main() { expect( () => MelosWorkspaceConfig.fromYaml( createYamlMap({'name': []}, 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': {}}, 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': []}, defaults: configMapDefaults, ), - path: currentPlatform.isWindows ? r'\\workspace' : '/workspace', + path: testWorkspacePath, ), throwsMelosConfigException(), ); @@ -439,7 +503,7 @@ void main() { {'ignore': {}}, 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 createYamlMap( Map source, { Map? defaults,