agent-infra-cli 0.6.0

Command-line client for Agent Infra
Documentation
use std::{
    fs::{self, OpenOptions},
    io::Write,
    path::{Path, PathBuf},
    process::Command,
};

#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;

use agent_infra_sdk::{
    deploy_contract::{OwnerKind, OwnerRef, RuntimeRepositoryRecord},
    runtime_repository_checkout_dir, runtime_repository_env_local, unique_owner_id,
};
use anyhow::{Context, Result, bail};

pub struct CheckoutOptions {
    pub dir: Option<PathBuf>,
    pub clone: bool,
    pub agent_id: Option<String>,
    pub runtime_id: Option<String>,
}

pub fn merge_identity_owners(
    mut owners: Vec<OwnerRef>,
    agent_id: Option<&str>,
    runtime_id: Option<&str>,
) -> Vec<OwnerRef> {
    if let Some(id) = agent_id.map(str::trim).filter(|id| !id.is_empty()) {
        owners.push(OwnerRef::agent(id));
    }
    if let Some(id) = runtime_id.map(str::trim).filter(|id| !id.is_empty()) {
        owners.push(OwnerRef::runtime(id));
    }
    owners
}

pub fn checkout_runtime_repository(
    record: &RuntimeRepositoryRecord,
    options: &CheckoutOptions,
) -> Result<PathBuf> {
    let agent_id = resolve_identity_id(
        options.agent_id.as_deref(),
        &record.owners,
        OwnerKind::Agent,
        "--agent-id",
    )?;
    let runtime_id = resolve_identity_id(
        options.runtime_id.as_deref(),
        &record.owners,
        OwnerKind::Runtime,
        "--runtime-id",
    )?;
    let dir = match &options.dir {
        Some(path) => path.clone(),
        None if options.clone => PathBuf::from(
            runtime_repository_checkout_dir(&record.path_with_namespace)
                .context("repository pathWithNamespace has no checkout directory name")?,
        ),
        None => PathBuf::from("."),
    };
    if let Some(parent) = dir.parent() {
        if !parent.as_os_str().is_empty() && !parent.exists() {
            fs::create_dir_all(parent)?;
        }
    }
    if options.clone {
        git_clone(
            &record.repository_url,
            record.default_branch.as_deref(),
            &dir,
        )?;
    } else if !dir.exists() {
        fs::create_dir_all(&dir)?;
    }
    ensure_gitignore_env_local(&dir)?;
    write_env_local(
        &dir.join(".env.local"),
        &runtime_repository_env_local(record, agent_id.as_deref(), runtime_id.as_deref()),
    )?;
    Ok(dir)
}

fn resolve_identity_id(
    flag: Option<&str>,
    owners: &[OwnerRef],
    kind: OwnerKind,
    flag_name: &str,
) -> Result<Option<String>> {
    if let Some(id) = flag.map(str::trim).filter(|id| !id.is_empty()) {
        return Ok(Some(id.to_string()));
    }
    match unique_owner_id(owners, kind) {
        Ok(id) => Ok(id),
        Err(_) => bail!("multiple {} owners; pass {flag_name}", kind.as_str()),
    }
}

fn git_clone(url: &str, branch: Option<&str>, dir: &Path) -> Result<()> {
    if dir.join(".git").is_dir() {
        return Ok(());
    }
    if dir.exists() {
        let empty = fs::read_dir(dir)?.next().is_none();
        if !empty {
            bail!(
                "checkout directory {} exists and is not a git repository",
                dir.display()
            );
        }
    }
    let branch = branch.filter(|value| !value.is_empty()).unwrap_or("master");
    let status = Command::new("git")
        .arg("clone")
        .arg("--branch")
        .arg(branch)
        .arg("--single-branch")
        .arg(url)
        .arg(dir)
        .env("GIT_TERMINAL_PROMPT", "0")
        .status()
        .with_context(|| format!("git clone {url}"))?;
    if !status.success() {
        bail!("git clone failed for {url}; configure GitLab credentials and retry");
    }
    Ok(())
}

fn ensure_gitignore_env_local(dir: &Path) -> Result<()> {
    let path = dir.join(".gitignore");
    if path.exists() {
        let text = fs::read_to_string(&path)?;
        if text.lines().any(|line| line.trim() == ".env.local") {
            return Ok(());
        }
        let mut file = OpenOptions::new().append(true).open(&path)?;
        if !text.ends_with('\n') {
            file.write_all(b"\n")?;
        }
        file.write_all(b".env.local\n")?;
        return Ok(());
    }
    fs::write(path, ".env.local\n")?;
    Ok(())
}

