diff --git a/packages/shorebird_cli/build.yaml b/packages/shorebird_cli/build.yaml new file mode 100644 index 00000000..52cb55e6 --- /dev/null +++ b/packages/shorebird_cli/build.yaml @@ -0,0 +1,16 @@ +targets: + $default: + builders: + source_gen|combining_builder: + options: + ignore_for_file: + - implicit_dynamic_parameter + - require_trailing_commas + - cast_nullable_to_non_nullable + - lines_longer_than_80_chars + - strict_raw_type + json_serializable: + options: + field_rename: snake + checked: true + explicit_to_json: true diff --git a/packages/shorebird_cli/lib/src/command.dart b/packages/shorebird_cli/lib/src/command.dart index 185ef6f4..1b24eebc 100644 --- a/packages/shorebird_cli/lib/src/command.dart +++ b/packages/shorebird_cli/lib/src/command.dart @@ -6,6 +6,7 @@ import 'package:mason_logger/mason_logger.dart'; import 'package:meta/meta.dart'; import 'package:shorebird_cli/src/auth/auth.dart'; import 'package:shorebird_code_push_api_client/shorebird_code_push_api_client.dart'; +import 'package:uuid/uuid.dart'; typedef CodePushClientBuilder = ShorebirdCodePushApiClient Function({ required String apiKey, @@ -23,25 +24,29 @@ typedef RunProcess = Future Function( bool runInShell, }); +typedef UuidBuilder = String Function(); + abstract class ShorebirdCommand extends Command { ShorebirdCommand({ + required this.logger, Auth? auth, CodePushClientBuilder? buildCodePushClient, - Logger? logger, RunProcess? runProcess, StartProcess? startProcess, + UuidBuilder? buildUuid, }) : auth = auth ?? Auth(), buildCodePushClient = buildCodePushClient ?? ShorebirdCodePushApiClient.new, - logger = logger ?? Logger(), runProcess = runProcess ?? Process.run, - startProcess = startProcess ?? Process.start; + startProcess = startProcess ?? Process.start, + buildUuid = buildUuid ?? const Uuid().v4; final Auth auth; final CodePushClientBuilder buildCodePushClient; final Logger logger; final RunProcess runProcess; final StartProcess startProcess; + final UuidBuilder buildUuid; /// [ArgResults] used for testing purposes only. @visibleForTesting diff --git a/packages/shorebird_cli/lib/src/command_runner.dart b/packages/shorebird_cli/lib/src/command_runner.dart index cd38aad0..3799c072 100644 --- a/packages/shorebird_cli/lib/src/command_runner.dart +++ b/packages/shorebird_cli/lib/src/command_runner.dart @@ -35,12 +35,12 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner { help: 'Noisy logging, including all shell commands executed.', ); - addCommand(BuildCommand()); - addCommand(LoginCommand()); - addCommand(LogoutCommand()); - addCommand(PublishCommand()); - addCommand(RunCommand()); - addCommand(UpdateCommand(logger: logger, pubUpdater: pubUpdater)); + addCommand(BuildCommand(logger: _logger)); + addCommand(LoginCommand(logger: _logger)); + addCommand(LogoutCommand(logger: _logger)); + addCommand(PublishCommand(logger: _logger)); + addCommand(RunCommand(logger: _logger)); + addCommand(UpdateCommand(logger: _logger, pubUpdater: pubUpdater)); } @override diff --git a/packages/shorebird_cli/lib/src/commands/build_command.dart b/packages/shorebird_cli/lib/src/commands/build_command.dart index 1c4b0166..9742856d 100644 --- a/packages/shorebird_cli/lib/src/commands/build_command.dart +++ b/packages/shorebird_cli/lib/src/commands/build_command.dart @@ -18,9 +18,9 @@ typedef RunProcess = Future Function( class BuildCommand extends ShorebirdCommand with ShorebirdEngineMixin { /// {@macro build_command} BuildCommand({ + required super.logger, super.auth, super.buildCodePushClient, - super.logger, super.runProcess, }); diff --git a/packages/shorebird_cli/lib/src/commands/login_command.dart b/packages/shorebird_cli/lib/src/commands/login_command.dart index 7416bee3..159f2016 100644 --- a/packages/shorebird_cli/lib/src/commands/login_command.dart +++ b/packages/shorebird_cli/lib/src/commands/login_command.dart @@ -8,7 +8,7 @@ import 'package:shorebird_cli/src/command.dart'; /// {@endtemplate} class LoginCommand extends ShorebirdCommand { /// {@macro login_command} - LoginCommand({super.auth, super.logger}); + LoginCommand({required super.logger, super.auth}); @override String get description => 'Login as a new Shorebird user.'; diff --git a/packages/shorebird_cli/lib/src/commands/logout_command.dart b/packages/shorebird_cli/lib/src/commands/logout_command.dart index 0c8df347..a94bc4cf 100644 --- a/packages/shorebird_cli/lib/src/commands/logout_command.dart +++ b/packages/shorebird_cli/lib/src/commands/logout_command.dart @@ -8,7 +8,7 @@ import 'package:shorebird_cli/src/command.dart'; /// {@endtemplate} class LogoutCommand extends ShorebirdCommand { /// {@macro logout_command} - LogoutCommand({super.auth, super.logger}); + LogoutCommand({required super.logger, super.auth}); @override String get description => 'Logout of the current Shorebird user'; diff --git a/packages/shorebird_cli/lib/src/commands/publish_command.dart b/packages/shorebird_cli/lib/src/commands/publish_command.dart index b24e3744..257227ee 100644 --- a/packages/shorebird_cli/lib/src/commands/publish_command.dart +++ b/packages/shorebird_cli/lib/src/commands/publish_command.dart @@ -1,8 +1,13 @@ import 'dart:io'; +import 'package:checked_yaml/checked_yaml.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:path/path.dart' as p; +import 'package:pubspec_parse/pubspec_parse.dart'; import 'package:shorebird_cli/src/command.dart'; +import 'package:shorebird_cli/src/config/shorebird_yaml.dart'; +import 'package:yaml/yaml.dart'; +import 'package:yaml_edit/yaml_edit.dart'; /// {@template publish_command} /// @@ -11,7 +16,12 @@ import 'package:shorebird_cli/src/command.dart'; /// {@endtemplate} class PublishCommand extends ShorebirdCommand { /// {@macro publish_command} - PublishCommand({super.auth, super.buildCodePushClient, super.logger}); + PublishCommand({ + required super.logger, + super.auth, + super.buildCodePushClient, + super.buildUuid, + }); @override String get description => 'Publish an update.'; @@ -32,7 +42,46 @@ class PublishCommand extends ShorebirdCommand { usageException('A single file path must be specified.'); } - final releasePath = args.isEmpty + late final Pubspec? pubspecYaml; + try { + pubspecYaml = _readPubspecYaml(); + if (pubspecYaml == null) { + logger.err('Could not find a "pubspec.yaml".'); + return ExitCode.noInput.code; + } + } catch (error) { + logger.err('Error parsing "pubspec.yaml": $error'); + return ExitCode.software.code; + } + + late final String productId; + late final ShorebirdYaml? shorebirdYaml; + try { + shorebirdYaml = _readShorebirdYaml(); + } catch (error) { + logger.err('Error parsing "shorebird.yaml": $error'); + return ExitCode.software.code; + } + + if (shorebirdYaml == null) { + productId = buildUuid(); + File( + p.join(Directory.current.path, 'shorebird.yaml'), + ).writeAsStringSync(''' +# This file is used to configure the Shorebird CLI. +# Learn more at https://shorebird.dev + +# This is the unique identifier for your app. +product_id: $productId +'''); + logger.info('Generated a "shorebird.yaml".'); + } else { + productId = shorebirdYaml.productId; + } + + _addShorebirdYamlToAssets(); + + final artifactPath = args.isEmpty ? p.join( Directory.current.path, 'build', @@ -47,15 +96,23 @@ class PublishCommand extends ShorebirdCommand { ) : args.first; - final artifact = File(releasePath); + final artifact = File(artifactPath); if (!artifact.existsSync()) { - logger.err('File not found: ${artifact.path}'); + logger.err('Artifact not found: "${artifact.path}"'); return ExitCode.noInput.code; } try { final codePushClient = buildCodePushClient(apiKey: session.apiKey); - await codePushClient.createRelease(artifact.path); + logger.detail( + 'Deploying ${artifact.path} to $productId (${pubspecYaml.version})', + ); + await codePushClient.createPatch( + artifactPath: artifact.path, + baseVersion: pubspecYaml.version.toString(), + productId: productId, + channel: 'stable', + ); } catch (error) { logger.err('Failed to deploy: $error'); return ExitCode.software.code; @@ -64,4 +121,48 @@ class PublishCommand extends ShorebirdCommand { logger.success('Deployed ${artifact.path}!'); return ExitCode.success.code; } + + ShorebirdYaml? _readShorebirdYaml() { + final file = File(p.join(Directory.current.path, 'shorebird.yaml')); + if (!file.existsSync()) return null; + final yaml = file.readAsStringSync(); + return checkedYamlDecode(yaml, (m) => ShorebirdYaml.fromJson(m!)); + } + + Pubspec? _readPubspecYaml() { + final file = File(p.join(Directory.current.path, 'pubspec.yaml')); + if (!file.existsSync()) return null; + final yaml = file.readAsStringSync(); + return Pubspec.parse(yaml); + } + + void _addShorebirdYamlToAssets() { + final pubspecFile = File(p.join(Directory.current.path, 'pubspec.yaml')); + final pubspecContents = pubspecFile.readAsStringSync(); + final yaml = loadYaml(pubspecContents, sourceUrl: pubspecFile.uri) as Map; + final editor = YamlEditor(pubspecContents); + + if (!yaml.containsKey('flutter')) { + editor.update( + ['flutter'], + { + 'assets': ['shorebird.yaml'] + }, + ); + } else { + if (!(yaml['flutter'] as Map).containsKey('assets')) { + editor.update(['flutter', 'assets'], ['shorebird.yaml']); + } else { + final assets = (yaml['flutter'] as Map)['assets'] as List; + if (!assets.contains('shorebird.yaml')) { + editor.update(['flutter', 'assets'], [...assets, 'shorebird.yaml']); + } + } + } + + if (editor.edits.isNotEmpty) { + pubspecFile.writeAsStringSync(editor.toString()); + logger.info('Added "shorebird.yaml" to "pubspec.yaml".'); + } + } } diff --git a/packages/shorebird_cli/lib/src/commands/run_command.dart b/packages/shorebird_cli/lib/src/commands/run_command.dart index 88952a60..bfa0bcdc 100644 --- a/packages/shorebird_cli/lib/src/commands/run_command.dart +++ b/packages/shorebird_cli/lib/src/commands/run_command.dart @@ -19,9 +19,9 @@ typedef StartProcess = Future Function( class RunCommand extends ShorebirdCommand with ShorebirdEngineMixin { /// {@macro run_command} RunCommand({ + required super.logger, super.auth, super.buildCodePushClient, - super.logger, super.startProcess, }); diff --git a/packages/shorebird_cli/lib/src/commands/update_command.dart b/packages/shorebird_cli/lib/src/commands/update_command.dart index 1bbd0f24..c2cbdfde 100644 --- a/packages/shorebird_cli/lib/src/commands/update_command.dart +++ b/packages/shorebird_cli/lib/src/commands/update_command.dart @@ -12,7 +12,7 @@ import 'package:shorebird_cli/src/version.dart'; class UpdateCommand extends ShorebirdCommand { /// {@macro update_command} UpdateCommand({ - super.logger, + required super.logger, PubUpdater? pubUpdater, }) : _pubUpdater = pubUpdater ?? PubUpdater(); diff --git a/packages/shorebird_cli/lib/src/config/shorebird_yaml.dart b/packages/shorebird_cli/lib/src/config/shorebird_yaml.dart new file mode 100644 index 00000000..4bc56bd1 --- /dev/null +++ b/packages/shorebird_cli/lib/src/config/shorebird_yaml.dart @@ -0,0 +1,17 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'shorebird_yaml.g.dart'; + +@JsonSerializable( + anyMap: true, + disallowUnrecognizedKeys: true, + createToJson: false, +) +class ShorebirdYaml { + const ShorebirdYaml({required this.productId}); + + factory ShorebirdYaml.fromJson(Map json) => + _$ShorebirdYamlFromJson(json); + + final String productId; +} diff --git a/packages/shorebird_cli/lib/src/config/shorebird_yaml.g.dart b/packages/shorebird_cli/lib/src/config/shorebird_yaml.g.dart new file mode 100644 index 00000000..9078399e --- /dev/null +++ b/packages/shorebird_cli/lib/src/config/shorebird_yaml.g.dart @@ -0,0 +1,25 @@ +// 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, strict_raw_type + +part of 'shorebird_yaml.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ShorebirdYaml _$ShorebirdYamlFromJson(Map json) => $checkedCreate( + 'ShorebirdYaml', + json, + ($checkedConvert) { + $checkKeys( + json, + allowedKeys: const ['product_id'], + ); + final val = ShorebirdYaml( + productId: $checkedConvert('product_id', (v) => v as String), + ); + return val; + }, + fieldKeyMap: const {'productId': 'product_id'}, + ); diff --git a/packages/shorebird_cli/pubspec.yaml b/packages/shorebird_cli/pubspec.yaml index 63db5f75..55d1c5d4 100644 --- a/packages/shorebird_cli/pubspec.yaml +++ b/packages/shorebird_cli/pubspec.yaml @@ -11,19 +11,26 @@ environment: dependencies: archive: ^3.3.6 args: ^2.3.1 + checked_yaml: ^2.0.2 cli_completion: ^0.3.0 cli_util: ^0.4.0 + json_annotation: ^4.8.0 mason_logger: ^0.2.4 meta: ^1.9.0 path: ^1.8.3 pub_updater: ^0.2.4 + pubspec_parse: ^1.2.2 shorebird_code_push_api_client: path: ../shorebird_code_push_api_client + uuid: ^3.0.7 + yaml: ^3.1.1 + yaml_edit: ^2.1.0 dev_dependencies: build_runner: ^2.0.0 build_verify: ^3.0.0 build_version: ^2.0.0 + json_serializable: ^6.6.1 mocktail: ^0.3.0 test: ^1.19.2 very_good_analysis: ^4.0.0 diff --git a/packages/shorebird_cli/test/fixtures/release.txt b/packages/shorebird_cli/test/fixtures/release.txt deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/shorebird_cli/test/src/commands/publish_command_test.dart b/packages/shorebird_cli/test/src/commands/publish_command_test.dart index 6df9e999..1ea25c48 100644 --- a/packages/shorebird_cli/test/src/commands/publish_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/publish_command_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:args/args.dart'; import 'package:args/command_runner.dart'; import 'package:mason_logger/mason_logger.dart'; @@ -31,6 +33,13 @@ void main() { projectId: 'test-project-id', apiKey: 'test-api-key', ); + const productId = 'test-product-id'; + const version = '1.2.3'; + const pubspecYamlContent = ''' +name: example +version: $version +environment: + sdk: ">=2.19.0 <3.0.0"'''; late ArgResults argResults; late Auth auth; @@ -51,7 +60,17 @@ void main() { ..testArgResults = argResults ..testCommandRunner = _FakeCommandRunner(); + when(() => argResults.rest).thenReturn([]); + when(() => auth.currentSession).thenReturn(session); when(() => logger.progress(any())).thenReturn(_MockProgress()); + when( + () => codePushClient.createPatch( + baseVersion: any(named: 'baseVersion'), + artifactPath: any(named: 'artifactPath'), + channel: any(named: 'channel'), + productId: any(named: 'productId'), + ), + ).thenAnswer((_) async {}); }); test('throws no user error when session does not exist', () async { @@ -61,48 +80,248 @@ void main() { }); test('throws usage error when multiple args are passed.', () async { - when(() => auth.currentSession).thenReturn(session); when(() => argResults.rest).thenReturn(['arg1', 'arg2']); await expectLater(command.run, throwsA(isA())); }); - test('throws no input error when file is not found (default).', () async { - when(() => auth.currentSession).thenReturn(session); - when(() => argResults.rest).thenReturn([]); - final exitCode = await command.run(); + test('throws no input error when pubspec.yaml is not found.', () async { + final tempDir = Directory.systemTemp.createTempSync(); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify(() => logger.err('Could not find a "pubspec.yaml".')).called(1); + expect(exitCode, ExitCode.noInput.code); + }); + + test('throws software error when pubspec.yaml is malformed.', () async { + final tempDir = Directory.systemTemp.createTempSync(); + File(p.join(tempDir.path, 'pubspec.yaml')).createSync(); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); verify( - () => logger.err(any(that: contains('File not found: '))), + () => logger.err(any(that: contains('Error parsing "pubspec.yaml":'))), + ).called(1); + expect(exitCode, ExitCode.software.code); + }); + + test('throws software error when shorebird.yaml is malformed.', () async { + final tempDir = Directory.systemTemp.createTempSync(); + File( + p.join(tempDir.path, 'pubspec.yaml'), + ).writeAsStringSync(pubspecYamlContent); + File(p.join(tempDir.path, 'shorebird.yaml')).createSync(); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify( + () => logger.err( + any(that: contains('Error parsing "shorebird.yaml":')), + ), + ).called(1); + expect(exitCode, ExitCode.software.code); + }); + + test('throws no input error when artifact is not found (default).', + () async { + final tempDir = Directory.systemTemp.createTempSync(); + File( + p.join(tempDir.path, 'pubspec.yaml'), + ).writeAsStringSync(pubspecYamlContent); + File( + p.join(tempDir.path, 'shorebird.yaml'), + ).writeAsStringSync('product_id: $productId'); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify( + () => logger.err(any(that: contains('Artifact not found:'))), ).called(1); expect(exitCode, ExitCode.noInput.code); }); - test('throws no input error when file is not found (custom).', () async { - when(() => auth.currentSession).thenReturn(session); - when(() => argResults.rest).thenReturn(['missing.txt']); - final exitCode = await command.run(); - verify(() => logger.err('File not found: missing.txt')).called(1); + test('throws no input error when artifact is not found (custom).', + () async { + final tempDir = Directory.systemTemp.createTempSync(); + final artifact = File(p.join(tempDir.path, 'patch.txt')); + when(() => argResults.rest).thenReturn([artifact.path]); + File( + p.join(tempDir.path, 'pubspec.yaml'), + ).writeAsStringSync(pubspecYamlContent); + File( + p.join(tempDir.path, 'shorebird.yaml'), + ).writeAsStringSync('product_id: $productId'); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify( + () => logger.err( + any( + that: contains('Artifact not found: "${artifact.path}"'), + ), + ), + ).called(1); expect(exitCode, ExitCode.noInput.code); }); - test('throws error when release fails.', () async { - when(() => auth.currentSession).thenReturn(session); + test('throws error when publish fails.', () async { const error = 'something went wrong'; - when(() => codePushClient.createRelease(any())).thenThrow(error); - final release = p.join('test', 'fixtures', 'release.txt'); - when(() => argResults.rest).thenReturn([release]); - final exitCode = await command.run(); + when( + () => codePushClient.createPatch( + baseVersion: any(named: 'baseVersion'), + artifactPath: any(named: 'artifactPath'), + channel: any(named: 'channel'), + productId: any(named: 'productId'), + ), + ).thenThrow(error); + final tempDir = Directory.systemTemp.createTempSync(); + final artifact = File(p.join(tempDir.path, 'patch.txt'))..createSync(); + when(() => argResults.rest).thenReturn([artifact.path]); + File( + p.join(tempDir.path, 'pubspec.yaml'), + ).writeAsStringSync(pubspecYamlContent); + File( + p.join(tempDir.path, 'shorebird.yaml'), + ).writeAsStringSync('product_id: $productId'); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); verify(() => logger.err('Failed to deploy: $error')).called(1); expect(exitCode, ExitCode.software.code); }); - test('succeeds when release is successful.', () async { - when(() => auth.currentSession).thenReturn(session); - when(() => codePushClient.createRelease(any())).thenAnswer((_) async {}); - final release = p.join('test', 'fixtures', 'release.txt'); - when(() => argResults.rest).thenReturn([release]); - final exitCode = await command.run(); - verify(() => logger.success('Deployed $release!')).called(1); + test('succeeds when publish is successful using existing product id', + () async { + final tempDir = Directory.systemTemp.createTempSync(); + final artifact = File(p.join(tempDir.path, 'patch.txt'))..createSync(); + when(() => argResults.rest).thenReturn([artifact.path]); + File( + p.join(tempDir.path, 'pubspec.yaml'), + ).writeAsStringSync(pubspecYamlContent); + File( + p.join(tempDir.path, 'shorebird.yaml'), + ).writeAsStringSync('product_id: $productId'); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify(() => logger.success('Deployed ${artifact.path}!')).called(1); + verify( + () => codePushClient.createPatch( + baseVersion: version, + productId: productId, + artifactPath: artifact.path, + channel: 'stable', + ), + ).called(1); expect(exitCode, ExitCode.success.code); }); + + test('succeeds when publish is successful using newly generated product id', + () async { + final tempDir = Directory.systemTemp.createTempSync(); + final artifact = File(p.join(tempDir.path, 'patch.txt'))..createSync(); + when(() => argResults.rest).thenReturn([artifact.path]); + File( + p.join(tempDir.path, 'pubspec.yaml'), + ).writeAsStringSync(pubspecYamlContent); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify(() => logger.success('Deployed ${artifact.path}!')).called(1); + verify( + () => codePushClient.createPatch( + baseVersion: version, + productId: any(named: 'productId', that: isNotEmpty), + artifactPath: artifact.path, + channel: 'stable', + ), + ).called(1); + expect( + File(p.join(tempDir.path, 'shorebird.yaml')).existsSync(), + isTrue, + ); + expect(exitCode, ExitCode.success.code); + }); + + test('creates flutter.assets and adds shorebird.yaml', () async { + final tempDir = Directory.systemTemp.createTempSync(); + final artifact = File(p.join(tempDir.path, 'patch.txt'))..createSync(); + when(() => argResults.rest).thenReturn([artifact.path]); + File( + p.join(tempDir.path, 'pubspec.yaml'), + ).writeAsStringSync(pubspecYamlContent); + await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + expect( + File(p.join(tempDir.path, 'pubspec.yaml')).readAsStringSync(), + equals(''' +$pubspecYamlContent +flutter: + assets: + - shorebird.yaml +'''), + ); + }); + + test('creates assets and adds shorebird.yaml', () async { + final tempDir = Directory.systemTemp.createTempSync(); + final artifact = File(p.join(tempDir.path, 'patch.txt'))..createSync(); + when(() => argResults.rest).thenReturn([artifact.path]); + File(p.join(tempDir.path, 'pubspec.yaml')).writeAsStringSync(''' +$pubspecYamlContent +flutter: + uses-material-design: true +'''); + await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + expect( + File(p.join(tempDir.path, 'pubspec.yaml')).readAsStringSync(), + equals(''' +$pubspecYamlContent +flutter: + assets: + - shorebird.yaml + uses-material-design: true +'''), + ); + }); + + test('adds shorebird.yaml to assets', () async { + final tempDir = Directory.systemTemp.createTempSync(); + final artifact = File(p.join(tempDir.path, 'patch.txt'))..createSync(); + when(() => argResults.rest).thenReturn([artifact.path]); + File(p.join(tempDir.path, 'pubspec.yaml')).writeAsStringSync(''' +$pubspecYamlContent +flutter: + assets: + - some/asset.txt +'''); + await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + expect( + File(p.join(tempDir.path, 'pubspec.yaml')).readAsStringSync(), + equals(''' +$pubspecYamlContent +flutter: + assets: + - some/asset.txt + - shorebird.yaml +'''), + ); + }); }); } diff --git a/packages/shorebird_code_push_api_client/example/main.dart b/packages/shorebird_code_push_api_client/example/main.dart index 384624bb..93d29bf4 100644 --- a/packages/shorebird_code_push_api_client/example/main.dart +++ b/packages/shorebird_code_push_api_client/example/main.dart @@ -1,6 +1,6 @@ // ignore_for_file: unused_local_variable -import 'package:shorebird_code_push_api_client/src/shorebird_code_push_api_client.dart'; +import 'package:shorebird_code_push_api_client/shorebird_code_push_api_client.dart'; Future main() async { final client = ShorebirdCodePushApiClient(apiKey: ''); @@ -8,6 +8,11 @@ Future main() async { // Download the latest engine revision. final engine = await client.downloadEngine('latest'); - // Create a new release. - await client.createRelease('path/to/release/libapp.so'); + // Create a new patch. + await client.createPatch( + artifactPath: '', // e.g. 'libapp.so' + baseVersion: '', // e.g. '1.0.0' + productId: '', // e.g. 'shorebird-example' + channel: '', // e.g. 'stable' + ); } diff --git a/packages/shorebird_code_push_api_client/lib/src/shorebird_code_push_api_client.dart b/packages/shorebird_code_push_api_client/lib/src/shorebird_code_push_api_client.dart index 4aef682a..375feed0 100644 --- a/packages/shorebird_code_push_api_client/lib/src/shorebird_code_push_api_client.dart +++ b/packages/shorebird_code_push_api_client/lib/src/shorebird_code_push_api_client.dart @@ -14,24 +14,35 @@ class ShorebirdCodePushApiClient { Uri? hostedUri, }) : _apiKey = apiKey, _httpClient = httpClient ?? http.Client(), - _hostedUri = hostedUri ?? - Uri.https('shorebird-code-push-api-cypqazu4da-uc.a.run.app'); + hostedUri = + hostedUri ?? Uri.https('code-push-server-kmdbqkx7rq-uc.a.run.app'); final String _apiKey; final http.Client _httpClient; - final Uri _hostedUri; + + /// The hosted uri for the Shorebird CodePush API. + final Uri hostedUri; Map get _apiKeyHeader => {'x-api-key': _apiKey}; - /// Upload the artifact at [path] to the - /// Shorebird CodePush API as a new release. - Future createRelease(String path) async { + /// Create a new patch. + Future createPatch({ + required String baseVersion, + required String productId, + required String channel, + required String artifactPath, + }) async { final request = http.MultipartRequest( 'POST', - Uri.parse('$_hostedUri/api/v1/releases'), + Uri.parse('$hostedUri/api/v1/patches'), ); - final file = await http.MultipartFile.fromPath('file', path); + final file = await http.MultipartFile.fromPath('file', artifactPath); request.files.add(file); + request.fields.addAll({ + 'base_version': baseVersion, + 'product_id': productId, + 'channel': channel, + }); request.headers.addAll(_apiKeyHeader); final response = await _httpClient.send(request); @@ -44,7 +55,10 @@ class ShorebirdCodePushApiClient { Future downloadEngine(String revision) async { final request = http.Request( 'GET', - Uri.parse('$_hostedUri/api/v1/engines/$revision'), + Uri.parse( + // TODO(felangel): use the revision instead of hardcoded "dev". + 'https://storage.googleapis.com/code-push-dev.appspot.com/engines/dev/engine.zip', + ), ); request.headers.addAll(_apiKeyHeader); diff --git a/packages/shorebird_code_push_api_client/test/src/shorebird_code_push_api_client_test.dart b/packages/shorebird_code_push_api_client/test/src/shorebird_code_push_api_client_test.dart index c589dd19..e26484bd 100644 --- a/packages/shorebird_code_push_api_client/test/src/shorebird_code_push_api_client_test.dart +++ b/packages/shorebird_code_push_api_client/test/src/shorebird_code_push_api_client_test.dart @@ -34,7 +34,7 @@ void main() { expect(ShorebirdCodePushApiClient(apiKey: apiKey), isNotNull); }); - group('createRelease', () { + group('createPatch', () { test('throws an exception if the http request fails', () async { when(() => httpClient.send(any())).thenAnswer((_) async { return http.StreamedResponse( @@ -44,8 +44,11 @@ void main() { }); expect( - shorebirdCodePushApiClient.createRelease( - path.join('test', 'fixtures', 'release.txt'), + shorebirdCodePushApiClient.createPatch( + artifactPath: path.join('test', 'fixtures', 'release.txt'), + baseVersion: '1.0.0', + productId: 'shorebird-example', + channel: 'stable', ), throwsA(isA()), ); @@ -59,8 +62,11 @@ void main() { ); }); - await shorebirdCodePushApiClient.createRelease( - path.join('test', 'fixtures', 'release.txt'), + await shorebirdCodePushApiClient.createPatch( + artifactPath: path.join('test', 'fixtures', 'release.txt'), + baseVersion: '1.0.0', + productId: 'shorebird-example', + channel: 'stable', ); final request = verify(() => httpClient.send(captureAny())) @@ -68,9 +74,7 @@ void main() { .single as http.MultipartRequest; expect( request.url, - Uri.parse( - 'https://shorebird-code-push-api-cypqazu4da-uc.a.run.app/api/v1/releases', - ), + shorebirdCodePushApiClient.hostedUri.replace(path: '/api/v1/patches'), ); }); }); @@ -108,7 +112,7 @@ void main() { expect( request.url, Uri.parse( - 'https://shorebird-code-push-api-cypqazu4da-uc.a.run.app/api/v1/engines/$engineRevision', + 'https://storage.googleapis.com/code-push-dev.appspot.com/engines/dev/engine.zip', ), ); }); diff --git a/updater/library/src/cache.rs b/updater/library/src/cache.rs index c8be9501..21977d48 100644 --- a/updater/library/src/cache.rs +++ b/updater/library/src/cache.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; -use crate::network::UpdateResponse; +use crate::network::PatchCheckResponse; pub struct PatchInfo { pub path: String, @@ -114,18 +114,18 @@ pub fn download_file_to_path(url: &str, path: &PathBuf) -> anyhow::Result<()> { pub fn download_into_unused_slot( cache_dir: &str, - update_response: &UpdateResponse, + patch_check_response: &PatchCheckResponse, state: &mut UpdaterState, ) -> anyhow::Result { // Download the new version into the unused slot. let slot_index = unused_slot(state); - download_into_slot(cache_dir, update_response, state, slot_index)?; + download_into_slot(cache_dir, patch_check_response, state, slot_index)?; Ok(slot_index) } fn download_into_slot( cache_dir: &str, - update_response: &UpdateResponse, + patch_check_response: &PatchCheckResponse, state: &mut UpdaterState, slot_index: usize, ) -> anyhow::Result<()> { @@ -136,14 +136,14 @@ fn download_into_slot( .join("libapp.txt"); // TODO: Shouldn't crash on malformed response. - let update = update_response.update.as_ref().unwrap(); + let patch = patch_check_response.patch.as_ref().unwrap(); // We should download into a separate place and move into place. // That would allow us to check the hash before moving into place. // Would also allow the move/state update to be "atomic" or at least allow // us to carefully guard against state corruption. // Would also let us support when we need to allow the system to download for us (e.g. iOS). - download_file_to_path(&update.download_url, &path)?; + download_file_to_path(&patch.download_url, &path)?; // Check the hash against the download? // Update the state to include the new version. @@ -152,8 +152,8 @@ fn download_into_slot( slot_index, Slot { path: path.to_str().unwrap().to_string(), - version: update.version.clone(), - hash: update.hash.clone(), + version: patch.version.clone(), + hash: patch.hash.clone(), }, ); save_state(&state, cache_dir)?; diff --git a/updater/library/src/config.rs b/updater/library/src/config.rs index f8d89200..1a06e74a 100644 --- a/updater/library/src/config.rs +++ b/updater/library/src/config.rs @@ -8,7 +8,7 @@ use once_cell::sync::OnceCell; // cbindgen looks for const, ignore these so it doesn't warn about them. /// cbindgen:ignore -const DEFAULT_BASE_URL: &'static str = "https://shorebird-code-push-api-cypqazu4da-uc.a.run.app"; +const DEFAULT_BASE_URL: &'static str = "https://code-push-server-kmdbqkx7rq-uc.a.run.app"; /// cbindgen:ignore const DEFAULT_CHANNEL: &'static str = "stable"; diff --git a/updater/library/src/network.rs b/updater/library/src/network.rs index 06bb2d15..d204a173 100644 --- a/updater/library/src/network.rs +++ b/updater/library/src/network.rs @@ -9,28 +9,28 @@ use serde::Deserialize; use crate::cache::PatchInfo; use crate::config::ResolvedConfig; -fn updates_url(base_url: &str) -> String { - return format!("{}/api/v1/updates", base_url); +fn patches_check_url(base_url: &str) -> String { + return format!("{}/api/v1/patches/check", base_url); } #[derive(Deserialize)] -pub struct Update { +pub struct Patch { pub version: String, pub hash: String, pub download_url: String, } #[derive(Deserialize)] -pub struct UpdateResponse { - pub update_available: bool, +pub struct PatchCheckResponse { + pub patch_available: bool, #[serde(default)] - pub update: Option, + pub patch: Option, } -pub fn send_update_request( +pub fn send_patch_check_request( config: &ResolvedConfig, patch: Option, -) -> anyhow::Result { +) -> anyhow::Result { #[cfg(target_os = "macos")] static PLATFORM: &str = "macos"; #[cfg(target_os = "linux")] @@ -61,7 +61,7 @@ pub fn send_update_request( body.insert("platform", PLATFORM.to_string()); body.insert("arch", ARCH.to_string()); let response = client - .post(&updates_url(&config.base_url)) + .post(&patches_check_url(&config.base_url)) .json(&body) .send()? .json()?; diff --git a/updater/library/src/updater.rs b/updater/library/src/updater.rs index da88d21e..533a7e50 100644 --- a/updater/library/src/updater.rs +++ b/updater/library/src/updater.rs @@ -8,7 +8,7 @@ use crate::cache::{ }; use crate::config::{set_config, with_config, ResolvedConfig}; use crate::logging::init_logging; -use crate::network::send_update_request; +use crate::network::send_patch_check_request; pub enum UpdateStatus { NoUpdate, @@ -56,14 +56,14 @@ pub fn check_for_update_internal(config: &ResolvedConfig) -> bool { // Check the current slot. let patch = current_patch_internal(&state); // Send info from app + current slot to server. - let response_result = send_update_request(&config, patch); + let response_result = send_patch_check_request(&config, patch); match response_result { Err(err) => { error!("Failed update check: {err}"); return false; } Ok(response) => { - return response.update_available; + return response.patch_available; } } } @@ -77,8 +77,8 @@ fn update_internal(config: &ResolvedConfig) -> anyhow::Result { let mut state = load_state(&config.cache_dir).unwrap_or_default(); let version = current_patch_internal(&state); // Check for update. - let response = send_update_request(&config, version)?; - if !response.update_available { + let response = send_patch_check_request(&config, version)?; + if !response.patch_available { return Ok(UpdateStatus::NoUpdate); } // If needed, download the new version.