diff --git a/docs/commands/bootstrap.mdx b/docs/commands/bootstrap.mdx index 8a0ea443..6301a7c2 100644 --- a/docs/commands/bootstrap.mdx +++ b/docs/commands/bootstrap.mdx @@ -84,6 +84,43 @@ example: melos bootstrap --diff="main" ``` +## Bootstrap flags + +Melos bootstrap command supports a few different flags that can be defined in +your `melos.yaml`. + + +### Shared dependencies + +If you want to share dependency versions between your packages in your Melos +project, just add the dependencies you wish to share between the packages to +your bootstrap config in your `melos.yaml` file. + +If a dependency from `environment`, `dependencies` or `dev_dependencies` in +your `common_packages.yaml` exists in a package, the dependency version in this +package will be updated to match the version defined in your +bootstrap config every time `melos bootstrap` is run. + +```yaml +# melos.yaml +# ... +command: + environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.0.0 <4.0.0" + bootstrap: + dependencies: + collection: ^1.18.0 + integral_isolates: any + uni_links2: + uni_links_macos: + git: https://github.com/SamJakob/uni_links_macos.git + + dev_dependencies: + build_runner: ^2.3.3 +# ... +``` + ## Adding a post bootstrap lifecycle script Melos supports various command [lifecycle hooks](/configuration/scripts#hooks) diff --git a/melos.yaml b/melos.yaml index 520e72d1..83ba7196 100644 --- a/melos.yaml +++ b/melos.yaml @@ -7,6 +7,38 @@ ignore: - packages/melos_flutter_deps_check command: + bootstrap: + environment: + sdk: '>=2.18.0 <3.0.0' + dependencies: + ansi_styles: ^0.3.1 + args: ^2.0.0 + cli_launcher: ^0.3.0 + cli_util: '>=0.3.0 <0.5.0' + collection: ^1.14.12 + conventional_commit: ^0.6.0+1 + file: ^6.1.0 + glob: ^2.1.0 + graphs: ^2.1.0 + http: ">=0.13.1 <2.0.0" + meta: ^1.1.8 + mustache_template: ^2.0.0 + path: ^1.7.0 + platform: ^3.1.0 + pool: ^1.4.0 + prompts: ^2.0.0 + pub_semver: ^2.0.0 + pub_updater: ^0.3.0 + pubspec: ^2.1.0 + string_scanner: ^1.0.5 + yaml: ^3.1.0 + yaml_edit: ^2.0.2 + dev_dependencies: + collection: ^1.15.0 + mockito: ^5.1.0 + test: ^1.17.5 + path: ^1.7.0 + yaml: ^3.1.0 version: # Generate commit links in package changelogs. linkToCommits: true diff --git a/packages/melos/lib/src/commands/bootstrap.dart b/packages/melos/lib/src/commands/bootstrap.dart index 2076eb5c..33fa7a83 100644 --- a/packages/melos/lib/src/commands/bootstrap.dart +++ b/packages/melos/lib/src/commands/bootstrap.dart @@ -12,13 +12,14 @@ mixin _BootstrapMixin on _CleanMixin { workspace, _CommandWithLifecycle.bootstrap, () async { + final bootstrapCommandConfig = workspace.config.commands.bootstrap; final pubCommandForLogging = [ ...pubCommandExecArgs( useFlutter: workspace.isFlutterWorkspace, workspace: workspace, ), 'get', - if (workspace.config.commands.bootstrap.runPubGetOffline) '--offline', + if (bootstrapCommandConfig.runPubGetOffline) '--offline', ].join(' '); logger @@ -35,6 +36,20 @@ mixin _BootstrapMixin on _CleanMixin { } try { + if (bootstrapCommandConfig.environment != null || + bootstrapCommandConfig.dependencies != null || + bootstrapCommandConfig.devDependencies != null) { + final filteredPackages = workspace.filteredPackages.values; + await Stream.fromIterable(filteredPackages).parallel((package) { + return _setSharedDependenciesForPackage( + package, + environment: bootstrapCommandConfig.environment, + dependencies: bootstrapCommandConfig.dependencies, + devDependencies: bootstrapCommandConfig.devDependencies, + ); + }).drain(); + } + await _linkPackagesWithPubspecOverrides(workspace); } on BootstrapException catch (exception) { _logBootstrapException(exception, workspace); @@ -199,6 +214,129 @@ mixin _BootstrapMixin on _CleanMixin { } } + Future _setSharedDependenciesForPackage( + Package package, { + required Environment? environment, + required Map? dependencies, + required Map? devDependencies, + }) async { + final packagePubspecFile = utils.pubspecPathForDirectory(package.path); + final packagePubspecContents = await readTextFileAsync(packagePubspecFile); + final pubspecEditor = YamlEditor(packagePubspecContents); + + final updatedEnvironment = _updateEnvironment( + pubspecEditor: pubspecEditor, + workspaceEnvironment: environment, + packageEnvironment: package.pubSpec.environment, + ); + + final updatedDependenciesCount = _updateDependencies( + pubspecEditor: pubspecEditor, + workspaceDependencies: dependencies, + packageDependencies: package.pubSpec.dependencies, + pubspecKey: 'dependencies', + ); + + final updatedDevDependenciesCount = _updateDependencies( + pubspecEditor: pubspecEditor, + workspaceDependencies: devDependencies, + packageDependencies: package.pubSpec.devDependencies, + pubspecKey: 'dev_dependencies', + ); + + if (pubspecEditor.edits.isNotEmpty) { + await writeTextFileAsync( + packagePubspecFile, + pubspecEditor.toString(), + ); + + final message = [ + if (updatedEnvironment) 'Updated environment', + if (updatedDependenciesCount > 0) + 'Updated $updatedDependenciesCount dependencies', + if (updatedDevDependenciesCount > 0) + 'Updated $updatedDevDependenciesCount dev_dependencies', + ]; + if (message.isNotEmpty) { + logger + .child(packageNameStyle(package.name), prefix: '') + .child(message.join('\n')); + } + } + } + + bool _updateEnvironment({ + required YamlEditor pubspecEditor, + required Environment? workspaceEnvironment, + required Environment? packageEnvironment, + }) { + if (workspaceEnvironment == null || packageEnvironment == null) { + return false; + } + + var didUpdate = false; + + if (workspaceEnvironment.sdkConstraint != + packageEnvironment.sdkConstraint) { + pubspecEditor.update( + ['environment', 'sdk'], + wrapAsYamlNode( + workspaceEnvironment.sdkConstraint.toString(), + collectionStyle: CollectionStyle.BLOCK, + ), + ); + didUpdate = true; + } + + final workspaceUnParsedYaml = workspaceEnvironment.unParsedYaml; + final packageUnParsedYaml = packageEnvironment.unParsedYaml; + if (workspaceUnParsedYaml != null && packageUnParsedYaml != null) { + for (final entry in workspaceUnParsedYaml.entries) { + if (!packageUnParsedYaml.containsKey(entry.key)) continue; + if (packageUnParsedYaml[entry.key] == entry.value) continue; + + pubspecEditor.update( + ['environment', entry.key], + wrapAsYamlNode( + entry.value.toString(), + collectionStyle: CollectionStyle.BLOCK, + ), + ); + didUpdate = true; + } + } + + return didUpdate; + } + + int _updateDependencies({ + required YamlEditor pubspecEditor, + required Map? workspaceDependencies, + required Map packageDependencies, + required String pubspecKey, + }) { + if (workspaceDependencies == null) return 0; + // Filter out the packages that do not exist in package and only the + // dependencies that have a different version specified in the workspace. + final dependenciesToUpdate = workspaceDependencies.entries.where((entry) { + if (!packageDependencies.containsKey(entry.key)) return false; + if (packageDependencies[entry.key] == entry.value) return false; + return true; + }); + + for (final entry in dependenciesToUpdate) { + pubspecEditor.update( + [pubspecKey, entry.key], + wrapAsYamlNode( + entry.value.toJson(), + collectionStyle: CollectionStyle.BLOCK, + ), + ); + } + + return dependenciesToUpdate.length; + } + void _logBootstrapSuccess(Package package) { logger.child(packageNameStyle(package.name), prefix: '$checkLabel ').child( packagePathStyle(printablePath(package.pathRelativeToWorkspace)), diff --git a/packages/melos/lib/src/workspace_configs.dart b/packages/melos/lib/src/workspace_configs.dart index 8ec981c3..9c7b0afc 100644 --- a/packages/melos/lib/src/workspace_configs.dart +++ b/packages/melos/lib/src/workspace_configs.dart @@ -21,6 +21,7 @@ import 'package:ansi_styles/ansi_styles.dart'; import 'package:collection/collection.dart'; import 'package:glob/glob.dart'; import 'package:meta/meta.dart'; +import 'package:pubspec/pubspec.dart'; import 'package:yaml/yaml.dart'; import '../melos.dart'; @@ -357,6 +358,9 @@ class BootstrapCommandConfigs { const BootstrapCommandConfigs({ this.runPubGetInParallel = true, this.runPubGetOffline = false, + this.environment, + this.dependencies, + this.devDependencies, this.dependencyOverridePaths = const [], this.hooks = LifecycleHooks.empty, }); @@ -379,6 +383,31 @@ class BootstrapCommandConfigs { ) ?? false; + final environment = assertKeyIsA?>( + key: 'environment', + map: yaml, + ).let(Environment.fromJson); + + final dependencies = assertKeyIsA?>( + key: 'dependencies', + map: yaml, + )?.map( + (key, value) => MapEntry( + key.toString(), + DependencyReference.fromJson(value), + ), + ); + + final devDependencies = assertKeyIsA?>( + key: 'dev_dependencies', + map: yaml, + )?.map( + (key, value) => MapEntry( + key.toString(), + DependencyReference.fromJson(value), + ), + ); + final dependencyOverridePaths = assertListIsA( key: 'dependencyOverridePaths', map: yaml, @@ -402,6 +431,9 @@ class BootstrapCommandConfigs { return BootstrapCommandConfigs( runPubGetInParallel: runPubGetInParallel, runPubGetOffline: runPubGetOffline, + environment: environment, + dependencies: dependencies, + devDependencies: devDependencies, dependencyOverridePaths: dependencyOverridePaths .map( (override) => @@ -425,6 +457,15 @@ class BootstrapCommandConfigs { /// The default is `false`. final bool runPubGetOffline; + /// Environment configuration to be synced between all packages. + final Environment? environment; + + /// Dependencies to be synced between all packages. + final Map? dependencies; + + /// Dev dependencies to be synced between all packages. + final Map? devDependencies; + /// A list of [Glob]s for paths that contain packages to be used as dependency /// overrides for all packages managed in the Melos workspace. final List dependencyOverridePaths; @@ -436,6 +477,15 @@ class BootstrapCommandConfigs { return { 'runPubGetInParallel': runPubGetInParallel, 'runPubGetOffline': runPubGetOffline, + if (environment != null) 'environment': environment!.toJson(), + if (dependencies != null) + 'dependencies': dependencies!.map( + (key, value) => MapEntry(key, value.toJson()), + ), + if (devDependencies != null) + 'dev_dependencies': devDependencies!.map( + (key, value) => MapEntry(key, value.toJson()), + ), if (dependencyOverridePaths.isNotEmpty) 'dependencyOverridePaths': dependencyOverridePaths.map((path) => path.toString()).toList(), @@ -449,6 +499,15 @@ class BootstrapCommandConfigs { runtimeType == other.runtimeType && other.runPubGetInParallel == runPubGetInParallel && other.runPubGetOffline == runPubGetOffline && + // Extracting equality from environment here as it does not implement == + other.environment?.sdkConstraint == environment?.sdkConstraint && + const DeepCollectionEquality().equals( + other.environment?.unParsedYaml, + environment?.unParsedYaml, + ) && + const DeepCollectionEquality().equals(other.dependencies, dependencies) && + const DeepCollectionEquality() + .equals(other.devDependencies, devDependencies) && const DeepCollectionEquality(_GlobEquality()) .equals(other.dependencyOverridePaths, dependencyOverridePaths) && other.hooks == hooks; @@ -458,6 +517,14 @@ class BootstrapCommandConfigs { runtimeType.hashCode ^ runPubGetInParallel.hashCode ^ runPubGetOffline.hashCode ^ + // Extracting hashCode from environment here as it does not implement + // hashCode + (environment?.sdkConstraint).hashCode ^ + const DeepCollectionEquality().hash( + environment?.unParsedYaml, + ) ^ + const DeepCollectionEquality().hash(dependencies) ^ + const DeepCollectionEquality().hash(devDependencies) ^ const DeepCollectionEquality(_GlobEquality()) .hash(dependencyOverridePaths) ^ hooks.hashCode; @@ -468,6 +535,9 @@ class BootstrapCommandConfigs { BootstrapCommandConfigs( runPubGetInParallel: $runPubGetInParallel, runPubGetOffline: $runPubGetOffline, + environment: $environment, + dependencies: $dependencies, + devDependencies: $devDependencies, dependencyOverridePaths: $dependencyOverridePaths, hooks: $hooks, )'''; diff --git a/packages/melos/test/commands/bootstrap_test.dart b/packages/melos/test/commands/bootstrap_test.dart index acaeb421..5f380815 100644 --- a/packages/melos/test/commands/bootstrap_test.dart +++ b/packages/melos/test/commands/bootstrap_test.dart @@ -2,6 +2,7 @@ import 'dart:io' as io; import 'package:melos/melos.dart'; import 'package:melos/src/commands/runner.dart'; +import 'package:melos/src/common/glob.dart'; import 'package:melos/src/common/utils.dart'; import 'package:path/path.dart' as p; import 'package:pub_semver/pub_semver.dart'; @@ -587,6 +588,164 @@ Generating IntelliJ IDE files... ), ); }); + + test( + 'applies dependencies from melos config', + () async { + final workspaceDir = await createTemporaryWorkspace( + configBuilder: (path) => MelosWorkspaceConfig( + name: 'Melos', + packages: [ + createGlob('packages/**', currentDirectoryPath: path), + ], + commands: CommandConfigs( + bootstrap: BootstrapCommandConfigs( + environment: Environment( + VersionConstraint.parse('>=2.18.0 <3.0.0'), + {'flutter': '>=2.18.0 <3.0.0'}, + ), + dependencies: { + 'intl': HostedReference( + VersionConstraint.compatibleWith(Version.parse('0.18.1')), + ), + 'integral_isolates': HostedReference( + VersionConstraint.compatibleWith(Version.parse('0.4.1')), + ), + 'path': HostedReference( + VersionConstraint.compatibleWith(Version.parse('1.8.3')), + ), + }, + devDependencies: { + 'build_runner': HostedReference( + VersionConstraint.compatibleWith(Version.parse('2.4.6')), + ), + }, + ), + ), + path: path, + ), + ); + + final pkgA = await createProject( + workspaceDir, + PubSpec( + name: 'a', + environment: Environment( + VersionConstraint.any, + {}, + ), + dependencies: { + 'intl': HostedReference( + VersionConstraint.compatibleWith(Version.parse('0.18.1')), + ), + 'path': HostedReference( + VersionConstraint.compatibleWith(Version.parse('1.7.2')), + ), + }, + devDependencies: { + 'build_runner': HostedReference( + VersionConstraint.compatibleWith(Version.parse('2.4.0')), + ), + }, + ), + ); + + final pkgB = await createProject( + workspaceDir, + PubSpec( + name: 'b', + environment: Environment( + VersionRange( + min: Version.parse('2.12.0'), + max: Version.parse('3.0.0'), + includeMin: true, + ), + { + 'flutter': '>=2.12.0 <3.0.0', + }, + ), + dependencies: { + 'integral_isolates': HostedReference( + VersionConstraint.compatibleWith(Version.parse('0.4.1')), + ), + 'intl': HostedReference( + VersionConstraint.compatibleWith(Version.parse('0.17.0')), + ), + 'path': HostedReference(VersionConstraint.any), + }, + ), + ); + + final logger = TestLogger(); + final config = + await MelosWorkspaceConfig.fromWorkspaceRoot(workspaceDir); + final melos = Melos( + logger: logger, + config: config, + ); + + await runMelosBootstrap(melos, logger); + + final pubspecA = pubSpecFromYamlFile(directory: pkgA.path); + final pubspecB = pubSpecFromYamlFile(directory: pkgB.path); + + expect( + pubspecA.environment?.sdkConstraint, + equals(VersionConstraint.parse('>=2.18.0 <3.0.0')), + ); + expect( + pubspecA.environment?.unParsedYaml, + equals({}), + ); + expect( + pubspecA.dependencies, + equals({ + 'intl': HostedReference( + VersionConstraint.compatibleWith(Version.parse('0.18.1')), + ), + 'path': HostedReference( + VersionConstraint.compatibleWith(Version.parse('1.8.3')), + ), + }), + ); + expect( + pubspecA.devDependencies, + equals({ + 'build_runner': HostedReference( + VersionConstraint.compatibleWith(Version.parse('2.4.6')), + ), + }), + ); + + expect( + pubspecB.environment?.sdkConstraint, + equals(VersionConstraint.parse('>=2.18.0 <3.0.0')), + ); + expect( + pubspecB.environment?.unParsedYaml, + equals({'flutter': '>=2.18.0 <3.0.0'}), + ); + expect( + pubspecB.dependencies, + equals({ + 'integral_isolates': HostedReference( + VersionConstraint.compatibleWith(Version.parse('0.4.1')), + ), + 'intl': HostedReference( + VersionConstraint.compatibleWith(Version.parse('0.18.1')), + ), + 'path': HostedReference( + VersionConstraint.compatibleWith(Version.parse('1.8.3')), + ), + }), + ); + expect( + pubspecB.devDependencies, + equals({}), + ); + }, + timeout: const Timeout(Duration(days: 2)), + ); }); } diff --git a/packages/melos/test/utils.dart b/packages/melos/test/utils.dart index e03df46c..6d15feac 100644 --- a/packages/melos/test/utils.dart +++ b/packages/melos/test/utils.dart @@ -284,6 +284,13 @@ PubSpec pubSpecFromJsonFile({ return PubSpec.fromJson(json.decode(jsonAsString) as Map); } +PubSpec pubSpecFromYamlFile({ + required String directory, +}) { + final filePath = pubspecPathForDirectory(directory); + return PubSpec.fromYamlString(readTextFile(filePath)); +} + /// Builder to build a [MelosWorkspace] that is entirely virtual and only exists /// in memory. class VirtualWorkspaceBuilder {