From d7cbeff199498868cda1cdb97c9b91805b6917fa Mon Sep 17 00:00:00 2001 From: nickshorebird Date: Tue, 12 May 2026 09:31:50 -0400 Subject: [PATCH] feat(shorebird_cli): add account apps subcommand (#3743) --- .../lib/src/commands/account/account.dart | 1 + .../src/commands/account/account_command.dart | 1 + .../src/commands/account/apps_command.dart | 85 ++++++ .../commands/account/apps_command_test.dart | 253 ++++++++++++++++++ 4 files changed, 340 insertions(+) create mode 100644 packages/shorebird_cli/lib/src/commands/account/apps_command.dart create mode 100644 packages/shorebird_cli/test/src/commands/account/apps_command_test.dart diff --git a/packages/shorebird_cli/lib/src/commands/account/account.dart b/packages/shorebird_cli/lib/src/commands/account/account.dart index 2f2b4dd0..e5d1d399 100644 --- a/packages/shorebird_cli/lib/src/commands/account/account.dart +++ b/packages/shorebird_cli/lib/src/commands/account/account.dart @@ -1,3 +1,4 @@ export 'account_command.dart'; +export 'apps_command.dart'; export 'orgs_command.dart'; export 'whoami_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/account/account_command.dart b/packages/shorebird_cli/lib/src/commands/account/account_command.dart index 647d9d01..9e3d74a7 100644 --- a/packages/shorebird_cli/lib/src/commands/account/account_command.dart +++ b/packages/shorebird_cli/lib/src/commands/account/account_command.dart @@ -7,6 +7,7 @@ import 'package:shorebird_cli/src/shorebird_command.dart'; class AccountCommand extends ShorebirdCommand { /// {@macro account_command} AccountCommand() { + addSubcommand(AppsCommand()); addSubcommand(OrgsCommand()); addSubcommand(WhoamiCommand()); } diff --git a/packages/shorebird_cli/lib/src/commands/account/apps_command.dart b/packages/shorebird_cli/lib/src/commands/account/apps_command.dart new file mode 100644 index 00000000..3d111655 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/account/apps_command.dart @@ -0,0 +1,85 @@ +import 'package:mason_logger/mason_logger.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/json_output.dart'; +import 'package:shorebird_cli/src/logging/logging.dart'; +import 'package:shorebird_cli/src/shorebird_command.dart'; +import 'package:shorebird_cli/src/shorebird_validator.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; + +/// {@template apps_command} +/// `shorebird account apps` +/// List the apps the current user has access to. +/// {@endtemplate} +class AppsCommand extends ShorebirdCommand { + /// {@macro apps_command} + AppsCommand(); + + @override + String get name => 'apps'; + + @override + String get description => + 'List the apps you have access to.\n\n' + 'Example output (space-separated: app_id display_name ' + 'latest_release_version latest_patch_number):\n' + ' 01H... Acme Mobile 1.2.3 4\n' + ' 01J... Acme Internal - -\n\n' + '"-" indicates no release or patch has been published yet.\n\n' + '${ShorebirdCommand.jsonHint('shorebird account apps --json')}'; + + @override + Future run() async { + try { + await shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + ); + } on PreconditionFailedException catch (error) { + return error.exitCode.code; + } + + final List apps; + try { + apps = await codePushClientWrapper.getApps(); + } on ProcessExit catch (e) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.fetchFailed, + message: 'Failed to fetch apps.', + ); + return e.exitCode; + } + rethrow; + } + + if (isJsonMode) { + emitJsonSuccess({ + 'apps': [ + for (final app in apps) + { + 'app_id': app.appId, + 'display_name': app.displayName, + 'latest_release_version': app.latestReleaseVersion, + 'latest_patch_number': app.latestPatchNumber, + }, + ], + }); + return ExitCode.success.code; + } + + if (apps.isEmpty) { + logger.info('No apps found.'); + return ExitCode.success.code; + } + + for (final app in apps) { + logger.info( + '${app.appId} ${lightCyan.wrap(app.displayName)} ' + '${app.latestReleaseVersion ?? '-'} ' + '${app.latestPatchNumber ?? '-'}', + ); + } + + return ExitCode.success.code; + } +} diff --git a/packages/shorebird_cli/test/src/commands/account/apps_command_test.dart b/packages/shorebird_cli/test/src/commands/account/apps_command_test.dart new file mode 100644 index 00000000..f50e44dc --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/account/apps_command_test.dart @@ -0,0 +1,253 @@ +import 'dart:convert'; + +import 'package:args/args.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/commands/account/apps_command.dart'; +import 'package:shorebird_cli/src/json_output.dart'; +import 'package:shorebird_cli/src/logging/shorebird_logger.dart'; +import 'package:shorebird_cli/src/shorebird_validator.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +import 'package:test/test.dart'; + +import '../../helpers.dart'; +import '../../mocks.dart'; + +void main() { + group(AppsCommand, () { + final appWithReleases = AppMetadata( + appId: '01H000000000000000000ABCDE', + displayName: 'Acme Mobile', + latestReleaseVersion: '1.2.3', + latestPatchNumber: 4, + createdAt: DateTime(2026, 1, 15), + updatedAt: DateTime(2026, 1, 16), + ); + final appWithoutReleases = AppMetadata( + appId: '01J000000000000000000ABCDE', + displayName: 'Acme Internal', + createdAt: DateTime(2026, 2), + updatedAt: DateTime(2026, 2, 2), + ); + + late ArgResults argResults; + late CodePushClientWrapper codePushClientWrapper; + late ShorebirdValidator shorebirdValidator; + late ShorebirdLogger logger; + late Progress progress; + late AppsCommand command; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + isJsonModeRef.overrideWith(() => false), + loggerRef.overrideWith(() => logger), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + setUp(() { + argResults = MockArgResults(); + codePushClientWrapper = MockCodePushClientWrapper(); + logger = MockShorebirdLogger(); + progress = MockProgress(); + shorebirdValidator = MockShorebirdValidator(); + command = runWithOverrides(AppsCommand.new)..testArgResults = argResults; + + when(() => logger.progress(any())).thenReturn(progress); + when(() => argResults.rest).thenReturn([]); + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + ), + ).thenAnswer((_) async {}); + when( + () => codePushClientWrapper.getApps(), + ).thenAnswer((_) async => [appWithReleases, appWithoutReleases]); + }); + + test('has correct description', () { + expect( + command.description, + startsWith('List the apps you have access to.'), + ); + }); + + group('when validation fails', () { + final exception = UserNotAuthorizedException(); + + setUp(() { + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + ), + ).thenThrow(exception); + }); + + test('returns the precondition failure exit code', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(exception.exitCode.code)); + }); + }); + + test('requires user to be authenticated', () async { + await runWithOverrides(command.run); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + ), + ).called(1); + }); + + group('human-readable output', () { + test('prints one line per app', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + final lines = verify( + () => logger.info(captureAny()), + ).captured.cast(); + expect(lines, hasLength(2)); + expect( + lines[0], + allOf( + contains('Acme Mobile'), + contains('1.2.3'), + contains('4'), + contains(appWithReleases.appId), + ), + ); + expect( + lines[1], + allOf( + contains('Acme Internal'), + contains('-'), + contains(appWithoutReleases.appId), + ), + ); + }); + + group('when there are no apps', () { + setUp(() { + when( + () => codePushClientWrapper.getApps(), + ).thenAnswer((_) async => []); + }); + + test('prints an empty-state message', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + verify(() => logger.info('No apps found.')).called(1); + }); + }); + }); + + group('when API fetch fails', () { + setUp(() { + when( + () => codePushClientWrapper.getApps(), + ).thenThrow(ProcessExit(ExitCode.software.code)); + }); + + test('in human-readable mode, rethrows ProcessExit', () async { + await expectLater( + () => runWithOverrides(command.run), + throwsA(isA()), + ); + }); + + test('in --json mode, emits JSON error envelope', () async { + final captured = []; + final result = await captureStdout( + () => runScoped( + command.run, + values: { + codePushClientWrapperRef.overrideWith( + () => codePushClientWrapper, + ), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ), + captured: captured, + ); + expect(result, equals(ExitCode.software.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + }); + }); + + group('--json', () { + R runJsonMode(R Function() body) { + return runScoped( + body, + values: { + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + test('emits JSON success with flat app fields', () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.success.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'success'); + final data = decoded['data'] as Map; + final apps = data['apps'] as List; + expect(apps, hasLength(2)); + final firstApp = apps.first as Map; + expect(firstApp['app_id'], appWithReleases.appId); + expect(firstApp['display_name'], 'Acme Mobile'); + expect(firstApp['latest_release_version'], '1.2.3'); + expect(firstApp['latest_patch_number'], 4); + final secondApp = apps[1] as Map; + expect(secondApp['latest_release_version'], isNull); + expect(secondApp['latest_patch_number'], isNull); + }); + + test('does not leak timestamps or protocol-internal fields', () async { + final captured = []; + await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + final decoded = jsonDecode(captured.first) as Map; + final apps = + ((decoded['data'] as Map)['apps'] as List) + .cast>(); + for (final app in apps) { + expect(app.containsKey('created_at'), isFalse); + expect(app.containsKey('updated_at'), isFalse); + } + }); + + test('emits empty array when there are no apps', () async { + when(() => codePushClientWrapper.getApps()).thenAnswer((_) async => []); + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.success.code)); + final decoded = jsonDecode(captured.first) as Map; + final data = decoded['data'] as Map; + expect(data['apps'], isEmpty); + }); + }); + }); +}