systemprompt-sync 0.1.18

Sync services for systemprompt.io - file, database, and crate deployment synchronization
Documentation
use std::env;
use std::io::Write;
use std::path::PathBuf;
use std::process::Command;

use crate::api_client::SyncApiClient;
use crate::error::{SyncError, SyncResult};
use crate::{SyncConfig, SyncOperationResult};

#[derive(Debug)]
pub struct CrateDeployService {
    config: SyncConfig,
    api_client: SyncApiClient,
}

impl CrateDeployService {
    pub const fn new(config: SyncConfig, api_client: SyncApiClient) -> Self {
        Self { config, api_client }
    }

    pub async fn deploy(
        &self,
        skip_build: bool,
        custom_tag: Option<String>,
    ) -> SyncResult<SyncOperationResult> {
        let project_root = Self::get_project_root()?;
        let app_id = self.get_app_id().await?;

        let tag = if let Some(t) = custom_tag {
            t
        } else {
            let timestamp = chrono::Utc::now().timestamp();
            let git_sha = Self::get_git_sha()?;
            format!("deploy-{timestamp}-{git_sha}")
        };

        let image = format!("registry.fly.io/{app_id}:{tag}");

        if !skip_build {
            Self::build_release(&project_root)?;
        }

        Self::build_docker(&project_root, &image)?;

        let token = self
            .api_client
            .get_registry_token(&self.config.tenant_id)
            .await?;
        Self::docker_login(&token.registry, &token.username, &token.token)?;
        Self::docker_push(&image)?;

        let response = self
            .api_client
            .deploy(&self.config.tenant_id, &image)
            .await?;

        Ok(
            SyncOperationResult::success("crate_deploy", 1).with_details(serde_json::json!({
                "image": image,
                "status": response.status,
                "app_url": response.app_url,
            })),
        )
    }

    fn get_project_root() -> SyncResult<PathBuf> {
        let current = env::current_dir()?;
        if current.join("infrastructure").exists() {
            Ok(current)
        } else {
            Err(SyncError::NotProjectRoot)
        }
    }

    async fn get_app_id(&self) -> SyncResult<String> {
        self.api_client
            .get_tenant_app_id(&self.config.tenant_id)
            .await
    }

    fn get_git_sha() -> SyncResult<String> {
        let output = Command::new("git")
            .args(["rev-parse", "--short", "HEAD"])
            .output()?;

        String::from_utf8(output.stdout)
            .map(|sha| sha.trim().to_string())
            .map_err(|_| SyncError::GitShaUnavailable)
    }

    fn build_release(project_root: &PathBuf) -> SyncResult<()> {
        Self::run_command(
            "cargo",
            &[
                "build",
                "--release",
                "--manifest-path=core/Cargo.toml",
                "--bin",
                "systemprompt",
            ],
            project_root,
        )
    }

    fn build_docker(project_root: &PathBuf, image: &str) -> SyncResult<()> {
        Self::run_command(
            "docker",
            &[
                "build",
                "-f",
                "infrastructure/docker/app.Dockerfile",
                "-t",
                image,
                ".",
            ],
            project_root,
        )
    }

    fn docker_login(registry: &str, username: &str, token: &str) -> SyncResult<()> {
        let mut command = Command::new("docker");
        command.args(["login", registry, "-u", username, "--password-stdin"]);
        command.stdin(std::process::Stdio::piped());

        let mut child = command.spawn()?;
        if let Some(mut stdin) = child.stdin.take() {
            stdin.write_all(token.as_bytes())?;
        }

        let status = child.wait()?;
        if !status.success() {
            return Err(SyncError::DockerLoginFailed);
        }
        Ok(())
    }

    fn docker_push(image: &str) -> SyncResult<()> {
        Self::run_command("docker", &["push", image], &env::current_dir()?)
    }

    fn run_command(cmd: &str, args: &[&str], dir: &PathBuf) -> SyncResult<()> {
        let status = Command::new(cmd).args(args).current_dir(dir).status()?;

        if !status.success() {
            return Err(SyncError::CommandFailed {
                command: format!("{cmd} {}", args.join(" ")),
            });
        }
        Ok(())
    }
}