diff options
Diffstat (limited to 'crates/atuin/src/command')
45 files changed, 6217 insertions, 0 deletions
diff --git a/crates/atuin/src/command/CONTRIBUTORS b/crates/atuin/src/command/CONTRIBUTORS new file mode 120000 index 00000000..1ca4115a --- /dev/null +++ b/crates/atuin/src/command/CONTRIBUTORS @@ -0,0 +1 @@ +../../../../CONTRIBUTORS
\ No newline at end of file diff --git a/crates/atuin/src/command/client.rs b/crates/atuin/src/command/client.rs new file mode 100644 index 00000000..23040695 --- /dev/null +++ b/crates/atuin/src/command/client.rs @@ -0,0 +1,144 @@ +use std::path::PathBuf; + +use clap::Subcommand; +use eyre::{Result, WrapErr}; + +use atuin_client::{database::Sqlite, record::sqlite_store::SqliteStore, settings::Settings}; +use env_logger::Builder; + +#[cfg(feature = "sync")] +mod sync; + +#[cfg(feature = "sync")] +mod account; + +mod default_config; +mod doctor; +mod dotfiles; +mod history; +mod import; +mod info; +mod init; +mod kv; +mod search; +mod stats; +mod store; + +#[derive(Subcommand, Debug)] +#[command(infer_subcommands = true)] +pub enum Cmd { + /// Manipulate shell history + #[command(subcommand)] + History(history::Cmd), + + /// Import shell history from file + #[command(subcommand)] + Import(import::Cmd), + + /// Calculate statistics for your history + Stats(stats::Cmd), + + /// Interactive history search + Search(search::Cmd), + + #[cfg(feature = "sync")] + #[command(flatten)] + Sync(sync::Cmd), + + /// Manage your sync account + #[cfg(feature = "sync")] + Account(account::Cmd), + + /// Get or set small key-value pairs + #[command(subcommand)] + Kv(kv::Cmd), + + /// Manage the atuin data store + #[command(subcommand)] + Store(store::Cmd), + + /// Manage your dotfiles with Atuin + #[command(subcommand)] + Dotfiles(dotfiles::Cmd), + + /// Print Atuin's shell init script + #[command()] + Init(init::Cmd), + + /// Information about dotfiles locations and ENV vars + #[command()] + Info, + + /// Run the doctor to check for common issues + #[command()] + Doctor, + + /// Print example configuration + #[command()] + DefaultConfig, +} + +impl Cmd { + pub fn run(self) -> Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + let settings = Settings::new().wrap_err("could not load client settings")?; + let res = runtime.block_on(self.run_inner(settings)); + + runtime.shutdown_timeout(std::time::Duration::from_millis(50)); + + res + } + + async fn run_inner(self, mut settings: Settings) -> Result<()> { + Builder::new() + .filter_level(log::LevelFilter::Off) + .filter_module("sqlx_sqlite::regexp", log::LevelFilter::Off) + .parse_env("ATUIN_LOG") + .init(); + + tracing::trace!(command = ?self, "client command"); + + let db_path = PathBuf::from(settings.db_path.as_str()); + let record_store_path = PathBuf::from(settings.record_store_path.as_str()); + + let db = Sqlite::new(db_path, settings.local_timeout).await?; + let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout).await?; + + match self { + Self::History(history) => history.run(&settings, &db, sqlite_store).await, + Self::Import(import) => import.run(&db).await, + Self::Stats(stats) => stats.run(&db, &settings).await, + Self::Search(search) => search.run(db, &mut settings, sqlite_store).await, + + #[cfg(feature = "sync")] + Self::Sync(sync) => sync.run(settings, &db, sqlite_store).await, + + #[cfg(feature = "sync")] + Self::Account(account) => account.run(settings, sqlite_store).await, + + Self::Kv(kv) => kv.run(&settings, &sqlite_store).await, + + Self::Store(store) => store.run(&settings, &db, sqlite_store).await, + + Self::Dotfiles(dotfiles) => dotfiles.run(&settings, sqlite_store).await, + + Self::Init(init) => init.run(&settings).await, + + Self::Info => { + info::run(&settings); + Ok(()) + } + + Self::Doctor => doctor::run(&settings), + + Self::DefaultConfig => { + default_config::run(); + Ok(()) + } + } + } +} diff --git a/crates/atuin/src/command/client/account.rs b/crates/atuin/src/command/client/account.rs new file mode 100644 index 00000000..e31e6208 --- /dev/null +++ b/crates/atuin/src/command/client/account.rs @@ -0,0 +1,47 @@ +use clap::{Args, Subcommand}; +use eyre::Result; + +use atuin_client::record::sqlite_store::SqliteStore; +use atuin_client::settings::Settings; + +pub mod change_password; +pub mod delete; +pub mod login; +pub mod logout; +pub mod register; + +#[derive(Args, Debug)] +pub struct Cmd { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +pub enum Commands { + /// Login to the configured server + Login(login::Cmd), + + // Register a new account + Register(register::Cmd), + + /// Log out + Logout, + + /// Delete your account, and all synced data + Delete, + + /// Change your password + ChangePassword(change_password::Cmd), +} + +impl Cmd { + pub async fn run(self, settings: Settings, store: SqliteStore) -> Result<()> { + match self.command { + Commands::Login(l) => l.run(&settings, &store).await, + Commands::Register(r) => r.run(&settings).await, + Commands::Logout => logout::run(&settings), + Commands::Delete => delete::run(&settings).await, + Commands::ChangePassword(c) => c.run(&settings).await, + } + } +} diff --git a/crates/atuin/src/command/client/account/change_password.rs b/crates/atuin/src/command/client/account/change_password.rs new file mode 100644 index 00000000..3b5ad6f5 --- /dev/null +++ b/crates/atuin/src/command/client/account/change_password.rs @@ -0,0 +1,57 @@ +use clap::Parser; +use eyre::{bail, Result}; + +use atuin_client::{api_client, settings::Settings}; +use rpassword::prompt_password; + +#[derive(Parser, Debug)] +pub struct Cmd { + #[clap(long, short)] + pub current_password: Option<String>, + + #[clap(long, short)] + pub new_password: Option<String>, +} + +impl Cmd { + pub async fn run(self, settings: &Settings) -> Result<()> { + run(settings, &self.current_password, &self.new_password).await + } +} + +pub async fn run( + settings: &Settings, + current_password: &Option<String>, + new_password: &Option<String>, +) -> Result<()> { + let client = api_client::Client::new( + &settings.sync_address, + &settings.session_token, + settings.network_connect_timeout, + settings.network_timeout, + )?; + + let current_password = current_password.clone().unwrap_or_else(|| { + prompt_password("Please enter the current password: ").expect("Failed to read from input") + }); + + if current_password.is_empty() { + bail!("please provide the current password"); + } + + let new_password = new_password.clone().unwrap_or_else(|| { + prompt_password("Please enter the new password: ").expect("Failed to read from input") + }); + + if new_password.is_empty() { + bail!("please provide a new password"); + } + + client + .change_password(current_password, new_password) + .await?; + + println!("Account password successfully changed!"); + + Ok(()) +} diff --git a/crates/atuin/src/command/client/account/delete.rs b/crates/atuin/src/command/client/account/delete.rs new file mode 100644 index 00000000..3591c6f3 --- /dev/null +++ b/crates/atuin/src/command/client/account/delete.rs @@ -0,0 +1,30 @@ +use atuin_client::{api_client, settings::Settings}; +use eyre::{bail, Result}; +use std::fs::remove_file; +use std::path::PathBuf; + +pub async fn run(settings: &Settings) -> Result<()> { + let session_path = settings.session_path.as_str(); + + if !PathBuf::from(session_path).exists() { + bail!("You are not logged in"); + } + + let client = api_client::Client::new( + &settings.sync_address, + &settings.session_token, + settings.network_connect_timeout, + settings.network_timeout, + )?; + + client.delete().await?; + + // Fixes stale session+key when account is deleted via CLI. + if PathBuf::from(session_path).exists() { + remove_file(PathBuf::from(session_path))?; + } + + println!("Your account is deleted"); + + Ok(()) +} diff --git a/crates/atuin/src/command/client/account/login.rs b/crates/atuin/src/command/client/account/login.rs new file mode 100644 index 00000000..9cd53399 --- /dev/null +++ b/crates/atuin/src/command/client/account/login.rs @@ -0,0 +1,177 @@ +use std::{io, path::PathBuf}; + +use clap::Parser; +use eyre::{bail, Context, Result}; +use tokio::{fs::File, io::AsyncWriteExt}; + +use atuin_client::{ + api_client, + encryption::{decode_key, encode_key, load_key, new_key, Key}, + record::sqlite_store::SqliteStore, + record::store::Store, + settings::Settings, +}; +use atuin_common::api::LoginRequest; +use rpassword::prompt_password; + +#[derive(Parser, Debug)] +pub struct Cmd { + #[clap(long, short)] + pub username: Option<String>, + + #[clap(long, short)] + pub password: Option<String>, + + /// The encryption key for your account + #[clap(long, short)] + pub key: Option<String>, +} + +fn get_input() -> Result<String> { + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + Ok(input.trim_end_matches(&['\r', '\n'][..]).to_string()) +} + +impl Cmd { + pub async fn run(&self, settings: &Settings, store: &SqliteStore) -> Result<()> { + let session_path = settings.session_path.as_str(); + + if PathBuf::from(session_path).exists() { + println!( + "You are already logged in! Please run 'atuin logout' if you wish to login again" + ); + + return Ok(()); + } + + let username = or_user_input(&self.username, "username"); + let password = self.password.clone().unwrap_or_else(read_user_password); + + let key_path = settings.key_path.as_str(); + let key_path = PathBuf::from(key_path); + + let key = or_user_input(&self.key, "encryption key [blank to use existing key file]"); + + // if provided, the key may be EITHER base64, or a bip mnemonic + // try to normalize on base64 + let key = if key.is_empty() { + key + } else { + // try parse the key as a mnemonic... + match bip39::Mnemonic::from_phrase(&key, bip39::Language::English) { + Ok(mnemonic) => encode_key(Key::from_slice(mnemonic.entropy()))?, + Err(err) => { + if let Some(err) = err.downcast_ref::<bip39::ErrorKind>() { + match err { + // assume they copied in the base64 key + bip39::ErrorKind::InvalidWord => key, + bip39::ErrorKind::InvalidChecksum => { + bail!("key mnemonic was not valid") + } + bip39::ErrorKind::InvalidKeysize(_) + | bip39::ErrorKind::InvalidWordLength(_) + | bip39::ErrorKind::InvalidEntropyLength(_, _) => { + bail!("key was not the correct length") + } + } + } else { + // unknown error. assume they copied the base64 key + key + } + } + } + }; + + // I've simplified this a little, but it could really do with a refactor + // Annoyingly, it's also very important to get it correct + if key.is_empty() { + if key_path.exists() { + let bytes = fs_err::read_to_string(key_path) + .context("existing key file couldn't be read")?; + if decode_key(bytes).is_err() { + bail!("the key in existing key file was invalid"); + } + } else { + println!("No key file exists, creating a new"); + let _key = new_key(settings)?; + } + } else if !key_path.exists() { + if decode_key(key.clone()).is_err() { + bail!("the specified key was invalid"); + } + + let mut file = File::create(key_path).await?; + file.write_all(key.as_bytes()).await?; + } else { + // we now know that the user has logged in specifying a key, AND that the key path + // exists + + // 1. check if the saved key and the provided key match. if so, nothing to do. + // 2. if not, re-encrypt the local history and overwrite the key + let current_key: [u8; 32] = load_key(settings)?.into(); + + let encoded = key.clone(); // gonna want to save it in a bit + let new_key: [u8; 32] = decode_key(key) + .context("could not decode provided key - is not valid base64")? + .into(); + + if new_key != current_key { + println!("\nRe-encrypting local store with new key"); + + store.re_encrypt(¤t_key, &new_key).await?; + + println!("Writing new key"); + let mut file = File::create(key_path).await?; + file.write_all(encoded.as_bytes()).await?; + } + } + + let session = api_client::login( + settings.sync_address.as_str(), + LoginRequest { username, password }, + ) + .await?; + + let session_path = settings.session_path.as_str(); + let mut file = File::create(session_path).await?; + file.write_all(session.session.as_bytes()).await?; + + println!("Logged in!"); + + Ok(()) + } +} + +pub(super) fn or_user_input(value: &'_ Option<String>, name: &'static str) -> String { + value.clone().unwrap_or_else(|| read_user_input(name)) +} + +pub(super) fn read_user_password() -> String { + let password = prompt_password("Please enter password: "); + password.expect("Failed to read from input") +} + +fn read_user_input(name: &'static str) -> String { + eprint!("Please enter {name}: "); + get_input().expect("Failed to read from input") +} + +#[cfg(test)] +mod tests { + use atuin_client::encryption::Key; + + #[test] + fn mnemonic_round_trip() { + let key = Key::from([ + 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9, 3, 2, 3, 8, 4, 6, 2, 6, 4, 3, 3, 8, 3, 2, + 7, 9, 5, + ]); + let phrase = bip39::Mnemonic::from_entropy(&key, bip39::Language::English) + .unwrap() + .into_phrase(); + let mnemonic = bip39::Mnemonic::from_phrase(&phrase, bip39::Language::English).unwrap(); + assert_eq!(mnemonic.entropy(), key.as_slice()); + assert_eq!(phrase, "adapt amused able anxiety mother adapt beef gaze amount else seat alcohol cage lottery avoid scare alcohol cactus school avoid coral adjust catch pink"); + } +} diff --git a/crates/atuin/src/command/client/account/logout.rs b/crates/atuin/src/command/client/account/logout.rs new file mode 100644 index 00000000..90b49d6d --- /dev/null +++ b/crates/atuin/src/command/client/account/logout.rs @@ -0,0 +1,19 @@ +use std::path::PathBuf; + +use eyre::{Context, Result}; +use fs_err::remove_file; + +use atuin_client::settings::Settings; + +pub fn run(settings: &Settings) -> Result<()> { + let session_path = settings.session_path.as_str(); + + if PathBuf::from(session_path).exists() { + remove_file(session_path).context("Failed to remove session file")?; + println!("You have logged out!"); + } else { + println!("You are not logged in"); + } + + Ok(()) +} diff --git a/crates/atuin/src/command/client/account/register.rs b/crates/atuin/src/command/client/account/register.rs new file mode 100644 index 00000000..96b7d7d6 --- /dev/null +++ b/crates/atuin/src/command/client/account/register.rs @@ -0,0 +1,55 @@ +use clap::Parser; +use eyre::{bail, Result}; +use tokio::{fs::File, io::AsyncWriteExt}; + +use atuin_client::{api_client, settings::Settings}; + +#[derive(Parser, Debug)] +pub struct Cmd { + #[clap(long, short)] + pub username: Option<String>, + + #[clap(long, short)] + pub password: Option<String>, + + #[clap(long, short)] + pub email: Option<String>, +} + +impl Cmd { + pub async fn run(self, settings: &Settings) -> Result<()> { + run(settings, &self.username, &self.email, &self.password).await + } +} + +pub async fn run( + settings: &Settings, + username: &Option<String>, + email: &Option<String>, + password: &Option<String>, +) -> Result<()> { + use super::login::or_user_input; + println!("Registering for an Atuin Sync account"); + + let username = or_user_input(username, "username"); + let email = or_user_input(email, "email"); + + let password = password + .clone() + .unwrap_or_else(super::login::read_user_password); + + if password.is_empty() { + bail!("please provide a password"); + } + + let session = + api_client::register(settings.sync_address.as_str(), &username, &email, &password).await?; + + let path = settings.session_path.as_str(); + let mut file = File::create(path).await?; + file.write_all(session.session.as_bytes()).await?; + + let _key = atuin_client::encryption::load_key(settings)?; + + Ok(()) +} diff --git a/crates/atuin/src/command/client/default_config.rs b/crates/atuin/src/command/client/default_config.rs new file mode 100644 index 00000000..f51e45c2 --- /dev/null +++ b/crates/atuin/src/command/client/default_config.rs @@ -0,0 +1,5 @@ +use atuin_client::settings::Settings; + +pub fn run() { + println!("{}", Settings::example_config()); +} diff --git a/crates/atuin/src/command/client/doctor.rs b/crates/atuin/src/command/client/doctor.rs new file mode 100644 index 00000000..48659ed1 --- /dev/null +++ b/crates/atuin/src/command/client/doctor.rs @@ -0,0 +1,346 @@ +use std::process::Command; +use std::{env, path::PathBuf, str::FromStr}; + +use atuin_client::settings::Settings; +use atuin_common::shell::{shell_name, Shell}; +use colored::Colorize; +use eyre::Result; +use serde::{Deserialize, Serialize}; + +use sysinfo::{get_current_pid, Disks, System}; + +#[derive(Debug, Serialize, Deserialize)] +struct ShellInfo { + pub name: String, + + // best-effort, not supported on all OSes + pub default: String, + + // Detect some shell plugins that the user has installed. + // I'm just going to start with preexec/blesh + pub plugins: Vec<String>, + + // The preexec framework used in the current session, if Atuin is loaded. + pub preexec: Option<String>, +} + +impl ShellInfo { + // HACK ALERT! + // Many of the shell vars we need to detect are not exported :( + // So, we're going to run a interactive session and directly check the + // variable. There's a chance this won't work, so it should not be fatal. + // + // Every shell we support handles `shell -ic 'command'` + fn shellvar_exists(shell: &str, var: &str) -> bool { + let cmd = Command::new(shell) + .args([ + "-ic", + format!("[ -z ${var} ] || echo ATUIN_DOCTOR_ENV_FOUND").as_str(), + ]) + .output() + .map_or(String::new(), |v| { + let out = v.stdout; + String::from_utf8(out).unwrap_or_default() + }); + + cmd.contains("ATUIN_DOCTOR_ENV_FOUND") + } + + fn detect_preexec_framework(shell: &str) -> Option<String> { + if env::var("ATUIN_SESSION").ok().is_none() { + None + } else if shell.starts_with("bash") || shell == "sh" { + env::var("ATUIN_PREEXEC_BACKEND") + .ok() + .filter(|value| !value.is_empty()) + .and_then(|atuin_preexec_backend| { + atuin_preexec_backend.rfind(':').and_then(|pos_colon| { + u32::from_str(&atuin_preexec_backend[..pos_colon]) + .ok() + .is_some_and(|preexec_shlvl| { + env::var("SHLVL") + .ok() + .and_then(|shlvl| u32::from_str(&shlvl).ok()) + .is_some_and(|shlvl| shlvl == preexec_shlvl) + }) + .then(|| atuin_preexec_backend[pos_colon + 1..].to_string()) + }) + }) + } else { + Some("built-in".to_string()) + } + } + + fn validate_plugin_blesh( + _shell: &str, |