From a9b6f1784cbb76ce8538fb9a27b6f9c058327cf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garci=CC=81a?= Date: Fri, 11 Jul 2025 17:00:02 +0200 Subject: [PATCH] Create an initial CLI skeleton --- Cargo.lock | 383 ++++++++++++++++++++++++++++- crates/bw/Cargo.toml | 9 + crates/bw/src/admin_console/mod.rs | 9 + crates/bw/src/auth/mod.rs | 103 +++++++- crates/bw/src/command.rs | 204 +++++++++++++++ crates/bw/src/main.rs | 310 +++++++---------------- crates/bw/src/platform/mod.rs | 52 ++++ crates/bw/src/render.rs | 100 ++++++++ crates/bw/src/tools/mod.rs | 81 ++++++ crates/bw/src/vault/mod.rs | 58 +++++ 10 files changed, 1082 insertions(+), 227 deletions(-) create mode 100644 crates/bw/src/admin_console/mod.rs create mode 100644 crates/bw/src/command.rs create mode 100644 crates/bw/src/platform/mod.rs create mode 100644 crates/bw/src/tools/mod.rs create mode 100644 crates/bw/src/vault/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 82d1c09b0..3d42498b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,6 +100,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "ansi_colours" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14eec43e0298190790f41679fe69ef7a829d2a2ddd78c8c00339e84710e435fe" +dependencies = [ + "rgb", +] + [[package]] name = "anstream" version = "0.6.19" @@ -279,6 +288,43 @@ dependencies = [ "serde", ] +[[package]] +name = "bat" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab792c2ad113a666f08856c88cdec0a62d732559b1f3982eedf0142571e669a" +dependencies = [ + "ansi_colours", + "anyhow", + "bincode", + "bytesize", + "clircle", + "console", + "content_inspector", + "encoding_rs", + "flate2", + "globset", + "home", + "indexmap 2.9.0", + "itertools 0.13.0", + "nu-ansi-term", + "once_cell", + "path_abs", + "plist", + "regex", + "semver", + "serde", + "serde_derive", + "serde_with", + "serde_yaml", + "syntect", + "terminal-colorsaurus", + "thiserror 1.0.69", + "toml 0.8.23", + "unicode-width 0.1.14", + "walkdir", +] + [[package]] name = "bcrypt-pbkdf" version = "0.10.0" @@ -290,6 +336,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -609,7 +664,7 @@ name = "bitwarden-state" version = "1.0.0" dependencies = [ "async-trait", - "thiserror 1.0.69", + "thiserror 2.0.12", "tokio", ] @@ -771,6 +826,16 @@ dependencies = [ "cipher", ] +[[package]] +name = "bstr" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.18.1" @@ -781,18 +846,31 @@ checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" name = "bw" version = "0.0.2" dependencies = [ + "base64", + "bat", "bitwarden-cli", "bitwarden-core", "bitwarden-generators", "bitwarden-vault", "clap", + "clap_complete", "color-eyre", "env_logger", + "erased-serde", "inquire", "log", + "serde", + "serde_json", + "serde_yaml", "tokio", ] +[[package]] +name = "bytemuck" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" + [[package]] name = "byteorder" version = "1.5.0" @@ -805,6 +883,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "bytesize" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e93abca9e28e0a1b9877922aacb20576e05d4679ffa78c3d6dc22a26a216659" + [[package]] name = "camino" version = "1.1.10" @@ -978,6 +1062,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5abde44486daf70c5be8b8f8f1b66c49f86236edf6fa2abadb4d961c4c6229a" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.5.40" @@ -996,6 +1089,16 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[package]] +name = "clircle" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d9334f725b46fb9bed8580b9b47a932587e044fadb344ed7fa98774b067ac1a" +dependencies = [ + "cfg-if", + "windows", +] + [[package]] name = "color-eyre" version = "0.6.5" @@ -1050,6 +1153,19 @@ dependencies = [ "unicode-width 0.2.1", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.1", + "windows-sys 0.59.0", +] + [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -1077,6 +1193,15 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "content_inspector" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7bda66e858c683005a53a9a60c69a4aca7eeaa45d124526e389f7aec8e62f38" +dependencies = [ + "memchr", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1112,6 +1237,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + [[package]] name = "credential-exchange-format" version = "0.1.0" @@ -1527,6 +1661,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "env_filter" version = "0.1.3" @@ -1586,6 +1735,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + [[package]] name = "fancy-regex" version = "0.13.0" @@ -1613,6 +1772,16 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1804,6 +1973,19 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +[[package]] +name = "globset" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -1927,6 +2109,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "http" version = "1.3.1" @@ -2046,7 +2237,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.61.2", ] [[package]] @@ -2519,6 +2710,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2764,6 +2964,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "path_abs" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ef02f6342ac01d8a93b65f96db53fe68a92a15f41144f97fb00a9e669633c3" +dependencies = [ + "std_prelude", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -2844,6 +3053,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" +dependencies = [ + "base64", + "indexmap 2.9.0", + "quick-xml", + "serde", + "time", +] + [[package]] name = "plotters" version = "0.3.7" @@ -2979,6 +3201,15 @@ name = "public-suffix" version = "0.1.1" source = "git+https://github.com/bitwarden/passkey-rs?rev=3b764633ebc6576c07bdd12ee14d8e5c87b494ed#3b764633ebc6576c07bdd12ee14d8e5c87b494ed" +[[package]] +name = "quick-xml" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8927b0664f5c5a98265138b7e3f90aa19a6b21353182469ace36d4ac527b7b1b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.8" @@ -3236,6 +3467,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "rgb" +version = "0.8.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a457e416a0f90d246a4c3288bd7a25b2304ca727f253f95be383dd17af56be8f" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -3717,6 +3957,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.9.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serdect" version = "0.2.0" @@ -3913,6 +4166,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "std_prelude" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8207e78455ffdf55661170876f88daf85356e4edd54e0a3dbc79586ca1e50cbe" + [[package]] name = "strsim" version = "0.11.1" @@ -3987,6 +4246,26 @@ dependencies = [ "syn", ] +[[package]] +name = "syntect" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874dcfa363995604333cf947ae9f751ca3af4522c60886774c4963943b4746b1" +dependencies = [ + "bincode", + "bitflags 1.3.2", + "fancy-regex 0.11.0", + "flate2", + "fnv", + "once_cell", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror 1.0.69", + "walkdir", +] + [[package]] name = "target-triple" version = "0.1.4" @@ -4002,6 +4281,32 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal-colorsaurus" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7afe4c174a3cbfb52ebcb11b28965daf74fe9111d4e07e40689d05af06e26e8" +dependencies = [ + "cfg-if", + "libc", + "memchr", + "mio 1.0.4", + "terminal-trx", + "windows-sys 0.59.0", + "xterm-color", +] + +[[package]] +name = "terminal-trx" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "975b4233aefa1b02456d5e53b22c61653c743e308c51cf4181191d8ce41753ab" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -4215,6 +4520,7 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ + "indexmap 2.9.0", "serde", "serde_spanned", "toml_datetime", @@ -4571,6 +4877,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -4854,19 +5166,52 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core 0.56.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement 0.56.0", + "windows-interface 0.56.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.0", + "windows-interface 0.59.1", "windows-link", - "windows-result", + "windows-result 0.3.4", "windows-strings", ] +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-implement" version = "0.60.0" @@ -4878,6 +5223,17 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-interface" version = "0.59.1" @@ -4895,6 +5251,15 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5248,6 +5613,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "xterm-color" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de5f056fb9dc8b7908754867544e26145767187aaac5a98495e88ad7cb8a80f" + [[package]] name = "yoke" version = "0.8.0" @@ -5381,7 +5752,7 @@ checksum = "ad76e35b00ad53688d6b90c431cabe3cbf51f7a4a154739e04b63004ab1c736c" dependencies = [ "chrono", "derive_builder", - "fancy-regex", + "fancy-regex 0.13.0", "itertools 0.13.0", "lazy_static", "regex", diff --git a/crates/bw/Cargo.toml b/crates/bw/Cargo.toml index 4a8187d49..a8f9ec417 100644 --- a/crates/bw/Cargo.toml +++ b/crates/bw/Cargo.toml @@ -15,15 +15,24 @@ repository.workspace = true license-file.workspace = true [dependencies] +base64 = ">=0.22.1, <0.23" +bat = { version = "0.25.0", features = [ + "regex-fancy", +], default-features = false } bitwarden-cli = { workspace = true } bitwarden-core = { workspace = true } bitwarden-generators = { workspace = true } bitwarden-vault = { workspace = true } clap = { version = "4.5.4", features = ["derive", "env"] } +clap_complete = "4.5.55" color-eyre = "0.6.3" env_logger = "0.11.1" +erased-serde = "0.4.6" inquire = "0.7.0" log = "0.4.20" +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = "0.9.33" tokio = { workspace = true, features = ["rt-multi-thread"] } [lints] diff --git a/crates/bw/src/admin_console/mod.rs b/crates/bw/src/admin_console/mod.rs new file mode 100644 index 000000000..7b8d4a81d --- /dev/null +++ b/crates/bw/src/admin_console/mod.rs @@ -0,0 +1,9 @@ +use clap::Subcommand; + +#[derive(Subcommand, Clone)] +pub enum ConfirmCommand { + OrgMember { + #[arg(long, help = "Organization id for an organization object.")] + organizationid: String, + }, +} diff --git a/crates/bw/src/auth/mod.rs b/crates/bw/src/auth/mod.rs index 1f165f5f3..8905ef740 100644 --- a/crates/bw/src/auth/mod.rs +++ b/crates/bw/src/auth/mod.rs @@ -1,2 +1,103 @@ +use bitwarden_cli::text_prompt_when_none; +use bitwarden_core::{auth::RegisterRequest, ClientSettings}; +use clap::{Args, Subcommand}; + mod login; -pub(crate) use login::{login_api_key, login_device, login_password}; +use inquire::Password; + +use crate::render::CommandResult; + +// TODO(CLI): This is incompatible with the current node CLI +#[derive(Args, Clone)] +pub struct LoginArgs { + #[command(subcommand)] + pub command: LoginCommands, + + #[arg(short = 's', long, global = true, help = "Server URL")] + pub server: Option, +} + +#[derive(Subcommand, Clone)] +pub enum LoginCommands { + Password { + #[arg(short = 'e', long, help = "Email address")] + email: Option, + }, + ApiKey { + client_id: Option, + client_secret: Option, + }, + Device { + #[arg(short = 'e', long, help = "Email address")] + email: Option, + device_identifier: Option, + }, +} + +impl LoginArgs { + pub async fn run(self) -> CommandResult { + let settings = self.server.map(|server| ClientSettings { + api_url: format!("{server}/api"), + identity_url: format!("{server}/identity"), + ..Default::default() + }); + let client = bitwarden_core::Client::new(settings); + + match self.command { + // FIXME: Rust CLI will not support password login! + LoginCommands::Password { email } => { + login::login_password(client, email).await?; + } + LoginCommands::ApiKey { + client_id, + client_secret, + } => login::login_api_key(client, client_id, client_secret).await?, + LoginCommands::Device { + email, + device_identifier, + } => { + login::login_device(client, email, device_identifier).await?; + } + } + Ok("Successfully logged in!".into()) + } +} + +#[derive(Args, Clone)] +pub struct RegisterArgs { + #[arg(short = 'e', long, help = "Email address")] + email: Option, + + name: Option, + + password_hint: Option, + + #[arg(short = 's', long, global = true, help = "Server URL")] + server: Option, +} + +impl RegisterArgs { + pub async fn run(self) -> CommandResult { + let settings = self.server.map(|server| ClientSettings { + api_url: format!("{server}/api"), + identity_url: format!("{server}/identity"), + ..Default::default() + }); + let client = bitwarden_core::Client::new(settings); + + let email = text_prompt_when_none("Email", self.email)?; + let password = Password::new("Password").prompt()?; + + client + .auth() + .register(&RegisterRequest { + email, + name: self.name, + password, + password_hint: self.password_hint, + }) + .await?; + + Ok("Successfully registered!".into()) + } +} diff --git a/crates/bw/src/command.rs b/crates/bw/src/command.rs new file mode 100644 index 000000000..c3d3f2501 --- /dev/null +++ b/crates/bw/src/command.rs @@ -0,0 +1,204 @@ +use bitwarden_cli::Color; +use clap::{Args, Parser, Subcommand}; + +use crate::{ + admin_console::ConfirmCommand, + auth::{LoginArgs, RegisterArgs}, + platform::ConfigCommand, + render::Output, + tools::GeneratorCommands, + vault::{ItemCommands, TemplateCommands}, +}; + +pub const SESSION_ENV: &str = "BW_SESSION"; + +#[derive(Parser, Clone)] +#[command(name = "Bitwarden CLI", version, about = "Bitwarden CLI", long_about = None, disable_version_flag = true)] +pub struct Cli { + // Optional as a workaround for https://github.com/clap-rs/clap/issues/3572 + #[command(subcommand)] + pub command: Option, + + #[arg(short = 'o', long, global = true, value_enum, default_value_t = Output::JSON)] + pub output: Output, + + #[arg(short = 'c', long, global = true, value_enum, default_value_t = Color::Auto)] + pub color: Color, + + // TODO(CLI): Pretty/raw/response options + #[arg( + long, + global = true, + env = SESSION_ENV, + help = "The session key used to decrypt your vault data. Can be obtained with `bw login` or `bw unlock`." + )] + pub session: Option, + + #[arg( + long, + global = true, + help = "Exit with a success exit code (0) unless an error is thrown." + )] + pub cleanexit: bool, + + #[arg( + short = 'q', + long, + global = true, + help = "Don't return anything to stdout." + )] + pub quiet: bool, + + #[arg( + long, + global = true, + help = "Do not prompt for interactive user input." + )] + pub nointeraction: bool, + + // Clap uses uppercase V for the short flag by default, but we want lowercase v + // for compatibility with the node CLI: + // https://github.com/clap-rs/clap/issues/138 + #[arg(short = 'v', long, action = clap::builder::ArgAction::Version)] + pub version: (), +} + +#[derive(Subcommand, Clone)] +pub enum Commands { + // Auth commands + #[command(long_about = "Log into a user account.")] + Login(LoginArgs), + + #[command(long_about = "Log out of the current user account.")] + Logout, + + #[command(long_about = "Register a new user account.")] + Register(RegisterArgs), + + // KM commands + #[command(long_about = "Unlock the vault and return a session key.")] + Unlock(UnlockArgs), + + // Platform commands + #[command(long_about = "Pull the latest vault data from server.")] + Sync { + #[arg(short = 'f', long, help = "Force a full sync.")] + force: bool, + + #[arg(long, help = "Get the last sync date.")] + last: bool, + }, + + #[command(long_about = "Base 64 encode stdin.")] + Encode, + + #[command(long_about = "Configure CLI settings.")] + Config { + #[command(subcommand)] + command: ConfigCommand, + }, + + #[command(long_about = "Check for updates.")] + Update { + #[arg(long, help = "Return only the download URL for the update.")] + raw: bool, + }, + + #[command(long_about = "Generate shell completions.")] + Completion { + #[arg(long, help = "The shell to generate completions for.")] + shell: Option, + }, + + #[command( + long_about = "Show server, last sync, user information, and vault status.", + after_help = r#"Example return value: + { + "serverUrl": "https://bitwarden.example.com", + "lastSync": "2020-06-16T06:33:51.419Z", + "userEmail": "user@example.com", + "userId": "00000000-0000-0000-0000-000000000000", + "status": "locked" + } + +Notes: + `status` is one of: + - `unauthenticated` when you are not logged in + - `locked` when you are logged in and the vault is locked + - `unlocked` when you are logged in and the vault is unlocked +"# + )] + Status, + + // Vault commands + #[command(long_about = "Manage vault objects.")] + Item { + #[command(subcommand)] + command: ItemCommands, + }, + #[command(long_about = "Get the available templates")] + Template { + #[command(subcommand)] + command: TemplateCommands, + }, + + // These are the old style action-name commands, to be replaced by name-action commands in the + // future + #[command(long_about = "List an array of objects from the vault.")] + List, + #[command(long_about = "Get an object from the vault.")] + Get, + #[command(long_about = "Create an object in the vault.")] + Create, + #[command(long_about = "Edit an object from the vault.")] + Edit, + #[command(long_about = "Delete an object from the vault.")] + Delete, + #[command(long_about = "Restores an object from the trash.")] + Restore, + #[command(long_about = "Move an item to an organization.")] + Move, + + // Admin console commands + #[command(long_about = "Confirm an object to the organization.")] + Confirm { + #[command(subcommand)] + command: ConfirmCommand, + }, + + // Tools commands + #[command(long_about = "Generate a password/passphrase.")] + Generate { + #[command(subcommand)] + command: GeneratorCommands, + }, + #[command(long_about = "Import vault data from a file.")] + Import, + #[command(long_about = "Export vault data to a CSV, JSON or ZIP file.")] + Export, + #[command(long_about = "--DEPRECATED-- Move an item to an organization.")] + Share, + #[command( + long_about = "Work with Bitwarden sends. A Send can be quickly created using this command or subcommands can be used to fine-tune the Send." + )] + Send, + #[command(long_about = "Access a Bitwarden Send from a url.")] + Receive, +} + +#[derive(Args, Clone)] +pub struct UnlockArgs { + pub password: Option, + + #[arg(long, help = "Environment variable storing your password.")] + pub passwordenv: Option, + + #[arg( + long, + help = "Path to a file containing your password as its first line." + )] + pub passwordfile: Option, + + #[arg(long, help = "Only return the session key.")] + pub raw: bool, +} diff --git a/crates/bw/src/main.rs b/crates/bw/src/main.rs index 1c68fd67e..71ac6a6e5 100644 --- a/crates/bw/src/main.rs +++ b/crates/bw/src/main.rs @@ -1,248 +1,118 @@ #![doc = include_str!("../README.md")] -use bitwarden_cli::{install_color_eyre, text_prompt_when_none, Color}; -use bitwarden_core::{auth::RegisterRequest, ClientSettings}; -use bitwarden_generators::{ - GeneratorClientsExt, PassphraseGeneratorRequest, PasswordGeneratorRequest, -}; -use clap::{command, Args, CommandFactory, Parser, Subcommand}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use bitwarden_cli::install_color_eyre; +use clap::{CommandFactory, Parser}; +use clap_complete::Shell; use color_eyre::eyre::Result; -use inquire::Password; -use render::Output; +use env_logger::Target; +use crate::{command::*, render::CommandResult}; + +mod admin_console; mod auth; +mod command; +mod platform; mod render; +mod tools; +mod vault; -#[derive(Parser, Clone)] -#[command(name = "Bitwarden CLI", version, about = "Bitwarden 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)] - output: Output, - - #[arg(short = 'c', long, global = true, value_enum, default_value_t = Color::Auto)] - color: Color, -} - -#[derive(Subcommand, Clone)] -enum Commands { - Login(LoginArgs), - - #[command(long_about = "Register")] - Register { - #[arg(short = 'e', long, help = "Email address")] - email: Option, - - name: Option, - - password_hint: Option, - - #[arg(short = 's', long, global = true, help = "Server URL")] - server: Option, - }, - - #[command(long_about = "Manage vault items")] - Item { - #[command(subcommand)] - command: ItemCommands, - }, - - #[command(long_about = "Pull the latest vault data from the server")] - Sync {}, - - #[command(long_about = "Password and passphrase generators")] - Generate { - #[command(subcommand)] - command: GeneratorCommands, - }, -} - -#[derive(Args, Clone)] -struct LoginArgs { - #[command(subcommand)] - command: LoginCommands, +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<()> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .target(Target::Stderr) + .init(); - #[arg(short = 's', long, global = true, help = "Server URL")] - server: Option, -} + let cli = Cli::parse(); + install_color_eyre(cli.color)?; + let render_config = render::RenderConfig::new(&cli); -#[derive(Subcommand, Clone)] -enum LoginCommands { - Password { - #[arg(short = 'e', long, help = "Email address")] - email: Option, - }, - ApiKey { - client_id: Option, - client_secret: Option, - }, - Device { - #[arg(short = 'e', long, help = "Email address")] - email: Option, - device_identifier: Option, - }, -} + let Some(command) = cli.command else { + let mut cmd = Cli::command(); + cmd.print_help()?; + return Ok(()); + }; -#[derive(Subcommand, Clone)] -enum ItemCommands { - Get { id: String }, - Create {}, -} + let result = process_commands(command, cli.session).await; -#[derive(Subcommand, Clone)] -enum GeneratorCommands { - Password(PasswordGeneratorArgs), - Passphrase(PassphraseGeneratorArgs), + // Render the result of the command + render_config.render_result(result) } -#[derive(Args, Clone)] -struct PasswordGeneratorArgs { - #[arg(short = 'l', long, action, help = "Include lowercase characters (a-z)")] - lowercase: bool, - - #[arg(short = 'u', long, action, help = "Include uppercase characters (A-Z)")] - uppercase: bool, +async fn process_commands(command: Commands, _session: Option) -> CommandResult { + // Try to initialize the client with the session if provided + // Ideally we'd have separate clients and this would be an enum, something like: + // enum CliClient { + // Unlocked(_), // If the user already logged in and the provided session is valid + // Locked(_), // If the user is logged in, but the session hasn't been provided + // LoggedOut(_), // If the user is not logged in + // } + // If the session was invalid, we'd just return an error immediately + // This would allow each command to match on the client type that they need, and we don't need + // to do two matches over the whole command tree + let client = bitwarden_core::Client::new(None); - #[arg(short = 'n', long, action, help = "Include numbers (0-9)")] - numbers: bool, + match command { + // Auth commands + Commands::Login(args) => args.run().await, + Commands::Logout => todo!(), + Commands::Register(register) => register.run().await, - #[arg( - short = 's', - long, - action, - help = "Include special characters (!@#$%^&*)" - )] - special: bool, + // KM commands + Commands::Unlock(_args) => todo!(), - #[arg(long, default_value = "16", help = "Length of generated password")] - length: u8, -} + // Platform commands + Commands::Sync { .. } => todo!(), -#[derive(Args, Clone)] -struct PassphraseGeneratorArgs { - #[arg(long, default_value = "3", help = "Number of words in the passphrase")] - words: u8, - #[arg(long, default_value = " ", help = "Separator between words")] - separator: char, - #[arg(long, action, help = "Capitalize the first letter of each word")] - capitalize: bool, - #[arg(long, action, help = "Include a number in one of the words")] - include_number: bool, -} - -#[tokio::main(flavor = "current_thread")] -async fn main() -> Result<()> { - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + Commands::Encode => { + let input = std::io::read_to_string(std::io::stdin())?; + let encoded = STANDARD.encode(input); + Ok(encoded.into()) + } - process_commands().await -} + Commands::Config { command } => command.run().await, -async fn process_commands() -> Result<()> { - let cli = Cli::parse(); + Commands::Update { .. } => todo!(), - install_color_eyre(cli.color)?; + Commands::Completion { shell } => { + let Some(shell) = shell.or_else(Shell::from_env) else { + return Ok( + "Couldn't autodetect a valid shell. Run `bw completion --help` for more info." + .into(), + ); + }; - let Some(command) = cli.command else { - let mut cmd = Cli::command(); - cmd.print_help()?; - return Ok(()); - }; - - match command.clone() { - Commands::Login(args) => { - let settings = args.server.map(|server| ClientSettings { - api_url: format!("{server}/api"), - identity_url: format!("{server}/identity"), - ..Default::default() - }); - let client = bitwarden_core::Client::new(settings); - - match args.command { - // FIXME: Rust CLI will not support password login! - LoginCommands::Password { email } => { - auth::login_password(client, email).await?; - } - LoginCommands::ApiKey { - client_id, - client_secret, - } => auth::login_api_key(client, client_id, client_secret).await?, - LoginCommands::Device { - email, - device_identifier, - } => { - auth::login_device(client, email, device_identifier).await?; - } - } - return Ok(()); - } - Commands::Register { - email, - name, - password_hint, - server, - } => { - let settings = server.map(|server| ClientSettings { - api_url: format!("{server}/api"), - identity_url: format!("{server}/identity"), - ..Default::default() - }); - let client = bitwarden_core::Client::new(settings); - - let email = text_prompt_when_none("Email", email)?; - let password = Password::new("Password").prompt()?; - - client - .auth() - .register(&RegisterRequest { - email, - name, - password, - password_hint, - }) - .await?; + let mut cmd = Cli::command(); + let name = cmd.get_name().to_string(); + clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout()); + Ok(().into()) } - _ => {} - } - // Not login, assuming we have a config - let client = bitwarden_core::Client::new(None); + Commands::Status => todo!(), - // And finally we process all the commands which require authentication - match command { - Commands::Login(_) => unreachable!(), - Commands::Register { .. } => unreachable!(), + // Vault commands Commands::Item { command: _ } => todo!(), - Commands::Sync {} => todo!(), - Commands::Generate { command } => match command { - GeneratorCommands::Password(args) => { - let password = client.generator().password(PasswordGeneratorRequest { - lowercase: args.lowercase, - uppercase: args.uppercase, - numbers: args.numbers, - special: args.special, - length: args.length, - ..Default::default() - })?; - - println!("{password}"); - } - GeneratorCommands::Passphrase(args) => { - let passphrase = client.generator().passphrase(PassphraseGeneratorRequest { - num_words: args.words, - word_separator: args.separator.to_string(), - capitalize: args.capitalize, - include_number: args.include_number, - })?; - - println!("{passphrase}"); - } - }, - }; - - Ok(()) + Commands::Template { command } => command.run(), + + Commands::List => todo!(), + Commands::Get => todo!(), + Commands::Create => todo!(), + Commands::Edit => todo!(), + Commands::Delete => todo!(), + Commands::Restore => todo!(), + Commands::Move => todo!(), + + // Admin console commands + Commands::Confirm { .. } => todo!(), + + // Tools commands + Commands::Generate { command } => command.run(&client), + Commands::Import => todo!(), + Commands::Export => todo!(), + Commands::Share => todo!(), + Commands::Send => todo!(), + Commands::Receive => todo!(), + } } #[cfg(test)] diff --git a/crates/bw/src/platform/mod.rs b/crates/bw/src/platform/mod.rs new file mode 100644 index 000000000..88dee2897 --- /dev/null +++ b/crates/bw/src/platform/mod.rs @@ -0,0 +1,52 @@ +use clap::Subcommand; + +use crate::render::CommandResult; + +#[derive(Subcommand, Clone)] +pub enum ConfigCommand { + Server { + base_url: Option, + + #[arg( + long, + help = "Provides a custom web vault URL that differs from the base URL." + )] + web_vault: Option, + + #[arg( + long, + help = "Provides a custom API URL that differs from the base URL." + )] + api: Option, + #[arg( + long, + help = "Provides a custom identity URL that differs from the base URL." + )] + identity: Option, + #[arg( + long, + help = "Provides a custom icons service URL that differs from the base URL." + )] + icons: Option, + #[arg( + long, + help = "Provides a custom notifications URL that differs from the base URL." + )] + notifications: Option, + #[arg( + long, + help = "Provides a custom events URL that differs from the base URL." + )] + events: Option, + + #[arg(long, help = "Provides the URL for your Key Connector server.")] + key_connector: Option, + }, +} + +impl ConfigCommand { + #[allow(clippy::unused_async)] + pub async fn run(self) -> CommandResult { + todo!() + } +} diff --git a/crates/bw/src/render.rs b/crates/bw/src/render.rs index da8ed4997..672f5d479 100644 --- a/crates/bw/src/render.rs +++ b/crates/bw/src/render.rs @@ -1,5 +1,8 @@ +use bitwarden_cli::Color; use clap::ValueEnum; +use crate::command::Cli; + #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] #[allow(clippy::upper_case_acronyms)] pub(crate) enum Output { @@ -9,3 +12,100 @@ pub(crate) enum Output { TSV, None, } + +pub enum CommandOutput { + Plain(String), + Object(Box), +} +pub type CommandResult = color_eyre::eyre::Result; + +impl From<&str> for CommandOutput { + fn from(text: &str) -> Self { + CommandOutput::Plain(text.to_owned()) + } +} +impl From for CommandOutput { + fn from(text: String) -> Self { + CommandOutput::Plain(text) + } +} +impl From<()> for CommandOutput { + fn from(_: ()) -> Self { + CommandOutput::Plain(String::new()) + } +} + +pub struct RenderConfig { + pub output: Output, + pub color: Color, + pub cleanexit: bool, + pub quiet: bool, +} + +impl RenderConfig { + pub fn new(cli: &Cli) -> Self { + Self { + output: cli.output, + color: cli.color, + cleanexit: cli.cleanexit, + quiet: cli.quiet, + } + } + + pub fn render_result(&self, result: CommandResult) -> color_eyre::eyre::Result<()> { + if self.quiet || self.output == Output::None { + return Ok(()); + } + + fn pretty_print(language: &str, data: &str, color: Color) { + if color.is_enabled() { + bat::PrettyPrinter::new() + .input_from_bytes(data.as_bytes()) + .language(language) + .print() + .expect("Input is valid"); + } else { + print!("{}", data); + } + } + + match result { + // Errors will be passed through to the caller, and rendered by the main function + Err(e) => Err(e), + + // With cleanexit, we don't print anything on success + Ok(_) if self.cleanexit => Ok(()), + + // Plain text is just output as is + Ok(CommandOutput::Plain(text)) => { + println!("{}", text); + Ok(()) + } + + // For objects, we serialize them based on the output format, + Ok(CommandOutput::Object(obj)) => { + match self.output { + Output::JSON => { + let mut json = serde_json::to_string_pretty(&*obj)?; + // Yaml/table/tsv serializations add a newline at the end, so we do the same + // here for consistency + json.push('\n'); + pretty_print("json", &json, self.color); + } + Output::YAML => { + let yaml = serde_yaml::to_string(&*obj)?; + pretty_print("yaml", &yaml, self.color); + } + Output::Table => { + todo!() + } + Output::TSV => { + todo!() + } + Output::None => unreachable!(), + } + Ok(()) + } + } + } +} diff --git a/crates/bw/src/tools/mod.rs b/crates/bw/src/tools/mod.rs new file mode 100644 index 000000000..829c71c28 --- /dev/null +++ b/crates/bw/src/tools/mod.rs @@ -0,0 +1,81 @@ +use bitwarden_core::Client; +use bitwarden_generators::{ + GeneratorClientsExt, PassphraseGeneratorRequest, PasswordGeneratorRequest, +}; +use clap::{Args, Subcommand}; + +use crate::render::CommandResult; + +// TODO(CLI): This is incompatible with the current node CLI +#[derive(Subcommand, Clone)] +pub enum GeneratorCommands { + Password(PasswordGeneratorArgs), + Passphrase(PassphraseGeneratorArgs), +} + +impl GeneratorCommands { + pub fn run(&self, client: &Client) -> CommandResult { + match self { + GeneratorCommands::Password(args) => { + let password = client.generator().password(PasswordGeneratorRequest { + lowercase: args.lowercase, + uppercase: args.uppercase, + numbers: args.numbers, + special: args.special, + length: args.length, + ..Default::default() + })?; + + Ok(password.into()) + } + GeneratorCommands::Passphrase(args) => { + let passphrase = client.generator().passphrase(PassphraseGeneratorRequest { + num_words: args.words, + word_separator: args.separator.to_string(), + capitalize: args.capitalize, + include_number: args.include_number, + })?; + + Ok(passphrase.into()) + } + } + } +} + +#[derive(Args, Clone)] +pub struct PasswordGeneratorArgs { + #[arg(short = 'l', long, action, help = "Include lowercase characters (a-z)")] + pub lowercase: bool, + + #[arg(short = 'u', long, action, help = "Include uppercase characters (A-Z)")] + pub uppercase: bool, + + #[arg(short = 'n', long, action, help = "Include numbers (0-9)")] + pub numbers: bool, + + #[arg( + short = 's', + long, + action, + help = "Include special characters (!@#$%^&*)" + )] + pub special: bool, + + #[arg(long, default_value = "16", help = "Length of generated password")] + pub length: u8, +} + +#[derive(Args, Clone)] +pub struct PassphraseGeneratorArgs { + #[arg(long, default_value = "3", help = "Number of words in the passphrase")] + pub words: u8, + + #[arg(long, default_value = " ", help = "Separator between words")] + pub separator: char, + + #[arg(long, action, help = "Capitalize the first letter of each word")] + pub capitalize: bool, + + #[arg(long, action, help = "Include a number in one of the words")] + pub include_number: bool, +} diff --git a/crates/bw/src/vault/mod.rs b/crates/bw/src/vault/mod.rs new file mode 100644 index 000000000..0a3106c90 --- /dev/null +++ b/crates/bw/src/vault/mod.rs @@ -0,0 +1,58 @@ +use clap::Subcommand; + +use crate::render::{CommandOutput, CommandResult}; + +#[derive(Subcommand, Clone)] +pub enum ItemCommands { + Get { id: String }, + Create {}, +} + +#[derive(Subcommand, Clone)] + +pub enum TemplateCommands { + #[command(name = "item")] + Item, + #[command(name = "item.field")] + ItemField, + #[command(name = "item.login")] + ItemLogin, + #[command(name = "item.login.uri")] + ItemLoginUri, + #[command(name = "item.card")] + ItemCard, + #[command(name = "item.identity")] + ItemIdentity, + #[command(name = "item.securenote")] + ItemSecureNote, + #[command(name = "folder")] + Folder, + #[command(name = "collection")] + Collection, + #[command(name = "item-collections")] + ItemCollections, + #[command(name = "org-collection")] + OrgCollection, + #[command(name = "send.text")] + SendText, + #[command(name = "send.file")] + SendFile, +} + +impl TemplateCommands { + pub fn run(&self) -> CommandResult { + match self { + Self::Folder => { + #[derive(serde::Serialize)] + struct FolderTemplate { + name: String, + } + + Ok(CommandOutput::Object(Box::new(FolderTemplate { + name: "Folder name".to_string(), + }))) + } + _ => todo!(), + } + } +}