1use anyhow::{anyhow, bail, Context, Result};
2use dialoguer::theme::ColorfulTheme;
3use dialoguer::{Confirm, Input, Select};
4use std::fs;
5use std::process::Command;
6use systemprompt_cloud::constants::checkout::CALLBACK_PORT;
7use systemprompt_cloud::constants::regions::AVAILABLE;
8use systemprompt_cloud::{
9 run_checkout_callback_flow, CheckoutTemplates, CloudApiClient, CloudCredentials,
10 ProjectContext, StoredTenant, TenantType,
11};
12use systemprompt_logging::CliService;
13use url::Url;
14
15use crate::cloud::deploy::deploy_with_secrets;
16use crate::cloud::profile::{
17 collect_api_keys, create_profile_for_tenant, get_cloud_user, handle_local_tenant_setup,
18};
19use crate::cloud::templates::{CHECKOUT_ERROR_HTML, CHECKOUT_SUCCESS_HTML, WAITING_HTML};
20
21use super::docker::{
22 check_volume_exists, create_database_for_tenant, generate_admin_password,
23 generate_shared_postgres_compose, get_container_password, is_shared_container_running,
24 load_shared_config, nanoid, remove_shared_volume, save_shared_config,
25 wait_for_postgres_healthy, SharedContainerConfig, SHARED_ADMIN_USER, SHARED_PORT,
26 SHARED_VOLUME_NAME,
27};
28use super::validation::{validate_build_ready, warn_required_secrets};
29use crate::cloud::profile::templates::validate_connection;
30
31pub async fn create_local_tenant() -> Result<StoredTenant> {
32 CliService::section("Create Local PostgreSQL Tenant");
33
34 let name: String = Input::with_theme(&ColorfulTheme::default())
35 .with_prompt("Tenant name")
36 .default("local".to_string())
37 .interact_text()?;
38
39 if name.is_empty() {
40 bail!("Tenant name cannot be empty");
41 }
42
43 let unique_suffix = nanoid();
44 let db_name = format!("{}_{}", sanitize_database_name(&name), unique_suffix);
45
46 let ctx = ProjectContext::discover();
47 let docker_dir = ctx.docker_dir();
48 fs::create_dir_all(&docker_dir).context("Failed to create docker directory")?;
49
50 let shared_config = load_shared_config()?;
51 let container_running = is_shared_container_running();
52
53 let (config, needs_start) = match (shared_config, container_running) {
54 (Some(config), true) => {
55 CliService::info("Using existing shared PostgreSQL container");
56 (config, false)
57 },
58 (Some(config), false) => {
59 CliService::info("Shared container config found, restarting container...");
60 (config, true)
61 },
62 (None, true) => {
63 CliService::info("Found existing shared PostgreSQL container.");
64
65 let use_existing = Confirm::with_theme(&ColorfulTheme::default())
66 .with_prompt("Use existing container?")
67 .default(true)
68 .interact()?;
69
70 if !use_existing {
71 bail!(
72 "To create a new container, first stop the existing one:\n docker stop \
73 systemprompt-postgres-shared && docker rm systemprompt-postgres-shared"
74 );
75 }
76
77 let spinner = CliService::spinner("Connecting to container...");
78 let password = get_container_password()
79 .ok_or_else(|| anyhow!("Could not retrieve password from container"))?;
80 spinner.finish_and_clear();
81
82 CliService::success("Connected to existing container");
83 let config = SharedContainerConfig::new(password, SHARED_PORT);
84 (config, false)
85 },
86 (None, false) => {
87 if check_volume_exists() {
88 CliService::warning(
89 "PostgreSQL data volume exists but no container or configuration found.",
90 );
91 CliService::info(&format!(
92 "Volume '{}' contains data from a previous installation.",
93 SHARED_VOLUME_NAME
94 ));
95
96 let reset = Confirm::with_theme(&ColorfulTheme::default())
97 .with_prompt("Reset volume? (This will delete existing database data)")
98 .default(false)
99 .interact()?;
100
101 if reset {
102 let spinner = CliService::spinner("Removing orphaned volume...");
103 remove_shared_volume()?;
104 spinner.finish_and_clear();
105 CliService::success("Volume removed");
106 } else {
107 bail!(
108 "Cannot create container with orphaned volume.\nEither reset the volume \
109 or remove it manually:\n docker volume rm {}",
110 SHARED_VOLUME_NAME
111 );
112 }
113 }
114
115 CliService::info("Creating new shared PostgreSQL container...");
116 let password = generate_admin_password();
117 let config = SharedContainerConfig::new(password, SHARED_PORT);
118 (config, true)
119 },
120 };
121
122 let compose_path = docker_dir.join("shared.yaml");
123
124 if needs_start {
125 let compose_content = generate_shared_postgres_compose(&config.admin_password, config.port);
126 fs::write(&compose_path, &compose_content)
127 .with_context(|| format!("Failed to write {}", compose_path.display()))?;
128 CliService::success(&format!("Created: {}", compose_path.display()));
129
130 CliService::info("Starting shared PostgreSQL container...");
131 let compose_path_str = compose_path
132 .to_str()
133 .ok_or_else(|| anyhow!("Invalid compose path"))?;
134
135 let status = Command::new("docker")
136 .args(["compose", "-f", compose_path_str, "up", "-d"])
137 .status()
138 .context("Failed to execute docker compose. Is Docker running?")?;
139
140 if !status.success() {
141 bail!("Failed to start PostgreSQL container. Is Docker running?");
142 }
143
144 let spinner = CliService::spinner("Waiting for PostgreSQL to be ready...");
145 wait_for_postgres_healthy(&compose_path, 60).await?;
146 spinner.finish_and_clear();
147 CliService::success("Shared PostgreSQL container is ready");
148 }
149
150 let spinner = CliService::spinner(&format!("Creating database '{}'...", db_name));
151 create_database_for_tenant(&config.admin_password, config.port, &db_name).await?;
152 spinner.finish_and_clear();
153 CliService::success(&format!("Database '{}' created", db_name));
154
155 let database_url = format!(
156 "postgres://{}:{}@localhost:{}/{}",
157 SHARED_ADMIN_USER, config.admin_password, config.port, db_name
158 );
159
160 let id = format!("local_{}", unique_suffix);
161 let tenant =
162 StoredTenant::new_local_shared(id, name.clone(), database_url.clone(), db_name.clone());
163
164 let mut updated_config = config;
165 updated_config.add_tenant(tenant.id.clone(), db_name);
166 save_shared_config(&updated_config)?;
167
168 CliService::section("Profile Setup");
169 let profile_name: String = Input::with_theme(&ColorfulTheme::default())
170 .with_prompt("Profile name")
171 .default(name.clone())
172 .interact_text()?;
173
174 CliService::section("API Keys");
175 let api_keys = collect_api_keys()?;
176
177 let profile = create_profile_for_tenant(&tenant, &api_keys, &profile_name)?;
178 CliService::success(&format!("Profile '{}' created", profile.name));
179
180 let cloud_user = get_cloud_user()?;
181 let ctx = ProjectContext::discover();
182 let profile_path = ctx.profile_dir(&profile.name).join("profile.yaml");
183 handle_local_tenant_setup(&cloud_user, &database_url, &name, &profile_path).await?;
184
185 Ok(tenant)
186}
187
188pub async fn create_external_tenant() -> Result<StoredTenant> {
189 CliService::section("Create Local Tenant (External PostgreSQL)");
190
191 let name: String = Input::with_theme(&ColorfulTheme::default())
192 .with_prompt("Tenant name")
193 .default("local".to_string())
194 .interact_text()?;
195
196 if name.is_empty() {
197 bail!("Tenant name cannot be empty");
198 }
199
200 let database_url: String = Input::with_theme(&ColorfulTheme::default())
201 .with_prompt("PostgreSQL connection URL")
202 .interact_text()?;
203
204 if database_url.is_empty() {
205 bail!("Database URL cannot be empty");
206 }
207
208 let spinner = CliService::spinner("Validating connection...");
209 let valid = validate_connection(&database_url).await;
210 spinner.finish_and_clear();
211
212 if !valid {
213 bail!("Could not connect to database. Check your connection URL and try again.");
214 }
215 CliService::success("Database connection verified");
216
217 let unique_suffix = nanoid();
218 let id = format!("local_{}", unique_suffix);
219 let tenant = StoredTenant::new_local(id, name.clone(), database_url.clone());
220
221 CliService::section("Profile Setup");
222 let profile_name: String = Input::with_theme(&ColorfulTheme::default())
223 .with_prompt("Profile name")
224 .default(name.clone())
225 .interact_text()?;
226
227 CliService::section("API Keys");
228 let api_keys = collect_api_keys()?;
229
230 let profile = create_profile_for_tenant(&tenant, &api_keys, &profile_name)?;
231 CliService::success(&format!("Profile '{}' created", profile.name));
232
233 let cloud_user = get_cloud_user()?;
234 let ctx = ProjectContext::discover();
235 let profile_path = ctx.profile_dir(&profile.name).join("profile.yaml");
236 handle_local_tenant_setup(&cloud_user, &database_url, &name, &profile_path).await?;
237
238 Ok(tenant)
239}
240
241fn sanitize_database_name(name: &str) -> String {
242 let sanitized: String = name
243 .chars()
244 .map(|c| {
245 if c.is_ascii_alphanumeric() || c == '_' {
246 c
247 } else {
248 '_'
249 }
250 })
251 .collect();
252
253 if sanitized.is_empty() {
254 "systemprompt".to_string()
255 } else if sanitized.chars().next().is_some_and(|c| c.is_ascii_digit()) {
256 format!("db_{}", sanitized)
257 } else {
258 sanitized
259 }
260}
261
262pub async fn create_cloud_tenant(
263 creds: &CloudCredentials,
264 _default_region: &str,
265) -> Result<StoredTenant> {
266 let validation = validate_build_ready().context(
267 "Cloud tenant creation requires a built project.\nRun 'just build --release' before \
268 creating a cloud tenant.",
269 )?;
270
271 CliService::success("Build validation passed");
272 CliService::info("Creating cloud tenant via subscription");
273
274 let client = CloudApiClient::new(&creds.api_url, &creds.api_token);
275
276 let spinner = CliService::spinner("Fetching available plans...");
277 let plans = client.get_plans().await?;
278 spinner.finish_and_clear();
279
280 if plans.is_empty() {
281 bail!("No plans available. Please contact support.");
282 }
283
284 let plan_options: Vec<String> = plans.iter().map(|p| p.name.clone()).collect();
285
286 let plan_selection = Select::with_theme(&ColorfulTheme::default())
287 .with_prompt("Select a plan")
288 .items(&plan_options)
289 .default(0)
290 .interact()?;
291
292 let selected_plan = &plans[plan_selection];
293
294 let region_options: Vec<String> = AVAILABLE
295 .iter()
296 .map(|(code, name)| format!("{} ({})", name, code))
297 .collect();
298
299 let region_selection = Select::with_theme(&ColorfulTheme::default())
300 .with_prompt("Select a region")
301 .items(®ion_options)
302 .default(0)
303 .interact()?;
304
305 let selected_region = AVAILABLE[region_selection].0;
306
307 let redirect_uri = format!("http://127.0.0.1:{}/callback", CALLBACK_PORT);
308 let spinner = CliService::spinner("Creating checkout session...");
309 let checkout = client
310 .create_checkout(
311 &selected_plan.paddle_price_id,
312 selected_region,
313 Some(&redirect_uri),
314 )
315 .await?;
316 spinner.finish_and_clear();
317
318 let templates = CheckoutTemplates {
319 success_html: CHECKOUT_SUCCESS_HTML,
320 error_html: CHECKOUT_ERROR_HTML,
321 waiting_html: WAITING_HTML,
322 };
323
324 let result = run_checkout_callback_flow(&client, &checkout.checkout_url, templates).await?;
325 CliService::success(&format!(
326 "Checkout complete! Tenant ID: {}",
327 result.tenant_id
328 ));
329
330 CliService::success("Tenant provisioned successfully");
331
332 let spinner = CliService::spinner("Fetching database credentials...");
333 let (database_url, sync_token) = match client.get_tenant_status(&result.tenant_id).await {
334 Ok(status) => {
335 if let Some(secrets_url) = status.secrets_url {
336 match client.fetch_secrets(&secrets_url).await {
337 Ok(secrets) => (Some(secrets.database_url), secrets.sync_token),
338 Err(e) => {
339 tracing::warn!(error = %e, "Failed to fetch secrets");
340 (None, None)
341 },
342 }
343 } else {
344 tracing::warn!("No secrets URL available for tenant {}", result.tenant_id);
345 (None, None)
346 }
347 },
348 Err(e) => {
349 tracing::warn!(error = %e, "Failed to get tenant status");
350 (None, None)
351 },
352 };
353 spinner.finish_and_clear();
354
355 let Some(internal_database_url) = database_url else {
356 bail!("Could not retrieve database credentials. Tenant creation incomplete.")
357 };
358 CliService::success("Database credentials retrieved");
359
360 CliService::section("Database Access");
361 CliService::info(
362 "External database access allows direct PostgreSQL connections from your local machine.",
363 );
364 CliService::info("This is required for the TUI and local development workflows.");
365
366 let enable_external = Confirm::with_theme(&ColorfulTheme::default())
367 .with_prompt("Enable external database access?")
368 .default(true)
369 .interact()?;
370
371 let (external_db_access, external_database_url) = if enable_external {
372 let spinner = CliService::spinner("Enabling external database access...");
373 match client.set_external_db_access(&result.tenant_id, true).await {
374 Ok(_) => {
375 let external_url = swap_to_external_host(&internal_database_url);
376 spinner.finish_and_clear();
377 CliService::success("External database access enabled");
378 print_database_connection_info(&external_url);
379 (true, Some(external_url))
380 },
381 Err(e) => {
382 spinner.finish_and_clear();
383 CliService::warning(&format!("Failed to enable external access: {}", e));
384 CliService::info("You can enable it later with 'systemprompt cloud tenant edit'");
385 (false, None)
386 },
387 }
388 } else {
389 CliService::info("External access disabled. TUI features will be limited.");
390 (false, None)
391 };
392
393 let spinner = CliService::spinner("Syncing new tenant...");
394 let response = client.get_user().await?;
395 spinner.finish_and_clear();
396
397 let new_tenant = response
398 .tenants
399 .iter()
400 .find(|t| t.id == result.tenant_id)
401 .ok_or_else(|| anyhow!("New tenant not found after checkout"))?;
402
403 let stored_tenant = StoredTenant {
404 id: new_tenant.id.clone(),
405 name: new_tenant.name.clone(),
406 tenant_type: TenantType::Cloud,
407 app_id: new_tenant.app_id.clone(),
408 hostname: new_tenant.hostname.clone(),
409 region: new_tenant.region.clone(),
410 database_url: external_database_url,
411 internal_database_url: Some(internal_database_url),
412 external_db_access,
413 sync_token,
414 shared_container_db: None,
415 };
416
417 CliService::section("Profile Setup");
418 let profile_name: String = Input::with_theme(&ColorfulTheme::default())
419 .with_prompt("Profile name")
420 .default(stored_tenant.name.clone())
421 .interact_text()?;
422
423 CliService::section("API Keys");
424 let api_keys = collect_api_keys()?;
425
426 let profile = create_profile_for_tenant(&stored_tenant, &api_keys, &profile_name)?;
427 CliService::success(&format!("Profile '{}' created", profile.name));
428
429 if result.needs_deploy {
430 CliService::section("Initial Deploy");
431 CliService::info("Deploying your code with profile configuration...");
432 deploy_with_secrets(&client, &result.tenant_id, &profile.name).await?;
433 }
434
435 warn_required_secrets(&validation.required_secrets);
436
437 Ok(stored_tenant)
438}
439
440pub fn swap_to_external_host(url: &str) -> String {
441 let Ok(parsed) = Url::parse(url) else {
442 return url.to_string();
443 };
444
445 let host = parsed.host_str().unwrap_or("");
446 let external_host = if host.contains("sandbox") {
447 "db-sandbox.systemprompt.io"
448 } else {
449 "db.systemprompt.io"
450 };
451
452 url.replace(host, external_host)
453 .replace("sslmode=disable", "sslmode=require")
454}
455
456fn print_database_connection_info(url: &str) {
457 let Ok(parsed) = Url::parse(url) else {
458 return;
459 };
460
461 let host = parsed.host_str().unwrap_or("unknown");
462 let port = parsed.port().unwrap_or(5432);
463 let database = parsed.path().trim_start_matches('/');
464 let username = parsed.username();
465 let password = parsed.password().unwrap_or("********");
466
467 CliService::section("Database Connection");
468 CliService::key_value("Host", host);
469 CliService::key_value("Port", &port.to_string());
470 CliService::key_value("Database", database);
471 CliService::key_value("User", username);
472 CliService::key_value("Password", password);
473 CliService::key_value("SSL", "required");
474 CliService::info("");
475 CliService::key_value("Connection URL", url);
476 CliService::info("");
477 CliService::info(&format!(
478 "Connect with psql:\n PGPASSWORD='{}' psql -h {} -p {} -U {} -d {}",
479 password, host, port, username, database
480 ));
481}