diff --git a/packages/shorebird_cli/README.md b/packages/shorebird_cli/README.md index e68840a1..fcae6fd8 100644 --- a/packages/shorebird_cli/README.md +++ b/packages/shorebird_cli/README.md @@ -78,6 +78,25 @@ shorebird logout ✓ Logging out of shorebird.dev (1ms) ``` +### Doctor + +To check your environment for common issues, use the `shorebird doctor` command. + +```bash +shorebird doctor +``` + +**Sample** + +``` +$ shorebird doctor +Doctor summary + +Shorebird v0.0.3 + +No issues detected! +``` + ### Create App To create an app use the `shorebird apps create` command. An app id can be specified as a CLI option but shorebird will default to the `app_id` defined in the `shorebird.yaml` @@ -308,6 +327,7 @@ Available commands: apps Manage your Shorebird apps. build Build a new release of your application. channels Manage the channels for your Shorebird app. + doctor Show information about the installed tooling. init Initialize Shorebird. login Login as a new Shorebird user. logout Logout of the current Shorebird user diff --git a/packages/shorebird_cli/lib/src/command_runner.dart b/packages/shorebird_cli/lib/src/command_runner.dart index 3d0035bd..4f2a986f 100644 --- a/packages/shorebird_cli/lib/src/command_runner.dart +++ b/packages/shorebird_cli/lib/src/command_runner.dart @@ -49,6 +49,7 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner { addCommand(AppsCommand(logger: _logger)); addCommand(BuildCommand(logger: _logger)); addCommand(ChannelsCommand(logger: _logger)); + addCommand(DoctorCommand(logger: _logger)); addCommand(InitCommand(logger: _logger)); addCommand(LoginCommand(logger: _logger)); addCommand(LogoutCommand(logger: _logger)); diff --git a/packages/shorebird_cli/lib/src/commands/commands.dart b/packages/shorebird_cli/lib/src/commands/commands.dart index 637cf1ff..2b2cca7e 100644 --- a/packages/shorebird_cli/lib/src/commands/commands.dart +++ b/packages/shorebird_cli/lib/src/commands/commands.dart @@ -1,6 +1,7 @@ export 'apps/apps.dart'; export 'build_command.dart'; export 'channels/channels.dart'; +export 'doctor_command.dart'; export 'init_command.dart'; export 'login_command.dart'; export 'logout_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/doctor_command.dart b/packages/shorebird_cli/lib/src/commands/doctor_command.dart new file mode 100644 index 00000000..1885a546 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/doctor_command.dart @@ -0,0 +1,55 @@ +import 'dart:io'; + +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; +import 'package:shorebird_cli/src/command.dart'; +import 'package:shorebird_cli/src/shorebird_version_mixin.dart'; +import 'package:shorebird_cli/src/version.dart'; + +/// {@template doctor_command} +/// +/// `shorebird doctor` +/// A command that checks for potential issues with the current shorebird +/// environment. +/// {@endtemplate} +class DoctorCommand extends ShorebirdCommand with ShorebirdVersionMixin { + /// {@macro doctor_command} + DoctorCommand({required super.logger, super.runProcess}); + + @override + String get name => 'doctor'; + + @override + String get description => 'Show information about the installed tooling.'; + + @override + Future run() async { + var numIssues = 0; + final workingDirectory = p.dirname(Platform.script.toFilePath()); + logger.info(''' +Doctor summary + +Shorebird v$packageVersion +'''); + + final isShorebirdUpToDate = await isShorebirdVersionCurrent( + workingDirectory: workingDirectory, + ); + + if (!isShorebirdUpToDate) { + numIssues += 1; + logger.info(''' +A new version of shorebird is available! +Run `shorebird upgrade` to upgrade. +'''); + } + + if (numIssues == 0) { + logger.info('No issues detected!'); + } else { + logger.info('$numIssues issue${numIssues == 1 ? '' : 's'} detected.'); + } + + return ExitCode.success.code; + } +} diff --git a/packages/shorebird_cli/lib/src/commands/upgrade_command.dart b/packages/shorebird_cli/lib/src/commands/upgrade_command.dart index dc376b57..106ad45d 100644 --- a/packages/shorebird_cli/lib/src/commands/upgrade_command.dart +++ b/packages/shorebird_cli/lib/src/commands/upgrade_command.dart @@ -3,12 +3,13 @@ import 'dart:io'; import 'package:mason_logger/mason_logger.dart'; import 'package:path/path.dart' as p; import 'package:shorebird_cli/src/command.dart'; +import 'package:shorebird_cli/src/shorebird_version_mixin.dart'; /// {@template upgrade_command} /// `shorebird upgrade` /// A command which upgrades your copy of Shorebird. /// {@endtemplate} -class UpgradeCommand extends ShorebirdCommand { +class UpgradeCommand extends ShorebirdCommand with ShorebirdVersionMixin { /// {@macro upgrade_command} UpgradeCommand({required super.logger, super.runProcess}); @@ -27,7 +28,7 @@ class UpgradeCommand extends ShorebirdCommand { late final String currentVersion; try { - currentVersion = await fetchCurrentVersion( + currentVersion = await fetchCurrentGitHash( workingDirectory: workingDirectory, ); } on ProcessException catch (error) { @@ -38,7 +39,7 @@ class UpgradeCommand extends ShorebirdCommand { late final String latestVersion; try { - latestVersion = await fetchLatestVersion( + latestVersion = await fetchLatestGitHash( workingDirectory: workingDirectory, ); } on ProcessException catch (error) { @@ -72,73 +73,4 @@ class UpgradeCommand extends ShorebirdCommand { return ExitCode.success.code; } - - /// Returns the remote HEAD shorebird version. - /// - /// Exits if HEAD isn't pointing to a branch, or there is no upstream. - Future fetchLatestVersion({required String workingDirectory}) async { - // Fetch upstream branch's commits and tags - await runProcess( - 'git', - ['fetch', '--tags'], - workingDirectory: workingDirectory, - ); - // Get the latest commit revision of the upstream - return _gitRevParse('@{upstream}', workingDirectory: workingDirectory); - } - - /// Returns the local HEAD shorebird version. - /// - /// Exits if HEAD isn't pointing to a branch, or there is no upstream. - Future fetchCurrentVersion({ - required String workingDirectory, - }) async { - // Get the commit revision of HEAD - return _gitRevParse('HEAD', workingDirectory: workingDirectory); - } - - Future _gitRevParse( - String revision, { - String? workingDirectory, - }) async { - // Get the commit revision of HEAD - final result = await runProcess( - 'git', - ['rev-parse', '--verify', revision], - workingDirectory: workingDirectory, - ); - if (result.exitCode != 0) { - throw ProcessException( - 'git', - ['rev-parse', '--verify', revision], - '${result.stderr}', - result.exitCode, - ); - } - return '${result.stdout}'.trim(); - } - - /// Attempts a hard reset to the given revision. - /// - /// This is a reset instead of fast forward because if we are on a release - /// branch with cherry picks, there may not be a direct fast-forward route - /// to the next release. - Future attemptReset({ - required String newRevision, - required String workingDirectory, - }) async { - final result = await runProcess( - 'git', - ['reset', '--hard', newRevision], - workingDirectory: workingDirectory, - ); - if (result.exitCode != 0) { - throw ProcessException( - 'git', - ['reset', '--hard', newRevision], - '${result.stderr}', - result.exitCode, - ); - } - } } diff --git a/packages/shorebird_cli/lib/src/shorebird_version_mixin.dart b/packages/shorebird_cli/lib/src/shorebird_version_mixin.dart new file mode 100644 index 00000000..786952d3 --- /dev/null +++ b/packages/shorebird_cli/lib/src/shorebird_version_mixin.dart @@ -0,0 +1,89 @@ +import 'dart:io'; + +import 'package:shorebird_cli/src/command.dart'; + +mixin ShorebirdVersionMixin on ShorebirdCommand { + /// Whether the current version of Shorebird is the latest available. + Future isShorebirdVersionCurrent({ + required String workingDirectory, + }) async { + final currentVersion = await fetchCurrentGitHash( + workingDirectory: workingDirectory, + ); + + final latestVersion = await fetchLatestGitHash( + workingDirectory: workingDirectory, + ); + + return currentVersion == latestVersion; + } + + /// Returns the remote HEAD shorebird hash. + /// + /// Exits if HEAD isn't pointing to a branch, or there is no upstream. + Future fetchLatestGitHash({required String workingDirectory}) async { + // Fetch upstream branch's commits and tags + await runProcess( + 'git', + ['fetch', '--tags'], + workingDirectory: workingDirectory, + ); + // Get the latest commit revision of the upstream + return _gitRevParse('@{upstream}', workingDirectory: workingDirectory); + } + + /// Returns the local HEAD shorebird hash. + /// + /// Exits if HEAD isn't pointing to a branch, or there is no upstream. + Future fetchCurrentGitHash({ + required String workingDirectory, + }) async { + // Get the commit revision of HEAD + return _gitRevParse('HEAD', workingDirectory: workingDirectory); + } + + Future _gitRevParse( + String revision, { + String? workingDirectory, + }) async { + // Get the commit revision of HEAD + final result = await runProcess( + 'git', + ['rev-parse', '--verify', revision], + workingDirectory: workingDirectory, + ); + if (result.exitCode != 0) { + throw ProcessException( + 'git', + ['rev-parse', '--verify', revision], + '${result.stderr}', + result.exitCode, + ); + } + return '${result.stdout}'.trim(); + } + + /// Attempts a hard reset to the given revision. + /// + /// This is a reset instead of fast forward because if we are on a release + /// branch with cherry picks, there may not be a direct fast-forward route + /// to the next release. + Future attemptReset({ + required String newRevision, + required String workingDirectory, + }) async { + final result = await runProcess( + 'git', + ['reset', '--hard', newRevision], + workingDirectory: workingDirectory, + ); + if (result.exitCode != 0) { + throw ProcessException( + 'git', + ['reset', '--hard', newRevision], + '${result.stderr}', + result.exitCode, + ); + } + } +} diff --git a/packages/shorebird_cli/test/src/commands/doctor_command_test.dart b/packages/shorebird_cli/test/src/commands/doctor_command_test.dart new file mode 100644 index 00000000..a549cac1 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/doctor_command_test.dart @@ -0,0 +1,86 @@ +import 'dart:io'; + +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shorebird_cli/src/commands/commands.dart'; +import 'package:test/test.dart'; + +class _MockLogger extends Mock implements Logger {} + +class _MockProcessResult extends Mock implements ProcessResult {} + +void main() { + const currentShorebirdRevision = 'revision-1'; + const newerShorebirdRevision = 'revision-2'; + + group('doctor', () { + late Logger logger; + late DoctorCommand command; + late ProcessResult fetchCurrentVersionResult; + late ProcessResult fetchLatestVersionResult; + + setUp(() { + logger = _MockLogger(); + fetchCurrentVersionResult = _MockProcessResult(); + fetchLatestVersionResult = _MockProcessResult(); + + command = DoctorCommand( + logger: logger, + runProcess: ( + executable, + arguments, { + bool runInShell = false, + workingDirectory, + }) async { + if (executable == 'git') { + const revParseHead = ['rev-parse', '--verify', 'HEAD']; + if (arguments.every((arg) => revParseHead.contains(arg))) { + return fetchCurrentVersionResult; + } + + const revParseUpstream = ['rev-parse', '--verify', '@{upstream}']; + if (arguments.every((arg) => revParseUpstream.contains(arg))) { + return fetchLatestVersionResult; + } + } + return _MockProcessResult(); + }, + ); + + when( + () => fetchCurrentVersionResult.exitCode, + ).thenReturn(ExitCode.success.code); + when( + () => fetchCurrentVersionResult.stdout, + ).thenReturn(currentShorebirdRevision); + when( + () => fetchLatestVersionResult.exitCode, + ).thenReturn(ExitCode.success.code); + when( + () => fetchLatestVersionResult.stdout, + ).thenReturn(currentShorebirdRevision); + }); + + test('prints "no issues" when everything is OK', () async { + await command.run(); + verify( + () => logger.info(captureAny(that: contains('No issues detected'))), + ).called(1); + }); + + test('prints that an upgrade is available', () async { + when( + () => fetchLatestVersionResult.stdout, + ).thenReturn(newerShorebirdRevision); + + await command.run(); + verify( + () => logger.info( + captureAny( + that: contains('A new version of shorebird is available!'), + ), + ), + ).called(1); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/commands/upgrade_test.dart b/packages/shorebird_cli/test/src/commands/upgrade_command_test.dart similarity index 100% rename from packages/shorebird_cli/test/src/commands/upgrade_test.dart rename to packages/shorebird_cli/test/src/commands/upgrade_command_test.dart