diff --git a/Cargo.lock b/Cargo.lock index dfa714cc6..ea80c6908 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -599,6 +599,7 @@ dependencies = [ "chrono", "clap", "clap_complete", + "clap_mangen", "color-eyre", "comfy-table", "directories", @@ -774,6 +775,16 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" +[[package]] +name = "clap_mangen" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1dd95b5ebb5c1c54581dd6346f3ed6a79a3eef95dd372fc2ac13d535535300e" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "clircle" version = "0.4.0" @@ -2671,6 +2682,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b833d8d034ea094b1ea68aa6d5c740e0d04bad9d16568d08ba6f76823a114316" + [[package]] name = "rsa" version = "0.9.6" diff --git a/crates/bws/Cargo.toml b/crates/bws/Cargo.toml index ccf143c47..ffea1a755 100644 --- a/crates/bws/Cargo.toml +++ b/crates/bws/Cargo.toml @@ -44,5 +44,11 @@ uuid = { version = "^1.7.0", features = ["serde"] } bitwarden = { workspace = true, features = ["secrets"] } +[build-dependencies] +clap = { version = "4.5.1", features = ["derive", "string"] } +clap_complete = "4.5.0" +clap_mangen = "0.2.20" +uuid = { version = "^1.7.0" } + [dev-dependencies] tempfile = "3.10.0" diff --git a/crates/bws/build.rs b/crates/bws/build.rs new file mode 100644 index 000000000..9560cc9c5 --- /dev/null +++ b/crates/bws/build.rs @@ -0,0 +1,16 @@ +include!("src/cli.rs"); + +fn main() -> Result<(), std::io::Error> { + use std::{env, fs, path::Path}; + + let out_dir = env::var_os("OUT_DIR").unwrap(); + let path = Path::new(&out_dir).join("manpages"); + fs::create_dir_all(&path).unwrap(); + + let cmd = ::command(); + clap_mangen::generate_to(cmd, &path)?; + + println!("cargo:warning=man files generated: {path:?}"); + + Ok(()) +} diff --git a/crates/bws/src/cli.rs b/crates/bws/src/cli.rs new file mode 100644 index 000000000..2ed7d3fe8 --- /dev/null +++ b/crates/bws/src/cli.rs @@ -0,0 +1,234 @@ +use std::path::PathBuf; + +use clap::{ArgGroup, Parser, Subcommand, ValueEnum}; +use clap_complete::Shell; +use uuid::Uuid; + +pub(crate) const ACCESS_TOKEN_KEY_VAR_NAME: &str = "BWS_ACCESS_TOKEN"; +pub(crate) const CONFIG_FILE_KEY_VAR_NAME: &str = "BWS_CONFIG_FILE"; +pub(crate) const PROFILE_KEY_VAR_NAME: &str = "BWS_PROFILE"; +pub(crate) const SERVER_URL_KEY_VAR_NAME: &str = "BWS_SERVER_URL"; + +pub(crate) const DEFAULT_CONFIG_FILENAME: &str = "config"; +pub(crate) const DEFAULT_CONFIG_DIRECTORY: &str = ".bws"; + +#[allow(non_camel_case_types)] +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] +pub(crate) enum ProfileKey { + server_base, + server_api, + server_identity, + state_file_dir, +} + +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] +#[allow(clippy::upper_case_acronyms)] +pub(crate) enum Output { + JSON, + YAML, + Env, + Table, + TSV, + None, +} + +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] +pub(crate) enum Color { + No, + Yes, + Auto, +} + +#[derive(Parser, Debug)] +#[command(name = "bws", version, about = "Bitwarden Secrets CLI", long_about = None)] +pub(crate) struct Cli { + // Optional as a workaround for https://github.com/clap-rs/clap/issues/3572 + #[command(subcommand)] + pub(crate) command: Option, + + #[arg(short = 'o', long, global = true, value_enum, default_value_t = Output::JSON, help="Output format")] + pub(crate) output: Output, + + #[arg(short = 'c', long, global = true, value_enum, default_value_t = Color::Auto, help="Use colors in the output")] + pub(crate) color: Color, + + #[arg(short = 't', long, global = true, env = ACCESS_TOKEN_KEY_VAR_NAME, hide_env_values = true, help="Specify access token for the service account")] + pub(crate) access_token: Option, + + #[arg( + short = 'f', + long, + global = true, + env = CONFIG_FILE_KEY_VAR_NAME, + help = format!("[default: ~/{}/{}] Config file to use", DEFAULT_CONFIG_DIRECTORY, DEFAULT_CONFIG_FILENAME) + )] + pub(crate) config_file: Option, + + #[arg(short = 'p', long, global = true, env = PROFILE_KEY_VAR_NAME, help="Profile to use from the config file")] + pub(crate) profile: Option, + + #[arg(short = 'u', long, global = true, env = SERVER_URL_KEY_VAR_NAME, help="Override the server URL from the config file")] + pub(crate) server_url: Option, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum Commands { + #[command(long_about = "Configure the CLI", arg_required_else_help(true))] + Config { + name: Option, + value: Option, + + #[arg(short = 'd', long)] + delete: bool, + }, + + #[command(long_about = "Generate shell completion files")] + Completions { shell: Option }, + + #[command(long_about = "Commands available on Projects")] + Project { + #[command(subcommand)] + cmd: ProjectCommand, + }, + #[command(long_about = "Commands available on Secrets")] + Secret { + #[command(subcommand)] + cmd: SecretCommand, + }, + #[command(long_about = "Create a single item (deprecated)", hide(true))] + Create { + #[command(subcommand)] + cmd: CreateCommand, + }, + #[command(long_about = "Delete one or more items (deprecated)", hide(true))] + Delete { + #[command(subcommand)] + cmd: DeleteCommand, + }, + #[command(long_about = "Edit a single item (deprecated)", hide(true))] + Edit { + #[command(subcommand)] + cmd: EditCommand, + }, + #[command(long_about = "Retrieve a single item (deprecated)", hide(true))] + Get { + #[command(subcommand)] + cmd: GetCommand, + }, + #[command(long_about = "List items (deprecated)", hide(true))] + List { + #[command(subcommand)] + cmd: ListCommand, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum SecretCommand { + Create { + key: String, + value: String, + + #[arg(help = "The ID of the project this secret will be added to")] + project_id: Uuid, + + #[arg(long, help = "An optional note to add to the secret")] + note: Option, + }, + Delete { + secret_ids: Vec, + }, + #[clap(group = ArgGroup::new("edit_field").required(true).multiple(true))] + Edit { + secret_id: Uuid, + #[arg(long, group = "edit_field")] + key: Option, + #[arg(long, group = "edit_field")] + value: Option, + #[arg(long, group = "edit_field")] + note: Option, + #[arg(long, group = "edit_field")] + project_id: Option, + }, + Get { + secret_id: Uuid, + }, + List { + project_id: Option, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum ProjectCommand { + Create { + name: String, + }, + Delete { + project_ids: Vec, + }, + Edit { + project_id: Uuid, + #[arg(long, group = "edit_field")] + name: String, + }, + Get { + project_id: Uuid, + }, + List, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum ListCommand { + Projects, + Secrets { project_id: Option }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum GetCommand { + Project { project_id: Uuid }, + Secret { secret_id: Uuid }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum CreateCommand { + Project { + name: String, + }, + Secret { + key: String, + value: String, + + #[arg(long, help = "An optional note to add to the secret")] + note: Option, + + #[arg(long, help = "The ID of the project this secret will be added to")] + project_id: Uuid, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum EditCommand { + #[clap(group = ArgGroup::new("edit_field").required(true).multiple(true))] + Project { + project_id: Uuid, + #[arg(long, group = "edit_field")] + name: String, + }, + #[clap(group = ArgGroup::new("edit_field").required(true).multiple(true))] + Secret { + secret_id: Uuid, + #[arg(long, group = "edit_field")] + key: Option, + #[arg(long, group = "edit_field")] + value: Option, + #[arg(long, group = "edit_field")] + note: Option, + #[arg(long, group = "edit_field")] + project_id: Option, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum DeleteCommand { + Project { project_ids: Vec }, + Secret { secret_ids: Vec }, +} diff --git a/crates/bws/src/config.rs b/crates/bws/src/config.rs index 0ea1259f9..756f463dc 100644 --- a/crates/bws/src/config.rs +++ b/crates/bws/src/config.rs @@ -4,11 +4,12 @@ use std::{ path::{Path, PathBuf}, }; -use clap::ValueEnum; use color_eyre::eyre::{bail, Result}; use directories::BaseDirs; use serde::{Deserialize, Serialize}; +use crate::cli::{ProfileKey, DEFAULT_CONFIG_DIRECTORY, DEFAULT_CONFIG_FILENAME}; + #[derive(Debug, Serialize, Deserialize, Default)] pub(crate) struct Config { pub profiles: HashMap, @@ -22,16 +23,6 @@ pub(crate) struct Profile { pub state_file_dir: Option, } -// TODO: This could probably be derived with a macro if we start adding more fields -#[allow(non_camel_case_types)] -#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] -pub(crate) enum ProfileKey { - server_base, - server_api, - server_identity, - state_file_dir, -} - impl ProfileKey { fn update_profile_value(&self, p: &mut Profile, value: String) { match self { @@ -43,13 +34,13 @@ impl ProfileKey { } } -pub(crate) const FILENAME: &str = "config"; -pub(crate) const DIRECTORY: &str = ".bws"; - pub(crate) fn get_config_path(config_file: Option<&Path>, ensure_folder_exists: bool) -> PathBuf { let config_file = config_file.map(ToOwned::to_owned).unwrap_or_else(|| { let base_dirs = BaseDirs::new().unwrap(); - base_dirs.home_dir().join(DIRECTORY).join(FILENAME) + base_dirs + .home_dir() + .join(DEFAULT_CONFIG_DIRECTORY) + .join(DEFAULT_CONFIG_FILENAME) }); if ensure_folder_exists { diff --git a/crates/bws/src/main.rs b/crates/bws/src/main.rs index 6ed78f7b7..f787f0304 100644 --- a/crates/bws/src/main.rs +++ b/crates/bws/src/main.rs @@ -14,212 +14,18 @@ use bitwarden::{ }, }, }; -use clap::{ArgGroup, CommandFactory, Parser, Subcommand}; +use clap::{CommandFactory, Parser}; use clap_complete::Shell; use color_eyre::eyre::{bail, Result}; use log::error; +use uuid::Uuid; +mod cli; mod config; mod render; mod state; -use config::ProfileKey; -use render::{serialize_response, Color, Output}; -use uuid::Uuid; - -#[derive(Parser, Debug)] -#[command(name = "bws", version, about = "Bitwarden Secrets CLI", long_about = None)] -struct Cli { - // Optional as a workaround for https://github.com/clap-rs/clap/issues/3572 - #[command(subcommand)] - command: Option, - - #[arg(short = 'o', long, global = true, value_enum, default_value_t = Output::JSON, help="Output format")] - output: Output, - - #[arg(short = 'c', long, global = true, value_enum, default_value_t = Color::Auto, help="Use colors in the output")] - color: Color, - - #[arg(short = 't', long, global = true, env = ACCESS_TOKEN_KEY_VAR_NAME, hide_env_values = true, help="Specify access token for the service account")] - access_token: Option, - - #[arg( - short = 'f', - long, - global = true, - env = CONFIG_FILE_KEY_VAR_NAME, - help = format!("[default: ~/{}/{}] Config file to use", config::DIRECTORY, config::FILENAME) - )] - config_file: Option, - - #[arg(short = 'p', long, global = true, env = PROFILE_KEY_VAR_NAME, help="Profile to use from the config file")] - profile: Option, - - #[arg(short = 'u', long, global = true, env = SERVER_URL_KEY_VAR_NAME, help="Override the server URL from the config file")] - server_url: Option, -} - -#[derive(Subcommand, Debug)] -enum Commands { - #[command(long_about = "Configure the CLI", arg_required_else_help(true))] - Config { - name: Option, - value: Option, - - #[arg(short = 'd', long)] - delete: bool, - }, - - #[command(long_about = "Generate shell completion files")] - Completions { shell: Option }, - - #[command(long_about = "Commands available on Projects")] - Project { - #[command(subcommand)] - cmd: ProjectCommand, - }, - #[command(long_about = "Commands available on Secrets")] - Secret { - #[command(subcommand)] - cmd: SecretCommand, - }, - #[command(long_about = "Create a single item (deprecated)", hide(true))] - Create { - #[command(subcommand)] - cmd: CreateCommand, - }, - #[command(long_about = "Delete one or more items (deprecated)", hide(true))] - Delete { - #[command(subcommand)] - cmd: DeleteCommand, - }, - #[command(long_about = "Edit a single item (deprecated)", hide(true))] - Edit { - #[command(subcommand)] - cmd: EditCommand, - }, - #[command(long_about = "Retrieve a single item (deprecated)", hide(true))] - Get { - #[command(subcommand)] - cmd: GetCommand, - }, - #[command(long_about = "List items (deprecated)", hide(true))] - List { - #[command(subcommand)] - cmd: ListCommand, - }, -} - -#[derive(Subcommand, Debug)] -enum SecretCommand { - Create { - key: String, - value: String, - - #[arg(help = "The ID of the project this secret will be added to")] - project_id: Uuid, - - #[arg(long, help = "An optional note to add to the secret")] - note: Option, - }, - Delete { - secret_ids: Vec, - }, - #[clap(group = ArgGroup::new("edit_field").required(true).multiple(true))] - Edit { - secret_id: Uuid, - #[arg(long, group = "edit_field")] - key: Option, - #[arg(long, group = "edit_field")] - value: Option, - #[arg(long, group = "edit_field")] - note: Option, - #[arg(long, group = "edit_field")] - project_id: Option, - }, - Get { - secret_id: Uuid, - }, - List { - project_id: Option, - }, -} - -#[derive(Subcommand, Debug)] -enum ProjectCommand { - Create { - name: String, - }, - Delete { - project_ids: Vec, - }, - Edit { - project_id: Uuid, - #[arg(long, group = "edit_field")] - name: String, - }, - Get { - project_id: Uuid, - }, - List, -} - -#[derive(Subcommand, Debug)] -enum ListCommand { - Projects, - Secrets { project_id: Option }, -} - -#[derive(Subcommand, Debug)] -enum GetCommand { - Project { project_id: Uuid }, - Secret { secret_id: Uuid }, -} - -#[derive(Subcommand, Debug)] -enum CreateCommand { - Project { - name: String, - }, - Secret { - key: String, - value: String, - - #[arg(long, help = "An optional note to add to the secret")] - note: Option, - - #[arg(long, help = "The ID of the project this secret will be added to")] - project_id: Uuid, - }, -} - -#[derive(Subcommand, Debug)] -enum EditCommand { - #[clap(group = ArgGroup::new("edit_field").required(true).multiple(true))] - Project { - project_id: Uuid, - #[arg(long, group = "edit_field")] - name: String, - }, - #[clap(group = ArgGroup::new("edit_field").required(true).multiple(true))] - Secret { - secret_id: Uuid, - #[arg(long, group = "edit_field")] - key: Option, - #[arg(long, group = "edit_field")] - value: Option, - #[arg(long, group = "edit_field")] - note: Option, - #[arg(long, group = "edit_field")] - project_id: Option, - }, -} - -#[derive(Subcommand, Debug)] -enum DeleteCommand { - Project { project_ids: Vec }, - Secret { secret_ids: Vec }, -} +use crate::{cli::*, render::serialize_response}; #[tokio::main(flavor = "current_thread")] async fn main() -> Result<()> { @@ -228,11 +34,6 @@ async fn main() -> Result<()> { process_commands().await } -const ACCESS_TOKEN_KEY_VAR_NAME: &str = "BWS_ACCESS_TOKEN"; -const CONFIG_FILE_KEY_VAR_NAME: &str = "BWS_CONFIG_FILE"; -const PROFILE_KEY_VAR_NAME: &str = "BWS_PROFILE"; -const SERVER_URL_KEY_VAR_NAME: &str = "BWS_SERVER_URL"; - #[allow(clippy::comparison_chain)] async fn process_commands() -> Result<()> { let cli = Cli::parse(); diff --git a/crates/bws/src/render.rs b/crates/bws/src/render.rs index f9563c81b..361258d06 100644 --- a/crates/bws/src/render.rs +++ b/crates/bws/src/render.rs @@ -1,26 +1,9 @@ use bitwarden::secrets_manager::{projects::ProjectResponse, secrets::SecretResponse}; use chrono::{DateTime, Utc}; -use clap::ValueEnum; use comfy_table::Table; use serde::Serialize; -#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] -#[allow(clippy::upper_case_acronyms)] -pub(crate) enum Output { - JSON, - YAML, - Env, - Table, - TSV, - None, -} - -#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] -pub(crate) enum Color { - No, - Yes, - Auto, -} +use crate::cli::{Color, Output}; impl Color { pub(crate) fn is_enabled(self) -> bool {