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