wasmer-deploy-client-cli 0.1.1

CLI client for Wasmer Deploy
Documentation
use std::path::Path;

use anyhow::{bail, Context};
use is_terminal::IsTerminal;
use wasmer_api::backend::gql::UserWithNamespaces;
use wasmer_deploy_schema::schema::{DeploymentV1, WorkloadV1};

use crate::{cmd::AsyncCliCommand, ApiOpts, ItemFormatOpts};

const WASM_STATIC_SERVER_PACKAGE: &str = "sharrattj/static-web-server";
const WASM_STATIC_SERVER_VERSION: &str = "1";

#[derive(clap::Parser, Debug)]
pub struct CmdInitStaticSite {
    #[clap(flatten)]
    api: ApiOpts,

    /// Name of the site.
    #[clap(long)]
    name: Option<String>,

    /// Directory to initialize the static site in.
    /// Should have a subdirectory with the website data.
    #[clap(long)]
    path: Option<std::path::PathBuf>,

    /// Path to the public directory.
    /// This directory should contain the website files.
    ///
    /// Defaults to [path]/public.
    public_path: Option<String>,

    /// Publish the package and the app.
    #[clap(long)]
    publish: bool,
}

impl CmdInitStaticSite {
    async fn exec(self) -> Result<(), anyhow::Error> {
        let interactive = std::io::stdin().is_terminal();

        let client = self.api.client()?;

        let user = wasmer_api::backend::current_user(&client).await?;

        let path = if let Some(path) = &self.path {
            if !path.is_dir() {
                anyhow::bail!("Specified path is not a directory: '{}'", path.display());
            }
            path.clone()
        } else {
            let current = std::env::current_dir()?;
            eprintln!("Initializing in current directory: {}", current.display());
            current
        };

        let manifest = self.build_package(&path, interactive, &user).await?;

        let (namespace, name) = manifest
            .package
            .name
            .split_once('/')
            .context("could not determine namespace")?;

        let _depl = self.build_deployment(&path, namespace, name).await?;

        let should_publish = if self.publish {
            true
        } else if interactive {
            dialoguer::Confirm::new()
                .with_prompt("Would you like to publish the new package and app?")
                .default(false)
                .interact()?
        } else {
            false
        };

        if should_publish {
            let cmd = crate::cmd::publish::CmdAppPublish {
                api: self.api.clone(),
                no_validate: false,
                path: Some(path),
                fmt: ItemFormatOpts {
                    format: Default::default(),
                },
                publish_package: true,
                non_interactive: true,
            };
            cmd.run_async().await?;

            eprintln!("Deployment complete");
            eprintln!("To use a custom domain, create a CNAME DNS record pointing to: {name}.{namespace}.wasmer.io");
        }

        // TODO: do we even need this anymore? we want to recommend CNAME setups
        // if interactive {
        //     let custom_domain = dialoguer::Confirm::new()
        //         .with_prompt("Would you like to configure a custom domain?")
        //         .default(false)
        //         .interact()
        //         .context("Could not open browser")?;

        //     if custom_domain {
        //         eprintln!("Generating DNS token...");

        //         // let deploy_token = api
        //         //     .create_deployment_token_raw(published_config.version.id.clone())
        //         //     .await
        //         //     .context("Could not create deployment token")?;
        //         todo!();

        //         // eprintln!("Deployment token generated!");
        //         // eprintln!("Create a DNS TXT record for your domain with the following value:");
        //         // eprintln!("token:{deploy_token}");
        //     }
        // }

        Ok(())
    }

    async fn build_deployment(
        &self,
        path: &Path,
        namespace: &str,
        name: &str,
    ) -> Result<DeploymentV1, anyhow::Error> {
        let deploy_yaml_path = path.join("deploy.yaml");

        let full_name = format!("{namespace}/{name}");

        let (deploy_config, _is_new_file) = if deploy_yaml_path.try_exists()? {
            let config = std::fs::read_to_string(&deploy_yaml_path).with_context(|| {
                format!(
                    "Could not read config file: '{}'",
                    deploy_yaml_path.display()
                )
            })?;
            let config: DeploymentV1 = serde_yaml::from_str(&config).with_context(|| {
                format!(
                    "Could not parse existing deploy.yaml file: '{}'",
                    deploy_yaml_path.display()
                )
            })?;

            (config, false)
        } else {
            eprintln!("No deploy.yaml found. Configuring new app...");

            let conf = DeploymentV1 {
                name: full_name,
                workload: WorkloadV1 {
                    name: None,
                    runner: wasmer_deploy_schema::schema::WorkloadRunnerV1::WebProxy(
                        wasmer_deploy_schema::schema::RunnerWebProxyV1 {
                            source: wasmer_deploy_schema::schema::WorkloadRunnerWasmSourceV1::Webc(
                                wasmer_deploy_schema::schema::WebcSourceV1 {
                                    package:
                                        wasmer_deploy_schema::schema::WebcPackageIdentifierV1 {
                                            repository: "https://wapm.dev".parse().unwrap(),
                                            namespace: namespace.to_string(),
                                            name: name.to_string(),
                                            tag: None,
                                        },
                                    auth_token: None,
                                },
                            ),
                        },
                    ),
                    capabilities: Default::default(),
                },
            };

            eprintln!("Writing Deploy config: '{}'", deploy_yaml_path.display());
            std::fs::write(&deploy_yaml_path, serde_yaml::to_string(&conf)?).with_context(
                || {
                    format!(
                        "Could not write deploy.yaml file: '{}'",
                        deploy_yaml_path.display()
                    )
                },
            )?;

            (conf, true)
        };

        Ok(deploy_config)
    }

