feat(shorebird_cli): shorebird create command (#3175)

This commit is contained in:
Felix Angelov
2025-06-16 15:41:44 -05:00
committed by GitHub
parent a973c67a78
commit 10c11eb53a
8 changed files with 175 additions and 3 deletions
@@ -1,4 +1,5 @@
export 'cache/cache.dart';
export 'create.dart';
export 'doctor_command.dart';
export 'flutter/flutter.dart';
export 'init_command.dart';
@@ -0,0 +1,41 @@
import 'dart:async';
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/shorebird_command.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
/// {@template shorebird_create_command}
/// `shorebird create`
/// Create a new Flutter app with Shorebird.
/// {@endtemplate}
class CreateCommand extends ShorebirdProxyCommand {
@override
String get name => 'create';
@override
String get description => 'Create a new Flutter project with Shorebird.';
@override
Future<int> run() async {
final createExitCode = await process.stream('flutter', [
'create',
...results.rest,
]);
if (createExitCode != ExitCode.success.code) return createExitCode;
return runScoped(
() => runner!.run(['init']),
values: {
shorebirdEnvRef.overrideWith(
() => ShorebirdEnv(
flutterProjectRootOverride: p.absolute(
p.normalize(results.rest.first),
),
),
),
},
);
}
}
@@ -69,6 +69,7 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner<int> {
);
addCommand(CacheCommand());
addCommand(CreateCommand());
addCommand(DoctorCommand());
addCommand(FlutterCommand());
addCommand(InitCommand());
@@ -34,13 +34,17 @@ abstract class ShorebirdCommand extends Command<int> {
// coverage:ignore-start
@override
ShorebirdCliCommandRunner? get runner =>
super.runner as ShorebirdCliCommandRunner?;
testRunner ?? super.runner as ShorebirdCliCommandRunner?;
// coverage:ignore-end
/// [ArgResults] used for testing purposes only.
@visibleForTesting
ArgResults? testArgResults;
/// The parent command runner used for testing purposes only.
@visibleForTesting
ShorebirdCliCommandRunner? testRunner;
/// [ArgResults] for the current command.
ArgResults get results => testArgResults ?? argResults!;
}
@@ -22,8 +22,11 @@ ShorebirdEnv get shorebirdEnv => read(shorebirdEnvRef);
/// {@endtemplate}
class ShorebirdEnv {
/// {@macro shorebird_env}
const ShorebirdEnv({String? flutterRevisionOverride})
: _flutterRevisionOverride = flutterRevisionOverride;
const ShorebirdEnv({
String? flutterRevisionOverride,
String? flutterProjectRootOverride,
}) : _flutterRevisionOverride = flutterRevisionOverride,
_flutterProjectRootOverride = flutterProjectRootOverride;
/// Copy the [ShorebirdEnv] and optionally override the flutter revision.
ShorebirdEnv copyWith({String? flutterRevisionOverride}) => ShorebirdEnv(
@@ -32,6 +35,7 @@ class ShorebirdEnv {
);
final String? _flutterRevisionOverride;
final String? _flutterProjectRootOverride;
/// The application config directory for the Shorebird CLI.
Directory get configDirectory {
@@ -156,6 +160,9 @@ class ShorebirdEnv {
/// Returns the root directory of the nearest Flutter project.
Directory? getFlutterProjectRoot() {
if (_flutterProjectRootOverride != null) {
return Directory(_flutterProjectRootOverride);
}
final file = findNearestAncestor(
where: (path) => getPubspecYamlFile(cwd: Directory(path)),
);
@@ -0,0 +1,97 @@
import 'package:args/args.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p;
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/commands/commands.dart';
import 'package:shorebird_cli/src/shorebird_cli_command_runner.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
import 'package:test/test.dart';
import '../mocks.dart';
void main() {
group(CreateCommand, () {
const args = ['my_app'];
late ShorebirdProcess process;
late ArgResults argResults;
late ShorebirdCliCommandRunner runner;
late CreateCommand command;
R runWithOverrides<R>(R Function() body) {
return runScoped(body, values: {processRef.overrideWith(() => process)});
}
setUp(() {
argResults = MockArgResults();
process = MockShorebirdProcess();
runner = MockShorebirdCliCommandRunner();
command = runWithOverrides(CreateCommand.new)
..testArgResults = argResults
..testRunner = runner;
when(() => argResults.rest).thenReturn(args);
when(
() => runner.run(any()),
).thenAnswer((_) async => ExitCode.success.code);
when(
() => process.stream('flutter', ['create', ...args]),
).thenAnswer((_) async => ExitCode.success.code);
});
test('has correct name and description', () {
expect(command.name, equals('create'));
expect(
command.description,
equals('Create a new Flutter project with Shorebird.'),
);
});
test('runs the `flutter create` command', () async {
await expectLater(
runWithOverrides(command.run),
completion(equals(ExitCode.success.code)),
);
verify(() => process.stream('flutter', ['create', ...args])).called(1);
});
test('runs the shorebird init command', () async {
when(() => runner.run(any())).thenAnswer((invocation) async {
final runnerArgs = invocation.positionalArguments.first as List;
if (runnerArgs.first == 'init') {
expect(
p.basename(shorebirdEnv.getFlutterProjectRoot()!.path),
args.first,
);
}
return ExitCode.success.code;
});
await expectLater(
runWithOverrides(command.run),
completion(equals(ExitCode.success.code)),
);
verify(() => runner.run(['init'])).called(1);
});
group('when flutter create fails', () {
setUp(() {
when(
() => process.stream('flutter', ['create', ...args]),
).thenAnswer((_) async => 1);
});
test('exits', () async {
await expectLater(
runWithOverrides(command.run),
completion(equals(1)),
);
verify(() => process.stream('flutter', ['create', ...args])).called(1);
verifyNever(() => runner.run(any()));
});
});
});
}
@@ -33,6 +33,7 @@ import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/pubspec_editor.dart';
import 'package:shorebird_cli/src/shorebird_android_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_cli_command_runner.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
@@ -160,6 +161,9 @@ class MockShorebirdAndroidArtifacts extends Mock
class MockShorebirdArtifacts extends Mock implements ShorebirdArtifacts {}
class MockShorebirdCliCommandRunner extends Mock
implements ShorebirdCliCommandRunner {}
class MockShorebirdEnv extends Mock implements ShorebirdEnv {}
class MockShorebirdFlutter extends Mock implements ShorebirdFlutter {}
@@ -94,6 +94,23 @@ void main() {
});
group('getFlutterProjectRoot', () {
test('uses override when provided', () {
final tempDir = Directory.systemTemp.createTempSync();
final overridePubspec = File(
p.join(tempDir.path, 'override', 'pubspec.yaml'),
);
final override = overridePubspec.parent.path;
File(p.join(tempDir.path, 'pubspec.yaml')).createSync(recursive: true);
expect(
runWithOverrides(
() => ShorebirdEnv(
flutterProjectRootOverride: override,
).getFlutterProjectRoot(),
),
isA<Directory>().having((d) => d.path, 'absolute', override),
);
});
test('returns null when no Flutter project exists', () {
final tempDir = Directory.systemTemp.createTempSync();
expect(