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 std::collections::HashSet;
use std::path::{Path, PathBuf};

use systemprompt_cloud::constants::{container, storage};
use systemprompt_extension::ExtensionRegistry;
use systemprompt_loader::{ConfigLoader, ExtensionLoader};
use systemprompt_models::{CliPaths, ServicesConfig};

use super::super::tenant::find_services_config;

#[derive(Debug)]
pub struct DockerfileBuilder<'a> {
    project_root: &'a Path,
    profile_name: Option<&'a str>,
    services_config: Option<ServicesConfig>,
}

impl<'a> DockerfileBuilder<'a> {
    pub fn new(project_root: &'a Path) -> Self {
        let services_config = find_services_config(project_root)
            .map_err(|e| {
                tracing::debug!(error = %e, "No services config found for dockerfile generation");
                e
            })
            .ok()
            .and_then(|path| {
                ConfigLoader::load_from_path(&path)
                    .map_err(|e| {
                        tracing::warn!(error = %e, "Failed to load services config");
                        e
                    })
                    .ok()
            });
        Self {
            project_root,
            profile_name: None,
            services_config,
        }
    }

    pub const fn with_profile(mut self, name: &'a str) -> Self {
        self.profile_name = Some(name);
        self
    }

    pub fn build(&self) -> String {
        let mcp_section = self.mcp_copy_section();
        let env_section = self.env_section();
        let extension_dirs = Self::extension_storage_dirs();
        let extension_assets_section = self.extension_asset_copy_section();

        format!(
            r#"# systemprompt.io Application Dockerfile
# Built by: systemprompt cloud profile create
# Used by: systemprompt cloud deploy

FROM debian:bookworm-slim

# Install runtime dependencies
RUN apt-get update && apt-get install -y \
    ca-certificates \
    curl \
    libssl3 \
    libpq5 \
    lsof \
    && rm -rf /var/lib/apt/lists/*

RUN useradd -m -u 1000 app
WORKDIR {app}

RUN mkdir -p {bin} {logs} {storage}/{images} {storage}/{generated} {storage}/{logos} {storage}/{audio} {storage}/{video} {storage}/{documents} {storage}/{uploads} {web}{extension_dirs}

# Copy pre-built binaries
COPY target/release/systemprompt {bin}/
{mcp_section}
# Copy storage assets (images, etc.)
COPY storage {storage}

# Copy web dist (generated HTML, CSS, JS)
COPY web/dist {web_dist}
{extension_assets_section}
# Copy services configuration
COPY services {services_path}

# Copy profiles
COPY .systemprompt/profiles {profiles}
RUN chmod +x {bin}/* && chown -R app:app {app}

USER app
EXPOSE 8080

# Environment configuration
{env_section}

HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
    CMD curl -f http://localhost:8080/api/v1/health || exit 1

CMD ["{bin}/systemprompt", "{cmd_infra}", "{cmd_services}", "{cmd_serve}", "--foreground"]
"#,
            app = container::APP,
            bin = container::BIN,
            logs = container::LOGS,
            storage = container::STORAGE,
            web = container::WEB,
            web_dist = container::WEB_DIST,
            services_path = container::SERVICES,
            profiles = container::PROFILES,
            images = storage::IMAGES,
            generated = storage::GENERATED,
            logos = storage::LOGOS,
            audio = storage::AUDIO,
            video = storage::VIDEO,
            documents = storage::DOCUMENTS,
            uploads = storage::UPLOADS,
            extension_dirs = extension_dirs,
            mcp_section = mcp_section,
            env_section = env_section,
            extension_assets_section = extension_assets_section,
            cmd_infra = CliPaths::INFRA,
            cmd_services = CliPaths::SERVICES,
            cmd_serve = CliPaths::SERVE,
        )
    }

    fn extension_storage_dirs() -> String {
        let registry = ExtensionRegistry::discover();
        let paths = registry.all_required_storage_paths();
        if paths.is_empty() {
            return String::new();
        }

        let mut result = String::new();
        for path in paths {
            result.push(' ');
            result.push_str(container::STORAGE);
            result.push('/');
            result.push_str(path);
        }
        result
    }

    fn extension_asset_copy_section(&self) -> String {
        let discovered = ExtensionLoader::discover(self.project_root);

        if discovered.is_empty() {
            return String::new();
        }

        let ext_dirs: HashSet<PathBuf> = discovered
            .iter()
            .filter_map(|ext| ext.path.strip_prefix(self.project_root).ok())
            .map(Path::to_path_buf)
            .collect();

        if ext_dirs.is_empty() {
            return String::new();
        }

        let mut sorted_dirs: Vec<_> = ext_dirs.into_iter().collect();
        sorted_dirs.sort();

        let copy_lines: Vec<_> = sorted_dirs
            .iter()
            .map(|dir| {
                format!(
                    "COPY {} {}/{}",
                    dir.display(),
                    container::APP,
                    dir.display()
                )
            })
            .collect();

        format!("\n# Copy extension assets\n{}\n", copy_lines.join("\n"))
    }

    fn mcp_copy_section(&self) -> String {
        let binaries = self.services_config.as_ref().map_or_else(
            || ExtensionLoader::get_mcp_binary_names(self.project_root),
            |config| ExtensionLoader::get_production_mcp_binary_names(self.project_root, config),
        );

        if binaries.is_empty() {
            return String::new();
        }

        let lines: Vec<String> = binaries
            .iter()
            .map(|bin| format!("COPY target/release/{} {}/", bin, container::BIN))
            .collect();

        format!("\n# Copy MCP server binaries\n{}\n", lines.join("\n"))
    }

    fn env_section(&self) -> String {
        let profile_env = self.profile_name.map_or_else(String::new, |name| {
            format!(
                "    SYSTEMPROMPT_PROFILE={}/{}/profile.yaml \\",
                container::PROFILES,
                name
            )
        });

        if profile_env.is_empty() {
            format!(
                r#"ENV HOST=0.0.0.0 \
    PORT=8080 \
    RUST_LOG=info \
    PATH="{}:$PATH" \
    SYSTEMPROMPT_SERVICES_PATH={} \
    SYSTEMPROMPT_TEMPLATES_PATH={} \
    SYSTEMPROMPT_ASSETS_PATH={}"#,
                container::BIN,
                container::SERVICES,
                container::TEMPLATES,
                container::ASSETS
            )
        } else {
            format!(
                r#"ENV HOST=0.0.0.0 \
    PORT=8080 \
    RUST_LOG=info \
    PATH="{}:$PATH" \
{}
    SYSTEMPROMPT_SERVICES_PATH={} \
    SYSTEMPROMPT_TEMPLATES_PATH={} \
    SYSTEMPROMPT_ASSETS_PATH={}"#,
                container::BIN,
                profile_env,
                container::SERVICES,
                container::TEMPLATES,
                container::ASSETS
            )
        }
    }
}