Repository: VeryGoodOpenSource/very_good_cli (dart)

Issue:
feat(create)! subcommand: Very Good Dart Package Usage
Split the create command to use this subcommand: 

```sh
# Very Good Dart Package Usage
very_good create dart_pkg <project-name> <options>

options:
    --description
```

Pull request description:
<!--
  Thanks for contributing!

  Provide a description of your changes below and a general summary in the title

  Please look at the following checklist to ensure that your PR can be accepted quickly:
-->

## Description
closes #584

follows #607
## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [x] ✨ New feature (non-breaking change which adds functionality)
- [ ] 🛠️ Bug fix (non-breaking change which fixes an issue)
- [ ] ❌ Breaking change (fix or feature that would cause existing functionality to change)
- [ ] 🧹 Code refactor
- [ ] ✅ Build configuration change
- [ ] 📝 Documentation
- [ ] 🗑️ Chore

Patch:
diff --git a/.github/workflows/very_good_cli.yaml b/.github/workflows/very_good_cli.yaml
index 36856a30e..bb1516471 100644
--- a/.github/workflows/very_good_cli.yaml
+++ b/.github/workflows/very_good_cli.yaml
@@ -55,6 +55,7 @@ jobs:
           - test/src/commands/test/e2e
           # E2E tests for the create command
           - test/src/commands/create/e2e/flutter_app/core_test.dart
+          - test/src/commands/create/e2e/dart_package/dart_pkg_test.dart
 
           # E2E tests for the legacy create command syntax
           - test/src/commands/create/e2e/legacy/core_test.dart
diff --git a/lib/src/commands/create/commands/commands.dart b/lib/src/commands/create/commands/commands.dart
index 608f7b6b8..8a550abb9 100644
--- a/lib/src/commands/create/commands/commands.dart
+++ b/lib/src/commands/create/commands/commands.dart
@@ -1,3 +1,4 @@
 export 'create_subcommand.dart';
+export 'dart_package.dart';
 export 'flutter_app.dart';
 export 'legacy.dart';
diff --git a/lib/src/commands/create/commands/create_subcommand.dart b/lib/src/commands/create/commands/create_subcommand.dart
index aee9e6872..d0865a52d 100644
--- a/lib/src/commands/create/commands/create_subcommand.dart
+++ b/lib/src/commands/create/commands/create_subcommand.dart
@@ -97,6 +97,14 @@ abstract class CreateSubCommand extends Command<int> {
         aliases: ['org'],
       );
     }
+
+    if (this is Publishable) {
+      argParser.addFlag(
+        'publishable',
+        negatable: false,
+        help: 'Whether the generated project is intended to be published.',
+      );
+    }
   }
 
   final Analytics _analytics;
@@ -236,6 +244,7 @@ abstract class CreateSubCommand extends Command<int> {
       'project_name': projectName,
       'description': projectDescription,
       if (this is OrgName) 'org_name': (this as OrgName).orgName,
+      if (this is Publishable) 'publishable': (this as Publishable).publishable,
     };
   }
 }
@@ -298,3 +307,13 @@ mixin MultiTemplates on CreateSubCommand {
     );
   }
 }
