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
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(
            &registry_token.registry,
            &registry_token.username,
            &registry_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(())
}