aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEllie Huxtable <ellie@atuin.sh>2026-05-12 16:06:33 -0700
committerGitHub <noreply@github.com>2026-05-12 16:06:33 -0700
commitabd88a1343aa1d1739beefedcc4c01f151e80f69 (patch)
tree35a803c341b4259cc62697e65f5a5842168db46d
parentfix: ensure local key matches remote data before syncing (#3474) (diff)
downloadatuin-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.rs11
-rw-r--r--crates/atuin/src/command/client/store/pull.rs4
-rw-r--r--crates/atuin/src/command/client/store/push.rs4
-rw-r--r--crates/atuin/src/command/client/sync.rs8
-rw-r--r--crates/atuin/src/main.rs2
-rw-r--r--crates/atuin/src/print_error.rs123
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"]);
+ }
+}