diff --git a/docs/configuration/overview.mdx b/docs/configuration/overview.mdx index 364450b9..1814f4bc 100644 --- a/docs/configuration/overview.mdx +++ b/docs/configuration/overview.mdx @@ -31,6 +31,24 @@ Supported hosts: repository: https://github.com/invertase/melos ``` +## `sdkPath` + +Path to the Dart/Flutter SDK that should be used. + +Relative paths are resolved relative to the `melos.yaml` file. + +To use the system-wide SDK, provide the special value "auto". + +If the SDK path is specified though multiple mechanisms, the precedence from highest to lowest is: + +1. `--sdk-path` global command line option +2. `MELOS_SDK_PATH` environment variable +3. `sdkPath` in `melos.yaml` + +```yaml +sdkPath: .fvm/flutter_sdk +``` + ## `packages` > required diff --git a/docs/environment-variables.mdx b/docs/environment-variables.mdx index fddacedb..553b9dd2 100644 --- a/docs/environment-variables.mdx +++ b/docs/environment-variables.mdx @@ -47,4 +47,9 @@ Note, this 'parent package' functionality only currently works when the 'child p ### `MELOS_PACKAGES` -Define a comma delimited list of package names that Melos should focus on. This bypasses all filtering flags if defined. \ No newline at end of file +Define a comma delimited list of package names that Melos should focus on. This bypasses all filtering flags if defined. + +### `MELOS_SDK_PATH` + +The path to the Dart/Flutter SDK to use. This environment variable has precedence over the `sdkPath` +option in `melos.yaml`, but is overridden by the command line option `--sdk-path`. diff --git a/packages/melos/lib/melos.dart b/packages/melos/lib/melos.dart index 47a9d98a..5479f42e 100644 --- a/packages/melos/lib/melos.dart +++ b/packages/melos/lib/melos.dart @@ -10,6 +10,7 @@ export 'src/commands/runner.dart' ListOutputKind; export 'src/common/exception.dart' show CancelledException, MelosException; export 'src/common/validation.dart' show MelosConfigException; +export 'src/global_options.dart' show GlobalOptions; export 'src/package.dart' show Package, PackageFilter, PackageMap, PackageType; export 'src/workspace.dart' show IdeWorkspace, MelosWorkspace; export 'src/workspace_configs.dart' diff --git a/packages/melos/lib/src/command_runner.dart b/packages/melos/lib/src/command_runner.dart index 89fdb8f8..633fa081 100644 --- a/packages/melos/lib/src/command_runner.dart +++ b/packages/melos/lib/src/command_runner.dart @@ -17,6 +17,7 @@ import 'package:args/args.dart'; import 'package:args/command_runner.dart'; + import 'command_runner/bootstrap.dart'; import 'command_runner/clean.dart'; import 'command_runner/exec.dart'; @@ -35,7 +36,7 @@ import 'workspace_configs.dart'; /// ```dart /// final melos = MelosCommandRunner(); /// -/// await melos.run(['boostrap']); +/// await melos.run(['bootstrap']); /// ``` class MelosCommandRunner extends CommandRunner { MelosCommandRunner(MelosWorkspaceConfig config) @@ -45,10 +46,18 @@ class MelosCommandRunner extends CommandRunner { usageLineLength: terminalWidth, ) { argParser.addFlag( - 'verbose', + globalOptionVerbose, negatable: false, help: 'Enable verbose logging.', ); + argParser.addOption( + globalOptionSdkPath, + help: 'Path to the Dart/Flutter SDK that should be used. This command ' + 'line option has precedence over the `sdkPath` option in the ' + '`melos.yaml` configuration file and the `MELOS_SDK_PATH` ' + 'environment variable. To use the system-wide SDK, provide ' + 'the special value "auto".', + ); addCommand(ExecCommand(config)); addCommand(BootstrapCommand(config)); diff --git a/packages/melos/lib/src/command_runner/base.dart b/packages/melos/lib/src/command_runner/base.dart index 57072b38..f6f83e3a 100644 --- a/packages/melos/lib/src/command_runner/base.dart +++ b/packages/melos/lib/src/command_runner/base.dart @@ -4,6 +4,7 @@ import 'package:cli_util/cli_logging.dart'; import '../common/glob.dart'; import '../common/utils.dart'; +import '../global_options.dart'; import '../package.dart'; import '../workspace_configs.dart'; @@ -12,8 +13,10 @@ abstract class MelosCommand extends Command { final MelosWorkspaceConfig config; - Logger? get logger => - globalResults!['verbose'] as bool ? Logger.verbose() : Logger.standard(); + /// The global Melos options parsed from the command line. + late final global = _parseGlobalOptions(); + + Logger? get logger => global.verbose ? Logger.verbose() : Logger.standard(); /// The `melos.yaml` configuration for this command. /// see [ArgParser.allowTrailingOptions] @@ -26,6 +29,13 @@ abstract class MelosCommand extends Command { allowTrailingOptions: allowTrailingOptions, ); + GlobalOptions _parseGlobalOptions() { + return GlobalOptions( + verbose: globalResults![globalOptionVerbose]! as bool, + sdkPath: globalResults![globalOptionSdkPath] as String?, + ); + } + void setupPackageFilterParser() { argParser.addFlag( filterOptionPrivate, diff --git a/packages/melos/lib/src/command_runner/bootstrap.dart b/packages/melos/lib/src/command_runner/bootstrap.dart index 0fb1bd15..b0f64df1 100644 --- a/packages/melos/lib/src/command_runner/bootstrap.dart +++ b/packages/melos/lib/src/command_runner/bootstrap.dart @@ -43,6 +43,7 @@ class BootstrapCommand extends MelosCommand { final melos = Melos(logger: logger, config: config); return melos.bootstrap( + global: global, filter: parsePackageFilter(config.path), ); } diff --git a/packages/melos/lib/src/command_runner/clean.dart b/packages/melos/lib/src/command_runner/clean.dart index a122d6a6..c6d0e4d4 100644 --- a/packages/melos/lib/src/command_runner/clean.dart +++ b/packages/melos/lib/src/command_runner/clean.dart @@ -38,6 +38,7 @@ class CleanCommand extends MelosCommand { final melos = Melos(logger: logger, config: config); await melos.clean( + global: global, filter: parsePackageFilter(config.path), ); } diff --git a/packages/melos/lib/src/command_runner/exec.dart b/packages/melos/lib/src/command_runner/exec.dart index d09f3fc3..dd1a1034 100644 --- a/packages/melos/lib/src/command_runner/exec.dart +++ b/packages/melos/lib/src/command_runner/exec.dart @@ -63,6 +63,7 @@ class ExecCommand extends MelosCommand { execArgs, concurrency: concurrency, failFast: failFast, + global: global, filter: packageFilter, ); } diff --git a/packages/melos/lib/src/command_runner/list.dart b/packages/melos/lib/src/command_runner/list.dart index 7c60c554..e7c35854 100644 --- a/packages/melos/lib/src/command_runner/list.dart +++ b/packages/melos/lib/src/command_runner/list.dart @@ -91,6 +91,7 @@ class ListCommand extends MelosCommand { return melos.list( long: long, + global: global, filter: parsePackageFilter(config.path), relativePaths: relative, kind: kind, diff --git a/packages/melos/lib/src/command_runner/publish.dart b/packages/melos/lib/src/command_runner/publish.dart index 6adfdb65..b4fce91f 100644 --- a/packages/melos/lib/src/command_runner/publish.dart +++ b/packages/melos/lib/src/command_runner/publish.dart @@ -61,6 +61,7 @@ class PublishCommand extends MelosCommand { final filter = parsePackageFilter(config.path); return melos.publish( + global: global, filter: filter, dryRun: dryRun, force: yes, diff --git a/packages/melos/lib/src/command_runner/version.dart b/packages/melos/lib/src/command_runner/version.dart index b02680ea..a548b373 100644 --- a/packages/melos/lib/src/command_runner/version.dart +++ b/packages/melos/lib/src/command_runner/version.dart @@ -217,6 +217,7 @@ class VersionCommand extends MelosCommand { } await melos.version( + global: global, filter: parsePackageFilter(config.path), force: force, gitTag: tag, diff --git a/packages/melos/lib/src/commands/bootstrap.dart b/packages/melos/lib/src/commands/bootstrap.dart index 073167b3..35e272fa 100644 --- a/packages/melos/lib/src/commands/bootstrap.dart +++ b/packages/melos/lib/src/commands/bootstrap.dart @@ -5,15 +5,20 @@ final _warningLabel = AnsiStyles.yellow('WARNING'); final _checkLabel = AnsiStyles.greenBright('✓'); mixin _BootstrapMixin on _CleanMixin { - Future bootstrap({PackageFilter? filter}) async { - final workspace = await createWorkspace(filter: filter); + Future bootstrap({GlobalOptions? global, PackageFilter? filter}) async { + final workspace = await createWorkspace(global: global, filter: filter); return _runLifecycle( workspace, ScriptLifecycle.bootstrap, () async { - final pubCommandForLogging = - "${workspace.isFlutterWorkspace ? "flutter " : "dart "}pub get"; + final pubCommandForLogging = [ + ...pubCommandExecArgs( + useFlutter: workspace.isFlutterWorkspace, + workspace: workspace, + ), + 'get' + ].join(' '); logger?.stdout(AnsiStyles.yellow.bold('melos bootstrap')); logger?.stdout(' └> ${AnsiStyles.cyan.bold(workspace.path)}\n'); @@ -61,7 +66,7 @@ mixin _BootstrapMixin on _CleanMixin { Future _linkPackagesWithPubspecOverrides( MelosWorkspace workspace, ) async { - if (!isPubspecOverridesSupported) { + if (!workspace.isPubspecOverridesSupported) { logger?.stderr( '$_warningLabel: Dart 2.17.0 or greater is required to use Melos with ' 'pubspec overrides.', @@ -88,7 +93,7 @@ mixin _BootstrapMixin on _CleanMixin { ); }, parallelism: workspace.config.commands.bootstrap.runPubGetInParallel && - canRunPubGetConcurrently + workspace.canRunPubGetConcurrently ? null : 1, ).drain(); @@ -167,7 +172,7 @@ mixin _BootstrapMixin on _CleanMixin { return package; }, parallelism: workspace.config.commands.bootstrap.runPubGetInParallel && - canRunPubGetConcurrently + workspace.canRunPubGetConcurrently ? null : 1, ); @@ -177,10 +182,14 @@ mixin _BootstrapMixin on _CleanMixin { Package package, { bool inTemporaryProject = false, }) async { - final pubGetArgs = ['pub', 'get']; - final execArgs = package.isFlutterPackage - ? ['flutter', ...pubGetArgs] - : [if (utils.isPubSubcommand()) 'dart', ...pubGetArgs]; + final execArgs = [ + ...pubCommandExecArgs( + useFlutter: package.isFlutterPackage, + workspace: workspace, + ), + 'get', + ]; + final executable = currentPlatform.isWindows ? 'cmd' : '/bin/sh'; final packagePath = inTemporaryProject ? join(workspace.melosToolPath, package.pathRelativeToWorkspace) diff --git a/packages/melos/lib/src/commands/clean.dart b/packages/melos/lib/src/commands/clean.dart index dd6c91b0..8a7db213 100644 --- a/packages/melos/lib/src/commands/clean.dart +++ b/packages/melos/lib/src/commands/clean.dart @@ -1,8 +1,8 @@ part of 'runner.dart'; mixin _CleanMixin on _Melos { - Future clean({PackageFilter? filter}) async { - final workspace = await createWorkspace(filter: filter); + Future clean({GlobalOptions? global, PackageFilter? filter}) async { + final workspace = await createWorkspace(global: global, filter: filter); return _runLifecycle( workspace, diff --git a/packages/melos/lib/src/commands/exec.dart b/packages/melos/lib/src/commands/exec.dart index c0339cfc..4fd81ab0 100644 --- a/packages/melos/lib/src/commands/exec.dart +++ b/packages/melos/lib/src/commands/exec.dart @@ -3,11 +3,12 @@ part of 'runner.dart'; mixin _ExecMixin on _Melos { Future exec( List execArgs, { + GlobalOptions? global, PackageFilter? filter, int concurrency = 5, bool failFast = false, }) async { - final workspace = await createWorkspace(filter: filter); + final workspace = await createWorkspace(global: global, filter: filter); final packages = workspace.filteredPackages.values; await _execForAllPackages( @@ -34,6 +35,9 @@ mixin _ExecMixin on _Melos { 'MELOS_PACKAGE_VERSION': (package.version).toString(), 'MELOS_PACKAGE_PATH': package.path, 'MELOS_ROOT_PATH': workspace.path, + if (workspace.sdkPath != null) envKeyMelosSdkPath: workspace.sdkPath!, + if (workspace.childProcessPath != null) + 'PATH': workspace.childProcessPath!, }; // TODO what if it's not called 'example'? @@ -66,8 +70,9 @@ mixin _ExecMixin on _Melos { environment.remove('MELOS_PARENT_PACKAGE_NAME'); environment.remove('MELOS_PARENT_PACKAGE_VERSION'); environment.remove('MELOS_PARENT_PACKAGE_PATH'); - environment.remove('MELOS_PACKAGES'); - environment.remove('MELOS_TERMINAL_WIDTH'); + environment.remove(envKeyMelosPackages); + environment.remove(envKeyMelosSdkPath); + environment.remove(envKeyMelosTerminalWidth); } return startProcess( diff --git a/packages/melos/lib/src/commands/list.dart b/packages/melos/lib/src/commands/list.dart index f56ac072..ceee3a35 100644 --- a/packages/melos/lib/src/commands/list.dart +++ b/packages/melos/lib/src/commands/list.dart @@ -5,12 +5,13 @@ enum ListOutputKind { json, parsable, graph, gviz, column } mixin _ListMixin on _Melos { Future list({ + GlobalOptions? global, bool long = false, bool relativePaths = false, PackageFilter? filter, ListOutputKind kind = ListOutputKind.column, }) async { - final workspace = await createWorkspace(filter: filter); + final workspace = await createWorkspace(global: global, filter: filter); switch (kind) { case ListOutputKind.graph: diff --git a/packages/melos/lib/src/commands/publish.dart b/packages/melos/lib/src/commands/publish.dart index 94b7a4cd..ba0110da 100644 --- a/packages/melos/lib/src/commands/publish.dart +++ b/packages/melos/lib/src/commands/publish.dart @@ -2,13 +2,14 @@ part of 'runner.dart'; mixin _PublishMixin on _ExecMixin { Future publish({ + GlobalOptions? global, PackageFilter? filter, bool dryRun = true, bool gitTagVersion = true, // yes bool force = false, }) async { - final workspace = await createWorkspace(filter: filter); + final workspace = await createWorkspace(global: global, filter: filter); logger?.stdout( AnsiStyles.yellow.bold('melos publish${dryRun ? " --dry-run" : ''}'), @@ -142,8 +143,7 @@ mixin _PublishMixin on _ExecMixin { 'Publishing ${unpublishedPackages.length} packages to registry:', ); final execArgs = [ - if (isPubSubcommand()) 'dart', - 'pub', + ...pubCommandExecArgs(useFlutter: false, workspace: workspace), 'publish', ]; diff --git a/packages/melos/lib/src/commands/run.dart b/packages/melos/lib/src/commands/run.dart index ddb8a574..39088649 100644 --- a/packages/melos/lib/src/commands/run.dart +++ b/packages/melos/lib/src/commands/run.dart @@ -3,6 +3,7 @@ part of 'runner.dart'; mixin _RunMixin on _Melos { @override Future run({ + GlobalOptions? global, String? scriptName, bool noSelect = false, List extraArgs = const [], @@ -22,6 +23,7 @@ mixin _RunMixin on _Melos { final exitCode = await _runScript( script, config, + global: global, noSelect: noSelect, extraArgs: extraArgs, ); @@ -67,24 +69,30 @@ mixin _RunMixin on _Melos { Future _runScript( Script script, MelosWorkspaceConfig config, { + GlobalOptions? global, required bool noSelect, List extraArgs = const [], }) async { + final workspace = await MelosWorkspace.fromConfig( + config, + global: global, + filter: script.filter?.copyWithUpdatedIgnore([ + ...script.filter!.ignore, + ...config.ignore, + ]), + logger: logger, + ) + ..validate(); + final environment = { 'MELOS_ROOT_PATH': config.path, + if (workspace.sdkPath != null) envKeyMelosSdkPath: workspace.sdkPath!, + if (workspace.childProcessPath != null) + 'PATH': workspace.childProcessPath!, ...script.env, }; if (script.filter != null) { - final workspace = await MelosWorkspace.fromConfig( - config, - filter: script.filter!.copyWithUpdatedIgnore([ - ...script.filter!.ignore, - ...config.ignore, - ]), - logger: logger, - ); - final packages = workspace.filteredPackages.values.toList(); var choices = packages.map((e) => AnsiStyles.cyan(e.name)).toList(); diff --git a/packages/melos/lib/src/commands/runner.dart b/packages/melos/lib/src/commands/runner.dart index 1b643684..deef5ea4 100644 --- a/packages/melos/lib/src/commands/runner.dart +++ b/packages/melos/lib/src/commands/runner.dart @@ -28,6 +28,7 @@ import '../common/utils.dart'; import '../common/utils.dart' as utils; import '../common/versioning.dart' as versioning; import '../common/workspace_changelog.dart'; +import '../global_options.dart'; import '../package.dart'; import '../prompts/prompt.dart' as prompts; import '../scripts.dart'; @@ -72,7 +73,10 @@ abstract class _Melos { Logger? get logger; MelosWorkspaceConfig get config; - Future createWorkspace({PackageFilter? filter}) async { + Future createWorkspace({ + GlobalOptions? global, + PackageFilter? filter, + }) async { var filterWithEnv = filter; if (currentPlatform.environment.containsKey(envKeyMelosPackages)) { @@ -89,11 +93,13 @@ abstract class _Melos { ); } - return MelosWorkspace.fromConfig( + return (await MelosWorkspace.fromConfig( config, + global: global, filter: filterWithEnv, logger: logger, - ); + )) + ..validate(); } Future _runLifecycle( diff --git a/packages/melos/lib/src/commands/version.dart b/packages/melos/lib/src/commands/version.dart index 947cd505..93882a33 100644 --- a/packages/melos/lib/src/commands/version.dart +++ b/packages/melos/lib/src/commands/version.dart @@ -4,6 +4,7 @@ mixin _VersionMixin on _RunMixin { /// Version packages automatically based on the git history or with manually /// specified versions. Future version({ + GlobalOptions? global, PackageFilter? filter, bool asPrerelease = false, bool asStableRelease = false, @@ -36,6 +37,7 @@ mixin _VersionMixin on _RunMixin { } final workspace = await createWorkspace( + global: global, // We ignore `since` package list filtering on the 'version' command as it // already filters it itself, filtering here would map dependant version fail // as it won't be aware of any packages that have been filtered out here diff --git a/packages/melos/lib/src/common/utils.dart b/packages/melos/lib/src/common/utils.dart index 09ff38d7..203b5346 100644 --- a/packages/melos/lib/src/common/utils.dart +++ b/packages/melos/lib/src/common/utils.dart @@ -23,15 +23,20 @@ import 'dart:isolate'; import 'package:ansi_styles/ansi_styles.dart'; import 'package:cli_util/cli_logging.dart'; import 'package:graphs/graphs.dart'; -import 'package:path/path.dart' - show relative, normalize, windows, posix, joinAll; +import 'package:path/path.dart' as p; import 'package:pub_semver/pub_semver.dart'; import 'package:yaml/yaml.dart'; import '../package.dart'; import '../prompts/prompt.dart' as prompts; +import '../workspace.dart'; import 'platform.dart'; +const globalOptionVerbose = 'verbose'; +const globalOptionSdkPath = 'sdk-path'; + +const autoSdkPathOptionValue = 'auto'; + const filterOptionScope = 'scope'; const filterOptionIgnore = 'ignore'; const filterOptionDirExists = 'dir-exists'; @@ -62,6 +67,8 @@ String describeEnum(Object value) => value.toString().split('.').last; // This can be user defined or can come from package selection in `melos run`. const envKeyMelosPackages = 'MELOS_PACKAGES'; +const envKeyMelosSdkPath = 'MELOS_SDK_PATH'; + const envKeyMelosTerminalWidth = 'MELOS_TERMINAL_WIDTH'; final melosPackageUri = Uri.parse('package:melos/melos.dart'); @@ -104,19 +111,47 @@ int get terminalWidth { return 80; } -Version get currentDartVersion { - return Version.parse(currentPlatform.version.split(' ')[0]); +// https://regex101.com/r/XlfVPy/1 +final _dartSdkVersionRegexp = RegExp(r'^Dart SDK version: (\S+)'); + +Version currentDartVersion(String dartTool) { + final result = Process.runSync( + dartTool, + ['--version'], + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + + if (result.exitCode != 0) { + throw Exception( + 'Failed to get current Dart version:\n${result.stdout}\n${result.stderr}', + ); + } + + // Older Dart SDK versions output to stderr instead of stdout. + final stdout = result.stdout as String; + final stderr = result.stderr as String; + final versionOutput = stdout.trim().isEmpty ? stderr : stdout; + + final versionString = + _dartSdkVersionRegexp.matchAsPrefix(versionOutput)?.group(1); + if (versionString == null) { + throw Exception('Unable to parse Dart version from:\n$versionOutput'); + } + + return Version.parse(versionString); } -String get nextDartMajorVersion { - return currentDartVersion.nextMajor.toString(); +String nextDartMajorVersion([String dartTool = 'dart']) { + return currentDartVersion(dartTool).nextMajor.toString(); } -bool get isPubspecOverridesSupported => - currentDartVersion.compareTo(Version.parse('2.17.0-266.0.dev')) >= 0; +bool isPubspecOverridesSupported([String dartTool = 'dart']) => + currentDartVersion(dartTool).compareTo(Version.parse('2.17.0-266.0.dev')) >= + 0; -bool get canRunPubGetConcurrently => - currentDartVersion.compareTo(Version.parse('2.16.0')) >= 0; +bool canRunPubGetConcurrently([String dartTool = 'dart']) => + currentDartVersion(dartTool).compareTo(Version.parse('2.16.0')) >= 0; String promptInput(String message, {String? defaultsTo}) { return prompts.get(message, defaultsTo: defaultsTo); @@ -135,11 +170,25 @@ bool get isCI { } String printablePath(String path) { - return posix - .prettyUri(posix.normalize(path)) + return p.posix + .prettyUri(p.posix.normalize(path)) .replaceAll(RegExp(r'[\/\\]+'), '/'); } +String get pathEnvVarSeparator => currentPlatform.isWindows ? ';' : ':'; + +String addToPathEnvVar({ + required String directory, + required String currentPath, + bool prepend = false, +}) { + if (prepend) { + return '$directory$pathEnvVarSeparator$currentPath'; + } else { + return '$currentPath$pathEnvVarSeparator$directory'; + } +} + Future getMelosRoot() async { final melosPackageFileUri = await Isolate.resolvePackageUri(melosPackageUri); @@ -165,28 +214,28 @@ Future loadYamlFile(String path) async { } String melosYamlPathForDirectory(Directory directory) { - return joinAll([directory.path, 'melos.yaml']); + return p.joinAll([directory.path, 'melos.yaml']); } String melosStatePathForDirectory(Directory directory) { - return joinAll([directory.path, '.melos']); + return p.joinAll([directory.path, '.melos']); } String pubspecPathForDirectory(Directory directory) { - return joinAll([directory.path, 'pubspec.yaml']); + return p.joinAll([directory.path, 'pubspec.yaml']); } String pubspecOverridesPathForDirectory(Directory directory) { - return joinAll([directory.path, 'pubspec_overrides.yaml']); + return p.joinAll([directory.path, 'pubspec_overrides.yaml']); } String relativePath(String path, String from) { if (currentPlatform.isWindows) { - return windows - .normalize(relative(path, from: from)) + return p.windows + .normalize(p.relative(path, from: from)) .replaceAll(r'\', r'\\'); } - return normalize(relative(path, from: from)); + return p.normalize(p.relative(path, from: from)); } String listAsPaddedTable(List> table, {int paddingSize = 1}) { @@ -352,9 +401,10 @@ Future startProcess( return exitCode; } -bool isPubSubcommand() { +bool isPubSubcommand({required MelosWorkspace workspace}) { try { - return Process.runSync('pub', ['--version']).exitCode != 0; + return Process.runSync(workspace.sdkTool('pub'), ['--version']).exitCode != + 0; } on ProcessException { return true; } @@ -382,6 +432,24 @@ void sortPackagesTopologically(List packages) { } } +/// Given a workspace and package, this assembles the correct command +/// to run pub / dart pub / flutter pub. +/// Takes into account a potential sdk path being provided. +/// If no sdk path is provided then it will assume to use the pub +/// command available in PATH. +List pubCommandExecArgs({ + required bool useFlutter, + required MelosWorkspace workspace, +}) { + return [ + if (useFlutter) + workspace.sdkTool('flutter') + else if (isPubSubcommand(workspace: workspace)) + workspace.sdkTool('dart'), + 'pub', + ]; +} + extension DirectoryUtils on Directory { /// Lists the sub-directories and files of this [Directory] similar to [list]. /// However instead of just having a `recursive` parameter that decides diff --git a/packages/melos/lib/src/common/validation.dart b/packages/melos/lib/src/common/validation.dart index a3612032..8e0e36fa 100644 --- a/packages/melos/lib/src/common/validation.dart +++ b/packages/melos/lib/src/common/validation.dart @@ -98,7 +98,7 @@ class MelosConfigException implements MelosException { Object? key, int? index, String? path, - }) : this('${_descriptor(key: key, index: index, path: path)} is is required but missing'); + }) : this('${_descriptor(key: key, index: index, path: path)} is required but missing'); MelosConfigException.invalidType({ required Object expectedType, diff --git a/packages/melos/lib/src/global_options.dart b/packages/melos/lib/src/global_options.dart new file mode 100644 index 00000000..b1b375f5 --- /dev/null +++ b/packages/melos/lib/src/global_options.dart @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +/// Global options that apply to all Melos commands. +class GlobalOptions { + const GlobalOptions({ + this.verbose = false, + this.sdkPath, + }); + + /// Whether to print verbose output. + final bool verbose; + + /// Path to the Dart/Flutter SDK that should be used. + final String? sdkPath; + + Map toJson() { + return { + 'verbose': verbose, + 'sdkPath': sdkPath, + }; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GlobalOptions && + other.runtimeType == runtimeType && + other.verbose == verbose && + other.sdkPath == sdkPath; + + @override + int get hashCode => verbose.hashCode ^ sdkPath.hashCode; + + @override + String toString() { + return ''' +GlobalOptions( + verbose: $verbose, + sdkPath: $sdkPath, +)'''; + } +} diff --git a/packages/melos/lib/src/package.dart b/packages/melos/lib/src/package.dart index 89b64ac1..31e073e7 100644 --- a/packages/melos/lib/src/package.dart +++ b/packages/melos/lib/src/package.dart @@ -186,7 +186,7 @@ class PackageFilter { /// Include/Exclude packages with `publish_to: none`. final bool? includePrivatePackages; - /// Include/exlude packages that are up-to-date on pub.dev + /// Include/exclude packages that are up-to-date on pub.dev final bool? published; /// Include/exclude packages that are null-safe. diff --git a/packages/melos/lib/src/workspace.dart b/packages/melos/lib/src/workspace.dart index 45591028..2046e65f 100644 --- a/packages/melos/lib/src/workspace.dart +++ b/packages/melos/lib/src/workspace.dart @@ -19,11 +19,15 @@ import 'dart:async'; import 'dart:io'; import 'package:cli_util/cli_logging.dart'; -import 'package:path/path.dart'; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as p; import 'common/intellij_project.dart'; +import 'common/platform.dart'; import 'common/pub_dependency_list.dart'; import 'common/utils.dart' as utils; +import 'common/validation.dart'; +import 'global_options.dart'; import 'package.dart'; import 'workspace_configs.dart'; @@ -45,12 +49,14 @@ class MelosWorkspace { required this.config, required this.allPackages, required this.filteredPackages, + required this.sdkPath, this.logger, }); /// Build a [MelosWorkspace] from a workspace configuration. static Future fromConfig( MelosWorkspaceConfig workspaceConfig, { + GlobalOptions? global, PackageFilter? filter, Logger? logger, }) async { @@ -70,6 +76,12 @@ class MelosWorkspace { allPackages: allPackages, logger: logger, filteredPackages: filteredPackages, + sdkPath: resolveSdkPath( + configSdkPath: workspaceConfig.sdkPath, + envSdkPath: currentPlatform.environment[utils.envKeyMelosSdkPath], + commandSdkPath: global?.sdkPath, + workspacePath: workspaceConfig.path, + ), ); } @@ -98,15 +110,75 @@ class MelosWorkspace { late final bool isFlutterWorkspace = allPackages.values.any((package) => package.isFlutterPackage); + /// Path to the Dart/Flutter SDK, if specified by the user. + final String? sdkPath; + + /// Returns the path to a [tool] from the Dart/Flutter SDK. + /// + /// If no [sdkPath] is specified, this will return the name of the tool as is + /// so that it can be used as an executable from PATH. + String sdkTool(String tool) { + final sdkPath = this.sdkPath; + if (sdkPath != null) { + return p.join(sdkPath, 'bin', tool); + } + return tool; + } + + late final bool canRunPubGetConcurrently = + utils.canRunPubGetConcurrently(sdkTool('dart')); + + late final bool isPubspecOverridesSupported = + utils.isPubspecOverridesSupported(sdkTool('dart')); + /// Returns a string path to the 'melos_tool' directory in this workspace. /// This directory should be git ignored and is used by Melos for temporary tasks /// such as pub install. - late final String melosToolPath = join(path, '.dart_tool', 'melos_tool'); + late final String melosToolPath = p.join(path, '.dart_tool', 'melos_tool'); + + /// PATH environment variable for child processes launched in this workspace. + /// + /// Is `null` if the PATH for child processes is the same as the PATH for the + /// current process. + late final String? childProcessPath = sdkPath == null + ? null + : utils.addToPathEnvVar( + directory: p.join(sdkPath!, 'bin'), + currentPath: currentPlatform.environment['PATH']!, + // We prepend the path to the bin directory in the Dart/Flutter SDK + // because we want to shadow any system wide SDK. + prepend: true, + ); + + /// Validates this workspace against the environment. + /// + /// By making this a separate method we can create workspaces for testing + /// which are not strictly valid. + void validate() { + if (sdkPath != null) { + final dartTool = sdkTool('dart'); + if (!File(dartTool).existsSync()) { + throw MelosConfigException( + 'SDK path is not valid. Could not find dart tool at $dartTool', + ); + } + if (isFlutterWorkspace) { + final flutterTool = sdkTool('flutter'); + if (!File(flutterTool).existsSync()) { + throw MelosConfigException( + 'SDK path is not valid. Could not find flutter tool at $dartTool', + ); + } + } + } + } /// Execute a command in the root of this workspace. Future exec(List execArgs, {bool onlyOutputOnError = false}) { final environment = { 'MELOS_ROOT_PATH': path, + if (sdkPath != null) utils.envKeyMelosSdkPath: sdkPath!, + if (childProcessPath != null) 'PATH': childProcessPath!, }; return utils.startProcess( @@ -125,6 +197,8 @@ class MelosWorkspace { }) { final environment = { 'MELOS_ROOT_PATH': path, + if (sdkPath != null) utils.envKeyMelosSdkPath: sdkPath!, + if (childProcessPath != null) 'PATH': childProcessPath!, }; return utils.startProcess( @@ -138,16 +212,19 @@ class MelosWorkspace { /// Builds a dependency graph of dependencies and their dependents in this workspace. Future>> getDependencyGraph() async { + final pubExecArgs = utils.pubCommandExecArgs( + useFlutter: isFlutterWorkspace, + workspace: this, + ); final pubDepsExecArgs = ['--style=list', '--dev']; final pubListCommandOutput = await Process.run( - isFlutterWorkspace - ? 'flutter' - : utils.isPubSubcommand() - ? 'dart' - : 'pub', - isFlutterWorkspace - ? ['pub', 'deps', '--', ...pubDepsExecArgs] - : [if (utils.isPubSubcommand()) 'pub', 'deps', ...pubDepsExecArgs], + pubExecArgs.removeAt(0), + [ + ...pubDepsExecArgs, + 'deps', + if (isFlutterWorkspace) '--', + ...pubDepsExecArgs, + ], runInShell: true, workingDirectory: melosToolPath, ); @@ -193,3 +270,31 @@ class MelosWorkspace { return dependencyGraphFlat; } } + +/// Takes the raw SDK paths from the workspace config file, the environment +/// variable and the command line and resolves the final path. +/// +/// The path provided through the command line takes precedence over the path +/// from the config file. +/// +/// Relative paths are resolved relative to the workspace path. +@visibleForTesting +String? resolveSdkPath({ + required String? configSdkPath, + required String? envSdkPath, + required String? commandSdkPath, + required String workspacePath, +}) { + var sdkPath = commandSdkPath ?? envSdkPath ?? configSdkPath; + if (sdkPath == utils.autoSdkPathOptionValue) { + return null; + } + + /// If the sdk path is a relative one, prepend the workspace path + /// to make it a valid full absolute path now. + if (sdkPath != null && p.isRelative(sdkPath)) { + sdkPath = p.join(workspacePath, sdkPath); + } + + return sdkPath; +} diff --git a/packages/melos/lib/src/workspace_configs.dart b/packages/melos/lib/src/workspace_configs.dart index a430bb03..6f49e1d5 100644 --- a/packages/melos/lib/src/workspace_configs.dart +++ b/packages/melos/lib/src/workspace_configs.dart @@ -359,6 +359,7 @@ class MelosWorkspaceConfig { MelosWorkspaceConfig({ required this.path, required this.name, + this.sdkPath, this.repository, required this.packages, this.ignore = const [], @@ -438,10 +439,16 @@ class MelosWorkspaceConfig { map: yaml, ); + final sdkPath = assertKeyIsA( + key: 'sdkPath', + map: yaml, + ); + return MelosWorkspaceConfig( path: path, name: name, repository: repository, + sdkPath: sdkPath, packages: packages .map((package) => createGlob(package, currentDirectoryPath: path)) .toList(), @@ -564,6 +571,10 @@ You must have one of the following to be a valid Melos workspace: /// This allows customizing the default behavior of melos commands. final CommandConfigs commands; + /// Path to the Dart/Flutter SDK that should be used, unless overridden though + /// the command line option or the environment variable. + final String? sdkPath; + /// Validates this workspace configuration for consistency. void _validate() { final workspaceDir = Directory(path); diff --git a/packages/melos/test/commands/bootstrap_test.dart b/packages/melos/test/commands/bootstrap_test.dart index 6a9a2733..c0a386b2 100644 --- a/packages/melos/test/commands/bootstrap_test.dart +++ b/packages/melos/test/commands/bootstrap_test.dart @@ -77,9 +77,14 @@ void main() { final logger = TestLogger(); final config = await MelosWorkspaceConfig.fromDirectory(workspaceDir); + final workspace = await MelosWorkspace.fromConfig(config); final melos = Melos(logger: logger, config: config); + final pubExecArgs = pubCommandExecArgs( + useFlutter: workspace.isFlutterWorkspace, + workspace: workspace, + ); - await melos.bootstrap(); + await runMelosBootstrap(melos, logger); expect( logger.output, @@ -88,7 +93,7 @@ void main() { melos bootstrap └> ${workspaceDir.path} -Running "dart pub get" in workspace packages... +Running "${pubExecArgs.join(' ')} get" in workspace packages... ✓ a └> packages/a @@ -172,7 +177,7 @@ Generating IntelliJ IDE files... config: config, ); - await melos.bootstrap(); + await runMelosBootstrap(melos, logger); expect( logger.output, @@ -251,7 +256,7 @@ Generating IntelliJ IDE files... }, usePubspecOverrides: true, ), - skip: !isPubspecOverridesSupported, + skip: !isPubspecOverridesSupported(), ); test( @@ -263,7 +268,7 @@ Generating IntelliJ IDE files... }, usePubspecOverrides: true, ), - skip: !isPubspecOverridesSupported, + skip: !isPubspecOverridesSupported(), ); group('mergeMelosPubspecOverrides', () { @@ -441,10 +446,15 @@ dependency_overrides: final logger = TestLogger(); final config = await MelosWorkspaceConfig.fromDirectory(workspaceDir); + final workspace = await MelosWorkspace.fromConfig(config); final melos = Melos( logger: logger, config: config, ); + final pubExecArgs = pubCommandExecArgs( + useFlutter: workspace.isFlutterWorkspace, + workspace: workspace, + ); await expectLater( melos.bootstrap(), @@ -461,7 +471,7 @@ dependency_overrides: melos bootstrap └> ${workspaceDir.path} -Running "dart pub get" in workspace packages... +Running "${pubExecArgs.join(' ')} get" in workspace packages... - a └> packages/a e- └> Failed to install. @@ -479,6 +489,16 @@ e-Because a depends on package_that_does_not_exists any which doesn't exist (cou }); } +Future runMelosBootstrap(Melos melos, TestLogger logger) async { + try { + await melos.bootstrap(); + } on BootstrapException { + // ignore: avoid_print + print(logger.output); + rethrow; + } +} + /// Tests whether dependencies are resolved correctly. /// /// [packages] is a map where keys are package names and values are lists of @@ -571,13 +591,7 @@ Future dependencyResolutionTest( config: config, ); - try { - await melos.bootstrap(); - } on BootstrapException { - // ignore: avoid_print - print(logger.output); - rethrow; - } + await runMelosBootstrap(melos, logger); await Future.wait(packages.keys.map(validatePackage)); } diff --git a/packages/melos/test/utils.dart b/packages/melos/test/utils.dart index 0971c346..51fc28fe 100644 --- a/packages/melos/test/utils.dart +++ b/packages/melos/test/utils.dart @@ -231,6 +231,7 @@ class VirtualWorkspaceBuilder { this.melosYaml, { this.path = '/workspace', this.defaultPackagesPath = 'packages', + this.sdkPath, Logger? logger, }) : logger = logger ?? TestLogger() { if (currentPlatform.isWindows) { @@ -251,6 +252,9 @@ class VirtualWorkspaceBuilder { /// The logger to build the workspace with. final Logger logger; + /// Optional Dart/Flutter SDK path. + final String? sdkPath; + Map get _defaultWorkspaceConfig => { 'name': 'virtual-workspace', 'packages': ['$defaultPackagesPath/**'], @@ -289,6 +293,7 @@ class VirtualWorkspaceBuilder { allPackages: packageMap, filteredPackages: packageMap, logger: logger, + sdkPath: sdkPath, ); } diff --git a/packages/melos/test/utils_test.dart b/packages/melos/test/utils_test.dart new file mode 100644 index 00000000..df29f869 --- /dev/null +++ b/packages/melos/test/utils_test.dart @@ -0,0 +1,48 @@ +import 'package:melos/src/common/utils.dart'; +import 'package:path/path.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + group('pubCommandExecArgs', () { + test('no sdk path specified', () { + final workspace = VirtualWorkspaceBuilder('').build(); + + expect( + pubCommandExecArgs( + workspace: workspace, + useFlutter: true, + ), + ['flutter', 'pub'], + ); + expect( + pubCommandExecArgs( + workspace: workspace, + useFlutter: false, + ), + [if (isPubSubcommand(workspace: workspace)) 'dart', 'pub'], + ); + }); + + test('with sdk path specified', () { + final sdkPath = join('flutter_sdks', 'stable'); + final workspace = VirtualWorkspaceBuilder('', sdkPath: sdkPath).build(); + + expect( + pubCommandExecArgs( + workspace: workspace, + useFlutter: true, + ), + [join(sdkPath, 'bin', 'flutter'), 'pub'], + ); + expect( + pubCommandExecArgs( + workspace: workspace, + useFlutter: false, + ), + [join(sdkPath, 'bin', 'dart'), 'pub'], + ); + }); + }); +} diff --git a/packages/melos/test/workspace_test.dart b/packages/melos/test/workspace_test.dart index 6f19c867..a5d9e12c 100644 --- a/packages/melos/test/workspace_test.dart +++ b/packages/melos/test/workspace_test.dart @@ -17,14 +17,17 @@ import 'dart:io'; import 'package:melos/src/common/glob.dart'; +import 'package:melos/src/common/utils.dart'; import 'package:melos/src/package.dart'; import 'package:melos/src/workspace.dart'; import 'package:melos/src/workspace_configs.dart'; import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; import 'package:pubspec/pubspec.dart'; import 'package:test/test.dart'; import 'matchers.dart'; +import 'mock_env.dart'; import 'mock_fs.dart'; import 'mock_workspace_fs.dart'; import 'utils.dart'; @@ -149,6 +152,33 @@ The packages that caused the problem are: ); }); + group('sdkPath', () { + test('use SDK path from environment variable', () async { + withMockPlatform( + () { + final workspace = VirtualWorkspaceBuilder('').build(); + expect(workspace.sdkPath, '/sdks/env'); + }, + platform: FakePlatform.fromPlatform(const LocalPlatform()) + ..environment[envKeyMelosSdkPath] = '/sdks/env', + ); + }); + + test('prepend SDK bin directory to PATH', () async { + withMockPlatform( + () { + final workspace = VirtualWorkspaceBuilder( + '', + sdkPath: '/sdk', + ).build(); + expect(workspace.path, '/sdk$pathEnvVarSeparator/bin'); + }, + platform: FakePlatform.fromPlatform(const LocalPlatform()) + ..environment['PATH'] = '/bin', + ); + }); + }); + group('package filtering', () { group('--include-dependencies', () { test( @@ -408,5 +438,159 @@ The packages that caused the problem are: ); }); }); + + group('resolveSdkPath', () { + final workspacePath = p.normalize('/workspace'); + final configSdkPath = p.normalize('/sdks/config'); + final envSdkPath = p.normalize('/sdks/env'); + final commandSdkPath = p.normalize('/sdks/command-line'); + + test('should return null if no sdk path is provided', () { + expect( + resolveSdkPath( + configSdkPath: null, + envSdkPath: null, + commandSdkPath: null, + workspacePath: workspacePath, + ), + null, + ); + }); + + test('commandSdkPath has precedence over envSdkPath', () { + expect( + resolveSdkPath( + configSdkPath: configSdkPath, + envSdkPath: envSdkPath, + commandSdkPath: commandSdkPath, + workspacePath: workspacePath, + ), + commandSdkPath, + ); + }); + + test('envSdkPath has precedence over configSdkPath', () { + expect( + resolveSdkPath( + configSdkPath: configSdkPath, + envSdkPath: envSdkPath, + commandSdkPath: null, + workspacePath: workspacePath, + ), + envSdkPath, + ); + }); + + test('use configSdkPath if no other sdkPath is specified', () { + expect( + resolveSdkPath( + configSdkPath: configSdkPath, + envSdkPath: null, + commandSdkPath: null, + workspacePath: workspacePath, + ), + configSdkPath, + ); + }); + + test('allow trailing path separator in sdk paths', () { + expect( + resolveSdkPath( + configSdkPath: null, + envSdkPath: null, + commandSdkPath: p.join(commandSdkPath, ''), + workspacePath: workspacePath, + ), + commandSdkPath, + ); + expect( + resolveSdkPath( + configSdkPath: null, + envSdkPath: p.join(envSdkPath, ''), + commandSdkPath: null, + workspacePath: workspacePath, + ), + envSdkPath, + ); + expect( + resolveSdkPath( + configSdkPath: p.join(configSdkPath, ''), + envSdkPath: null, + commandSdkPath: null, + workspacePath: workspacePath, + ), + configSdkPath, + ); + }); + + test('create absolute path from a relative sdk path', () { + expect( + resolveSdkPath( + configSdkPath: null, + envSdkPath: null, + commandSdkPath: 'sdk', + workspacePath: workspacePath, + ), + p.join(workspacePath, 'sdk'), + ); + expect( + resolveSdkPath( + configSdkPath: null, + envSdkPath: 'sdk', + commandSdkPath: null, + workspacePath: workspacePath, + ), + p.join(workspacePath, 'sdk'), + ); + expect( + resolveSdkPath( + configSdkPath: 'sdk', + envSdkPath: null, + commandSdkPath: null, + workspacePath: workspacePath, + ), + p.join(workspacePath, 'sdk'), + ); + }); + + test('return null if sdk path is `auto`', () { + expect( + resolveSdkPath( + configSdkPath: autoSdkPathOptionValue, + envSdkPath: null, + commandSdkPath: null, + workspacePath: workspacePath, + ), + null, + ); + expect( + resolveSdkPath( + configSdkPath: null, + envSdkPath: autoSdkPathOptionValue, + commandSdkPath: null, + workspacePath: workspacePath, + ), + null, + ); + expect( + resolveSdkPath( + configSdkPath: null, + envSdkPath: null, + commandSdkPath: autoSdkPathOptionValue, + workspacePath: workspacePath, + ), + null, + ); + expect( + resolveSdkPath( + configSdkPath: autoSdkPathOptionValue, + envSdkPath: autoSdkPathOptionValue, + commandSdkPath: autoSdkPathOptionValue, + workspacePath: workspacePath, + ), + null, + ); + }); + }); }); }