wasmer_cli/commands/app/
create.rs

1//! Create a new Edge app.
2
3use std::{
4    env,
5    io::Cursor,
6    path::{Path, PathBuf},
7    str::FromStr,
8    time::Duration,
9};
10
11use anyhow::Context;
12use colored::Colorize;
13use dialoguer::{Confirm, Select, theme::ColorfulTheme};
14use futures::stream::TryStreamExt;
15use indexmap::IndexMap;
16use is_terminal::IsTerminal;
17use wasmer_backend_api::{
18    WasmerClient,
19    types::{AppTemplate, TemplateLanguage},
20};
21use wasmer_config::{app::AppConfigV1, package::PackageSource};
22
23use super::{deploy::CmdAppDeploy, util::login_user};
24use crate::{
25    commands::AsyncCliCommand,
26    config::WasmerEnv,
27    opts::ItemFormatOpts,
28    utils::{load_package_manifest, prompts::PackageCheckMode},
29};
30
31pub(crate) async fn write_app_config(
32    app_config: &AppConfigV1,
33    dir: Option<PathBuf>,
34) -> anyhow::Result<()> {
35    let raw_app_config = app_config.clone().to_yaml()?;
36
37    let app_dir = match dir {
38        Some(dir) => dir,
39        None => std::env::current_dir()?,
40    };
41
42    tokio::fs::create_dir_all(&app_dir).await?;
43
44    let app_config_path = app_dir.join(AppConfigV1::CANONICAL_FILE_NAME);
45    tokio::fs::write(&app_config_path, raw_app_config)
46        .await
47        .with_context(|| {
48            format!(
49                "could not write app config to '{}'",
50                app_config_path.display()
51            )
52        })
53}
54
55pub(crate) fn minimal_app_config(owner: &str, name: &str) -> AppConfigV1 {
56    AppConfigV1 {
57        name: Some(String::from(name)),
58        owner: Some(String::from(owner)),
59        package: PackageSource::Path(String::from(".")),
60        app_id: None,
61        domains: None,
62        env: IndexMap::new(),
63        cli_args: None,
64        capabilities: None,
65        scheduled_tasks: None,
66        volumes: None,
67        health_checks: None,
68        debug: None,
69        scaling: None,
70        locality: None,
71        redirect: None,
72        extra: IndexMap::new(),
73        jobs: None,
74    }
75}
76
77/// Create a new Edge app.
78#[derive(clap::Parser, Debug)]
79pub struct CmdAppCreate {
80    /// A reference to the template to use.
81    ///
82    /// It can be either an URL to a github repository - like
83    /// `https://github.com/wasmer-examples/php-wasmer-starter` -  or the name of a template that
84    /// will be searched for in the selected registry, like `astro-starter`.
85    #[clap(
86        long,
87        conflicts_with = "package",
88        conflicts_with = "use_local_manifest"
89    )]
90    pub template: Option<String>,
91
92    /// Name of the package to use.
93    #[clap(
94        long,
95        conflicts_with = "template",
96        conflicts_with = "use_local_manifest"
97    )]
98    pub package: Option<String>,
99
100    /// Whether or not to search (and use) a local manifest.
101    #[clap(long, conflicts_with = "template", conflicts_with = "package")]
102    pub use_local_manifest: bool,
103
104    /// Whether or not to deploy the application once it is created.
105    ///
106    /// If selected, this might entail the step of publishing the package related to the
107    /// application. By default, the application is not deployed and the package is not published.
108    #[clap(long = "deploy")]
109    pub deploy_app: bool,
110
111    /// Skip local schema validation.
112    #[clap(long)]
113    pub no_validate: bool,
114
115    /// Do not prompt for user input.
116    #[clap(long, default_value_t = !std::io::stdin().is_terminal())]
117    pub non_interactive: bool,
118
119    /// Do not interact with any APIs.
120    #[clap(long)]
121    pub offline: bool,
122
123    /// The owner of the app.
124    #[clap(long)]
125    pub owner: Option<String>,
126
127    /// The name of the app (can be changed later)
128    #[clap(long = "name")]
129    pub app_name: Option<String>,
130
131    /// The path to the directory where the config file for the application will be written to.
132    #[clap(long = "dir")]
133    pub app_dir_path: Option<PathBuf>,
134
135    /// Do not wait for the app to become reachable if deployed.
136    #[clap(long)]
137    pub no_wait: bool,
138
139    // Common args.
140    #[clap(flatten)]
141    pub env: WasmerEnv,
142
143    #[clap(flatten)]
144    #[allow(missing_docs)]
145    pub fmt: ItemFormatOpts,
146
147    /// Name to use when creating a new package from a template.
148    #[clap(long)]
149    pub new_package_name: Option<String>,
150
151    /// Don't print any message.
152    #[clap(long)]
153    pub quiet: bool,
154}
155
156impl CmdAppCreate {
157    #[inline]
158    fn get_app_config(&self, owner: &str, name: &str, package: &str) -> AppConfigV1 {
159        AppConfigV1 {
160            name: Some(String::from(name)),
161            owner: Some(String::from(owner)),
162            package: PackageSource::from_str(package).unwrap(),
163            app_id: None,
164            domains: None,
165            env: IndexMap::new(),
166            cli_args: None,
167            capabilities: None,
168            scheduled_tasks: None,
169            volumes: None,
170            health_checks: None,
171            debug: None,
172            scaling: None,
173            locality: None,
174            redirect: None,
175            extra: IndexMap::new(),
176            jobs: None,
177        }
178    }
179
180    async fn get_app_name(&self) -> anyhow::Result<String> {
181        if let Some(name) = &self.app_name {
182            return Ok(name.clone());
183        }
184
185        if self.non_interactive {
186            // if not interactive we can't prompt the user to choose the owner of the app.
187            anyhow::bail!("No app name specified: use --name <app_name>");
188        }
189
190        let default_name = match &self.app_dir_path {
191            Some(path) => path
192                .file_name()
193                .and_then(|f| f.to_str())
194                .map(|s| s.to_owned()),
195            None => env::current_dir().ok().and_then(|dir| {
196                dir.file_name()
197                    .and_then(|f| f.to_str())
198                    .map(|s| s.to_owned())
199            }),
200        };
201
202        crate::utils::prompts::prompt_for_app_ident(
203            "What should be the name of the app?",
204            default_name.as_deref(),
205        )
206    }
207
208    async fn get_owner(&self, client: Option<&WasmerClient>) -> anyhow::Result<String> {
209        if let Some(owner) = &self.owner {
210            return Ok(owner.clone());
211        }
212
213        if self.non_interactive {
214            // if not interactive we can't prompt the user to choose the owner of the app.
215            anyhow::bail!("No owner specified: use --owner <owner>");
216        }
217
218        let user = if let Some(client) = client {
219            Some(wasmer_backend_api::query::current_user_with_namespaces(client, None).await?)
220        } else {
221            None
222        };
223        crate::utils::prompts::prompt_for_namespace("Who should own this app?", None, user.as_ref())
224    }
225
226    async fn get_output_dir(&self, app_name: &str) -> anyhow::Result<PathBuf> {
227        let mut output_path = if let Some(path) = &self.app_dir_path {
228            path.clone()
229        } else {
230            PathBuf::from(".").canonicalize()?
231        };
232
233        if output_path.is_dir() && output_path.read_dir()?.next().is_some() {
234            if self.non_interactive {
235                if !self.quiet {
236                    eprintln!("The current directory is not empty.");
237                    eprintln!(
238                        "Use the `--dir` flag to specify another directory, or remove files from the currently selected one."
239                    )
240                }
241                anyhow::bail!("Stopping as the directory is not empty")
242            } else {
243                let theme = ColorfulTheme::default();
244                let raw: String = dialoguer::Input::with_theme(&theme)
245                    .with_prompt("Select the directory to save the app in")
246                    .with_initial_text(app_name)
247                    .interact_text()
248                    .context("could not read user input")?;
249                output_path = PathBuf::from_str(&raw)?
250            }
251        }
252
253        Ok(output_path)
254    }
255
256    async fn create_from_local_manifest(
257        &self,
258        owner: &str,
259        app_name: &str,
260    ) -> anyhow::Result<bool> {
261        if (!self.use_local_manifest && self.non_interactive)
262            || self.template.is_some()
263            || self.package.is_some()
264        {
265            return Ok(false);
266        }
267
268        let app_dir = match &self.app_dir_path {
269            Some(dir) => PathBuf::from(dir),
270            None => std::env::current_dir()?,
271        };
272
273        let (manifest_path, _) = if let Some(res) = load_package_manifest(&app_dir)? {
274            res
275        } else if self.use_local_manifest {
276            anyhow::bail!(
277                "The --use_local_manifest flag was passed, but path {} does not contain a valid package manifest.",
278                app_dir.display()
279            )
280        } else {
281            return Ok(false);
282        };
283
284        let ask_confirmation = || {
285            eprintln!(
286                "A package manifest was found in path {}.",
287                &manifest_path.display()
288            );
289            let theme = dialoguer::theme::ColorfulTheme::default();
290            Confirm::with_theme(&theme)
291                .with_prompt("Use it for the app?")
292                .interact()
293        };
294
295        if self.use_local_manifest || ask_confirmation()? {
296            let app_config = self.get_app_config(owner, app_name, ".");
297            write_app_config(&app_config, self.app_dir_path.clone()).await?;
298            self.try_deploy(owner, app_name, None, false, false).await?;
299            return Ok(true);
300        }
301
302        Ok(false)
303    }
304
305    async fn create_from_package(
306        &self,
307        client: Option<&WasmerClient>,
308        owner: &str,
309        app_name: &str,
310    ) -> anyhow::Result<bool> {
311        if self.template.is_some() {
312            return Ok(false);
313        }
314
315        let output_path = self.get_output_dir(app_name).await?;
316
317        if let Some(pkg) = &self.package {
318            let app_config = self.get_app_config(owner, app_name, pkg);
319            write_app_config(&app_config, Some(output_path.clone())).await?;
320            self.try_deploy(owner, app_name, Some(&output_path), false, false)
321                .await?;
322            return Ok(true);
323        } else if !self.non_interactive {
324            let (package_id, _) = crate::utils::prompts::prompt_for_package(
325                "Enter the name of the package",
326                Some("wasmer/hello"),
327                if client.is_some() {
328                    Some(PackageCheckMode::MustExist)
329                } else {
330                    None
331                },
332                client,
333            )
334            .await?;
335
336            let app_config = self.get_app_config(owner, app_name, &package_id.to_string());
337            write_app_config(&app_config, Some(output_path.clone())).await?;
338            self.try_deploy(owner, app_name, Some(&output_path), false, false)
339                .await?;
340            return Ok(true);
341        } else {
342            eprintln!(
343                "{}: the app creation process did not produce any local change.",
344                "Warning".bold().yellow()
345            );
346        }
347
348        Ok(false)
349    }
350
351    fn persist_in_cache<S: serde::Serialize>(path: &Path, data: &S) -> Result<(), anyhow::Error> {
352        if let Some(parent) = path.parent() {
353            std::fs::create_dir_all(parent).context("could not create cache dir")?;
354        }
355
356        let data = serde_json::to_vec(data)?;
357
358        std::fs::write(path, data)?;
359        tracing::trace!(path=%path.display(), "persisted app template cache");
360
361        Ok(())
362    }
363
364    /// Tries to retrieve templates from a local file cache.
365    /// Fetches the templates from the backend if the file doesn't exist,
366    /// can't be loaded, or is older then the max age,
367    async fn fetch_templates_cached(
368        client: &WasmerClient,
369        cache_dir: &Path,
370        language: &str,
371    ) -> Result<Vec<AppTemplate>, anyhow::Error> {
372        const MAX_CACHE_AGE: Duration = Duration::from_secs(60 * 60);
373        const MAX_COUNT: usize = 100;
374        let cache_filename = format!("app_templates_{language}.json");
375
376        let cache_path = cache_dir.join(cache_filename);
377
378        let cached_items = match Self::load_cached::<Vec<AppTemplate>>(&cache_path) {
379            Ok((items, age)) => {
380                if age <= MAX_CACHE_AGE {
381                    return Ok(items);
382                }
383                items
384            }
385            Err(e) => {
386                tracing::trace!(error = &*e, "could not load templates from local cache");
387                Vec::new()
388            }
389        };
390
391        // Either no cache present, or cache has exceeded max age.
392        // Fetch the first page.
393        // If first item matches, then no need to re-fetch.
394        //
395        let stream = wasmer_backend_api::query::fetch_all_app_templates_from_language(
396            client,
397            10,
398            Some(wasmer_backend_api::types::AppTemplatesSortBy::Newest),
399            language.to_string(),
400        );
401
402        futures_util::pin_mut!(stream);
403
404        let first_page = match stream.try_next().await? {
405            Some(items) => items,
406            None => return Ok(Vec::new()),
407        };
408
409        if let (Some(a), Some(b)) = (cached_items.first(), first_page.first()) {
410            if a == b {
411                // Cached items are up to date, no need to query more.
412                return Ok(cached_items);
413            }
414        }
415
416        let mut items = first_page;
417        while let Some(next) = stream.try_next().await? {
418            items.extend(next);
419
420            if items.len() >= MAX_COUNT {
421                break;
422            }
423        }
424
425        // Persist to cache.
426        if let Err(err) = Self::persist_in_cache(&cache_path, &items) {
427            tracing::trace!(error = &*err, "could not persist template cache");
428        }
429
430        // TODO: sort items by popularity!
431        // Since we can't rely on backend sorting because of the cache
432        // preservation logic, the backend needs to add a popluarity field.
433
434        Ok(items)
435    }
436
437    /// Load cached data from a file.
438    ///
439    /// Returns an error if the cache file is older than the max age.
440    fn load_cached<D: serde::de::DeserializeOwned>(
441        path: &Path,
442    ) -> Result<(D, std::time::Duration), anyhow::Error> {
443        let modified = path.metadata()?.modified()?;
444        let age = modified.elapsed()?;
445
446        let data = std::fs::read_to_string(path)?;
447        match serde_json::from_str::<D>(data.as_str()) {
448            Ok(v) => Ok((v, age)),
449            Err(err) => {
450                std::fs::remove_file(path).ok();
451                Err(err).context("could not deserialize cached file")
452            }
453        }
454    }
455
456    async fn fetch_template_languages_cached(
457        client: &WasmerClient,
458        cache_dir: &Path,
459    ) -> anyhow::Result<Vec<TemplateLanguage>> {
460        const MAX_CACHE_AGE: Duration = Duration::from_secs(60 * 60);
461        const MAX_COUNT: usize = 100;
462        const CACHE_FILENAME: &str = "app_languages.json";
463
464        let cache_path = cache_dir.join(CACHE_FILENAME);
465
466        let cached_items = match Self::load_cached::<Vec<TemplateLanguage>>(&cache_path) {
467            Ok((items, age)) => {
468                if age <= MAX_CACHE_AGE {
469                    return Ok(items);
470                }
471                items
472            }
473            Err(e) => {
474                tracing::trace!(error = &*e, "could not load templates from local cache");
475                Vec::new()
476            }
477        };
478        //
479        // Either no cache present, or cache has exceeded max age.
480        // Fetch the first page.
481        // If first item matches, then no need to re-fetch.
482        let mut stream = Box::pin(wasmer_backend_api::query::fetch_all_app_template_languages(
483            client, None,
484        ));
485
486        let first_page = match stream.try_next().await? {
487            Some(items) => items,
488            None => return Ok(Vec::new()),
489        };
490
491        if let (Some(a), Some(b)) = (cached_items.first(), first_page.first()) {
492            if a == b {
493                // Cached items are up to date, no need to query more.
494                return Ok(cached_items);
495            }
496        }
497
498        let mut items = first_page;
499        while let Some(next) = stream.try_next().await? {
500            items.extend(next);
501
502            if items.len() >= MAX_COUNT {
503                break;
504            }
505        }
506
507        // Persist to cache.
508        if let Err(err) = Self::persist_in_cache(&cache_path, &items) {
509            tracing::trace!(error = &*err, "could not persist template cache");
510        }
511
512        // TODO: sort items by popularity!
513        // Since we can't rely on backend sorting because of the cache
514        // preservation logic, the backend needs to add a popluarity field.
515
516        Ok(items)
517    }
518
519    // A utility function used to fetch the URL of the template to use.
520    async fn get_template_url(&self, client: &WasmerClient) -> anyhow::Result<url::Url> {
521        let mut url = if let Some(template) = &self.template {
522            if let Ok(url) = url::Url::parse(template) {
523                url
524            } else if let Some(template) =
525                wasmer_backend_api::query::fetch_app_template_from_slug(client, template.clone())
526                    .await?
527            {
528                url::Url::parse(&template.repo_url)?
529            } else {
530                anyhow::bail!("Template '{template}' not found in the registry")
531            }
532        } else {
533            if self.non_interactive {
534                anyhow::bail!("No template selected")
535            }
536
537            let theme = ColorfulTheme::default();
538            let registry = self
539                .env
540                .registry_public_url()?
541                .host_str()
542                .unwrap_or("unknown_registry")
543                .replace('.', "_");
544            let cache_dir = self.env.cache_dir().join("templates").join(registry);
545
546            let languages = Self::fetch_template_languages_cached(client, &cache_dir).await?;
547
548            let items = languages.iter().map(|t| t.name.clone()).collect::<Vec<_>>();
549
550            // Note: this should really use `dialoger::FuzzySelect`, but that
551            // breaks the formatting.
552            let dialog = dialoguer::Select::with_theme(&theme)
553                .with_prompt(format!("Select a language ({} available)", items.len()))
554                .items(&items)
555                .max_length(10)
556                .clear(true)
557                .report(true)
558                .default(0);
559
560            let selection = dialog.interact()?;
561
562            let selected_language = languages
563                .get(selection)
564                .ok_or(anyhow::anyhow!("Invalid selection!"))?;
565
566            let templates =
567                Self::fetch_templates_cached(client, &cache_dir, &selected_language.slug).await?;
568
569            let items = templates
570                .iter()
571                .map(|t| {
572                    format!(
573                        "{} - {} {}",
574                        t.name.bold(),
575                        "demo:".bold().dimmed(),
576                        t.demo_url.dimmed()
577                    )
578                })
579                .collect::<Vec<_>>();
580
581            let dialog = dialoguer::Select::with_theme(&theme)
582                .with_prompt(format!("Select a template ({} available)", items.len()))
583                .items(&items)
584                .max_length(10)
585                .clear(true)
586                .report(false)
587                .default(0);
588
589            let selection = dialog.interact()?;
590
591            let selected_template = templates
592                .get(selection)
593                .ok_or(anyhow::anyhow!("Invalid selection!"))?;
594
595            if !self.quiet {
596                eprintln!(
597                    "{} {} {} {} - {} {}",
598                    "✔".green().bold(),
599                    "Selected template".bold(),
600                    "·".dimmed(),
601                    selected_template.name.green().bold(),
602                    "demo url".dimmed().bold(),
603                    selected_template.demo_url.dimmed()
604                )
605            }
606
607            url::Url::parse(&selected_template.repo_url)?
608        };
609
610        let url = if url.path().contains("archive/refs/heads") || url.path().contains("/zipball/") {
611            url
612        } else {
613            let old_path = url.path();
614            url.set_path(&format!("{old_path}/zipball/main"));
615            url
616        };
617
618        Ok(url)
619    }
620
621    async fn create_from_template(
622        &self,
623        client: Option<&WasmerClient>,
624        owner: &str,
625        app_name: &str,
626    ) -> anyhow::Result<bool> {
627        let client = match client {
628            Some(client) => client,
629            None => anyhow::bail!("Cannot"),
630        };
631
632        let url = self.get_template_url(client).await?;
633
634        tracing::info!("Downloading template from url {url}");
635
636        let output_path = self.get_output_dir(app_name).await?;
637        let pb = indicatif::ProgressBar::new_spinner();
638
639        pb.enable_steady_tick(std::time::Duration::from_millis(500));
640        pb.set_style(
641            indicatif::ProgressStyle::with_template("{spinner:.magenta} {msg}")
642                .unwrap()
643                .tick_strings(&["✶", "✸", "✹", "✺", "✹", "✷"]),
644        );
645
646        pb.set_message("Downloading package..");
647
648        let response = reqwest::get(url).await?;
649        let bytes = response.bytes().await?;
650        pb.set_message("Unpacking the template..");
651
652        let cursor = Cursor::new(bytes);
653        let mut archive = zip::ZipArchive::new(cursor)?;
654
655        // Extract the files to the output path
656        for entry in 0..archive.len() {
657            let mut entry = archive
658                .by_index(entry)
659                .context(format!("Getting the archive entry #{entry}"))?;
660
661            let path = entry.mangled_name();
662
663            let path: PathBuf = {
664                let mut components = path.components();
665                components.next();
666                components.collect()
667            };
668
669            if path.to_str().unwrap_or_default().contains(".github") {
670                continue;
671            }
672
673            let path = output_path.join(path);
674
675            if let Some(parent) = path.parent() {
676                if !parent.exists() {
677                    std::fs::create_dir_all(parent)?;
678                }
679            }
680
681            if !path.exists() {
682                // AsyncRead not implemented for entry..
683                if entry.is_file() {
684                    let mut outfile = std::fs::OpenOptions::new()
685                        .create(true)
686                        .truncate(true)
687                        .write(true)
688                        .open(&path)?;
689                    std::io::copy(&mut entry, &mut outfile)?;
690                } else {
691                    std::fs::create_dir(path)?;
692                }
693            }
694        }
695        pb.set_style(
696            indicatif::ProgressStyle::with_template(&format!("{} {{msg}}", "✔".green().bold()))
697                .unwrap(),
698        );
699        pb.finish_with_message(format!("{}", "Unpacked template".bold()));
700
701        pb.finish();
702
703        let app_yaml_path = output_path.join(AppConfigV1::CANONICAL_FILE_NAME);
704
705        if app_yaml_path.exists() && app_yaml_path.is_file() {
706            let contents = tokio::fs::read_to_string(&app_yaml_path).await?;
707            let mut raw_yaml: serde_yaml::Value = serde_yaml::from_str(&contents)?;
708
709            if let serde_yaml::Value::Mapping(m) = &mut raw_yaml {
710                m.insert("name".into(), app_name.into());
711                m.insert("owner".into(), owner.into());
712                m.shift_remove("domains");
713                m.shift_remove("app_id");
714            };
715
716            let raw_app = serde_yaml::to_string(&raw_yaml)?;
717
718            // Validate..
719            AppConfigV1::parse_yaml(&raw_app)?;
720
721            tokio::fs::write(&app_yaml_path, raw_app).await?;
722        } else {
723            let app_config = minimal_app_config(owner, app_name);
724            write_app_config(&app_config, Some(output_path.clone())).await?;
725        }
726
727        let build_md_path = output_path.join("BUILD.md");
728        if build_md_path.exists() {
729            let contents = tokio::fs::read_to_string(build_md_path).await?;
730            eprintln!(
731                "{}: {} 
732{}",
733                "NOTE".bold(),
734                "The selected template has a `BUILD.md` file.
735This means there are likely additional build 
736steps that you need to perform before deploying
737the app:\n"
738                    .bold(),
739                contents
740            );
741            let bin_name = match std::env::args().nth(0) {
742                Some(n) => n,
743                None => String::from("wasmer"),
744            };
745            eprintln!(
746                "After taking the necessary steps to build your application, re-run `{}`",
747                format!("{bin_name} deploy").bold()
748            )
749        } else {
750            self.try_deploy(owner, app_name, Some(&output_path), false, false)
751                .await?;
752        }
753
754        Ok(true)
755    }
756
757    async fn try_deploy(
758        &self,
759        owner: &str,
760        app_name: &str,
761        path: Option<&Path>,
762        build_remote: bool,
763        skip_prompt: bool,
764    ) -> anyhow::Result<()> {
765        let interactive = !self.non_interactive;
766        let theme = dialoguer::theme::ColorfulTheme::default();
767
768        let mut should_deploy = self.deploy_app;
769
770        if skip_prompt {
771            should_deploy = true;
772        } else if !should_deploy && interactive {
773            should_deploy = Confirm::with_theme(&theme)
774                .with_prompt("Do you want to deploy the app now?")
775                .interact()?;
776        }
777
778        if should_deploy {
779            let cmd_deploy = CmdAppDeploy {
780                quiet: false,
781                env: self.env.clone(),
782                fmt: ItemFormatOpts {
783                    format: self.fmt.format,
784                },
785                no_validate: false,
786                non_interactive: self.non_interactive,
787                publish_package: !build_remote,
788                dir: self.app_dir_path.clone(),
789                path: path.map(|v| v.to_path_buf()),
790                no_wait: self.no_wait,
791                no_default: false,
792                no_persist_id: false,
793                owner: Some(String::from(owner)),
794                app_name: Some(app_name.into()),
795                bump: false,
796                build_remote,
797                template: None,
798                package: None,
799                use_local_manifest: self.use_local_manifest,
800                ensure_app_config: true,
801            };
802            cmd_deploy.run_async().await?;
803        }
804
805        Ok(())
806    }
807}
808
809#[async_trait::async_trait]
810impl AsyncCliCommand for CmdAppCreate {
811    type Output = ();
812
813    async fn run_async(self) -> Result<Self::Output, anyhow::Error> {
814        let client = if self.offline {
815            None
816        } else {
817            Some(
818                login_user(
819                    &self.env,
820                    !self.non_interactive,
821                    "retrieve informations about the owner of the app",
822                )
823                .await?,
824            )
825        };
826
827        // Get the future owner of the app.
828        let owner = self.get_owner(client.as_ref()).await?;
829
830        // Get the name of the app.
831        let app_name = self.get_app_name().await?;
832
833        if !self.create_from_local_manifest(&owner, &app_name).await? {
834            if self.template.is_some() {
835                self.create_from_template(client.as_ref(), &owner, &app_name)
836                    .await?;
837            } else if self.package.is_some() {
838                self.create_from_package(client.as_ref(), &owner, &app_name)
839                    .await?;
840            } else if !self.non_interactive {
841                if self.offline {
842                    eprintln!("Creating app from a package name running in offline mode");
843                    self.create_from_package(client.as_ref(), &owner, &app_name)
844                        .await?;
845                } else {
846                    let theme = ColorfulTheme::default();
847                    let working_dir = if let Some(dir) = &self.app_dir_path {
848                        dir.clone()
849                    } else {
850                        std::env::current_dir()?
851                    };
852
853                    let remote_option_available = working_dir.is_dir()
854                        && std::fs::read_dir(&working_dir)?.next().is_some()
855                        && !working_dir.join(AppConfigV1::CANONICAL_FILE_NAME).exists()
856                        && load_package_manifest(&working_dir)?.is_none();
857
858                    let mut items = Vec::new();
859                    let mut remote_idx = None;
860                    if remote_option_available {
861                        remote_idx = Some(items.len());
862                        items.push(String::from(
863                            "Deploy the current directory with a remote build",
864                        ));
865                    }
866                    let template_idx = items.len();
867                    items.push(String::from("Start with a template"));
868                    let package_idx = items.len();
869                    items.push(String::from("Choose an existing package"));
870
871                    let choice = Select::with_theme(&theme)
872                        .with_prompt("What would you like to deploy?")
873                        .items(&items)
874                        .default(0)
875                        .interact()?;
876
877                    if remote_idx.is_some() && Some(choice) == remote_idx {
878                        let app_config = minimal_app_config(owner.as_str(), app_name.as_str());
879                        write_app_config(&app_config, Some(working_dir.clone())).await?;
880                        self.try_deploy(
881                            owner.as_str(),
882                            app_name.as_str(),
883                            Some(&working_dir),
884                            true,
885                            true,
886                        )
887                        .await?;
888                        return Ok(());
889                    } else if choice == template_idx {
890                        self.create_from_template(client.as_ref(), &owner, &app_name)
891                            .await?
892                    } else if choice == package_idx {
893                        self.create_from_package(client.as_ref(), &owner, &app_name)
894                            .await?
895                    } else {
896                        panic!("unhandled selection {choice}");
897                    };
898                }
899            } else {
900                eprintln!("Warning: the creation process did not produce any result.");
901            }
902        }
903
904        Ok(())
905    }
906}