+
+/// Mixin for [CreateSubCommand] subclasses that receives the publishable
+/// flag.
+///
+/// Takes care of parsing it from [argResults] and pass it
+/// to the brick generator.
+mixin Publishable on CreateSubCommand {
+  /// Gets the publishable flag.
+  bool get publishable => argResults['publishable'] as bool? ?? false;
+}
diff --git a/lib/src/commands/create/commands/dart_package.dart b/lib/src/commands/create/commands/dart_package.dart
new file mode 100644
index 000000000..39da542ac
--- /dev/null
+++ b/lib/src/commands/create/commands/dart_package.dart
@@ -0,0 +1,35 @@
+import 'package:mason_logger/mason_logger.dart';
+import 'package:usage/usage.dart';
+import 'package:very_good_cli/src/commands/commands.dart';
+import 'package:very_good_cli/src/commands/create/templates/templates.dart';
+
+/// {@template very_good_create_dart_package_command}
+/// A [CreateSubCommand] for creating Dart packages.
+/// {@endtemplate}
+class CreateDartPackage extends CreateSubCommand with Publishable {
+  /// {@macro very_good_create_dart_package_command}
+  CreateDartPackage({
+    required Analytics analytics,
+    required Logger logger,
+    required MasonGeneratorFromBundle? generatorFromBundle,
+    required MasonGeneratorFromBrick? generatorFromBrick,
+  }) : super(
+          analytics: analytics,
+          logger: logger,
+          generatorFromBundle: generatorFromBundle,
+          generatorFromBrick: generatorFromBrick,
+        );
+
+  @override
+  String get name => 'dart_package';
+
+  @override
+  List<String> get aliases => ['dart_pkg'];
+
+  @override
+  String get description =>
+      'Creates a new very good Dart package in the specified directory.';
+
+  @override
+  Template get template => DartPkgTemplate();
+}
diff --git a/lib/src/commands/create/create.dart b/lib/src/commands/create/create.dart
index c3990bb6e..ee7425359 100644
--- a/lib/src/commands/create/create.dart
+++ b/lib/src/commands/create/create.dart
@@ -38,6 +38,16 @@ class CreateCommand extends Command<int> {
         generatorFromBrick: generatorFromBrick,
       ),
     );
+
+    // very_good create dart_pkg <args>
+    addSubcommand(
+      CreateDartPackage(
+        analytics: analytics,
+        logger: logger,
+        generatorFromBundle: generatorFromBundle,
+        generatorFromBrick: generatorFromBrick,
+      ),
+    );
   }
 
   @override


