diff options
| author | Ellie Huxtable <ellie@atuin.sh> | 2026-05-12 16:06:33 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-12 16:06:33 -0700 |
| commit | abd88a1343aa1d1739beefedcc4c01f151e80f69 (patch) | |
| tree | 35a803c341b4259cc62697e65f5a5842168db46d | |
| parent | fix: ensure local key matches remote data before syncing (#3474) (diff) | |
| download | atuin-abd88a1343aa1d1739beefedcc4c01f151e80f69.zip | |
feat(ui): prominent banner for wrong-key errors at login/sync (#3475)
The wrong-key error was a long unwrapped sentence buried under
"Successfully authenticated.", and wrapped by eyre error formatting
add
- print_error(title, description) — red box-drawn bars across the
terminal width (capped at 100 cols) with bold title; word-wraps the
description; plain "Error:" header when stderr isn't a TTY.
- format_sync_error(SyncError) -> eyre::Report — intercepts WrongKey to
print the banner and exit(1) so eyre's footer never runs; forwards other
variants unchanged.
Use it from:
- account/login.rs — replaces bail\! in the wrong-key path
- command/client/sync.rs — .map_err(format_sync_error)? on sync()
- store/push.rs and store/pull.rs — .map_err on check_encryption_key
## Checks
- [ ] I am happy for maintainers to push small adjustments to this PR,
to speed up the review cycle
- [ ] I have checked that there are no existing pull requests for the
same thing
| -rw-r--r-- | crates/atuin/src/command/client/account/login.rs | 11 | ||||
| -rw-r--r-- | crates/atuin/src/command/client/store/pull.rs | 4 | ||||
| -rw-r--r-- | crates/atuin/src/command/client/store/push.rs | 4 | ||||
| -rw-r--r-- | crates/atuin/src/command/client/sync.rs | 8 | ||||
| -rw-r--r-- | crates/atuin/src/main.rs | 2 | ||||
| -rw-r--r-- | crates/atuin/src/print_error.rs | 123 |
6 files changed, 144 insertions, 8 deletions
diff --git a/crates/atuin/src/command/client/account/login.rs b/crates/atuin/src/command/client/account/login.rs index 852cb2a1..072f0815 100644 --- a/crates/atuin/src/command/client/account/login.rs +++ b/crates/atuin/src/command/client/account/login.rs @@ -295,11 +295,14 @@ async fn verify_key_against_remote(settings: &Settings) -> Result<()> { let _ = meta.delete_session().await; let _ = meta.delete_hub_session().await; } - bail!( - "The provided encryption key does not match the data on the server. \ - You have been logged out — please run `atuin login` again with the correct key. \ - Find it by running `atuin key` on a machine that already syncs successfully." + 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 diff --git a/crates/atuin/src/command/client/store/pull.rs b/crates/atuin/src/command/client/store/pull.rs index cda0d741..25b925c7 100644 --- a/crates/atuin/src/command/client/store/pull.rs +++ b/crates/atuin/src/command/client/store/pull.rs @@ -53,7 +53,9 @@ impl Pull { // Skip on --force: local was already wiped above, mismatch is the user's call. if !self.force { let key: [u8; 32] = load_key(settings)?.into(); - sync::check_encryption_key(&client, &remote_index, &key).await?; + sync::check_encryption_key(&client, &remote_index, &key) + .await + .map_err(crate::print_error::format_sync_error)?; } let operations = sync::operations(diff, &store).await?; diff --git a/crates/atuin/src/command/client/store/push.rs b/crates/atuin/src/command/client/store/push.rs index 14a6d9dd..d8569e1e 100644 --- a/crates/atuin/src/command/client/store/push.rs +++ b/crates/atuin/src/command/client/store/push.rs @@ -65,7 +65,9 @@ impl Push { // Skip on --force: that path intentionally replaces remote with local. if !self.force { let key: [u8; 32] = load_key(settings)?.into(); - sync::check_encryption_key(&client, &remote_index, &key).await?; + sync::check_encryption_key(&client, &remote_index, &key) + .await + .map_err(crate::print_error::format_sync_error)?; } let operations = sync::operations(diff, &store).await?; diff --git a/crates/atuin/src/command/client/sync.rs b/crates/atuin/src/command/client/sync.rs index 954b07de..15123a7f 100644 --- a/crates/atuin/src/command/client/sync.rs +++ b/crates/atuin/src/command/client/sync.rs @@ -88,7 +88,9 @@ async fn run( let host_id = Settings::host_id().await?; let history_store = HistoryStore::new(store.clone(), host_id, encryption_key); - let (uploaded, downloaded) = sync::sync(settings, &store, &encryption_key).await?; + let (uploaded, downloaded) = sync::sync(settings, &store, &encryption_key) + .await + .map_err(crate::print_error::format_sync_error)?; crate::sync::build(settings, &store, db, Some(&downloaded)).await?; @@ -111,7 +113,9 @@ async fn run( println!("Re-running sync due to new records locally"); // we'll want to run sync once more, as there will now be stuff to upload - let (uploaded, downloaded) = sync::sync(settings, &store, &encryption_key).await?; + let (uploaded, downloaded) = sync::sync(settings, &store, &encryption_key) + .await + .map_err(crate::print_error::format_sync_error)?; crate::sync::build(settings, &store, db, Some(&downloaded)).await?; diff --git a/crates/atuin/src/main.rs b/crates/atuin/src/main.rs index 1a45988a..255db36a 100644 --- a/crates/atuin/src/main.rs +++ b/crates/atuin/src/main.rs @@ -11,6 +11,8 @@ use command::AtuinCmd; mod command; #[cfg(feature = "sync")] +mod print_error; +#[cfg(feature = "sync")] mod sync; const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/atuin/src/print_error.rs b/crates/atuin/src/print_error.rs new file mode 100644 index 00000000..a6da283d --- /dev/null +++ b/crates/atuin/src/print_error.rs @@ -0,0 +1,123 @@ +use std::io::IsTerminal; + +use atuin_client::record::sync::SyncError; +use colored::Colorize; +use crossterm::terminal; + +/// Print a prominent error to stderr. Colored and box-bordered when stderr is +/// a TTY, plain "Error: ..." header otherwise. The description is word-wrapped +/// to the terminal width (capped at 100 columns) so the message stays readable. +pub fn print_error(title: &str, description: &str) { + let is_tty = std::io::stderr().is_terminal(); + let width = if is_tty { + terminal::size().map_or(80, |(w, _)| w as usize) + } else { + 80 + } + .min(100); + + eprintln!(); + if is_tty { + let bar = "━".repeat(width).red().bold().to_string(); + eprintln!("{bar}"); + eprintln!(" {} {}", "✗".red().bold(), title.red().bold()); + eprintln!("{bar}"); + } else { + eprintln!("Error: {title}"); + eprintln!("{}", "-".repeat(width)); + } + eprintln!(); + + for line in wrap_text(description, width.saturating_sub(2)) { + eprintln!(" {line}"); + } + eprintln!(); +} + +/// Convert a `SyncError` into an `eyre::Report`, exiting on `WrongKey` after +/// painting the prominent banner. +pub fn format_sync_error(e: SyncError) -> eyre::Report { + if matches!(e, SyncError::WrongKey) { + print_error( + "Wrong encryption key", + "Your local encryption key cannot decrypt the data on the server. \ + This usually means another machine wrote records with a different key.\n\n\ + To fix this, find the correct key by running `atuin key` on a machine that \ + already syncs successfully, then run `atuin store rekey <key>` here.", + ); + std::process::exit(1); + } + e.into() +} + +fn wrap_text(text: &str, width: usize) -> Vec<String> { + let mut out = Vec::new(); + for paragraph in text.split('\n') { + let mut line = String::new(); + let mut line_len = 0; + for word in paragraph.split_whitespace() { + let word_len = word.chars().count(); + if !line.is_empty() && line_len + 1 + word_len > width { + out.push(std::mem::take(&mut line)); + line_len = 0; + } + if !line.is_empty() { + line.push(' '); + line_len += 1; + } + line.push_str(word); + line_len += word_len; + } + // Push every paragraph's final line (even empty) so `\n\n` in the + // input becomes a blank line in the output. + out.push(line); + } + while out.first().is_some_and(String::is_empty) { + out.remove(0); + } + while out.last().is_some_and(String::is_empty) { + out.pop(); + } + out +} + +#[cfg(test)] +mod tests { + use super::wrap_text; + + #[test] + fn wraps_long_text() { + let lines = wrap_text("the quick brown fox jumps over the lazy dog", 20); + for line in &lines { + assert!(line.chars().count() <= 20, "line too long: {line:?}"); + } + assert_eq!( + lines.join(" "), + "the quick brown fox jumps over the lazy dog" + ); + } + + #[test] + fn preserves_explicit_newlines() { + let lines = wrap_text("first line\nsecond line", 80); + assert_eq!(lines, vec!["first line", "second line"]); + } + + #[test] + fn handles_word_longer_than_width() { + let lines = wrap_text("short superlongword more", 5); + assert_eq!(lines, vec!["short", "superlongword", "more"]); + } + + #[test] + fn preserves_blank_lines_between_paragraphs() { + let lines = wrap_text("first paragraph\n\nsecond paragraph", 80); + assert_eq!(lines, vec!["first paragraph", "", "second paragraph"]); + } + + #[test] + fn trims_leading_and_trailing_blank_lines() { + let lines = wrap_text("\n\nbody\n\n", 80); + assert_eq!(lines, vec!["body"]); + } +} |
