summaryrefslogtreecommitdiffstats
path: root/crates/atuin/src/command
diff options
context:
space:
mode:
Diffstat (limited to 'crates/atuin/src/command')
l---------crates/atuin/src/command/CONTRIBUTORS1
-rw-r--r--crates/atuin/src/command/client.rs144
-rw-r--r--crates/atuin/src/command/client/account.rs47
-rw-r--r--crates/atuin/src/command/client/account/change_password.rs57
-rw-r--r--crates/atuin/src/command/client/account/delete.rs30
-rw-r--r--crates/atuin/src/command/client/account/login.rs177
-rw-r--r--crates/atuin/src/command/client/account/logout.rs19
-rw-r--r--crates/atuin/src/command/client/account/register.rs55
-rw-r--r--crates/atuin/src/command/client/default_config.rs5
-rw-r--r--crates/atuin/src/command/client/doctor.rs346
-rw-r--r--crates/atuin/src/command/client/dotfiles.rs22
-rw-r--r--crates/atuin/src/command/client/dotfiles/alias.rs95
-rw-r--r--crates/atuin/src/command/client/history.rs556
-rw-r--r--crates/atuin/src/command/client/import.rs168
-rw-r--r--crates/atuin/src/command/client/info.rs31
-rw-r--r--crates/atuin/src/command/client/init.rs145
-rw-r--r--crates/atuin/src/command/client/init/bash.rs26
-rw-r--r--crates/atuin/src/command/client/init/fish.rs45
-rw-r--r--crates/atuin/src/command/client/init/xonsh.rs31
-rw-r--r--crates/atuin/src/command/client/init/zsh.rs39
-rw-r--r--crates/atuin/src/command/client/kv.rs96
-rw-r--r--crates/atuin/src/command/client/search.rs307
-rw-r--r--crates/atuin/src/command/client/search/cursor.rs333
-rw-r--r--crates/atuin/src/command/client/search/duration.rs65
-rw-r--r--crates/atuin/src/command/client/search/engines.rs46
-rw-r--r--crates/atuin/src/command/client/search/engines/db.rs33
-rw-r--r--crates/atuin/src/command/client/search/engines/skim.rs166
-rw-r--r--crates/atuin/src/command/client/search/history_list.rs221
-rw-r--r--crates/atuin/src/command/client/search/inspector.rs259
-rw-r--r--crates/atuin/src/command/client/search/interactive.rs1309
-rw-r--r--crates/atuin/src/command/client/search/sort.rs46
-rw-r--r--crates/atuin/src/command/client/stats.rs437
-rw-r--r--crates/atuin/src/command/client/store.rs105
-rw-r--r--crates/atuin/src/command/client/store/pull.rs78
-rw-r--r--crates/atuin/src/command/client/store/purge.rs26
-rw-r--r--crates/atuin/src/command/client/store/push.rs96
-rw-r--r--crates/atuin/src/command/client/store/rebuild.rs68
-rw-r--r--crates/atuin/src/command/client/store/rekey.rs64
-rw-r--r--crates/atuin/src/command/client/store/verify.rs26
-rw-r--r--crates/atuin/src/command/client/sync.rs131
-rw-r--r--crates/atuin/src/command/client/sync/status.rs51
-rw-r--r--crates/atuin/src/command/contributors.rs5
-rw-r--r--crates/atuin/src/command/gen_completions.rs84
-rw-r--r--crates/atuin/src/command/mod.rs65
-rw-r--r--crates/atuin/src/command/server.rs61
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(&current_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,