From 9e4b2253f6ddaccd0f5c88734287bd5c84d4936a Mon Sep 17 00:00:00 2001 From: Amr Bashir Date: Sat, 25 May 2024 17:46:55 +0300 Subject: [PATCH] feat(cli): add `tauri bundle` subcommand (#9734) * feat(cli): add `tauri bundle` subcommand closes #8734 * license header * log application path after building * fix no-bundle check * typo * enhance error with deep causes --------- Co-authored-by: Lucas Nogueira --- .changes/tauri-bundle-command.md | 7 + tooling/bundler/src/error.rs | 2 +- tooling/cli/src/build.rs | 294 +++-------------------------- tooling/cli/src/bundle.rs | 303 ++++++++++++++++++++++++++++++ tooling/cli/src/helpers/mod.rs | 61 ++++++ tooling/cli/src/interface/rust.rs | 13 ++ tooling/cli/src/lib.rs | 17 +- 7 files changed, 423 insertions(+), 274 deletions(-) create mode 100644 .changes/tauri-bundle-command.md create mode 100644 tooling/cli/src/bundle.rs diff --git a/.changes/tauri-bundle-command.md b/.changes/tauri-bundle-command.md new file mode 100644 index 000000000..8d586dcc2 --- /dev/null +++ b/.changes/tauri-bundle-command.md @@ -0,0 +1,7 @@ +--- +"tauri-cli": "patch:feat" +"@tauri-apps/cli": "patch:feat" +--- + +Add `tauri bundle` subcommand which runs the bundle phase only, best paired with `tauri build --no-bundle` + diff --git a/tooling/bundler/src/error.rs b/tooling/bundler/src/error.rs index 82b0b23a3..35650a976 100644 --- a/tooling/bundler/src/error.rs +++ b/tooling/bundler/src/error.rs @@ -14,7 +14,7 @@ pub enum Error { #[error("{0}")] Resource(#[from] tauri_utils::Error), /// Bundler error. - #[error("{0}")] + #[error("{0:#}")] BundlerError(#[from] anyhow::Error), /// I/O error. #[error("`{0}`")] diff --git a/tooling/cli/src/build.rs b/tooling/cli/src/build.rs index d412ea466..14b106b7b 100644 --- a/tooling/cli/src/build.rs +++ b/tooling/cli/src/build.rs @@ -3,54 +3,20 @@ // SPDX-License-Identifier: MIT use crate::{ + bundle::BundleFormat, helpers::{ - app_paths::{app_dir, tauri_dir}, - command_env, - config::{get as get_config, ConfigHandle, ConfigMetadata, FrontendDist, HookCommand}, - updater_signature::{secret_key as updater_secret_key, sign_file}, + self, + app_paths::tauri_dir, + config::{get as get_config, ConfigHandle, FrontendDist}, }, interface::{AppInterface, AppSettings, Interface}, - CommandExt, ConfigValue, Result, -}; -use anyhow::{bail, Context}; -use base64::Engine; -use clap::{builder::PossibleValue, ArgAction, Parser, ValueEnum}; -use std::{ - env::{set_current_dir, var}, - path::{Path, PathBuf}, - process::Command, - str::FromStr, - sync::OnceLock, -}; -use tauri_bundler::{ - bundle::{bundle_project, PackageType}, - Bundle, + ConfigValue, Result, }; +use anyhow::Context; +use clap::{ArgAction, Parser}; +use std::env::set_current_dir; use tauri_utils::platform::Target; -#[derive(Debug, Clone)] -pub struct BundleFormat(PackageType); - -impl FromStr for BundleFormat { - type Err = anyhow::Error; - fn from_str(s: &str) -> Result { - PackageType::from_short_name(s) - .map(Self) - .ok_or_else(|| anyhow::anyhow!("unknown bundle format {s}")) - } -} - -impl ValueEnum for BundleFormat { - fn value_variants<'a>() -> &'a [Self] { - static VARIANTS: OnceLock> = OnceLock::new(); - VARIANTS.get_or_init(|| PackageType::all().iter().map(|t| Self(*t)).collect()) - } - - fn to_possible_value(&self) -> Option { - Some(PossibleValue::new(self.0.short_name())) - } -} - #[derive(Debug, Clone, Parser)] #[clap( about = "Build your app in release mode and generate bundles and installers", @@ -120,17 +86,21 @@ pub fn command(mut options: Options, verbosity: u8) -> Result<()> { interface.build(interface_options)?; + log::info!(action ="Built"; "application at: {}", tauri_utils::display_path(&bin_path)); + let app_settings = interface.app_settings(); - bundle( - &options, - verbosity, - ci, - &interface, - &app_settings, - config_, - out_dir, - )?; + if !options.no_bundle && (config_.bundle.active || options.bundles.is_some()) { + crate::bundle::bundle( + &options.into(), + verbosity, + ci, + &interface, + &app_settings, + config_, + out_dir, + )?; + } Ok(()) } @@ -173,7 +143,7 @@ pub fn setup( } if let Some(before_build) = config_.build.before_build_command.clone() { - run_hook("beforeBuildCommand", before_build, interface, options.debug)?; + helpers::run_hook("beforeBuildCommand", before_build, interface, options.debug)?; } if let Some(FrontendDist::Directory(web_asset_path)) = &config_.build.frontend_dist { @@ -222,223 +192,3 @@ pub fn setup( Ok(()) } - -fn bundle( - options: &Options, - verbosity: u8, - ci: bool, - interface: &AppInterface, - app_settings: &std::sync::Arc, - config: &ConfigMetadata, - out_dir: &Path, -) -> crate::Result<()> { - if options.no_bundle || (options.bundles.is_none() && !config.bundle.active) { - return Ok(()); - } - - let package_types: Vec = if let Some(bundles) = &options.bundles { - bundles.iter().map(|bundle| bundle.0).collect::>() - } else { - config - .bundle - .targets - .to_vec() - .into_iter() - .map(Into::into) - .collect() - }; - - if package_types.is_empty() { - return Ok(()); - } - - // if we have a package to bundle, let's run the `before_bundle_command`. - if !package_types.is_empty() { - if let Some(before_bundle) = config.build.before_bundle_command.clone() { - run_hook( - "beforeBundleCommand", - before_bundle, - interface, - options.debug, - )?; - } - } - - let mut settings = app_settings - .get_bundler_settings(options.clone().into(), config, out_dir, package_types) - .with_context(|| "failed to build bundler settings")?; - - settings.set_log_level(match verbosity { - 0 => log::Level::Error, - 1 => log::Level::Info, - _ => log::Level::Trace, - }); - - // set env vars used by the bundler - #[cfg(target_os = "linux")] - { - if config.bundle.linux.appimage.bundle_media_framework { - std::env::set_var("APPIMAGE_BUNDLE_GSTREAMER", "1"); - } - - if let Some(open) = config.plugins.0.get("shell").and_then(|v| v.get("open")) { - if open.as_bool().is_some_and(|x| x) || open.is_string() { - std::env::set_var("APPIMAGE_BUNDLE_XDG_OPEN", "1"); - } - } - - if settings.deep_link_protocols().is_some() { - std::env::set_var("APPIMAGE_BUNDLE_XDG_MIME", "1"); - } - } - - let bundles = bundle_project(settings) - .map_err(|e| anyhow::anyhow!("{:#}", e)) - .with_context(|| "failed to bundle project")?; - - let update_enabled_bundles: Vec<&Bundle> = bundles - .iter() - .filter(|bundle| { - matches!( - bundle.package_type, - PackageType::Updater | PackageType::Nsis | PackageType::WindowsMsi | PackageType::AppImage - ) - }) - .collect(); - - // Skip if no updater is active - if !update_enabled_bundles.is_empty() { - let updater_pub_key = config - .plugins - .0 - .get("updater") - .and_then(|k| k.get("pubkey")) - .and_then(|v| v.as_str()) - .map(|v| v.to_string()); - - if let Some(pubkey) = updater_pub_key { - // get the public key - // check if pubkey points to a file... - let maybe_path = Path::new(&pubkey); - let pubkey = if maybe_path.exists() { - std::fs::read_to_string(maybe_path)? - } else { - pubkey - }; - - // if no password provided we use an empty string - let password = var("TAURI_SIGNING_PRIVATE_KEY_PASSWORD").ok().or_else(|| { - if ci { - Some("".into()) - } else { - None - } - }); - - // get the private key - let secret_key = match var("TAURI_SIGNING_PRIVATE_KEY") { - Ok(private_key) => { - // check if private_key points to a file... - let maybe_path = Path::new(&private_key); - let private_key = if maybe_path.exists() { - std::fs::read_to_string(maybe_path)? - } else { - private_key - }; - updater_secret_key(private_key, password) - } - _ => Err(anyhow::anyhow!("A public key has been found, but no private key. Make sure to set `TAURI_SIGNING_PRIVATE_KEY` environment variable.")), - }?; - - let pubkey = base64::engine::general_purpose::STANDARD.decode(pubkey)?; - let pub_key_decoded = String::from_utf8_lossy(&pubkey); - let public_key = minisign::PublicKeyBox::from_string(&pub_key_decoded)?.into_public_key()?; - - // make sure we have our package built - let mut signed_paths = Vec::new(); - for bundle in update_enabled_bundles { - // we expect to have only one path in the vec but we iter if we add - // another type of updater package who require multiple file signature - for path in bundle.bundle_paths.iter() { - // sign our path from environment variables - let (signature_path, signature) = sign_file(&secret_key, path)?; - if signature.keynum() != public_key.keynum() { - log::warn!("The updater secret key from `TAURI_PRIVATE_KEY` does not match the public key from `plugins > updater > pubkey`. If you are not rotating keys, this means your configuration is wrong and won't be accepted at runtime when performing update."); - } - signed_paths.push(signature_path); - } - } - - print_signed_updater_archive(&signed_paths)?; - } - } - - Ok(()) -} - -fn run_hook(name: &str, hook: HookCommand, interface: &AppInterface, debug: bool) -> Result<()> { - let (script, script_cwd) = match hook { - HookCommand::Script(s) if s.is_empty() => (None, None), - HookCommand::Script(s) => (Some(s), None), - HookCommand::ScriptWithOptions { script, cwd } => (Some(script), cwd.map(Into::into)), - }; - let cwd = script_cwd.unwrap_or_else(|| app_dir().clone()); - if let Some(script) = script { - log::info!(action = "Running"; "{} `{}`", name, script); - - let mut env = command_env(debug); - env.extend(interface.env()); - - log::debug!("Setting environment for hook {:?}", env); - - #[cfg(target_os = "windows")] - let status = Command::new("cmd") - .arg("/S") - .arg("/C") - .arg(&script) - .current_dir(cwd) - .envs(env) - .piped() - .with_context(|| format!("failed to run `{}` with `cmd /C`", script))?; - #[cfg(not(target_os = "windows"))] - let status = Command::new("sh") - .arg("-c") - .arg(&script) - .current_dir(cwd) - .envs(env) - .piped() - .with_context(|| format!("failed to run `{script}` with `sh -c`"))?; - - if !status.success() { - bail!( - "{} `{}` failed with exit code {}", - name, - script, - status.code().unwrap_or_default() - ); - } - } - - Ok(()) -} - -fn print_signed_updater_archive(output_paths: &[PathBuf]) -> crate::Result<()> { - use std::fmt::Write; - if !output_paths.is_empty() { - let pluralised = if output_paths.len() == 1 { - "updater signature" - } else { - "updater signatures" - }; - let mut printable_paths = String::new(); - for path in output_paths { - writeln!( - printable_paths, - " {}", - tauri_utils::display_path(path) - )?; - } - log::info!( action = "Finished"; "{} {} at:\n{}", output_paths.len(), pluralised, printable_paths); - } - Ok(()) -} diff --git a/tooling/cli/src/bundle.rs b/tooling/cli/src/bundle.rs new file mode 100644 index 000000000..bcabfe2bc --- /dev/null +++ b/tooling/cli/src/bundle.rs @@ -0,0 +1,303 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{ + path::{Path, PathBuf}, + str::FromStr, + sync::OnceLock, +}; + +use anyhow::Context; +use base64::Engine; +use clap::{builder::PossibleValue, ArgAction, Parser, ValueEnum}; +use tauri_bundler::PackageType; +use tauri_utils::platform::Target; + +use crate::{ + helpers::{ + self, + app_paths::tauri_dir, + config::{get as get_config, ConfigMetadata}, + updater_signature, + }, + interface::{AppInterface, AppSettings, Interface}, + ConfigValue, +}; + +#[derive(Debug, Clone)] +pub struct BundleFormat(PackageType); + +impl FromStr for BundleFormat { + type Err = anyhow::Error; + fn from_str(s: &str) -> crate::Result { + PackageType::from_short_name(s) + .map(Self) + .ok_or_else(|| anyhow::anyhow!("unknown bundle format {s}")) + } +} + +impl ValueEnum for BundleFormat { + fn value_variants<'a>() -> &'a [Self] { + static VARIANTS: OnceLock> = OnceLock::new(); + VARIANTS.get_or_init(|| PackageType::all().iter().map(|t| Self(*t)).collect()) + } + + fn to_possible_value(&self) -> Option { + Some(PossibleValue::new(self.0.short_name())) + } +} + +#[derive(Debug, Parser, Clone)] +#[clap( + about = "Generate bundles and installers for your app (already built by `tauri build`)", + long_about = "Generate bundles and installers for your app (already built by `tauri build`). This run `build.beforeBundleCommand` before generating the bundles and installers of your app." +)] +pub struct Options { + /// Builds with the debug flag + #[clap(short, long)] + pub debug: bool, + /// Space or comma separated list of bundles to package. + /// + /// Note that the `updater` bundle is not automatically added so you must specify it if the updater is enabled. + #[clap(short, long, action = ArgAction::Append, num_args(0..), value_delimiter = ',')] + pub bundles: Option>, + /// JSON string or path to JSON file to merge with tauri.conf.json + #[clap(short, long)] + pub config: Option, + /// Space or comma separated list of features, should be the same features passed to `tauri build` if any. + #[clap(short, long, action = ArgAction::Append, num_args(0..))] + pub features: Option>, + /// Target triple to build against. + /// + /// It must be one of the values outputted by `$rustc --print target-list` or `universal-apple-darwin` for an universal macOS application. + /// + /// Note that compiling an universal macOS application requires both `aarch64-apple-darwin` and `x86_64-apple-darwin` targets to be installed. + #[clap(short, long)] + pub target: Option, + /// Skip prompting for values + #[clap(long, env = "CI")] + pub ci: bool, +} + +impl From for Options { + fn from(value: crate::build::Options) -> Self { + Self { + bundles: value.bundles, + target: value.target, + features: value.features, + debug: value.debug, + ci: value.ci, + config: value.config, + } + } +} + +pub fn command(options: Options, verbosity: u8) -> crate::Result<()> { + let ci = options.ci; + + let target = options + .target + .as_deref() + .map(Target::from_triple) + .unwrap_or_else(Target::current); + + let config = get_config(target, options.config.as_ref().map(|c| &c.0))?; + + let interface = AppInterface::new( + config.lock().unwrap().as_ref().unwrap(), + options.target.clone(), + )?; + + let tauri_path = tauri_dir(); + std::env::set_current_dir(tauri_path) + .with_context(|| "failed to change current working directory")?; + + let config_guard = config.lock().unwrap(); + let config_ = config_guard.as_ref().unwrap(); + + let app_settings = interface.app_settings(); + let interface_options = options.clone().into(); + + let bin_path = app_settings.app_binary_path(&interface_options)?; + let out_dir = bin_path.parent().unwrap(); + + bundle( + &options, + verbosity, + ci, + &interface, + &app_settings, + config_, + out_dir, + ) +} + +pub fn bundle( + options: &Options, + verbosity: u8, + ci: bool, + interface: &AppInterface, + app_settings: &std::sync::Arc, + config: &ConfigMetadata, + out_dir: &Path, +) -> crate::Result<()> { + let package_types: Vec = if let Some(bundles) = &options.bundles { + bundles.iter().map(|bundle| bundle.0).collect::>() + } else { + config + .bundle + .targets + .to_vec() + .into_iter() + .map(Into::into) + .collect() + }; + + if package_types.is_empty() { + return Ok(()); + } + + // if we have a package to bundle, let's run the `before_bundle_command`. + if !package_types.is_empty() { + if let Some(before_bundle) = config.build.before_bundle_command.clone() { + helpers::run_hook( + "beforeBundleCommand", + before_bundle, + interface, + options.debug, + )?; + } + } + + let mut settings = app_settings + .get_bundler_settings(options.clone().into(), config, out_dir, package_types) + .with_context(|| "failed to build bundler settings")?; + + settings.set_log_level(match verbosity { + 0 => log::Level::Error, + 1 => log::Level::Info, + _ => log::Level::Trace, + }); + + // set env vars used by the bundler + #[cfg(target_os = "linux")] + { + if config.bundle.linux.appimage.bundle_media_framework { + std::env::set_var("APPIMAGE_BUNDLE_GSTREAMER", "1"); + } + + if let Some(open) = config.plugins.0.get("shell").and_then(|v| v.get("open")) { + if open.as_bool().is_some_and(|x| x) || open.is_string() { + std::env::set_var("APPIMAGE_BUNDLE_XDG_OPEN", "1"); + } + } + + if settings.deep_link_protocols().is_some() { + std::env::set_var("APPIMAGE_BUNDLE_XDG_MIME", "1"); + } + } + + let bundles = tauri_bundler::bundle_project(settings) + .map_err(|e| match e { + tauri_bundler::Error::BundlerError(e) => e, + e => anyhow::anyhow!("{e:#}"), + }) + .with_context(|| "failed to bundle project")?; + + let update_enabled_bundles: Vec<&tauri_bundler::Bundle> = bundles + .iter() + .filter(|bundle| { + matches!( + bundle.package_type, + PackageType::Updater | PackageType::Nsis | PackageType::WindowsMsi | PackageType::AppImage + ) + }) + .collect(); + + // Skip if no updater is active + if !update_enabled_bundles.is_empty() { + let updater_pub_key = config + .plugins + .0 + .get("updater") + .and_then(|k| k.get("pubkey")) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + + if let Some(pubkey) = updater_pub_key { + // get the public key + // check if pubkey points to a file... + let maybe_path = Path::new(&pubkey); + let pubkey = if maybe_path.exists() { + std::fs::read_to_string(maybe_path)? + } else { + pubkey + }; + + // if no password provided we use an empty string + let password = std::env::var("TAURI_SIGNING_PRIVATE_KEY_PASSWORD") + .ok() + .or_else(|| if ci { Some("".into()) } else { None }); + + // get the private key + let secret_key = match std::env::var("TAURI_SIGNING_PRIVATE_KEY") { + Ok(private_key) => { + // check if private_key points to a file... + let maybe_path = Path::new(&private_key); + let private_key = if maybe_path.exists() { + std::fs::read_to_string(maybe_path)? + } else { + private_key + }; + updater_signature::secret_key(private_key, password) + } + _ => Err(anyhow::anyhow!("A public key has been found, but no private key. Make sure to set `TAURI_SIGNING_PRIVATE_KEY` environment variable.")), + }?; + + let pubkey = base64::engine::general_purpose::STANDARD.decode(pubkey)?; + let pub_key_decoded = String::from_utf8_lossy(&pubkey); + let public_key = minisign::PublicKeyBox::from_string(&pub_key_decoded)?.into_public_key()?; + + // make sure we have our package built + let mut signed_paths = Vec::new(); + for bundle in update_enabled_bundles { + // we expect to have only one path in the vec but we iter if we add + // another type of updater package who require multiple file signature + for path in bundle.bundle_paths.iter() { + // sign our path from environment variables + let (signature_path, signature) = updater_signature::sign_file(&secret_key, path)?; + if signature.keynum() != public_key.keynum() { + log::warn!("The updater secret key from `TAURI_PRIVATE_KEY` does not match the public key from `plugins > updater > pubkey`. If you are not rotating keys, this means your configuration is wrong and won't be accepted at runtime when performing update."); + } + signed_paths.push(signature_path); + } + } + + print_signed_updater_archive(&signed_paths)?; + } + } + + Ok(()) +} + +fn print_signed_updater_archive(output_paths: &[PathBuf]) -> crate::Result<()> { + use std::fmt::Write; + if !output_paths.is_empty() { + let pluralised = if output_paths.len() == 1 { + "updater signature" + } else { + "updater signatures" + }; + let mut printable_paths = String::new(); + for path in output_paths { + writeln!( + printable_paths, + " {}", + tauri_utils::display_path(path) + )?; + } + log::info!( action = "Finished"; "{} {} at:\n{}", output_paths.len(), pluralised, printable_paths); + } + Ok(()) +} diff --git a/tooling/cli/src/helpers/mod.rs b/tooling/cli/src/helpers/mod.rs index 4d80f1d66..3a9495922 100644 --- a/tooling/cli/src/helpers/mod.rs +++ b/tooling/cli/src/helpers/mod.rs @@ -18,6 +18,16 @@ use std::{ process::Command, }; +use anyhow::Context; +use tauri_utils::config::HookCommand; + +use crate::{ + interface::{AppInterface, Interface}, + CommandExt, +}; + +use self::app_paths::app_dir; + pub fn command_env(debug: bool) -> HashMap<&'static str, String> { let mut map = HashMap::new(); @@ -53,3 +63,54 @@ pub fn cross_command(bin: &str) -> Command { let cmd = Command::new(bin); cmd } + +pub fn run_hook( + name: &str, + hook: HookCommand, + interface: &AppInterface, + debug: bool, +) -> crate::Result<()> { + let (script, script_cwd) = match hook { + HookCommand::Script(s) if s.is_empty() => (None, None), + HookCommand::Script(s) => (Some(s), None), + HookCommand::ScriptWithOptions { script, cwd } => (Some(script), cwd.map(Into::into)), + }; + let cwd = script_cwd.unwrap_or_else(|| app_dir().clone()); + if let Some(script) = script { + log::info!(action = "Running"; "{} `{}`", name, script); + + let mut env = command_env(debug); + env.extend(interface.env()); + + log::debug!("Setting environment for hook {:?}", env); + + #[cfg(target_os = "windows")] + let status = Command::new("cmd") + .arg("/S") + .arg("/C") + .arg(&script) + .current_dir(cwd) + .envs(env) + .piped() + .with_context(|| format!("failed to run `{}` with `cmd /C`", script))?; + #[cfg(not(target_os = "windows"))] + let status = Command::new("sh") + .arg("-c") + .arg(&script) + .current_dir(cwd) + .envs(env) + .piped() + .with_context(|| format!("failed to run `{script}` with `sh -c`"))?; + + if !status.success() { + anyhow::bail!( + "{} `{}` failed with exit code {}", + name, + script, + status.code().unwrap_or_default() + ); + } + } + + Ok(()) +} diff --git a/tooling/cli/src/interface/rust.rs b/tooling/cli/src/interface/rust.rs index b7b7b95de..b610e73d7 100644 --- a/tooling/cli/src/interface/rust.rs +++ b/tooling/cli/src/interface/rust.rs @@ -69,6 +69,19 @@ impl From for Options { } } +impl From for Options { + fn from(options: crate::bundle::Options) -> Self { + Self { + debug: options.debug, + config: options.config, + target: options.target, + features: options.features, + no_watch: true, + ..Default::default() + } + } +} + impl From for Options { fn from(options: crate::dev::Options) -> Self { Self { diff --git a/tooling/cli/src/lib.rs b/tooling/cli/src/lib.rs index 5cdc3e25d..88ff4a05a 100644 --- a/tooling/cli/src/lib.rs +++ b/tooling/cli/src/lib.rs @@ -17,6 +17,7 @@ pub use anyhow::Result; mod acl; mod add; mod build; +mod bundle; mod completions; mod dev; mod helpers; @@ -135,6 +136,7 @@ enum Commands { Init(init::Options), Dev(dev::Options), Build(build::Options), + Bundle(bundle::Options), Android(mobile::android::Cli), #[cfg(target_os = "macos")] Ios(mobile::ios::Cli), @@ -173,7 +175,19 @@ where A: Into + Clone, { if let Err(e) = try_run(args, bin_name) { - log::error!("{:#}", e); + let mut message = e.to_string(); + if e.chain().count() > 1 { + message.push(':'); + } + e.chain().skip(1).for_each(|cause| { + let m = cause.to_string(); + if !message.contains(&m) { + message.push('\n'); + message.push_str(" - "); + message.push_str(&m); + } + }); + log::error!("{message}"); exit(1); } } @@ -238,6 +252,7 @@ where match cli.command { Commands::Build(options) => build::command(options, cli.verbose)?, + Commands::Bundle(options) => bundle::command(options, cli.verbose)?, Commands::Dev(options) => dev::command(options)?, Commands::Add(options) => add::command(options)?, Commands::Icon(options) => icon::command(options)?,