diff options
| author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-06-11 18:02:55 +0200 |
|---|---|---|
| committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-06-11 18:02:55 +0200 |
| commit | 0b6ca5cb8ca4c46265e08e13053260d9b5cff568 (patch) | |
| tree | 9dc656095f806e6dd1177e40b9a87cf6d6f10f1b /crates/turtle/src/command/client | |
| parent | chore(server): Remove the last remnants of the "hub" sync-server thingy (diff) | |
| download | atuin-0b6ca5cb8ca4c46265e08e13053260d9b5cff568.zip | |
feat(server): Make user stuff stateless
Diffstat (limited to 'crates/turtle/src/command/client')
| -rw-r--r-- | crates/turtle/src/command/client/account.rs | 47 | ||||
| -rw-r--r-- | crates/turtle/src/command/client/account/change_password.rs | 55 | ||||
| -rw-r--r-- | crates/turtle/src/command/client/account/delete.rs | 45 | ||||
| -rw-r--r-- | crates/turtle/src/command/client/account/login.rs | 201 | ||||
| -rw-r--r-- | crates/turtle/src/command/client/account/logout.rs | 5 | ||||
| -rw-r--r-- | crates/turtle/src/command/client/account/register.rs | 67 | ||||
| -rw-r--r-- | crates/turtle/src/command/client/doctor.rs | 35 | ||||
| -rw-r--r-- | crates/turtle/src/command/client/setup.rs | 81 | ||||
| -rw-r--r-- | crates/turtle/src/command/client/store/push.rs | 2 | ||||
| -rw-r--r-- | crates/turtle/src/command/client/sync.rs | 14 | ||||
| -rw-r--r-- | crates/turtle/src/command/client/sync/status.rs | 6 |
11 files changed, 18 insertions, 540 deletions
diff --git a/crates/turtle/src/command/client/account.rs b/crates/turtle/src/command/client/account.rs deleted file mode 100644 index f2ceb10b..00000000 --- a/crates/turtle/src/command/client/account.rs +++ /dev/null @@ -1,47 +0,0 @@ -use clap::{Args, Subcommand}; -use eyre::Result; - -use crate::atuin_client::record::sqlite_store::SqliteStore; -use crate::atuin_client::settings::Settings; - -pub(crate) mod change_password; -pub(crate) mod delete; -pub(crate) mod login; -pub(crate) mod logout; -pub(crate) mod register; - -#[derive(Args, Debug)] -pub(crate) struct Cmd { - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand, Debug)] -pub(crate) 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(delete::Cmd), - - /// Change your password - ChangePassword(change_password::Cmd), -} - -impl Cmd { - pub(crate) 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().await, - Commands::Delete(d) => d.run(&settings).await, - Commands::ChangePassword(c) => c.run(&settings).await, - } - } -} diff --git a/crates/turtle/src/command/client/account/change_password.rs b/crates/turtle/src/command/client/account/change_password.rs deleted file mode 100644 index b23f518d..00000000 --- a/crates/turtle/src/command/client/account/change_password.rs +++ /dev/null @@ -1,55 +0,0 @@ -use clap::Parser; -use eyre::{Result, bail}; - -use crate::atuin_client::{auth, settings::Settings}; -use rpassword::prompt_password; - -#[derive(Parser, Debug)] -pub(crate) struct Cmd { - #[clap(long, short)] - pub(crate) current_password: Option<String>, - - #[clap(long, short)] - pub(crate) new_password: Option<String>, - - /// The two-factor authentication code for your account, if any - #[clap(long, short)] - pub(crate) totp_code: Option<String>, -} - -impl Cmd { - pub(crate) async fn run(&self, settings: &Settings) -> Result<()> { - if !settings.logged_in().await? { - bail!("You are not logged in"); - } - - let client = auth::auth_client(settings).await; - - let current_password = self.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 = self.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"); - } - - let totp_code = self.totp_code.clone(); - - client - .change_password(¤t_password, &new_password, totp_code.as_deref()) - .await?; - - println!("Account password successfully changed!"); - - Ok(()) - } -} diff --git a/crates/turtle/src/command/client/account/delete.rs b/crates/turtle/src/command/client/account/delete.rs deleted file mode 100644 index 722c39ec..00000000 --- a/crates/turtle/src/command/client/account/delete.rs +++ /dev/null @@ -1,45 +0,0 @@ -use crate::atuin_client::{auth, settings::Settings}; -use clap::Parser; -use eyre::{Result, bail}; - -use super::login::read_user_password; - -#[derive(Parser, Debug)] -pub(crate) struct Cmd { - #[clap(long, short)] - pub(crate) password: Option<String>, - - /// The two-factor authentication code for your account, if any - #[clap(long, short)] - pub(crate) totp_code: Option<String>, -} - -impl Cmd { - pub(crate) async fn run(&self, settings: &Settings) -> Result<()> { - if !settings.logged_in().await? { - bail!("You are not logged in"); - } - - let client = auth::auth_client(settings).await; - - let password = self.password.clone().unwrap_or_else(read_user_password); - - if password.is_empty() { - bail!("please provide your password"); - } - - let mut totp_code = self.totp_code.clone(); - - client - .delete_account(&password, totp_code.as_deref()) - .await?; - - // Clean up sessions from meta store - let meta = Settings::meta_store().await?; - meta.delete_session().await?; - - println!("Your account is deleted"); - - Ok(()) - } -} diff --git a/crates/turtle/src/command/client/account/login.rs b/crates/turtle/src/command/client/account/login.rs deleted file mode 100644 index e9513879..00000000 --- a/crates/turtle/src/command/client/account/login.rs +++ /dev/null @@ -1,201 +0,0 @@ -use std::{io, path::PathBuf}; - -use clap::Parser; -use eyre::{Context, Result, bail}; -use tokio::{fs::File, io::AsyncWriteExt}; - -use crate::atuin_client::{ - auth, - encryption::{decode_key, load_key}, - record::sqlite_store::SqliteStore, - record::store::Store, - record::sync::{self, SyncError}, - settings::{Settings, SyncAuth}, -}; -use rpassword::prompt_password; - -#[derive(Parser, Debug)] -pub(crate) struct Cmd { - #[clap(long, short)] - pub(crate) username: Option<String>, - - #[clap(long, short)] - pub(crate) password: Option<String>, - - /// The encryption key for your account - #[clap(long, short)] - pub(crate) key: Option<String>, - - /// The two-factor authentication code for your account, if any - #[clap(long, short)] - pub(crate) totp_code: Option<String>, - - #[clap(long, hide = true)] - pub(crate) from_registration: bool, -} - -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(crate) async fn run(&self, settings: &Settings, store: &SqliteStore) -> Result<()> { - match settings.resolve_sync_auth().await { - SyncAuth::Legacy { .. } => { - println!("You are logged in to your sync server."); - println!("Run 'atuin logout' to log out."); - return Ok(()); - } - SyncAuth::NotLoggedIn { .. } => {} - } - - self.run_legacy_login(settings, store).await?; - - verify_key_against_remote(settings).await - } - - /// Legacy login: always prompt for username/password interactively - /// (or accept them via flags). - async fn run_legacy_login(&self, settings: &Settings, store: &SqliteStore) -> Result<()> { - let username = or_user_input(self.username.clone(), "username"); - let password = self.password.clone().unwrap_or_else(read_user_password); - - self.prompt_and_store_key(settings, store).await?; - - let client = auth::auth_client(settings).await; - let response = client.login(&username, &password).await?; - - Settings::meta_store() - .await? - .save_session(&response.session) - .await?; - - println!("Logged in!"); - Ok(()) - } - - async fn prompt_and_store_key(&self, settings: &Settings, store: &SqliteStore) -> Result<()> { - let key_path = settings.key_path.as_str(); - let key_path = PathBuf::from(key_path); - - println!("IMPORTANT"); - println!( - "If you are already logged in on another machine, you must ensure that the key you use here is the same as the key you used there." - ); - println!("You can find your key by running 'atuin key' on the other machine."); - println!("Do not share this key with anyone."); - println!("\nRead more here: https://docs.atuin.sh/guide/sync/#login \n"); - - let key = or_user_input( - self.key.clone(), - "encryption key [blank to use existing key file]", - ); - - if key.is_empty() { - if key_path.exists() { - let bytes = fs_err::read_to_string(&key_path).context(format!( - "Existing key file at '{}' could not be read", - key_path.to_string_lossy() - ))?; - if decode_key(bytes).is_err() { - bail!(format!( - "The key in existing key file at '{}' is invalid", - key_path.to_string_lossy() - )); - } - } else { - panic!( - "No key provided and no existing key file found. Please use 'atuin key' on your other machine, or recover your key from a backup" - ) - } - } else if !key_path.exists() { - if decode_key(key.clone()).is_err() { - bail!("The specified key is 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-encoded key")? - .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?; - } - } - - Ok(()) - } -} - -async fn verify_key_against_remote(settings: &Settings) -> Result<()> { - let key: [u8; 32] = load_key(settings) - .context("could not load encryption key for verification")? - .into(); - - let client = sync::build_client(settings).await?; - let remote_index = match client.record_status().await { - Ok(idx) => idx, - Err(e) => { - tracing::warn!("could not fetch remote status to verify key: {e}"); - return Ok(()); - } - }; - - match sync::check_encryption_key(&client, &remote_index, &key).await { - Ok(()) => Ok(()), - Err(SyncError::WrongKey) => { - // Roll back the saved session so the user is not left in a - // half-authenticated state with a key that can't read the data. - if let Ok(meta) = Settings::meta_store().await { - let _ = meta.delete_session().await; - } - crate::print_error::print_error( - "Wrong encryption key", - "The encryption key on this machine does not match the data on the server. \ - You have been logged out.\n\n\ - To fix this, find your existing key by running `atuin key` on a machine that \ - already syncs successfully, then run `atuin login` again here with that key.", - ); - std::process::exit(1); - } - Err(e) => { - // Non-key error (e.g. transient network issue). Don't fail the - // login — the user is authenticated and can sync later when the - // network recovers. - tracing::warn!("could not verify encryption key against remote: {e}"); - Ok(()) - } - } -} - -pub(super) fn or_user_input(value: Option<String>, name: &'static str) -> String { - value.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") -} diff --git a/crates/turtle/src/command/client/account/logout.rs b/crates/turtle/src/command/client/account/logout.rs deleted file mode 100644 index 5708e34c..00000000 --- a/crates/turtle/src/command/client/account/logout.rs +++ /dev/null @@ -1,5 +0,0 @@ -use eyre::Result; - -pub(crate) async fn run() -> Result<()> { - crate::atuin_client::logout::logout().await -} diff --git a/crates/turtle/src/command/client/account/register.rs b/crates/turtle/src/command/client/account/register.rs deleted file mode 100644 index 64fb9f8d..00000000 --- a/crates/turtle/src/command/client/account/register.rs +++ /dev/null @@ -1,67 +0,0 @@ -use clap::Parser; -use eyre::{Result, bail}; - -use super::login::or_user_input; -use crate::atuin_client::settings::{Settings, SyncAuth}; - -#[derive(Parser, Debug)] -pub(crate) struct Cmd { - #[clap(long, short)] - pub(crate) username: Option<String>, - - #[clap(long, short)] - pub(crate) password: Option<String>, - - #[clap(long, short)] - pub(crate) email: Option<String>, -} - -impl Cmd { - pub(crate) async fn run(&self, settings: &Settings) -> Result<()> { - match settings.resolve_sync_auth().await { - SyncAuth::Legacy { .. } => { - println!("You are already logged in."); - println!("Run 'atuin logout' to log out."); - return Ok(()); - } - - SyncAuth::NotLoggedIn { .. } => {} - } - - // Legacy registration flow - println!("Registering for an Atuin Sync account"); - - let username = or_user_input(self.username.clone(), "username"); - let email = or_user_input(self.email.clone(), "email"); - let password = self - .password - .clone() - .unwrap_or_else(super::login::read_user_password); - - if password.is_empty() { - bail!("please provide a password"); - } - - let session = crate::atuin_client::api_client::register( - settings.sync_address.as_str(), - &username, - &email, - &password, - ) - .await?; - - let meta = Settings::meta_store().await?; - meta.save_session(&session.session).await?; - - let _key = crate::atuin_client::encryption::load_key(settings)?; - - println!( - "Registration successful! Please make a note of your key (run 'atuin key') and keep it safe." - ); - println!( - "You will need it to log in on other devices, and we cannot help recover it if you lose it." - ); - - Ok(()) - } -} diff --git a/crates/turtle/src/command/client/doctor.rs b/crates/turtle/src/command/client/doctor.rs index 1ed90c47..eec690a5 100644 --- a/crates/turtle/src/command/client/doctor.rs +++ b/crates/turtle/src/command/client/doctor.rs @@ -243,15 +243,8 @@ struct SyncInfo { } impl SyncInfo { - pub(crate) async fn new(settings: &Settings) -> Self { - // Build auth state description from raw token state without calling - // resolve_sync_auth(), which has side effects (token migration cleanup) - // that a diagnostic command should not trigger. - let meta = Settings::meta_store().await.ok(); - let has_cli_token = match &meta { - Some(m) => m.session_token().await.ok().flatten().is_some(), - None => false, - }; + pub(crate) async fn new(settings: &Settings) -> Result<Self> { + let has_cli_token = settings.have_sync_key().await?; let auth_state = if has_cli_token { "Self-hosted (authenticated)".into() @@ -259,13 +252,13 @@ impl SyncInfo { "Not authenticated".into() }; - Self { + Ok(Self { auth_state, auto_sync: settings.auto_sync, last_sync: Settings::last_sync() .await .map_or_else(|_| "no last sync".to_string(), |v| v.to_string()), - } + }) } } @@ -318,11 +311,11 @@ struct AtuinInfo { } impl AtuinInfo { - pub(crate) async fn new(settings: &Settings) -> Self { - let logged_in = settings.logged_in().await.unwrap_or(false); + pub(crate) async fn new(settings: &Settings) -> Result<Self> { + let logged_in = settings.have_sync_key().await?; let sync = if logged_in { - Some(SyncInfo::new(settings).await) + Some(SyncInfo::new(settings).await?) } else { None }; @@ -335,13 +328,13 @@ impl AtuinInfo { Err(_) => "error".to_string(), }; - Self { + Ok(Self { version: crate::VERSION.to_string(), commit: crate::SHA.to_string(), sync, sqlite_version, setting_paths: SettingPaths::new(settings), - } + }) } } @@ -353,12 +346,12 @@ struct DoctorDump { } impl DoctorDump { - pub(crate) async fn new(settings: &Settings) -> Self { - Self { - atuin: AtuinInfo::new(settings).await, + pub(crate) async fn new(settings: &Settings) -> Result<Self> { + Ok(Self { + atuin: AtuinInfo::new(settings).await?, shell: ShellInfo::new(), system: SystemInfo::new(), - } + }) } } @@ -399,7 +392,7 @@ fn checks(info: &DoctorDump) { pub(crate) async fn run(settings: &Settings) -> Result<()> { println!("{}", "Atuin Doctor".bold()); println!("Checking for diagnostics"); - let dump = DoctorDump::new(settings).await; + let dump = DoctorDump::new(settings).await?; checks(&dump); diff --git a/crates/turtle/src/command/client/setup.rs b/crates/turtle/src/command/client/setup.rs deleted file mode 100644 index 3231b6ec..00000000 --- a/crates/turtle/src/command/client/setup.rs +++ /dev/null @@ -1,81 +0,0 @@ -use crate::atuin_client::settings::Settings; - -use colored::Colorize; -use eyre::Result; -use std::io::{self, Write}; -use toml_edit::{DocumentMut, value}; - -pub(crate) async fn run(_settings: &Settings) -> Result<()> { - let enable_ai = prompt( - "Atuin AI", - "This will enable command generation and other AI features via the question mark key", - Some( - "By default, Atuin AI only has access to the name and version of your operating system and shell - your shell history is not sent to the AI.", - ), - )?; - - let enable_daemon = prompt( - "Atuin Daemon", - "This will enable improved search and history sync using a persistent background process", - None, - )?; - - let config_file = Settings::get_config_path()?; - let config_str = tokio::fs::read_to_string(&config_file).await?; - let mut doc = config_str.parse::<DocumentMut>()?; - - let mut changed = false; - if enable_ai { - changed = true; - if !doc.contains_key("ai") { - doc["ai"] = toml_edit::table(); - } - doc["ai"]["enabled"] = value(true); - } - - if enable_daemon { - changed = true; - if !doc.contains_key("daemon") { - doc["daemon"] = toml_edit::table(); - } - doc["daemon"]["enabled"] = value(true); - doc["daemon"]["autostart"] = value(true); - doc["search_mode"] = value("daemon-fuzzy"); - } - - if changed { - tokio::fs::write(config_file, doc.to_string()).await?; - - println!( - "{check} Settings updated successfully", - check = "✓".bold().bright_green() - ); - } else { - println!( - "{check} No settings changed", - check = "✓".bold().bright_green() - ); - } - - Ok(()) -} - -pub(crate) fn prompt(feature: &str, description: &str, note: Option<&str>) -> Result<bool> { - println!( - "> Enable {feature}?", - feature = feature.bold().bright_blue() - ); - if let Some(note) = note { - println!(" {description}"); - print!(" {note} {q} ", q = "[Y/n]".bold()); - } else { - print!(" {description} {q} ", q = "[Y/n]".bold()); - } - - io::stdout().flush().ok(); - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - let answer = input.trim().to_lowercase(); - Ok(answer.is_empty() || answer == "y" || answer == "yes") -} diff --git a/crates/turtle/src/command/client/store/push.rs b/crates/turtle/src/command/client/store/push.rs index 042ad201..30177dbd 100644 --- a/crates/turtle/src/command/client/store/push.rs +++ b/crates/turtle/src/command/client/store/push.rs @@ -43,7 +43,7 @@ impl Push { let client = Client::new( &settings.sync_address, - settings.sync_auth_token().await?, + settings.sync_auth().await?.into_auth_token()?, settings.network_connect_timeout, settings.network_timeout * 10, // we may be deleting a lot of data... so up the // timeout diff --git a/crates/turtle/src/command/client/sync.rs b/crates/turtle/src/command/client/sync.rs index 8d7cb50a..7adf90ed 100644 --- a/crates/turtle/src/command/client/sync.rs +++ b/crates/turtle/src/command/client/sync.rs @@ -11,8 +11,6 @@ use crate::atuin_client::{ mod status; -use crate::command::client::account; - #[derive(Subcommand, Debug)] #[command(infer_subcommands = true)] pub(crate) enum Cmd { @@ -23,15 +21,6 @@ pub(crate) enum Cmd { force: bool, }, - /// Login to the configured server - Login(account::login::Cmd), - - /// Log out - Logout, - - /// Register with the configured server - Register(account::register::Cmd), - /// Print the encryption key for transfer to another machine Key {}, @@ -48,9 +37,6 @@ impl Cmd { ) -> Result<()> { match self { Self::Sync { force } => run(&settings, force, db, store).await, - Self::Login(l) => l.run(&settings, &store).await, - Self::Logout => account::logout::run().await, - Self::Register(r) => r.run(&settings).await, Self::Status => status::run(&settings).await, Self::Key {} => { use crate::atuin_client::encryption::{encode_key, load_key}; diff --git a/crates/turtle/src/command/client/sync/status.rs b/crates/turtle/src/command/client/sync/status.rs index cb0d86e4..27b10dbd 100644 --- a/crates/turtle/src/command/client/sync/status.rs +++ b/crates/turtle/src/command/client/sync/status.rs @@ -1,16 +1,16 @@ -use crate::{SHA, VERSION}; use crate::atuin_client::{api_client, settings::Settings}; +use crate::{SHA, VERSION}; use colored::Colorize; use eyre::{Result, bail}; pub(crate) async fn run(settings: &Settings) -> Result<()> { - if !settings.logged_in().await? { + if !settings.have_sync_key().await? { bail!("You are not logged in to a sync server - cannot show sync status"); } let client = api_client::Client::new( &settings.sync_address, - settings.sync_auth_token().await?, + settings.sync_auth().await?.into_auth_token()?, settings.network_connect_timeout, settings.network_timeout, )?; |
