bw/
main.rs

1#![doc = include_str!("../README.md")]
2
3use bitwarden_cli::{install_color_eyre, text_prompt_when_none, Color};
4use bitwarden_core::{auth::RegisterRequest, ClientSettings};
5use bitwarden_generators::{
6    GeneratorClientsExt, PassphraseGeneratorRequest, PasswordGeneratorRequest,
7};
8use clap::{command, Args, CommandFactory, Parser, Subcommand};
9use color_eyre::eyre::Result;
10use inquire::Password;
11use render::Output;
12
13mod auth;
14mod render;
15
16#[derive(Parser, Clone)]
17#[command(name = "Bitwarden CLI", version, about = "Bitwarden CLI", long_about = None)]
18struct Cli {
19    // Optional as a workaround for https://github.com/clap-rs/clap/issues/3572
20    #[command(subcommand)]
21    command: Option<Commands>,
22
23    #[arg(short = 'o', long, global = true, value_enum, default_value_t = Output::JSON)]
24    output: Output,
25
26    #[arg(short = 'c', long, global = true, value_enum, default_value_t = Color::Auto)]
27    color: Color,
28}
29
30#[derive(Subcommand, Clone)]
31enum Commands {
32    Login(LoginArgs),
33
34    #[command(long_about = "Register")]
35    Register {
36        #[arg(short = 'e', long, help = "Email address")]
37        email: Option<String>,
38
39        name: Option<String>,
40
41        password_hint: Option<String>,
42
43        #[arg(short = 's', long, global = true, help = "Server URL")]
44        server: Option<String>,
45    },
46
47    #[command(long_about = "Manage vault items")]
48    Item {
49        #[command(subcommand)]
50        command: ItemCommands,
51    },
52
53    #[command(long_about = "Pull the latest vault data from the server")]
54    Sync {},
55
56    #[command(long_about = "Password and passphrase generators")]
57    Generate {
58        #[command(subcommand)]
59        command: GeneratorCommands,
60    },
61}
62
63#[derive(Args, Clone)]
64struct LoginArgs {
65    #[command(subcommand)]
66    command: LoginCommands,
67
68    #[arg(short = 's', long, global = true, help = "Server URL")]
69    server: Option<String>,
70}
71
72#[derive(Subcommand, Clone)]
73enum LoginCommands {
74    Password {
75        #[arg(short = 'e', long, help = "Email address")]
76        email: Option<String>,
77    },
78    ApiKey {
79        client_id: Option<String>,
80        client_secret: Option<String>,
81    },
82    Device {
83        #[arg(short = 'e', long, help = "Email address")]
84        email: Option<String>,
85        device_identifier: Option<String>,
86    },
87}
88
89#[derive(Subcommand, Clone)]
90enum ItemCommands {
91    Get { id: String },
92    Create {},
93}
94
95#[derive(Subcommand, Clone)]
96enum GeneratorCommands {
97    Password(PasswordGeneratorArgs),
98    Passphrase(PassphraseGeneratorArgs),
99}
100
101#[derive(Args, Clone)]
102struct PasswordGeneratorArgs {
103    #[arg(short = 'l', long, action, help = "Include lowercase characters (a-z)")]
104    lowercase: bool,
105
106    #[arg(short = 'u', long, action, help = "Include uppercase characters (A-Z)")]
107    uppercase: bool,
108
109    #[arg(short = 'n', long, action, help = "Include numbers (0-9)")]
110    numbers: bool,
111
112    #[arg(
113        short = 's',
114        long,
115        action,
116        help = "Include special characters (!@#$%^&*)"
117    )]
118    special: bool,
119
120    #[arg(long, default_value = "16", help = "Length of generated password")]
121    length: u8,
122}
123
124#[derive(Args, Clone)]
125struct PassphraseGeneratorArgs {
126    #[arg(long, default_value = "3", help = "Number of words in the passphrase")]
127    words: u8,
128    #[arg(long, default_value = " ", help = "Separator between words")]
129    separator: char,
130    #[arg(long, action, help = "Capitalize the first letter of each word")]
131    capitalize: bool,
132    #[arg(long, action, help = "Include a number in one of the words")]
133    include_number: bool,
134}
135
136#[tokio::main(flavor = "current_thread")]
137async fn main() -> Result<()> {
138    env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
139
140    process_commands().await
141}
142
143async fn process_commands() -> Result<()> {
144    let cli = Cli::parse();
145
146    install_color_eyre(cli.color)?;
147
148    let Some(command) = cli.command else {
149        let mut cmd = Cli::command();
150        cmd.print_help()?;
151        return Ok(());
152    };
153
154    match command.clone() {
155        Commands::Login(args) => {
156            let settings = args.server.map(|server| ClientSettings {
157                api_url: format!("{}/api", server),
158                identity_url: format!("{}/identity", server),
159                ..Default::default()
160            });
161            let client = bitwarden_core::Client::new(settings);
162
163            match args.command {
164                // FIXME: Rust CLI will not support password login!
165                LoginCommands::Password { email } => {
166                    auth::login_password(client, email).await?;
167                }
168                LoginCommands::ApiKey {
169                    client_id,
170                    client_secret,
171                } => auth::login_api_key(client, client_id, client_secret).await?,
172                LoginCommands::Device {
173                    email,
174                    device_identifier,
175                } => {
176                    auth::login_device(client, email, device_identifier).await?;
177                }
178            }
179            return Ok(());
180        }
181        Commands::Register {
182            email,
183            name,
184            password_hint,
185            server,
186        } => {
187            let settings = server.map(|server| ClientSettings {
188                api_url: format!("{}/api", server),
189                identity_url: format!("{}/identity", server),
190                ..Default::default()
191            });
192            let client = bitwarden_core::Client::new(settings);
193
194            let email = text_prompt_when_none("Email", email)?;
195            let password = Password::new("Password").prompt()?;
196
197            client
198                .auth()
199                .register(&RegisterRequest {
200                    email,
201                    name,
202                    password,
203                    password_hint,
204                })
205                .await?;
206        }
207        _ => {}
208    }
209
210    // Not login, assuming we have a config
211    let client = bitwarden_core::Client::new(None);
212
213    // And finally we process all the commands which require authentication
214    match command {
215        Commands::Login(_) => unreachable!(),
216        Commands::Register { .. } => unreachable!(),
217        Commands::Item { command: _ } => todo!(),
218        Commands::Sync {} => todo!(),
219        Commands::Generate { command } => match command {
220            GeneratorCommands::Password(args) => {
221                let password = client.generator().password(PasswordGeneratorRequest {
222                    lowercase: args.lowercase,
223                    uppercase: args.uppercase,
224                    numbers: args.numbers,
225                    special: args.special,
226                    length: args.length,
227                    ..Default::default()
228                })?;
229
230                println!("{}", password);
231            }
232            GeneratorCommands::Passphrase(args) => {
233                let passphrase = client.generator().passphrase(PassphraseGeneratorRequest {
234                    num_words: args.words,
235                    word_separator: args.separator.to_string(),
236                    capitalize: args.capitalize,
237                    include_number: args.include_number,
238                })?;
239
240                println!("{}", passphrase);
241            }
242        },
243    };
244
245    Ok(())
246}
247
248#[cfg(test)]
249mod tests {
250    #[test]
251    fn it_works() {
252        assert_eq!(2 + 2, 4);
253    }
254}