Test patch:
diff --git a/test/src/commands/create/commands/dart_package_test.dart b/test/src/commands/create/commands/dart_package_test.dart
new file mode 100644
index 000000000..be6113d7f
--- /dev/null
+++ b/test/src/commands/create/commands/dart_package_test.dart
@@ -0,0 +1,214 @@
+import 'dart:io';
+
+import 'package:args/args.dart';
+import 'package:mason/mason.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:path/path.dart' as path;
+import 'package:test/test.dart';
+import 'package:usage/usage.dart';
+import 'package:very_good_cli/src/commands/commands.dart';
+
+import '../../../../helpers/helpers.dart';
+
+class MockAnalytics extends Mock implements Analytics {}
+
+class MockLogger extends Mock implements Logger {}
+
+class MockMasonGenerator extends Mock implements MasonGenerator {}
+
+class MockGeneratorHooks extends Mock implements GeneratorHooks {}
+
+class MockArgResults extends Mock implements ArgResults {}
+
+class FakeLogger extends Fake implements Logger {}
+
+class FakeDirectoryGeneratorTarget extends Fake
+    implements DirectoryGeneratorTarget {}
+
+final expectedUsage = [
+  '''
+Creates a new very good Dart package in the specified directory.
+
+Usage: very_good create dart_package <project-name> [arguments]
+-h, --help                Print this usage information.
+-o, --output-directory    The desired output directory when creating a new project.
+    --description         The description for this new project.
+                          (defaults to "A Very Good Project created by Very Good CLI.")
+    --publishable         Whether the generated project is intended to be published.
+
+Run "very_good help" to see global options.''',
+];
+
+const pubspec = '''
+name: example
+environment:
+  sdk: ">=2.13.0 <3.0.0"
+''';
+
+void main() {
+  late Analytics analytics;
+  late Logger logger;
+
+  setUpAll(() {
+    registerFallbackValue(FakeDirectoryGeneratorTarget());
+    registerFallbackValue(FakeLogger());
+  });
+
+  setUp(() {
+    analytics = MockAnalytics();
+    when(
+      () => analytics.sendEvent(any(), any(), label: any(named: 'label')),
+    ).thenAnswer((_) async {});
+    when(
+      () => analytics.waitForLastPing(timeout: any(named: 'timeout')),
+    ).thenAnswer((_) async {});
+
+    logger = MockLogger();
+
+    final progress = MockProgress();
+
+    when(() => logger.progress(any())).thenReturn(progress);
+  });
+
+  group('can be instantiated', () {
+    test('with default options', () {
+      final logger = Logger();
+      final command = CreateDartPackage(
+        analytics: analytics,
+        logger: logger,
+        generatorFromBundle: null,
+        generatorFromBrick: null,
+      );
+      expect(command.name, equals('dart_package'));
+      expect(
+        command.description,
+        equals(
+          'Creates a new very good Dart package in the specified directory.',
+        ),
+      );
+      expect(command.logger, equals(logger));
+      expect(command, isA<Publishable>());
+    });
+  });
+
+  group('create dart_package', () {
+    test(
+      'help',
+      withRunner((commandRunner, logger, pubUpdater, printLogs) async {
+        final result =
+            await commandRunner.run(['create', 'dart_package', '--help']);
+        expect(printLogs, equals(expectedUsage));
+        expect(result, equals(ExitCode.success.code));
+
+        printLogs.clear();
+
+        final resultAbbr =
+            await commandRunner.run(['create', 'dart_pkg', '-h']);
+        expect(printLogs, equals(expectedUsage));
+        expect(resultAbbr, equals(ExitCode.success.code));
+      }),
+    );
+
+    group('running the command', () {
+      final generatedFiles =
+          List.filled(10, const GeneratedFile.created(path: ''));
+
+      late GeneratorHooks hooks;
+      late MasonGenerator generator;
+
+      setUp(() {
+        hooks = MockGeneratorHooks();
+        generator = MockMasonGenerator();
+
+        when(() => generator.hooks).thenReturn(hooks);
+        when(
+          () => hooks.preGen(
+            vars: any(named: 'vars'),
+            onVarsChanged: any(named: 'onVarsChanged'),
+          ),
+        ).thenAnswer((_) async {});
+
+        when(
+          () => generator.generate(
+            any(),
+            vars: any(named: 'vars'),
+            logger: any(named: 'logger'),
+          ),
+        ).thenAnswer((_) async {
+          return generatedFiles;
+        });
+
+        when(() => generator.id).thenReturn('generator_id');
+        when(() => generator.description).thenReturn('generator description');
+        when(() => generator.hooks).thenReturn(hooks);
+
+        when(
+          () => hooks.preGen(
+            vars: any(named: 'vars'),
+            onVarsChanged: any(named: 'onVarsChanged'),
+          ),
+        ).thenAnswer((_) async {});
+        when(
+          () => generator.generate(
+            any(),
+            vars: any(named: 'vars'),
+            logger: any(named: 'logger'),
+          ),
+        ).thenAnswer((_) async {
+          final target =
+              _.positionalArguments.first as DirectoryGeneratorTarget;
+          File(path.join(target.dir.path, 'my_package', 'pubspec.yaml'))
+            ..createSync(recursive: true)
+            ..writeAsStringSync(pubspec);
+          return generatedFiles;
+        });
+      });
+
+      test('creates dart package', () async {
+        final tempDir = Directory.systemTemp.createTempSync();
+        addTearDown(() => tempDir.deleteSync(recursive: true));
+        final argResults = MockArgResults();
+        final command = CreateDartPackage(
+          analytics: analytics,
+          logger: logger,
+          generatorFromBundle: (_) async => throw Exception('oops'),
+          generatorFromBrick: (_) async => generator,
+        )..argResultOverrides = argResults;
+        when(() => argResults['output-directory'] as String?)
+            .thenReturn(tempDir.path);
+        when(() => argResults.rest).thenReturn(['my_package']);
+
+        final result = await command.run();
+
+        expect(command.template.name, 'dart_pkg');
+        expect(result, equals(ExitCode.success.code));
+
+        verify(() => logger.progress('Bootstrapping')).called(1);
+        verify(
+          () => hooks.preGen(
+            vars: <String, dynamic>{
+              'project_name': 'my_package',
+              'description': '',
+              'publishable': false,
+            },
+            onVarsChanged: any(named: 'onVarsChanged'),
+          ),
+        );
+        verify(
+          () => generator.generate(
+            any(),
+            vars: <String, dynamic>{
+              'project_name': 'my_package',
+              'description': '',
+              'publishable': false,
+            },
+            logger: logger,
+          ),
+        ).called(1);
+        verify(
+          () => logger.info('Created a Very Good Dart Package! 🦄'),
+        ).called(1);
+      });
+    });
+  });
+}
diff --git a/test/src/commands/create/commands/legacy_test.dart b/test/src/commands/create/commands/legacy_test.dart
index 9ac2cabd0..cd3f7779e 100644
--- a/test/src/commands/create/commands/legacy_test.dart
+++ b/test/src/commands/create/commands/legacy_test.dart
@@ -23,7 +23,8 @@ Usage: very_good create <subcommand> <project-name> [arguments]
 -h, --help    Print this usage information.
 
 Available subcommands:
