Skip to main content

systemprompt_cli/commands/cloud/tenant/create/
cloud.rs

1//! Cloud tenant creation via subscription checkout.
2//!
3//! Validates a release build, prompts for the plan, region, and external
4//! database access, then hands a [`TenantCreatePlan`] to the cloud crate's
5//! [`TenantProvisioningService`], which drives the Paddle checkout callback,
6//! provisioning wait, and credential retrieval. Afterwards a profile is
7//! written for the new tenant and, when required, the initial deploy runs
8//! through the sync crate's [`DeployOrchestrator`].
9
10use anyhow::{Context, Result, anyhow, bail};
11use dialoguer::theme::ColorfulTheme;
12use dialoguer::{Confirm, Input, Select};
13use systemprompt_cloud::constants::checkout::CALLBACK_PORT;
14use systemprompt_cloud::constants::regions::AVAILABLE;
15use systemprompt_cloud::tenants::{TenantCreatePlan, TenantProvisioningService};
16use systemprompt_cloud::{
17    CheckoutTemplates, CloudApiClient, CloudCredentials, ProfilePath, ProjectContext, StoredTenant,
18};
19use systemprompt_identifiers::{PriceId, TenantId};
20use systemprompt_logging::CliService;
21use systemprompt_sync::deploy::{DeployOptions, DeployOrchestrator, DeployRequest};
22
23use crate::cloud::deploy::CliDeployProgress;
24use crate::cloud::profile::{collect_api_keys, create_profile_for_tenant};
25use crate::cloud::templates::{CHECKOUT_ERROR_HTML, CHECKOUT_SUCCESS_HTML, WAITING_HTML};
26use crate::shared::project::ProjectRoot;
27
28use super::super::validation::{validate_build_ready, warn_required_secrets};
29use super::progress::CliProvisioningProgress;
30
31pub async fn create_cloud_tenant(
32    creds: &CloudCredentials,
33    _default_region: &str,
34) -> Result<StoredTenant> {
35    let validation = validate_build_ready().context(
36        "Cloud tenant creation requires a built project.\nRun 'just build --release' before \
37         creating a cloud tenant.",
38    )?;
39
40    CliService::success("Build validation passed");
41    CliService::info("Creating cloud tenant via subscription");
42
43    let client = CloudApiClient::new(&creds.api_url, &creds.api_token)?;
44
45    let plan = assemble_plan(&client).await?;
46
47    let progress = CliProvisioningProgress::new();
48    let provisioned = TenantProvisioningService::new(&client)
49        .provision(&plan, &progress)
50        .await?;
51    let stored_tenant = provisioned.tenant;
52
53    CliService::section("Profile Setup");
54    let profile_name: String = Input::with_theme(&ColorfulTheme::default())
55        .with_prompt("Profile name")
56        .default(stored_tenant.name.clone())
57        .interact_text()?;
58
59    CliService::section("API Keys");
60    let api_keys = collect_api_keys()?;
61
62    let profile = create_profile_for_tenant(
63        &stored_tenant,
64        &api_keys,
65        &profile_name,
66        Some(&creds.api_url),
67    )?;
68    CliService::success(&format!("Profile '{}' created", profile.name));
69
70    if provisioned.needs_deploy {
71        CliService::section("Initial Deploy");
72        CliService::info("Deploying your code with profile configuration...");
73        run_initial_deploy(creds, &stored_tenant, &profile.name).await?;
74    }
75
76    warn_required_secrets(&validation.required_secrets);
77
78    Ok(stored_tenant)
79}
80
81async fn assemble_plan(client: &CloudApiClient) -> Result<TenantCreatePlan> {
82    let price_id = select_plan(client).await?;
83    let region = select_region()?;
84    let external_db_access = prompt_external_access()?;
85
86    Ok(TenantCreatePlan {
87        price_id,
88        region: region.to_owned(),
89        redirect_uri: format!("http://127.0.0.1:{}/callback", CALLBACK_PORT),
90        external_db_access,
91        templates: CheckoutTemplates {
92            success_html: CHECKOUT_SUCCESS_HTML,
93            error_html: CHECKOUT_ERROR_HTML,
94            waiting_html: WAITING_HTML,
95        },
96    })
97}
98
99async fn select_plan(client: &CloudApiClient) -> Result<PriceId> {
100    let spinner = CliService::spinner("Fetching available plans...");
101    let plans = client.get_plans().await?;
102    spinner.finish_and_clear();
103
104    if plans.is_empty() {
105        bail!("No plans available. Please contact support.");
106    }
107
108    let plan_options: Vec<String> = plans.iter().map(|p| p.name.clone()).collect();
109
110    let plan_selection = Select::with_theme(&ColorfulTheme::default())
111        .with_prompt("Select a plan")
112        .items(&plan_options)
113        .default(0)
114        .interact()?;
115
116    Ok(plans[plan_selection].paddle_price_id.clone())
117}
118
119fn select_region() -> Result<&'static str> {
120    let region_options: Vec<String> = AVAILABLE
121        .iter()
122        .map(|(code, name)| format!("{} ({})", name, code))
123        .collect();
124
125    let region_selection = Select::with_theme(&ColorfulTheme::default())
126        .with_prompt("Select a region")
127        .items(&region_options)
128        .default(0)
129        .interact()?;
130
131    Ok(AVAILABLE[region_selection].0)
132}
133
134fn prompt_external_access() -> Result<bool> {
135    CliService::section("Database Access");
136    CliService::info(
137        "External database access allows direct PostgreSQL connections from your local machine.",
138    );
139    CliService::info("This is required for local development workflows.");
140
141    let enable_external = Confirm::with_theme(&ColorfulTheme::default())
142        .with_prompt("Enable external database access?")
143        .default(true)
144        .interact()?;
145
146    if !enable_external {
147        CliService::info("External access disabled. Some local features will be limited.");
148    }
149
150    Ok(enable_external)
151}
152
153async fn run_initial_deploy(
154    creds: &CloudCredentials,
155    tenant: &StoredTenant,
156    profile_name: &str,
157) -> Result<()> {
158    let project = ProjectRoot::discover().map_err(|e| anyhow!("{}", e))?;
159    let ctx = ProjectContext::discover();
160    let profile_dir = ctx.profile_dir(profile_name);
161
162    let request = DeployRequest {
163        tenant_id: TenantId::new(tenant.id.clone()),
164        tenant_name: tenant.name.clone(),
165        profile_name: profile_name.to_owned(),
166        project_root: project.as_path().to_path_buf(),
167        credentials: creds.clone(),
168        hostname: tenant.hostname.clone(),
169        secrets_path: ProfilePath::Secrets.resolve(&profile_dir),
170        signing_key_path: profile_dir.join("signing_key.pem"),
171        options: DeployOptions {
172            skip_push: false,
173            dry_run: false,
174            pre_sync: None,
175        },
176    };
177
178    let progress = CliDeployProgress::non_interactive();
179    DeployOrchestrator::new()
180        .deploy(&request, &progress)
181        .await?;
182
183    Ok(())
184}