    async fn build_package(
        &self,
        path: &Path,
        interactive: bool,
        user: &UserWithNamespaces,
    ) -> Result<wasmer_toml::Manifest, anyhow::Error> {
        let manifest_path = path.join("wasmer.toml");

        let (manifest, _is_new_file) = if manifest_path.is_file() {
            eprintln!("Using existing package definition in wasmer.toml...");
            let contents = std::fs::read_to_string(&manifest_path)?;
            let manifest = wasmer_toml::Manifest::parse(&contents).with_context(|| {
                format!(
                    "Could not parse existing wasmer.toml file: '{}'",
                    manifest_path.display()
                )
            })?;

            eprintln!("Existing wasmer.toml found. To publish a new version, increment the version in `wasmer.toml` and run `wasmer publish`");

            (manifest, false)
        } else {
            eprintln!("No wasmer.toml found. Initializing package...");

            let name = if let Some(name) = &self.name {
                name.to_string()
            } else {
                let auto_name = path.file_name().map(|x| x.to_string_lossy().to_string());
                if interactive {
                    let auto_name = auto_name.unwrap_or_else(|| "my-app".to_string());

                    let username = &user.username;

                    let name: String = dialoguer::Input::new()
                        .with_prompt("App name (namespace/name)")
                        .with_initial_text(format!("{username}/{auto_name}"))
                        .interact_text()?;

                    if name.trim().is_empty() {
                        anyhow::bail!("App name cannot be empty.");
                    }

                    name.trim().to_string()
                } else {
                    auto_name.with_context(|| {
                        "Could not guess app name. Please provide a name with --name."
                    })?
                }
            };

            let full_name = if name.contains('/') {
                name
            } else {
                format!("{}/{}", user.username, name.trim())
            };

            let default_public = path.join("public");
            let public_path_name = if let Some(p) = &self.public_path {
                let full = path.join(p);
                if !full.is_dir() {
                    bail!("Specified public path is not a directory: '{p}'",);
                }
                p.clone()
            } else if default_public.is_dir() {
                "public".to_string()
            } else if interactive {
                let dirname: String = dialoguer::Input::new()
                    .with_prompt("Public directory (path to the website files): ")
                    .with_initial_text("public")
                    .interact_text()?;

                let pubdir = path.join(&dirname);
                if !pubdir.is_dir() {
                    eprintln!("Creating public directory at '{}'...", pubdir.display());
                    eprintln!("Creating stub index.html...");

                    std::fs::create_dir_all(&pubdir)?;

                    let content = SAMPLE_INDEX_HTML
                        .replace("{title}", &full_name)
                        .replace("{description}", "A website built with Wasmer.");

                    std::fs::write(pubdir.join("index.html"), content)?;
                }

                dirname
            } else {
                bail!("No public directory found. Please specify one with --public-path.");
            };

            let public_path = path.join(&public_path_name);
            if !public_path.join("index.html").is_file() {
                eprintln!(
                    "WARN: No index.html file found in public directory '{}'",
                    public_path_name,
                );
            }

            let manifest = wasmer_toml::Manifest {
                base_directory_path: path.to_path_buf(),
                module: None,
                package: wasmer_toml::Package {
                    name: full_name.clone(),
                    version: "0.1.0".parse().unwrap(),
                    description: format!("{full_name} website"),
                    license: None,
                    license_file: None,
                    readme: None,
                    repository: None,
                    homepage: None,
                    wasmer_extra_flags: None,
                    disable_command_rename: false,
                    rename_commands_to_raw_command_name: false,
                },
                dependencies: Some(
                    vec![(
                        WASM_STATIC_SERVER_PACKAGE.to_string(),
                        WASM_STATIC_SERVER_VERSION.to_string(),
                    )]
                    .into_iter()
                    .collect(),
                ),
                fs: Some(
                    vec![("public".to_string(), public_path_name.into())]
                        .into_iter()
                        .collect(),
                ),
                command: None,
            };

            let raw = manifest.to_string()?;
            eprintln!(
                "Writing wasmer.toml package to '{}'",
                manifest_path.display()
            );
            std::fs::write(&manifest_path, raw)?;

            (manifest, true)
        };

        Ok(manifest)
    }
}

impl AsyncCliCommand for CmdInitStaticSite {
    fn run_async(self) -> futures::future::BoxFuture<'static, Result<(), anyhow::Error>> {
        Box::pin(self.exec())
    }
}

const SAMPLE_INDEX_HTML: &str = r#"
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>{title}</title>
  </head>
  <body>
    <header>
      <h1>{title}</h1>

      <p>{description}</p>
  </body>
</html>
"#;