Skip to content

Commit

Permalink
feat: simplify writing scripts that use melos exec (#315)
Browse files Browse the repository at this point in the history
Fixes #266
  • Loading branch information
blaugold authored Jun 8, 2022
1 parent f0a62a2 commit 3e5807d
Show file tree
Hide file tree
Showing 6 changed files with 344 additions and 46 deletions.
43 changes: 42 additions & 1 deletion docs/configuration/scripts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
```
Expand Down
3 changes: 2 additions & 1 deletion docs/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 2 additions & 2 deletions packages/melos/lib/src/commands/run.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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}'));
Expand Down
166 changes: 143 additions & 23 deletions packages/melos/lib/src/scripts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,48 @@ 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,
required this.run,
this.description,
this.env = const {},
this.filter,
this.exec,
});

factory Script.fromYaml(
Expand All @@ -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,
Expand Down Expand Up @@ -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');
}
Expand All @@ -163,6 +225,7 @@ class Script {
description: description,
env: env,
filter: packageFilter,
exec: exec,
);
}

Expand All @@ -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) {
Expand All @@ -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(
Expand All @@ -273,29 +336,83 @@ 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,
'run': run,
if (description != null) 'description': description,
if (env.isNotEmpty) 'env': env,
if (filter != null) 'select-package': filter!.toJson(),
if (exec != null) 'exec': exec!.toJson(),
};
}

Expand All @@ -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 =>
Expand All @@ -316,7 +434,8 @@ class Script {
run.hashCode ^
description.hashCode ^
const DeepCollectionEquality().hash(env) ^
filter.hashCode;
filter.hashCode ^
exec.hashCode;

@override
String toString() {
Expand All @@ -327,6 +446,7 @@ Script(
description: $description,
env: $env,
packageFilter: ${filter.toString().indent(' ')},
exec: ${exec.toString().indent(' ')},
)''';
}
}
Loading

0 comments on commit 3e5807d

Please sign in to comment.