bot-forge 1.0.2

Rust CLI for installing agent skills and developer tools from configurable forms.
Documentation
use std::collections::BTreeMap;
use std::path::PathBuf;

use sha2::{Digest, Sha256};

use crate::activation::ActivationProvider;
use crate::artifact::{ArtifactProvenance, ArtifactStore, VerifiedArtifact, digest_tree};
use crate::backends::typed::{BackendContext, BackendOperation, install_operations};
use crate::config::schema::{GitInstall, InstallSpec};
use crate::error::ForgeError;
use crate::execution::command::{ShellDisplayMode, ShellStep, run_invocation_labeled};
use crate::execution::install::{installation_verification_result, verify_typed_tool};
use crate::execution::managed_cargo::{rollback_activations, switch_activations};
use crate::execution::policy::enforce_network_policy;
use crate::fsutil::{
    NamedFileLease, acquire_named_lock, copy_file, create_dir_all, remove_dir_all,
};
use crate::model::{
    BackendKind, InstallKind, RegistryEntry, RegistryTarget, ToolDef, ToolInstallOutcome,
    ToolInstallResult,
};
use crate::paths::{app_home, managed_bin_dir};
use crate::planning::ExecutionPlan;
use crate::state::journal::{JournalCheckpoint, JournalPhase};
use crate::state::{append_registry, read_registry};
use crate::util::{exe_name, now_secs};

pub(crate) fn install(
    plan: &ExecutionPlan,
    tool: &ToolDef,
    specification: &GitInstall,
    display_mode: ShellDisplayMode,
    step: Option<ShellStep>,
) -> Result<ToolInstallResult, ForgeError> {
    let fingerprint = git_fingerprint(specification)?;
    let artifact_id = format!("{}-{fingerprint}", tool.name);
    let provenance = ArtifactProvenance {
        backend: "git".into(),
        source: specification.url.clone(),
        revision: Some(specification.revision.clone()),
        fingerprint,
    };
    let expected_binaries = specification.bins.keys().cloned().collect::<Vec<_>>();
    let store = ArtifactStore::new(None)?;
    let mut cached = store
        .get(&artifact_id, &provenance, &expected_binaries)?
        .artifact;
    if cached.is_none() {
        enforce_network_policy(
            plan.policy.network,
            &InstallSpec::Git(specification.clone()),
        )?;
    }

    let work_identity = artifact_id.clone();
    let _work_lease = acquire_named_lock("git-work", &work_identity)?;
    if cached.is_none() {
        cached = store
            .get(&artifact_id, &provenance, &expected_binaries)?
            .artifact;
    }
    let (artifact, stored) = if let Some(artifact) = cached {
        let stored = artifact.root.clone();
        (artifact, stored)
    } else {
        let artifact = build_artifact(
            tool,
            specification,
            &artifact_id,
            provenance,
            display_mode,
            step,
        )?;
        let _artifact_cleanup = DirectoryCleanup::new(artifact.root.clone());
        let _artifact_lease = acquire_named_lock("git-artifacts", &artifact.id)?;
        let stored = store.put(&artifact)?;
        (artifact, stored)
    };
    let activator = ActivationProvider::new(managed_bin_dir())?;
    let previous_artifact_id = read_registry()?
        .into_iter()
        .find(|entry| entry.name == tool.name && entry.kind == InstallKind::Tool)
        .and_then(|entry| entry.artifact_id);
    let mut targets = Vec::new();
    let mut registry_targets = Vec::new();
    let mut prepared = Vec::new();
    let mut _bin_leases: Vec<NamedFileLease> = Vec::new();
    for (name, path) in &artifact.binaries {
        _bin_leases.push(acquire_named_lock("activation", name)?);
        let relative = path.strip_prefix(&artifact.root).map_err(|_| {
            ForgeError::Config(format!(
                "Git artifact binary escapes the root: {}",
                path.display()
            ))
        })?;
        let stored_binary = stored.join(relative);
        let activation = activator.prepare(name, &stored_binary)?;
        targets.push(activation.destination().to_path_buf());
        registry_targets.push(RegistryTarget {
            path: activation.destination().to_path_buf(),
            binary: Some(name.clone()),
        });
        prepared.push(activation);
    }
    let mut journal = JournalCheckpoint::new(
        format!(
            "{}-{}-{}",
            &plan.plan_hash[..plan.plan_hash.len().min(12)],
            std::process::id(),
            now_secs()
        ),
        &tool.name,
        &plan.plan_hash,
        &plan.config_hash,
        &plan.profile,
    );
    journal.save()?;
    journal.update_artifacts(&artifact.id, previous_artifact_id.clone(), targets.clone())?;
    journal.transition(JournalPhase::Stored)?;
    journal.transition(JournalPhase::Activating)?;
    let switched = switch_activations(&prepared, &mut journal)?;
    journal.transition(JournalPhase::Activated)?;
    let result = installation_verification_result(tool, step)?;
    if result.outcome != ToolInstallOutcome::Installed {
        rollback_activations(&prepared, switched)?;
        journal.transition(JournalPhase::RolledBack)?;
        return Ok(result);
    }
    if let Err(error) = verify_typed_tool(tool) {
        rollback_activations(&prepared, switched)?;
        journal.transition(JournalPhase::RolledBack)?;
        return Err(ForgeError::Command(format!(
            "verification failed for tool {}: {error}",
            tool.name
        )));
    }
    let entry = RegistryEntry {
        name: tool.name.clone(),
        kind: InstallKind::Tool,
        source: "backend:git".to_string(),
        profile: plan.profile.clone(),
        targets: registry_targets,
        installed_at: now_secs(),
        artifact_id: Some(artifact.id),
        previous_artifact_id,
        config_hash: Some(plan.config_hash.clone()),
        plan_hash: Some(plan.plan_hash.clone()),
        source_revision: Some(specification.revision.clone()),
        backend: Some(BackendKind::Git),
    };
    if let Err(error) = append_registry(entry) {
        rollback_activations(&prepared, switched)?;
        journal.transition(JournalPhase::RolledBack)?;
        return Err(error);
    }
    journal.transition(JournalPhase::Recorded)?;
    Ok(result)
}

