feat(shorebird_cli): add shorebird upgrade (#182)

This commit is contained in:
Felix Angelov
2023-03-28 11:24:27 -05:00
committed by GitHub
parent f9de6f7bbd
commit a1033d79fa
11 changed files with 332 additions and 369 deletions
@@ -4,7 +4,6 @@ import 'package:args/args.dart';
import 'package:args/command_runner.dart';
import 'package:cli_completion/cli_completion.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:pub_updater/pub_updater.dart';
import 'package:shorebird_cli/src/commands/commands.dart';
import 'package:shorebird_cli/src/flutter_engine_revision.dart';
import 'package:shorebird_cli/src/version.dart';
@@ -17,6 +16,7 @@ typedef RunProcess = Future<ProcessResult> Function(
String executable,
List<String> arguments, {
bool runInShell,
String? workingDirectory,
});
/// {@template shorebird_cli_command_runner}
@@ -29,11 +29,9 @@ typedef RunProcess = Future<ProcessResult> Function(
class ShorebirdCliCommandRunner extends CompletionCommandRunner<int> {
/// {@macro shorebird_cli_command_runner}
ShorebirdCliCommandRunner({
PubUpdater? pubUpdater,
Logger? logger,
RunProcess? runProcess,
}) : _logger = logger ?? Logger(),
_pubUpdater = pubUpdater ?? PubUpdater(),
_runProcess = runProcess ?? Process.run,
super(executableName, description) {
argParser
@@ -55,14 +53,13 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner<int> {
addCommand(LogoutCommand(logger: _logger));
addCommand(PublishCommand(logger: _logger));
addCommand(RunCommand(logger: _logger));
addCommand(UpdateCommand(logger: _logger, pubUpdater: pubUpdater));
addCommand(UpgradeCommand(logger: _logger));
}
@override
void printUsage() => _logger.info(usage);
final Logger _logger;
final PubUpdater _pubUpdater;
final RunProcess _runProcess;
@override
@@ -134,11 +131,6 @@ Detected engine revision: "$flutterEngineRevision"''',
exitCode = await super.runCommand(topLevelResults);
}
// Check for updates
if (topLevelResults.command?.name != UpdateCommand.commandName) {
await _checkForUpdates();
}
return exitCode;
}
@@ -158,23 +150,4 @@ Detected engine revision: "$flutterEngineRevision"''',
}
return flutterEngineRevision;
}
/// Checks if the current version (set by the build runner on the
/// version.dart file) is the most recent one. If not, show a prompt to the
/// user.
Future<void> _checkForUpdates() async {
try {
final latestVersion = await _pubUpdater.getLatestVersion(packageName);
final isUpToDate = packageVersion == latestVersion;
if (!isUpToDate) {
_logger
..info('')
..info(
'''
${lightYellow.wrap('Update available!')} ${lightCyan.wrap(packageVersion)} \u2192 ${lightCyan.wrap(latestVersion)}
Run ${lightCyan.wrap('$executableName update')} to update''',
);
}
} catch (_) {}
}
}
@@ -5,4 +5,4 @@ export 'login_command.dart';
export 'logout_command.dart';
export 'publish_command.dart';
export 'run_command.dart';
export 'update_command.dart';
export 'upgrade_command.dart';
@@ -13,7 +13,6 @@ typedef StartProcess = Future<Process> Function(
});
/// {@template run_command}
///
/// `shorebird run`
/// Run the Flutter application.
/// {@endtemplate}
@@ -1,72 +0,0 @@
import 'dart:io';
import 'package:mason_logger/mason_logger.dart';
import 'package:pub_updater/pub_updater.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/command_runner.dart';
import 'package:shorebird_cli/src/version.dart';
/// {@template update_command}
/// A command which updates the CLI.
/// {@endtemplate}
class UpdateCommand extends ShorebirdCommand {
/// {@macro update_command}
UpdateCommand({
required super.logger,
PubUpdater? pubUpdater,
}) : _pubUpdater = pubUpdater ?? PubUpdater();
final PubUpdater _pubUpdater;
@override
String get description => 'Update the CLI.';
static const String commandName = 'update';
@override
String get name => commandName;
@override
Future<int> run() async {
final updateCheckProgress = logger.progress('Checking for updates');
late final String latestVersion;
try {
latestVersion = await _pubUpdater.getLatestVersion(packageName);
} catch (error) {
updateCheckProgress.fail();
logger.err('$error');
return ExitCode.software.code;
}
updateCheckProgress.complete('Checked for updates');
final isUpToDate = packageVersion == latestVersion;
if (isUpToDate) {
logger.info('CLI is already at the latest version.');
return ExitCode.success.code;
}
final updateProgress = logger.progress('Updating to $latestVersion');
late final ProcessResult result;
try {
result = await _pubUpdater.update(
packageName: packageName,
versionConstraint: latestVersion,
);
} catch (error) {
updateProgress.fail();
logger.err('$error');
return ExitCode.software.code;
}
if (result.exitCode != ExitCode.success.code) {
updateProgress.fail();
logger.err('Error updating CLI: ${result.stderr}');
return ExitCode.software.code;
}
updateProgress.complete('Updated to $latestVersion');
return ExitCode.success.code;
}
}
@@ -0,0 +1,144 @@
import 'dart:io';
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/command.dart';
/// {@template upgrade_command}
/// `shorebird upgrade`
/// A command which upgrades your copy of Shorebird.
/// {@endtemplate}
class UpgradeCommand extends ShorebirdCommand {
/// {@macro upgrade_command}
UpgradeCommand({required super.logger, super.runProcess});
@override
String get description => 'Upgrade your copy of Shorebird.';
static const String commandName = 'upgrade';
@override
String get name => commandName;
@override
Future<int> run() async {
final updateCheckProgress = logger.progress('Checking for updates');
final workingDirectory = p.dirname(Platform.script.toFilePath());
late final String currentVersion;
try {
currentVersion = await fetchCurrentVersion(
workingDirectory: workingDirectory,
);
} on ProcessException catch (error) {
updateCheckProgress.fail();
logger.err('Fetching current version failed: ${error.message}');
return ExitCode.software.code;
}
late final String latestVersion;
try {
latestVersion = await fetchLatestVersion(
workingDirectory: workingDirectory,
);
} on ProcessException catch (error) {
updateCheckProgress.fail();
logger.err('Checking for updates failed: ${error.message}');
return ExitCode.software.code;
}
updateCheckProgress.complete('Checked for updates');
final isUpToDate = currentVersion == latestVersion;
if (isUpToDate) {
logger.info('Shorebird is already at the latest version.');
return ExitCode.success.code;
}
final updateProgress = logger.progress('Updating');
try {
await attemptReset(
newRevision: latestVersion,
workingDirectory: workingDirectory,
);
} on ProcessException catch (error) {
updateProgress.fail();
logger.err('Updating failed: ${error.message}');
return ExitCode.software.code;
}
updateProgress.complete('Updated successfully.');
return ExitCode.success.code;
}
/// Returns the remote HEAD shorebird version.
///
/// Exits if HEAD isn't pointing to a branch, or there is no upstream.
Future<String> fetchLatestVersion({required String workingDirectory}) async {
// Fetch upstream branch's commits and tags
await runProcess(
'git',
['fetch', '--tags'],
workingDirectory: workingDirectory,
);
// Get the latest commit revision of the upstream
return _gitRevParse('@{upstream}', workingDirectory: workingDirectory);
}
/// Returns the local HEAD shorebird version.
///
/// Exits if HEAD isn't pointing to a branch, or there is no upstream.
Future<String> fetchCurrentVersion({
required String workingDirectory,
}) async {
// Get the commit revision of HEAD
return _gitRevParse('HEAD', workingDirectory: workingDirectory);
}
Future<String> _gitRevParse(
String revision, {
String? workingDirectory,
}) async {
// Get the commit revision of HEAD
final result = await runProcess(
'git',
['rev-parse', '--verify', revision],
workingDirectory: workingDirectory,
);
if (result.exitCode != 0) {
throw ProcessException(
'git',
['rev-parse', '--verify', revision],
'${result.stderr}',
result.exitCode,
);
}
return '${result.stdout}'.trim();
}
/// Attempts a hard reset to the given revision.
///
/// This is a reset instead of fast forward because if we are on a release
/// branch with cherry picks, there may not be a direct fast-forward route
/// to the next release.
Future<void> attemptReset({
required String newRevision,
required String workingDirectory,
}) async {
final result = await runProcess(
'git',
['reset', '--hard', newRevision],
workingDirectory: workingDirectory,
);
if (result.exitCode != 0) {
throw ProcessException(
'git',
['reset', '--hard', newRevision],
'${result.stderr}',
result.exitCode,
);
}
}
}
-1
View File
@@ -20,7 +20,6 @@ dependencies:
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_client:
path: ../shorebird_code_push_client
@@ -4,7 +4,6 @@ import 'package:args/command_runner.dart';
import 'package:cli_completion/cli_completion.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pub_updater/pub_updater.dart';
import 'package:shorebird_cli/src/command_runner.dart';
import 'package:shorebird_cli/src/flutter_engine_revision.dart';
import 'package:shorebird_cli/src/version.dart';
@@ -14,30 +13,13 @@ class _MockLogger extends Mock implements Logger {}
class _MockProcessResult extends Mock implements ProcessResult {}
class _MockProgress extends Mock implements Progress {}
class _MockPubUpdater extends Mock implements PubUpdater {}
const latestVersion = '0.0.0';
final updatePrompt = '''
${lightYellow.wrap('Update available!')} ${lightCyan.wrap(packageVersion)} \u2192 ${lightCyan.wrap(latestVersion)}
Run ${lightCyan.wrap('$executableName update')} to update''';
void main() {
group('ShorebirdCliCommandRunner', () {
late PubUpdater pubUpdater;
late Logger logger;
late ProcessResult processResult;
late ShorebirdCliCommandRunner commandRunner;
setUp(() {
pubUpdater = _MockPubUpdater();
when(
() => pubUpdater.getLatestVersion(any()),
).thenAnswer((_) async => packageVersion);
logger = _MockLogger();
processResult = _MockProcessResult();
@@ -48,11 +30,11 @@ void main() {
commandRunner = ShorebirdCliCommandRunner(
logger: logger,
pubUpdater: pubUpdater,
runProcess: (
String executable,
List<String> arguments, {
executable,
arguments, {
bool runInShell = false,
String? workingDirectory,
}) async {
return processResult;
},
@@ -113,60 +95,6 @@ Tools • Dart 2.19.4 • DevTools 2.20.1
).called(1);
});
test('shows update message when newer version exists', () async {
when(
() => pubUpdater.getLatestVersion(any()),
).thenAnswer((_) async => latestVersion);
final result = await commandRunner.run(['--version']);
expect(result, equals(ExitCode.success.code));
verify(() => logger.info(updatePrompt)).called(1);
});
test(
'Does not show update message when the shell calls the '
'completion command',
() async {
when(
() => pubUpdater.getLatestVersion(any()),
).thenAnswer((_) async => latestVersion);
final result = await commandRunner.run(['completion']);
expect(result, equals(ExitCode.success.code));
verifyNever(() => logger.info(updatePrompt));
},
);
test('does not show update message when using update command', () async {
when(
() => pubUpdater.getLatestVersion(any()),
).thenAnswer((_) async => latestVersion);
when(
() => pubUpdater.update(
packageName: packageName,
versionConstraint: any(named: 'versionConstraint'),
),
).thenAnswer((_) async => processResult);
when(
() => pubUpdater.isUpToDate(
packageName: any(named: 'packageName'),
currentVersion: any(named: 'currentVersion'),
),
).thenAnswer((_) async => true);
final progress = _MockProgress();
final progressLogs = <String>[];
when(() => progress.complete(any())).thenAnswer((_) {
final message = _.positionalArguments.elementAt(0) as String?;
if (message != null) progressLogs.add(message);
});
when(() => logger.progress(any())).thenReturn(progress);
final result = await commandRunner.run(['update']);
expect(result, equals(ExitCode.success.code));
verifyNever(() => logger.info(updatePrompt));
});
test('can be instantiated without an explicit analytics/logger instance',
() {
final commandRunner = ShorebirdCliCommandRunner();
@@ -218,5 +146,12 @@ Tools • Dart 2.19.4 • DevTools 2.20.1
expect(result, equals(ExitCode.success.code));
});
});
group('completion', () {
test('fast tracks completion', () async {
final result = await commandRunner.run(['completion']);
expect(result, equals(ExitCode.success.code));
});
});
});
}
@@ -49,7 +49,12 @@ void main() {
return codePushClient;
},
logger: logger,
runProcess: (executable, arguments, {bool runInShell = false}) async {
runProcess: (
executable,
arguments, {
bool runInShell = false,
String? workingDirectory,
}) async {
return processResult;
},
)..testArgResults = argResults;
@@ -95,7 +95,12 @@ flutter:
capturedHostedUri = hostedUri;
return codePushClient;
},
runProcess: (executable, arguments, {bool runInShell = false}) async {
runProcess: (
executable,
arguments, {
bool runInShell = false,
String? workingDirectory,
}) async {
return processResult;
},
logger: logger,
@@ -1,188 +0,0 @@
import 'dart:io';
import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pub_updater/pub_updater.dart';
import 'package:shorebird_cli/src/command_runner.dart';
import 'package:shorebird_cli/src/commands/commands.dart';
import 'package:shorebird_cli/src/version.dart';
import 'package:test/test.dart';
class _MockLogger extends Mock implements Logger {}
class _MockProcessResult extends Mock implements ProcessResult {}
class _MockProgress extends Mock implements Progress {}
class _MockPubUpdater extends Mock implements PubUpdater {}
void main() {
const latestVersion = '0.0.0';
group('update', () {
late PubUpdater pubUpdater;
late Logger logger;
late ProcessResult processResult;
late UpdateCommand command;
setUp(() {
final progress = _MockProgress();
final progressLogs = <String>[];
pubUpdater = _MockPubUpdater();
logger = _MockLogger();
processResult = _MockProcessResult();
command = UpdateCommand(
logger: logger,
pubUpdater: pubUpdater,
);
when(
() => pubUpdater.getLatestVersion(any()),
).thenAnswer((_) async => packageVersion);
when(
() => pubUpdater.update(
packageName: packageName,
versionConstraint: latestVersion,
),
).thenAnswer((_) async => processResult);
when(
() => pubUpdater.isUpToDate(
packageName: any(named: 'packageName'),
currentVersion: any(named: 'currentVersion'),
),
).thenAnswer((_) async => true);
when(() => progress.complete(any())).thenAnswer((_) {
final message = _.positionalArguments.elementAt(0) as String?;
if (message != null) progressLogs.add(message);
});
when(() => logger.progress(any())).thenReturn(progress);
when(() => processResult.exitCode).thenReturn(ExitCode.success.code);
});
test('can be instantiated without a pub updater', () {
final command = UpdateCommand(logger: logger);
expect(command, isNotNull);
});
test(
'handles pub latest version query errors',
() async {
when(
() => pubUpdater.getLatestVersion(any()),
).thenThrow(Exception('oops'));
final result = await command.run();
expect(result, equals(ExitCode.software.code));
verify(() => logger.progress('Checking for updates')).called(1);
verify(() => logger.err('Exception: oops'));
verifyNever(
() => pubUpdater.update(
packageName: any(named: 'packageName'),
versionConstraint: any(named: 'versionConstraint'),
),
);
},
);
test(
'handles pub update errors',
() async {
when(
() => pubUpdater.getLatestVersion(any()),
).thenAnswer((_) async => latestVersion);
when(
() => pubUpdater.update(
packageName: any(named: 'packageName'),
versionConstraint: any(named: 'versionConstraint'),
),
).thenThrow(Exception('oops'));
final result = await command.run();
expect(result, equals(ExitCode.software.code));
verify(() => logger.progress('Checking for updates')).called(1);
verify(() => logger.err('Exception: oops'));
verify(
() => pubUpdater.update(
packageName: any(named: 'packageName'),
versionConstraint: any(named: 'versionConstraint'),
),
).called(1);
},
);
test('handles pub update process errors', () async {
const error = 'Oh no! Installing this is not possible right now!';
when(() => processResult.exitCode).thenReturn(1);
when<dynamic>(() => processResult.stderr).thenReturn(error);
when(
() => pubUpdater.getLatestVersion(any()),
).thenAnswer((_) async => latestVersion);
when(
() => pubUpdater.update(
packageName: any(named: 'packageName'),
versionConstraint: any(named: 'versionConstraint'),
),
).thenAnswer((_) async => processResult);
final result = await command.run();
expect(result, equals(ExitCode.software.code));
verify(() => logger.progress('Checking for updates')).called(1);
verify(() => logger.err('Error updating CLI: $error'));
verify(
() => pubUpdater.update(
packageName: any(named: 'packageName'),
versionConstraint: any(named: 'versionConstraint'),
),
).called(1);
});
test(
'updates when newer version exists',
() async {
when(
() => pubUpdater.getLatestVersion(any()),
).thenAnswer((_) async => latestVersion);
when(
() => pubUpdater.update(
packageName: any(named: 'packageName'),
versionConstraint: any(named: 'versionConstraint'),
),
).thenAnswer((_) async => processResult);
when(() => logger.progress(any())).thenReturn(_MockProgress());
final result = await command.run();
expect(result, equals(ExitCode.success.code));
verify(() => logger.progress('Checking for updates')).called(1);
verify(() => logger.progress('Updating to $latestVersion')).called(1);
verify(
() => pubUpdater.update(
packageName: packageName,
versionConstraint: latestVersion,
),
).called(1);
},
);
test(
'does not update when already on latest version',
() async {
when(
() => pubUpdater.getLatestVersion(any()),
).thenAnswer((_) async => packageVersion);
when(() => logger.progress(any())).thenReturn(_MockProgress());
final result = await command.run();
expect(result, equals(ExitCode.success.code));
verify(
() => logger.info('CLI is already at the latest version.'),
).called(1);
verifyNever(() => logger.progress('Updating to $latestVersion'));
verifyNever(
() => pubUpdater.update(
packageName: any(named: 'packageName'),
versionConstraint: any(named: 'versionConstraint'),
),
);
},
);
});
}
@@ -0,0 +1,163 @@
import 'dart:io';
import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shorebird_cli/src/commands/commands.dart';
import 'package:test/test.dart';
class _MockLogger extends Mock implements Logger {}
class _MockProcessResult extends Mock implements ProcessResult {}
class _MockProgress extends Mock implements Progress {}
void main() {
const currentShorebirdRevision = 'revision-1';
const newerShorebirdRevision = 'revision-2';
group('upgrade', () {
late Logger logger;
late ProcessResult fetchCurrentVersionResult;
late ProcessResult fetchTagsResult;
late ProcessResult fetchLatestVersionResult;
late ProcessResult hardResetResult;
late UpgradeCommand command;
setUp(() {
final progress = _MockProgress();
final progressLogs = <String>[];
logger = _MockLogger();
fetchCurrentVersionResult = _MockProcessResult();
fetchTagsResult = _MockProcessResult();
fetchLatestVersionResult = _MockProcessResult();
hardResetResult = _MockProcessResult();
command = UpgradeCommand(
logger: logger,
runProcess: (
executable,
arguments, {
bool runInShell = false,
workingDirectory,
}) async {
if (executable == 'git') {
const revParseHead = ['rev-parse', '--verify', 'HEAD'];
if (arguments.every((arg) => revParseHead.contains(arg))) {
return fetchCurrentVersionResult;
}
const fetchTags = ['fetch', '--tags'];
if (arguments.every((arg) => fetchTags.contains(arg))) {
return fetchTagsResult;
}
const revParseUpstream = ['rev-parse', '--verify', '@{upstream}'];
if (arguments.every((arg) => revParseUpstream.contains(arg))) {
return fetchLatestVersionResult;
}
const resetHard = ['reset', '--hard', newerShorebirdRevision];
if (arguments.every((arg) => resetHard.contains(arg))) {
return hardResetResult;
}
}
return _MockProcessResult();
},
);
when(
() => fetchCurrentVersionResult.exitCode,
).thenReturn(ExitCode.success.code);
when(
() => fetchCurrentVersionResult.stdout,
).thenReturn(currentShorebirdRevision);
when(
() => fetchLatestVersionResult.exitCode,
).thenReturn(ExitCode.success.code);
when(
() => fetchLatestVersionResult.stdout,
).thenReturn(currentShorebirdRevision);
when(() => hardResetResult.exitCode).thenReturn(ExitCode.success.code);
when(() => progress.complete(any())).thenAnswer((_) {
final message = _.positionalArguments.elementAt(0) as String?;
if (message != null) progressLogs.add(message);
});
when(() => logger.progress(any())).thenReturn(progress);
});
test('can be instantiated', () {
final command = UpgradeCommand(logger: logger);
expect(command, isNotNull);
});
test(
'handles errors when determining the current version',
() async {
const errorMessage = 'oops';
when(() => fetchCurrentVersionResult.exitCode).thenReturn(1);
when(() => fetchCurrentVersionResult.stderr).thenReturn(errorMessage);
final result = await command.run();
expect(result, equals(ExitCode.software.code));
verify(() => logger.progress('Checking for updates')).called(1);
verify(
() => logger.err('Fetching current version failed: $errorMessage'),
).called(1);
},
);
test(
'handles errors when determining the latest version',
() async {
const errorMessage = 'oops';
when(() => fetchLatestVersionResult.exitCode).thenReturn(1);
when(() => fetchLatestVersionResult.stderr).thenReturn(errorMessage);
final result = await command.run();
expect(result, equals(ExitCode.software.code));
verify(() => logger.progress('Checking for updates')).called(1);
verify(() => logger.err('Checking for updates failed: oops')).called(1);
},
);
test(
'handles errors when updating',
() async {
const errorMessage = 'oops';
when(
() => fetchLatestVersionResult.stdout,
).thenReturn(newerShorebirdRevision);
when(() => hardResetResult.exitCode).thenReturn(1);
when(() => hardResetResult.stderr).thenReturn(errorMessage);
final result = await command.run();
expect(result, equals(ExitCode.software.code));
verify(() => logger.progress('Checking for updates')).called(1);
verify(() => logger.err('Updating failed: oops')).called(1);
},
);
test(
'updates when newer version exists',
() async {
when(
() => fetchLatestVersionResult.stdout,
).thenReturn(newerShorebirdRevision);
when(() => logger.progress(any())).thenReturn(_MockProgress());
final result = await command.run();
expect(result, equals(ExitCode.success.code));
verify(() => logger.progress('Checking for updates')).called(1);
verify(() => logger.progress('Updating')).called(1);
},
);
test(
'does not update when already on latest version',
() async {
when(() => logger.progress(any())).thenReturn(_MockProgress());
final result = await command.run();
expect(result, equals(ExitCode.success.code));
verify(
() => logger.info('Shorebird is already at the latest version.'),
).called(1);
},
);
});
}