diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 37f5c15c8..cceff12be 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -435,3 +435,35 @@ jobs: name: THIRDPARTY.html path: ./crates/bws/THIRDPARTY.html if-no-files-found: error + + manpages: + name: Generate manpages + runs-on: ubuntu-22.04 + needs: + - setup + steps: + - name: Checkout repo + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Install rust + uses: dtolnay/rust-toolchain@be73d7920c329f220ce78e0234b8f96b7ae60248 # stable + with: + toolchain: stable + + - name: Cache cargo registry + uses: Swatinem/rust-cache@23bce251a8cd2ffc3c1075eaa2367cf899916d84 # v2.7.3 + with: + key: cargo-cli-manpage + + - name: Generate manpages + run: | + cargo check -p bws --message-format json > build.json + OUT_DIR=$(jq -r --slurp '.[] | select (.reason == "build-script-executed") | select(.package_id|contains("crates/bws")) .out_dir' build.json) + mv $OUT_DIR/manpages . + + - name: Upload artifact + uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + with: + name: manpages + path: ./manpages/* + if-no-files-found: error diff --git a/Cargo.lock b/Cargo.lock index bda3a9c4a..15ce61b1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -602,6 +602,7 @@ dependencies = [ "chrono", "clap", "clap_complete", + "clap_mangen", "color-eyre", "comfy-table", "directories", @@ -777,6 +778,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" @@ -2675,6 +2686,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 d27daca6f..28fecc0e2 100644 --- a/crates/bws/Cargo.toml +++ b/crates/bws/Cargo.toml @@ -44,6 +44,13 @@ tokio = { version = "1.36.0", features = ["rt-multi-thread", "macros"] } toml = "0.8.10" uuid = { version = "^1.7.0", features = ["serde"] } +[build-dependencies] +bitwarden-cli = { workspace = true } +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/README.md b/crates/bws/README.md index cb9c268fb..ace5210f1 100644 --- a/crates/bws/README.md +++ b/crates/bws/README.md @@ -62,3 +62,15 @@ To use a configuration file, utilize docker ```bash docker run --rm -it -v "$HOME"/.bws:/home/app/.bws bitwarden/bws --help ``` + +## How to build manpages + +The manpages get built during compilation of the `bws` crate through the use of a build script. The +output path of this build script can be located as follows: + +``` +MANPAGES_DIR=$(cargo build -p bws --message-format json | jq -r --slurp '.[] | select (.reason == "build-script-executed") | select(.package_id|contains("crates/bws")) .out_dir') +``` + +After running the provided commands, the built manpages should be located in +`$MANPAGES_DIR/manpages` diff --git a/crates/bws/build.rs b/crates/bws/build.rs new file mode 100644 index 000000000..be0562379 --- /dev/null +++ b/crates/bws/build.rs @@ -0,0 +1,14 @@ +include!("src/cli.rs"); + +fn main() -> Result<(), std::io::Error> { + use std::{env, fs, path::Path}; + + let out_dir = env::var_os("OUT_DIR").expect("OUT_DIR exists"); + let path = Path::new(&out_dir).join("manpages"); + fs::create_dir_all(&path).expect("OUT_DIR is writable"); + + let cmd = ::command(); + clap_mangen::generate_to(cmd, &path)?; + + Ok(()) +} diff --git a/crates/bws/src/cli.rs b/crates/bws/src/cli.rs new file mode 100644 index 000000000..48d2f528e --- /dev/null +++ b/crates/bws/src/cli.rs @@ -0,0 +1,228 @@ +use std::path::PathBuf; + +use bitwarden_cli::Color; +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(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 6cc471f97..9756704f4 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,9 +34,6 @@ impl ProfileKey { } } -pub(crate) const FILENAME: &str = "config"; -pub(crate) const DIRECTORY: &str = ".bws"; - fn get_config_path(config_file: Option<&Path>, ensure_folder_exists: bool) -> Result { let config_file = match config_file { Some(path) => path.to_owned(), @@ -53,7 +41,10 @@ fn get_config_path(config_file: Option<&Path>, ensure_folder_exists: bool) -> Re let Some(base_dirs) = BaseDirs::new() else { bail!("A valid home directory doesn't exist"); }; - base_dirs.home_dir().join(DIRECTORY).join(FILENAME) + base_dirs + .home_dir() + .join(DEFAULT_CONFIG_DIRECTORY) + .join(DEFAULT_CONFIG_FILENAME) } }; diff --git a/crates/bws/src/main.rs b/crates/bws/src/main.rs index d57aee5a6..eb5c8304b 100644 --- a/crates/bws/src/main.rs +++ b/crates/bws/src/main.rs @@ -14,213 +14,19 @@ use bitwarden::{ }, }, }; -use bitwarden_cli::{install_color_eyre, Color}; -use clap::{ArgGroup, CommandFactory, Parser, Subcommand}; +use bitwarden_cli::install_color_eyre; +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, 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 machine 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<()> { @@ -229,11 +35,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 7acdd30a6..219e72b68 100644 --- a/crates/bws/src/render.rs +++ b/crates/bws/src/render.rs @@ -1,20 +1,10 @@ use bitwarden::secrets_manager::{projects::ProjectResponse, secrets::SecretResponse}; use bitwarden_cli::Color; 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, -} +use crate::cli::Output; const ASCII_HEADER_ONLY: &str = " -- ";