-  flutter_app   Creates a new very good Flutter app in the specified directory.
+  dart_package   Creates a new very good Dart package in the specified directory.
+  flutter_app    Creates a new very good Flutter app in the specified directory.
 
 Run "very_good help" to see global options.'''
 ];
diff --git a/test/src/commands/create/create_subcommand_test.dart b/test/src/commands/create/create_subcommand_test.dart
index 273489bbc..faa180537 100644
--- a/test/src/commands/create/create_subcommand_test.dart
+++ b/test/src/commands/create/create_subcommand_test.dart
@@ -71,6 +71,23 @@ class _TestCreateSubCommandWithOrgName extends _TestCreateSubCommand
         );
 }
 
+class _TestCreateSubCommandWithPublishable extends _TestCreateSubCommand
+    with Publishable {
+  _TestCreateSubCommandWithPublishable({
+    required Template template,
+    required Analytics analytics,
+    required Logger logger,
+    required MasonGeneratorFromBundle? generatorFromBundle,
+    required MasonGeneratorFromBrick? generatorFromBrick,
+  }) : super(
+          template: template,
+          analytics: analytics,
+          logger: logger,
+          generatorFromBundle: generatorFromBundle,
+          generatorFromBrick: generatorFromBrick,
+        );
+}
+
 class _TestCreateSubCommandMultiTemplate extends CreateSubCommand
     with MultiTemplates {
   _TestCreateSubCommandMultiTemplate({
@@ -1082,4 +1099,222 @@ Run "runner help" to see global options.''';
       });
     });
   });
