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 deploy pipeline's [`DeployOrchestrator`].
9//!
10//! Copyright (c) systemprompt.io — Business Source License 1.1.
11//! See <https://systemprompt.io> for licensing details.
12
13use crate::cloud::deploy::CliDeployProgress;
14use crate::cloud::deploy::pipeline::{DeployOptions, DeployOrchestrator, DeployRequest};
15use crate::cloud::profile::{collect_api_keys, create_profile_for_tenant};
16use crate::cloud::templates::{CHECKOUT_ERROR_HTML, CHECKOUT_SUCCESS_HTML, WAITING_HTML};
17use crate::interactive::Prompter;
18use crate::shared::project::ProjectRoot;
19use anyhow::{Context, Result, anyhow, bail};
20use systemprompt_cloud::constants::checkout::CALLBACK_PORT;
21use systemprompt_cloud::constants::regions::AVAILABLE;
22use systemprompt_cloud::tenants::{TenantCreatePlan, TenantProvisioningService};
23use systemprompt_cloud::{
24    CheckoutTemplates, CloudApiClient, CloudCredentials, ProfilePath, ProjectContext, StoredTenant,
25};
26use systemprompt_identifiers::{PriceId, TenantId};
27use systemprompt_logging::CliService;
28
29use super::super::validation::{validate_build_ready, warn_required_secrets};
30use super::progress::CliProvisioningProgress;
31
32pub async fn create_cloud_tenant(
33    creds: &CloudCredentials,
34    _default_region: &str,
35    prompter: &dyn Prompter,
36) -> Result<StoredTenant> {
37    let validation = validate_build_ready().context(
38        "Cloud tenant creation requires a built project.\nRun 'just build --release' before \
39         creating a cloud tenant.",
40    )?;
41
42    CliService::success("Build validation passed");
43    CliService::info("Creating cloud tenant via subscription");
44
45    let client = CloudApiClient::new(&creds.api_url, creds.api_token.as_str())?;
46
47    let plan = assemble_plan(&client, prompter).await?;
48
49    let progress = CliProvisioningProgress::new();
50    let provisioned = TenantProvisioningService::new(&client)
51        .provision(&plan, &progress)
52        .await?;
53    let stored_tenant = provisioned.tenant;
54
55    CliService::section("Profile Setup");
56    let profile_name = prompter.input_with_default("Profile name", &stored_tenant.name)?;
57
58    CliService::section("API Keys");
59    let api_keys = collect_api_keys(prompter)?;
60
61    let profile = create_profile_for_tenant(
62        prompter,
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(
82    client: &CloudApiClient,
83    prompter: &dyn Prompter,
84) -> Result<TenantCreatePlan> {
85    let price_id = select_plan(client, prompter).await?;
86    let region = select_region(prompter)?;
87    let external_db_access = prompt_external_access(prompter)?;
88
89    Ok(TenantCreatePlan {
90        price_id,
91        region: region.to_owned(),
92        redirect_uri: format!("http://127.0.0.1:{}/callback", CALLBACK_PORT),
93        external_db_access,
94        templates: CheckoutTemplates {
95            success_html: CHECKOUT_SUCCESS_HTML,
96            error_html: CHECKOUT_ERROR_HTML,
97            waiting_html: WAITING_HTML,
98        },
99    })
100}
101
102async fn select_plan(client: &CloudApiClient, prompter: &dyn Prompter) -> Result<PriceId> {
103    let spinner = CliService::spinner("Fetching available plans...");
104    let plans = client.get_plans().await?;
105    spinner.finish_and_clear();
106
107    if plans.is_empty() {
108        bail!("No plans available. Please contact support.");
109    }
110
111    let plan_options: Vec<String> = plans.iter().map(|p| p.name.clone()).collect();
112
113    let plan_selection = prompter.select("Select a plan", &plan_options)?;
114
115    Ok(plans[plan_selection].paddle_price_id.clone())
116}
117
118fn select_region(prompter: &dyn Prompter) -> Result<&'static str> {
119    let region_options: Vec<String> = AVAILABLE
120        .iter()
121        .map(|(code, name)| format!("{} ({})", name, code))
122        .collect();
123
124    let region_selection = prompter.select("Select a region", &region_options)?;
125
126    Ok(AVAILABLE[region_selection].0)
127}
128
129fn prompt_external_access(prompter: &dyn Prompter) -> Result<bool> {
130    CliService::section("Database Access");
131    CliService::info(
132        "External database access allows direct PostgreSQL connections from your local machine.",
133    );
134    CliService::info("This is required for local development workflows.");
135
136    let enable_external = prompter.confirm("Enable external database access?", true)?;
137
138    if !enable_external {
139        CliService::info("External access disabled. Some local features will be limited.");
140    }
141
142    Ok(enable_external)
143}
144
145async fn run_initial_deploy(
146    creds: &CloudCredentials,
147    tenant: &StoredTenant,
148    profile_name: &str,
149) -> Result<()> {
150    let project = ProjectRoot::discover().map_err(|e| anyhow!("{}", e))?;
151    let ctx = ProjectContext::discover();
152    let profile_dir = ctx.profile_dir(profile_name);
153
154    let request = DeployRequest {
155        tenant_id: TenantId::new(tenant.id.clone()),
156        tenant_name: tenant.name.clone(),
157        profile_name: profile_name.to_owned(),
158        project_root: project.as_path().to_path_buf(),
159        credentials: creds.clone(),
160        secrets_path: ProfilePath::Secrets.resolve(&profile_dir),
161        signing_key_path: profile_dir.join("signing_key.pem"),
162        options: DeployOptions { skip_push: false },
163    };
164
165    let progress = CliDeployProgress::new();
166    DeployOrchestrator::new()
167        .deploy(&request, &progress)
168        .await?;
169
170    Ok(())
171}