//! Create a new Edge app. use crate::{ commands::AsyncCliCommand, opts::{ApiOpts, ItemFormatOpts, WasmerEnv}, utils::load_package_manifest, }; use anyhow::Context; use colored::Colorize; use dialoguer::{theme::ColorfulTheme, Confirm, Select}; use is_terminal::IsTerminal; use std::{collections::HashMap, env, io::Cursor, path::PathBuf, str::FromStr}; use wasmer_api::{types::AppTemplate, WasmerClient}; use wasmer_config::{app::AppConfigV1, package::PackageSource}; use super::{deploy::CmdAppDeploy, util::login_user}; async fn write_app_config(app_config: &AppConfigV1, dir: Option) -> anyhow::Result<()> { let raw_app_config = app_config.clone().to_yaml()?; let app_dir = match dir { Some(dir) => dir, None => std::env::current_dir()?, }; let app_config_path = app_dir.join(AppConfigV1::CANONICAL_FILE_NAME); std::fs::write(&app_config_path, raw_app_config).with_context(|| { format!( "could not write app config to '{}'", app_config_path.display() ) }) } /// Create a new Edge app. #[derive(clap::Parser, Debug)] pub struct CmdAppCreate { /// A reference to the template to use. /// /// It can be either an URL to a github repository - like /// `https://github.com/wasmer-examples/php-wasmer-starter` - or the name of a template that /// will be searched for in the selected registry, like `astro-starter`. #[clap( long, conflicts_with = "package", conflicts_with = "use_local_manifest" )] pub template: Option, /// Name of the package to use. #[clap( long, conflicts_with = "template", conflicts_with = "use_local_manifest" )] pub package: Option, /// Whether or not to search (and use) a local manifest. #[clap(long, conflicts_with = "template", conflicts_with = "package")] pub use_local_manifest: bool, /// Whether or not to deploy the application once it is created. /// /// If selected, this might entail the step of publishing the package related to the /// application. By default, the application is not deployed and the package is not published. #[clap(long = "deploy")] pub deploy_app: bool, /// Skip local schema validation. #[clap(long)] pub no_validate: bool, /// Do not prompt for user input. #[clap(long, default_value_t = !std::io::stdin().is_terminal())] pub non_interactive: bool, /// Do not interact with any APIs. #[clap(long)] pub offline: bool, /// The owner of the app. #[clap(long)] pub owner: Option, /// The name of the app (can be changed later) #[clap(long = "name")] pub app_name: Option, /// The path to the directory where the config file for the application will be written to. #[clap(long = "path")] pub app_dir_path: Option, /// Do not wait for the app to become reachable if deployed. #[clap(long)] pub no_wait: bool, // Common args. #[clap(flatten)] #[allow(missing_docs)] pub api: ApiOpts, #[clap(flatten)] pub env: WasmerEnv, #[clap(flatten)] #[allow(missing_docs)] pub fmt: ItemFormatOpts, /// Name to use when creating a new package from a template. #[clap(long)] pub new_package_name: Option, /// Don't print any message. #[clap(long)] pub quiet: bool, } impl CmdAppCreate { #[inline] fn get_app_config(&self, owner: &str, name: &str, package: &str) -> AppConfigV1 { AppConfigV1 { name: String::from(name), owner: Some(String::from(owner)), package: PackageSource::from_str(package).unwrap(), app_id: None, domains: None, env: HashMap::new(), cli_args: None, capabilities: None, scheduled_tasks: None, volumes: None, health_checks: None, debug: None, scaling: None, extra: HashMap::new(), } } async fn get_app_name(&self) -> anyhow::Result { if let Some(name) = &self.app_name { return Ok(name.clone()); } if self.non_interactive { // if not interactive we can't prompt the user to choose the owner of the app. anyhow::bail!("No app name specified: use --name "); } let default_name = env::current_dir().ok().and_then(|dir| { dir.file_name() .and_then(|f| f.to_str()) .map(|s| s.to_owned()) }); crate::utils::prompts::prompt_for_ident( "What should be the name of the app?", default_name.as_deref(), ) } async fn get_owner(&self, client: &WasmerClient) -> anyhow::Result { if let Some(owner) = &self.owner { return Ok(owner.clone()); } if self.non_interactive { // if not interactive we can't prompt the user to choose the owner of the app. anyhow::bail!("No owner specified: use --owner "); } if !self.offline { let user = wasmer_api::query::current_user_with_namespaces(&client, None).await?; crate::utils::prompts::prompt_for_namespace( "Who should own this app?", None, Some(&user), ) } else { anyhow::bail!( "Please, user `wasmer login` before deploying an app or use the --owner flag to specify the owner of the app to deploy." ) } } async fn create_from_local_manifest( &self, owner: &str, app_name: &str, ) -> anyhow::Result { if !self.use_local_manifest && self.non_interactive { return Ok(false); } let app_dir = match &self.app_dir_path { Some(dir) => PathBuf::from(dir), None => std::env::current_dir()?, }; let (manifest_path, _) = if let Some(res) = load_package_manifest(&app_dir)? { res } else if self.use_local_manifest { anyhow::bail!("The --use_local_manifest flag was passed, but path {} does not contain a valid package manifest.", app_dir.display()) } else { return Ok(false); }; let ask_confirmation = || { eprintln!( "A package manifest was found in path {}.", &manifest_path.display() ); let theme = dialoguer::theme::ColorfulTheme::default(); Confirm::with_theme(&theme) .with_prompt("Use it for the app?") .interact() }; if self.use_local_manifest || ask_confirmation()? { let app_config = self.get_app_config(owner, app_name, "."); write_app_config(&app_config, self.app_dir_path.clone()).await?; self.try_deploy(owner, app_name).await?; return Ok(true); } Ok(false) } async fn create_from_package(&self, owner: &str, app_name: &str) -> anyhow::Result { if self.template.is_some() { return Ok(false); } if let Some(pkg) = &self.package { let app_config = self.get_app_config(owner, app_name, pkg); write_app_config(&app_config, self.app_dir_path.clone()).await?; self.try_deploy(owner, app_name).await?; return Ok(true); } else if !self.non_interactive { let theme = ColorfulTheme::default(); let package_name: String = dialoguer::Input::with_theme(&theme) .with_prompt("What is the name of the package?") .interact()?; let app_config = self.get_app_config(owner, app_name, &package_name); write_app_config(&app_config, self.app_dir_path.clone()).await?; self.try_deploy(owner, app_name).await?; return Ok(true); } else { eprintln!( "{}: the app creation process did not produce any local change.", "Warning".bold().yellow() ); } Ok(false) } // A utility function used to fetch the URL of the template to use. async fn get_template_url(&self, client: &WasmerClient) -> anyhow::Result { let mut url = if let Some(template) = &self.template { if let Ok(url) = url::Url::parse(&template) { url } else if let Some(template) = wasmer_api::query::fetch_app_template_from_slug(client, template.clone()).await? { url::Url::parse(&template.repo_url)? } else { anyhow::bail!("Template '{}' not found in the registry", template) } } else { if self.non_interactive { anyhow::bail!("No template selected") } let templates: Vec = wasmer_api::query::fetch_app_templates(client, String::new(), 20) .await? .ok_or(anyhow::anyhow!("No template received from the backend"))? .edges .into_iter() .filter(|v| v.is_some()) .map(|v| v.unwrap()) .filter(|v| v.node.is_some()) .map(|v| v.node.unwrap()) .collect(); let theme = ColorfulTheme::default(); let items = templates .iter() .map(|t| { format!( "{}{}{}\n", t.name.bold(), if t.language.is_empty() { String::new() } else { format!(" {}", t.language.dimmed()) }, format!( "\n {} {}", "demo url:".bold().dimmed(), t.demo_url.dimmed() ) ) }) .collect::>(); // items.sort(); let dialog = dialoguer::Select::with_theme(&theme) .with_prompt(format!("Select a template ({} available)", items.len())) .items(&items) .max_length(3) .clear(true) .report(false) .default(0); let selection = dialog.interact()?; let selected_template = templates .get(selection) .ok_or(anyhow::anyhow!("Invalid selection!"))?; if !self.quiet { eprintln!( "{} {} {} {} ({} {})", "✔".green().bold(), "Selected template".bold(), "·".dimmed(), selected_template.name.green().bold(), "demo url".dimmed().bold(), selected_template.demo_url.dimmed() ) } url::Url::parse(&selected_template.repo_url)? }; if url.path().contains("archive/refs/heads") { return Ok(url); } else if url.path().contains("/zipball/") { return Ok(url); } else { let old_path = url.path(); url.set_path(&format!("{old_path}/zipball/main")); return Ok(url); } } async fn create_from_template( &self, client: &WasmerClient, owner: &str, app_name: &str, ) -> anyhow::Result { let url = self.get_template_url(client).await?; tracing::info!("Downloading template from url {url}"); let output_path = if let Some(path) = &self.app_dir_path { path.clone() } else { PathBuf::from(".").canonicalize()? }; let pb = indicatif::ProgressBar::new_spinner(); pb.enable_steady_tick(std::time::Duration::from_millis(500)); pb.set_style( indicatif::ProgressStyle::with_template("{spinner:.magenta} {msg}") .unwrap() .tick_strings(&["✶", "✸", "✹", "✺", "✹", "✷"]), ); pb.set_message("Downloading package.."); let response = reqwest::get(url).await?; let bytes = response.bytes().await?; pb.set_message("Unpacking the template.."); let cursor = Cursor::new(bytes); let mut archive = zip::ZipArchive::new(cursor)?; // Extract the files to the output path for entry in 0..archive.len() { let mut entry = archive .by_index(entry) .context(format!("Getting the archive entry #{entry}"))?; let path = entry.mangled_name(); let path: PathBuf = { let mut components = path.components(); components.next(); components.collect() }; if path.to_str().unwrap_or_default().contains(".github") { continue; } let path = output_path.join(path); if let Some(parent) = path.parent() { if !parent.exists() { std::fs::create_dir_all(parent)?; } } if !path.exists() { // AsyncRead not implemented for entry.. if entry.is_file() { let mut outfile = std::fs::File::create(&path)?; std::io::copy(&mut entry, &mut outfile)?; } else { std::fs::create_dir(path)?; } } } pb.finish(); let app_yaml_path = output_path.join(AppConfigV1::CANONICAL_FILE_NAME); if app_yaml_path.exists() && app_yaml_path.is_file() { let contents = tokio::fs::read_to_string(&app_yaml_path).await?; let contents = format!("{contents}\nname: {app_name}"); let mut app_config = AppConfigV1::parse_yaml(&contents)?; app_config.owner = Some(owner.to_string()); let raw_app = serde_yaml::to_string(&app_config)?; tokio::fs::write(&app_yaml_path, raw_app).await?; } let build_md_path = output_path.join("BUILD.md"); if build_md_path.exists() { let contents = tokio::fs::read_to_string(build_md_path).await?; eprintln!( "{}: {} {}", "NOTE".bold(), "The selected template has a `BUILD.md` file. This means there are likely additional build steps that you need to perform before deploying the app:\n" .bold(), contents ); } else { self.try_deploy(owner, app_name).await?; } Ok(true) } async fn try_deploy(&self, owner: &str, app_name: &str) -> anyhow::Result<()> { let interactive = !self.non_interactive; let theme = dialoguer::theme::ColorfulTheme::default(); if self.deploy_app || (interactive && Confirm::with_theme(&theme) .with_prompt("Do you want to deploy the app now?") .interact()?) { let cmd_deploy = CmdAppDeploy { quiet: false, api: self.api.clone(), env: self.env.clone(), fmt: ItemFormatOpts { format: self.fmt.format, }, no_validate: false, non_interactive: self.non_interactive, publish_package: true, path: self.app_dir_path.clone(), no_wait: self.no_wait, no_default: false, no_persist_id: false, owner: Some(String::from(owner)), app_name: Some(app_name.into()), bump: false, template: self.template.clone(), package: self.package.clone(), use_local_manifest: self.use_local_manifest, }; cmd_deploy.run_async().await?; } Ok(()) } } #[async_trait::async_trait] impl AsyncCliCommand for CmdAppCreate { type Output = (); async fn run_async(self) -> Result { let client = login_user( &self.api, &self.env, !self.non_interactive, "retrieve informations about the owner of the app", ) .await?; // Get the future owner of the app. let owner = self.get_owner(&client).await?; // Get the name of the app. let app_name = self.get_app_name().await?; if !self.create_from_local_manifest(&owner, &app_name).await? { if self.template.is_some() { self.create_from_template(&client, &owner, &app_name) .await?; } else if self.package.is_some() { self.create_from_package(&owner, &app_name).await?; } else if !self.non_interactive { let theme = ColorfulTheme::default(); let choice = Select::with_theme(&theme) .with_prompt("What would you like to deploy?") .items(&["Start with a template", "Choose an existing package"]) .default(0) .interact()?; match choice { 0 => { self.create_from_template(&client, &owner, &app_name) .await? } 1 => self.create_from_package(&owner, &app_name).await?, x => panic!("unhandled selection {x}"), }; } else { eprintln!("Warning: the creation process did not produce any result."); } } Ok(()) } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_app_create_static_site_offline() { let dir = tempfile::tempdir().unwrap(); let cmd = CmdAppCreate { quiet: true, template: Some(AppType::StaticWebsite), deploy_app: false, no_validate: false, non_interactive: true, offline: true, owner: Some("testuser".to_string()), app_name: Some("static-site-1".to_string()), app_dir_path: Some(dir.path().to_owned()), no_wait: true, api: ApiOpts::default(), fmt: ItemFormatOpts::default(), package: Some("testuser/static-site-1@0.1.0".to_string()), use_local_manifest: false, new_package_name: None, env: WasmerEnv::default(), }; cmd.run_async().await.unwrap(); let app = std::fs::read_to_string(dir.path().join("app.yaml")).unwrap(); assert_eq!( app, r#"kind: wasmer.io/App.v0 name: static-site-1 owner: testuser package: testuser/static-site-1@^0.1.0 debug: false "#, ); } #[tokio::test] async fn test_app_create_offline_with_package() { let dir = tempfile::tempdir().unwrap(); let cmd = CmdAppCreate { quiet: true, template: Some(AppType::HttpServer), deploy_app: false, no_validate: false, non_interactive: true, offline: true, owner: Some("wasmer".to_string()), app_name: Some("testapp".to_string()), app_dir_path: Some(dir.path().to_owned()), no_wait: true, api: ApiOpts::default(), fmt: ItemFormatOpts::default(), package: Some("wasmer/testpkg".to_string()), use_local_manifest: false, new_package_name: None, env: WasmerEnv::default(), }; cmd.run_async().await.unwrap(); let app = std::fs::read_to_string(dir.path().join("app.yaml")).unwrap(); assert_eq!( app, r#"kind: wasmer.io/App.v0 name: testapp owner: wasmer package: wasmer/testpkg debug: false "#, ); } #[tokio::test] async fn test_app_create_js_worker() { let dir = tempfile::tempdir().unwrap(); let cmd = CmdAppCreate { quiet: true, template: Some(AppType::JsWorker), deploy_app: false, no_validate: false, non_interactive: true, offline: true, owner: Some("wasmer".to_string()), app_name: Some("test-js-worker".to_string()), app_dir_path: Some(dir.path().to_owned()), no_wait: true, api: ApiOpts::default(), fmt: ItemFormatOpts::default(), package: Some("wasmer/test-js-worker".to_string()), use_local_manifest: false, new_package_name: None, env: WasmerEnv::default(), }; cmd.run_async().await.unwrap(); let app = std::fs::read_to_string(dir.path().join("app.yaml")).unwrap(); assert_eq!( app, r#"kind: wasmer.io/App.v0 name: test-js-worker owner: wasmer package: wasmer/test-js-worker cli_args: - /src/index.js debug: false "#, ); } #[tokio::test] async fn test_app_create_py_worker() { let dir = tempfile::tempdir().unwrap(); let cmd = CmdAppCreate { quiet: true, template: Some(AppType::PyApplication), deploy_app: false, no_validate: false, non_interactive: true, offline: true, owner: Some("wasmer".to_string()), app_name: Some("test-py-worker".to_string()), app_dir_path: Some(dir.path().to_owned()), no_wait: true, api: ApiOpts::default(), fmt: ItemFormatOpts::default(), package: Some("wasmer/test-py-worker".to_string()), use_local_manifest: false, new_package_name: None, env: WasmerEnv::default(), }; cmd.run_async().await.unwrap(); let app = std::fs::read_to_string(dir.path().join("app.yaml")).unwrap(); assert_eq!( app, r#"kind: wasmer.io/App.v0 name: test-py-worker owner: wasmer package: wasmer/test-py-worker cli_args: - /src/main.py debug: false "#, ); } }