greentic-dev 0.4.70

Developer CLI and local tooling for Greentic flows, packs, and components
Documentation
use std::fs;
use std::io::{Cursor, Read, Write};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, anyhow, bail};
use bytes::Bytes;
use greentic_pack::builder::ComponentEntry;
use semver::Version;
use serde::{Deserialize, Serialize};
use zip::ZipArchive;

use crate::config;
use crate::distributor::{
    DevArtifactKind, DevDistributorClient, DevDistributorError, DevIntent, DevResolveRequest,
    DevResolveResponse, resolve_profile,
};

#[derive(Debug, Clone, Copy)]
pub enum PackInitIntent {
    Dev,
    Runtime,
}

pub fn run(from: &str, profile: Option<&str>) -> Result<()> {
    let config = config::load_with_meta(None)?;
    let profile = resolve_profile(&config, profile)?;
    let client = DevDistributorClient::from_profile(profile.clone())?;

    let resolve = client.resolve(&DevResolveRequest {
        coordinate: from.to_string(),
        intent: DevIntent::Dev,
        platform: Some(default_platform()),
        features: Vec::new(),
    });

    let resolved = handle_resolve_result(resolve)?;
    if resolved.kind != DevArtifactKind::Pack {
        bail!(
            "coordinate `{}` resolved to {:?}, expected pack",
            resolved.coordinate,
            resolved.kind
        );
    }

    let bytes = client.download_artifact(&resolved.artifact_download_path)?;
    let cache_path = write_pack_to_cache(&resolved, &bytes)?;
    let workspace_dir = slug_to_dir(&resolved.name)?;
    fs::create_dir(&workspace_dir).with_context(|| {
        format!(
            "failed to create workspace directory {}",
            workspace_dir.display()
        )
    })?;

    let bundle_path = workspace_dir.join("bundle.gtpack");
    fs::write(&bundle_path, &bytes)
        .with_context(|| format!("failed to write {}", bundle_path.display()))?;
    unpack_gtpack(&workspace_dir, bytes.clone())?;

    println!(
        "Initialized pack {}@{} in {} (cached at {})",
        resolved.name,
        resolved.version,
        workspace_dir.display(),
        cache_path.display()
    );

    Ok(())
}

/// Fetch a component via distributor and cache it locally.
/// Returns the cache directory containing the component artifact/flows.
pub fn run_component_add(
    coordinate: &str,
    profile: Option<&str>,
    intent: PackInitIntent,
) -> Result<PathBuf> {
    let config = config::load_with_meta(None)?;
    let profile = resolve_profile(&config, profile)?;
    let client = DevDistributorClient::from_profile(profile.clone())?;

    let resolve = client.resolve(&DevResolveRequest {
        coordinate: coordinate.to_string(),
        intent: match intent {
            PackInitIntent::Dev => DevIntent::Dev,
            PackInitIntent::Runtime => DevIntent::Runtime,
        },
        platform: Some(default_platform()),
        features: Vec::new(),
    });
    let resolved = handle_resolve_result(resolve)?;
    if resolved.kind != DevArtifactKind::Component {
        bail!(
            "coordinate `{}` resolved to {:?}, expected component",
            resolved.coordinate,
            resolved.kind
        );
    }

    let bytes = client.download_artifact(&resolved.artifact_download_path)?;
    let (cache_dir, cache_path) = write_component_to_cache(&resolved, &bytes)?;
    update_workspace_manifest(&resolved, &cache_path)?;

    println!(
        "Resolved {} -> {}@{}",
        resolved.coordinate, resolved.name, resolved.version
    );
    println!("Cached component at {}", cache_path.display());
    println!(
        "Updated workspace manifest at {}",
        manifest_path()?.display()
    );
    Ok(cache_dir)
}

fn default_platform() -> String {
    "wasm32-wasip2".to_string()
}

fn handle_resolve_result(
    result: Result<DevResolveResponse, DevDistributorError>,
) -> Result<DevResolveResponse> {
    match result {
        Ok(resp) => Ok(resp),
        Err(DevDistributorError::LicenseRequired(body)) => bail!(
            "license required for {}: {}\nCheckout URL: {}",
            body.coordinate,
            body.message,
            body.checkout_url
        ),
        Err(other) => Err(anyhow!(other)),
    }
}

fn cache_base_dir() -> Result<PathBuf> {
    let mut base = dirs::home_dir().ok_or_else(|| anyhow!("unable to determine home directory"))?;
    base.push(".greentic");
    base.push("cache");
    fs::create_dir_all(&base)
        .with_context(|| format!("failed to create cache directory {}", base.display()))?;
    Ok(base)
}

fn write_component_to_cache(
    resolved: &DevResolveResponse,
    bytes: &Bytes,
) -> Result<(PathBuf, PathBuf)> {
    let mut path = cache_base_dir()?;
    path.push("components");
    let slug = cache_slug(resolved);
    path.push(slug);
    fs::create_dir_all(&path).with_context(|| format!("failed to create {}", path.display()))?;
    let file_path = path.join("artifact.wasm");
    fs::write(&file_path, bytes)
        .with_context(|| format!("failed to write {}", file_path.display()))?;
    Ok((path, file_path))
}

