diff --git a/packages/shorebird_tools/.gitignore b/packages/shorebird_tools/.gitignore new file mode 100644 index 0000000000000..9f6ee8a769286 --- /dev/null +++ b/packages/shorebird_tools/.gitignore @@ -0,0 +1,15 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock + +# Files generated during tests +.test_coverage.dart +coverage/ +.test_runner.dart + +# Android studio and IntelliJ +.idea \ No newline at end of file diff --git a/packages/shorebird_tools/README.md b/packages/shorebird_tools/README.md new file mode 100644 index 0000000000000..86be36477f96d --- /dev/null +++ b/packages/shorebird_tools/README.md @@ -0,0 +1,3 @@ +## shorebird_tools + +Tools for shorebird which need to be pinned to a specific flutter version. \ No newline at end of file diff --git a/packages/shorebird_tools/analysis_options.yaml b/packages/shorebird_tools/analysis_options.yaml new file mode 100644 index 0000000000000..fa798a831248a --- /dev/null +++ b/packages/shorebird_tools/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:very_good_analysis/analysis_options.5.1.0.yaml +linter: + rules: + public_member_api_docs: false diff --git a/packages/shorebird_tools/bin/shorebird_tools.dart b/packages/shorebird_tools/bin/shorebird_tools.dart new file mode 100644 index 0000000000000..8aa6880abb82d --- /dev/null +++ b/packages/shorebird_tools/bin/shorebird_tools.dart @@ -0,0 +1,18 @@ +import 'dart:io'; + +import 'package:shorebird_tools/src/command_runner.dart'; + +Future main(List args) async { + await _flushThenExit(await ShorebirdToolsCommandRunner().run(args)); +} + +/// Flushes the stdout and stderr streams, then exits the program with the given +/// status code. +/// +/// This returns a Future that will never complete, since the program will have +/// exited already. This is useful to prevent Future chains from proceeding +/// after you've decided to exit. +Future _flushThenExit(int status) { + return Future.wait([stdout.close(), stderr.close()]) + .then((_) => exit(status)); +} diff --git a/packages/shorebird_tools/dart_test.yaml b/packages/shorebird_tools/dart_test.yaml new file mode 100644 index 0000000000000..2f46c7e98c1b1 --- /dev/null +++ b/packages/shorebird_tools/dart_test.yaml @@ -0,0 +1,3 @@ +tags: + version-verify: + skip: "Should only be run during pull request. Verifies if version file is updated." \ No newline at end of file diff --git a/packages/shorebird_tools/lib/shorebird_tools.dart b/packages/shorebird_tools/lib/shorebird_tools.dart new file mode 100644 index 0000000000000..2c6f094a47bf5 --- /dev/null +++ b/packages/shorebird_tools/lib/shorebird_tools.dart @@ -0,0 +1,10 @@ +/// shorebird_tools, Shorebird tools for Flutter +/// +/// ```sh +/// # activate shorebird_tools +/// dart pub global activate shorebird_tools +/// +/// # see usage +/// shorebird_tools --help +/// ``` +library; diff --git a/packages/shorebird_tools/lib/src/command_runner.dart b/packages/shorebird_tools/lib/src/command_runner.dart new file mode 100644 index 0000000000000..9b3a09a2ae5c5 --- /dev/null +++ b/packages/shorebird_tools/lib/src/command_runner.dart @@ -0,0 +1,108 @@ +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:shorebird_tools/src/commands/commands.dart'; +import 'package:shorebird_tools/src/version.dart'; + +const executableName = 'shorebird_tools'; +const packageName = 'shorebird_tools'; +const description = 'Shorebird tools for Flutter'; + +/// {@template shorebird_tools_command_runner} +/// A [CommandRunner] for the CLI. +/// +/// ``` +/// $ shorebird_tools --version +/// ``` +/// {@endtemplate} +class ShorebirdToolsCommandRunner extends CommandRunner { + /// {@macro shorebird_tools_command_runner} + ShorebirdToolsCommandRunner({ + Logger? logger, + }) : _logger = logger ?? Logger(), + super(executableName, description) { + // Add root options and flags + argParser + ..addFlag( + 'version', + abbr: 'v', + negatable: false, + help: 'Print the current version.', + ) + ..addFlag( + 'verbose', + help: 'Noisy logging, including all shell commands executed.', + ); + + // Add sub commands + addCommand(SampleCommand(logger: _logger)); + } + + @override + void printUsage() => _logger.info(usage); + + final Logger _logger; + + @override + Future run(Iterable args) async { + try { + final topLevelResults = parse(args); + if (topLevelResults['verbose'] == true) { + _logger.level = Level.verbose; + } + return await runCommand(topLevelResults) ?? ExitCode.success.code; + } on FormatException catch (e, stackTrace) { + // On format errors, show the commands error message, root usage and + // exit with an error code + _logger + ..err(e.message) + ..err('$stackTrace') + ..info('') + ..info(usage); + return ExitCode.usage.code; + } on UsageException catch (e) { + // On usage errors, show the commands usage message and + // exit with an error code + _logger + ..err(e.message) + ..info('') + ..info(e.usage); + return ExitCode.usage.code; + } + } + + @override + Future runCommand(ArgResults topLevelResults) async { + // Verbose logs + _logger + ..detail('Argument information:') + ..detail(' Top level options:'); + for (final option in topLevelResults.options) { + if (topLevelResults.wasParsed(option)) { + _logger.detail(' - $option: ${topLevelResults[option]}'); + } + } + if (topLevelResults.command != null) { + final commandResult = topLevelResults.command!; + _logger + ..detail(' Command: ${commandResult.name}') + ..detail(' Command options:'); + for (final option in commandResult.options) { + if (commandResult.wasParsed(option)) { + _logger.detail(' - $option: ${commandResult[option]}'); + } + } + } + + // Run the command or show version + final int? exitCode; + if (topLevelResults['version'] == true) { + _logger.info(packageVersion); + exitCode = ExitCode.success.code; + } else { + exitCode = await super.runCommand(topLevelResults); + } + + return exitCode; + } +} diff --git a/packages/shorebird_tools/lib/src/commands/commands.dart b/packages/shorebird_tools/lib/src/commands/commands.dart new file mode 100644 index 0000000000000..fc0403a8354fe --- /dev/null +++ b/packages/shorebird_tools/lib/src/commands/commands.dart @@ -0,0 +1 @@ +export 'sample_command.dart'; diff --git a/packages/shorebird_tools/lib/src/commands/sample_command.dart b/packages/shorebird_tools/lib/src/commands/sample_command.dart new file mode 100644 index 0000000000000..b332cc914a0b4 --- /dev/null +++ b/packages/shorebird_tools/lib/src/commands/sample_command.dart @@ -0,0 +1,39 @@ +import 'package:args/command_runner.dart'; +import 'package:mason_logger/mason_logger.dart'; + +/// {@template sample_command} +/// +/// `shorebird_tools sample` +/// A [Command] to exemplify a sub command +/// {@endtemplate} +class SampleCommand extends Command { + /// {@macro sample_command} + SampleCommand({ + required Logger logger, + }) : _logger = logger { + argParser.addFlag( + 'cyan', + abbr: 'c', + help: 'Prints the same joke, but in cyan', + negatable: false, + ); + } + + @override + String get description => 'A sample sub command that just prints one joke'; + + @override + String get name => 'sample'; + + final Logger _logger; + + @override + Future run() async { + var output = 'Which unicorn has a cold? The Achoo-nicorn!'; + if (argResults?['cyan'] == true) { + output = lightCyan.wrap(output)!; + } + _logger.info(output); + return ExitCode.success.code; + } +} diff --git a/packages/shorebird_tools/lib/src/version.dart b/packages/shorebird_tools/lib/src/version.dart new file mode 100644 index 0000000000000..67a7647b6c6d7 --- /dev/null +++ b/packages/shorebird_tools/lib/src/version.dart @@ -0,0 +1,2 @@ +// Generated code. Do not modify. +const packageVersion = '0.0.1'; diff --git a/packages/shorebird_tools/pubspec.yaml b/packages/shorebird_tools/pubspec.yaml new file mode 100644 index 0000000000000..af140b04e5999 --- /dev/null +++ b/packages/shorebird_tools/pubspec.yaml @@ -0,0 +1,22 @@ +name: shorebird_tools +description: Shorebird tools for Flutter +version: 0.0.1 +publish_to: none + +environment: + sdk: "^3.3.0" + +dependencies: + args: ^2.4.2 + mason_logger: ^0.2.12 + +dev_dependencies: + build_runner: ^2.4.8 + build_verify: ^3.1.0 + build_version: ^2.1.1 + mocktail: ^1.0.3 + test: ^1.25.2 + very_good_analysis: ^5.1.0 + +executables: + shorebird_tools: diff --git a/packages/shorebird_tools/test/ensure_build_test.dart b/packages/shorebird_tools/test/ensure_build_test.dart new file mode 100644 index 0000000000000..3d1173b063895 --- /dev/null +++ b/packages/shorebird_tools/test/ensure_build_test.dart @@ -0,0 +1,9 @@ +@Tags(['version-verify']) +library; + +import 'package:build_verify/build_verify.dart'; +import 'package:test/test.dart'; + +void main() { + test('ensure_build', expectBuildClean); +} diff --git a/packages/shorebird_tools/test/src/command_runner_test.dart b/packages/shorebird_tools/test/src/command_runner_test.dart new file mode 100644 index 0000000000000..22ebba4f46078 --- /dev/null +++ b/packages/shorebird_tools/test/src/command_runner_test.dart @@ -0,0 +1,103 @@ +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shorebird_tools/src/command_runner.dart'; +import 'package:shorebird_tools/src/version.dart'; +import 'package:test/test.dart'; + +class _MockLogger extends Mock implements Logger {} + +class _MockProgress extends Mock implements Progress {} + + +const latestVersion = '0.0.0'; + + +void main() { + group('ShorebirdToolsCommandRunner', () { + late Logger logger; + late ShorebirdToolsCommandRunner commandRunner; + + setUp(() { + logger = _MockLogger(); + + commandRunner = ShorebirdToolsCommandRunner( + logger: logger, + ); + }); + + test('can be instantiated without an explicit analytics/logger instance', + () { + final commandRunner = ShorebirdToolsCommandRunner(); + expect(commandRunner, isNotNull); + }); + + test('handles FormatException', () async { + const exception = FormatException('oops!'); + var isFirstInvocation = true; + when(() => logger.info(any())).thenAnswer((_) { + if (isFirstInvocation) { + isFirstInvocation = false; + throw exception; + } + }); + final result = await commandRunner.run(['--version']); + expect(result, equals(ExitCode.usage.code)); + verify(() => logger.err(exception.message)).called(1); + verify(() => logger.info(commandRunner.usage)).called(1); + }); + + test('handles UsageException', () async { + final exception = UsageException('oops!', 'exception usage'); + var isFirstInvocation = true; + when(() => logger.info(any())).thenAnswer((_) { + if (isFirstInvocation) { + isFirstInvocation = false; + throw exception; + } + }); + final result = await commandRunner.run(['--version']); + expect(result, equals(ExitCode.usage.code)); + verify(() => logger.err(exception.message)).called(1); + verify(() => logger.info('exception usage')).called(1); + }); + + group('--version', () { + test('outputs current version', () async { + final result = await commandRunner.run(['--version']); + expect(result, equals(ExitCode.success.code)); + verify(() => logger.info(packageVersion)).called(1); + }); + }); + + group('--verbose', () { + test('enables verbose logging', () async { + final result = await commandRunner.run(['--verbose']); + expect(result, equals(ExitCode.success.code)); + + verify(() => logger.detail('Argument information:')).called(1); + verify(() => logger.detail(' Top level options:')).called(1); + verify(() => logger.detail(' - verbose: true')).called(1); + verifyNever(() => logger.detail(' Command options:')); + }); + + test('enables verbose logging for sub commands', () async { + final result = await commandRunner.run([ + '--verbose', + 'sample', + '--cyan', + ]); + expect(result, equals(ExitCode.success.code)); + + verify(() => logger.detail('Argument information:')).called(1); + verify(() => logger.detail(' Top level options:')).called(1); + verify(() => logger.detail(' - verbose: true')).called(1); + verify(() => logger.detail(' Command: sample')).called(1); + verify(() => logger.detail(' Command options:')).called(1); + verify(() => logger.detail(' - cyan: true')).called(1); + }); + }); + }); +} diff --git a/packages/shorebird_tools/test/src/commands/sample_command_test.dart b/packages/shorebird_tools/test/src/commands/sample_command_test.dart new file mode 100644 index 0000000000000..f0a55a5ad31e8 --- /dev/null +++ b/packages/shorebird_tools/test/src/commands/sample_command_test.dart @@ -0,0 +1,58 @@ +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shorebird_tools/src/command_runner.dart'; +import 'package:test/test.dart'; + +class _MockLogger extends Mock implements Logger {} + +void main() { + group('sample', () { + late Logger logger; + late ShorebirdToolsCommandRunner commandRunner; + + setUp(() { + logger = _MockLogger(); + commandRunner = ShorebirdToolsCommandRunner(logger: logger); + }); + + test('tells a joke', () async { + final exitCode = await commandRunner.run(['sample']); + + expect(exitCode, ExitCode.success.code); + + verify( + () => logger.info('Which unicorn has a cold? The Achoo-nicorn!'), + ).called(1); + }); + test('tells a joke in cyan', () async { + final exitCode = await commandRunner.run(['sample', '-c']); + + expect(exitCode, ExitCode.success.code); + + verify( + () => logger.info( + lightCyan.wrap('Which unicorn has a cold? The Achoo-nicorn!'), + ), + ).called(1); + }); + + test('wrong usage', () async { + final exitCode = await commandRunner.run(['sample', '-p']); + + expect(exitCode, ExitCode.usage.code); + + verify(() => logger.err('Could not find an option or flag "-p".')) + .called(1); + verify( + () => logger.info( + ''' +Usage: $executableName sample [arguments] +-h, --help Print this usage information. +-c, --cyan Prints the same joke, but in cyan + +Run "$executableName help" to see global options.''', + ), + ).called(1); + }); + }); +}