fn write_env_local(path: &Path, contents: &str) -> Result<()> {
    let mut options = OpenOptions::new();
    options.create(true).write(true).truncate(true);
    #[cfg(unix)]
    options.mode(0o600);
    let mut file = options
        .open(path)
        .with_context(|| format!("write {}", path.display()))?;
    file.write_all(contents.as_bytes())?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut permissions = file.metadata()?.permissions();
        permissions.set_mode(0o600);
        file.set_permissions(permissions)?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use agent_infra_sdk::deploy_contract::RuntimeRepositoryRecord;

    fn record(owners: Vec<OwnerRef>, url: &str) -> RuntimeRepositoryRecord {
        RuntimeRepositoryRecord {
            id: "rrepo-1".into(),
            tenant_id: "local".into(),
            project_id: "default".into(),
            template_id: "rtpl-1".into(),
            owners,
            namespace: "runtimes".into(),
            gitlab_project_id: 1,
            path_with_namespace: "runtimes/demo-runtime".into(),
            repository_url: url.into(),
            default_branch: Some("master".into()),
            status: Default::default(),
            error_message: None,
            version: 1,
            created_at: "t".into(),
            updated_at: "t".into(),
        }
    }

    fn git(args: &[&str], cwd: &Path) {
        let status = Command::new("git")
            .args(args)
            .current_dir(cwd)
            .env("GIT_CONFIG_NOSYSTEM", "1")
            .env("GIT_CONFIG_GLOBAL", "/dev/null")
            .env("GIT_AUTHOR_NAME", "test")
            .env("GIT_AUTHOR_EMAIL", "test@example.com")
            .env("GIT_COMMITTER_NAME", "test")
            .env("GIT_COMMITTER_EMAIL", "test@example.com")
            .env("GIT_TERMINAL_PROMPT", "0")
            .status()
            .unwrap();
        assert!(status.success(), "git {args:?}");
    }

    #[test]
    fn merge_identity_owners_appends_flags() {
        let owners = merge_identity_owners(vec![OwnerRef::user("u1")], Some("agt_1"), Some("rt-1"));
        assert!(owners.contains(&OwnerRef::agent("agt_1")));
        assert!(owners.contains(&OwnerRef::runtime("rt-1")));
    }

    #[test]
    fn checkout_without_clone_writes_gitignored_env() {
        let root = tempfile::tempdir().unwrap();
        let dir = root.path().join("demo");
        let checkout = checkout_runtime_repository(
            &record(vec![OwnerRef::agent("agt_1")], "https://git.test/demo.git"),
            &CheckoutOptions {
                dir: Some(dir.clone()),
                clone: false,
                agent_id: None,
                runtime_id: None,
            },
        )
        .unwrap();
        assert_eq!(checkout, dir);
        let env = fs::read_to_string(dir.join(".env.local")).unwrap();
        assert!(env.contains("AGENT_ID=agt_1"));
        assert!(env.contains("RREPO_ID=rrepo-1"));
        assert_eq!(
            fs::read_to_string(dir.join(".gitignore")).unwrap().trim(),
            ".env.local"
        );
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = fs::metadata(dir.join(".env.local"))
                .unwrap()
                .permissions()
                .mode()
                & 0o777;
            assert_eq!(mode, 0o600);
        }
    }

    #[test]
    fn checkout_rejects_ambiguous_agent_without_flag() {
        let root = tempfile::tempdir().unwrap();
        let error = checkout_runtime_repository(
            &record(
                vec![OwnerRef::agent("agt_1"), OwnerRef::agent("agt_2")],
                "https://git.test/demo.git",
            ),
            &CheckoutOptions {
                dir: Some(root.path().join("demo")),
                clone: false,
                agent_id: None,
                runtime_id: None,
            },
        )
        .unwrap_err();
        assert!(error.to_string().contains("--agent-id"), "{error}");
    }

    #[test]
    fn checkout_clones_single_branch_and_skips_existing_git_dir() {
        let root = tempfile::tempdir().unwrap();
        let origin = root.path().join("origin.git");
        fs::create_dir_all(&origin).unwrap();
        git(&["-c", "init.defaultBranch=master", "init"], &origin);
        fs::write(origin.join("README.md"), "hello").unwrap();
        git(&["add", "README.md"], &origin);
        git(
            &["-c", "commit.gpgsign=false", "commit", "-m", "init"],
            &origin,
        );

        let dest = root.path().join("demo-runtime");
        let record = record(Vec::new(), origin.to_str().unwrap());
        checkout_runtime_repository(
            &record,
            &CheckoutOptions {
                dir: Some(dest.clone()),
                clone: true,
                agent_id: Some("agt_1".into()),
                runtime_id: Some("rt-1".into()),
            },
        )
        .unwrap();
        assert!(dest.join("README.md").is_file());
        assert!(dest.join(".env.local").is_file());

        fs::write(dest.join("README.md"), "changed").unwrap();
        checkout_runtime_repository(
            &record,
            &CheckoutOptions {
                dir: Some(dest.clone()),
                clone: true,
                agent_id: Some("agt_1".into()),
                runtime_id: Some("rt-1".into()),
            },
        )
        .unwrap();
        assert_eq!(
            fs::read_to_string(dest.join("README.md")).unwrap(),
            "changed"
        );
    }

    #[test]
    fn checkout_rejects_existing_non_git_directory() {
        let root = tempfile::tempdir().unwrap();
        let dest = root.path().join("demo");
        fs::create_dir_all(&dest).unwrap();
        fs::write(dest.join("keep.txt"), "nope").unwrap();
        let error = checkout_runtime_repository(
            &record(Vec::new(), "https://git.test/demo.git"),
            &CheckoutOptions {
                dir: Some(dest),
                clone: true,
                agent_id: None,
                runtime_id: None,
            },
        )
        .unwrap_err();
        assert!(
            error.to_string().contains("not a git repository"),
            "{error}"
        );
    }

    #[test]
    fn checkout_appends_env_local_to_existing_gitignore() {
        let root = tempfile::tempdir().unwrap();
        let dest = root.path().join("demo");
        fs::create_dir_all(&dest).unwrap();
        fs::write(dest.join(".gitignore"), "target\n").unwrap();
        checkout_runtime_repository(
            &record(Vec::new(), "https://git.test/demo.git"),
            &CheckoutOptions {
                dir: Some(dest.clone()),
                clone: false,
                agent_id: None,
                runtime_id: None,
            },
        )
        .unwrap();
        assert_eq!(
            fs::read_to_string(dest.join(".gitignore")).unwrap(),
            "target\n.env.local\n"
        );
    }
}