diff --git a/docs/configuration/scripts.mdx b/docs/configuration/scripts.mdx index bfcea0422..29f36c7cd 100644 --- a/docs/configuration/scripts.mdx +++ b/docs/configuration/scripts.mdx @@ -52,6 +52,37 @@ A short description, shown when using `melos run` with no argument. The command to execute. +## steps + +Enables the combination of multiple scripts within a single script definition. +In the example below, the 'pre-commit' is configured to sequentially invoke the +format and analyze scripts. + +```yaml +scripts: + pre-commit: + description: pre-commit git hook script + steps: + - echo 'hello world' + - format + - analyze + format: + description: Format Dart code. + run: dart format . + exec: + concurrency: 5 + analyze: + description: Analyze Dart code + run: dart analyze . + exec: + concurrency: 5 +``` + +Note: When utilizing the `steps`, it's important to understand that options +related to exec — such as concurrency — or `packageFilters` cannot be directly +applied within the steps configuration. Instead, these options should be +configured in the individual scripts that are being called as part of the step. + ## exec Execute a script in multiple packages through `melos exec`. diff --git a/packages/melos/lib/src/commands/run.dart b/packages/melos/lib/src/commands/run.dart index 8245fd0f2..943e86dff 100644 --- a/packages/melos/lib/src/commands/run.dart +++ b/packages/melos/lib/src/commands/run.dart @@ -20,6 +20,31 @@ mixin _RunMixin on _Melos { ); } + if (script.steps != null && script.steps!.isNotEmpty) { + if (script.exec != null) { + throw ScriptExecOptionsException._( + scriptName, + ); + } + + _detectRecursiveScriptCalls(script); + + await _runMultipleScripts( + script, + global: global, + noSelect: noSelect, + scripts: config.scripts, + steps: script.steps!, + ); + return; + } + + if (script.run == null && script.exec is! String) { + throw MissingScriptCommandException._( + scriptName, + ); + } + final scriptSourceCode = targetStyle( script.command(extraArgs).join(' ').withoutTrailing('\n'), ); @@ -45,6 +70,36 @@ mixin _RunMixin on _Melos { resultLogger.child(successLabel); } + /// Detects recursive script calls within the provided [script]. + /// + /// This method recursively traverses the steps of the script to check + /// for any recursive calls. If a step calls another script that + /// eventually leads back to the original script, it indicates a + /// recursive script call, which can result in an infinite loop during + /// execution. + void _detectRecursiveScriptCalls(Script script) { + final visitedScripts = {}; + + void traverseSteps(Script currentScript) { + visitedScripts.add(currentScript.name); + + for (final step in currentScript.steps!) { + if (visitedScripts.contains(step)) { + throw RecursiveScriptCallException._(step); + } + + final nestedScript = config.scripts[step]; + if (nestedScript != null) { + traverseSteps(nestedScript); + } + } + + visitedScripts.remove(currentScript.name); + } + + traverseSteps(script); + } + Future _pickScript(MelosWorkspaceConfig config) async { // using toList as Maps may be unordered final scripts = config.scripts.values.toList(); @@ -159,6 +214,82 @@ mixin _RunMixin on _Melos { workingDirectory: config.path, ); } + + Future _runMultipleScripts( + Script script, { + GlobalOptions? global, + bool noSelect = false, + required Scripts scripts, + required List steps, + }) async { + final workspace = await createWorkspace( + global: global, + ) + ..validate(); + + final environment = { + EnvironmentVariableKey.melosRootPath: config.path, + if (workspace.sdkPath != null) + EnvironmentVariableKey.melosSdkPath: workspace.sdkPath!, + if (workspace.childProcessPath != null) + EnvironmentVariableKey.path: workspace.childProcessPath!, + ...script.env, + }; + + await _executeScriptSteps(steps, scripts, script, environment); + } + + Future _executeScriptSteps( + List steps, + Scripts scripts, + Script script, + Map environment, + ) async { + for (final step in steps) { + final scriptCommand = + scripts.containsKey(step) ? 'melos run $step' : step; + + final scriptSourceCode = targetStyle( + step.withoutTrailing('\n'), + ); + + await _executeAndLogCommand( + script, + scriptSourceCode, + scriptCommand, + environment, + ); + } + } + + Future _executeAndLogCommand( + Script script, + String scriptSourceCode, + String scriptCommand, + Map environment, + ) async { + logger.command('melos run ${script.name}'); + logger.child(scriptSourceCode).child(runningLabel).newLine(); + + final exitCode = await startCommand( + [scriptCommand], + logger: logger, + environment: environment, + workingDirectory: config.path, + ); + + logger.newLine(); + logger.command('melos run ${script.name}'); + final resultLogger = logger.child(scriptSourceCode); + + if (exitCode != 0) { + resultLogger.child(failedLabel); + throw ScriptException._(script.name); + } else { + resultLogger.child(successLabel); + } + logger.newLine(); + } } class NoPackageFoundScriptException implements MelosException { @@ -214,3 +345,43 @@ class ScriptException implements MelosException { return 'ScriptException: The script $scriptName failed to execute.'; } } + +class ScriptExecOptionsException implements MelosException { + ScriptExecOptionsException._(this.scriptName); + final String scriptName; + + @override + String toString() { + return 'ScriptExecOptionsException: Execution options are not supported ' + 'for the script "$scriptName". Ensure the script is designed to run ' + 'with the provided options or consult the documentation for supported ' + 'scripts.'; + } +} + +class MissingScriptCommandException implements MelosException { + MissingScriptCommandException._(this.scriptName); + final String scriptName; + + @override + String toString() { + return 'MissingScriptCommandException: The script $scriptName failed ' + 'to execute. You must specify a script to run. ' + 'This can be done by filling "run" with a command, ' + 'defining a sequence of commands in the "steps", ' + 'or by providing a script execution definition in the "exec".'; + } +} + +class RecursiveScriptCallException implements MelosException { + RecursiveScriptCallException._(this.scriptName); + + final String scriptName; + + @override + String toString() { + return 'RecursiveScriptCallException: Detected a recursive call in script ' + 'execution. The script "$scriptName" calls itself or forms a recursive ' + 'loop.'; + } +} diff --git a/packages/melos/lib/src/scripts.dart b/packages/melos/lib/src/scripts.dart index d7a1bc910..fbe313eb9 100644 --- a/packages/melos/lib/src/scripts.dart +++ b/packages/melos/lib/src/scripts.dart @@ -116,11 +116,12 @@ ExecOptions( class Script { const Script({ required this.name, - required this.run, + this.run, this.description, this.env = const {}, this.packageFilters, this.exec, + this.steps = const [], }); factory Script.fromYaml( @@ -129,87 +130,119 @@ class Script { required String workspacePath, }) { final scriptPath = 'scripts/$name'; - String run; + String? run; String? description; var env = {}; + final List steps; PackageFilters? packageFilters; ExecOptions? exec; if (yaml is String) { run = yaml; - } else if (yaml is Map) { - 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, + steps = []; + return Script( + name: name, + run: run, + steps: steps, + description: description, + env: env, + packageFilters: packageFilters, + exec: exec, + ); + } + + if (yaml is! Map) { + throw MelosConfigException('Unsupported value for script $name'); + } + + 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.', ); } - - description = assertKeyIsA( - key: 'description', - map: yaml, - path: scriptPath, - ); - final envMap = assertKeyIsA?>( - key: 'env', + run = execYaml; + exec = const ExecOptions(); + } else { + final execMap = assertKeyIsA?>( + key: 'exec', map: yaml, path: scriptPath, ); - env = { - if (envMap != null) - for (final entry in envMap.entries) - assertIsA( - value: entry.key, - key: 'env', + exec = execMap != null + ? execOptionsFromYaml(execMap, scriptName: name) + : null; + } + + final stepsList = yaml['steps']; + steps = stepsList is List && stepsList.isNotEmpty + ? assertListIsA( + key: 'steps', + map: yaml, + isRequired: false, + assertItemIsA: (index, value) { + return assertIsA( + value: value, + index: index, + path: scriptPath, + ); + }, + ) + : []; + + final runYaml = yaml['run']; + if (runYaml is String && runYaml.isNotEmpty) { + run = execYaml is String + ? execYaml + : assertKeyIsA( + key: 'run', + map: yaml, path: scriptPath, - ): entry.value.toString(), - }; + ); + } - final packageFiltersMap = assertKeyIsA?>( - key: 'packageFilters', - map: yaml, - path: scriptPath, - ); + description = assertKeyIsA( + key: 'description', + map: yaml, + path: scriptPath, + ); + final envMap = assertKeyIsA?>( + key: 'env', + map: yaml, + path: scriptPath, + ); - packageFilters = packageFiltersMap == null - ? null - : PackageFilters.fromYaml( - packageFiltersMap, - path: 'scripts/$name/packageFilters', - workspacePath: workspacePath, - ); + env = { + if (envMap != null) + for (final entry in envMap.entries) + assertIsA( + value: entry.key, + key: 'env', + path: scriptPath, + ): entry.value.toString(), + }; - if (execYaml is String) { - exec = const ExecOptions(); - } else { - final execMap = assertKeyIsA?>( - key: 'exec', - map: yaml, - path: scriptPath, - ); + final packageFiltersMap = assertKeyIsA?>( + key: 'packageFilters', + map: yaml, + path: scriptPath, + ); - exec = execMap == null - ? null - : execOptionsFromYaml(execMap, scriptName: name); - } - } else { - throw MelosConfigException('Unsupported value for script $name'); - } + packageFilters = packageFiltersMap == null + ? null + : PackageFilters.fromYaml( + packageFiltersMap, + path: 'scripts/$name/packageFilters', + workspacePath: workspacePath, + ); return Script( name: name, run: run, + steps: steps, description: description, env: env, packageFilters: packageFilters, @@ -266,7 +299,14 @@ class Script { final String name; /// The command specified by the user. - final String run; + final String? run; + + /// A list of individual command steps to be executed as part of this script. + /// Each string in the list represents a separate command to be run. + /// These steps are executed in sequence. This is an alternative to + /// specifying a single command in the [run] variable. If [steps] is + /// provided, [run] should not be used. + final List? steps; /// A short description, shown when using `melos run` with no argument. final String? description; @@ -286,7 +326,7 @@ class Script { List command([List? extraArgs]) { String quoteScript(String script) => '"${script.replaceAll('"', r'\"')}"'; - final scriptCommand = run.split(' ').toList(); + final scriptCommand = run!.split(' ').toList(); if (extraArgs != null && extraArgs.isNotEmpty) { scriptCommand.addAll(extraArgs); } @@ -318,7 +358,9 @@ class Script { /// Validates the script. Throws a [MelosConfigException] if the script is /// invalid. void validate() { - if (exec != null && run.startsWith(_leadingMelosExecRegExp)) { + if (exec != null && + run != null && + run!.startsWith(_leadingMelosExecRegExp)) { throw MelosConfigException( 'Do not use "melos exec" in "run" when also providing options in ' '"exec". In this case the script in "run" is already being executed by ' @@ -337,6 +379,7 @@ class Script { if (description != null) 'description': description, if (env.isNotEmpty) 'env': env, if (packageFilters != null) 'packageFilters': packageFilters!.toJson(), + if (steps != null) 'steps': steps, if (exec != null) 'exec': exec!.toJson(), }; } @@ -350,6 +393,7 @@ class Script { other.description == description && const DeepCollectionEquality().equals(other.env, env) && other.packageFilters == packageFilters && + other.steps == steps && other.exec == exec; @override @@ -360,6 +404,7 @@ class Script { description.hashCode ^ const DeepCollectionEquality().hash(env) ^ packageFilters.hashCode ^ + steps.hashCode ^ exec.hashCode; @override @@ -371,6 +416,7 @@ Script( description: $description, env: $env, packageFilters: ${packageFilters.toString().indent(' ')}, + steps: $steps, exec: ${exec.toString().indent(' ')}, )'''; } diff --git a/packages/melos/test/commands/run_test.dart b/packages/melos/test/commands/run_test.dart index 2598e55da..b47c823ae 100644 --- a/packages/melos/test/commands/run_test.dart +++ b/packages/melos/test/commands/run_test.dart @@ -1,4 +1,5 @@ import 'package:melos/melos.dart'; +import 'package:melos/src/commands/runner.dart'; import 'package:melos/src/common/environment_variable_key.dart'; import 'package:melos/src/common/glob.dart'; import 'package:melos/src/common/io.dart'; @@ -361,5 +362,325 @@ melos run test_script ), ); }); + + test('throws an error if neither run, steps, nor exec are provided', + () async { + final workspaceDir = await createTemporaryWorkspace( + runPubGet: true, + configBuilder: (path) => MelosWorkspaceConfig( + path: path, + name: 'test_package', + packages: [ + createGlob('packages/**', currentDirectoryPath: path), + ], + scripts: const Scripts({ + 'test_script': Script( + name: 'test_script', + ), + }), + ), + ); + + await createProject( + workspaceDir, + const PubSpec(name: 'a'), + ); + + final logger = TestLogger(); + final config = await MelosWorkspaceConfig.fromWorkspaceRoot(workspaceDir); + final melos = Melos( + logger: logger, + config: config, + ); + + expect(() => melos.run(scriptName: 'test_script'), throwsException); + }); + + test( + 'throws an error if neither run or steps are provided, and exec ' + 'are options', () async { + final workspaceDir = await createTemporaryWorkspace( + runPubGet: true, + configBuilder: (path) => MelosWorkspaceConfig( + path: path, + name: 'test_package', + packages: [ + createGlob('packages/**', currentDirectoryPath: path), + ], + scripts: const Scripts({ + 'test_script': Script( + name: 'test_script', + exec: ExecOptions(), + ), + }), + ), + ); + + await createProject( + workspaceDir, + const PubSpec(name: 'a'), + ); + + final logger = TestLogger(); + final config = await MelosWorkspaceConfig.fromWorkspaceRoot(workspaceDir); + final melos = Melos( + logger: logger, + config: config, + ); + + expect(() => melos.run(scriptName: 'test_script'), throwsException); + }); + }); + + group('multiple scripts', () { + test( + 'verifies that a melos script can successfully call another ' + 'script as a step and execute commands', () async { + final workspaceDir = await createTemporaryWorkspace( + runPubGet: true, + configBuilder: (path) => MelosWorkspaceConfig( + path: path, + name: 'test_package', + packages: [ + createGlob('packages/**', currentDirectoryPath: path), + ], + scripts: const Scripts({ + 'hello_script': Script( + name: 'hello_script', + steps: ['test_script', 'echo "hello world"'], + ), + 'test_script': Script( + name: 'test_script', + run: 'echo "test_script"', + ), + }), + ), + ); + + await createProject( + workspaceDir, + const PubSpec(name: 'a'), + ); + + final logger = TestLogger(); + final config = await MelosWorkspaceConfig.fromWorkspaceRoot(workspaceDir); + final melos = Melos( + logger: logger, + config: config, + ); + + await melos.run(scriptName: 'hello_script', noSelect: true); + + expect( + logger.output.normalizeNewLines(), + ignoringAnsii( + ''' +melos run hello_script + └> test_script + └> RUNNING + +melos run test_script + └> echo "test_script" + └> RUNNING + +${currentPlatform.isWindows ? '"test_script"' : 'test_script'} + +melos run test_script + └> echo "test_script" + └> SUCCESS + +melos run hello_script + └> test_script + └> SUCCESS + +melos run hello_script + └> echo "hello world" + └> RUNNING + +${currentPlatform.isWindows ? '"hello world"' : 'hello world'} + +melos run hello_script + └> echo "hello world" + └> SUCCESS + +''', + ), + ); + }); + + test( + 'throws an error if a script defined with steps also includes exec ' + 'options', () async { + final workspaceDir = await createTemporaryWorkspace( + runPubGet: true, + configBuilder: (path) => MelosWorkspaceConfig( + path: path, + name: 'test_package', + packages: [ + createGlob('packages/**', currentDirectoryPath: path), + ], + scripts: const Scripts({ + 'hello_script': Script( + name: 'hello_script', + steps: ['test_script', 'echo "hello world"'], + exec: ExecOptions( + concurrency: 5, + ), + ), + 'test_script': Script( + name: 'test_script', + run: 'echo "test_script"', + ), + }), + ), + ); + + await createProject( + workspaceDir, + const PubSpec(name: 'a'), + ); + + final logger = TestLogger(); + final config = await MelosWorkspaceConfig.fromWorkspaceRoot(workspaceDir); + final melos = Melos( + logger: logger, + config: config, + ); + + expect( + () => melos.run(scriptName: 'hello_script'), + throwsException, + ); + }); + + test( + 'verifies that a melos script can call another script containing ' + 'steps, and ensures all commands in those steps are executed ' + 'successfully', () async { + final workspaceDir = await createTemporaryWorkspace( + runPubGet: true, + configBuilder: (path) => MelosWorkspaceConfig( + path: path, + name: 'test_package', + packages: [ + createGlob('packages/**', currentDirectoryPath: path), + ], + scripts: const Scripts({ + 'hello_script': Script( + name: 'hello_script', + steps: ['test_script', 'echo "hello world"'], + ), + 'test_script': Script( + name: 'test_script', + steps: ['echo "test_script_1"', 'echo "test_script_2"'], + ), + }), + ), + ); + + await createProject( + workspaceDir, + const PubSpec(name: 'a'), + ); + + final logger = TestLogger(); + final config = await MelosWorkspaceConfig.fromWorkspaceRoot(workspaceDir); + final melos = Melos( + logger: logger, + config: config, + ); + + await melos.run(scriptName: 'hello_script', noSelect: true); + + expect( + logger.output.normalizeNewLines(), + ignoringAnsii( + ''' +melos run hello_script + └> test_script + └> RUNNING + +melos run test_script + └> echo "test_script_1" + └> RUNNING + +${currentPlatform.isWindows ? '"test_script_1"' : 'test_script_1'} + +melos run test_script + └> echo "test_script_1" + └> SUCCESS + +melos run test_script + └> echo "test_script_2" + └> RUNNING + +${currentPlatform.isWindows ? '"test_script_2"' : 'test_script_2'} + +melos run test_script + └> echo "test_script_2" + └> SUCCESS + + +melos run hello_script + └> test_script + └> SUCCESS + +melos run hello_script + └> echo "hello world" + └> RUNNING + +${currentPlatform.isWindows ? '"hello world"' : 'hello world'} + +melos run hello_script + └> echo "hello world" + └> SUCCESS + +''', + ), + ); + }); + + test( + 'throw an error if correctly identifies when a script indirectly ' + 'calls itself through another script, leading to a recursive call', + () async { + final workspaceDir = await createTemporaryWorkspace( + runPubGet: true, + configBuilder: (path) => MelosWorkspaceConfig( + path: path, + name: 'test_package', + packages: [ + createGlob('packages/**', currentDirectoryPath: path), + ], + scripts: const Scripts({ + 'hello_script': Script( + name: 'hello_script', + steps: ['test_script', 'echo "hello world"'], + ), + 'test_script': Script( + name: 'test_script', + steps: ['echo "test_script_1"', 'hello_script'], + ), + }), + ), + ); + + await createProject( + workspaceDir, + const PubSpec(name: 'a'), + ); + + final logger = TestLogger(); + final config = await MelosWorkspaceConfig.fromWorkspaceRoot(workspaceDir); + final melos = Melos( + logger: logger, + config: config, + ); + + expect( + () => melos.run(scriptName: 'hello_script', noSelect: true), + throwsA(const TypeMatcher()), + ); + }); }); }