systemprompt-cli 0.2.1

Unified CLI for systemprompt.io AI governance: agent orchestration, MCP governance, analytics, profiles, cloud deploy, and self-hosted operations.
Documentation
use anyhow::{Context, Result, anyhow, bail};
use std::fs;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use systemprompt_cloud::ProjectContext;
use systemprompt_logging::CliService;

use super::config::{SHARED_CONTAINER_NAME, SHARED_VOLUME_NAME, shared_config_path};

pub fn is_shared_container_running() -> bool {
    let output = Command::new("docker")
        .args(["ps", "-q", "-f", &format!("name={}", SHARED_CONTAINER_NAME)])
        .output();

    match output {
        Ok(out) => !String::from_utf8_lossy(&out.stdout).trim().is_empty(),
        Err(e) => {
            tracing::debug!(error = %e, "Failed to check shared container status");
            false
        },
    }
}

pub fn get_container_password() -> Option<String> {
    let output = Command::new("docker")
        .args([
            "inspect",
            SHARED_CONTAINER_NAME,
            "--format",
            "{{range .Config.Env}}{{println .}}{{end}}",
        ])
        .output();

    match output {
        Ok(out) if out.status.success() => {
            let env_vars = String::from_utf8_lossy(&out.stdout);
            for line in env_vars.lines() {
                if let Some(password) = line.strip_prefix("POSTGRES_PASSWORD=") {
                    return Some(password.to_string());
                }
            }
            None
        },
        Ok(_out) => {
            tracing::debug!("Docker inspect returned non-success exit code");
            None
        },
        Err(e) => {
            tracing::debug!(error = %e, "Failed to inspect container");
            None
        },
    }
}

pub fn check_volume_exists() -> bool {
    let output = Command::new("docker")
        .args([
            "volume",
            "ls",
            "-q",
            "-f",
            &format!("name={}", SHARED_VOLUME_NAME),
        ])
        .output();

    match output {
        Ok(out) => !String::from_utf8_lossy(&out.stdout).trim().is_empty(),
        Err(e) => {
            tracing::debug!(error = %e, "Failed to check volume existence");
            false
        },
    }
}

pub fn remove_shared_volume() -> Result<()> {
    let status = Command::new("docker")
        .args(["volume", "rm", SHARED_VOLUME_NAME])
        .status()
        .context("Failed to remove PostgreSQL volume")?;

    if !status.success() {
        bail!(
            "Failed to remove volume '{}'. Is a container still using it?",
            SHARED_VOLUME_NAME
        );
    }

    Ok(())
}

pub fn stop_shared_container() -> Result<()> {
    let ctx = ProjectContext::discover();
    let compose_path = ctx.docker_dir().join("shared.yaml");

    if compose_path.exists() {
        let compose_path_str = compose_path
            .to_str()
            .ok_or_else(|| anyhow!("Invalid compose path"))?;

        CliService::info("Stopping shared PostgreSQL container...");
        let status = Command::new("docker")
            .args(["compose", "-f", compose_path_str, "down", "-v"])
            .status()
            .context("Failed to stop shared container")?;

        if !status.success() {
            CliService::warning("Failed to stop container via compose, trying direct stop");
        }
    }

    let output = Command::new("docker")
        .args([
            "ps",
            "-aq",
            "-f",
            &format!("name={}", SHARED_CONTAINER_NAME),
        ])
        .output()?;

    let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if !container_id.is_empty() {
        Command::new("docker")
            .args(["stop", &container_id])
            .status()?;
        Command::new("docker")
            .args(["rm", &container_id])
            .status()?;
    }

    let config_path = shared_config_path();
    if config_path.exists() {
        fs::remove_file(&config_path)?;
    }

    CliService::success("Shared PostgreSQL container removed");
    Ok(())
}

pub async fn wait_for_postgres_healthy(compose_path: &Path, timeout_secs: u64) -> Result<()> {
    let start = std::time::Instant::now();
    let compose_path_str = compose_path
        .to_str()
        .ok_or_else(|| anyhow!("Invalid compose path"))?;

    loop {
        let output = Command::new("docker")
            .args([
                "compose",
                "-f",
                compose_path_str,
                "ps",
                "--format",
                "{{.Health}}",
            ])
            .output()
            .context("Failed to check container health")?;

        let health = String::from_utf8_lossy(&output.stdout).trim().to_string();

        if health.contains("healthy") {
            return Ok(());
        }

        if start.elapsed().as_secs() > timeout_secs {
            bail!(
                "Timeout waiting for PostgreSQL to become healthy.\nCheck logs with: docker \
                 compose -f {} logs",
                compose_path.display()
            );
        }

        tokio::time::sleep(Duration::from_secs(2)).await;
    }
}

pub fn generate_shared_postgres_compose(password: &str, port: u16) -> String {
    format!(
        r#"# systemprompt.io Shared PostgreSQL Container
# Generated by: systemprompt cloud tenant create
# Manage: docker compose -f .systemprompt/docker/shared.yaml up/down

services:
  postgres:
    image: postgres:18-alpine
    container_name: {container_name}
    restart: unless-stopped
    environment:
      POSTGRES_USER: {admin_user}
      POSTGRES_PASSWORD: {password}
      POSTGRES_DB: postgres
    ports:
      - "{port}:5432"
    volumes:
      - {volume_name}:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U {admin_user}"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  {volume_name}:
    name: {volume_name}
"#,
        container_name = SHARED_CONTAINER_NAME,
        admin_user = super::config::SHARED_ADMIN_USER,
        password = password,
        port = port,
        volume_name = SHARED_VOLUME_NAME
    )
}

pub fn generate_admin_password() -> String {
    use std::time::{SystemTime, UNIX_EPOCH};
    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(1, |d| d.as_nanos());
    let random_part = format!("{:x}{:x}", timestamp, timestamp.wrapping_mul(31337));
    random_part.chars().take(32).collect()
}

pub fn nanoid() -> String {
    use std::time::{SystemTime, UNIX_EPOCH};
    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(1, |d| d.as_millis());
    format!("{:x}", timestamp)
}