Parcourir la source

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 <lucas@tauri.studio>
Amr Bashir il y a 1 an
Parent
commit
9e4b2253f6

+ 7 - 0
.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`
+

+ 1 - 1
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}`")]

+ 22 - 272
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<Self> {
-    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<Vec<BundleFormat>> = OnceLock::new();
-    VARIANTS.get_or_init(|| PackageType::all().iter().map(|t| Self(*t)).collect())
-  }
-
-  fn to_possible_value(&self) -> Option<PossibleValue> {
-    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<A: AppSettings>(
-  options: &Options,
-  verbosity: u8,
-  ci: bool,
-  interface: &AppInterface,
-  app_settings: &std::sync::Arc<A>,
-  config: &ConfigMetadata,
-  out_dir: &Path,
-) -> crate::Result<()> {
-  if options.no_bundle || (options.bundles.is_none() && !config.bundle.active) {
-    return Ok(());
-  }
-
-  let package_types: Vec<PackageType> = if let Some(bundles) = &options.bundles {
-    bundles.iter().map(|bundle| bundle.0).collect::<Vec<_>>()
-  } 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(())
-}

+ 303 - 0
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<Self> {
+    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<Vec<BundleFormat>> = OnceLock::new();
+    VARIANTS.get_or_init(|| PackageType::all().iter().map(|t| Self(*t)).collect())
+  }
+
+  fn to_possible_value(&self) -> Option<PossibleValue> {
+    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<Vec<BundleFormat>>,
+  /// JSON string or path to JSON file to merge with tauri.conf.json
+  #[clap(short, long)]
+  pub config: Option<ConfigValue>,
+  /// 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<Vec<String>>,
+  /// 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<String>,
+  /// Skip prompting for values
+  #[clap(long, env = "CI")]
+  pub ci: bool,
+}
+
+impl From<crate::build::Options> 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<A: AppSettings>(
+  options: &Options,
+  verbosity: u8,
+  ci: bool,
+  interface: &AppInterface,
+  app_settings: &std::sync::Arc<A>,
+  config: &ConfigMetadata,
+  out_dir: &Path,
+) -> crate::Result<()> {
+  let package_types: Vec<PackageType> = if let Some(bundles) = &options.bundles {
+    bundles.iter().map(|bundle| bundle.0).collect::<Vec<_>>()
+  } 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(())
+}

+ 61 - 0
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(())
+}

+ 13 - 0
tooling/cli/src/interface/rust.rs

@@ -69,6 +69,19 @@ impl From<crate::build::Options> for Options {
   }
 }
 
+impl From<crate::bundle::Options> 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<crate::dev::Options> for Options {
   fn from(options: crate::dev::Options) -> Self {
     Self {

+ 16 - 1
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<OsString> + 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)?,