systemprompt-cli 0.2.2

Unified CLI for systemprompt.io AI governance: agent orchestration, MCP governance, analytics, profiles, cloud deploy, and self-hosted operations.
Documentation
use anyhow::{Context, Result};
use regex::Regex;
use std::path::Path;
use systemprompt_cloud::constants::container;
use systemprompt_logging::CliService;
use systemprompt_models::{CliPaths, Profile};

use crate::commands::cloud::init::templates::ai_config;

use crate::cloud::dockerfile::DockerfileBuilder;

pub use crate::shared::profile::{generate_display_name, generate_jwt_secret};

pub fn save_profile(profile: &Profile, profile_path: &Path) -> Result<()> {
    let header = format!(
        "# systemprompt.io Profile: {}\n# Generated by 'systemprompt cloud profile create'",
        profile.display_name
    );

    crate::shared::profile::save_profile_yaml(profile, profile_path, Some(&header))
}

pub fn save_dockerfile(path: &Path, profile_name: &str, project_root: &Path) -> Result<()> {
    let content = DockerfileBuilder::new(project_root)
        .with_profile(profile_name)
        .build();

    std::fs::write(path, &content)
        .with_context(|| format!("Failed to write {}", path.display()))?;

    Ok(())
}

pub fn save_entrypoint(path: &Path) -> Result<()> {
    let content = format!(
        r#"#!/bin/sh
set -e

echo "Running database migrations..."
{bin}/systemprompt {db_migrate_cmd}

echo "Starting services..."
exec {bin}/systemprompt {services_serve_cmd} --foreground
"#,
        bin = container::BIN,
        db_migrate_cmd = CliPaths::db_migrate_cmd(),
        services_serve_cmd = CliPaths::services_serve_cmd(),
    );

    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
    }

    std::fs::write(path, &content)
        .with_context(|| format!("Failed to write {}", path.display()))?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let permissions = std::fs::Permissions::from_mode(0o755);
        std::fs::set_permissions(path, permissions)
            .with_context(|| format!("Failed to set permissions on {}", path.display()))?;
    }

    Ok(())
}

pub fn save_dockerignore(path: &Path) -> Result<()> {
    let content = r".git
.gitignore
.gitmodules
target/debug
target/release/.fingerprint
target/release/build
target/release/deps
target/release/examples
target/release/incremental
target/release/.cargo-lock
.cargo
.systemprompt/credentials.json
.systemprompt/tenants.json
.systemprompt/**/secrets.json
.systemprompt/docker
.systemprompt/storage
.env*
backup
docs
instructions
*.md
web/node_modules
.vscode
.idea
logs
*.log
plan
";

    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
    }

    std::fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))?;

    Ok(())
}

#[derive(Debug)]
pub struct DatabaseUrls<'a> {
    pub external: &'a str,
    pub internal: Option<&'a str>,
}

pub fn save_secrets(
    db_urls: &DatabaseUrls<'_>,
    api_keys: &super::api_keys::ApiKeys,
    sync_token: Option<&str>,
    secrets_path: &Path,
    is_cloud_tenant: bool,
) -> Result<()> {
    use serde_json::json;
    use systemprompt_models::Profile;

    if Profile::is_masked_database_url(db_urls.external) {
        CliService::warning(
            "Database URL appears to be masked. Credentials may not work correctly.",
        );
        CliService::warning(
            "Run 'systemprompt cloud tenant refresh-credentials' to fetch real credentials.",
        );
    }

    if let Some(internal) = db_urls.internal {
        if Profile::is_masked_database_url(internal) {
            CliService::warning(
                "Internal database URL appears to be masked. Credentials may not work correctly.",
            );
        }
    }

    if sync_token.is_none() && is_cloud_tenant {
        CliService::warning("Sync token not available. Cloud sync will not work.");
        CliService::warning("Run 'systemprompt cloud tenant rotate-sync-token' to generate one.");
    }

    if let Some(parent) = secrets_path.parent() {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
    }

    let mut secrets = json!({
        "jwt_secret": generate_jwt_secret(),
        "database_url": db_urls.external,
        "external_database_url": db_urls.external,
        "gemini": api_keys.gemini,
        "anthropic": api_keys.anthropic,
        "openai": api_keys.openai
    });

    if let Some(internal) = db_urls.internal {
        secrets["internal_database_url"] = json!(internal);
    }

    if let Some(token) = sync_token {
        secrets["sync_token"] = json!(token);
    }

    let content = serde_json::to_string_pretty(&secrets).context("Failed to serialize secrets")?;

    std::fs::write(secrets_path, content)
        .with_context(|| format!("Failed to write {}", secrets_path.display()))?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let permissions = std::fs::Permissions::from_mode(0o600);
        std::fs::set_permissions(secrets_path, permissions)
            .with_context(|| format!("Failed to set permissions on {}", secrets_path.display()))?;
    }

    Ok(())
}


