wasmer-deploy-cli 0.1.29

CLI for Wasmer Deploy
Documentation
pub mod package_wizard;
pub mod prompts;
pub mod render;

use std::{
    path::{Path, PathBuf},
    str::FromStr,
};

use anyhow::Context;
use once_cell::sync::Lazy;
use regex::Regex;
use wasmer_api::backend::BackendClient;
use wasmer_deploy_schema::schema::StringWebcIdent;

pub const DEFAULT_PACKAGE_MANIFEST_FILE: &str = "wasmer.toml";

/// A value combined with a label.
///
/// Used for select options.
#[derive(Debug)]
pub struct Labeled<T> {
    pub label: String,
    pub value: T,
}

/// Load a package manifest from the manifest file.
///
/// Path can either be a directory, or a concrete file path.
pub fn load_package_manifest(
    path: &Path,
) -> Result<Option<(PathBuf, wasmer_toml::Manifest)>, anyhow::Error> {
    let file_path = if path.is_file() {
        path.to_owned()
    } else {
        path.join(DEFAULT_PACKAGE_MANIFEST_FILE)
    };

    let contents = match std::fs::read_to_string(&file_path) {
        Ok(c) => c,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(err) => {
            return Err(err).with_context(|| {
                format!(
                    "Could not read package manifest at '{}'",
                    file_path.display()
                )
            })
        }
    };
    let manifest = wasmer_toml::Manifest::parse(&contents).with_context(|| {
        format!(
            "Could not parse package config at: '{}'",
            file_path.display()
        )
    })?;
    Ok(Some((file_path, manifest)))
}

/// Ask a user for a package name.
///
/// Will continue looping until the user provides a valid name.
pub fn prompt_for_package_name(
    message: &str,
    default: Option<&str>,
) -> Result<StringWebcIdent, anyhow::Error> {
    loop {
        let raw: String = dialoguer::Input::new()
            .with_prompt(message)
            .with_initial_text(default.unwrap_or_default())
            .interact_text()
            .context("could not read user input")?;

        match raw.parse::<StringWebcIdent>() {
            Ok(p) => break Ok(p),
            Err(err) => {
                eprintln!("invalid package name: {err}");
            }
        }
    }
}

/// Defines how to check for a package.
pub enum PackageCheckMode {
    /// The package must exist in the registry.
    MustExist,
    /// The package must NOT exist in the registry.
    MustNotExist,
}

/// Ask for a package name.
///
/// Will continue looping until the user provides a valid name.
///
/// If an API is provided, will check if the package exists.
pub async fn prompt_for_package(
    message: &str,
    default: Option<&str>,
    check: Option<PackageCheckMode>,
    client: Option<&BackendClient>,
) -> Result<(StringWebcIdent, Option<wasmer_api::backend::gql::Package>), anyhow::Error> {
    loop {
        let name = prompt_for_package_name(message, default)?;

        if let Some(check) = &check {
            let api = client.expect("Check mode specified, but no API provided");

            let pkg = wasmer_api::backend::get_package(api, name.to_string())
                .await
                .context("could not query backend for package")?;

            match check {
                PackageCheckMode::MustExist => {
                    if let Some(pkg) = pkg {
                        break Ok((name, Some(pkg)));
                    } else {
                        eprintln!("Package '{name}' does not exist");
                    }
                }
                PackageCheckMode::MustNotExist => {
                    if pkg.is_none() {
                        break Ok((name, None));
                    } else {
                        eprintln!("Package '{name}' already exists");
                    }
                }
            }
        }
    }
}

/// Re-publish a package with an increased minor version.
pub async fn republish_package_with_bumped_version(
    client: &BackendClient,
    manifest_path: &Path,
    mut manifest: wasmer_toml::Manifest,
) -> Result<wasmer_toml::Manifest, anyhow::Error> {
    // Try to load existing version.
    // If it does not exist yet, we don't need to increment.

    let current_opt = wasmer_api::backend::get_package(client, manifest.package.name.clone())
        .await
        .context("could not load package info from backend")?
        .and_then(|x| x.last_version);

    let new_version = if let Some(current) = current_opt {
        let mut v = semver::Version::parse(&current.version)
            .with_context(|| format!("Could not parse package version: '{}'", current.version))?;

        v.patch += 1;
        v
    } else {
        manifest.package.version
    };

    manifest.package.version = new_version;
    let contents = toml::to_string(&manifest).with_context(|| {
        format!(
            "could not persist manifest to '{}'",
            manifest_path.display()
        )
    })?;

    std::fs::write(manifest_path, contents)
        .with_context(|| format!("could not write manifest to '{}'", manifest_path.display()))?;

    let dir = manifest_path
        .parent()
        .context("could not determine wasmer.toml parent directory")?
        .to_owned();

    let registry = client.graphql_endpoint().to_string();
    let token = client.auth_token().map(|s| s.to_string());

    std::thread::spawn({
        move || {
            let publish = wasmer_registry::package::builder::Publish {
                registry: Some(registry),
                dry_run: false,
                quiet: false,
                package_name: None,
                version: None,
                token,
                no_validate: true,
                package_path: Some(dir.to_str().unwrap().to_string()),
            };
            publish.execute()
        }
    })
    .join()
    .expect("thread failed")?;

    Ok(manifest)
}

