Skip to main content

systemprompt_cli/commands/cloud/profile/
templates.rs

1//! Writers for the on-disk artifacts of a created profile.
2//!
3//! Persists the profile YAML, Dockerfile, entrypoint, dockerignore, and
4//! secrets file (with restrictive permissions), and updates the AI config's
5//! default provider.
6
7use anyhow::{Context, Result};
8use regex::Regex;
9use std::path::Path;
10use systemprompt_cloud::constants::container;
11use systemprompt_logging::CliService;
12use systemprompt_models::{CliPaths, Profile};
13
14use crate::commands::cloud::init::templates::ai_config;
15
16use crate::cloud::dockerfile::DockerfileBuilder;
17
18pub use crate::shared::profile::{generate_display_name, generate_oauth_at_rest_pepper};
19
20pub fn save_profile(profile: &Profile, profile_path: &Path) -> Result<()> {
21    let header = format!(
22        "# systemprompt.io Profile: {}\n# Generated by 'systemprompt cloud profile create'",
23        profile.display_name
24    );
25
26    crate::shared::profile::save_profile_yaml(profile, profile_path, Some(&header))
27}
28
29pub fn save_dockerfile(path: &Path, profile_name: &str, project_root: &Path) -> Result<()> {
30    let content = DockerfileBuilder::new(project_root)
31        .with_profile(profile_name)
32        .build();
33
34    std::fs::write(path, &content)
35        .with_context(|| format!("Failed to write {}", path.display()))?;
36
37    Ok(())
38}
39
40pub fn save_entrypoint(path: &Path) -> Result<()> {
41    let content = format!(
42        r#"#!/bin/sh
43set -e
44
45echo "Running database migrations..."
46{bin}/systemprompt {db_migrate_cmd}
47
48echo "Starting services..."
49exec {bin}/systemprompt {services_serve_cmd} --foreground
50"#,
51        bin = container::BIN,
52        db_migrate_cmd = CliPaths::db_migrate_cmd(),
53        services_serve_cmd = CliPaths::services_serve_cmd(),
54    );
55
56    if let Some(parent) = path.parent() {
57        std::fs::create_dir_all(parent)
58            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
59    }
60
61    std::fs::write(path, &content)
62        .with_context(|| format!("Failed to write {}", path.display()))?;
63
64    #[cfg(unix)]
65    {
66        use std::os::unix::fs::PermissionsExt;
67        let permissions = std::fs::Permissions::from_mode(0o755);
68        std::fs::set_permissions(path, permissions)
69            .with_context(|| format!("Failed to set permissions on {}", path.display()))?;
70    }
71
72    Ok(())
73}
74
75pub fn save_dockerignore(path: &Path) -> Result<()> {
76    let content = r".git
77.gitignore
78.gitmodules
79target/debug
80target/release/.fingerprint
81target/release/build
82target/release/deps
83target/release/examples
84target/release/incremental
85target/release/.cargo-lock
86.cargo
87.systemprompt/credentials.json
88.systemprompt/tenants.json
89.systemprompt/**/secrets.json
90.systemprompt/docker
91.systemprompt/storage
92.env*
93backup
94docs
95instructions
96*.md
97web/node_modules
98.vscode
99.idea
100logs
101*.log
102plan
103";
104
105    if let Some(parent) = path.parent() {
106        std::fs::create_dir_all(parent)
107            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
108    }
109
110    std::fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))?;
111
112    Ok(())
113}
114
115#[derive(Debug)]
116pub struct DatabaseUrls<'a> {
117    pub external: &'a str,
118    pub internal: Option<&'a str>,
119}
120
121pub fn save_secrets(
122    db_urls: &DatabaseUrls<'_>,
123    api_keys: &super::api_keys::ApiKeys,
124    secrets_path: &Path,
125    _is_cloud_tenant: bool,
126) -> Result<()> {
127    use serde_json::json;
128    use systemprompt_models::Profile;
129
130    if Profile::is_masked_database_url(db_urls.external) {
131        CliService::warning(
132            "Database URL appears to be masked. Credentials may not work correctly.",
133        );
134        CliService::warning(
135            "Run 'systemprompt cloud tenant refresh-credentials' to fetch real credentials.",
136        );
137    }
138
139    if let Some(internal) = db_urls.internal {
140        if Profile::is_masked_database_url(internal) {
141            CliService::warning(
142                "Internal database URL appears to be masked. Credentials may not work correctly.",
143            );
144        }
145    }
146
147    if let Some(parent) = secrets_path.parent() {
148        std::fs::create_dir_all(parent)
149            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
150    }
151
152    let mut secrets = json!({
153        "oauth_at_rest_pepper": generate_oauth_at_rest_pepper(),
154        "database_url": db_urls.external,
155        "external_database_url": db_urls.external,
156        "gemini": api_keys.gemini,
157        "anthropic": api_keys.anthropic,
158        "openai": api_keys.openai
159    });
160
161    if let Some(internal) = db_urls.internal {
162        secrets["internal_database_url"] = json!(internal);
163    }
164
165    let content = serde_json::to_string_pretty(&secrets).context("Failed to serialize secrets")?;
166
167    std::fs::write(secrets_path, content)
168        .with_context(|| format!("Failed to write {}", secrets_path.display()))?;
169
170    #[cfg(unix)]
171    {
172        use std::os::unix::fs::PermissionsExt;
173        let permissions = std::fs::Permissions::from_mode(0o600);
174        std::fs::set_permissions(secrets_path, permissions)
175            .with_context(|| format!("Failed to set permissions on {}", secrets_path.display()))?;
176    }
177
178    Ok(())
179}
180
181pub fn get_services_path() -> Result<String> {
182    if let Ok(path) = std::env::var("SYSTEMPROMPT_SERVICES_PATH") {
183        return Ok(path);
184    }
185
186    let cwd = std::env::current_dir().context("Failed to get current directory")?;
187    let services_path = cwd.join("services");
188
189    Ok(services_path.to_string_lossy().to_string())
190}
191
192pub async fn validate_connection(db_url: &str) -> bool {
193    use tokio::time::{Duration, timeout};
194
195    let result = timeout(Duration::from_secs(5), async {
196        sqlx::postgres::PgPoolOptions::new()
197            .max_connections(1)
198            .connect(db_url)
199            .await
200    })
201    .await;
202
203    matches!(result, Ok(Ok(_)))
204}
205
206pub fn run_migrations_cmd(profile_path: &Path) -> Result<()> {
207    use std::process::Command;
208
209    CliService::info("Running database migrations...");
210
211    let current_exe = std::env::current_exe().context("Failed to get executable path")?;
212    let profile_path_str = profile_path.to_string_lossy();
213
214    let output = Command::new(&current_exe)
215        .args(CliPaths::db_migrate_args())
216        .env("SYSTEMPROMPT_PROFILE", profile_path_str.as_ref())
217        .output()
218        .context("Failed to run migrations")?;
219
220    if output.status.success() {
221        CliService::success("Migrations completed");
222        return Ok(());
223    }
224
225    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
226    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
227
228    let error_output = if !stderr.is_empty() {
229        stderr
230    } else if !stdout.is_empty() {
231        stdout
232    } else {
233        "Unknown error (no output)".to_owned()
234    };
235
236    anyhow::bail!("Migration failed: {}", error_output)
237}
238
239pub fn update_ai_config_default_provider(provider: &str) -> Result<()> {
240    let services_path = get_services_path()?;
241    let ai_dir = Path::new(&services_path).join("ai");
242    let ai_config_path = ai_dir.join("config.yaml");
243
244    if !ai_config_path.exists() {
245        CliService::warning("AI config not found. Creating services/ai/config.yaml");
246        CliService::info("Run 'systemprompt cloud init' for full project setup");
247
248        std::fs::create_dir_all(&ai_dir)
249            .with_context(|| format!("Failed to create directory {}", ai_dir.display()))?;
250        std::fs::write(&ai_config_path, ai_config(provider))
251            .with_context(|| format!("Failed to write {}", ai_config_path.display()))?;
252        CliService::success(&format!("Created: {}", ai_config_path.display()));
253        return Ok(());
254    }
255
256    let content = std::fs::read_to_string(&ai_config_path)
257        .with_context(|| format!("Failed to read {}", ai_config_path.display()))?;
258    let re = Regex::new(r#"default_provider:\s*"?\w+"?"#).context("Failed to compile regex")?;
259    let updated = re.replace(&content, format!(r#"default_provider: "{}""#, provider));
260
261    std::fs::write(&ai_config_path, updated.as_ref())
262        .with_context(|| format!("Failed to write {}", ai_config_path.display()))?;
263    CliService::success(&format!(
264        "Updated default_provider to '{}' in AI config",
265        provider
266    ));
267    Ok(())
268}