wasmer_cli/commands/app/
util.rs

1use std::{path::Path, str::FromStr};
2
3use anyhow::{Context, bail};
4use colored::Colorize;
5use dialoguer::{Confirm, theme::ColorfulTheme};
6use wasmer_backend_api::{
7    WasmerClient,
8    global_id::{GlobalId, NodeKind},
9    types::DeployApp,
10};
11use wasmer_config::app::AppConfigV1;
12
13use crate::{
14    commands::{AsyncCliCommand, Login},
15    config::WasmerEnv,
16};
17
18/// App identifier.
19///
20/// Can be either a namespace/name a plain name or an app id.
21#[derive(Debug, PartialEq, Eq, Clone)]
22pub enum AppIdent {
23    /// Backend app id like "da_xxysw34234"
24    AppId(String),
25    /// Backend app VERSION id like "dav_xxysw34234"
26    AppVersionId(String),
27    NamespacedName(String, String),
28    Name(String),
29}
30
31impl AppIdent {
32    /// Resolve an app identifier through the API.
33    pub async fn resolve(&self, client: &WasmerClient) -> Result<DeployApp, anyhow::Error> {
34        match self {
35            AppIdent::AppId(app_id) => {
36                wasmer_backend_api::query::get_app_by_id(client, app_id.clone())
37                    .await
38                    .with_context(|| format!("Could not find app with id '{app_id}'"))
39            }
40            AppIdent::AppVersionId(id) => {
41                let (app, _version) =
42                    wasmer_backend_api::query::get_app_version_by_id_with_app(client, id.clone())
43                        .await
44                        .with_context(|| format!("Could not query for app version id '{id}'"))?;
45                Ok(app)
46            }
47            AppIdent::Name(name) => {
48                // The API only allows to query by owner + name,
49                // so default to the current user as the owner.
50                // To to so the username must first be retrieved.
51                let user = wasmer_backend_api::query::current_user(client)
52                    .await?
53                    .context("not logged in")?;
54
55                wasmer_backend_api::query::get_app(client, user.username, name.clone())
56                    .await?
57                    .with_context(|| format!("Could not find app with name '{name}'"))
58            }
59            AppIdent::NamespacedName(owner, name) => {
60                wasmer_backend_api::query::get_app(client, owner.clone(), name.clone())
61                    .await?
62                    .with_context(|| format!("Could not find app '{owner}/{name}'"))
63            }
64        }
65    }
66}
67
68impl std::fmt::Display for AppIdent {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        match self {
71            AppIdent::AppId(id) => write!(f, "{id}"),
72            AppIdent::AppVersionId(id) => write!(f, "{id}"),
73            AppIdent::NamespacedName(namespace, name) => write!(f, "{namespace}/{name}"),
74            AppIdent::Name(name) => write!(f, "{name}"),
75        }
76    }
77}
78
79impl std::str::FromStr for AppIdent {
80    type Err = anyhow::Error;
81
82    fn from_str(s: &str) -> Result<Self, Self::Err> {
83        if let Some((namespace, name)) = s.split_once('/') {
84            if namespace.is_empty() {
85                bail!("invalid app identifier '{s}': namespace can not be empty");
86            }
87            if name.is_empty() {
88                bail!("invalid app identifier '{s}': name can not be empty");
89            }
90
91            Ok(Self::NamespacedName(
92                namespace.to_string(),
93                name.to_string(),
94            ))
95        } else if let Ok(id) = GlobalId::parse_prefixed(s) {
96            match id.kind() {
97                NodeKind::DeployApp => Ok(Self::AppId(s.to_string())),
98                NodeKind::DeployAppVersion => Ok(Self::AppVersionId(s.to_string())),
99                _ => {
100                    bail!(
101                        "invalid app identifier '{s}': expected an app id, but id is of type {kind}",
102                        kind = id.kind(),
103                    );
104                }
105            }
106        } else {
107            Ok(Self::Name(s.to_string()))
108        }
109    }
110}
111
112/// Options for identifying an app.
113///
114/// Provides convenience methods for resolving an app identifier or loading it
115/// from a local app.yaml.
116///
117/// NOTE: this is a separate struct to prevent the need for copy-pasting the
118/// field docs
119#[derive(clap::Parser, Debug)]
120pub struct AppIdentOpts {
121    /// Identifier of the application.
122    ///
123    /// NOTE: If not specified, the command will look for an app config file in
124    /// the current directory.
125    ///
126    /// Valid input:
127    /// - namespace/app-name
128    /// - app-alias
129    /// - App ID
130    pub app: Option<AppIdent>,
131}
132
133// Allowing because this is not performance-critical at all.
134#[allow(clippy::large_enum_variant)]
135pub enum ResolvedAppIdent {
136    Ident(AppIdent),
137    #[allow(dead_code)]
138    Config {
139        ident: AppIdent,
140        config: AppConfigV1,
141        path: std::path::PathBuf,
142    },
143}
144
145impl ResolvedAppIdent {
146    pub fn ident(&self) -> &AppIdent {
147        match self {
148            Self::Ident(ident) => ident,
149            Self::Config { ident, .. } => ident,
150        }
151    }
152}
153
154impl AppIdentOpts {
155    pub fn resolve_static_opt(&self) -> Result<Option<ResolvedAppIdent>, anyhow::Error> {
156        if let Some(id) = &self.app {
157            return Ok(Some(ResolvedAppIdent::Ident(id.clone())));
158        }
159
160        // Try to load from local.
161        let Some((config, path)) = get_app_config_from_current_dir_opt()? else {
162            return Ok(None);
163        };
164
165        let ident = if let Some(id) = &config.app_id {
166            AppIdent::AppId(id.clone())
167        } else if let Some(owner) = &config.owner {
168            AppIdent::NamespacedName(
169                owner.clone(),
170                config.name.clone().context("App name was not specified")?,
171            )
172        } else {
173            AppIdent::Name(config.name.clone().context("App name was not specified")?)
174        };
175
176        Ok(Some(ResolvedAppIdent::Config {
177            ident,
178            config,
179            path,
180        }))
181    }
182
183    pub fn resolve_static(&self) -> Result<ResolvedAppIdent, anyhow::Error> {
184        if let Some(id) = &self.app {
185            return Ok(ResolvedAppIdent::Ident(id.clone()));
186        }
187
188        // Try to load from local.
189        let (config, path) = get_app_config_from_current_dir()?;
190
191        let ident = if let Some(id) = &config.app_id {
192            AppIdent::AppId(id.clone())
193        } else if let Some(owner) = &config.owner {
194            AppIdent::NamespacedName(
195                owner.clone(),
196                config.name.clone().context("App name was not specified")?,
197            )
198        } else {
199            AppIdent::Name(config.name.clone().context("App name was not specified")?)
200        };
201
202        Ok(ResolvedAppIdent::Config {
203            ident,
204            config,
205            path,
206        })
207    }
208
209    /// Load the specified app from the API.
210    pub async fn load_app(
211        &self,
212        client: &WasmerClient,
213    ) -> Result<(ResolvedAppIdent, DeployApp), anyhow::Error> {
214        let id = self.resolve_static()?;
215        let app = id.ident().resolve(client).await?;
216
217        Ok((id, app))
218    }
219
220    pub async fn load_app_opt(
221        &self,
222        client: &WasmerClient,
223    ) -> Result<Option<(ResolvedAppIdent, DeployApp)>, anyhow::Error> {
224        let id = match self.resolve_static_opt()? {
225            Some(id) => id,
226            None => return Ok(None),
227        };
228        let app = id.ident().resolve(client).await?;
229
230        Ok(Some((id, app)))
231    }
232}
233
234/// Options for identifying an app.
235///
236/// Same as [`AppIdentOpts`], but with the app being a --app flag instead of
237/// a positional argument.
238#[derive(clap::Parser, Debug, Clone)]
239pub struct AppIdentArgOpts {
240    /// Identifier of the application.
241    ///
242    /// NOTE: If not specified, the command will look for an app config file in
243    /// the current directory.
244    ///
245    /// Valid input:
246    /// - namespace/app-name
247    /// - app-alias
248    /// - App ID
249    #[clap(long, short)]
250    pub app: Option<AppIdent>,
251}
252
253impl AppIdentArgOpts {
254    /// Convert to `AppIdentOpts`.
255    /// Useful for accessing the methods on that type.
256    pub fn to_opts(&self) -> AppIdentOpts {
257        AppIdentOpts {
258            app: self.app.clone(),
259        }
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use std::str::FromStr;
266
267    use super::*;
268
269    #[test]
270    fn test_app_ident() {
271        assert_eq!(
272            AppIdent::from_str("da_MRrWI0t5U582").unwrap(),
273            AppIdent::AppId("da_MRrWI0t5U582".to_string()),
274        );
275        assert_eq!(
276            AppIdent::from_str("lala").unwrap(),
277            AppIdent::Name("lala".to_string()),
278        );
279
280        assert_eq!(
281            AppIdent::from_str("alpha/beta").unwrap(),
282            AppIdent::NamespacedName("alpha".to_string(), "beta".to_string()),
283        );
284    }
285}
286
287/// A utility struct used by commands that need the [`AppIdent`] as a flag.
288///
289/// NOTE: Differently from [`AppIdentOpts`], the use of this struct does not entail searching the
290/// current directory for an `app.yaml` if not specified.
291#[derive(clap::Parser, Debug)]
292pub struct AppIdentFlag {
293    /// Identifier of the application.
294    ///
295    /// Valid input:
296    /// - namespace/app-name
297    /// - app-alias
298    /// - App ID
299    #[clap(long)]
300    pub app: Option<AppIdent>,
301}
302
303pub(super) async fn login_user(
304    env: &WasmerEnv,
305    interactive: bool,
306    msg: &str,
307) -> anyhow::Result<WasmerClient> {
308    if let Ok(client) = env.client() {
309        return Ok(client);
310    }
311
312    let theme = dialoguer::theme::ColorfulTheme::default();
313
314    if env.token().is_none() {
315        if interactive {
316            eprintln!(
317                "{}: You need to be logged in to {msg}.",
318                "WARN".yellow().bold()
319            );
320
321            if Confirm::with_theme(&theme)
322                .with_prompt("Do you want to login now?")
323                .interact()?
324            {
325                Login {
326                    no_browser: false,
327                    wasmer_dir: env.dir().to_path_buf(),
328                    cache_dir: env.cache_dir().to_path_buf(),
329                    token: None,
330                    registry: env.registry.clone(),
331                }
332                .run_async()
333                .await?;
334                // self.api = ApiOpts::default();
335            } else {
336                anyhow::bail!("Stopping the flow as the user is not logged in.")
337            }
338        } else {
339            let bin_name = match std::env::args().next() {
340                Some(n) => n,
341                None => String::from("wasmer"),
342            };
343            eprintln!(
344                "You are not logged in. Use the `--token` flag or log in (use `{bin_name} login`) to {msg}."
345            );
346
347            anyhow::bail!("Stopping execution as the user is not logged in.")
348        }
349    }
350
351    env.client()
352}
353
354pub fn get_app_config_from_dir_opt(
355    path: &Path,
356) -> Result<Option<(AppConfigV1, std::path::PathBuf)>, anyhow::Error> {
357    let app_config_path = path.join(AppConfigV1::CANONICAL_FILE_NAME);
358
359    if !app_config_path.exists() || !app_config_path.is_file() {
360        return Ok(None);
361    }
362    // read the app.yaml
363    let raw_app_config = std::fs::read_to_string(&app_config_path)
364        .with_context(|| format!("Could not read file '{}'", app_config_path.display()))?;
365
366    // parse the app.yaml
367    let config = AppConfigV1::parse_yaml(&raw_app_config)
368        .map_err(|err| anyhow::anyhow!("Could not parse app.yaml: {err:?}"))?;
369
370    Ok(Some((config, app_config_path)))
371}
372
373pub fn get_app_config_from_current_dir_opt()
374-> Result<Option<(AppConfigV1, std::path::PathBuf)>, anyhow::Error> {
375    let current_dir = std::env::current_dir()?;
376    get_app_config_from_dir_opt(&current_dir)
377}
378
379pub fn get_app_config_from_dir(
380    path: &Path,
381) -> Result<(AppConfigV1, std::path::PathBuf), anyhow::Error> {
382    get_app_config_from_dir_opt(path)?
383        .with_context(|| {
384            format!(
385                "Could not find app.yaml in directory: '{}'.\nPlease specify an app like 'wasmer app get <namespace>/<name>' or 'wasmer app get <name>`'",
386                path.display()
387            )
388        })
389}
390
391pub fn get_app_config_from_current_dir() -> Result<(AppConfigV1, std::path::PathBuf), anyhow::Error>
392{
393    let current_dir = std::env::current_dir()?;
394    get_app_config_from_dir(&current_dir)
395}
396
397/// Prompt for an app ident.
398#[allow(dead_code)]
399pub(crate) fn prompt_app_ident(message: &str) -> Result<AppIdent, anyhow::Error> {
400    let theme = ColorfulTheme::default();
401    loop {
402        let ident: String = dialoguer::Input::with_theme(&theme)
403            .with_prompt(message)
404            .interact_text()?;
405        match AppIdent::from_str(&ident) {
406            Ok(id) => break Ok(id),
407            Err(e) => eprintln!("{e}"),
408        }
409    }
410}