+
+  group('Publishable', () {
+    const expectedUsage = '''
+Usage: very_good create create_subcommand <project-name> [arguments]
+-h, --help                Print this usage information.
+-o, --output-directory    The desired output directory when creating a new project.
+    --description         The description for this new project.
+                          (defaults to "A Very Good Project created by Very Good CLI.")
+    --publishable         Whether the generated project is intended to be published.
+
+Run "runner help" to see global options.''';
+
+    late Template template;
+    late MockBundle bundle;
+
+    setUp(() {
+      bundle = MockBundle();
+      when(() => bundle.name).thenReturn('test');
+      when(() => bundle.description).thenReturn('Test bundle');
+      when(() => bundle.version).thenReturn('<bundleversion>');
+      template = MockTemplate();
+      when(() => template.name).thenReturn('test');
+      when(() => template.bundle).thenReturn(bundle);
+      when(() => template.onGenerateComplete(any(), any())).thenAnswer(
+        (_) async {},
+      );
+      when(
+        () => analytics.sendEvent(any(), any(), label: any(named: 'label')),
+      ).thenAnswer((_) async {});
+    });
+
+    group('can be instantiated', () {
+      test('with default options', () {
+        final command = _TestCreateSubCommandWithPublishable(
+          template: template,
+          analytics: analytics,
+          logger: logger,
+          generatorFromBundle: null,
+          generatorFromBrick: null,
+        );
+
+        expect(
+          command.argParser.options['publishable'],
+          isA<Option>()
+              .having((o) => o.isFlag, 'isFlag', true)
+              .having((o) => o.abbr, 'abbr', null)
+              .having((o) => o.defaultsTo, 'defaultsTo', false)
+              .having((o) => o.aliases, 'aliases', <String>[]),
+        );
+        expect(command.argParser.commands, isEmpty);
+      });
+    });
+
+    group('parsing of options', () {
+      late GeneratorHooks hooks;
+      late MasonGenerator generator;
+      late _TestCommandRunner runner;
+
+      setUp(() {
+        hooks = MockGeneratorHooks();
+        generator = MockMasonGenerator();
+
+        when(() => generator.hooks).thenReturn(hooks);
+        when(
+          () => hooks.preGen(
+            vars: any(named: 'vars'),
+            onVarsChanged: any(named: 'onVarsChanged'),
+          ),
+        ).thenAnswer((_) async {});
+
+        when(
+          () => generator.generate(
+            any(),
+            vars: any(named: 'vars'),
+            logger: any(named: 'logger'),
+          ),
+        ).thenAnswer((_) async {
+          return generatedFiles;
+        });
+
+        when(() => generator.id).thenReturn('generator_id');
+        when(() => generator.description).thenReturn('generator description');
+        when(() => generator.hooks).thenReturn(hooks);
+
+        when(
+          () => hooks.preGen(
+            vars: any(named: 'vars'),
+            onVarsChanged: any(named: 'onVarsChanged'),
+          ),
+        ).thenAnswer((_) async {});
+        when(
+          () => generator.generate(
+            any(),
+            vars: any(named: 'vars'),
+            logger: any(named: 'logger'),
+          ),
+        ).thenAnswer((_) async {
+          return generatedFiles;
+        });
+
+        final command = _TestCreateSubCommandWithPublishable(
+          template: template,
+          analytics: analytics,
+          logger: logger,
+          generatorFromBundle: (_) async => throw Exception('oops'),
+          generatorFromBrick: (_) async => generator,
+        );
+
+        runner = _TestCommandRunner(command: command);
+      });
+
+      test('parses publishable', () async {
+        final result = await runner.run([
+          'create_subcommand',
+          'test_project',
+          '--publishable',
+        ]);
+
+        expect(result, equals(ExitCode.success.code));
+
+        verify(
+          () => hooks.preGen(
+            vars: any(
+              named: 'vars',
+              that: isA<Map<String, dynamic>>().having(
+                (description) => description['publishable'],
+                'publishable',
+                true,
+              ),
+            ),
+            onVarsChanged: any(named: 'onVarsChanged'),
+          ),
+        );
+
+        verify(
+          () => generator.generate(
+            any(
+              that: isA<DirectoryGeneratorTarget>(),
+            ),
+            vars: any(
+              named: 'vars',
+              that: isA<Map>().having(
+                (vars) {
+                  return vars['publishable'];
+                },
+                'publishable',
+                true,
+              ),
+            ),
+            logger: logger,
+          ),
+        ).called(1);
+      });
+
+      test('uses default values for omitted options', () async {
+        final result = await runner.run([
+          'create_subcommand',
+          'test_project',
+        ]);
+
+        expect(result, equals(ExitCode.success.code));
+
+        verify(
+          () => hooks.preGen(
+            vars: any(
+              named: 'vars',
+              that: isA<Map<String, dynamic>>().having(
+                (description) => description['publishable'],
+                'publishable',
+                false,
+              ),
+            ),
+            onVarsChanged: any(named: 'onVarsChanged'),
+          ),
+        );
+
+        verify(
+          () => generator.generate(
+            any(
+              that: isA<DirectoryGeneratorTarget>(),
+            ),
+            vars: any(
+              named: 'vars',
+              that: isA<Map>().having(
+                (description) => description['publishable'],
+                'publishable',
+                false,
+              ),
+            ),
+            logger: logger,
+          ),
+        ).called(1);
+      });
+
+      group('validates publishable', () {
+        test('throws UsageException when --template is invalid', () async {
+          await expectLater(
+            () async {
+              await runner.run([
+                'create_subcommand',
+                'test_project',
+                '--no-publishable',
+              ]);
+            },
+            throwsA(
+              isA<UsageException>()
+                  .having((e) => e.usage, 'usage', expectedUsage)
+                  .having(
+                    (e) => e.message,
+                    'message',
+                    'Cannot negate option "no-publishable".',
+                  ),
+            ),
+          );
+        });
+      });
+    });
+  });
 }
