diff --git a/docs/commands/init.mdx b/docs/commands/init.mdx new file mode 100644 index 00000000..4fb4340d --- /dev/null +++ b/docs/commands/init.mdx @@ -0,0 +1,93 @@ +--- +title: Init Command +description: Learn more about the `init` command in Melos. +--- + +# Init Command + +The `init` command initializes a new Melos workspace. +It creates the necessary configuration files and +directory structure for your monorepo. + +## Basic Usage + +```bash +melos init [workspace_name] +``` + +If no workspace name is provided, you'll be prompted to enter one. +By default, it uses the current directory name. + +## Options + +### --directory (-d) +Specifies the directory where the workspace should be created. +If not provided, you'll be prompted to enter one. +Defaults to the workspace name, or current directory ('.') +if the workspace name matches the current directory name. + +```bash +melos init my_workspace --directory custom_dir +``` + +### --packages (-p) +Defines additional glob patterns for package directories +to include in the workspace. Accepts comma-separated values and can be +specified multiple times. + +```bash +melos init --packages "modules/*" --packages "libs/*" +``` + +## Interactive Setup + +When running `melos init`, you'll be guided through +an interactive setup process that will: + +1. Prompt for a workspace name +(if not provided, defaults to current directory name) +2. Ask for a directory location +(defaults to workspace name, or '.' if matching current directory) +3. Ask if you want to include an `apps` directory (defaults to true) + +## Created Files + +The command creates the following structure: + +``` +/ +├── melos.yaml # Workspace configuration +├── pubspec.yaml # Root package configuration +├── packages/ # Packages directory (always created) +└── apps/ # Apps directory (created if confirmed during setup) +``` + +### melos.yaml +Contains the workspace configuration with: +- Workspace name +- Package locations (defaults to ['packages/*'] and optionally 'apps/*') +- Additional package glob patterns (if specified via --packages) + +### pubspec.yaml +Contains the root package configuration with: +- Project name (same as workspace name) +- Dart SDK constraints (based on current Dart version) +- Melos as a dev dependency + +## Example + +```bash +# Basic initialization +melos init my_workspace + +# Custom initialization with options +melos init my_workspace \ + --directory custom_dir \ + --packages "modules/*" +``` + +After initialization, you can bootstrap your workspace by running: +```bash +cd +melos bootstrap +``` diff --git a/packages/melos/lib/src/command_runner.dart b/packages/melos/lib/src/command_runner.dart index 8f4fe912..d06809b7 100644 --- a/packages/melos/lib/src/command_runner.dart +++ b/packages/melos/lib/src/command_runner.dart @@ -13,6 +13,7 @@ import 'command_runner/bootstrap.dart'; import 'command_runner/clean.dart'; import 'command_runner/exec.dart'; import 'command_runner/format.dart'; +import 'command_runner/init.dart'; import 'command_runner/list.dart'; import 'command_runner/publish.dart'; import 'command_runner/run.dart'; @@ -38,7 +39,8 @@ class MelosCommandRunner extends CommandRunner { : super( 'melos', 'A CLI tool for managing Dart & Flutter projects with multiple ' - 'packages.', + 'packages.\n\n' + 'To get started with Melos, run "melos init".', usageLineLength: terminalWidth, ) { argParser.addFlag( @@ -55,6 +57,7 @@ class MelosCommandRunner extends CommandRunner { 'the special value "auto".', ); + addCommand(InitCommand(config)); addCommand(ExecCommand(config)); addCommand(BootstrapCommand(config)); addCommand(CleanCommand(config)); @@ -154,6 +157,7 @@ Future _resolveConfig( } bool _shouldUseEmptyConfig(List arguments) { + if (arguments.firstOrNull == 'init') return true; final willShowHelp = arguments.isEmpty || arguments.contains('--help') || arguments.contains('-h'); diff --git a/packages/melos/lib/src/command_runner/init.dart b/packages/melos/lib/src/command_runner/init.dart new file mode 100644 index 00000000..3af82789 --- /dev/null +++ b/packages/melos/lib/src/command_runner/init.dart @@ -0,0 +1,58 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../../melos.dart'; +import '../common/utils.dart'; +import 'base.dart'; + +class InitCommand extends MelosCommand { + InitCommand(super.config) { + argParser.addOption( + 'directory', + abbr: 'd', + help: 'Directory to create project in. Defaults to the workspace name.', + ); + + argParser.addMultiOption( + 'packages', + abbr: 'p', + help: 'Comma separated glob paths to add to the melos workspace.', + ); + } + + @override + final String name = 'init'; + + @override + final String description = 'Initialize a new Melos workspace.'; + + @override + Future run() { + final workspaceDefault = p.basename(Directory.current.absolute.path); + final workspaceName = argResults!.rest.firstOrNull ?? + promptInput( + 'Enter your workspace name', + defaultsTo: workspaceDefault, + ); + final directory = argResults!['directory'] as String? ?? + promptInput( + 'Enter the directory', + defaultsTo: workspaceDefault != workspaceName ? workspaceName : '.', + ); + final packages = argResults!['packages'] as List?; + final useAppsDir = promptBool( + message: 'Do you want to add the apps directory?', + defaultsTo: true, + ); + + final melos = Melos(logger: logger, config: config); + + return melos.init( + workspaceName, + directory: directory, + packages: packages ?? const [], + useAppDir: useAppsDir, + ); + } +} diff --git a/packages/melos/lib/src/commands/init.dart b/packages/melos/lib/src/commands/init.dart new file mode 100644 index 00000000..690a6fb6 --- /dev/null +++ b/packages/melos/lib/src/commands/init.dart @@ -0,0 +1,62 @@ +part of 'runner.dart'; + +mixin _InitMixin on _Melos { + Future init( + String workspaceName, { + required String directory, + required List packages, + required bool useAppDir, + }) async { + late final String qualifiedWorkspaceName; + if (workspaceName == '.') { + qualifiedWorkspaceName = p.basename(Directory.current.absolute.path); + } else { + qualifiedWorkspaceName = workspaceName; + } + + final isCurrentDir = directory == '.'; + final dir = Directory(directory); + if (!isCurrentDir && dir.existsSync()) { + throw StateError('Directory $directory already exists'); + } else { + dir.createSync(recursive: true); + Directory(p.join(dir.absolute.path, 'packages')).createSync(); + if (useAppDir) { + Directory(p.join(dir.absolute.path, 'apps')).createSync(); + } + } + + final dartVersion = utils.currentDartVersion('dart'); + final melosYaml = { + 'name': qualifiedWorkspaceName, + 'packages': [if (useAppDir) 'apps/*', 'packages/*'], + if (packages.isNotEmpty) 'packages': packages, + }; + final pubspecYaml = { + 'name': qualifiedWorkspaceName, + 'environment': { + 'sdk': '>=$dartVersion <${dartVersion.major + 1}.0.0', + }, + 'dev_dependencies': { + 'melos': '^$melosVersion', + }, + }; + + final melosFile = File(p.join(dir.absolute.path, 'melos.yaml')); + final pubspecFile = File(p.join(dir.absolute.path, 'pubspec.yaml')); + + melosFile.writeAsStringSync( + (YamlEditor('')..update([], melosYaml)).toString(), + ); + pubspecFile.writeAsStringSync( + (YamlEditor('')..update([], pubspecYaml)).toString(), + ); + + logger.log( + 'Initialized Melos workspace in ${dir.path}.\n' + 'Run the following commands to bootstrap the workspace when you have created some packages and/or apps:\n' + '${isCurrentDir ? '' : ' cd ${dir.path}\n'}' + ' melos bootstrap', + ); + } +} diff --git a/packages/melos/lib/src/commands/runner.dart b/packages/melos/lib/src/commands/runner.dart index 9b743f59..06604c47 100644 --- a/packages/melos/lib/src/commands/runner.dart +++ b/packages/melos/lib/src/commands/runner.dart @@ -17,6 +17,7 @@ import 'package:pubspec_parse/pubspec_parse.dart'; import 'package:yaml/yaml.dart'; import 'package:yaml_edit/yaml_edit.dart'; +import '../../version.g.dart'; import '../command_configs/command_configs.dart'; import '../command_runner/version.dart'; import '../common/aggregate_changelog.dart'; @@ -51,6 +52,7 @@ part 'bootstrap.dart'; part 'clean.dart'; part 'exec.dart'; part 'format.dart'; +part 'init.dart'; part 'list.dart'; part 'publish.dart'; part 'run.dart'; @@ -73,7 +75,8 @@ class Melos extends _Melos _VersionMixin, _PublishMixin, _AnalyzeMixin, - _FormatMixin { + _FormatMixin, + _InitMixin { Melos({ required this.config, Logger? logger, diff --git a/packages/melos/lib/src/workspace_configs.dart b/packages/melos/lib/src/workspace_configs.dart index 6e376ef0..18653acc 100644 --- a/packages/melos/lib/src/workspace_configs.dart +++ b/packages/melos/lib/src/workspace_configs.dart @@ -376,6 +376,17 @@ class MelosWorkspaceConfig { commands: CommandConfigs.empty, ); + @visibleForTesting + MelosWorkspaceConfig.emptyWith({ + String? name, + String? path, + }) : this( + name: name ?? 'Melos', + packages: [], + path: path ?? Directory.current.path, + commands: CommandConfigs.empty, + ); + /// Loads the [MelosWorkspaceConfig] for the workspace at [workspaceRoot]. static Future fromWorkspaceRoot( Directory workspaceRoot, diff --git a/packages/melos/test/commands/init_test.dart b/packages/melos/test/commands/init_test.dart new file mode 100644 index 00000000..35d81f41 --- /dev/null +++ b/packages/melos/test/commands/init_test.dart @@ -0,0 +1,184 @@ +import 'dart:io'; + +import 'package:melos/melos.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; + +import '../utils.dart'; + +void main() { + group('init', () { + late TestLogger logger; + late Directory tempDir; + + setUp(() async { + logger = TestLogger(); + tempDir = await Directory.systemTemp.createTemp('melos_init_test_'); + }); + + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + test('creates a new workspace with default settings', () async { + final workspaceDir = Directory(p.join(tempDir.path, 'my_workspace')); + final config = MelosWorkspaceConfig.emptyWith( + name: 'my_workspace', + path: tempDir.path, + ); + final melos = Melos(logger: logger, config: config); + + await melos.init( + 'my_workspace', + directory: workspaceDir.path, + packages: [], + useAppDir: true, + ); + + // Verify directory structure + expect(workspaceDir.existsSync(), isTrue); + expect( + Directory(p.join(workspaceDir.path, 'packages')).existsSync(), + isTrue, + ); + expect(Directory(p.join(workspaceDir.path, 'apps')).existsSync(), isTrue); + + // Verify melos.yaml content + final melosYaml = loadYaml( + File(p.join(workspaceDir.path, 'melos.yaml')).readAsStringSync(), + ) as YamlMap; + expect(melosYaml['name'], equals('my_workspace')); + expect(melosYaml['packages'], equals(['apps/*', 'packages/*'])); + + // Verify pubspec.yaml content + final pubspecYaml = loadYaml( + File(p.join(workspaceDir.path, 'pubspec.yaml')).readAsStringSync(), + ) as YamlMap; + expect(pubspecYaml['name'], equals('my_workspace')); + expect( + (pubspecYaml['environment'] as YamlMap)['sdk'], + contains('>='), + ); + expect( + (pubspecYaml['dev_dependencies'] as YamlMap)['melos'], + contains('^'), + ); + + // Verify logger output + expect( + logger.output, + contains( + 'Initialized Melos workspace in ${workspaceDir.path}', + ), + ); + }); + + test('creates a workspace with custom packages', () async { + final workspaceDir = Directory(p.join(tempDir.path, 'custom_workspace')); + final config = MelosWorkspaceConfig.emptyWith( + name: 'custom_workspace', + path: tempDir.path, + ); + final melos = Melos(logger: logger, config: config); + + await melos.init( + 'custom_workspace', + directory: workspaceDir.path, + packages: ['custom/*', 'plugins/**'], + useAppDir: false, + ); + + final melosYaml = loadYaml( + File(p.join(workspaceDir.path, 'melos.yaml')).readAsStringSync(), + ) as YamlMap; + expect(melosYaml['packages'], equals(['custom/*', 'plugins/**'])); + }); + + test('creates workspace in current directory when directory is "."', + () async { + final config = MelosWorkspaceConfig.emptyWith( + name: 'melos_init_test_', + path: tempDir.path, + ); + final melos = Melos(logger: logger, config: config); + + final originalDir = Directory.current; + try { + Directory.current = tempDir; + + await melos.init( + '.', + directory: '.', + packages: [], + useAppDir: true, + ); + + // Verify files were created in current directory + expect(File('melos.yaml').existsSync(), isTrue); + expect(File('pubspec.yaml').existsSync(), isTrue); + expect(Directory('packages').existsSync(), isTrue); + expect(Directory('apps').existsSync(), isTrue); + + final melosYaml = + loadYaml(File('melos.yaml').readAsStringSync()) as YamlMap; + expect(melosYaml['name'], equals(p.basename(tempDir.path))); + } finally { + Directory.current = originalDir; + } + }); + + test('throws error if target directory already exists', () async { + final workspaceDir = Directory(p.join(tempDir.path, 'existing_workspace')) + ..createSync(); + final config = MelosWorkspaceConfig.emptyWith( + name: 'existing_workspace', + path: tempDir.path, + ); + final melos = Melos(logger: logger, config: config); + + expect( + () => melos.init( + 'existing_workspace', + directory: workspaceDir.path, + packages: [], + useAppDir: false, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Directory ${workspaceDir.path} already exists', + ), + ), + ); + }); + + test('creates workspace without apps directory when useAppDir is false', + () async { + final workspaceDir = Directory(p.join(tempDir.path, 'no_apps_workspace')); + final config = MelosWorkspaceConfig.emptyWith( + name: 'no_apps_workspace', + path: tempDir.path, + ); + final melos = Melos(logger: logger, config: config); + + await melos.init( + 'no_apps_workspace', + directory: workspaceDir.path, + packages: [], + useAppDir: false, + ); + + expect( + Directory(p.join(workspaceDir.path, 'apps')).existsSync(), + isFalse, + ); + + final melosYaml = loadYaml( + File(p.join(workspaceDir.path, 'melos.yaml')).readAsStringSync(), + ) as YamlMap; + expect(melosYaml['packages'], equals(['packages/*'])); + }); + }); +}