feat: upgrade shorebird tools to new API (#52)
This commit is contained in:
@@ -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
|
||||
@@ -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<ProcessResult> Function(
|
||||
bool runInShell,
|
||||
});
|
||||
|
||||
typedef UuidBuilder = String Function();
|
||||
|
||||
abstract class ShorebirdCommand extends Command<int> {
|
||||
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
|
||||
|
||||
@@ -35,12 +35,12 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner<int> {
|
||||
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
|
||||
|
||||
@@ -18,9 +18,9 @@ typedef RunProcess = Future<ProcessResult> Function(
|
||||
class BuildCommand extends ShorebirdCommand with ShorebirdEngineMixin {
|
||||
/// {@macro build_command}
|
||||
BuildCommand({
|
||||
required super.logger,
|
||||
super.auth,
|
||||
super.buildCodePushClient,
|
||||
super.logger,
|
||||
super.runProcess,
|
||||
});
|
||||
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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".');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,9 @@ typedef StartProcess = Future<Process> Function(
|
||||
class RunCommand extends ShorebirdCommand with ShorebirdEngineMixin {
|
||||
/// {@macro run_command}
|
||||
RunCommand({
|
||||
required super.logger,
|
||||
super.auth,
|
||||
super.buildCodePushClient,
|
||||
super.logger,
|
||||
super.startProcess,
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<dynamic, dynamic> json) =>
|
||||
_$ShorebirdYamlFromJson(json);
|
||||
|
||||
final String productId;
|
||||
}
|
||||
@@ -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'},
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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<UsageException>()));
|
||||
});
|
||||
|
||||
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
|
||||
'''),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void> main() async {
|
||||
final client = ShorebirdCodePushApiClient(apiKey: '<API KEY>');
|
||||
@@ -8,6 +8,11 @@ Future<void> 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: '<PATH TO ARTIFACT>', // e.g. 'libapp.so'
|
||||
baseVersion: '<BASE VERSION>', // e.g. '1.0.0'
|
||||
productId: '<PRODUCT ID>', // e.g. 'shorebird-example'
|
||||
channel: '<CHANNEL>', // e.g. 'stable'
|
||||
);
|
||||
}
|
||||
|
||||
+23
-9
@@ -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<String, String> get _apiKeyHeader => {'x-api-key': _apiKey};
|
||||
|
||||
/// Upload the artifact at [path] to the
|
||||
/// Shorebird CodePush API as a new release.
|
||||
Future<void> createRelease(String path) async {
|
||||
/// Create a new patch.
|
||||
Future<void> 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<Uint8List> 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);
|
||||
|
||||
|
||||
+13
-9
@@ -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<Exception>()),
|
||||
);
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<usize> {
|
||||
// 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)?;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<Update>,
|
||||
pub patch: Option<Patch>,
|
||||
}
|
||||
|
||||
pub fn send_update_request(
|
||||
pub fn send_patch_check_request(
|
||||
config: &ResolvedConfig,
|
||||
patch: Option<PatchInfo>,
|
||||
) -> anyhow::Result<UpdateResponse> {
|
||||
) -> anyhow::Result<PatchCheckResponse> {
|
||||
#[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()?;
|
||||
|
||||
@@ -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<UpdateStatus> {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user