mod deploy_steps;
mod pre_sync;
mod select;
pub use deploy_steps::deploy_with_secrets;
use std::path::PathBuf;
use anyhow::{Context, Result, anyhow, bail};
use systemprompt_cloud::constants::{build, container, paths};
use systemprompt_cloud::{
CloudApiClient, CloudPath, ProfilePath, ProjectContext, TenantStore, get_cloud_paths,
};
use systemprompt_identifiers::TenantId;
use systemprompt_logging::CliService;
use super::dockerfile::validate_profile_dockerfile;
use super::secrets::sync_cloud_credentials;
use super::tenant::{find_services_config, get_credentials};
use crate::cli_settings::CliConfig;
use crate::shared::docker::{build_docker_image, docker_login, docker_push};
use crate::shared::project::ProjectRoot;
use select::resolve_profile;
use systemprompt_extension::{AssetPaths, ExtensionRegistry};
use systemprompt_loader::ConfigLoader;
struct ProjectAssetPaths {
storage_files: PathBuf,
web_dist: PathBuf,
}
impl AssetPaths for ProjectAssetPaths {
fn storage_files(&self) -> &std::path::Path {
&self.storage_files
}
fn web_dist(&self) -> &std::path::Path {
&self.web_dist
}
}
#[derive(Debug)]
pub struct DeployConfig {
pub binary: PathBuf,
pub dockerfile: PathBuf,
project_root: PathBuf,
}
impl DeployConfig {
pub fn from_project(project: &ProjectRoot, profile_name: &str) -> Result<Self> {
let root = project.as_path();
let binary = root
.join(build::CARGO_TARGET)
.join("release")
.join(build::BINARY_NAME);
let ctx = ProjectContext::new(root.to_path_buf());
let dockerfile = ctx.profile_dockerfile(profile_name);
let config = Self {
binary,
dockerfile,
project_root: root.to_path_buf(),
};
config.validate()?;
Ok(config)
}
fn validate(&self) -> Result<()> {
if !self.binary.exists() {
return Err(anyhow!(
"Release binary not found: {}\n\nRun: cargo build --release --bin systemprompt",
self.binary.display()
));
}
self.validate_extension_assets()?;
self.validate_storage_directory()?;
self.validate_templates_directory()?;
if !self.dockerfile.exists() {
return Err(anyhow!(
"Dockerfile not found: {}\n\nCreate a Dockerfile at this location",
self.dockerfile.display()
));
}
Ok(())
}
fn validate_extension_assets(&self) -> Result<()> {
let paths = ProjectAssetPaths {
storage_files: self.project_root.join("storage/files"),
web_dist: self.project_root.join("web/dist"),
};
let registry = ExtensionRegistry::discover();
let mut missing = Vec::new();
let mut outside_context = Vec::new();
for ext in registry.asset_extensions() {
let ext_id = ext.id();
for asset in ext.required_assets(&paths) {
if !asset.is_required() {
continue;
}
let source = asset.source();
if !source.exists() {
missing.push(format!("[ext:{}] {}", ext_id, source.display()));
continue;
}
if !source.starts_with(&self.project_root) {
outside_context.push(format!(
"[ext:{}] {} (not under {})",
ext_id,
source.display(),
self.project_root.display()
));
}
}
}
if !missing.is_empty() {
bail!(
"Missing required extension assets:\n {}\n\nCreate these files or mark them as \
optional.",
missing.join("\n ")
);
}
if !outside_context.is_empty() {
bail!(
"Extension assets outside Docker build context:\n {}\n\nMove assets inside the \
project directory.",
outside_context.join("\n ")
);
}
Ok(())
}
fn validate_storage_directory(&self) -> Result<()> {
let storage_dir = self.project_root.join("storage");
if !storage_dir.exists() {
bail!(
"Storage directory not found: {}\n\nExpected: storage/\n\nCreate this directory \
for files, images, and other assets.",
storage_dir.display()
);
}
let files_dir = storage_dir.join("files");
if !files_dir.exists() {
bail!(
"Storage files directory not found: {}\n\nExpected: storage/files/\n\nThis \
directory is required for serving static assets.",
files_dir.display()
);
}
Ok(())
}
fn validate_templates_directory(&self) -> Result<()> {
let templates_dir = self.project_root.join("services/web/templates");
if !templates_dir.exists() {
bail!(
"Templates directory not found: {}\n\nExpected: services/web/templates/\n\nCreate \
this directory with your HTML templates.",
templates_dir.display()
);
}
Ok(())
}
}
pub struct DeployArgs {
pub skip_push: bool,
pub profile_name: Option<String>,
pub no_sync: bool,
pub yes: bool,
pub dry_run: bool,
}
pub async fn execute(args: DeployArgs, config: &CliConfig) -> Result<()> {
CliService::section("systemprompt.io Cloud Deploy");
let (profile, profile_path) = resolve_profile(args.profile_name.as_deref(), config)?;
let cloud_config = profile
.cloud
.as_ref()
.ok_or_else(|| anyhow!("No cloud configuration in profile"))?;
if profile.target != systemprompt_models::ProfileType::Cloud {
bail!(
"Cannot deploy a local profile. Create a cloud profile with: systemprompt cloud \
profile create <name>"
);
}
let tenant_id = cloud_config
.tenant_id
.as_ref()
.map(TenantId::new)
.ok_or_else(|| anyhow!("No tenant configured. Run 'systemprompt cloud config'"))?;
let creds = get_credentials()?;
if creds.is_token_expired() {
bail!("Token expired. Run 'systemprompt cloud login' to refresh.");
}
let cloud_paths = get_cloud_paths();
let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
let tenant_store = TenantStore::load_from_path(&tenants_path)
.context("Tenants not synced. Run 'systemprompt cloud login'")?;
let tenant = tenant_store
.find_tenant(tenant_id.as_str())
.ok_or_else(|| {
anyhow!(
"Tenant {} not found. Run 'systemprompt cloud login'",
tenant_id
)
})?;
let tenant_name = &tenant.name;
let sync_result = pre_sync::execute(
&tenant_id,
pre_sync::PreSyncConfig {
no_sync: args.no_sync,
yes: args.yes,
dry_run: args.dry_run,
},
config,
&profile_path,
)
.await?;
if sync_result.dry_run {
CliService::info("Dry run complete. No deployment performed.");
return Ok(());
}
let project = ProjectRoot::discover().map_err(|e| anyhow!("{}", e))?;
let config = DeployConfig::from_project(&project, &profile.name)?;
CliService::key_value("Tenant", tenant_name);
CliService::key_value("Binary", &config.binary.display().to_string());
CliService::key_value("Dockerfile", &config.dockerfile.display().to_string());
let services_config_path = find_services_config(project.as_path())?;
let services_config = ConfigLoader::load_from_path(&services_config_path)?;
validate_profile_dockerfile(&config.dockerfile, project.as_path(), &services_config)?;
let api_client = CloudApiClient::new(&creds.api_url, &creds.api_token)?;
let spinner = CliService::spinner("Fetching registry credentials...");
let registry_token = api_client.get_registry_token(tenant_id.as_str()).await?;
spinner.finish_and_clear();
let image = format!(
"{}/{}:{}",
registry_token.registry, registry_token.repository, registry_token.tag
);
CliService::key_value("Image", &image);
let spinner = CliService::spinner("Building Docker image...");
build_docker_image(project.as_path(), &config.dockerfile, &image)?;
spinner.finish_and_clear();
CliService::success("Docker image built");
if args.skip_push {
CliService::info("Push skipped (--skip-push)");
} else {
let spinner = CliService::spinner("Pushing to registry...");
docker_login(
®istry_token.registry,
®istry_token.username,
®istry_token.token,
)?;
docker_push(&image)?;
spinner.finish_and_clear();
CliService::success("Image pushed");
}
let spinner = CliService::spinner("Deploying...");
let response = api_client.deploy(tenant_id.as_str(), &image).await?;
spinner.finish_and_clear();
CliService::success("Deployed!");
CliService::key_value("Status", &response.status);
if let Some(url) = response.app_url {
CliService::key_value("URL", &url);
}
CliService::section("Syncing Secrets");
let profile_dir = profile_path
.parent()
.ok_or_else(|| anyhow!("Invalid profile path"))?;
let secrets_path = ProfilePath::Secrets.resolve(profile_dir);
if secrets_path.exists() {
let secrets = super::secrets::load_secrets_json(&secrets_path)?;
if !secrets.is_empty() {
let env_secrets = super::secrets::map_secrets_to_env_vars(secrets);
let spinner = CliService::spinner("Syncing secrets...");
let keys = api_client
.set_secrets(tenant_id.as_str(), env_secrets)
.await?;
spinner.finish_and_clear();
CliService::success(&format!("Synced {} secrets", keys.len()));
}
} else {
CliService::warning("No secrets.json found - skipping secrets sync");
}
CliService::section("Syncing Cloud Credentials");
let spinner = CliService::spinner("Syncing cloud credentials...");
let keys = sync_cloud_credentials(&api_client, &tenant_id, &creds).await?;
spinner.finish_and_clear();
CliService::success(&format!("Synced {} cloud credentials", keys.len()));
let profile_env_path = format!(
"{}/{}/{}",
container::PROFILES,
profile.name,
paths::PROFILE_CONFIG
);
let spinner = CliService::spinner("Setting profile path...");
let mut profile_secret = std::collections::HashMap::new();
profile_secret.insert("SYSTEMPROMPT_PROFILE".to_string(), profile_env_path);
api_client
.set_secrets(tenant_id.as_str(), profile_secret)
.await?;
spinner.finish_and_clear();
CliService::success("Profile path configured");
Ok(())
}