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(¤t_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(())
}