Skip to content

Commit c0084e6

Browse files
author
serverpod_cloud
committed
feat(scloud): 89a724c481c0911609d3c165651ce8921c52c779
1 parent 72c2dcc commit c0084e6

7 files changed

Lines changed: 426 additions & 99 deletions

File tree

serverpod_cloud_cli/lib/command_runner/commands/launch_command.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ enum LaunchOption<V> implements OptionDefinition<V> {
1919
group: _projectGroup,
2020
),
2121
),
22+
plan(PlanOption()),
2223
enableDb(
2324
FlagOption(
2425
argName: 'enable-db',
@@ -69,6 +70,7 @@ class CloudLaunchCommand extends CloudCliCommand<LaunchOption> {
6970
LaunchOption.projectId,
7071
);
7172
final newProjectId = commandConfig.optionalValue(LaunchOption.newProjectId);
73+
final plan = commandConfig.optionalValue(LaunchOption.plan);
7274
final enableDb = commandConfig.optionalValue(LaunchOption.enableDb);
7375
final deploy = commandConfig.optionalValue(LaunchOption.deploy);
7476
final dartVersionOverride = commandConfig.optionalValue(
@@ -83,6 +85,7 @@ class CloudLaunchCommand extends CloudCliCommand<LaunchOption> {
8385
foundProjectDir: foundProjectDir,
8486
newProjectId: newProjectId,
8587
existingProjectId: existingProjectId,
88+
plan: plan,
8689
enableDb: enableDb,
8790
performDeploy: deploy,
8891
dartVersionOverride: dartVersionOverride,

serverpod_cloud_cli/lib/command_runner/commands/project_command.dart

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,7 @@ class CloudProjectCommand extends CloudCliCommand {
3333

3434
enum ProjectCreateOption<V> implements OptionDefinition<V> {
3535
projectId(ProjectIdOption.argsOnly(asFirstArg: true)),
36-
profile(
37-
EnumOption<ProjectProfile>(
38-
argName: 'profile',
39-
helpText: 'Project profile (starter or growth).',
40-
enumParser: EnumParser(ProjectProfile.values),
41-
defaultsTo: ProjectProfile.defaultProfile,
42-
mandatory: false,
43-
hide: true,
44-
),
45-
),
36+
plan(PlanOption()),
4637
enableDb(
4738
FlagOption(
4839
argName: 'enable-db',
@@ -71,7 +62,7 @@ class CloudProjectCreateCommand extends CloudCliCommand<ProjectCreateOption> {
7162
@override
7263
Future<void> runWithConfig(final Configuration commandConfig) async {
7364
final projectId = commandConfig.value(ProjectCreateOption.projectId);
74-
final projectProfile = commandConfig.value(ProjectCreateOption.profile);
65+
final plan = commandConfig.optionalValue(ProjectCreateOption.plan);
7566
final enableDb = commandConfig.value(ProjectCreateOption.enableDb);
7667
final projectDir =
7768
runner.selectProjectDirectory() ?? Directory.current.path;
@@ -85,7 +76,7 @@ class CloudProjectCreateCommand extends CloudCliCommand<ProjectCreateOption> {
8576
runner.serviceProvider.cloudApiClient,
8677
logger: logger,
8778
projectId: projectId,
88-
projectProfile: projectProfile,
79+
plan: plan,
8980
enableDb: enableDb,
9081
projectDir: projectDir,
9182
configFilePath: configFilePath,

serverpod_cloud_cli/lib/command_runner/helpers/command_options.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import 'package:config/config.dart';
2+
import 'package:serverpod_cloud_cli/commands/project/project.dart'
3+
show PlanProfile;
24
import 'package:serverpod_cloud_cli/util/scloud_config/scloud_config.dart';
35

46
import 'email_validator.dart';
@@ -58,6 +60,17 @@ class ProjectIdOption extends StringOption {
5860
);
5961
}
6062

63+
class PlanOption extends EnumOption<PlanProfile> {
64+
const PlanOption({final PlanProfile? defaultValue})
65+
: super(
66+
argName: 'plan',
67+
helpText: 'Selects the plan to use.',
68+
enumParser: const EnumParser(PlanProfile.values),
69+
defaultsTo: defaultValue,
70+
hide: true,
71+
);
72+
}
73+
6174
class NameOption extends StringOption {
6275
const NameOption({required String super.helpText, required int super.argPos})
6376
: super(argName: 'name', mandatory: true);

serverpod_cloud_cli/lib/commands/launch/launch.dart

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,11 @@ abstract class Launch {
3131
required final String? foundProjectDir,
3232
required final String? newProjectId,
3333
required final String? existingProjectId,
34+
required final PlanProfile? plan,
3435
required final bool? enableDb,
3536
required final bool? performDeploy,
3637
final String? dartVersionOverride,
3738
}) async {
38-
await ProjectCommands.checkPlanAvailability(cloudApiClient, logger: logger);
39-
4039
if (newProjectId != null && existingProjectId != null) {
4140
throw ArgumentError(
4241
'Cannot specify both newProjectId and existingProjectId.',
@@ -48,6 +47,7 @@ abstract class Launch {
4847
final projectSetup = ProjectLaunch(
4948
projectDir: specifiedProjectDir,
5049
projectId: newProjectId ?? existingProjectId,
50+
plan: plan,
5151
enableDb: enableDb,
5252
preexistingProject: existingProjectId != null,
5353
performDeploy: performDeploy,
@@ -58,6 +58,8 @@ abstract class Launch {
5858
await selectProjectId(cloudApiClient, logger, projectSetup);
5959

6060
if (projectSetup.preexistingProject != true) {
61+
await selectPlan(cloudApiClient, logger, projectSetup);
62+
6163
await selectEnableDb(logger, projectSetup);
6264
}
6365

@@ -328,6 +330,63 @@ The default API domain will be: <project-id>.api.serverpod.space
328330
return null;
329331
}
330332

333+
static Future<void> selectPlan(
334+
final Client cloudApiClient,
335+
final CommandLogger logger,
336+
final ProjectLaunch projectSetup,
337+
) async {
338+
final validPlanNames = PlanProfile.values
339+
.map((final p) => p.name)
340+
.join(', ');
341+
342+
var planProfile = projectSetup.plan;
343+
344+
do {
345+
if (planProfile != null) {
346+
if (await _isAvailablePlan(cloudApiClient, logger, planProfile)) {
347+
projectSetup.plan = planProfile;
348+
return;
349+
}
350+
}
351+
352+
final projectPlanName = await logger.input('Enter the plan');
353+
354+
if (projectPlanName.isEmpty) {
355+
logger.error('Plan is required. Must be one of: $validPlanNames');
356+
continue;
357+
}
358+
359+
planProfile = PlanProfile.values
360+
.where((final p) => p.name == projectPlanName)
361+
.firstOrNull;
362+
if (planProfile == null) {
363+
logger.error('Invalid plan. Must be one of: $validPlanNames');
364+
continue;
365+
}
366+
} while (true);
367+
}
368+
369+
static Future<bool> _isAvailablePlan(
370+
final Client cloudApiClient,
371+
final CommandLogger logger,
372+
final PlanProfile planProfile,
373+
) async {
374+
try {
375+
await ProjectCommands.checkPlanAvailability(
376+
cloudApiClient,
377+
logger: logger,
378+
plan: planProfile,
379+
);
380+
return true;
381+
} on NotFoundException catch (e) {
382+
logger.error('No plan found for ${planProfile.name}: ${e.message}');
383+
return false;
384+
} on ProcurementDeniedException catch (e) {
385+
logger.error(e.message);
386+
return false;
387+
}
388+
}
389+
331390
static Future<void> selectEnableDb(
332391
final CommandLogger logger,
333392
final ProjectLaunch projectSetup,
@@ -458,6 +517,7 @@ The default API domain will be: <project-id>.api.serverpod.space
458517
final projectDir = projectSetup.projectDir;
459518
final configFilePath = projectSetup.configFilePath;
460519
final performDeploy = projectSetup.performDeploy;
520+
final planProfile = projectSetup.plan;
461521

462522
if (projectId == null) {
463523
throw StateError('ProjectId must be set.');
@@ -476,12 +536,16 @@ The default API domain will be: <project-id>.api.serverpod.space
476536
}
477537

478538
if (projectSetup.preexistingProject != true) {
539+
if (planProfile == null) {
540+
throw StateError('PlanProfile must be set.');
541+
}
542+
479543
final enableDb = projectSetup.enableDb!;
480544
await ProjectCommands.createProject(
481545
cloudApiClient,
482546
logger: logger,
483547
projectId: projectId,
484-
projectProfile: ProjectProfile.defaultProfile,
548+
plan: planProfile,
485549
enableDb: enableDb,
486550
projectDir: projectDir,
487551
configFilePath: configFilePath,
@@ -600,6 +664,7 @@ class ProjectLaunch {
600664
String? _projectDir;
601665
String? configFilePath;
602666
String? projectId;
667+
PlanProfile? plan;
603668
bool? enableDb;
604669
bool? preexistingProject;
605670
bool? performDeploy;
@@ -608,6 +673,7 @@ class ProjectLaunch {
608673
ProjectLaunch({
609674
final String? projectDir,
610675
this.projectId,
676+
this.plan,
611677
this.enableDb,
612678
this.preexistingProject,
613679
this.performDeploy,
@@ -637,6 +703,7 @@ class ProjectLaunch {
637703
['Project directory', projectDir],
638704
if (preexistingProject != true) ...[
639705
['New project id', projectId],
706+
['Project plan', plan?.name ?? ''],
640707
['Enable DB', enableDb == true ? 'yes' : 'no'],
641708
] else
642709
['Existing project id', projectId],

serverpod_cloud_cli/lib/commands/project/project.dart

Lines changed: 51 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -11,49 +11,57 @@ import 'package:serverpod_cloud_cli/util/dart_version_util.dart';
1111
import 'package:serverpod_cloud_cli/util/pubspec_validator.dart'
1212
show resolveProjectDartSdkVersion;
1313

14-
enum ProjectProfile {
14+
enum PlanProfile {
1515
starter('starter', 'starter', 'starter-project'),
16-
growth('growth', 'growth', 'growth-project'),
17-
defaultProfile(null, null, null);
16+
growth('growth', 'growth', 'growth-project');
1817

19-
const ProjectProfile(
20-
this.name,
21-
this.planProductName,
22-
this.projectProductName,
23-
);
18+
const PlanProfile(this.name, this.planProductName, this.projectProductName);
2419

25-
final String? name;
26-
final String? planProductName;
27-
final String? projectProductName;
20+
final String name;
21+
final String planProductName;
22+
final String projectProductName;
2823
}
2924

3025
abstract class ProjectCommands {
31-
static const defaultPlanName = 'early-access';
26+
static const defaultPlan = 'starter';
3227

33-
/// Subcommand to check if the user is subscribed to a plan,
34-
/// and if not whether a plan can be procured.
35-
/// If [planProductName] is not provided, the default plan will be assumed.
36-
/// Throws an exception if there is no subscription and the plan cannot be
37-
/// procured.
28+
static const _legacyPlanNames = [
29+
'early-access',
30+
'closed-beta',
31+
'internal-test-runs',
32+
'internal-payment-testing',
33+
];
34+
35+
/// Subcommand to check if the user is subscribed to a given plan,
36+
/// and if not whether the plan can be procured.
37+
///
38+
/// Throws [ProcurementDeniedException] if there is no subscription and the
39+
/// plan cannot be procured.
3840
static Future<void> checkPlanAvailability(
3941
final Client cloudApiClient, {
4042
required final CommandLogger logger,
41-
final String? planProductName,
43+
required final PlanProfile? plan,
4244
}) async {
4345
final planNames = await cloudApiClient.plans.listProcuredPlanNames();
44-
if (planNames.isEmpty) {
45-
await cloudApiClient.plans.checkPlanAvailability(
46-
planProductName: planProductName ?? defaultPlanName,
47-
);
46+
47+
if (plan == null &&
48+
planNames.any((final name) => _legacyPlanNames.contains(name))) {
49+
return;
4850
}
51+
52+
final planProductName = plan?.name ?? defaultPlan;
53+
54+
await cloudApiClient.plans.checkPlanAvailability(
55+
planProductName: planProductName,
56+
);
4957
}
5058

5159
/// Subcommand to create a new tenant project.
5260
static Future<void> createProject(
5361
final Client cloudApiClient, {
5462
required final CommandLogger logger,
5563
required final String projectId,
56-
required final ProjectProfile projectProfile,
64+
required final PlanProfile? plan,
5765
required final bool enableDb,
5866
required final String projectDir,
5967
required final String configFilePath,
@@ -63,33 +71,32 @@ abstract class ProjectCommands {
6371
await UserConfirmations.confirmNewProjectCostAcceptance(logger);
6472
}
6573

66-
final String subscriptionId;
67-
if (projectProfile.planProductName == null) {
68-
// Check that the user is on a plan and automatically procure one if not.
69-
// This behavior will be changed in the future.
74+
String? subscriptionId;
75+
if (plan == null) {
76+
// If no plan is specified and user has a legacy plan, use that.
7077
final subscriptions = await cloudApiClient.plans.listSubscriptions();
71-
if (subscriptions.isEmpty) {
72-
subscriptionId = await cloudApiClient.plans.procurePlan(
73-
planProductName: defaultPlanName,
74-
);
75-
logger.init('Creating Serverpod Cloud project "$projectId".');
76-
logger.info('On plan: $defaultPlanName');
77-
} else {
78-
final subscription = subscriptions
79-
.where((final s) => s.planName == defaultPlanName)
78+
if (subscriptions.isNotEmpty) {
79+
final legacySubscription = subscriptions
80+
.where(
81+
(final s) =>
82+
_legacyPlanNames.contains(s.planProductId.split(':').first),
83+
)
8084
.firstOrNull;
81-
if (subscription == null) {
82-
throw FailureException(error: 'User does not have the default plan.');
85+
if (legacySubscription != null) {
86+
logger.init('Creating Serverpod Cloud project "$projectId".');
87+
logger.info('On plan: ${legacySubscription.planDisplayName}');
88+
subscriptionId = legacySubscription.subscriptionId;
8389
}
84-
logger.init('Creating Serverpod Cloud project "$projectId".');
85-
logger.debug('On plan: ${subscription.planName}');
86-
subscriptionId = subscription.subscriptionId;
8790
}
88-
} else {
91+
}
92+
93+
if (subscriptionId == null) {
94+
final planProductName = plan?.name ?? defaultPlan;
8995
subscriptionId = await cloudApiClient.plans.procurePlan(
90-
planProductName: projectProfile.planProductName,
96+
planProductName: planProductName,
9197
);
9298
logger.init('Creating Serverpod Cloud project "$projectId".');
99+
logger.info('On plan: $planProductName');
93100
}
94101

95102
try {
@@ -99,7 +106,7 @@ abstract class ProjectCommands {
99106
() async {
100107
await cloudApiClient.projects.createProject(
101108
cloudProjectId: projectId,
102-
projectProductName: projectProfile.projectProductName,
109+
projectProductName: plan?.projectProductName,
103110
underSubscriptionId: subscriptionId,
104111
);
105112
return true;

0 commit comments

Comments
 (0)