feat(shorebird_cli): app creation enhancements (#134)

This commit is contained in:
Felix Angelov
2023-03-21 21:14:10 -05:00
committed by GitHub
parent 3aa72edeac
commit c40a227c2f
17 changed files with 292 additions and 96 deletions
@@ -3,13 +3,16 @@ import 'dart:async';
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_cli/src/shorebird_create_app_mixin.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
/// {@template create_app_command}
///
/// `shorebird apps create`
/// Create a new app on Shorebird.
/// {@endtemplate}
class CreateAppCommand extends ShorebirdCommand with ShorebirdConfigMixin {
class CreateAppCommand extends ShorebirdCommand
with ShorebirdConfigMixin, ShorebirdCreateAppMixin {
/// {@macro create_app_command}
CreateAppCommand({
required super.logger,
@@ -17,10 +20,10 @@ class CreateAppCommand extends ShorebirdCommand with ShorebirdConfigMixin {
super.auth,
}) {
argParser.addOption(
'app-id',
'app-name',
help: '''
The unique application identifier.
Defaults to the app_id in "shorebird.yaml".''',
The display name of your application.
Defaults to the name in "pubspec.yaml".''',
);
}
@@ -38,37 +41,17 @@ Defaults to the app_id in "shorebird.yaml".''',
return ExitCode.noUser.code;
}
final appIdArg = results['app-id'] as String?;
late final String appId;
if (appIdArg == null) {
String? defaultAppId;
try {
defaultAppId = getShorebirdYaml()?.appId;
} catch (_) {}
appId = logger.prompt(
'${lightGreen.wrap('?')} Enter the App ID',
defaultValue: defaultAppId,
);
} else {
appId = appIdArg;
}
final client = buildCodePushClient(
apiKey: session.apiKey,
hostedUri: hostedUri,
);
final appName = results['app-name'] as String?;
late final App app;
try {
await client.createApp(appId: appId);
app = await createApp(appName: appName);
} catch (error) {
logger.err('$error');
return ExitCode.software.code;
}
logger.info(
'${lightGreen.wrap('Created new app: ${cyan.wrap(appId)}')}',
'''${lightGreen.wrap('Created ${cyan.wrap(app.displayName)} ${styleDim.wrap(cyan.wrap('(${app.id})'))}')}''',
);
return ExitCode.success.code;
@@ -40,7 +40,7 @@ class ListAppsCommand extends ShorebirdCommand with ShorebirdConfigMixin {
hostedUri: hostedUri,
);
late final List<App> apps;
late final List<AppMetadata> apps;
try {
apps = await client.getApps();
} catch (error) {
@@ -61,12 +61,12 @@ class ListAppsCommand extends ShorebirdCommand with ShorebirdConfigMixin {
}
}
extension on App {
extension on AppMetadata {
String prettyPrint() {
final latestReleasePart =
latestReleaseVersion != null ? 'v$latestReleaseVersion' : '(empty)';
final latestPatchPart =
latestPatchNumber != null ? '(patch #$latestPatchNumber)' : '';
return '$appId: $latestReleasePart $latestPatchPart';
latestPatchNumber != null ? ' (patch #$latestPatchNumber)' : '';
return '$displayName: $latestReleasePart$latestPatchPart ($appId)';
}
}
@@ -5,6 +5,8 @@ import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/shorebird_config_mixin.dart';
import 'package:shorebird_cli/src/shorebird_create_app_mixin.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
import 'package:yaml/yaml.dart';
import 'package:yaml_edit/yaml_edit.dart';
@@ -13,9 +15,10 @@ import 'package:yaml_edit/yaml_edit.dart';
/// `shorebird init`
/// Initialize Shorebird.
/// {@endtemplate}
class InitCommand extends ShorebirdCommand with ShorebirdConfigMixin {
class InitCommand extends ShorebirdCommand
with ShorebirdConfigMixin, ShorebirdCreateAppMixin {
/// {@macro init_command}
InitCommand({required super.logger});
InitCommand({required super.logger, super.auth, super.buildCodePushClient});
@override
String get description => 'Initialize Shorebird.';
@@ -25,6 +28,12 @@ class InitCommand extends ShorebirdCommand with ShorebirdConfigMixin {
@override
Future<int> run() async {
final session = auth.currentSession;
if (session == null) {
logger.err('You must be logged in.');
return ExitCode.noUser.code;
}
final progress = logger.progress('Initializing Shorebird');
try {
if (!hasPubspecYaml) {
@@ -36,14 +45,22 @@ class InitCommand extends ShorebirdCommand with ShorebirdConfigMixin {
return ExitCode.software.code;
}
late final App app;
try {
final pubspecYaml = getPubspecYaml()!;
app = await createApp(appName: pubspecYaml.name);
} catch (error) {
logger.err('$error');
return ExitCode.software.code;
}
progress.update('Creating "shorebird.yaml"');
try {
if (hasShorebirdYaml) {
progress.update('"shorebird.yaml" already exists.');
} else {
final pubspecYaml = getPubspecYaml()!;
_addShorebirdYamlToProject(pubspecYaml.name);
_addShorebirdYamlToProject(app.id);
progress.update('Generated a "shorebird.yaml".');
}
} catch (error) {
@@ -72,7 +89,6 @@ ${lightGreen.wrap('🐦 Shorebird initialized successfully!')}
Reference the following commands to get started:
✨ To create a new app use: "${lightCyan.wrap('shorebird apps create')}".
🚙 To run your project use: "${lightCyan.wrap('shorebird run')}".
📦 To build your project use: "${lightCyan.wrap('shorebird build')}".
🚀 To publish an update use: "${lightCyan.wrap('shorebird publish')}".
@@ -89,7 +105,8 @@ For more information about Shorebird, visit ${link(uri: Uri.parse('https://shore
# This file is used to configure the Shorebird CLI.
# Learn more at https://shorebird.dev
# This is the unique identifier for your app.
# This is the unique identifier assigned to your app.
# It is used by your app to request the correct patches from the Shorebird servers.
app_id: $appId
''');
@@ -0,0 +1,29 @@
import 'package:mason_logger/mason_logger.dart';
import 'package:shorebird_cli/src/shorebird_config_mixin.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
mixin ShorebirdCreateAppMixin on ShorebirdConfigMixin {
Future<App> createApp({String? appName}) async {
late final String displayName;
if (appName == null) {
String? defaultAppName;
try {
defaultAppName = getPubspecYaml()?.name;
} catch (_) {}
displayName = logger.prompt(
'${lightGreen.wrap('?')} How should we refer to this app?',
defaultValue: defaultAppName,
);
} else {
displayName = appName;
}
final client = buildCodePushClient(
apiKey: auth.currentSession!.apiKey,
hostedUri: hostedUri,
);
return client.createApp(displayName: displayName);
}
}
@@ -18,7 +18,8 @@ class _MockLogger extends Mock implements Logger {}
void main() {
group('create', () {
const apiKey = 'test-api-key';
const appId = 'example';
const appId = 'app-id';
const displayName = 'Example App';
const session = Session(apiKey: apiKey);
late ArgResults argResults;
@@ -53,33 +54,43 @@ void main() {
expect(result, ExitCode.noUser.code);
});
test('prompts for app-id when not provided', () async {
when(() => logger.prompt(any())).thenReturn(appId);
test('prompts for app name when not provided', () async {
when(
() => logger.prompt(any(), defaultValue: any(named: 'defaultValue')),
).thenReturn(displayName);
await command.run();
verify(() => logger.prompt(any())).called(1);
verify(() => codePushClient.createApp(appId: appId)).called(1);
verify(
() => logger.prompt(any(), defaultValue: any(named: 'defaultValue')),
).called(1);
verify(
() => codePushClient.createApp(displayName: displayName),
).called(1);
});
test('uses provided app-id when provided', () async {
when(() => argResults['app-id']).thenReturn(appId);
test('uses provided app name when provided', () async {
when(() => argResults['app-name']).thenReturn(displayName);
await command.run();
verifyNever(() => logger.prompt(any()));
verify(() => codePushClient.createApp(appId: appId)).called(1);
verify(
() => codePushClient.createApp(displayName: displayName),
).called(1);
});
test('returns success when app is created', () async {
when(() => argResults['app-id']).thenReturn(appId);
when(() => argResults['app-name']).thenReturn(displayName);
when(
() => codePushClient.createApp(appId: appId),
).thenAnswer((_) async {});
() => codePushClient.createApp(displayName: displayName),
).thenAnswer((_) async => const App(id: appId, displayName: displayName));
final result = await command.run();
expect(result, ExitCode.success.code);
});
test('returns software error when app creation fails', () async {
final error = Exception('oops');
when(() => argResults['app-id']).thenReturn(appId);
when(() => codePushClient.createApp(appId: appId)).thenThrow(error);
when(() => argResults['app-name']).thenReturn(displayName);
when(
() => codePushClient.createApp(displayName: displayName),
).thenThrow(error);
final result = await command.run();
expect(result, ExitCode.software.code);
verify(() => logger.err('$error')).called(1);
@@ -59,8 +59,9 @@ void main() {
test('returns ExitCode.success when apps are not empty', () async {
final apps = [
const App(
appId: 'shorebird-counter',
const AppMetadata(
appId: '30370f27-dbf1-4673-8b20-fb096e38dffa',
displayName: 'Shorebird Counter',
latestReleaseVersion: '1.0.0',
latestPatchNumber: 1,
),
@@ -68,7 +69,9 @@ void main() {
when(() => codePushClient.getApps()).thenAnswer((_) async => apps);
expect(await command.run(), ExitCode.success.code);
verify(
() => logger.info('shorebird-counter: v1.0.0 (patch #1)'),
() => logger.info(
'''Shorebird Counter: v1.0.0 (patch #1) (30370f27-dbf1-4673-8b20-fb096e38dffa)''',
),
).called(1);
});
});
@@ -3,33 +3,66 @@ import 'dart:io';
import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/auth/session.dart';
import 'package:shorebird_cli/src/commands/init_command.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
import 'package:test/test.dart';
class _MockAuth extends Mock implements Auth {}
class _MockCodePushClient extends Mock implements CodePushClient {}
class _MockLogger extends Mock implements Logger {}
class _MockProgress extends Mock implements Progress {}
void main() {
group('init', () {
const apiKey = 'test-api-key';
const version = '1.2.3';
const appId = 'test_app_id';
const appName = 'test_app_name';
const app = App(id: appId, displayName: appName);
const pubspecYamlContent = '''
name: $appId
name: $appName
version: $version
environment:
sdk: ">=2.19.0 <3.0.0"''';
const session = Session(apiKey: apiKey);
late Auth auth;
late CodePushClient codePushClient;
late Logger logger;
late Progress progress;
late InitCommand command;
setUp(() {
auth = _MockAuth();
codePushClient = _MockCodePushClient();
logger = _MockLogger();
progress = _MockProgress();
command = InitCommand(logger: logger);
command = InitCommand(
auth: auth,
buildCodePushClient: ({required String apiKey, Uri? hostedUri}) {
return codePushClient;
},
logger: logger);
when(() => auth.currentSession).thenReturn(session);
when(
() => codePushClient.createApp(displayName: any(named: 'displayName')),
).thenAnswer((_) async => app);
when(() => logger.progress(any())).thenReturn(progress);
when(
() => logger.prompt(any(), defaultValue: any(named: 'defaultValue')),
).thenReturn(appName);
});
test('returns no user error when not logged in', () async {
when(() => auth.currentSession).thenReturn(null);
final result = await command.run();
expect(result, ExitCode.noUser.code);
});
test('throws no input error when pubspec.yaml is not found.', () async {
@@ -55,6 +88,24 @@ environment:
expect(exitCode, ExitCode.software.code);
});
test('throws software error when error occurs creating app.', () async {
final error = Exception('oops');
final tempDir = Directory.systemTemp.createTempSync();
File(
p.join(tempDir.path, 'pubspec.yaml'),
).writeAsStringSync(pubspecYamlContent);
File(p.join(tempDir.path, 'shorebird.yaml')).createSync();
when(
() => codePushClient.createApp(displayName: any(named: 'displayName')),
).thenThrow(error);
final exitCode = await IOOverrides.runZoned(
command.run,
getCurrentDirectory: () => tempDir,
);
verify(() => logger.err('$error')).called(1);
expect(exitCode, ExitCode.software.code);
});
test('throws software error when shorebird.yaml is malformed.', () async {
final tempDir = Directory.systemTemp.createTempSync();
File(
@@ -9,7 +9,9 @@ Future<void> main() async {
final engine = await client.downloadEngine('1837b5be5f');
// Create a new Shorebird application.
await client.createApp(appId: '<APP ID>');
final app = await client.createApp(
displayName: '<DISPLAY NAME>', // e.g. 'Shorebird Example'
);
// List all apps.
final apps = await client.getApps();
@@ -18,7 +20,7 @@ Future<void> main() async {
await client.createPatch(
artifactPath: '<PATH TO ARTIFACT>', // e.g. 'libapp.so'
releaseVersion: '<RELEASE VERSION>', // e.g. '1.0.0'
appId: '<APP ID>', // e.g. 'shorebird-example'
appId: app.id, // e.g. '30370f27-dbf1-4673-8b20-fb096e38dffa'
channel: '<CHANNEL>', // e.g. 'stable'
);
@@ -46,17 +46,20 @@ class CodePushClient {
Map<String, String> get _apiKeyHeader => {'x-api-key': _apiKey};
/// Create a new app with the provided [appId].
Future<void> createApp({required String appId}) async {
/// Create a new app with the provided [displayName].
/// Returns the newly created app.
Future<App> createApp({required String displayName}) async {
final response = await _httpClient.post(
Uri.parse('$hostedUri/api/v1/apps'),
headers: _apiKeyHeader,
body: json.encode({'app_id': appId}),
body: json.encode({'display_name': displayName}),
);
if (response.statusCode != HttpStatus.created) {
if (response.statusCode != HttpStatus.ok) {
throw _parseErrorResponse(response.body);
}
final body = json.decode(response.body) as Map<String, dynamic>;
return App.fromJson(body);
}
/// Create a new patch.
@@ -119,7 +122,7 @@ class CodePushClient {
}
/// List all apps for the current account.
Future<List<App>> getApps() async {
Future<List<AppMetadata>> getApps() async {
final response = await _httpClient.get(
Uri.parse('$hostedUri/api/v1/apps'),
headers: _apiKeyHeader,
@@ -131,7 +134,7 @@ class CodePushClient {
final apps = json.decode(response.body) as List;
return apps
.map((app) => App.fromJson(app as Map<String, dynamic>))
.map((app) => AppMetadata.fromJson(app as Map<String, dynamic>))
.toList();
}
@@ -15,7 +15,8 @@ class _FakeBaseRequest extends Fake implements http.BaseRequest {}
void main() {
group('CodePushClient', () {
const apiKey = 'api-key';
const appId = 'shorebird-example';
const appId = 'app-id';
const displayName = 'shorebird-example';
const errorResponse = ErrorResponse(
code: 'test_code',
message: 'test message',
@@ -66,7 +67,7 @@ void main() {
).thenAnswer((_) async => http.Response('', HttpStatus.badRequest));
expect(
codePushClient.createApp(appId: appId),
codePushClient.createApp(displayName: displayName),
throwsA(
isA<CodePushException>().having(
(e) => e.message,
@@ -92,7 +93,7 @@ void main() {
);
expect(
codePushClient.createApp(appId: appId),
codePushClient.createApp(displayName: displayName),
throwsA(
isA<CodePushException>().having(
(e) => e.message,
@@ -110,9 +111,23 @@ void main() {
headers: any(named: 'headers'),
body: any(named: 'body'),
),
).thenAnswer((_) async => http.Response('', HttpStatus.created));
).thenAnswer(
(_) async => http.Response(
json.encode(App(id: appId, displayName: displayName)),
HttpStatus.ok,
),
);
await codePushClient.createApp(appId: appId);
await expectLater(
codePushClient.createApp(displayName: displayName),
completion(
equals(
isA<App>()
.having((a) => a.id, 'id', appId)
.having((a) => a.displayName, 'displayName', displayName),
),
),
);
final uri = verify(
() => httpClient.post(
@@ -393,8 +408,8 @@ void main() {
test('completes when request succeeds (populated)', () async {
final expected = [
App(appId: 'shorebird-example'),
App(appId: 'shorebird-counter'),
AppMetadata(appId: '1', displayName: 'Shorebird Example'),
AppMetadata(appId: '2', displayName: 'Shorebird Clock'),
];
when(
@@ -9,9 +9,8 @@ part 'app.g.dart';
class App {
/// {@macro app}
const App({
required this.appId,
this.latestReleaseVersion,
this.latestPatchNumber,
required this.id,
required this.displayName,
});
/// Converts a Map<String, dynamic> to an [App]
@@ -21,11 +20,8 @@ class App {
Map<String, dynamic> toJson() => _$AppToJson(this);
/// The ID of the app.
final String appId;
final String id;
/// The latest release version of the app.
final String? latestReleaseVersion;
/// The latest patch number of the app.
final int? latestPatchNumber;
/// The display name of the app.
final String displayName;
}
@@ -13,23 +13,15 @@ App _$AppFromJson(Map<String, dynamic> json) => $checkedCreate(
json,
($checkedConvert) {
final val = App(
appId: $checkedConvert('app_id', (v) => v as String),
latestReleaseVersion:
$checkedConvert('latest_release_version', (v) => v as String?),
latestPatchNumber:
$checkedConvert('latest_patch_number', (v) => v as int?),
id: $checkedConvert('id', (v) => v as String),
displayName: $checkedConvert('display_name', (v) => v as String),
);
return val;
},
fieldKeyMap: const {
'appId': 'app_id',
'latestReleaseVersion': 'latest_release_version',
'latestPatchNumber': 'latest_patch_number'
},
fieldKeyMap: const {'displayName': 'display_name'},
);
Map<String, dynamic> _$AppToJson(App instance) => <String, dynamic>{
'app_id': instance.appId,
'latest_release_version': instance.latestReleaseVersion,
'latest_patch_number': instance.latestPatchNumber,
'id': instance.id,
'display_name': instance.displayName,
};
@@ -0,0 +1,36 @@
import 'package:json_annotation/json_annotation.dart';
part 'app_metadata.g.dart';
/// {@template app_metadata}
/// A single app which contains zero or more releases.
/// {@endtemplate}
@JsonSerializable()
class AppMetadata {
/// {@macro app_metadata}
const AppMetadata({
required this.appId,
required this.displayName,
this.latestReleaseVersion,
this.latestPatchNumber,
});
/// Converts a Map<String, dynamic> to an [AppMetadata]
factory AppMetadata.fromJson(Map<String, dynamic> json) =>
_$AppMetadataFromJson(json);
/// Converts a [AppMetadata] to a Map<String, dynamic>
Map<String, dynamic> toJson() => _$AppMetadataToJson(this);
/// The ID of the app.
final String appId;
/// The display name of the app.
final String displayName;
/// The latest release version of the app.
final String? latestReleaseVersion;
/// The latest patch number of the app.
final int? latestPatchNumber;
}
@@ -0,0 +1,39 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: implicit_dynamic_parameter, require_trailing_commas, cast_nullable_to_non_nullable, lines_longer_than_80_chars
part of 'app_metadata.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AppMetadata _$AppMetadataFromJson(Map<String, dynamic> json) => $checkedCreate(
'AppMetadata',
json,
($checkedConvert) {
final val = AppMetadata(
appId: $checkedConvert('app_id', (v) => v as String),
displayName: $checkedConvert('display_name', (v) => v as String),
latestReleaseVersion:
$checkedConvert('latest_release_version', (v) => v as String?),
latestPatchNumber:
$checkedConvert('latest_patch_number', (v) => v as int?),
);
return val;
},
fieldKeyMap: const {
'appId': 'app_id',
'displayName': 'display_name',
'latestReleaseVersion': 'latest_release_version',
'latestPatchNumber': 'latest_patch_number'
},
);
Map<String, dynamic> _$AppMetadataToJson(AppMetadata instance) =>
<String, dynamic>{
'app_id': instance.appId,
'display_name': instance.displayName,
'latest_release_version': instance.latestReleaseVersion,
'latest_patch_number': instance.latestPatchNumber,
};
@@ -1,4 +1,5 @@
export 'app.dart';
export 'app_metadata.dart';
export 'error_response.dart';
export 'patch.dart';
export 'user.dart';
@@ -0,0 +1,19 @@
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('AppMetadata', () {
test('can be (de)serialized', () {
const appMetadata = AppMetadata(
appId: '30370f27-dbf1-4673-8b20-fb096e38dffa',
displayName: 'My App',
latestReleaseVersion: '1.0.0',
latestPatchNumber: 1,
);
expect(
AppMetadata.fromJson(appMetadata.toJson()).toJson(),
equals(appMetadata.toJson()),
);
});
});
}
@@ -5,9 +5,8 @@ void main() {
group('App', () {
test('can be (de)serialized', () {
const app = App(
appId: 'my_app',
latestReleaseVersion: '1.0.0',
latestPatchNumber: 1,
id: '30370f27-dbf1-4673-8b20-fb096e38dffa',
displayName: 'My App',
);
expect(App.fromJson(app.toJson()).toJson(), equals(app.toJson()));
});