fn write_pack_to_cache(resolved: &DevResolveResponse, bytes: &Bytes) -> Result<PathBuf> {
    let mut path = cache_base_dir()?;
    path.push("packs");
    let slug = cache_slug(resolved);
    path.push(slug);
    fs::create_dir_all(&path).with_context(|| format!("failed to create {}", path.display()))?;
    let file_path = path.join("bundle.gtpack");
    fs::write(&file_path, bytes)
        .with_context(|| format!("failed to write {}", file_path.display()))?;
    Ok(file_path)
}

pub fn cache_slug(resolved: &DevResolveResponse) -> String {
    if let Some(digest) = &resolved.digest {
        return digest.replace(':', "-");
    }
    slugify(&format!("{}-{}", resolved.name, resolved.version))
}

pub fn slugify(raw: &str) -> String {
    let mut out = String::new();
    let mut prev_dash = false;
    for ch in raw.chars() {
        let c = ch.to_ascii_lowercase();
        if c.is_ascii_alphanumeric() {
            out.push(c);
            prev_dash = false;
        } else if !prev_dash {
            out.push('-');
            prev_dash = true;
        }
    }
    out.trim_matches('-').to_string()
}

pub fn manifest_path() -> Result<PathBuf> {
    let mut root = std::env::current_dir().context("unable to determine current directory")?;
    root.push(".greentic");
    fs::create_dir_all(&root).with_context(|| format!("failed to create {}", root.display()))?;
    Ok(root.join("manifest.json"))
}

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct WorkspaceManifest {
    pub components: Vec<WorkspaceComponent>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceComponent {
    pub coordinate: String,
    pub entry: ComponentEntry,
}

pub fn update_workspace_manifest(resolved: &DevResolveResponse, cache_path: &Path) -> Result<()> {
    let manifest_path = manifest_path()?;
    let mut manifest: WorkspaceManifest = if manifest_path.exists() {
        let data = fs::read_to_string(&manifest_path)
            .with_context(|| format!("failed to read {}", manifest_path.display()))?;
        serde_json::from_str(&data)
            .with_context(|| format!("failed to parse {}", manifest_path.display()))?
    } else {
        WorkspaceManifest::default()
    };

    let version = Version::parse(&resolved.version)
        .with_context(|| format!("invalid semver version `{}`", resolved.version))?;
    let entry = ComponentEntry {
        name: resolved.name.clone(),
        version,
        file_wasm: cache_path.display().to_string(),
        hash_blake3: resolved.digest.clone().unwrap_or_default(),
        schema_file: None,
        manifest_file: None,
        world: None,
        capabilities: None,
    };

    let mut replaced = false;
    for existing in manifest.components.iter_mut() {
        if existing.entry.name == entry.name {
            existing.coordinate = resolved.coordinate.clone();
            existing.entry = entry.clone();
            replaced = true;
            break;
        }
    }
    if !replaced {
        manifest.components.push(WorkspaceComponent {
            coordinate: resolved.coordinate.clone(),
            entry,
        });
    }

    let rendered =
        serde_json::to_string_pretty(&manifest).context("failed to render workspace manifest")?;
    fs::write(&manifest_path, rendered)
        .with_context(|| format!("failed to write {}", manifest_path.display()))?;
    Ok(())
}

fn slug_to_dir(name: &str) -> Result<PathBuf> {
    let slug = slugify(name);
    let root = std::env::current_dir().context("unable to determine current directory")?;
    let dest = root.join(slug);
    if dest.exists() {
        bail!(
            "destination {} already exists; choose a different directory or remove it first",
            dest.display()
        );
    }
    Ok(dest)
}

fn unpack_gtpack(dest: &Path, bytes: Bytes) -> Result<()> {
    let cursor = Cursor::new(bytes);
    let mut archive = ZipArchive::new(cursor).context("failed to open gtpack archive")?;
    for i in 0..archive.len() {
        let mut file = archive.by_index(i).context("failed to read gtpack entry")?;
        let name = match file.enclosed_name() {
            Some(path) => path.to_owned(),
            None => bail!("gtpack contained a suspicious path; aborting extract"),
        };
        let out_path = dest.join(name);
        if file.name().ends_with('/') {
            fs::create_dir_all(&out_path)
                .with_context(|| format!("failed to create {}", out_path.display()))?;
        } else {
            if let Some(parent) = out_path.parent() {
                fs::create_dir_all(parent)
                    .with_context(|| format!("failed to create {}", parent.display()))?;
            }
            let mut buffer = Vec::new();
            file.read_to_end(&mut buffer)
                .context("failed to read gtpack entry")?;
            let mut out = fs::File::create(&out_path)
                .with_context(|| format!("failed to create {}", out_path.display()))?;
            out.write_all(&buffer)
                .with_context(|| format!("failed to write {}", out_path.display()))?;
        }
    }
    Ok(())
}