diff --git a/test/src/commands/create/create_test.dart b/test/src/commands/create/create_test.dart
index 835f69b54..ab086ba96 100644
--- a/test/src/commands/create/create_test.dart
+++ b/test/src/commands/create/create_test.dart
@@ -29,7 +29,8 @@ Usage: very_good create <subcommand> <project-name> [arguments]
 -h, --help    Print this usage information.
 
 Available subcommands:
-  flutter_app   Creates a new very good Flutter app in the specified directory.
+  dart_package   Creates a new very good Dart package in the specified directory.
+  flutter_app    Creates a new very good Flutter app in the specified directory.
 
 Run "very_good help" to see global options.'''
 ];
diff --git a/test/src/commands/create/e2e/dart_package/dart_pkg_test.dart b/test/src/commands/create/e2e/dart_package/dart_pkg_test.dart
new file mode 100644
index 000000000..856906371
--- /dev/null
+++ b/test/src/commands/create/e2e/dart_package/dart_pkg_test.dart
@@ -0,0 +1,61 @@
+@Tags(['e2e'])
+import 'package:mason/mason.dart';
+import 'package:path/path.dart' as path;
+import 'package:test/test.dart';
+import 'package:universal_io/io.dart';
+
+import '../../../../../helpers/helpers.dart';
+
+void main() {
+  test(
+    'create dart_package',
+    withRunner((commandRunner, logger, updater, logs) async {
+      final directory = Directory.systemTemp.createTempSync();
+
+      final result = await commandRunner.run(
+        ['create', 'dart_package', 'very_good_dart', '-o', directory.path],
+      );
+      expect(result, equals(ExitCode.success.code));
+
+      final formatResult = await Process.run(
+        'flutter',
+        ['format', '--set-exit-if-changed', '.'],
+        workingDirectory: path.join(directory.path, 'very_good_dart'),
+        runInShell: true,
+      );
+      expect(formatResult.exitCode, equals(ExitCode.success.code));
+      expect(formatResult.stderr, isEmpty);
+
+      final analyzeResult = await Process.run(
+        'flutter',
+        ['analyze', '.'],
+        workingDirectory: path.join(directory.path, 'very_good_dart'),
+        runInShell: true,
+      );
+      expect(analyzeResult.exitCode, equals(ExitCode.success.code));
+      expect(analyzeResult.stderr, isEmpty);
+      expect(analyzeResult.stdout, contains('No issues found!'));
+
+      final testResult = await Process.run(
+        'flutter',
+        ['test', '--no-pub', '--coverage'],
+        workingDirectory: path.join(directory.path, 'very_good_dart'),
+        runInShell: true,
+      );
+      expect(testResult.exitCode, equals(ExitCode.success.code));
+      expect(testResult.stderr, isEmpty);
+      expect(testResult.stdout, contains('All tests passed!'));
+
+      final testCoverageResult = await Process.run(
+        'genhtml',
+        ['coverage/lcov.info', '-o', 'coverage'],
+        workingDirectory: path.join(directory.path, 'very_good_dart'),
+        runInShell: true,
+      );
+      expect(testCoverageResult.exitCode, equals(ExitCode.success.code));
+      expect(testCoverageResult.stderr, isEmpty);
+      expect(testCoverageResult.stdout, contains('lines......: 100.0%'));
+    }),
+    timeout: const Timeout(Duration(minutes: 2)),
+  );
+}