diff --git a/packages/shorebird_cli/README.md b/packages/shorebird_cli/README.md index e54ae8a3..7d976614 100644 --- a/packages/shorebird_cli/README.md +++ b/packages/shorebird_cli/README.md @@ -239,6 +239,29 @@ Build a new release of your application using the `shorebird build` command: shorebird build ``` +### List Channels + +See all available channels for your application using the `shorebird channels list` command: + +```bash +shorebird channels list +``` + +**Sample** + +``` +shorebird channels list +📱 App ID: 61fc9c16-3c4a-4825-a155-9765993614aa +📺 Channels +┌─────────────┐ +│ Name │ +├─────────────┤ +│ stable │ +├─────────────┤ +│ development │ +└─────────────┘ +``` + ### Usage ``` @@ -252,15 +275,16 @@ Global options: --[no-]verbose Noisy logging, including all shell commands executed. Available commands: - apps Manage your Shorebird apps. - build Build a new release of your application. - init Initialize Shorebird. - login Login as a new Shorebird user. - logout Logout of the current Shorebird user - patch Publish new patches for a specific release to Shorebird. - release Builds and submits your app to Shorebird. - run Run the Flutter application. - upgrade Upgrade your copy of Shorebird. + apps Manage your Shorebird apps. + build Build a new release of your application. + channels Manage the channels for your Shorebird app. + init Initialize Shorebird. + login Login as a new Shorebird user. + logout Logout of the current Shorebird user + patch Publish new patches for a specific release to Shorebird. + release Builds and submits your app to Shorebird. + run Run the Flutter application. + upgrade Upgrade your copy of Shorebird. Run "shorebird help " for more information about a command. ``` diff --git a/packages/shorebird_cli/lib/src/command_runner.dart b/packages/shorebird_cli/lib/src/command_runner.dart index db7ad69f..ec504119 100644 --- a/packages/shorebird_cli/lib/src/command_runner.dart +++ b/packages/shorebird_cli/lib/src/command_runner.dart @@ -48,6 +48,7 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner { addCommand(AppsCommand(logger: _logger)); addCommand(BuildCommand(logger: _logger)); + addCommand(ChannelsCommand(logger: _logger)); addCommand(InitCommand(logger: _logger)); addCommand(LoginCommand(logger: _logger)); addCommand(LogoutCommand(logger: _logger)); diff --git a/packages/shorebird_cli/lib/src/commands/channels/channels.dart b/packages/shorebird_cli/lib/src/commands/channels/channels.dart new file mode 100644 index 00000000..496e8f1c --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/channels/channels.dart @@ -0,0 +1,2 @@ +export 'channels_command.dart'; +export 'list_channels_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/channels/channels_command.dart b/packages/shorebird_cli/lib/src/commands/channels/channels_command.dart new file mode 100644 index 00000000..b61b6e21 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/channels/channels_command.dart @@ -0,0 +1,19 @@ +import 'package:shorebird_cli/src/command.dart'; +import 'package:shorebird_cli/src/commands/commands.dart'; + +/// {@template channels_command} +/// `shorebird channels` +/// Manage the channels for your Shorebird app. +/// {@endtemplate} +class ChannelsCommand extends ShorebirdCommand { + /// {@macro channels_command} + ChannelsCommand({required super.logger}) { + addSubcommand(ListChannelsCommand(logger: logger)); + } + + @override + String get description => 'Manage the channels for your Shorebird app.'; + + @override + String get name => 'channels'; +} diff --git a/packages/shorebird_cli/lib/src/commands/channels/list_channels_command.dart b/packages/shorebird_cli/lib/src/commands/channels/list_channels_command.dart new file mode 100644 index 00000000..2d5b727a --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/channels/list_channels_command.dart @@ -0,0 +1,110 @@ +import 'dart:async'; + +import 'package:barbecue/barbecue.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:shorebird_cli/src/command.dart'; +import 'package:shorebird_cli/src/shorebird_config_mixin.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; + +/// {@template list_channels_command} +/// `shorebird channels list` +/// List all channels for a Shorebird app. +/// {@endtemplate} +class ListChannelsCommand extends ShorebirdCommand with ShorebirdConfigMixin { + /// {@macro list_channels_command} + ListChannelsCommand({ + required super.logger, + super.buildCodePushClient, + super.auth, + }) { + argParser.addOption( + _appIdOption, + help: 'The app id to list channels for.', + ); + } + + static const String _appIdOption = 'app-id'; + + @override + String get description => 'List all channels for a Shorebird app.'; + + @override + String get name => 'list'; + + @override + List get aliases => ['ls']; + + @override + Future? run() async { + final session = auth.currentSession; + if (session == null) { + logger.err('You must be logged in to view channels.'); + return ExitCode.noUser.code; + } + + final client = buildCodePushClient( + apiKey: session.apiKey, + hostedUri: hostedUri, + ); + + final appId = results[_appIdOption] as String? ?? getShorebirdYaml()?.appId; + if (appId == null) { + logger.err( + ''' +Could not find an app id. + +You must either specify an app id via the "--$_appIdOption" flag or run this command from within a directory with a valid "shorebird.yaml" file.''', + ); + return ExitCode.usage.code; + } + + final List channels; + try { + channels = await client.getChannels(appId: appId); + } catch (error) { + logger.err('$error'); + return ExitCode.software.code; + } + + logger.info( + ''' +📱 App ID: ${lightCyan.wrap(appId)} +📺 Channels''', + ); + + if (channels.isEmpty) { + logger.info('(empty)'); + return ExitCode.success.code; + } + + logger.info(channels.prettyPrint()); + + return ExitCode.success.code; + } +} + +extension on List { + String prettyPrint() { + const cellStyle = CellStyle( + paddingLeft: 1, + paddingRight: 1, + borderBottom: true, + borderTop: true, + borderLeft: true, + borderRight: true, + ); + return Table( + cellStyle: cellStyle, + header: const TableSection( + rows: [ + Row(cells: [Cell('Name')]) + ], + ), + body: TableSection( + rows: [ + for (final channel in this) Row(cells: [Cell(channel.name)]), + ], + ), + ).render(); + } +} diff --git a/packages/shorebird_cli/lib/src/commands/commands.dart b/packages/shorebird_cli/lib/src/commands/commands.dart index 4dd9b1ce..637cf1ff 100644 --- a/packages/shorebird_cli/lib/src/commands/commands.dart +++ b/packages/shorebird_cli/lib/src/commands/commands.dart @@ -1,5 +1,6 @@ export 'apps/apps.dart'; export 'build_command.dart'; +export 'channels/channels.dart'; export 'init_command.dart'; export 'login_command.dart'; export 'logout_command.dart'; diff --git a/packages/shorebird_cli/pubspec.yaml b/packages/shorebird_cli/pubspec.yaml index e2c68806..8a1c0123 100644 --- a/packages/shorebird_cli/pubspec.yaml +++ b/packages/shorebird_cli/pubspec.yaml @@ -11,6 +11,7 @@ environment: dependencies: archive: ^3.3.6 args: ^2.3.1 + barbecue: ^0.4.0 checked_yaml: ^2.0.2 cli_completion: ^0.3.0 cli_util: ^0.4.0 diff --git a/packages/shorebird_cli/test/src/commands/channels/list_channels_command_test.dart b/packages/shorebird_cli/test/src/commands/channels/list_channels_command_test.dart new file mode 100644 index 00000000..6b7f1a3e --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/channels/list_channels_command_test.dart @@ -0,0 +1,108 @@ +import 'package:args/args.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shorebird_cli/src/auth/auth.dart'; +import 'package:shorebird_cli/src/auth/session.dart'; +import 'package:shorebird_cli/src/commands/commands.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +import 'package:test/test.dart'; + +class _MockArgResults extends Mock implements ArgResults {} + +class _MockAuth extends Mock implements Auth {} + +class _MockCodePushClient extends Mock implements CodePushClient {} + +class _MockLogger extends Mock implements Logger {} + +void main() { + group('list', () { + const session = Session(apiKey: 'test-api-key'); + const appId = 'test-app-id'; + + late ArgResults argResults; + late Auth auth; + late CodePushClient codePushClient; + late Logger logger; + late ListChannelsCommand command; + + setUp(() { + argResults = _MockArgResults(); + auth = _MockAuth(); + codePushClient = _MockCodePushClient(); + logger = _MockLogger(); + command = ListChannelsCommand( + auth: auth, + buildCodePushClient: ({required String apiKey, Uri? hostedUri}) { + return codePushClient; + }, + logger: logger, + )..testArgResults = argResults; + + when(() => argResults['app-id']).thenReturn(appId); + when(() => auth.currentSession).thenReturn(session); + }); + + test('description is correct', () { + expect( + command.description, + equals('List all channels for a Shorebird app.'), + ); + }); + + test('returns ExitCode.noUser when not logged in', () async { + when(() => auth.currentSession).thenReturn(null); + expect(await command.run(), ExitCode.noUser.code); + }); + + test('returns ExitCode.usage when app id is missing.', () async { + when(() => argResults['app-id']).thenReturn(null); + expect(await command.run(), ExitCode.usage.code); + }); + + test('returns ExitCode.software when unable to get channels', () async { + when( + () => codePushClient.getChannels(appId: any(named: 'appId')), + ).thenThrow(Exception()); + expect(await command.run(), ExitCode.software.code); + }); + + test('returns ExitCode.success when channels are empty', () async { + when( + () => codePushClient.getChannels(appId: any(named: 'appId')), + ).thenAnswer((_) async => []); + expect(await command.run(), ExitCode.success.code); + verify(() => logger.info('(empty)')).called(1); + }); + + test('returns ExitCode.success when channels are not empty', () async { + final channels = [ + const Channel(id: 0, appId: appId, name: 'stable'), + const Channel(id: 1, appId: appId, name: 'development'), + ]; + when( + () => codePushClient.getChannels(appId: any(named: 'appId')), + ).thenAnswer((_) async => channels); + expect(await command.run(), ExitCode.success.code); + verify( + () => logger.info( + ''' +📱 App ID: ${lightCyan.wrap(appId)} +📺 Channels''', + ), + ).called(1); + verify( + () => logger.info( + ''' +┌─────────────┐ +│ Name │ +├─────────────┤ +│ stable │ +├─────────────┤ +│ development │ +└─────────────┘''', + ), + ).called(1); + }); + }); +}