feat(shorebird_cli): add account command (whoami + orgs) (#3742)

This commit is contained in:
nickshorebird
2026-05-06 11:58:04 -04:00
committed by GitHub
parent c0b104d272
commit 178359aa10
10 changed files with 763 additions and 0 deletions
@@ -116,6 +116,23 @@ class CodePushClientWrapper {
);
}
/// Fetches the currently authenticated user.
Future<PrivateUser> getCurrentUser() async {
final progress = logger.progress('Fetching account');
final PrivateUser? user;
try {
user = await codePushClient.getCurrentUser();
progress.complete();
} catch (error) {
_handleErrorAndExit(error, progress: progress);
}
if (user == null) {
logger.err('Could not find current user.');
throw ProcessExit(ExitCode.software.code);
}
return user;
}
/// Fetches the organization memberships for the current user.
Future<List<OrganizationMembership>> getOrganizationMemberships() async {
final progress = logger.progress('Fetching organizations');
@@ -0,0 +1,3 @@
export 'account_command.dart';
export 'orgs_command.dart';
export 'whoami_command.dart';
@@ -0,0 +1,19 @@
import 'package:shorebird_cli/src/commands/account/account.dart';
import 'package:shorebird_cli/src/shorebird_command.dart';
/// {@template account_command}
/// Commands for inspecting the current Shorebird account.
/// {@endtemplate}
class AccountCommand extends ShorebirdCommand {
/// {@macro account_command}
AccountCommand() {
addSubcommand(OrgsCommand());
addSubcommand(WhoamiCommand());
}
@override
String get name => 'account';
@override
String get description => 'Manage your Shorebird account.';
}
@@ -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 orgs_command}
/// `shorebird account orgs`
/// List the organizations the current user belongs to.
/// {@endtemplate}
class OrgsCommand extends ShorebirdCommand {
/// {@macro orgs_command}
OrgsCommand();
@override
String get name => 'orgs';
@override
String get description =>
'List the organizations you belong to.\n\n'
'Example output (space-separated: id name type role):\n'
' 1 Acme Corp team admin\n'
' 2 user@example.com personal owner\n\n'
'Type is "personal" or "team". Role is "owner", "admin", or '
'"developer".\n\n'
'${ShorebirdCommand.jsonHint('shorebird account orgs --json')}';
@override
Future<int> run() async {
try {
await shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: true,
);
} on PreconditionFailedException catch (error) {
return error.exitCode.code;
}
final List<OrganizationMembership> memberships;
try {
memberships = await codePushClientWrapper.getOrganizationMemberships();
} on ProcessExit catch (e) {
if (isJsonMode) {
emitJsonError(
code: JsonErrorCode.fetchFailed,
message: 'Failed to fetch organizations.',
);
return e.exitCode;
}
rethrow;
}
if (isJsonMode) {
emitJsonSuccess({
'organizations': [
for (final m in memberships)
{
'id': m.organization.id,
'name': m.organization.name,
'type': m.organization.organizationType.name,
'role': m.role.name,
},
],
});
return ExitCode.success.code;
}
if (memberships.isEmpty) {
logger.info('No organizations found.');
return ExitCode.success.code;
}
for (final membership in memberships) {
final org = membership.organization;
logger.info(
'${org.id} ${lightCyan.wrap(org.name)} '
'${org.organizationType.name} ${membership.role.name}',
);
}
return ExitCode.success.code;
}
}
@@ -0,0 +1,86 @@
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 whoami_command}
/// `shorebird account whoami`
/// Show the currently authenticated Shorebird user.
/// {@endtemplate}
class WhoamiCommand extends ShorebirdCommand {
/// {@macro whoami_command}
WhoamiCommand();
@override
String get name => 'whoami';
@override
String get description =>
'Show the currently authenticated Shorebird user.\n\n'
'Example output:\n'
' ID: 42\n'
' Email: user@example.com\n'
' Display name: Example User\n'
' Plan: paid\n'
' Overage limit: 10000\n\n'
'Plan is "paid" (active Shorebird subscription) or "free".\n'
'Overage limit is the max pay-as-you-go patch installs allowed '
'beyond your plan ("none" if unset).\n\n'
'${ShorebirdCommand.jsonHint('shorebird account whoami --json')}';
@override
Future<int> run() async {
try {
await shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: true,
);
} on PreconditionFailedException catch (error) {
return error.exitCode.code;
}
final PrivateUser user;
try {
user = await codePushClientWrapper.getCurrentUser();
} on ProcessExit catch (e) {
if (isJsonMode) {
emitJsonError(
code: JsonErrorCode.fetchFailed,
message: 'Failed to fetch account.',
);
return e.exitCode;
}
rethrow;
}
final plan = (user.hasActiveSubscription ?? false) ? 'paid' : 'free';
if (isJsonMode) {
emitJsonSuccess({
'user': {
'id': user.id,
'email': user.email,
'display_name': user.displayName,
'plan': plan,
'overage_limit': user.patchOverageLimit,
},
});
return ExitCode.success.code;
}
logger
..info('ID: ${user.id}')
..info('Email: ${user.email}');
if (user.displayName != null) {
logger.info('Display name: ${user.displayName}');
}
logger
..info('Plan: $plan')
..info('Overage limit: ${user.patchOverageLimit ?? 'none'}');
return ExitCode.success.code;
}
}
@@ -1,3 +1,4 @@
export 'account/account.dart';
export 'cache/cache.dart';
export 'create_command.dart';
export 'doctor_command.dart';
@@ -70,6 +70,7 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner<int> {
help: 'The build of the local engine to use as the host platform.',
);
addCommand(AccountCommand());
addCommand(CacheCommand());
addCommand(CreateCommand());
addCommand(DoctorCommand());
@@ -279,6 +279,49 @@ void main() {
});
});
group('getCurrentUser', () {
test('exits with code 70 when fetching the user fails', () async {
const error = 'something went wrong';
when(() => codePushClient.getCurrentUser()).thenThrow(error);
await expectLater(
() async => runWithOverrides(codePushClientWrapper.getCurrentUser),
exitsWithCode(ExitCode.software),
);
verify(() => progress.fail(error)).called(1);
});
test('exits with code 70 when no current user is found', () async {
when(
() => codePushClient.getCurrentUser(),
).thenAnswer((_) async => null);
await expectLater(
() async => runWithOverrides(codePushClientWrapper.getCurrentUser),
exitsWithCode(ExitCode.software),
);
verify(() => logger.err('Could not find current user.')).called(1);
});
test('returns the current user on success', () async {
const expectedUser = PrivateUser(
id: 1,
email: 'user@example.com',
jwtIssuer: 'https://accounts.google.com',
);
when(
() => codePushClient.getCurrentUser(),
).thenAnswer((_) async => expectedUser);
final user = await runWithOverrides(
codePushClientWrapper.getCurrentUser,
);
expect(user, equals(expectedUser));
verify(() => progress.complete()).called(1);
});
});
group('getOrganizationMemberships', () {
test(
'exits with code 70 when getting organization memberships fails',
@@ -0,0 +1,249 @@
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/orgs_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(OrgsCommand, () {
final teamMembership = OrganizationMembership(
organization: Organization(
id: 1,
name: 'Acme Corp',
organizationType: OrganizationType.team,
createdAt: DateTime(2026, 1, 15),
updatedAt: DateTime(2026, 1, 16),
),
role: Role.admin,
);
final personalMembership = OrganizationMembership(
organization: Organization(
id: 2,
name: 'user@example.com',
organizationType: OrganizationType.personal,
createdAt: DateTime(2026, 1, 10),
updatedAt: DateTime(2026, 1, 11),
),
role: Role.owner,
);
late ArgResults argResults;
late CodePushClientWrapper codePushClientWrapper;
late ShorebirdValidator shorebirdValidator;
late ShorebirdLogger logger;
late Progress progress;
late OrgsCommand command;
R runWithOverrides<R>(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(OrgsCommand.new)..testArgResults = argResults;
when(() => logger.progress(any())).thenReturn(progress);
when(() => argResults.rest).thenReturn([]);
when(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'),
),
).thenAnswer((_) async {});
when(
() => codePushClientWrapper.getOrganizationMemberships(),
).thenAnswer((_) async => [teamMembership, personalMembership]);
});
test('has correct description', () {
expect(
command.description,
startsWith('List the organizations you belong 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 organization', () async {
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.success.code));
final lines = verify(
() => logger.info(captureAny()),
).captured.cast<String>();
expect(lines, hasLength(2));
expect(lines[0], allOf(contains('Acme Corp'), contains('admin')));
expect(
lines[1],
allOf(contains('user@example.com'), contains('owner')),
);
});
group('when there are no organizations', () {
setUp(() {
when(
() => codePushClientWrapper.getOrganizationMemberships(),
).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 organizations found.')).called(1);
});
});
});
group('when API fetch fails', () {
setUp(() {
when(
() => codePushClientWrapper.getOrganizationMemberships(),
).thenThrow(ProcessExit(ExitCode.software.code));
});
test('in human-readable mode, rethrows ProcessExit', () async {
await expectLater(
() => runWithOverrides(command.run),
throwsA(isA<ProcessExit>()),
);
});
test('in --json mode, emits JSON error envelope', () async {
final captured = <String>[];
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<String, dynamic>;
expect(decoded['status'], 'error');
});
});
group('--json', () {
R runJsonMode<R>(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 organization fields', () async {
final captured = <String>[];
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<String, dynamic>;
expect(decoded['status'], 'success');
final data = decoded['data'] as Map<String, dynamic>;
final orgs = data['organizations'] as List<dynamic>;
expect(orgs, hasLength(2));
final firstOrg = orgs.first as Map<String, dynamic>;
expect(firstOrg['id'], 1);
expect(firstOrg['name'], 'Acme Corp');
expect(firstOrg['type'], 'team');
expect(firstOrg['role'], 'admin');
});
test('does not leak timestamps or protocol-internal fields', () async {
final captured = <String>[];
await captureStdout(
() => runJsonMode(command.run),
captured: captured,
);
final decoded = jsonDecode(captured.first) as Map<String, dynamic>;
final orgs =
((decoded['data'] as Map<String, dynamic>)['organizations']
as List<dynamic>)
.cast<Map<String, dynamic>>();
for (final org in orgs) {
expect(org.containsKey('created_at'), isFalse);
expect(org.containsKey('updated_at'), isFalse);
expect(org.containsKey('organization_type'), isFalse);
expect(org.containsKey('organization'), isFalse);
}
});
test('emits empty array when there are no organizations', () async {
when(
() => codePushClientWrapper.getOrganizationMemberships(),
).thenAnswer((_) async => []);
final captured = <String>[];
final result = await captureStdout(
() => runJsonMode(command.run),
captured: captured,
);
expect(result, equals(ExitCode.success.code));
final decoded = jsonDecode(captured.first) as Map<String, dynamic>;
final data = decoded['data'] as Map<String, dynamic>;
expect(data['organizations'], isEmpty);
});
});
});
}
@@ -0,0 +1,259 @@
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/whoami_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(WhoamiCommand, () {
const user = PrivateUser(
id: 1,
email: 'user@example.com',
displayName: 'Example User',
hasActiveSubscription: true,
jwtIssuer: 'https://accounts.google.com',
patchOverageLimit: 10000,
);
late ArgResults argResults;
late CodePushClientWrapper codePushClientWrapper;
late ShorebirdValidator shorebirdValidator;
late ShorebirdLogger logger;
late Progress progress;
late WhoamiCommand command;
R runWithOverrides<R>(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(WhoamiCommand.new)
..testArgResults = argResults;
when(() => logger.progress(any())).thenReturn(progress);
when(() => argResults.rest).thenReturn([]);
when(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'),
),
).thenAnswer((_) async {});
when(
() => codePushClientWrapper.getCurrentUser(),
).thenAnswer((_) async => user);
});
test('has correct description', () {
expect(
command.description,
startsWith('Show the currently authenticated Shorebird user.'),
);
});
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 account fields', () async {
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.success.code));
verify(
() => logger.info(any(that: contains('user@example.com'))),
).called(1);
verify(
() => logger.info(any(that: contains('Example User'))),
).called(1);
verify(
() => logger.info(any(that: contains('Plan: paid'))),
).called(1);
verify(
() => logger.info(any(that: contains('Overage limit: 10000'))),
).called(1);
});
group('when display name is null', () {
const userNoName = PrivateUser(
id: 2,
email: 'noname@example.com',
jwtIssuer: 'https://accounts.google.com',
);
setUp(() {
when(
() => codePushClientWrapper.getCurrentUser(),
).thenAnswer((_) async => userNoName);
});
test('omits the display name line', () async {
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.success.code));
verifyNever(
() => logger.info(any(that: contains('Display name'))),
);
});
});
group('when user is on the free plan', () {
const userNoSub = PrivateUser(
id: 3,
email: 'noplan@example.com',
jwtIssuer: 'https://accounts.google.com',
);
setUp(() {
when(
() => codePushClientWrapper.getCurrentUser(),
).thenAnswer((_) async => userNoSub);
});
test('prints plan as free', () async {
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.success.code));
verify(
() => logger.info(any(that: contains('Plan: free'))),
).called(1);
});
test('prints overage limit as none when unset', () async {
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.success.code));
verify(
() => logger.info(any(that: contains('Overage limit: none'))),
).called(1);
});
});
});
group('when API fetch fails', () {
setUp(() {
when(
() => codePushClientWrapper.getCurrentUser(),
).thenThrow(ProcessExit(ExitCode.software.code));
});
test('in human-readable mode, rethrows ProcessExit', () async {
await expectLater(
() => runWithOverrides(command.run),
throwsA(isA<ProcessExit>()),
);
});
test('in --json mode, emits JSON error envelope', () async {
final captured = <String>[];
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<String, dynamic>;
expect(decoded['status'], 'error');
});
});
group('--json', () {
R runJsonMode<R>(R Function() body) {
return runScoped(
body,
values: {
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
isJsonModeRef.overrideWith(() => true),
loggerRef.overrideWith(() => logger),
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
},
);
}
test('emits JSON success with projected user fields', () async {
final captured = <String>[];
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<String, dynamic>;
expect(decoded['status'], 'success');
final data = decoded['data'] as Map<String, dynamic>;
final userData = data['user'] as Map<String, dynamic>;
expect(userData['id'], 1);
expect(userData['email'], 'user@example.com');
expect(userData['display_name'], 'Example User');
expect(userData['plan'], 'paid');
expect(userData['overage_limit'], 10000);
});
test('does not leak protocol-internal fields', () async {
final captured = <String>[];
await captureStdout(
() => runJsonMode(command.run),
captured: captured,
);
final decoded = jsonDecode(captured.first) as Map<String, dynamic>;
final userData =
(decoded['data'] as Map<String, dynamic>)['user']
as Map<String, dynamic>;
expect(userData.containsKey('stripe_customer_id'), isFalse);
expect(userData.containsKey('jwt_issuer'), isFalse);
expect(userData.containsKey('has_active_subscription'), isFalse);
});
});
});
}