systemprompt-cli 0.1.22

systemprompt.io OS - CLI for agent orchestration, AI operations, and system management
Documentation
use anyhow::{Context, Result};
use std::path::Path;
use systemprompt_cloud::ProjectContext;
use systemprompt_loader::ExtensionLoader;
use systemprompt_logging::CliService;
use systemprompt_models::auth::JwtAudience;
use systemprompt_models::profile::{SecretsConfig, SecretsSource, SecretsValidationMode};
use systemprompt_models::{
    CliPaths, CloudConfig, CloudValidationMode, ContentNegotiationConfig, Environment,
    ExtensionsConfig, LogLevel, OutputFormat, PathsConfig, Profile, ProfileDatabaseConfig,
    ProfileType, RateLimitsConfig, RuntimeConfig, SecurityConfig, SecurityHeadersConfig,
    ServerConfig, SiteConfig,
};

use crate::shared::profile::generate_display_name;

fn determine_environment(env_name: &str) -> Environment {
    match env_name.to_lowercase().as_str() {
        "prod" | "production" => Environment::Production,
        "staging" | "stage" => Environment::Staging,
        "test" | "testing" => Environment::Test,
        _ => Environment::Development,
    }
}

pub fn build(
    env_name: &str,
    secrets_path: &str,
    project_root: &Path,
    bin_path: Option<&Path>,
) -> Result<Profile> {
    let ctx = ProjectContext::new(project_root.to_path_buf());
    let system_path = project_root.to_string_lossy().to_string();
    let services_path = project_root.join("services").to_string_lossy().to_string();

    let runtime_env = determine_environment(env_name);
    let is_prod = matches!(runtime_env, Environment::Production);

    let profile = Profile {
        name: env_name.to_string(),
        display_name: generate_display_name(env_name),
        target: ProfileType::Local,
        site: SiteConfig {
            name: "systemprompt.io".to_string(),
            github_link: None,
        },
        database: ProfileDatabaseConfig {
            db_type: "postgres".to_string(),
            external_db_access: false,
        },
        server: ServerConfig {
            host: if is_prod {
                "0.0.0.0".to_string()
            } else {
                "127.0.0.1".to_string()
            },
            port: 8080,
            api_server_url: "http://localhost:8080".to_string(),
            api_internal_url: "http://localhost:8080".to_string(),
            api_external_url: "http://localhost:8080".to_string(),
            use_https: is_prod,
            cors_allowed_origins: vec![
                "http://localhost:8080".to_string(),
                "http://localhost:5173".to_string(),
                "http://127.0.0.1:8080".to_string(),
            ],
            content_negotiation: ContentNegotiationConfig::default(),
            security_headers: SecurityHeadersConfig::default(),
        },
        paths: PathsConfig {
            system: system_path,
            services: services_path,
            bin: bin_path.map_or_else(
                || {
                    ExtensionLoader::resolve_bin_directory(project_root, None)
                        .to_string_lossy()
                        .to_string()
                },
                |p| p.to_string_lossy().to_string(),
            ),
            storage: Some(ctx.storage_dir().to_string_lossy().to_string()),
            geoip_database: None,
            web_path: None,
        },
        security: SecurityConfig {
            issuer: format!("systemprompt-{}", env_name),
            access_token_expiration: 2_592_000,
            refresh_token_expiration: 15_552_000,
            audiences: vec![
                JwtAudience::Web,
                JwtAudience::Api,
                JwtAudience::A2a,
                JwtAudience::Mcp,
            ],
            allow_registration: true,
        },
        rate_limits: RateLimitsConfig {
            disabled: !is_prod,
            ..Default::default()
        },
        runtime: RuntimeConfig {
            environment: runtime_env,
            log_level: if is_prod {
                LogLevel::Normal
            } else {
                LogLevel::Verbose
            },
            output_format: OutputFormat::Text,
            no_color: false,
            non_interactive: is_prod,
        },
        cloud: Some(CloudConfig {
            tenant_id: None,
            validation: CloudValidationMode::Skip,
        }),
        secrets: Some(SecretsConfig {
            secrets_path: secrets_path.to_string(),
            validation: SecretsValidationMode::Warn,
            source: SecretsSource::File,
        }),
        extensions: ExtensionsConfig::default(),
    };

    validate_profile(&profile)?;
    Ok(profile)
}

fn validate_profile(profile: &Profile) -> Result<()> {
    if profile.security.issuer.is_empty() {
        anyhow::bail!("JWT issuer cannot be empty");
    }
    if profile.security.access_token_expiration <= 0 {
        anyhow::bail!("Access token expiration must be positive");
    }
    if profile.security.refresh_token_expiration <= 0 {
        anyhow::bail!("Refresh token expiration must be positive");
    }
    if profile.security.audiences.is_empty() {
        anyhow::bail!("At least one JWT audience must be configured");
    }
    Ok(())
}

pub fn save(profile: &Profile, profile_path: &Path) -> Result<()> {
    let header = format!(
        "# systemprompt.io Profile: {}\n#\n# Generated by 'systemprompt setup'\n#\n# WARNING: \
         This file contains database credentials.\n# DO NOT commit this file to version control.",
        profile.display_name
    );

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

    CliService::success(&format!("Saved profile to {}", profile_path.display()));

    Ok(())
}

pub fn default_path(systemprompt_dir: &Path, env_name: &str) -> std::path::PathBuf {
    systemprompt_dir
        .join("profiles")
        .join(format!("{}.profile.yaml", env_name))
}

pub fn run_migrations(profile_path: &Path) -> Result<()> {
    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 = std::process::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 successfully");

        let stdout = String::from_utf8_lossy(&output.stdout);
        for line in stdout.lines().filter(|l| !l.is_empty()) {
            CliService::info(&format!("    {}", line));
        }
        return Ok(());
    }

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

    CliService::error("Migrations failed");

    if !stdout.is_empty() {
        CliService::info(&stdout);
    }
    if !stderr.is_empty() {
        CliService::error(&stderr);
    }

    CliService::info("Run manually with:");
    CliService::info(&format!(
        "  SYSTEMPROMPT_PROFILE={} systemprompt {}",
        profile_path_str,
        CliPaths::db_migrate_cmd()
    ));

    Ok(())
}