diff --git a/apps/cli/fixtures/fixtures_test.dart b/apps/cli/fixtures/fixtures_test.dart index 48f8f7de9..674de2a11 100644 --- a/apps/cli/fixtures/fixtures_test.dart +++ b/apps/cli/fixtures/fixtures_test.dart @@ -14,6 +14,7 @@ import 'package:celest/src/runtime/serve.dart'; import 'package:celest_ast/celest_ast.dart' as ast; import 'package:celest_cli/src/analyzer/analysis_result.dart'; import 'package:celest_cli/src/analyzer/celest_analyzer.dart'; +import 'package:celest_cli/src/cli/stop_signal.dart'; import 'package:celest_cli/src/codegen/client_code_generator.dart'; import 'package:celest_cli/src/codegen/cloud_code_generator.dart'; import 'package:celest_cli/src/compiler/api/local_api_runner.dart'; @@ -407,7 +408,7 @@ class TestRunner { expect(errors, isEmpty); expect(project, isNotNull); - final frontend = CelestFrontend(); + final frontend = CelestFrontend(stopSignal: StopSignal()); final buildDir = fileSystem.directory(projectPaths.buildDir); if (buildDir.existsSync()) { buildDir.deleteSync(recursive: true); diff --git a/apps/cli/fixtures/standalone/api/pubspec.yaml b/apps/cli/fixtures/standalone/api/pubspec.yaml index 4d9105936..0956630b9 100644 --- a/apps/cli/fixtures/standalone/api/pubspec.yaml +++ b/apps/cli/fixtures/standalone/api/pubspec.yaml @@ -25,6 +25,8 @@ dependency_overrides: path: ../../../../../packages/celest_cloud celest_cloud_auth: path: ../../../../../services/celest_cloud_auth + celest_cloud_core: + path: ../../../../../services/celest_cloud_core celest_core: path: ../../../../../packages/celest_core diff --git a/apps/cli/fixtures/standalone/auth/pubspec.yaml b/apps/cli/fixtures/standalone/auth/pubspec.yaml index bed677b5a..abb59ad39 100644 --- a/apps/cli/fixtures/standalone/auth/pubspec.yaml +++ b/apps/cli/fixtures/standalone/auth/pubspec.yaml @@ -26,6 +26,8 @@ dependency_overrides: path: ../../../../../packages/celest_cloud celest_cloud_auth: path: ../../../../../services/celest_cloud_auth + celest_cloud_core: + path: ../../../../../services/celest_cloud_core celest_core: path: ../../../../../packages/celest_core diff --git a/apps/cli/fixtures/standalone/data/pubspec.yaml b/apps/cli/fixtures/standalone/data/pubspec.yaml index cccc7b9b8..56ad6a791 100644 --- a/apps/cli/fixtures/standalone/data/pubspec.yaml +++ b/apps/cli/fixtures/standalone/data/pubspec.yaml @@ -23,6 +23,8 @@ dependency_overrides: path: ../../../../../packages/celest_cloud celest_cloud_auth: path: ../../../../../services/celest_cloud_auth + celest_cloud_core: + path: ../../../../../services/celest_cloud_core celest_core: path: ../../../../../packages/celest_core diff --git a/apps/cli/fixtures/standalone/env_vars/pubspec.yaml b/apps/cli/fixtures/standalone/env_vars/pubspec.yaml index 2c4665043..3981f043c 100644 --- a/apps/cli/fixtures/standalone/env_vars/pubspec.yaml +++ b/apps/cli/fixtures/standalone/env_vars/pubspec.yaml @@ -20,6 +20,8 @@ dependency_overrides: path: ../../../../../packages/celest_cloud celest_cloud_auth: path: ../../../../../services/celest_cloud_auth + celest_cloud_core: + path: ../../../../../services/celest_cloud_core celest_core: path: ../../../../../packages/celest_core dev_dependencies: diff --git a/apps/cli/fixtures/standalone/exceptions/pubspec.yaml b/apps/cli/fixtures/standalone/exceptions/pubspec.yaml index ed1359cf8..d62ed65e8 100644 --- a/apps/cli/fixtures/standalone/exceptions/pubspec.yaml +++ b/apps/cli/fixtures/standalone/exceptions/pubspec.yaml @@ -22,6 +22,8 @@ dependency_overrides: path: ../../../../../packages/celest_cloud celest_cloud_auth: path: ../../../../../services/celest_cloud_auth + celest_cloud_core: + path: ../../../../../services/celest_cloud_core celest_core: path: ../../../../../packages/celest_core diff --git a/apps/cli/fixtures/standalone/flutter/pubspec.yaml b/apps/cli/fixtures/standalone/flutter/pubspec.yaml index 2be7f58e5..e5c9a522d 100644 --- a/apps/cli/fixtures/standalone/flutter/pubspec.yaml +++ b/apps/cli/fixtures/standalone/flutter/pubspec.yaml @@ -22,6 +22,8 @@ dependency_overrides: path: ../../../../../packages/celest_cloud celest_cloud_auth: path: ../../../../../services/celest_cloud_auth + celest_cloud_core: + path: ../../../../../services/celest_cloud_core celest_core: path: ../../../../../packages/celest_core celest: diff --git a/apps/cli/fixtures/standalone/http/pubspec.yaml b/apps/cli/fixtures/standalone/http/pubspec.yaml index ed1359cf8..d62ed65e8 100644 --- a/apps/cli/fixtures/standalone/http/pubspec.yaml +++ b/apps/cli/fixtures/standalone/http/pubspec.yaml @@ -22,6 +22,8 @@ dependency_overrides: path: ../../../../../packages/celest_cloud celest_cloud_auth: path: ../../../../../services/celest_cloud_auth + celest_cloud_core: + path: ../../../../../services/celest_cloud_core celest_core: path: ../../../../../packages/celest_core diff --git a/apps/cli/fixtures/standalone/marcelo/pubspec.yaml b/apps/cli/fixtures/standalone/marcelo/pubspec.yaml index 1fe7b3643..e870f18be 100644 --- a/apps/cli/fixtures/standalone/marcelo/pubspec.yaml +++ b/apps/cli/fixtures/standalone/marcelo/pubspec.yaml @@ -22,6 +22,8 @@ dependency_overrides: path: ../../../../../packages/celest_cloud celest_cloud_auth: path: ../../../../../services/celest_cloud_auth + celest_cloud_core: + path: ../../../../../services/celest_cloud_core celest_core: path: ../../../../../packages/celest_core dev_dependencies: diff --git a/apps/cli/fixtures/standalone/streaming/pubspec.yaml b/apps/cli/fixtures/standalone/streaming/pubspec.yaml index ed1359cf8..d62ed65e8 100644 --- a/apps/cli/fixtures/standalone/streaming/pubspec.yaml +++ b/apps/cli/fixtures/standalone/streaming/pubspec.yaml @@ -22,6 +22,8 @@ dependency_overrides: path: ../../../../../packages/celest_cloud celest_cloud_auth: path: ../../../../../services/celest_cloud_auth + celest_cloud_core: + path: ../../../../../services/celest_cloud_core celest_core: path: ../../../../../packages/celest_core diff --git a/apps/cli/lib/src/cli/stop_signal.dart b/apps/cli/lib/src/cli/stop_signal.dart new file mode 100644 index 000000000..eebe48388 --- /dev/null +++ b/apps/cli/lib/src/cli/stop_signal.dart @@ -0,0 +1,28 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:celest_cli/src/exceptions.dart'; + +/// {@template celest.cli.stop_signal} +/// Signals that a `SIGINT` or `SIGTERM` event has fired and the CLI needs to +/// shutdown. +/// {@endtemplate} +extension type StopSignal._(Completer _it) { + factory StopSignal() => StopSignal._(Completer.sync()); + + Future get future => _it.future; + + void complete(ProcessSignal signal) => _it.complete(signal); + + /// Whether a SIGINT or SIGTERM signal has been received and the frontend + /// is no longer operational. + bool get isStopped => _it.isCompleted; + + /// Checks if the signal has been received and throws a [CancellationException] + /// if it has. + void check() { + if (isStopped) { + throw const CancellationException('Celest was interrupted'); + } + } +} diff --git a/apps/cli/lib/src/commands/celest_command.dart b/apps/cli/lib/src/commands/celest_command.dart index cc7ab1e9e..276999db4 100644 --- a/apps/cli/lib/src/commands/celest_command.dart +++ b/apps/cli/lib/src/commands/celest_command.dart @@ -3,7 +3,9 @@ import 'dart:convert'; import 'dart:io'; import 'package:args/command_runner.dart'; +import 'package:async/async.dart'; import 'package:celest_cli/src/cli/cli_runtime.dart'; +import 'package:celest_cli/src/cli/stop_signal.dart'; import 'package:celest_cli/src/commands/auth/auth_command.dart'; import 'package:celest_cli/src/context.dart'; import 'package:celest_cli/src/models.dart'; @@ -19,6 +21,22 @@ import 'package:pub_semver/pub_semver.dart'; /// Base class for all commands in this package providing common functionality. abstract base class CelestCommand extends Command { + CelestCommand() { + // Initialize immediately instead of lazily since _stopSub is never accessed + // directly until `close`. + _stopSub = StreamGroup.merge([ + ProcessSignal.sigint.watch(), + // SIGTERM is not supported on Windows. Attempting to register a SIGTERM + // handler raises an exception. + if (!Platform.isWindows) ProcessSignal.sigterm.watch(), + ]).listen((signal) { + logger.fine('Got exit signal: $signal'); + if (!stopSignal.isStopped) { + stopSignal.complete(signal); + } + }); + } + late final Logger logger = Logger(name); /// The version of the CLI. @@ -115,6 +133,13 @@ abstract base class CelestCommand extends Command { )!; } + /// {@macro celest.cli.stop_signal} + final stopSignal = StopSignal(); + + /// Subscription to [ProcessSignal.sigint] and [ProcessSignal.sigterm] which + /// forwards to [stopSignal] when triggered. + late final StreamSubscription _stopSub; + @override @mustCallSuper Future run() async { @@ -147,6 +172,7 @@ abstract base class CelestCommand extends Command { @mustCallSuper Future close() async { await Future.wait([ + _stopSub.cancel(), for (final deferred in _deferred) Future.value(deferred()), ]); } diff --git a/apps/cli/lib/src/commands/project/build_command.dart b/apps/cli/lib/src/commands/project/build_command.dart index 61e2203f7..db21e4093 100644 --- a/apps/cli/lib/src/commands/project/build_command.dart +++ b/apps/cli/lib/src/commands/project/build_command.dart @@ -26,7 +26,7 @@ final class BuildCommand extends CelestCommand final needsMigration = await configure(); - return CelestFrontend().build( + return CelestFrontend(stopSignal: stopSignal).build( migrateProject: needsMigration, currentProgress: cliLogger.progress('Building project'), environmentId: 'production', // TODO(dnys1): Allow setting environment diff --git a/apps/cli/lib/src/commands/project/deploy_command.dart b/apps/cli/lib/src/commands/project/deploy_command.dart index c00921898..b89136759 100644 --- a/apps/cli/lib/src/commands/project/deploy_command.dart +++ b/apps/cli/lib/src/commands/project/deploy_command.dart @@ -29,6 +29,7 @@ final class DeployCommand extends CelestCommand return code; } - return CelestFrontend().deploy(migrateProject: needsMigration); + return CelestFrontend(stopSignal: stopSignal) + .deploy(migrateProject: needsMigration); } } diff --git a/apps/cli/lib/src/commands/project/init_command.dart b/apps/cli/lib/src/commands/project/init_command.dart index 7d28328d6..77bc1279d 100644 --- a/apps/cli/lib/src/commands/project/init_command.dart +++ b/apps/cli/lib/src/commands/project/init_command.dart @@ -1,20 +1,16 @@ import 'dart:io'; -import 'package:celest_cli/src/cli/cli_runtime.dart'; +import 'package:celest_cli/src/commands/auth/authenticate.dart'; import 'package:celest_cli/src/commands/celest_command.dart'; import 'package:celest_cli/src/context.dart'; +import 'package:celest_cli/src/init/project_creator.dart'; import 'package:celest_cli/src/init/project_init.dart'; +import 'package:celest_cli/src/repositories/cloud_repository.dart'; import 'package:mason_logger/mason_logger.dart'; -final class InitCommand extends CelestCommand with Configure, ProjectCreator { +final class InitCommand extends CelestCommand + with Configure, ProjectCreator, Authenticate, CloudRepository { InitCommand() { - argParser.addFlag( - 'precache', - help: 'Precache assets and warm up analyzer in the background.', - negatable: true, - hide: true, - defaultsTo: true, - ); argParser.addOption( 'template', abbr: 't', @@ -26,6 +22,10 @@ final class InitCommand extends CelestCommand with Configure, ProjectCreator { }, defaultsTo: 'hello', ); + argParser.addOption( + 'name', + help: 'The project name.', + ); } @override @@ -43,47 +43,8 @@ final class InitCommand extends CelestCommand with Configure, ProjectCreator { @override late final String template = argResults!.option('template')!; - /// Precache assets in the background. - Future _precacheInBackground() async { - final command = switch (CliRuntime.current) { - CliRuntime.pubGlobal => [ - platform.resolvedExecutable, - 'pub', - 'global', - 'run', - 'celest_cli:celest', - 'precache', - projectPaths.projectRoot, - if (verbose) '--verbose', - ], - CliRuntime.local => [ - platform.resolvedExecutable, - platform.script.toFilePath(), - 'precache', - projectPaths.projectRoot, - if (verbose) '--verbose', - ], - CliRuntime.aot => [ - platform.resolvedExecutable, - 'precache', - projectPaths.projectRoot, - if (verbose) '--verbose', - ], - }; - try { - logger.fine('Precaching assets in background...'); - await processManager.start( - command, - mode: ProcessStartMode.detached, - workingDirectory: projectPaths.projectRoot, - ); - } on Object catch (e, st) { - logger.fine('Failed to precache assets', e, st); - performance.captureError(e, stackTrace: st, extra: {'command': command}); - } - } - - bool get precache => argResults!.flag('precache'); + @override + String? get projectName => argResults!.option('name'); @override Future run() async { @@ -91,9 +52,6 @@ final class InitCommand extends CelestCommand with Configure, ProjectCreator { await checkForLatestVersion(); await configure(); - if (precache) { - await _precacheInBackground(); - } final projectRoot = projectPaths.projectRoot; diff --git a/apps/cli/lib/src/commands/project/start_command.dart b/apps/cli/lib/src/commands/project/start_command.dart index 445885dd3..126203750 100644 --- a/apps/cli/lib/src/commands/project/start_command.dart +++ b/apps/cli/lib/src/commands/project/start_command.dart @@ -2,6 +2,7 @@ import 'package:celest_cli/src/commands/celest_command.dart'; import 'package:celest_cli/src/context.dart'; import 'package:celest_cli/src/frontend/celest_frontend.dart'; import 'package:celest_cli/src/frontend/child_process.dart'; +import 'package:celest_cli/src/init/project_creator.dart'; import 'package:celest_cli/src/init/project_init.dart'; import 'package:celest_cli/src/init/project_migrate.dart'; import 'package:mason_logger/mason_logger.dart'; @@ -53,7 +54,7 @@ final class StartCommand extends CelestCommand } // Start the Celest Frontend Loop - return CelestFrontend().run( + return CelestFrontend(stopSignal: stopSignal).run( migrateProject: needsMigration, currentProgress: currentProgress, childProcess: childProcess, diff --git a/apps/cli/lib/src/frontend/celest_frontend.dart b/apps/cli/lib/src/frontend/celest_frontend.dart index 4633262a7..c1c6d71f6 100644 --- a/apps/cli/lib/src/frontend/celest_frontend.dart +++ b/apps/cli/lib/src/frontend/celest_frontend.dart @@ -12,6 +12,7 @@ import 'package:celest_cli/src/analyzer/analysis_error.dart'; import 'package:celest_cli/src/analyzer/analysis_result.dart'; import 'package:celest_cli/src/analyzer/celest_analyzer.dart'; import 'package:celest_cli/src/ast/project_diff.dart'; +import 'package:celest_cli/src/cli/stop_signal.dart'; import 'package:celest_cli/src/codegen/api/dockerfile_generator.dart'; import 'package:celest_cli/src/codegen/client_code_generator.dart'; import 'package:celest_cli/src/codegen/cloud_code_generator.dart'; @@ -24,9 +25,7 @@ import 'package:celest_cli/src/exceptions.dart'; import 'package:celest_cli/src/frontend/child_process.dart'; import 'package:celest_cli/src/project/celest_project.dart'; import 'package:celest_cli/src/project/project_linker.dart'; -import 'package:celest_cli/src/repositories/organization_repository.dart'; -import 'package:celest_cli/src/repositories/project_environment_repository.dart'; -import 'package:celest_cli/src/repositories/project_repository.dart'; +import 'package:celest_cli/src/repositories/cloud_repository.dart'; import 'package:celest_cli/src/sdk/dart_sdk.dart'; import 'package:celest_cli/src/utils/json.dart'; import 'package:celest_cli/src/utils/process.dart'; @@ -40,21 +39,10 @@ import 'package:watcher/watcher.dart'; enum RestartMode { hotReload, fullRestart } -final class CelestFrontend { - CelestFrontend() { - // Initialize immediately instead of lazily since _stopSub is never accessed - // directly until `close`. - _stopSub = StreamGroup.merge([ - ProcessSignal.sigint.watch(), - // SIGTERM is not supported on Windows. Attempting to register a SIGTERM - // handler raises an exception. - if (!io.Platform.isWindows) ProcessSignal.sigterm.watch(), - ]).listen((signal) { - logger.fine('Got exit signal: $signal'); - if (!_stopSignal.isCompleted) { - _stopSignal.complete(signal); - } - }); +final class CelestFrontend with CloudRepository { + CelestFrontend({ + required this.stopSignal, + }) { // Windows doesn't support listening for SIGUSR1 and SIGUSR2 signals. if (!platform.isWindows) { _reloadStream = StreamQueue( @@ -67,7 +55,8 @@ final class CelestFrontend { } static CelestFrontend? instance; - static final Logger logger = Logger('CelestFrontend'); + @override + final Logger logger = Logger('CelestFrontend'); final CelestAnalyzer analyzer = CelestAnalyzer(); int _logErrors(List errors) { @@ -83,6 +72,10 @@ final class CelestFrontend { } } + /// {@macro celest.cli.stop_signal} + @override + final StopSignal stopSignal; + /// Signals that Celest is ready for the next batch of [_watcherSub] changes. /// /// Used to buffer [_watcherSub] in the time while a project is being (re-)built. @@ -264,7 +257,7 @@ final class CelestFrontend { ); } - await Future.any([reloadComplete.future, _stopSignal.future]); + await Future.any([reloadComplete.future, stopSignal.future]); await _cancelPendingOperations(); } @@ -300,18 +293,6 @@ final class CelestFrontend { return p.isWithin(projectPaths.projectLib, path); } - /// Signals that a SIGINT or SIGTERM event has fired and the CLI needs to - /// shutdown. - final _stopSignal = Completer.sync(); - - /// Whether a SIGINT or SIGTERM signal has been received and the frontend - /// is no longer operational. - bool get stopped => _stopSignal.isCompleted; - - /// Subscription to [ProcessSignal.sigint] and [ProcessSignal.sigterm] which - /// forwards to [_stopSignal] when triggered. - late final StreamSubscription _stopSub; - /// A broadcast stream of [ProcessSignal.sigusr1] and [ProcessSignal.sigusr2] /// signals which triggers a hot reload. /// @@ -328,20 +309,13 @@ final class CelestFrontend { /// analyzed yet. ast.Project? currentProject; - ProjectRepository get projects => ProjectRepository(cloud); - - ProjectEnvironmentRepository get projectEnvironments => - ProjectEnvironmentRepository(cloud); - - OrganizationRepository get organizations => OrganizationRepository(cloud); - Future run({ required bool migrateProject, required Progress? currentProgress, ChildProcess? childProcess, }) async { try { - while (!stopped) { + while (!stopSignal.isStopped) { if (celestProject.usesBuildRunner) { _buildRunner ??= await _buildRunnerWatch(); } @@ -467,7 +441,7 @@ final class CelestFrontend { }, ); unawaited( - _stopSignal.future.then(childProcess.stop), + stopSignal.future.then(childProcess.stop), ); } } @@ -537,7 +511,7 @@ final class CelestFrontend { running: 'Creating', completed: 'created', ), - cancelTrigger: _stopSignal.future, + cancelTrigger: stopSignal.future, resource: pb.Organization(), ); logger.fine('Created organization: $organization'); @@ -585,7 +559,7 @@ final class CelestFrontend { ); final project = await waiter.run( verbs: const (run: 'create', running: 'Creating', completed: 'created'), - cancelTrigger: _stopSignal.future, + cancelTrigger: stopSignal.future, resource: pb.Project(), ); logger.fine('Created project: $project'); @@ -676,7 +650,7 @@ final class CelestFrontend { Progress? currentProgress; try { var projectId = await _loadProjectId(); - while (!stopped) { + while (!stopSignal.isStopped) { if (projectId != null) { currentProgress ??= cliLogger.progress('🔥 Warming up the engines'); } @@ -786,9 +760,7 @@ final class CelestFrontend { final result = await analyzer.analyzeProject( migrateProject: migrateProject, ); - if (stopped) { - throw const CancellationException('Celest was stopped'); - } + stopSignal.check(); return result; }); @@ -807,9 +779,7 @@ final class CelestFrontend { ); final outputs = codeGenerator.generate(); await (outputs.write(), celestProject.invalidate(outputs.keys)).wait; - if (stopped) { - throw const CancellationException('Celest was stopped'); - } + stopSignal.check(); return codeGenerator.fileOutputs.keys.toList(); }); @@ -957,9 +927,7 @@ final class CelestFrontend { }); } } - if (stopped) { - throw const CancellationException('Celest was stopped'); - } + stopSignal.check(); return Uri.parse('http://localhost:${_localApiRunner!.port}'); } @@ -995,7 +963,7 @@ final class CelestFrontend { ); final cloudEnvironment = await waiter.run( verbs: const (run: 'create', running: 'Creating', completed: 'created'), - cancelTrigger: _stopSignal.future, + cancelTrigger: stopSignal.future, resource: pb.ProjectEnvironment(), ); logger.fine('Created production environment: $cloudEnvironment'); @@ -1070,7 +1038,7 @@ final class CelestFrontend { running: 'Deploying', completed: 'deployed', ), - cancelTrigger: _stopSignal.future, + cancelTrigger: stopSignal.future, resource: pb.DeployProjectEnvironmentResponse(), ); final deployedProject = @@ -1109,7 +1077,6 @@ final class CelestFrontend { // Cancel subscriptions in order of dependencies await _readyForChanges.close(); await Future.wait([ - _stopSub.cancel(), Future.value(_reloadStream?.cancel()), Future.value(_watcherSub?.cancel()), Future.value(_localApiRunner?.close()), diff --git a/apps/cli/lib/src/init/project_creator.dart b/apps/cli/lib/src/init/project_creator.dart new file mode 100644 index 000000000..720acb2f1 --- /dev/null +++ b/apps/cli/lib/src/init/project_creator.dart @@ -0,0 +1,40 @@ +import 'package:celest_cli/src/context.dart'; +import 'package:celest_cli/src/init/project_generator.dart'; +import 'package:celest_cli/src/init/project_init.dart'; +import 'package:celest_cli/src/init/templates/project_template.dart'; +import 'package:celest_cli/src/project/celest_project.dart'; +import 'package:celest_cli/src/utils/error.dart'; + +base mixin ProjectCreator on Configure { + /// The project template to use when creating a project. + String get template; + + /// The project name to use when creating a project. + String? get projectName => null; + + Future createProject({ + required String projectName, + required String projectDisplayName, + required ParentProject? parentProject, + }) async { + logger.finest( + 'Generating project for "$projectName" at ' + '"${projectPaths.projectRoot}"...', + ); + await performance.trace('ProjectCreator', 'createProject', () async { + await ProjectGenerator( + parentProject: parentProject, + projectRoot: projectPaths.projectRoot, + projectName: projectName, + projectDisplayName: projectDisplayName, + projectTemplate: switch (template) { + 'hello' => HelloProject.new, + 'data' => DataProject.new, + _ => unreachable('Invalid project template: $template'), + }, + ).generate(); + logger.fine('Project generated successfully'); + }); + return projectName; + } +} diff --git a/apps/cli/lib/src/init/project_init.dart b/apps/cli/lib/src/init/project_init.dart index 003616a9f..49d6b86d3 100644 --- a/apps/cli/lib/src/init/project_init.dart +++ b/apps/cli/lib/src/init/project_init.dart @@ -7,9 +7,8 @@ import 'package:celest_cli/src/commands/project/init_command.dart'; import 'package:celest_cli/src/commands/project/start_command.dart'; import 'package:celest_cli/src/context.dart'; import 'package:celest_cli/src/exceptions.dart'; -import 'package:celest_cli/src/init/project_generator.dart'; +import 'package:celest_cli/src/init/project_creator.dart'; import 'package:celest_cli/src/init/project_migrate.dart'; -import 'package:celest_cli/src/init/templates/project_template.dart'; import 'package:celest_cli/src/project/celest_project.dart'; import 'package:celest_cli/src/pub/pub_action.dart'; import 'package:celest_cli/src/pub/pub_cache.dart'; @@ -21,37 +20,6 @@ import 'package:celest_cli/src/utils/run.dart'; import 'package:dcli/dcli.dart' as dcli; import 'package:mason_logger/mason_logger.dart'; -base mixin ProjectCreator on Configure { - /// The project template to use when creating a project. - String get template; - - Future createProject({ - required String projectName, - required String projectDisplayName, - required ParentProject? parentProject, - }) async { - logger.finest( - 'Generating project for "$projectName" at ' - '"${projectPaths.projectRoot}"...', - ); - await performance.trace('ProjectCreator', 'createProject', () async { - await ProjectGenerator( - parentProject: parentProject, - projectRoot: projectPaths.projectRoot, - projectName: projectName, - projectDisplayName: projectDisplayName, - projectTemplate: switch (template) { - 'hello' => HelloProject.new, - 'data' => DataProject.new, - _ => unreachable('Invalid project template: $template'), - }, - ).generate(); - logger.fine('Project generated successfully'); - }); - return projectName; - } -} - sealed class ConfigureState {} final class Initializing implements ConfigureState { @@ -88,10 +56,7 @@ base mixin Configure on CelestCommand { 'To create a new project, run `celest init`.', ); - ({ - String projectNameInput, - String projectName, - }) newProjectName({String? defaultName}) { + String newProjectName({String? defaultName}) { if (defaultName != null && defaultName.startsWith('celest')) { defaultName = null; } @@ -104,14 +69,18 @@ base mixin Configure on CelestCommand { cliLogger.err('Project name cannot be empty.'); continue; } - final words = input.groupIntoWords(); - for (final (index, word) in List.of(words).indexed) { - if (word.toLowerCase() == 'celest') { - words.removeAt(index); - } + return input; + } + } + + String _sanitizeProjectName(String name) { + final words = name.groupIntoWords(); + for (final (index, word) in List.of(words).indexed) { + if (word.toLowerCase() == 'celest') { + words.removeAt(index); } - return (projectNameInput: input, projectName: words.snakeCase); } + return words.snakeCase; } Future configure() async { @@ -289,12 +258,16 @@ base mixin Configure on CelestCommand { _throwNoProject(); } - var defaultProjectName = parentProject?.name; - if (currentDirIsEmpty) { - defaultProjectName ??= p.basename(currentDir.path); + if (this case ProjectCreator(:final projectName)) { + projectNameInput = projectName; + } else { + var defaultProjectName = parentProject?.name; + if (currentDirIsEmpty) { + defaultProjectName ??= p.basename(currentDir.path); + } + projectNameInput = newProjectName(defaultName: defaultProjectName); } - (:projectNameInput, :projectName) = - newProjectName(defaultName: defaultProjectName); + projectName = _sanitizeProjectName(projectNameInput!); // Choose where to store the project based on the current directory. projectRoot = switch (celestDir) { diff --git a/apps/cli/lib/src/repositories/cloud_repository.dart b/apps/cli/lib/src/repositories/cloud_repository.dart new file mode 100644 index 000000000..00df4eb0d --- /dev/null +++ b/apps/cli/lib/src/repositories/cloud_repository.dart @@ -0,0 +1,100 @@ +import 'package:celest_ast/celest_ast.dart' as ast; +import 'package:celest_cli/src/cli/stop_signal.dart'; +import 'package:celest_cli/src/commands/cloud/cloud_command.dart'; +import 'package:celest_cli/src/context.dart'; +import 'package:celest_cli/src/repositories/organization_repository.dart'; +import 'package:celest_cli/src/repositories/project_environment_repository.dart'; +import 'package:celest_cli/src/repositories/project_repository.dart'; +import 'package:celest_cli/src/utils/error.dart'; +import 'package:celest_cli/src/utils/recase.dart'; +import 'package:celest_cloud/src/proto.dart' as pb; +import 'package:logging/logging.dart'; + +mixin CloudRepository { + Logger get logger; + StopSignal get stopSignal; + + OrganizationRepository get organizations => OrganizationRepository(cloud); + + ProjectRepository get projects => ProjectRepository(cloud); + + ProjectEnvironmentRepository get projectEnvironments => + ProjectEnvironmentRepository(cloud); + + /// Returns the primary organization for the current user, if any. + Future get primaryOrganization async { + final organization = await organizations.primary; + switch (organization?.state) { + case null: + return null; + case pb.LifecycleState.ACTIVE || + pb.LifecycleState.CREATING || + pb.LifecycleState.UPDATING: + return organization; + case pb.LifecycleState.DELETING || pb.LifecycleState.DELETED: + throw StateError('Organization has been deleted'); + case pb.LifecycleState.CREATION_FAILED || + pb.LifecycleState.UPDATE_FAILED || + pb.LifecycleState.DELETION_FAILED: + throw StateError('Organization is in a failed state'); + case pb.LifecycleState.LIFECYCLE_STATE_UNSPECIFIED: + default: + throw StateError('Organization is in an unknown state'); + } + } + + Future createPrimaryOrg({ + ast.Region? primaryRegion, + }) async { + var organizationDisplayName = ''; + do { + organizationDisplayName = cliLogger.prompt( + 'What should we call your organization?', + ); + if (organizationDisplayName.isEmpty) { + cliLogger.warn('Organization name cannot be empty.'); + } + } while (organizationDisplayName.isEmpty); + + final organizationId = organizationDisplayName.paramCase; + final progress = cliLogger.progress( + 'Creating organization $organizationId', + ); + try { + final operation = cloud.organizations.create( + organizationId: organizationId, + organization: pb.Organization( + displayName: organizationDisplayName, + primaryRegion: switch (primaryRegion) { + ast.Region.europe => pb.Region.EUROPE, + ast.Region.asiaPacific => pb.Region.ASIA_PACIFIC, + ast.Region.northAmerica => pb.Region.NORTH_AMERICA, + null => pb.Region.REGION_UNSPECIFIED, + _ => unreachable('Unsupported region: $primaryRegion'), + }, + ), + ); + final waiter = CloudCliOperation( + operation, + resourceType: 'organization', + logger: logger, + ); + final organization = await waiter.run( + verbs: const ( + run: 'create', + running: 'Creating', + completed: 'created', + ), + cancelTrigger: stopSignal.future, + resource: pb.Organization(), + ); + logger.fine('Created organization: $organization'); + progress.complete('Created organization $organizationId'); + return organization; + } on Object catch (e, st) { + logger.fine('Failed to create organization', e, st); + progress.fail('Failed to create organization'); + rethrow; + } + } +}