pub fn get_services_path() -> Result<String> {
    if let Ok(path) = std::env::var("SYSTEMPROMPT_SERVICES_PATH") {
        return Ok(path);
    }

    let cwd = std::env::current_dir().context("Failed to get current directory")?;
    let services_path = cwd.join("services");

    Ok(services_path.to_string_lossy().to_string())
}

pub async fn validate_connection(db_url: &str) -> bool {
    use tokio::time::{Duration, timeout};

    let result = timeout(Duration::from_secs(5), async {
        sqlx::postgres::PgPoolOptions::new()
            .max_connections(1)
            .connect(db_url)
            .await
    })
    .await;

    matches!(result, Ok(Ok(_)))
}

pub fn run_migrations_cmd(profile_path: &Path) -> Result<()> {
    use std::process::Command;

    CliService::info("Running database migrations...");

    let current_exe = std::env::current_exe().context("Failed to get executable path")?;
    let profile_path_str = profile_path.to_string_lossy();

    let output = Command::new(&current_exe)
        .args(CliPaths::db_migrate_args())
        .env("SYSTEMPROMPT_PROFILE", profile_path_str.as_ref())
        .output()
        .context("Failed to run migrations")?;

    if output.status.success() {
        CliService::success("Migrations completed");
        return Ok(());
    }

    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();

    let error_output = if !stderr.is_empty() {
        stderr
    } else if !stdout.is_empty() {
        stdout
    } else {
        "Unknown error (no output)".to_string()
    };

    anyhow::bail!("Migration failed: {}", error_output)
}

pub fn update_ai_config_default_provider(provider: &str) -> Result<()> {
    let services_path = get_services_path()?;
    let ai_dir = Path::new(&services_path).join("ai");
    let ai_config_path = ai_dir.join("config.yaml");

    if !ai_config_path.exists() {
        CliService::warning("AI config not found. Creating services/ai/config.yaml");
        CliService::info("Run 'systemprompt cloud init' for full project setup");

        std::fs::create_dir_all(&ai_dir)
            .with_context(|| format!("Failed to create directory {}", ai_dir.display()))?;
        std::fs::write(&ai_config_path, ai_config(provider))
            .with_context(|| format!("Failed to write {}", ai_config_path.display()))?;
        CliService::success(&format!("Created: {}", ai_config_path.display()));
        return Ok(());
    }

    let content = std::fs::read_to_string(&ai_config_path)
        .with_context(|| format!("Failed to read {}", ai_config_path.display()))?;
    let re = Regex::new(r#"default_provider:\s*"?\w+"?"#).context("Failed to compile regex")?;
    let updated = re.replace(&content, format!(r#"default_provider: "{}""#, provider));

    std::fs::write(&ai_config_path, updated.as_ref())
        .with_context(|| format!("Failed to write {}", ai_config_path.display()))?;
    CliService::success(&format!(
        "Updated default_provider to '{}' in AI config",
        provider
    ));
    Ok(())
}