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