/// The identifier for an app or package in the form, `owner/package@version`,
/// where the `owner` and `version` are optional.
#[derive(Debug, Clone, PartialEq)]
pub struct Identifier {
    /// The package's name.
    pub name: String,
    /// The package's owner, typically a username or namespace.
    pub owner: Option<String>,
    /// The package's version number.
    pub version: Option<String>,
}

impl Identifier {
    pub fn new(name: impl Into<String>) -> Self {
        Identifier {
            name: name.into(),
            owner: None,
            version: None,
        }
    }

    pub fn with_owner(self, owner: impl Into<String>) -> Self {
        Identifier {
            owner: Some(owner.into()),
            ..self
        }
    }

    pub fn with_version(self, version: impl Into<String>) -> Self {
        Identifier {
            version: Some(version.into()),
            ..self
        }
    }
}

impl FromStr for Identifier {
    type Err = anyhow::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        const PATTERN: &str = r"^(?x)
            (?:
                (?P<owner>[a-zA-Z][\w\d_.-]*)
                /
            )?
            (?P<name>[a-zA-Z][\w\d_.-]*)
            (?:
                @
                (?P<version>[\w\d.]+)
            )?
            $
        ";
        static RE: Lazy<Regex> = Lazy::new(|| Regex::new(PATTERN).unwrap());

        let caps = RE.captures(s).context(
            "Invalid package identifier, expected something like namespace/package@version",
        )?;

        let mut identifier = Identifier::new(&caps["name"]);

        if let Some(owner) = caps.name("owner") {
            identifier = identifier.with_owner(owner.as_str());
        }

        if let Some(version) = caps.name("version") {
            identifier = identifier.with_version(version.as_str());
        }

        Ok(identifier)
    }
}

/// Merge two yaml values by recursively merging maps from b into a.
///
/// Preserves old values that were not in b.
pub(crate) fn merge_yaml_values(a: &serde_yaml::Value, b: &serde_yaml::Value) -> serde_yaml::Value {
    use serde_yaml::Value as V;
    match (a, b) {
        (V::Mapping(a), V::Mapping(b)) => {
            let mut m = a.clone();
            for (k, v) in b.iter() {
                let newval = if let Some(old) = a.get(k) {
                    merge_yaml_values(old, v)
                } else {
                    v.clone()
                };
                m.insert(k.clone(), newval);
            }
            V::Mapping(m)
        }
        _ => b.clone(),
    }
}

// pub(crate) fn shell(cmd: &str, args: &[&str]) -> anyhow::Result<Option<i32>> {
//     let ecode = Command::new(cmd).args(args).spawn()?.wait()?;
//     if ecode.success() {
//         Ok(ecode.code())
//     } else {
//         Err(anyhow::format_err!(
//             "failed to execute linux command (code={:?})",
//             ecode.code()
//         ))
//     }
// }

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

    #[test]
    fn test_merge_yaml_values() {
        use serde_yaml::Value;
        let v1 = r#"
a: a
b:
  b1: b1
c: c
        "#;
        let v2 = r#"
a: a1
b:
  b2: b2
        "#;
        let v3 = r#"
a: a1
b:
  b1: b1
  b2: b2
c: c
        "#;

        let a: Value = serde_yaml::from_str(v1).unwrap();
        let b: Value = serde_yaml::from_str(v2).unwrap();
        let c: Value = serde_yaml::from_str(v3).unwrap();
        let merged = merge_yaml_values(&a, &b);
        assert_eq!(merged, c);
    }

    #[test]
    fn parse_valid_identifiers() {
        let inputs = [
            ("python", Identifier::new("python")),
            (
                "syrusakbary/python",
                Identifier::new("python").with_owner("syrusakbary"),
            ),
            (
                "wasmer/wasmer.io",
                Identifier::new("wasmer.io").with_owner("wasmer"),
            ),
            (
                "syrusakbary/python@1.2.3",
                Identifier::new("python")
                    .with_owner("syrusakbary")
                    .with_version("1.2.3"),
            ),
            (
                "python@1.2.3",
                Identifier::new("python").with_version("1.2.3"),
            ),
        ];

        for (src, expected) in inputs {
            let identifier = Identifier::from_str(src).expect(src);
            assert_eq!(identifier, expected);
        }
    }

    #[test]
    fn invalid_package_identifiers() {
        let inputs = ["", "$", "python/", "/python", "python@", "."];

        for input in inputs {
            let result = Identifier::from_str(input);
            assert!(result.is_err(), "Got {result:?} from {input:?}");
        }
    }
}