Browse Source

feat(cli): add new acl subcommands (#8827)

* unify `CI` var handling, and lay foundation for `permission` subcommand

* feat(cli/init&new): create `permissions` directory by default for plugins

* generate permissions with consistent pathing on windows and unix

* `pemrission create` initial implementation

* add ls command

* finalize `permission create` subcommand

* `permission rm` subcommand

* `permission add` subcommand

* remove empty `permission copy` subcommand

* clippy

* `capability create` subcommand and move modules under `acl` directory

* fix multiselect for `permission add` when capabilty doesn't have identifier

* clippy

* `create` -> `new`  and change file

* license headers

* more license headers

* clippy

* Discard changes to examples/resources/src-tauri/.gitignore

* fix build

* cleanup

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.studio>
Amr Bashir 1 year ago
parent
commit
06d63d67a0

+ 12 - 0
.changes/cli-acl-subcommands.md

@@ -0,0 +1,12 @@
+---
+'tauri-cli': 'patch:feat'
+'@tauri-apps/cli': 'patch:feat'
+---
+
+Add new subcommands for managing permissions and cababilities:
+
+- `tauri permission new`
+- `tauri permission add`
+- `tauri permission rm`
+- `tauri permission ls`
+- `tauri capability new`

+ 1 - 1
core/tauri-plugin/src/build/mod.rs

@@ -105,7 +105,7 @@ impl<'a> Builder<'a> {
       let _ = std::fs::remove_file(format!(
         "./permissions/{}/{}",
         acl::build::PERMISSION_SCHEMAS_FOLDER_NAME,
-        acl::build::PERMISSION_SCHEMA_FILE_NAME
+        acl::PERMISSION_SCHEMA_FILE_NAME
       ));
       let _ = std::fs::remove_file(autogenerated.join(acl::build::PERMISSION_DOCS_FILE_NAME));
     } else {

+ 1 - 3
core/tauri-utils/src/acl/build.rs

@@ -20,6 +20,7 @@ use schemars::{
 use super::{
   capability::{Capability, CapabilityFile},
   plugin::PermissionFile,
+  PERMISSION_SCHEMA_FILE_NAME,
 };
 
 /// Known name of the folder containing autogenerated permissions.
@@ -37,9 +38,6 @@ pub const PERMISSION_FILE_EXTENSIONS: &[&str] = &["json", "toml"];
 /// Known foldername of the permission schema files
 pub const PERMISSION_SCHEMAS_FOLDER_NAME: &str = "schemas";
 
-/// Known filename of the permission schema JSON file
-pub const PERMISSION_SCHEMA_FILE_NAME: &str = "schema.json";
-
 /// Known filename of the permission documentation file
 pub const PERMISSION_DOCS_FILE_NAME: &str = "reference.md";
 

+ 2 - 1
core/tauri-utils/src/acl/capability.rs

@@ -57,6 +57,7 @@ pub struct Capability {
   #[serde(default)]
   pub description: String,
   /// Configure remote URLs that can use the capability permissions.
+  #[serde(default, skip_serializing_if = "Option::is_none")]
   pub remote: Option<CapabilityRemote>,
   /// Whether this capability is enabled for local app URLs or not. Defaults to `true`.
   #[serde(default = "default_capability_local")]
@@ -74,7 +75,7 @@ pub struct Capability {
   /// List of permissions attached to this capability. Must include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`.
   pub permissions: Vec<PermissionEntry>,
   /// Target platforms this capability applies. By default all platforms applies.
-  #[serde(default = "default_platforms")]
+  #[serde(default = "default_platforms", skip_serializing_if = "Vec::is_empty")]
   pub platforms: Vec<Target>,
 }
 

+ 12 - 1
core/tauri-utils/src/acl/mod.rs

@@ -11,6 +11,9 @@ use thiserror::Error;
 
 pub use self::{identifier::*, value::*};
 
+/// Known filename of the permission schema JSON file
+pub const PERMISSION_SCHEMA_FILE_NAME: &str = "schema.json";
+
 #[cfg(feature = "build")]
 pub mod build;
 pub mod capability;
@@ -142,6 +145,12 @@ pub struct Scopes {
   pub deny: Option<Vec<Value>>,
 }
 
+impl Scopes {
+  fn is_empty(&self) -> bool {
+    self.allow.is_none() && self.deny.is_none()
+  }
+}
+
 /// Descriptions of explicit privileges of commands.
 ///
 /// It can enable commands to be accessible in the frontend of the application.
@@ -151,12 +160,14 @@ pub struct Scopes {
 #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
 pub struct Permission {
   /// The version of the permission.
+  #[serde(skip_serializing_if = "Option::is_none")]
   pub version: Option<NonZeroU64>,
 
   /// A unique identifier for the permission.
   pub identifier: String,
 
   /// Human-readable description of what the permission does.
+  #[serde(skip_serializing_if = "Option::is_none")]
   pub description: Option<String>,
 
   /// Allowed or denied commands when using this permission.
@@ -164,7 +175,7 @@ pub struct Permission {
   pub commands: Commands,
 
   /// Allowed or denied scoped when using this permission.
-  #[serde(default)]
+  #[serde(default, skip_serializing_if = "Scopes::is_empty")]
   pub scope: Scopes,
 }
 

+ 3 - 3
core/tauri-utils/src/acl/plugin.rs

@@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
 /// The default permission set of the plugin.
 ///
 /// Works similarly to a permission with the "default" identifier.
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Deserialize, Serialize)]
 #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
 pub struct DefaultPermission {
   /// The version of the permission.
@@ -26,14 +26,14 @@ pub struct DefaultPermission {
 }
 
 /// Permission file that can define a default permission, a set of permissions or a list of inlined permissions.
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Deserialize, Serialize)]
 #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
 pub struct PermissionFile {
   /// The default permission set for the plugin
   pub default: Option<DefaultPermission>,
 
   /// A list of permissions sets defined
-  #[serde(default)]
+  #[serde(default, skip_serializing_if = "Vec::is_empty")]
   pub set: Vec<PermissionSet>,
 
   /// A list of inlined permissions

+ 3 - 12
tooling/cli/Cargo.lock

@@ -4112,6 +4112,7 @@ version = "1.0.113"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79"
 dependencies = [
+ "indexmap 2.2.3",
  "itoa 1.0.10",
  "ryu",
  "serde",
@@ -4697,6 +4698,7 @@ dependencies = [
  "ctrlc",
  "dialoguer",
  "duct",
+ "dunce",
  "env_logger",
  "glob",
  "handlebars 5.1.0",
@@ -4737,7 +4739,7 @@ dependencies = [
  "thiserror",
  "tokio",
  "toml 0.8.10",
- "toml_edit 0.21.1",
+ "toml_edit 0.22.6",
  "unicode-width",
  "ureq",
  "url",
@@ -5103,17 +5105,6 @@ dependencies = [
  "winnow 0.5.40",
 ]
 
-[[package]]
-name = "toml_edit"
-version = "0.21.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1"
-dependencies = [
- "indexmap 2.2.3",
- "toml_datetime",
- "winnow 0.5.40",
-]
-
 [[package]]
 name = "toml_edit"
 version = "0.22.6"

+ 3 - 2
tooling/cli/Cargo.toml

@@ -52,12 +52,12 @@ anyhow = "1.0"
 tauri-bundler = { version = "2.0.1-beta.0", default-features = false, path = "../bundler" }
 colored = "2.0"
 serde = { version = "1.0", features = [ "derive" ] }
-serde_json = "1.0"
+serde_json = { version = "1.0", features = [ "preserve_order" ] }
 notify = "6.1"
 notify-debouncer-mini = "0.4"
 shared_child = "1.0"
 duct = "0.13"
-toml_edit = "0.21"
+toml_edit = { version = "0.22", features = [ "serde" ] }
 json-patch = "1.2"
 tauri-utils = { version = "2.0.0-beta.4", path = "../../core/tauri-utils", features = [ "isolation", "schema", "config-json5", "config-toml" ] }
 tauri-utils-v1 = { version = "1", package = "tauri-utils", features = [ "isolation", "schema", "config-json5", "config-toml" ] }
@@ -93,6 +93,7 @@ itertools = "0.11"
 local-ip-address = "0.5"
 css-color = "0.2"
 resvg = "0.36.0"
+dunce = "1"
 glob = "0.3"
 
 [target."cfg(windows)".dependencies]

+ 28 - 0
tooling/cli/src/acl/capability/mod.rs

@@ -0,0 +1,28 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+use clap::{Parser, Subcommand};
+
+use crate::Result;
+
+mod new;
+
+#[derive(Debug, Parser)]
+#[clap(about = "Manage or create capabilities for your app")]
+pub struct Cli {
+  #[clap(subcommand)]
+  command: Commands,
+}
+
+#[derive(Subcommand, Debug)]
+enum Commands {
+  #[clap(alias = "create")]
+  New(new::Options),
+}
+
+pub fn command(cli: Cli) -> Result<()> {
+  match cli.command {
+    Commands::New(options) => new::command(options),
+  }
+}

+ 141 - 0
tooling/cli/src/acl/capability/new.rs

@@ -0,0 +1,141 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+use std::{collections::HashSet, path::PathBuf};
+
+use clap::Parser;
+use tauri_utils::acl::capability::{Capability, PermissionEntry};
+
+use crate::{
+  acl::FileFormat,
+  helpers::{app_paths::tauri_dir, prompts},
+  Result,
+};
+
+#[derive(Debug, Parser)]
+#[clap(about = "Create a new permission file")]
+pub struct Options {
+  /// Capability identifier.
+  identifier: Option<String>,
+  /// Capability description
+  #[clap(long)]
+  description: Option<String>,
+  /// Capability windows
+  #[clap(long)]
+  windows: Option<Vec<String>>,
+  /// Capability permissions
+  #[clap(long)]
+  permission: Option<Vec<String>>,
+  /// Output file format.
+  #[clap(long, default_value_t = FileFormat::Json)]
+  format: FileFormat,
+  /// The output file.
+  #[clap(short, long)]
+  out: Option<PathBuf>,
+}
+
+pub fn command(options: Options) -> Result<()> {
+  let identifier = match options.identifier {
+    Some(i) => i,
+    None => prompts::input("What's the capability identifier?", None, false, false)?.unwrap(),
+  };
+
+  let description = match options.description {
+    Some(d) => Some(d),
+    None => prompts::input::<String>("What's the capability description?", None, false, true)?
+      .and_then(|d| if d.is_empty() { None } else { Some(d) }),
+  };
+
+  let windows = match options.windows.map(FromIterator::from_iter) {
+    Some(w) => w,
+    None => prompts::input::<String>(
+      "Which windows should be affected by this? (comma separated)",
+      Some("main".into()),
+      false,
+      false,
+    )?
+    .and_then(|d| {
+      if d.is_empty() {
+        None
+      } else {
+        Some(d.split(',').map(ToString::to_string).collect())
+      }
+    })
+    .unwrap_or_default(),
+  };
+
+  let permissions: HashSet<String> = match options.permission.map(FromIterator::from_iter) {
+    Some(p) => p,
+    None => prompts::input::<String>(
+      "What permissions to enable? (comma separated)",
+      None,
+      false,
+      true,
+    )?
+    .and_then(|p| {
+      if p.is_empty() {
+        None
+      } else {
+        Some(p.split(',').map(ToString::to_string).collect())
+      }
+    })
+    .unwrap_or_default(),
+  };
+
+  let capability = Capability {
+    identifier,
+    description: description.unwrap_or_default(),
+    remote: None,
+    local: true,
+    windows,
+    webviews: Vec::new(),
+    permissions: permissions
+      .into_iter()
+      .map(|p| {
+        PermissionEntry::PermissionRef(
+          p.clone()
+            .try_into()
+            .unwrap_or_else(|_| panic!("invalid permission {}", p)),
+        )
+      })
+      .collect(),
+    platforms: Vec::new(),
+  };
+
+  let path = match options.out {
+    Some(o) => o.canonicalize()?,
+    None => {
+      let dir = tauri_dir();
+      let capabilities_dir = dir.join("capabilities");
+      capabilities_dir.join(format!(
+        "{}.{}",
+        capability.identifier,
+        options.format.extension()
+      ))
+    }
+  };
+
+  if path.exists() {
+    let msg = format!(
+      "Capability already exists at {}",
+      dunce::simplified(&path).display()
+    );
+    let overwrite = prompts::confirm(&format!("{msg}, overwrite?"), Some(false))?;
+    if overwrite {
+      std::fs::remove_file(&path)?;
+    } else {
+      anyhow::bail!(msg);
+    }
+  }
+
+  if let Some(parent) = path.parent() {
+    std::fs::create_dir_all(parent)?;
+  }
+
+  std::fs::write(&path, options.format.serialize(&capability)?)?;
+
+  log::info!(action = "Created"; "capability at {}", dunce::simplified(&path).display());
+
+  Ok(())
+}

+ 41 - 0
tooling/cli/src/acl/mod.rs

@@ -0,0 +1,41 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+use serde::Serialize;
+use std::fmt::Display;
+
+pub mod capability;
+pub mod permission;
+
+#[derive(Debug, clap::ValueEnum, Clone)]
+enum FileFormat {
+  Json,
+  Toml,
+}
+
+impl Display for FileFormat {
+  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+    match self {
+      Self::Json => write!(f, "json"),
+      Self::Toml => write!(f, "toml"),
+    }
+  }
+}
+
+impl FileFormat {
+  pub fn extension(&self) -> &'static str {
+    match self {
+      Self::Json => "json",
+      Self::Toml => "toml",
+    }
+  }
+
+  pub fn serialize<S: Serialize>(&self, s: &S) -> crate::Result<String> {
+    let contents = match self {
+      Self::Json => serde_json::to_string_pretty(s)?,
+      Self::Toml => toml_edit::ser::to_string_pretty(s)?,
+    };
+    Ok(contents)
+  }
+}

+ 147 - 0
tooling/cli/src/acl/permission/add.rs

@@ -0,0 +1,147 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+use std::path::Path;
+
+use clap::Parser;
+
+use crate::{
+  helpers::{app_paths::tauri_dir_opt, prompts},
+  Result,
+};
+
+#[derive(Clone)]
+enum TomlOrJson {
+  Toml(toml_edit::Document),
+  Json(serde_json::Value),
+}
+
+impl TomlOrJson {
+  fn identifier(&self) -> &str {
+    match self {
+      TomlOrJson::Toml(t) => t
+        .get("identifier")
+        .and_then(|k| k.as_str())
+        .unwrap_or_default(),
+      TomlOrJson::Json(j) => j
+        .get("identifier")
+        .and_then(|k| k.as_str())
+        .unwrap_or_default(),
+    }
+  }
+
+  fn insert_permission(&mut self, idenitifer: String) {
+    match self {
+      TomlOrJson::Toml(t) => {
+        let permissions = t.entry("permissions").or_insert_with(|| {
+          toml_edit::Item::Value(toml_edit::Value::Array(toml_edit::Array::new()))
+        });
+        if let Some(permissions) = permissions.as_array_mut() {
+          permissions.push(idenitifer)
+        };
+      }
+
+      TomlOrJson::Json(j) => {
+        if let Some(o) = j.as_object_mut() {
+          let permissions = o
+            .entry("permissions")
+            .or_insert_with(|| serde_json::Value::Array(Vec::new()));
+          if let Some(permissions) = permissions.as_array_mut() {
+            permissions.push(serde_json::Value::String(idenitifer))
+          };
+        }
+      }
+    };
+  }
+
+  fn to_string(&self) -> Result<String> {
+    Ok(match self {
+      TomlOrJson::Toml(t) => t.to_string(),
+      TomlOrJson::Json(j) => serde_json::to_string_pretty(&j)?,
+    })
+  }
+}
+
+fn capability_from_path<P: AsRef<Path>>(path: P) -> Option<TomlOrJson> {
+  match path.as_ref().extension().and_then(|o| o.to_str()) {
+    Some("toml") => std::fs::read_to_string(&path)
+      .ok()
+      .and_then(|c| c.parse::<toml_edit::Document>().ok())
+      .map(TomlOrJson::Toml),
+    Some("json") => std::fs::read(&path)
+      .ok()
+      .and_then(|c| serde_json::from_slice::<serde_json::Value>(&c).ok())
+      .map(TomlOrJson::Json),
+    _ => None,
+  }
+}
+
+#[derive(Debug, Parser)]
+#[clap(about = "Add a permission to capabilities")]
+pub struct Options {
+  /// Permission to remove.
+  identifier: String,
+  /// Capability to add the permission to.
+  capability: Option<String>,
+}
+
+pub fn command(options: Options) -> Result<()> {
+  let dir = match tauri_dir_opt() {
+    Some(t) => t,
+    None => std::env::current_dir()?,
+  };
+
+  let capabilities_dir = dir.join("capabilities");
+  if !capabilities_dir.exists() {
+    anyhow::bail!(
+      "Couldn't find capabilities directory at {}",
+      dunce::simplified(&capabilities_dir).display()
+    );
+  }
+
+  let capabilities = std::fs::read_dir(&capabilities_dir)?
+    .flatten()
+    .filter(|e| e.file_type().map(|e| e.is_file()).unwrap_or_default())
+    .filter_map(|e| {
+      let path = e.path();
+      capability_from_path(&path).and_then(|capability| match &options.capability {
+        Some(c) => (c == capability.identifier()).then_some((capability, path)),
+        None => Some((capability, path)),
+      })
+    })
+    .collect::<Vec<_>>();
+
+  let mut capabilities = if capabilities.len() > 1 {
+    let selections = prompts::multiselect(
+      "Choose which capabilities to add the permission to:",
+      capabilities
+        .iter()
+        .map(|(c, p)| {
+          let id = c.identifier();
+          if id.is_empty() {
+            dunce::simplified(p).to_str().unwrap_or_default()
+          } else {
+            id
+          }
+        })
+        .collect::<Vec<_>>()
+        .as_slice(),
+      None,
+    )?;
+    selections
+      .into_iter()
+      .map(|idx| capabilities[idx].clone())
+      .collect()
+  } else {
+    capabilities
+  };
+
+  for (capability, path) in &mut capabilities {
+    capability.insert_permission(options.identifier.clone());
+    std::fs::write(&path, capability.to_string()?)?;
+    log::info!(action = "Added"; "permission `{}` to `{}` at {}", options.identifier, capability.identifier(), dunce::simplified(path).display());
+  }
+
+  Ok(())
+}

+ 148 - 0
tooling/cli/src/acl/permission/ls.rs

@@ -0,0 +1,148 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+use clap::Parser;
+
+use crate::{helpers::app_paths::tauri_dir, Result};
+use colored::Colorize;
+use tauri_utils::acl::plugin::Manifest;
+
+use std::{collections::BTreeMap, fs::read_to_string};
+
+#[derive(Debug, Parser)]
+#[clap(about = "List permissions available to your application")]
+pub struct Options {
+  /// Name of the plugin to list permissions.
+  plugin: Option<String>,
+  /// Permission identifier filter.
+  #[clap(short, long)]
+  filter: Option<String>,
+}
+
+pub fn command(options: Options) -> Result<()> {
+  let tauri_dir = tauri_dir();
+  let plugin_manifests_path = tauri_dir
+    .join("gen")
+    .join("schemas")
+    .join("plugin-manifests.json");
+
+  if plugin_manifests_path.exists() {
+    let plugin_manifest_json = read_to_string(&plugin_manifests_path)?;
+    let acl = serde_json::from_str::<BTreeMap<String, Manifest>>(&plugin_manifest_json)?;
+
+    for (plugin, manifest) in acl {
+      if options
+        .plugin
+        .as_ref()
+        .map(|p| p != &plugin)
+        .unwrap_or_default()
+      {
+        continue;
+      }
+
+      let mut permissions = Vec::new();
+
+      if let Some(default) = manifest.default_permission {
+        if options
+          .filter
+          .as_ref()
+          .map(|f| "default".contains(f))
+          .unwrap_or(true)
+        {
+          permissions.push(format!(
+            "{}:{}\n{}\nPermissions: {}",
+            plugin.magenta(),
+            "default".cyan(),
+            default.description,
+            default
+              .permissions
+              .iter()
+              .map(|c| c.cyan().to_string())
+              .collect::<Vec<_>>()
+              .join(", ")
+          ));
+        }
+      }
+
+      for set in manifest.permission_sets.values() {
+        if options
+          .filter
+          .as_ref()
+          .map(|f| set.identifier.contains(f))
+          .unwrap_or(true)
+        {
+          permissions.push(format!(
+            "{}:{}\n{}\nPermissions: {}",
+            plugin.magenta(),
+            set.identifier.cyan(),
+            set.description,
+            set
+              .permissions
+              .iter()
+              .map(|c| c.cyan().to_string())
+              .collect::<Vec<_>>()
+              .join(", ")
+          ));
+        }
+      }
+
+      for permission in manifest.permissions.into_values() {
+        if options
+          .filter
+          .as_ref()
+          .map(|f| permission.identifier.contains(f))
+          .unwrap_or(true)
+        {
+          permissions.push(format!(
+            "{}:{}{}{}{}",
+            plugin.magenta(),
+            permission.identifier.cyan(),
+            permission
+              .description
+              .map(|d| format!("\n{d}"))
+              .unwrap_or_default(),
+            if permission.commands.allow.is_empty() {
+              "".to_string()
+            } else {
+              format!(
+                "\n{}: {}",
+                "Allow commands".bold(),
+                permission
+                  .commands
+                  .allow
+                  .iter()
+                  .map(|c| c.green().to_string())
+                  .collect::<Vec<_>>()
+                  .join(", ")
+              )
+            },
+            if permission.commands.deny.is_empty() {
+              "".to_string()
+            } else {
+              format!(
+                "\n{}: {}",
+                "Deny commands".bold(),
+                permission
+                  .commands
+                  .deny
+                  .iter()
+                  .map(|c| c.red().to_string())
+                  .collect::<Vec<_>>()
+                  .join(", ")
+              )
+            },
+          ));
+        }
+      }
+
+      if !permissions.is_empty() {
+        println!("{}\n", permissions.join("\n\n"));
+      }
+    }
+
+    Ok(())
+  } else {
+    anyhow::bail!("permission file not found, please build your application once first")
+  }
+}

+ 39 - 0
tooling/cli/src/acl/permission/mod.rs

@@ -0,0 +1,39 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+use clap::{Parser, Subcommand};
+
+use crate::Result;
+
+mod add;
+mod ls;
+mod new;
+mod rm;
+
+#[derive(Debug, Parser)]
+#[clap(about = "Manage or create permissions for your app or plugin")]
+pub struct Cli {
+  #[clap(subcommand)]
+  command: Commands,
+}
+
+#[derive(Subcommand, Debug)]
+enum Commands {
+  #[clap(alias = "create")]
+  New(new::Options),
+  Add(add::Options),
+  #[clap(alias = "remove")]
+  Rm(rm::Options),
+  #[clap(alias = "list")]
+  Ls(ls::Options),
+}
+
+pub fn command(cli: Cli) -> Result<()> {
+  match cli.command {
+    Commands::New(options) => new::command(options),
+    Commands::Add(options) => add::command(options),
+    Commands::Rm(options) => rm::command(options),
+    Commands::Ls(options) => ls::command(options),
+  }
+}

+ 113 - 0
tooling/cli/src/acl/permission/new.rs

@@ -0,0 +1,113 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+use std::path::PathBuf;
+
+use clap::Parser;
+
+use crate::{
+  acl::FileFormat,
+  helpers::{app_paths::tauri_dir_opt, prompts},
+  Result,
+};
+
+use tauri_utils::acl::{plugin::PermissionFile, Commands, Permission};
+
+#[derive(Debug, Parser)]
+#[clap(about = "Create a new permission file")]
+pub struct Options {
+  /// Permission identifier.
+  identifier: Option<String>,
+  /// Permission description
+  #[clap(long)]
+  description: Option<String>,
+  /// List of commands to allow
+  #[clap(short, long, use_value_delimiter = true)]
+  allow: Option<Vec<String>>,
+  /// List of commands to deny
+  #[clap(short, long, use_value_delimiter = true)]
+  deny: Option<Vec<String>>,
+  /// Output file format.
+  #[clap(long, default_value_t = FileFormat::Json)]
+  format: FileFormat,
+  /// The output file.
+  #[clap(short, long)]
+  out: Option<PathBuf>,
+}
+
+pub fn command(options: Options) -> Result<()> {
+  let identifier = match options.identifier {
+    Some(i) => i,
+    None => prompts::input("What's the permission identifier?", None, false, false)?.unwrap(),
+  };
+
+  let description = match options.description {
+    Some(d) => Some(d),
+    None => prompts::input::<String>("What's the permission description?", None, false, true)?
+      .and_then(|d| if d.is_empty() { None } else { Some(d) }),
+  };
+
+  let allow: Vec<String> = options
+    .allow
+    .map(FromIterator::from_iter)
+    .unwrap_or_default();
+  let deny: Vec<String> = options
+    .deny
+    .map(FromIterator::from_iter)
+    .unwrap_or_default();
+
+  let permission = Permission {
+    version: None,
+    identifier,
+    description,
+    commands: Commands { allow, deny },
+    scope: Default::default(),
+  };
+
+  let path = match options.out {
+    Some(o) => o.canonicalize()?,
+    None => {
+      let dir = match tauri_dir_opt() {
+        Some(t) => t,
+        None => std::env::current_dir()?,
+      };
+      let permissions_dir = dir.join("permissions");
+      permissions_dir.join(format!(
+        "{}.{}",
+        permission.identifier,
+        options.format.extension()
+      ))
+    }
+  };
+
+  if path.exists() {
+    let msg = format!(
+      "Permission already exists at {}",
+      dunce::simplified(&path).display()
+    );
+    let overwrite = prompts::confirm(&format!("{msg}, overwrite?"), Some(false))?;
+    if overwrite {
+      std::fs::remove_file(&path)?;
+    } else {
+      anyhow::bail!(msg);
+    }
+  }
+
+  if let Some(parent) = path.parent() {
+    std::fs::create_dir_all(parent)?;
+  }
+
+  std::fs::write(
+    &path,
+    options.format.serialize(&PermissionFile {
+      default: None,
+      set: Vec::new(),
+      permission: vec![permission],
+    })?,
+  )?;
+
+  log::info!(action = "Created"; "permission at {}", dunce::simplified(&path).display());
+
+  Ok(())
+}

+ 137 - 0
tooling/cli/src/acl/permission/rm.rs

@@ -0,0 +1,137 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+use std::path::Path;
+
+use clap::Parser;
+use tauri_utils::acl::{plugin::PermissionFile, PERMISSION_SCHEMA_FILE_NAME};
+
+use crate::{acl::FileFormat, helpers::app_paths::tauri_dir_opt, Result};
+
+fn rm_permission_files(identifier: &str, dir: &Path) -> Result<()> {
+  for entry in std::fs::read_dir(dir)?.flatten() {
+    let file_type = entry.file_type()?;
+    let path = entry.path();
+    if file_type.is_dir() {
+      rm_permission_files(identifier, &path)?;
+    } else {
+      if path
+        .file_name()
+        .map(|name| name == PERMISSION_SCHEMA_FILE_NAME)
+        .unwrap_or_default()
+      {
+        continue;
+      }
+
+      let (mut permission_file, format): (PermissionFile, FileFormat) =
+        match path.extension().and_then(|o| o.to_str()) {
+          Some("toml") => {
+            let content = std::fs::read_to_string(&path)?;
+            (toml::from_str(&content)?, FileFormat::Toml)
+          }
+          Some("json") => {
+            let content = std::fs::read(&path)?;
+            (serde_json::from_slice(&content)?, FileFormat::Json)
+          }
+          _ => {
+            continue;
+          }
+        };
+
+      let mut updated;
+
+      if identifier == "default" {
+        updated = permission_file.default.is_some();
+        permission_file.default = None;
+      } else {
+        let set_len = permission_file.set.len();
+        permission_file.set.retain(|s| s.identifier != identifier);
+        updated = permission_file.set.len() != set_len;
+
+        let permission_len = permission_file.permission.len();
+        permission_file
+          .permission
+          .retain(|s| s.identifier != identifier);
+        updated = updated || permission_file.permission.len() != permission_len;
+      }
+
+      // if the file is empty, let's remove it
+      if permission_file.default.is_none()
+        && permission_file.set.is_empty()
+        && permission_file.permission.is_empty()
+      {
+        std::fs::remove_file(&path)?;
+        log::info!(action = "Removed"; "file {}", dunce::simplified(&path).display());
+      } else if updated {
+        std::fs::write(&path, format.serialize(&permission_file)?)?;
+        log::info!(action = "Removed"; "permission {identifier} from {}", dunce::simplified(&path).display());
+      }
+    }
+  }
+
+  Ok(())
+}
+
+fn rm_permission_from_capabilities(identifier: &str, dir: &Path) -> Result<()> {
+  for entry in std::fs::read_dir(dir)?.flatten() {
+    let file_type = entry.file_type()?;
+    if file_type.is_file() {
+      let path = entry.path();
+      match path.extension().and_then(|o| o.to_str()) {
+        Some("toml") => {
+          let content = std::fs::read_to_string(&path)?;
+          if let Ok(mut value) = content.parse::<toml_edit::Document>() {
+            if let Some(permissions) = value.get_mut("permissions").and_then(|p| p.as_array_mut()) {
+              let prev_len = permissions.len();
+              permissions.retain(|p| p.as_str().map(|p| p != identifier).unwrap_or(false));
+              if prev_len != permissions.len() {
+                std::fs::write(&path, value.to_string())?;
+                log::info!(action = "Removed"; "permission from capability at {}", dunce::simplified(&path).display());
+              }
+            }
+          }
+        }
+        Some("json") => {
+          let content = std::fs::read(&path)?;
+          if let Ok(mut value) = serde_json::from_slice::<serde_json::Value>(&content) {
+            if let Some(permissions) = value.get_mut("permissions").and_then(|p| p.as_array_mut()) {
+              let prev_len = permissions.len();
+              permissions.retain(|p| p.as_str().map(|p| p != identifier).unwrap_or(false));
+              if prev_len != permissions.len() {
+                std::fs::write(&path, serde_json::to_vec_pretty(&value)?)?;
+                log::info!(action = "Removed"; "permission from capability at {}", dunce::simplified(&path).display());
+              }
+            }
+          }
+        }
+        _ => {}
+      }
+    }
+  }
+
+  Ok(())
+}
+
+#[derive(Debug, Parser)]
+#[clap(about = "Remove a permission file, and its reference from any capability")]
+pub struct Options {
+  /// Permission to remove.
+  identifier: String,
+}
+
+pub fn command(options: Options) -> Result<()> {
+  let permissions_dir = std::env::current_dir()?.join("permissions");
+  if permissions_dir.exists() {
+    rm_permission_files(&options.identifier, &permissions_dir)?;
+  }
+
+  if let Some(tauri_dir) = tauri_dir_opt() {
+    let capabilities_dir = tauri_dir.join("capabilities");
+    if capabilities_dir.exists() {
+      rm_permission_from_capabilities(&options.identifier, &capabilities_dir)?;
+    }
+  }
+
+  Ok(())
+}

+ 1 - 2
tooling/cli/src/build.rs

@@ -60,12 +60,11 @@ pub struct Options {
   /// Command line arguments passed to the runner. Use `--` to explicitly mark the start of the arguments.
   pub args: Vec<String>,
   /// Skip prompting for values
-  #[clap(long)]
+  #[clap(long, env = "CI")]
   pub ci: bool,
 }
 
 pub fn command(mut options: Options, verbosity: u8) -> Result<()> {
-  options.ci = options.ci || std::env::var("CI").is_ok();
   let ci = options.ci;
 
   let target = options

+ 22 - 14
tooling/cli/src/helpers/app_paths.rs

@@ -66,14 +66,16 @@ fn lookup<F: Fn(&PathBuf) -> bool>(dir: &Path, checker: F) -> Option<PathBuf> {
   None
 }
 
-fn get_tauri_dir() -> PathBuf {
-  let cwd = current_dir().expect("failed to read cwd");
+pub fn tauri_dir_opt() -> Option<PathBuf> {
+  let Ok(cwd) = current_dir() else {
+    return None;
+  };
 
   if cwd.join(ConfigFormat::Json.into_file_name()).exists()
     || cwd.join(ConfigFormat::Json5.into_file_name()).exists()
     || cwd.join(ConfigFormat::Toml.into_file_name()).exists()
   {
-    return cwd;
+    return Some(cwd);
   }
 
   let src_tauri = cwd.join("src-tauri");
@@ -83,12 +85,23 @@ fn get_tauri_dir() -> PathBuf {
       .exists()
     || src_tauri.join(ConfigFormat::Toml.into_file_name()).exists()
   {
-    return src_tauri;
+    return Some(src_tauri);
   }
 
-  lookup(&cwd, |path| folder_has_configuration_file(Target::Linux, path) || is_configuration_file(Target::Linux, path))
-  .map(|p| if p.is_dir() { p } else {  p.parent().unwrap().to_path_buf() })
-  .unwrap_or_else(||
+  lookup(&cwd, |path| {
+    folder_has_configuration_file(Target::Linux, path) || is_configuration_file(Target::Linux, path)
+  })
+  .map(|p| {
+    if p.is_dir() {
+      p
+    } else {
+      p.parent().unwrap().to_path_buf()
+    }
+  })
+}
+
+pub fn tauri_dir() -> PathBuf {
+  tauri_dir_opt().unwrap_or_else(||
     panic!("Couldn't recognize the current folder as a Tauri project. It must contain a `{}`, `{}` or `{}` file in any subfolder.",
       ConfigFormat::Json.into_file_name(),
       ConfigFormat::Json5.into_file_name(),
@@ -116,11 +129,6 @@ fn get_app_dir() -> Option<PathBuf> {
 
 pub fn app_dir() -> &'static PathBuf {
   static APP_DIR: OnceLock<PathBuf> = OnceLock::new();
-  APP_DIR.get_or_init(|| {
-    get_app_dir().unwrap_or_else(|| get_tauri_dir().parent().unwrap().to_path_buf())
-  })
-}
-
-pub fn tauri_dir() -> PathBuf {
-  get_tauri_dir()
+  APP_DIR
+    .get_or_init(|| get_app_dir().unwrap_or_else(|| tauri_dir().parent().unwrap().to_path_buf()))
 }

+ 1 - 0
tooling/cli/src/helpers/mod.rs

@@ -7,6 +7,7 @@ pub mod config;
 pub mod flock;
 pub mod framework;
 pub mod npm;
+pub mod prompts;
 pub mod template;
 pub mod updater_signature;
 pub mod web_dev_server;

+ 57 - 0
tooling/cli/src/helpers/prompts.rs

@@ -0,0 +1,57 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+use std::{fmt::Display, str::FromStr};
+
+use crate::Result;
+
+pub fn input<T>(
+  prompt: &str,
+  initial: Option<T>,
+  skip: bool,
+  allow_empty: bool,
+) -> Result<Option<T>>
+where
+  T: Clone + FromStr + Display + ToString,
+  T::Err: Display + std::fmt::Debug,
+{
+  if skip {
+    Ok(initial)
+  } else {
+    let theme = dialoguer::theme::ColorfulTheme::default();
+    let mut builder = dialoguer::Input::with_theme(&theme)
+      .with_prompt(prompt)
+      .allow_empty(allow_empty);
+
+    if let Some(v) = initial {
+      builder = builder.with_initial_text(v.to_string());
+    }
+
+    builder.interact_text().map(Some).map_err(Into::into)
+  }
+}
+
+pub fn confirm(prompt: &str, default: Option<bool>) -> Result<bool> {
+  let theme = dialoguer::theme::ColorfulTheme::default();
+  let mut builder = dialoguer::Confirm::with_theme(&theme).with_prompt(prompt);
+  if let Some(default) = default {
+    builder = builder.default(default);
+  }
+  builder.interact().map_err(Into::into)
+}
+
+pub fn multiselect<T: ToString>(
+  prompt: &str,
+  items: &[T],
+  defaults: Option<&[bool]>,
+) -> Result<Vec<usize>> {
+  let theme = dialoguer::theme::ColorfulTheme::default();
+  let mut builder = dialoguer::MultiSelect::with_theme(&theme)
+    .with_prompt(prompt)
+    .items(items);
+  if let Some(defaults) = defaults {
+    builder = builder.defaults(defaults);
+  }
+  builder.interact().map_err(Into::into)
+}

+ 8 - 38
tooling/cli/src/init.rs

@@ -5,23 +5,20 @@
 use crate::{
   helpers::{
     framework::{infer_from_package_json as infer_framework, Framework},
-    resolve_tauri_path, template,
+    prompts, resolve_tauri_path, template,
   },
   VersionMetadata,
 };
 use std::{
   collections::BTreeMap,
   env::current_dir,
-  fmt::Display,
   fs::{read_to_string, remove_dir_all},
   path::PathBuf,
-  str::FromStr,
 };
 
 use crate::Result;
 use anyhow::Context;
 use clap::Parser;
-use dialoguer::Input;
 use handlebars::{to_json, Handlebars};
 use include_dir::{include_dir, Dir};
 use log::warn;
@@ -33,7 +30,7 @@ const TAURI_CONF_TEMPLATE: &str = include_str!("../templates/tauri.conf.json");
 #[clap(about = "Initialize a Tauri project in an existing directory")]
 pub struct Options {
   /// Skip prompting for values
-  #[clap(long)]
+  #[clap(long, env = "CI")]
   ci: bool,
   /// Force init to overwrite the src-tauri folder
   #[clap(short, long)]
@@ -76,7 +73,6 @@ struct InitDefaults {
 
 impl Options {
   fn load(mut self) -> Result<Self> {
-    self.ci = self.ci || std::env::var("CI").is_ok();
     let package_json_path = PathBuf::from(&self.directory).join("package.json");
 
     let init_defaults = if package_json_path.exists() {
@@ -92,7 +88,7 @@ impl Options {
     };
 
     self.app_name = self.app_name.map(|s| Ok(Some(s))).unwrap_or_else(|| {
-      request_input(
+      prompts::input(
         "What is your app name?",
         init_defaults.app_name.clone(),
         self.ci,
@@ -101,7 +97,7 @@ impl Options {
     })?;
 
     self.window_title = self.window_title.map(|s| Ok(Some(s))).unwrap_or_else(|| {
-      request_input(
+      prompts::input(
         "What should the window title be?",
         init_defaults.app_name.clone(),
         self.ci,
@@ -109,7 +105,7 @@ impl Options {
       )
     })?;
 
-    self.frontend_dist = self.frontend_dist.map(|s| Ok(Some(s))).unwrap_or_else(|| request_input(
+    self.frontend_dist = self.frontend_dist.map(|s| Ok(Some(s))).unwrap_or_else(|| prompts::input(
       r#"Where are your web assets (HTML/CSS/JS) located, relative to the "<current dir>/src-tauri/tauri.conf.json" file that will be created?"#,
       init_defaults.framework.as_ref().map(|f| f.frontend_dist()),
       self.ci,
@@ -117,7 +113,7 @@ impl Options {
     ))?;
 
     self.dev_url = self.dev_url.map(|s| Ok(Some(s))).unwrap_or_else(|| {
-      request_input(
+      prompts::input(
         "What is the url of your dev server?",
         init_defaults.framework.map(|f| f.dev_url()),
         self.ci,
@@ -129,7 +125,7 @@ impl Options {
       .before_dev_command
       .map(|s| Ok(Some(s)))
       .unwrap_or_else(|| {
-        request_input(
+        prompts::input(
           "What is your frontend dev command?",
           Some("npm run dev".to_string()),
           self.ci,
@@ -140,7 +136,7 @@ impl Options {
       .before_build_command
       .map(|s| Ok(Some(s)))
       .unwrap_or_else(|| {
-        request_input(
+        prompts::input(
           "What is your frontend build command?",
           Some("npm run build".to_string()),
           self.ci,
@@ -283,29 +279,3 @@ pub fn command(mut options: Options) -> Result<()> {
 
   Ok(())
 }
-
-fn request_input<T>(
-  prompt: &str,
-  initial: Option<T>,
-  skip: bool,
-  allow_empty: bool,
-) -> Result<Option<T>>
-where
-  T: Clone + FromStr + Display + ToString,
-  T::Err: Display + std::fmt::Debug,
-{
-  if skip {
-    Ok(initial)
-  } else {
-    let theme = dialoguer::theme::ColorfulTheme::default();
-    let mut builder = Input::with_theme(&theme)
-      .with_prompt(prompt)
-      .allow_empty(allow_empty);
-
-    if let Some(v) = initial {
-      builder = builder.with_initial_text(v.to_string());
-    }
-
-    builder.interact_text().map(Some).map_err(Into::into)
-  }
-}

+ 5 - 0
tooling/cli/src/lib.rs

@@ -14,6 +14,7 @@
 use anyhow::Context;
 pub use anyhow::Result;
 
+mod acl;
 mod add;
 mod build;
 mod completions;
@@ -145,6 +146,8 @@ enum Commands {
   Icon(icon::Options),
   Signer(signer::Cli),
   Completions(completions::Options),
+  Permission(acl::permission::Cli),
+  Capability(acl::capability::Cli),
 }
 
 fn format_error<I: CommandFactory>(err: clap::Error) -> clap::Error {
@@ -247,6 +250,8 @@ where
     Commands::Plugin(cli) => plugin::command(cli)?,
     Commands::Signer(cli) => signer::command(cli)?,
     Commands::Completions(options) => completions::command(options, cli_)?,
+    Commands::Permission(options) => acl::permission::command(options)?,
+    Commands::Capability(options) => acl::capability::command(options)?,
     Commands::Android(c) => mobile::android::command(c, cli.verbose)?,
     #[cfg(target_os = "macos")]
     Commands::Ios(c) => mobile::ios::command(c, cli.verbose)?,

+ 4 - 1
tooling/cli/src/mobile/android/build.rs

@@ -64,6 +64,9 @@ pub struct Options {
   /// Open Android Studio
   #[clap(short, long)]
   pub open: bool,
+  /// Skip prompting for values
+  #[clap(long, env = "CI")]
+  pub ci: bool,
 }
 
 impl From<Options> for BuildOptions {
@@ -76,7 +79,7 @@ impl From<Options> for BuildOptions {
       bundles: None,
       config: options.config,
       args: Vec::new(),
-      ci: false,
+      ci: options.ci,
     }
   }
 }

+ 1 - 1
tooling/cli/src/mobile/android/mod.rs

@@ -57,7 +57,7 @@ pub struct Cli {
 #[clap(about = "Initialize Android target in the project")]
 pub struct InitOptions {
   /// Skip prompting for values
-  #[clap(long)]
+  #[clap(long, env = "CI")]
   ci: bool,
   /// Skips installing rust toolchains via rustup
   #[clap(long)]

+ 2 - 8
tooling/cli/src/mobile/init.rs

@@ -38,14 +38,8 @@ pub fn command(
 ) -> Result<()> {
   let wrapper = TextWrapper::default();
 
-  exec(
-    target,
-    &wrapper,
-    ci || var_os("CI").is_some(),
-    reinstall_deps,
-    skip_targets_install,
-  )
-  .map_err(|e| anyhow::anyhow!("{:#}", e))?;
+  exec(target, &wrapper, ci, reinstall_deps, skip_targets_install)
+    .map_err(|e| anyhow::anyhow!("{:#}", e))?;
   Ok(())
 }
 

+ 4 - 1
tooling/cli/src/mobile/ios/build.rs

@@ -60,6 +60,9 @@ pub struct Options {
   /// Open Xcode
   #[clap(short, long)]
   pub open: bool,
+  /// Skip prompting for values
+  #[clap(long, env = "CI")]
+  pub ci: bool,
 }
 
 impl From<Options> for BuildOptions {
@@ -72,7 +75,7 @@ impl From<Options> for BuildOptions {
       bundles: None,
       config: options.config,
       args: Vec::new(),
-      ci: false,
+      ci: options.ci,
     }
   }
 }

+ 1 - 1
tooling/cli/src/mobile/ios/mod.rs

@@ -63,7 +63,7 @@ pub struct Cli {
 #[clap(about = "Initialize iOS target in the project")]
 pub struct InitOptions {
   /// Skip prompting for values
-  #[clap(long)]
+  #[clap(long, env = "CI")]
   ci: bool,
   /// Reinstall dependencies
   #[clap(short, long)]

+ 5 - 2
tooling/cli/src/plugin/android.rs

@@ -2,7 +2,10 @@
 // SPDX-License-Identifier: Apache-2.0
 // SPDX-License-Identifier: MIT
 
-use crate::{helpers::template, Result};
+use crate::{
+  helpers::{prompts::input, template},
+  Result,
+};
 use clap::{Parser, Subcommand};
 use handlebars::Handlebars;
 
@@ -56,7 +59,7 @@ pub fn command(cli: Cli) -> Result<()> {
         return Err(anyhow::anyhow!("android folder already exists"));
       }
 
-      let plugin_id = super::init::request_input(
+      let plugin_id = input(
         "What should be the Android Package ID for your plugin?",
         Some(format!("com.plugin.{}", plugin_name)),
         false,

+ 6 - 30
tooling/cli/src/plugin/init.rs

@@ -2,6 +2,7 @@
 // SPDX-License-Identifier: Apache-2.0
 // SPDX-License-Identifier: MIT
 
+use crate::helpers::prompts::input;
 use crate::Result;
 use crate::{
   helpers::{resolve_tauri_path, template},
@@ -9,7 +10,6 @@ use crate::{
 };
 use anyhow::Context;
 use clap::Parser;
-use dialoguer::Input;
 use handlebars::{to_json, Handlebars};
 use heck::{ToKebabCase, ToPascalCase, ToSnakeCase};
 use include_dir::{include_dir, Dir};
@@ -18,10 +18,8 @@ use std::{
   collections::BTreeMap,
   env::current_dir,
   ffi::OsStr,
-  fmt::Display,
   fs::{create_dir_all, remove_dir_all, File, OpenOptions},
   path::{Component, Path, PathBuf},
-  str::FromStr,
 };
 
 pub const TEMPLATE_DIR: Dir<'_> = include_dir!("templates/plugin");
@@ -145,7 +143,7 @@ pub fn command(mut options: Options) -> Result<()> {
     }
 
     let plugin_id = if options.android || options.mobile {
-      let plugin_id = request_input(
+      let plugin_id = input(
         "What should be the Android Package ID for your plugin?",
         Some(format!("com.plugin.{}", plugin_name)),
         false,
@@ -218,6 +216,10 @@ pub fn command(mut options: Options) -> Result<()> {
     )
     .with_context(|| "failed to render plugin Android template")?;
   }
+
+  std::fs::create_dir(template_target_path.join("permissions"))
+    .with_context(|| "failed to create `permissions` directory")?;
+
   Ok(())
 }
 
@@ -278,29 +280,3 @@ pub fn generate_android_out_file(
     Ok(None)
   }
 }
-
-pub fn request_input<T>(
-  prompt: &str,
-  initial: Option<T>,
-  skip: bool,
-  allow_empty: bool,
-) -> Result<Option<T>>
-where
-  T: Clone + FromStr + Display + ToString,
-  T::Err: Display + std::fmt::Debug,
-{
-  if skip {
-    Ok(initial)
-  } else {
-    let theme = dialoguer::theme::ColorfulTheme::default();
-    let mut builder = Input::with_theme(&theme)
-      .with_prompt(prompt)
-      .allow_empty(allow_empty);
-
-    if let Some(v) = initial {
-      builder = builder.with_initial_text(v.to_string());
-    }
-
-    builder.interact_text().map(Some).map_err(Into::into)
-  }
-}

+ 1 - 3
tooling/cli/src/signer/generate.rs

@@ -23,13 +23,11 @@ pub struct Options {
   #[clap(short, long)]
   force: bool,
   /// Skip prompting for values
-  #[clap(long)]
+  #[clap(long, env = "CI")]
   ci: bool,
 }
 
 pub fn command(mut options: Options) -> Result<()> {
-  options.ci = options.ci || std::env::var("CI").is_ok();
-
   if options.ci && options.password.is_none() {
     log::warn!("Generating new private key without password. For security reasons, we recommend setting a password instead.");
     options.password.replace("".into());