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