fn git_fingerprint(specification: &GitInstall) -> Result<String, ForgeError> {
    let identity = serde_json::to_vec(&serde_json::json!({
        "url": specification.url,
        "revision": specification.revision,
        "subdirectory": specification.subdirectory,
        "bins": specification.bins,
    }))
    .map_err(|error| {
        ForgeError::Parse(format!(
            "failed to serialize Git artifact identity: {error}"
        ))
    })?;
    Ok(Sha256::digest(identity)
        .iter()
        .map(|byte| format!("{byte:02x}"))
        .collect())
}

fn build_artifact(
    tool: &ToolDef,
    specification: &GitInstall,
    artifact_id: &str,
    provenance: ArtifactProvenance,
    display_mode: ShellDisplayMode,
    step: Option<ShellStep>,
) -> Result<VerifiedArtifact, ForgeError> {
    let work = app_home().join("work").join("git").join(artifact_id);
    if work.exists() {
        remove_dir_all(&work)?;
    }
    let target = work.join("target");
    create_dir_all(&work)?;
    let _work_cleanup = DirectoryCleanup::new(work.clone());
    let context = BackendContext {
        staging: &work,
        target: &target,
        cargo_jobs: 1,
        allow_insecure_hosts: &[],
        apt_source: None,
        apt_lists: None,
    };
    for operation in install_operations(&InstallSpec::Git(specification.clone()), &context)? {
        let BackendOperation::Command(invocation) = operation else {
            return Err(ForgeError::Config(
                "Git backend produced a non-command operation".into(),
            ));
        };
        run_invocation_labeled(&tool.name, &invocation, display_mode, step)?;
    }
    let repository_root = specification
        .subdirectory
        .as_ref()
        .map_or_else(|| work.clone(), |path| work.join(path));
    if !repository_root.is_dir() {
        return Err(ForgeError::Config(format!(
            "Git subdirectory does not exist: {}",
            repository_root.display()
        )));
    }
    let artifact_root = app_home()
        .join("work")
        .join("git-artifacts")
        .join(artifact_id);
    if artifact_root.exists() {
        remove_dir_all(&artifact_root)?;
    }
    create_dir_all(&artifact_root)?;
    let mut artifact_cleanup = DirectoryCleanup::new(artifact_root.clone());
    let mut binaries = BTreeMap::new();
    for (name, relative) in &specification.bins {
        let source = repository_root.join(relative);
        let destination = artifact_root.join(exe_name(name));
        copy_file(&source, &destination)?;
        binaries.insert(name.clone(), destination);
    }
    let sha256 = digest_tree(&artifact_root)?;
    artifact_cleanup.keep();
    Ok(VerifiedArtifact {
        id: artifact_id.to_string(),
        root: artifact_root,
        binaries,
        sha256,
        provenance,
    })
}

struct DirectoryCleanup {
    path: PathBuf,
    keep: bool,
}

impl DirectoryCleanup {
    fn new(path: PathBuf) -> Self {
        Self { path, keep: false }
    }

    fn keep(&mut self) {
        self.keep = true;
    }
}

impl Drop for DirectoryCleanup {
    fn drop(&mut self) {
        if !self.keep && self.path.exists() {
            let _ = remove_dir_all(&self.path);
        }
    }
}

#[cfg(test)]
mod tests {
    use std::fs;

    use crate::execution::managed_git::DirectoryCleanup;
    use crate::util::now_secs;

    #[test]
    fn failed_git_build_cleanup_removes_partial_directories() {
        let root = std::env::temp_dir().join(format!(
            "bot-forge-git-cleanup-{}-{}",
            std::process::id(),
            now_secs()
        ));
        fs::create_dir_all(root.join("partial")).unwrap();
        {
            let _cleanup = DirectoryCleanup::new(root.clone());
        }
        assert!(!root.exists());
    }
}