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 systemprompt_cloud::constants::checkout::CALLBACK_PORT;
12use systemprompt_cloud::constants::regions::AVAILABLE;
13use systemprompt_cloud::tenants::{TenantCreatePlan, TenantProvisioningService};
14use systemprompt_cloud::{
15    CheckoutTemplates, CloudApiClient, CloudCredentials, ProfilePath, ProjectContext, StoredTenant,
16};
17use systemprompt_identifiers::{PriceId, TenantId};
18use systemprompt_logging::CliService;
19use systemprompt_sync::deploy::{DeployOptions, DeployOrchestrator, DeployRequest};
20
21use crate::cloud::deploy::CliDeployProgress;
22use crate::cloud::profile::{collect_api_keys, create_profile_for_tenant};
23use crate::cloud::templates::{CHECKOUT_ERROR_HTML, CHECKOUT_SUCCESS_HTML, WAITING_HTML};
24use crate::interactive::Prompter;
25use crate::shared::project::ProjectRoot;
26
27use super::super::validation::{validate_build_ready, warn_required_secrets};
28use super::progress::CliProvisioningProgress;
29
30pub async fn create_cloud_tenant(
31    creds: &CloudCredentials,
32    _default_region: &str,
33    prompter: &dyn Prompter,
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.as_str())?;
44
45    let plan = assemble_plan(&client, prompter).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 = prompter.input_with_default("Profile name", &stored_tenant.name)?;
55
56    CliService::section("API Keys");
57    let api_keys = collect_api_keys(prompter)?;
58
59    let profile = create_profile_for_tenant(
60        prompter,
61        &stored_tenant,
62        &api_keys,
63        &profile_name,
64        Some(&creds.api_url),
65    )?;
66    CliService::success(&format!("Profile '{}' created", profile.name));
67
68    if provisioned.needs_deploy {
69        CliService::section("Initial Deploy");
70        CliService::info("Deploying your code with profile configuration...");
71        run_initial_deploy(creds, &stored_tenant, &profile.name).await?;
72    }
73
74    warn_required_secrets(&validation.required_secrets);
75
76    Ok(stored_tenant)
77}
78
79async fn assemble_plan(
80    client: &CloudApiClient,
81    prompter: &dyn Prompter,
82) -> Result<TenantCreatePlan> {
83    let price_id = select_plan(client, prompter).await?;
84    let region = select_region(prompter)?;
85    let external_db_access = prompt_external_access(prompter)?;
86
87    Ok(TenantCreatePlan {
88        price_id,
89        region: region.to_owned(),
90        redirect_uri: format!("http://127.0.0.1:{}/callback", CALLBACK_PORT),
91        external_db_access,
92        templates: CheckoutTemplates {
93            success_html: CHECKOUT_SUCCESS_HTML,
94            error_html: CHECKOUT_ERROR_HTML,
95            waiting_html: WAITING_HTML,
96        },
97    })
98}
99
100async fn select_plan(client: &CloudApiClient, prompter: &dyn Prompter) -> Result<PriceId> {
101    let spinner = CliService::spinner("Fetching available plans...");
102    let plans = client.get_plans().await?;
103    spinner.finish_and_clear();
104
105    if plans.is_empty() {
106        bail!("No plans available. Please contact support.");
107    }
108
109    let plan_options: Vec<String> = plans.iter().map(|p| p.name.clone()).collect();
110
111    let plan_selection = prompter.select("Select a plan", &plan_options)?;
112
113    Ok(plans[plan_selection].paddle_price_id.clone())
114}
115
116fn select_region(prompter: &dyn Prompter) -> Result<&'static str> {
117    let region_options: Vec<String> = AVAILABLE
118        .iter()
119        .map(|(code, name)| format!("{} ({})", name, code))
120        .collect();
121
122    let region_selection = prompter.select("Select a region", &region_options)?;
123
124    Ok(AVAILABLE[region_selection].0)
125}
126
127fn prompt_external_access(prompter: &dyn Prompter) -> Result<bool> {
128    CliService::section("Database Access");
129    CliService::info(
130        "External database access allows direct PostgreSQL connections from your local machine.",
131    );
132    CliService::info("This is required for local development workflows.");
133
134    let enable_external = prompter.confirm("Enable external database access?", true)?;
135
136    if !enable_external {
137        CliService::info("External access disabled. Some local features will be limited.");
138    }
139
140    Ok(enable_external)
141}
142
143async fn run_initial_deploy(
144    creds: &CloudCredentials,
145    tenant: &StoredTenant,
146    profile_name: &str,
147) -> Result<()> {
148    let project = ProjectRoot::discover().map_err(|e| anyhow!("{}", e))?;
149    let ctx = ProjectContext::discover();
150    let profile_dir = ctx.profile_dir(profile_name);
151
152    let request = DeployRequest {
153        tenant_id: TenantId::new(tenant.id.clone()),
154        tenant_name: tenant.name.clone(),
155        profile_name: profile_name.to_owned(),
156        project_root: project.as_path().to_path_buf(),
157        credentials: creds.clone(),
158        hostname: tenant.hostname.clone(),
159        secrets_path: ProfilePath::Secrets.resolve(&profile_dir),
160        signing_key_path: profile_dir.join("signing_key.pem"),
161        options: DeployOptions {
162            skip_push: false,
163            dry_run: false,
164            pre_sync: None,
165        },
166    };
167
168    let progress = CliDeployProgress::non_interactive();
169    DeployOrchestrator::new()
170        .deploy(&request, &progress)
171        .await?;
172
173    Ok(())
174}