aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/codespell.yml2
-rw-r--r--.github/workflows/docker.yaml105
-rw-r--r--.github/workflows/installer.yml4
-rw-r--r--.github/workflows/nix.yml4
-rw-r--r--.github/workflows/rust.yml14
-rw-r--r--.github/workflows/shellcheck.yml2
-rw-r--r--.github/workflows/update-nix-deps.yml2
-rw-r--r--CHANGELOG.md84
-rw-r--r--cliff.toml2
-rw-r--r--crates/atuin-ai/src/commands/inline.rs8
-rw-r--r--crates/atuin-client/src/auth.rs455
-rw-r--r--crates/atuin-client/src/lib.rs2
-rw-r--r--crates/atuin-client/src/settings.rs36
l---------crates/atuin-server/CHANGELOG.md1
l---------crates/atuin/CHANGELOG.md1
-rw-r--r--crates/atuin/src/command/client/account.rs6
-rw-r--r--crates/atuin/src/command/client/account/change_password.rs92
-rw-r--r--crates/atuin/src/command/client/account/delete.rs76
-rw-r--r--crates/atuin/src/command/client/account/link.rs10
-rw-r--r--crates/atuin/src/command/client/account/login.rs141
-rw-r--r--crates/atuin/src/command/client/account/register.rs156
-rw-r--r--crates/atuin/src/shell/atuin.nu6
-rw-r--r--depot.json1
-rw-r--r--dist-workspace.toml7
24 files changed, 851 insertions, 366 deletions
diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml
index 5e8da009..7d941112 100644
--- a/.github/workflows/codespell.yml
+++ b/.github/workflows/codespell.yml
@@ -14,7 +14,7 @@ permissions:
jobs:
codespell:
name: Check for spelling errors
- runs-on: ubuntu-latest
+ runs-on: depot-ubuntu-24.04
steps:
- name: Checkout
diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml
index e7b3da6a..670bc2d5 100644
--- a/.github/workflows/docker.yaml
+++ b/.github/workflows/docker.yaml
@@ -5,72 +5,25 @@ on:
branches: [main]
jobs:
- publish_x86:
+ publish:
concurrency:
- group: ${{ github.ref }}-x86
+ group: ${{ github.ref }}-docker
cancel-in-progress: true
permissions:
packages: write
+ contents: read
+ id-token: write
- runs-on: ubuntu-latest
+ runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@v6
- with:
- repository: atuinsh/atuin
- path: "./"
- name: Get Repo Owner
id: get_repo_owner
run: echo "REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" > $GITHUB_ENV
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
- - name: Login to container Registry
- uses: docker/login-action@v3
- with:
- username: ${{ github.repository_owner }}
- password: ${{ secrets.GITHUB_TOKEN }}
- registry: ghcr.io
-
- - name: Get short sha
- id: shortsha
- run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
-
- - name: Release build
- id: release_build
- uses: docker/build-push-action@v6
- with:
- outputs: "type=registry,push=true"
- platforms: linux/amd64
- file: ./Dockerfile
- context: .
- provenance: false
- build-args: |
- Version=dev
- GitCommit=${{ steps.shortsha.outputs.short_sha }}
- tags: |
- ghcr.io/${{ env.REPO_OWNER }}/atuin:${{ steps.shortsha.outputs.short_sha }}-amd64
-
- publish_aarch64:
- concurrency:
- group: ${{ github.ref }}-aarch64
- cancel-in-progress: true
- permissions:
- packages: write
-
- runs-on: ubuntu-24.04-arm
- steps:
- - uses: actions/checkout@v6
- with:
- repository: atuinsh/atuin
- path: "./"
-
- - name: Get Repo Owner
- id: get_repo_owner
- run: echo "REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" > $GITHUB_ENV
+ - uses: depot/setup-action@v1
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
- name: Login to container Registry
uses: docker/login-action@v3
with:
@@ -82,12 +35,11 @@ jobs:
id: shortsha
run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- - name: Release build
- id: release_build
- uses: docker/build-push-action@v6
+ - name: Build and push
+ uses: depot/build-push-action@v1
with:
- outputs: "type=registry,push=true"
- platforms: linux/arm64
+ push: true
+ platforms: linux/amd64,linux/arm64
file: ./Dockerfile
context: .
provenance: false
@@ -95,39 +47,4 @@ jobs:
Version=dev
GitCommit=${{ steps.shortsha.outputs.short_sha }}
tags: |
- ghcr.io/${{ env.REPO_OWNER }}/atuin:${{ steps.shortsha.outputs.short_sha }}-aarch64
-
- publish_manifest:
- runs-on: ubuntu-latest
- needs: [publish_x86, publish_aarch64]
- steps:
- - uses: actions/checkout@v6
- with:
- repository: atuinsh/atuin
- path: "./"
-
- - name: Get Repo Owner
- id: get_repo_owner
- run: echo "REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" > $GITHUB_ENV
-
- - name: Login to container Registry
- uses: docker/login-action@v3
- with:
- username: ${{ github.repository_owner }}
- password: ${{ secrets.GITHUB_TOKEN }}
- registry: ghcr.io
-
- - name: Get short sha
- id: shortsha
- run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
-
- - name: Create manifest
- run: |
- docker manifest create ghcr.io/${{ env.REPO_OWNER }}/atuin:${{ steps.shortsha.outputs.short_sha }} \
- --amend ghcr.io/${{ env.REPO_OWNER }}/atuin:${{ steps.shortsha.outputs.short_sha }}-amd64 \
- --amend ghcr.io/${{ env.REPO_OWNER }}/atuin:${{ steps.shortsha.outputs.short_sha }}-aarch64
- docker manifest annotate --arch amd64 --os linux ghcr.io/${{ env.REPO_OWNER }}/atuin:${{ steps.shortsha.outputs.short_sha }} ghcr.io/${{ env.REPO_OWNER }}/atuin:${{ steps.shortsha.outputs.short_sha }}-amd64
- docker manifest annotate --arch arm64 --os linux ghcr.io/${{ env.REPO_OWNER }}/atuin:${{ steps.shortsha.outputs.short_sha }} ghcr.io/${{ env.REPO_OWNER }}/atuin:${{ steps.shortsha.outputs.short_sha }}-aarch64
- docker manifest inspect ghcr.io/${{ env.REPO_OWNER }}/atuin:${{ steps.shortsha.outputs.short_sha }}
-
- docker manifest push ghcr.io/${{ env.REPO_OWNER }}/atuin:${{ steps.shortsha.outputs.short_sha }}
+ ghcr.io/${{ env.REPO_OWNER }}/atuin:${{ steps.shortsha.outputs.short_sha }}
diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml
index 33fbe50d..2cc4f89f 100644
--- a/.github/workflows/installer.yml
+++ b/.github/workflows/installer.yml
@@ -13,14 +13,14 @@ jobs:
install:
strategy:
matrix:
- os: [ubuntu-latest, macos-14]
+ os: [depot-ubuntu-24.04, macos-14]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- name: Install zsh for ubuntu
- if: matrix.os == 'ubuntu-latest'
+ if: matrix.os == 'depot-ubuntu-24.04'
run: |
sudo apt install zsh
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
index b148d979..515a05eb 100644
--- a/.github/workflows/nix.yml
+++ b/.github/workflows/nix.yml
@@ -14,7 +14,7 @@ on:
jobs:
check:
- runs-on: ubuntu-latest
+ runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@v6
@@ -24,7 +24,7 @@ jobs:
run: nix flake check --print-build-logs
build-test:
- runs-on: ubuntu-latest
+ runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@v6
diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml
index b996c582..a215888f 100644
--- a/.github/workflows/rust.yml
+++ b/.github/workflows/rust.yml
@@ -17,7 +17,7 @@ jobs:
build:
strategy:
matrix:
- os: [ubuntu-latest, macos-14, windows-latest]
+ os: [depot-ubuntu-24.04, macos-14, windows-latest]
runs-on: ${{ matrix.os }}
steps:
@@ -55,7 +55,7 @@ jobs:
#
# warning: libelf.so.2, needed by <...>/libkvm.so, not found (try using -rpath or -rpath-link)
target: [x86_64-unknown-illumos]
- runs-on: ubuntu-latest
+ runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@v6
@@ -88,7 +88,7 @@ jobs:
unit-test:
strategy:
matrix:
- os: [ubuntu-latest, macos-14, windows-latest]
+ os: [depot-ubuntu-24.04, macos-14, windows-latest]
runs-on: ${{ matrix.os }}
steps:
@@ -118,7 +118,7 @@ jobs:
check:
strategy:
matrix:
- os: [ubuntu-latest, macos-14, windows-latest]
+ os: [depot-ubuntu-24.04, macos-14, windows-latest]
runs-on: ${{ matrix.os }}
steps:
@@ -153,7 +153,7 @@ jobs:
run: cargo check --no-default-features --features client --workspace
integration-test:
- runs-on: ubuntu-latest
+ runs-on: depot-ubuntu-24.04
services:
postgres:
@@ -192,7 +192,7 @@ jobs:
ATUIN_DB_URI: postgres://atuin:pass@localhost:5432/atuin
clippy:
- runs-on: ubuntu-latest
+ runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@v6
@@ -215,7 +215,7 @@ jobs:
run: cargo clippy -- -D warnings -D clippy::redundant_clone
format:
- runs-on: ubuntu-latest
+ runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@v6
diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml
index 7c983232..e85381fd 100644
--- a/.github/workflows/shellcheck.yml
+++ b/.github/workflows/shellcheck.yml
@@ -8,7 +8,7 @@ on:
jobs:
shellcheck:
- runs-on: ubuntu-latest
+ runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@v6
diff --git a/.github/workflows/update-nix-deps.yml b/.github/workflows/update-nix-deps.yml
index 8c43e549..2ed5ff78 100644
--- a/.github/workflows/update-nix-deps.yml
+++ b/.github/workflows/update-nix-deps.yml
@@ -6,7 +6,7 @@ on:
jobs:
lockfile:
- runs-on: ubuntu-latest
+ runs-on: depot-ubuntu-24.04
if: github.repository == 'atuinsh/atuin'
steps:
- name: Checkout repository
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cb07b32b..789b83de 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,19 @@
All notable changes to this project will be documented in this file.
-## [18.13.2] - 2026-03-13
+## [unreleased]
+
+### Features
+
+- Call atuin setup from install script ([#3265](https://github.com/atuinsh/atuin/issues/3265))
+
+
+### Miscellaneous Tasks
+
+- Update changelog
+
+
+## 18.13.2
### Miscellaneous Tasks
@@ -10,7 +22,7 @@ All notable changes to this project will be documented in this file.
- Update changelog
-## [18.13.1] - 2026-03-13
+## 18.13.1
### Miscellaneous Tasks
@@ -18,7 +30,7 @@ All notable changes to this project will be documented in this file.
- Update changelog
-## [18.13.0] - 2026-03-13
+## 18.13.0
### Bug Fixes
@@ -81,7 +93,7 @@ All notable changes to this project will be documented in this file.
- Update changelog
-## [18.12.1] - 2026-02-11
+## 18.12.1
### Bug Fixes
@@ -101,7 +113,7 @@ All notable changes to this project will be documented in this file.
- Update changelog
-## [18.12.0] - 2026-02-09
+## 18.12.0
### Bug Fixes
@@ -191,7 +203,7 @@ All notable changes to this project will be documented in this file.
- Add alternative projects ([#3076](https://github.com/atuinsh/atuin/issues/3076))
-## [18.11.0] - 2026-01-12
+## 18.11.0
### Bug Fixes
@@ -242,7 +254,7 @@ All notable changes to this project will be documented in this file.
- *(nix)* Update rust toolchain hash ([#2990](https://github.com/atuinsh/atuin/issues/2990))
-## [18.10.0] - 2025-10-21
+## 18.10.0
### Bug Fixes
@@ -264,7 +276,7 @@ All notable changes to this project will be documented in this file.
- Update changelog
-## [18.9.0] - 2025-10-20
+## 18.9.0
### Bug Fixes
@@ -318,7 +330,7 @@ All notable changes to this project will be documented in this file.
- Update flake.nix with new sha256
-## [18.8.0] - 2025-08-04
+## 18.8.0
### Bug Fixes
@@ -347,7 +359,7 @@ All notable changes to this project will be documented in this file.
- Fix `get -i` deprecation ([#2829](https://github.com/atuinsh/atuin/issues/2829))
-## [18.7.1] - 2025-07-16
+## 18.7.1
### Bug Fixes
@@ -359,7 +371,7 @@ All notable changes to this project will be documented in this file.
- Update changelog
-## [18.7.0] - 2025-07-16
+## 18.7.0
### Bug Fixes
@@ -383,7 +395,7 @@ All notable changes to this project will be documented in this file.
- Allow setting script DB path ([#2750](https://github.com/atuinsh/atuin/issues/2750))
-## [18.6.1] - 2025-05-08
+## 18.6.1
### Bug Fixes
@@ -404,7 +416,7 @@ All notable changes to this project will be documented in this file.
- Fix typos ([#2668](https://github.com/atuinsh/atuin/issues/2668))
-## [18.5.0] - 2025-04-09
+## 18.5.0
### Bug Fixes
@@ -483,7 +495,7 @@ All notable changes to this project will be documented in this file.
- Update ([#2463](https://github.com/atuinsh/atuin/issues/2463))
-## [18.4.0] - 2024-12-27
+## 18.4.0
### Bug Fixes
@@ -615,7 +627,7 @@ All notable changes to this project will be documented in this file.
- Update ([#2402](https://github.com/atuinsh/atuin/issues/2402))
-## [18.3.0] - 2024-06-10
+## 18.3.0
### Bug Fixes
@@ -703,7 +715,7 @@ All notable changes to this project will be documented in this file.
- Preview_auto to use enum and different option ([#1991](https://github.com/atuinsh/atuin/issues/1991))
-## [18.2.0] - 2024-04-15
+## 18.2.0
### Bug Fixes
@@ -758,7 +770,7 @@ All notable changes to this project will be documented in this file.
- *(dotfiles)* Cache aliases and read straight from file ([#1918](https://github.com/atuinsh/atuin/issues/1918))
-## [18.1.0] - 2024-03-11
+## 18.1.0
### Bug Fixes
@@ -842,14 +854,14 @@ All notable changes to this project will be documented in this file.
- Rename atuin-config to atuin-dotfiles ([#1817](https://github.com/atuinsh/atuin/issues/1817))
-## [18.0.1] - 2024-02-12
+## 18.0.1
### Bug Fixes
- Reorder the exit of enhanced keyboard mode ([#1694](https://github.com/atuinsh/atuin/issues/1694))
-## [18.0.0] - 2024-02-09
+## 18.0.0
### Bug Fixes
@@ -980,14 +992,14 @@ All notable changes to this project will be documented in this file.
- Misc improvements ([#1613](https://github.com/atuinsh/atuin/issues/1613))
-## [17.2.1] - 2024-01-03
+## 17.2.1
### Bug Fixes
- *(server)* Typo with default config ([#1493](https://github.com/atuinsh/atuin/issues/1493))
-## [17.2.0] - 2024-01-03
+## 17.2.0
### Bug Fixes
@@ -1044,7 +1056,7 @@ All notable changes to this project will be documented in this file.
- *(bash)* Refactor and optimize `__atuin_accept_line` ([#1482](https://github.com/atuinsh/atuin/issues/1482))
-## [17.1.0] - 2023-12-10
+## 17.1.0
### Bug Fixes
@@ -1088,7 +1100,7 @@ All notable changes to this project will be documented in this file.
- Setup git cliff ([#1431](https://github.com/atuinsh/atuin/issues/1431))
-## [17.0.1] - 2023-10-28
+## 17.0.1
### Bug Fixes
@@ -1097,7 +1109,7 @@ All notable changes to this project will be documented in this file.
- *(fish)* Improve output for `enter_accept` ([#1341](https://github.com/atuinsh/atuin/issues/1341))
-## [17.0.0] - 2023-10-26
+## 17.0.0
### Bug Fixes
@@ -1131,7 +1143,7 @@ All notable changes to this project will be documented in this file.
- Invert mode detailed options ([#1225](https://github.com/atuinsh/atuin/issues/1225))
-## [16.0.0] - 2023-08-07
+## 16.0.0
### Bug Fixes
@@ -1153,7 +1165,7 @@ All notable changes to this project will be documented in this file.
- Fix filtering aggregates ([#1114](https://github.com/atuinsh/atuin/issues/1114))
-## [15.0.0] - 2023-05-28
+## 15.0.0
### Documentation
@@ -1177,7 +1189,7 @@ All notable changes to this project will be documented in this file.
- Much like history_filter, only it applies to cwd ([#904](https://github.com/atuinsh/atuin/issues/904))
-## [14.0.0] - 2023-04-01
+## 14.0.0
### Bug Fixes
@@ -1213,7 +1225,7 @@ All notable changes to this project will be documented in this file.
- Add flake-compat ([#743](https://github.com/atuinsh/atuin/issues/743))
-## [13.0.0] - 2023-02-26
+## 13.0.0
### Documentation
@@ -1237,35 +1249,35 @@ All notable changes to this project will be documented in this file.
- Fallback to using cargo ([#639](https://github.com/atuinsh/atuin/issues/639))
-## [12.0.0] - 2022-11-06
+## 12.0.0
### Documentation
- Add more details about date parsing in the stats command ([#579](https://github.com/atuinsh/atuin/issues/579))
-## [0.10.0] - 2022-06-06
+## 0.10.0
### Miscellaneous Tasks
- Allow specifiying the limited of returned entries ([#364](https://github.com/atuinsh/atuin/issues/364))
-## [0.9.0] - 2022-04-23
+## 0.9.0
### README
- Add MacPorts installation instructions ([#302](https://github.com/atuinsh/atuin/issues/302))
-## [0.8.1] - 2022-04-12
+## 0.8.1
### Bug Fixes
- Get install.sh working on UbuntuWSL ([#260](https://github.com/atuinsh/atuin/issues/260))
-## [0.8.0] - 2021-12-17
+## 0.8.0
### Bug Fixes
@@ -1277,7 +1289,7 @@ All notable changes to this project will be documented in this file.
- Login/register no longer blocking ([#216](https://github.com/atuinsh/atuin/issues/216))
-## [0.7.2] - 2021-12-08
+## 0.7.2
### Bug Fixes
@@ -1297,14 +1309,14 @@ All notable changes to this project will be documented in this file.
- Improve build times ([#213](https://github.com/atuinsh/atuin/issues/213))
-## [0.7.1] - 2021-05-10
+## 0.7.1
### Features
- Build individual crates ([#109](https://github.com/atuinsh/atuin/issues/109))
-## [0.6.3] - 2021-04-26
+## 0.6.3
### Bug Fixes
diff --git a/cliff.toml b/cliff.toml
index eff32702..1d46f10c 100644
--- a/cliff.toml
+++ b/cliff.toml
@@ -15,7 +15,7 @@ All notable changes to this project will be documented in this file.\n
# https://keats.github.io/tera/docs/#introduction
body = """
{% if version %}\
- ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
+ ## {{ version | trim_start_matches(pat="v") }}
{% else %}\
## [unreleased]
{% endif %}\
diff --git a/crates/atuin-ai/src/commands/inline.rs b/crates/atuin-ai/src/commands/inline.rs
index ce566be1..e0ee05d6 100644
--- a/crates/atuin-ai/src/commands/inline.rs
+++ b/crates/atuin-ai/src/commands/inline.rs
@@ -84,9 +84,7 @@ async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Resu
return Ok(token);
}
- let hub_address = settings
- .active_hub_endpoint()
- .unwrap_or("https://hub.atuin.sh".to_string());
+ let hub_address = settings.active_hub_endpoint().unwrap_or_default();
let will_sync = settings.is_hub_sync();
@@ -109,7 +107,7 @@ async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Resu
debug!("Starting Atuin Hub authentication...");
println!("Authenticating with Atuin Hub...");
- let session = atuin_client::hub::HubAuthSession::start(&hub_address).await?;
+ let session = atuin_client::hub::HubAuthSession::start(hub_address.as_ref()).await?;
println!("Open this URL to continue:");
println!("{}", session.auth_url);
@@ -130,7 +128,7 @@ async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Resu
&& let Ok(Some(cli_token)) = meta.session_token().await
{
debug!("CLI session found, attempting to link accounts");
- if let Err(e) = atuin_client::hub::link_account(&hub_address, &cli_token).await {
+ if let Err(e) = atuin_client::hub::link_account(hub_address.as_ref(), &cli_token).await {
// Don't fail AI flow if linking fails - it's not critical
debug!("Could not link CLI account to Hub: {}", e);
} else {
diff --git a/crates/atuin-client/src/auth.rs b/crates/atuin-client/src/auth.rs
new file mode 100644
index 00000000..1e638c21
--- /dev/null
+++ b/crates/atuin-client/src/auth.rs
@@ -0,0 +1,455 @@
+use async_trait::async_trait;
+use eyre::{Context, Result, bail};
+use reqwest::{StatusCode, Url, header::USER_AGENT};
+use serde::Deserialize;
+
+use atuin_common::{
+ api::{
+ ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ChangePasswordRequest, LoginRequest,
+ LoginResponse, RegisterResponse,
+ },
+ tls::ensure_crypto_provider,
+};
+
+use crate::settings::Settings;
+
+static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSION"));
+
+/// Result of an auth operation that may require 2FA.
+pub enum AuthResponse {
+ /// Operation succeeded; for login/register, contains the session token.
+ Success { session: String },
+ /// Two-factor authentication is required; the caller should prompt for a
+ /// TOTP code and retry with it.
+ TwoFactorRequired,
+}
+
+/// Result of a mutating account operation that may require 2FA.
+pub enum MutateResponse {
+ /// Operation completed successfully.
+ Success,
+ /// Two-factor authentication is required; the caller should prompt for a
+ /// TOTP code and retry.
+ TwoFactorRequired,
+}
+
+/// Abstraction over the legacy (Rust sync server) and Hub auth APIs.
+///
+/// CLI commands use this trait so they don't need to know which backend is
+/// active — they just prompt for input and call these methods.
+#[async_trait]
+pub trait AuthClient: Send + Sync {
+ /// Log in with username + password, optionally providing a TOTP code.
+ async fn login(
+ &self,
+ username: &str,
+ password: &str,
+ totp_code: Option<&str>,
+ ) -> Result<AuthResponse>;
+
+ /// Register a new account.
+ async fn register(&self, username: &str, email: &str, password: &str) -> Result<AuthResponse>;
+
+ /// Change the account password, optionally providing a TOTP code.
+ async fn change_password(
+ &self,
+ current_password: &str,
+ new_password: &str,
+ totp_code: Option<&str>,
+ ) -> Result<MutateResponse>;
+
+ /// Delete the account, requiring the current password and optionally a TOTP code.
+ async fn delete_account(
+ &self,
+ password: &str,
+ totp_code: Option<&str>,
+ ) -> Result<MutateResponse>;
+}
+
+/// Resolve the appropriate [`AuthClient`] for the current settings.
+pub async fn auth_client(settings: &Settings) -> Box<dyn AuthClient> {
+ if settings.is_hub_sync() {
+ let endpoint = settings.active_hub_endpoint().unwrap_or_default();
+ Box::new(HubAuthClient::new(
+ endpoint.as_ref(),
+ settings.hub_session_token().await.ok(),
+ )) as Box<dyn AuthClient>
+ } else {
+ Box::new(LegacyAuthClient::new(
+ &settings.sync_address,
+ settings.session_token().await.ok(),
+ settings.network_connect_timeout,
+ settings.network_timeout,
+ )) as Box<dyn AuthClient>
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Legacy backend — talks to the Rust sync server
+// ---------------------------------------------------------------------------
+
+pub struct LegacyAuthClient {
+ address: String,
+ session_token: Option<String>,
+ connect_timeout: u64,
+ timeout: u64,
+}
+
+impl LegacyAuthClient {
+ pub fn new(
+ address: &str,
+ session_token: Option<String>,
+ connect_timeout: u64,
+ timeout: u64,
+ ) -> Self {
+ Self {
+ address: address.to_string(),
+ session_token,
+ connect_timeout,
+ timeout,
+ }
+ }
+
+ fn authenticated_client(&self) -> Result<reqwest::Client> {
+ let token = self
+ .session_token
+ .as_deref()
+ .ok_or_else(|| eyre::eyre!("Not logged in"))?;
+
+ ensure_crypto_provider();
+ let mut headers = reqwest::header::HeaderMap::new();
+ headers.insert(
+ reqwest::header::AUTHORIZATION,
+ format!("Token {token}").parse()?,
+ );
+ headers.insert(USER_AGENT, APP_USER_AGENT.parse()?);
+ headers.insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse()?);
+
+ Ok(reqwest::Client::builder()
+ .default_headers(headers)
+ .connect_timeout(std::time::Duration::new(self.connect_timeout, 0))
+ .timeout(std::time::Duration::new(self.timeout, 0))
+ .build()?)
+ }
+}
+
+#[async_trait]
+impl AuthClient for LegacyAuthClient {
+ async fn login(
+ &self,
+ username: &str,
+ password: &str,
+ _totp_code: Option<&str>,
+ ) -> Result<AuthResponse> {
+ // The legacy server has no 2FA support; totp_code is ignored.
+ let resp = crate::api_client::login(
+ &self.address,
+ LoginRequest {
+ username: username.to_string(),
+ password: password.to_string(),
+ },
+ )
+ .await?;
+
+ Ok(AuthResponse::Success {
+ session: resp.session,
+ })
+ }
+
+ async fn register(&self, username: &str, email: &str, password: &str) -> Result<AuthResponse> {
+ let resp = crate::api_client::register(&self.address, username, email, password).await?;
+ Ok(AuthResponse::Success {
+ session: resp.session,
+ })
+ }
+
+ async fn change_password(
+ &self,
+ current_password: &str,
+ new_password: &str,
+ _totp_code: Option<&str>,
+ ) -> Result<MutateResponse> {
+ let client = self.authenticated_client()?;
+ let url = make_url(&self.address, "/account/password")?;
+
+ let resp = client
+ .patch(&url)
+ .json(&ChangePasswordRequest {
+ current_password: current_password.to_string(),
+ new_password: new_password.to_string(),
+ })
+ .send()
+ .await?;
+
+ match resp.status().as_u16() {
+ 200 => Ok(MutateResponse::Success),
+ 401 => bail!("current password is incorrect"),
+ 403 => bail!("invalid login details"),
+ _ => bail!("unknown error"),
+ }
+ }
+
+ async fn delete_account(
+ &self,
+ password: &str,
+ _totp_code: Option<&str>,
+ ) -> Result<MutateResponse> {
+ let client = self.authenticated_client()?;
+ let url = make_url(&self.address, "/account")?;
+
+ let resp = client
+ .delete(&url)
+ .json(&serde_json::json!({ "password": password }))
+ .send()
+ .await?;
+
+ match resp.status().as_u16() {
+ 200 => Ok(MutateResponse::Success),
+ 401 => bail!("password is incorrect"),
+ 403 => bail!("invalid login details"),
+ _ => bail!("unknown error"),
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Hub backend — talks to the Hub v0 API endpoints
+// ---------------------------------------------------------------------------
+
+pub struct HubAuthClient {
+ address: String,
+ hub_token: Option<String>,
+}
+
+impl HubAuthClient {
+ pub fn new(address: &str, hub_token: Option<String>) -> Self {
+ Self {
+ address: address.trim_end_matches('/').to_string(),
+ hub_token,
+ }
+ }
+}
+
+/// Hub v0 error/status response — includes an optional `code` field for
+/// machine-readable status like `"2fa_required"`.
+#[derive(Debug, Deserialize)]
+struct HubErrorResponse {
+ reason: String,
+ code: Option<String>,
+}
+
+#[async_trait]
+impl AuthClient for HubAuthClient {
+ async fn login(
+ &self,
+ username: &str,
+ password: &str,
+ totp_code: Option<&str>,
+ ) -> Result<AuthResponse> {
+ ensure_crypto_provider();
+ let url = make_url(&self.address, "/api/v0/login")?;
+ let client = reqwest::Client::new();
+
+ let mut body = serde_json::json!({
+ "username": username,
+ "password": password,
+ });
+ if let Some(code) = totp_code {
+ body["totp_code"] = serde_json::Value::String(code.to_string());
+ }
+
+ let resp = client
+ .post(&url)
+ .header(USER_AGENT, APP_USER_AGENT)
+ .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)
+ .json(&body)
+ .send()
+ .await
+ .context("failed to connect to Atuin Hub")?;
+
+ let status = resp.status();
+
+ if status.is_success() {
+ let login: LoginResponse = resp.json().await?;
+ return Ok(AuthResponse::Success {
+ session: login.session,
+ });
+ }
+
+ if status == StatusCode::FORBIDDEN
+ && let Ok(err) = resp.json::<HubErrorResponse>().await
+ {
+ if err.code.as_deref() == Some("2fa_required") {
+ return Ok(AuthResponse::TwoFactorRequired);
+ }
+ bail!("{}", err.reason);
+ }
+
+ if status == StatusCode::UNAUTHORIZED {
+ bail!("invalid credentials");
+ }
+
+ bail!("Hub login failed with status {status}");
+ }
+
+ async fn register(&self, username: &str, email: &str, password: &str) -> Result<AuthResponse> {
+ ensure_crypto_provider();
+ let url = make_url(&self.address, "/api/v0/register")?;
+ let client = reqwest::Client::new();
+
+ let resp = client
+ .post(&url)
+ .header(USER_AGENT, APP_USER_AGENT)
+ .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)
+ .json(&serde_json::json!({
+ "email": email,
+ "username": username,
+ "password": password,
+ }))
+ .send()
+ .await
+ .context("failed to connect to Atuin Hub")?;
+
+ let status = resp.status();
+
+ if status.is_success() {
+ let reg: RegisterResponse = resp.json().await?;
+ return Ok(AuthResponse::Success {
+ session: reg.session,
+ });
+ }
+
+ if let Ok(err) = resp.json::<HubErrorResponse>().await {
+ bail!("{}", err.reason);
+ }
+
+ bail!("Hub registration failed with status {status}");
+ }
+
+ async fn change_password(
+ &self,
+ current_password: &str,
+ new_password: &str,
+ totp_code: Option<&str>,
+ ) -> Result<MutateResponse> {
+ let hub_token = self
+ .hub_token
+ .as_deref()
+ .ok_or_else(|| eyre::eyre!("Not logged in to Hub"))?;
+
+ ensure_crypto_provider();
+ let url = make_url(&self.address, "/api/v0/account/password")?;
+ let client = reqwest::Client::new();
+
+ let mut body = serde_json::json!({
+ "current_password": current_password,
+ "new_password": new_password,
+ });
+ if let Some(code) = totp_code {
+ body["totp_code"] = serde_json::Value::String(code.to_string());
+ }
+
+ let resp = client
+ .patch(&url)
+ .header(USER_AGENT, APP_USER_AGENT)
+ .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)
+ .bearer_auth(hub_token)
+ .json(&body)
+ .send()
+ .await
+ .context("failed to connect to Atuin Hub")?;
+
+ let status = resp.status();
+
+ if status.is_success() {
+ return Ok(MutateResponse::Success);
+ }
+
+ if let Ok(err) = resp.json::<HubErrorResponse>().await {
+ match err.code.as_deref() {
+ Some("2fa_required") => return Ok(MutateResponse::TwoFactorRequired),
+ Some("invalid_2fa_code") => bail!("invalid two-factor code"),
+ _ => bail!("{}", err.reason),
+ }
+ }
+
+ match status {
+ StatusCode::UNAUTHORIZED => bail!("current password is incorrect"),
+ StatusCode::FORBIDDEN => bail!("invalid login details"),
+ _ => bail!("Hub password change failed with status {status}"),
+ }
+ }
+
+ async fn delete_account(
+ &self,
+ password: &str,
+ totp_code: Option<&str>,
+ ) -> Result<MutateResponse> {
+ let hub_token = self
+ .hub_token
+ .as_deref()
+ .ok_or_else(|| eyre::eyre!("Not logged in to Hub"))?;
+
+ ensure_crypto_provider();
+ let url = make_url(&self.address, "/api/v0/account")?;
+ let client = reqwest::Client::new();
+
+ let mut body = serde_json::json!({
+ "password": password,
+ });
+ if let Some(code) = totp_code {
+ body["totp_code"] = serde_json::Value::String(code.to_string());
+ }
+
+ let resp = client
+ .delete(&url)
+ .header(USER_AGENT, APP_USER_AGENT)
+ .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)
+ .bearer_auth(hub_token)
+ .json(&body)
+ .send()
+ .await
+ .context("failed to connect to Atuin Hub")?;
+
+ let status = resp.status();
+
+ if status.is_success() {
+ return Ok(MutateResponse::Success);
+ }
+
+ if let Ok(err) = resp.json::<HubErrorResponse>().await {
+ match err.code.as_deref() {
+ Some("2fa_required") => return Ok(MutateResponse::TwoFactorRequired),
+ Some("invalid_2fa_code") => bail!("invalid two-factor code"),
+ _ => bail!("{}", err.reason),
+ }
+ }
+
+ match status {
+ StatusCode::UNAUTHORIZED => bail!("password is incorrect"),
+ StatusCode::FORBIDDEN => bail!("invalid login details"),
+ _ => bail!("Hub account deletion failed with status {status}"),
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Shared helpers
+// ---------------------------------------------------------------------------
+
+fn make_url(address: &str, path: &str) -> Result<String> {
+ let address = if address.ends_with('/') {
+ address.to_string()
+ } else {
+ format!("{address}/")
+ };
+
+ let path = path.strip_prefix('/').unwrap_or(path);
+
+ let url = Url::parse(&address)
+ .context("failed to parse server address")?
+ .join(path)
+ .context("failed to join URL path")?;
+
+ Ok(url.to_string())
+}
diff --git a/crates/atuin-client/src/lib.rs b/crates/atuin-client/src/lib.rs
index 352e5746..4609a8e8 100644
--- a/crates/atuin-client/src/lib.rs
+++ b/crates/atuin-client/src/lib.rs
@@ -5,6 +5,8 @@ extern crate log;
#[cfg(feature = "sync")]
pub mod api_client;
+#[cfg(feature = "sync")]
+pub mod auth;
#[cfg(feature = "hub")]
pub mod hub;
#[cfg(feature = "sync")]
diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs
index 2a96a2b3..745bd2ff 100644
--- a/crates/atuin-client/src/settings.rs
+++ b/crates/atuin-client/src/settings.rs
@@ -29,6 +29,26 @@ pub(crate) mod meta;
mod scripts;
pub mod watcher;
+pub struct HubEndpoint(String);
+
+/// Default sync address for Atuin's hosted service
+pub const DEFAULT_SYNC_ADDRESS: &str = "https://api.atuin.sh";
+
+/// Default Hub web/API endpoint for Atuin's hosted service
+pub const DEFAULT_HUB_ENDPOINT: &str = "https://hub.atuin.sh";
+
+impl Default for HubEndpoint {
+ fn default() -> Self {
+ HubEndpoint(DEFAULT_HUB_ENDPOINT.to_string())
+ }
+}
+
+impl AsRef<str> for HubEndpoint {
+ fn as_ref(&self) -> &str {
+ &self.0
+ }
+}
+
#[derive(Clone, Debug, Deserialize, Copy, ValueEnum, PartialEq, Serialize)]
pub enum SearchMode {
#[serde(rename = "prefix")]
@@ -1176,12 +1196,6 @@ impl Settings {
}
}
- /// Default sync address for Atuin's hosted service
- pub const DEFAULT_SYNC_ADDRESS: &'static str = "https://api.atuin.sh";
-
- /// Default Hub web/API endpoint for Atuin's hosted service
- pub const DEFAULT_HUB_ENDPOINT: &'static str = "https://hub.atuin.sh";
-
/// Normalize a URL for comparison by trimming trailing slashes
fn normalize_url(url: &str) -> &str {
url.trim_end_matches('/')
@@ -1190,8 +1204,8 @@ impl Settings {
/// Check if a URL matches one of Atuin's official hosted addresses
fn is_official_address(url: &str) -> bool {
let normalized = Self::normalize_url(url);
- normalized == Self::normalize_url(Self::DEFAULT_SYNC_ADDRESS)
- || normalized == Self::normalize_url(Self::DEFAULT_HUB_ENDPOINT)
+ normalized == Self::normalize_url(DEFAULT_SYNC_ADDRESS)
+ || normalized == Self::normalize_url(DEFAULT_HUB_ENDPOINT)
}
/// Returns whether this configuration uses Hub-style sync.
@@ -1213,12 +1227,12 @@ impl Settings {
/// For Atuin's official hosted service, this always returns `https://hub.atuin.sh`
/// regardless of whether `sync_address` is `api.atuin.sh` or `hub.atuin.sh`.
/// For self-hosted instances, returns the configured `sync_address`.
- pub fn active_hub_endpoint(&self) -> Option<String> {
+ pub fn active_hub_endpoint(&self) -> Option<HubEndpoint> {
if self.is_hub_sync() {
if Self::is_official_address(&self.sync_address) {
- Some(Self::DEFAULT_HUB_ENDPOINT.to_string())
+ Some(HubEndpoint::default())
} else {
- Some(self.sync_address.clone())
+ Some(HubEndpoint(self.sync_address.clone()))
}
} else {
None
diff --git a/crates/atuin-server/CHANGELOG.md b/crates/atuin-server/CHANGELOG.md
new file mode 120000
index 00000000..699cc9e7
--- /dev/null
+++ b/crates/atuin-server/CHANGELOG.md
@@ -0,0 +1 @@
+../../CHANGELOG.md \ No newline at end of file
diff --git a/crates/atuin/CHANGELOG.md b/crates/atuin/CHANGELOG.md
new file mode 120000
index 00000000..699cc9e7
--- /dev/null
+++ b/crates/atuin/CHANGELOG.md
@@ -0,0 +1 @@
+../../CHANGELOG.md \ No newline at end of file
diff --git a/crates/atuin/src/command/client/account.rs b/crates/atuin/src/command/client/account.rs
index e9861c0a..a1be65a5 100644
--- a/crates/atuin/src/command/client/account.rs
+++ b/crates/atuin/src/command/client/account.rs
@@ -11,8 +11,6 @@ pub mod login;
pub mod logout;
pub mod register;
-const DEFAULT_HUB_ENDPOINT: &str = "https://hub.atuin.sh";
-
#[derive(Args, Debug)]
pub struct Cmd {
#[command(subcommand)]
@@ -31,7 +29,7 @@ pub enum Commands {
Logout,
/// Delete your account, and all synced data
- Delete,
+ Delete(delete::Cmd),
/// Change your password
ChangePassword(change_password::Cmd),
@@ -46,7 +44,7 @@ impl Cmd {
Commands::Login(l) => l.run(&settings, &store).await,
Commands::Register(r) => r.run(&settings, &store).await,
Commands::Logout => logout::run().await,
- Commands::Delete => delete::run(&settings).await,
+ Commands::Delete(d) => d.run(&settings).await,
Commands::ChangePassword(c) => c.run(&settings).await,
Commands::Link => link::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
index 0f4a31cd..234d4dc0 100644
--- a/crates/atuin/src/command/client/account/change_password.rs
+++ b/crates/atuin/src/command/client/account/change_password.rs
@@ -1,11 +1,12 @@
use clap::Parser;
use eyre::{Result, bail};
-use atuin_client::{api_client, settings::Settings};
+use atuin_client::{
+ auth::{self, MutateResponse},
+ settings::Settings,
+};
use rpassword::prompt_password;
-use crate::command::client::account::DEFAULT_HUB_ENDPOINT;
-
#[derive(Parser, Debug)]
pub struct Cmd {
#[clap(long, short)]
@@ -13,65 +14,54 @@ pub struct Cmd {
#[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
- }
+ /// The two-factor authentication code for your account, if any
+ #[clap(long, short)]
+ pub totp_code: Option<String>,
}
-pub async fn run(
- settings: &Settings,
- current_password: Option<String>,
- new_password: Option<String>,
-) -> Result<()> {
- let using_hub_sync = settings.is_hub_sync();
- let has_sync_session = settings.session_token().await.is_ok();
- let has_hub_session = settings.hub_session_token().await.is_ok();
-
- if using_hub_sync && has_hub_session {
- let endpoint = settings
- .active_hub_endpoint()
- .unwrap_or_else(|| DEFAULT_HUB_ENDPOINT.to_string());
+impl Cmd {
+ pub async fn run(&self, settings: &Settings) -> Result<()> {
+ if !settings.logged_in().await? {
+ bail!("You are not logged in");
+ }
- println!("You are authenticated with Atuin Hub.");
- println!("Manage your account on the site: {endpoint}/settings/account");
- return Ok(());
- }
+ let client = auth::auth_client(settings).await;
- if !has_sync_session {
- bail!("You are not logged in");
- }
+ let current_password = self.current_password.clone().unwrap_or_else(|| {
+ prompt_password("Please enter the current password: ")
+ .expect("Failed to read from input")
+ });
- let client = api_client::Client::new(
- &settings.sync_address,
- settings.sync_auth_token().await?,
- settings.network_connect_timeout,
- settings.network_timeout,
- )?;
+ if current_password.is_empty() {
+ bail!("please provide the current password");
+ }
- let current_password = current_password.clone().unwrap_or_else(|| {
- prompt_password("Please enter the current password: ").expect("Failed to read from input")
- });
+ let new_password = self.new_password.clone().unwrap_or_else(|| {
+ prompt_password("Please enter the new password: ").expect("Failed to read from input")
+ });
- if current_password.is_empty() {
- bail!("please provide the current password");
- }
+ if new_password.is_empty() {
+ bail!("please provide a new password");
+ }
- let new_password = new_password.clone().unwrap_or_else(|| {
- prompt_password("Please enter the new password: ").expect("Failed to read from input")
- });
+ let mut totp_code = self.totp_code.clone();
- if new_password.is_empty() {
- bail!("please provide a new password");
- }
+ loop {
+ let response = client
+ .change_password(&current_password, &new_password, totp_code.as_deref())
+ .await?;
- client
- .change_password(current_password, new_password)
- .await?;
+ match response {
+ MutateResponse::Success => break,
+ MutateResponse::TwoFactorRequired => {
+ totp_code = Some(super::login::or_user_input(None, "two-factor code"));
+ }
+ }
+ }
- println!("Account password successfully changed!");
+ println!("Account password successfully changed!");
- Ok(())
+ Ok(())
+ }
}
diff --git a/crates/atuin/src/command/client/account/delete.rs b/crates/atuin/src/command/client/account/delete.rs
index 5c0439a3..7f8dc682 100644
--- a/crates/atuin/src/command/client/account/delete.rs
+++ b/crates/atuin/src/command/client/account/delete.rs
@@ -1,40 +1,58 @@
-use atuin_client::{api_client, settings::Settings};
+use atuin_client::{
+ auth::{self, MutateResponse},
+ settings::Settings,
+};
+use clap::Parser;
use eyre::{Result, bail};
-use crate::command::client::account::DEFAULT_HUB_ENDPOINT;
+use super::login::{or_user_input, read_user_password};
-pub async fn run(settings: &Settings) -> Result<()> {
- let using_hub_sync = settings.is_hub_sync();
- let has_sync_session = settings.session_token().await.is_ok();
- let has_hub_session = settings.hub_session_token().await.is_ok();
+#[derive(Parser, Debug)]
+pub struct Cmd {
+ #[clap(long, short)]
+ pub password: Option<String>,
- if using_hub_sync && has_hub_session {
- let endpoint = settings
- .active_hub_endpoint()
- .unwrap_or_else(|| DEFAULT_HUB_ENDPOINT.to_string());
- println!("You are authenticated with Atuin Hub.");
- println!("Manage your account on the site: {endpoint}/settings/account");
- return Ok(());
- }
+ /// The two-factor authentication code for your account, if any
+ #[clap(long, short)]
+ pub totp_code: Option<String>,
+}
- if !has_sync_session {
- bail!("You are not logged in");
- }
+impl Cmd {
+ pub 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 client = api_client::Client::new(
- &settings.sync_address,
- settings.sync_auth_token().await?,
- settings.network_connect_timeout,
- settings.network_timeout,
- )?;
+ let password = self.password.clone().unwrap_or_else(read_user_password);
- client.delete().await?;
+ if password.is_empty() {
+ bail!("please provide your password");
+ }
- // Clean up session from meta store
- Settings::meta_store().await?.delete_session().await?;
- Settings::meta_store().await?.delete_hub_session().await?;
+ let mut totp_code = self.totp_code.clone();
- println!("Your account is deleted");
+ loop {
+ let response = client
+ .delete_account(&password, totp_code.as_deref())
+ .await?;
- Ok(())
+ match response {
+ MutateResponse::Success => break,
+ MutateResponse::TwoFactorRequired => {
+ totp_code = Some(or_user_input(None, "two-factor code"));
+ }
+ }
+ }
+
+ // Clean up sessions from meta store
+ let meta = Settings::meta_store().await?;
+ meta.delete_session().await?;
+ meta.delete_hub_session().await?;
+
+ println!("Your account is deleted");
+
+ Ok(())
+ }
}
diff --git a/crates/atuin/src/command/client/account/link.rs b/crates/atuin/src/command/client/account/link.rs
index 5a2e4044..69c4eebe 100644
--- a/crates/atuin/src/command/client/account/link.rs
+++ b/crates/atuin/src/command/client/account/link.rs
@@ -2,8 +2,6 @@ use eyre::{Result, bail};
use atuin_client::settings::Settings;
-use super::DEFAULT_HUB_ENDPOINT;
-
pub async fn run(settings: &Settings) -> Result<()> {
let meta = Settings::meta_store().await?;
@@ -14,16 +12,14 @@ pub async fn run(settings: &Settings) -> Result<()> {
bail!("No CLI session found. Please log in first with 'atuin login'.");
};
- let hub_address = settings
- .active_hub_endpoint()
- .unwrap_or_else(|| DEFAULT_HUB_ENDPOINT.to_string());
+ let hub_address = settings.active_hub_endpoint().unwrap_or_default();
if hub_token.is_some() {
println!("Found both Hub and CLI sessions. Linking accounts...");
} else {
println!("Found CLI session but no Hub session. Logging in to Hub first...");
- let session = atuin_client::hub::HubAuthSession::start(&hub_address).await?;
+ let session = atuin_client::hub::HubAuthSession::start(hub_address.as_ref()).await?;
println!("Open this URL to authenticate with Atuin Hub:");
println!("{}", session.auth_url);
@@ -38,7 +34,7 @@ pub async fn run(settings: &Settings) -> Result<()> {
println!("Hub authentication complete.");
}
- atuin_client::hub::link_account(&hub_address, &cli_token).await?;
+ atuin_client::hub::link_account(hub_address.as_ref(), &cli_token).await?;
println!("Successfully linked CLI account to Hub.");
Ok(())
diff --git a/crates/atuin/src/command/client/account/login.rs b/crates/atuin/src/command/client/account/login.rs
index b8aad5a9..c9ba74c9 100644
--- a/crates/atuin/src/command/client/account/login.rs
+++ b/crates/atuin/src/command/client/account/login.rs
@@ -5,13 +5,12 @@ use eyre::{Context, Result, bail};
use tokio::{fs::File, io::AsyncWriteExt};
use atuin_client::{
- api_client,
+ auth::{self, AuthResponse},
encryption::{Key, decode_key, encode_key, load_key},
record::sqlite_store::SqliteStore,
record::store::Store,
settings::Settings,
};
-use atuin_common::api::LoginRequest;
use rpassword::prompt_password;
#[derive(Parser, Debug)]
@@ -26,6 +25,10 @@ pub struct Cmd {
#[clap(long, short)]
pub key: Option<String>,
+ /// The two-factor authentication code for your account, if any
+ #[clap(long, short)]
+ pub totp_code: Option<String>,
+
#[clap(long, hide = true)]
pub from_registration: bool,
}
@@ -38,35 +41,102 @@ fn get_input() -> Result<String> {
impl Cmd {
pub async fn run(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {
- if let Some(endpoint) = settings.active_hub_endpoint() {
- if settings.hub_session_token().await.is_ok() {
+ if settings.logged_in().await? {
+ if settings.is_hub_sync() {
println!("You are authenticated with Atuin Hub.");
- println!("Run 'atuin logout' to log out.");
- return Ok(());
+ } else {
+ println!("You are already logged in.");
}
+ println!("Run 'atuin logout' to log out.");
+ return Ok(());
+ }
+
+ if settings.is_hub_sync() {
+ self.run_hub_login(settings, store).await
+ } else {
+ self.run_legacy_login(settings, store).await
+ }
+ }
+
+ /// Hub login: use the browser OAuth flow unless all three flags
+ /// (username, password, key) were provided for headless/CI use.
+ async fn run_hub_login(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {
+ let endpoint = settings.active_hub_endpoint().unwrap_or_default();
+
+ if let Some(username) = &self.username {
+ // Headless login via v0 API (for CI / scripting).
+ let client = auth::auth_client(settings).await;
+
+ self.prompt_and_store_key(settings, store).await?;
+
+ let password = self.password.clone().unwrap_or_else(read_user_password);
+ let mut totp_code = self.totp_code.clone();
- // The only difference between login and registration is that registration doesn't prompt for a key
+ let session = loop {
+ let response = client
+ .login(username, &password, totp_code.as_deref())
+ .await?;
+
+ match response {
+ AuthResponse::Success { session } => break session,
+ AuthResponse::TwoFactorRequired => {
+ totp_code = Some(or_user_input(None, "two-factor code"));
+ }
+ }
+ };
+
+ Settings::meta_store()
+ .await?
+ .save_hub_session(&session)
+ .await?;
+ } else {
+ // Interactive login via browser OAuth flow.
if self.from_registration {
load_key(settings)?;
} else {
self.prompt_and_store_key(settings, store).await?;
}
- self.ensure_hub_session(settings, endpoint.as_str()).await?;
- println!("Successfully authenticated with Atuin Hub.");
- return Ok(());
+ self.ensure_hub_session(settings, endpoint.as_ref()).await?;
}
- if settings.logged_in().await? {
- println!("You are already logged in.");
- println!("Run 'atuin logout' to log out.");
- return Ok(());
+ // Silently attempt to link CLI account to Hub if one exists
+ if let Ok(cli_token) = settings.session_token().await
+ && let Err(e) = atuin_client::hub::link_account(endpoint.as_ref(), &cli_token).await
+ {
+ tracing::debug!("Could not link CLI account to Hub: {}", e);
+ }
+
+ println!("Successfully authenticated with Atuin Hub.");
+ Ok(())
+ }
+
+ /// 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, None).await?;
+
+ match response {
+ AuthResponse::Success { session } => {
+ Settings::meta_store().await?.save_session(&session).await?;
+ }
+ AuthResponse::TwoFactorRequired => {
+ // Legacy server doesn't support 2FA, so this shouldn't happen.
+ bail!("unexpected two-factor requirement from legacy server");
+ }
}
- self.run_sync_login(settings, store).await
+ println!("Logged in!");
+ Ok(())
}
- async fn ensure_hub_session(&self, settings: &Settings, hub_address: &str) -> Result<()> {
+ async fn ensure_hub_session(&self, _settings: &Settings, hub_address: &str) -> Result<()> {
tracing::info!("Authenticating with Atuin Hub...");
let session = atuin_client::hub::HubAuthSession::start(hub_address).await?;
@@ -84,45 +154,6 @@ impl Cmd {
atuin_client::hub::save_session(&token).await?;
- // Silently attempt to link CLI account to Hub if one exists
- // This enables unified auth - users can use their Hub token for sync
- if let Ok(cli_token) = settings.session_token().await {
- tracing::debug!("CLI session found, attempting to link accounts");
- if let Err(e) = atuin_client::hub::link_account(hub_address, &cli_token).await {
- tracing::debug!("Could not link CLI account to Hub: {}", e);
- } else {
- tracing::info!("Successfully linked CLI account to Hub");
- }
- }
-
- Ok(())
- }
-
- async fn run_sync_login(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {
- // TODO(ellie): Replace this with a call to atuin_client::login::login
- // The reason I haven't done this yet is that this implementation allows for
- // an empty key. This will use an existing key file.
- //
- // I'd quite like to ditch that behaviour, so have not brought it into the library
- // function.
- 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 session = api_client::login(
- settings.sync_address.as_str(),
- LoginRequest { username, password },
- )
- .await?;
-
- Settings::meta_store()
- .await?
- .save_session(&session.session)
- .await?;
-
- println!("Logged in!");
-
Ok(())
}
diff --git a/crates/atuin/src/command/client/account/register.rs b/crates/atuin/src/command/client/account/register.rs
index a2f4edfd..03a97512 100644
--- a/crates/atuin/src/command/client/account/register.rs
+++ b/crates/atuin/src/command/client/account/register.rs
@@ -2,7 +2,11 @@ use clap::Parser;
use eyre::{Result, bail};
use super::login::or_user_input;
-use atuin_client::{api_client, record::sqlite_store::SqliteStore, settings::Settings};
+use atuin_client::{
+ auth::{self, AuthResponse},
+ record::sqlite_store::SqliteStore,
+ settings::Settings,
+};
#[derive(Parser, Debug)]
pub struct Cmd {
@@ -17,71 +21,115 @@ pub struct Cmd {
}
impl Cmd {
- pub async fn run(self, settings: &Settings, store: &SqliteStore) -> Result<()> {
- run(settings, store, self.username, self.email, self.password).await
- }
-}
-
-pub async fn run(
- settings: &Settings,
- store: &SqliteStore,
- username: Option<String>,
- email: Option<String>,
- password: Option<String>,
-) -> Result<()> {
- if let Some(_endpoint) = settings.active_hub_endpoint() {
- if settings.hub_session_token().await.is_ok() {
- println!("You are already authenticated with Atuin Hub.");
+ pub async fn run(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {
+ if settings.logged_in().await? {
+ if settings.is_hub_sync() {
+ println!("You are already authenticated with Atuin Hub.");
+ } else {
+ println!("You are already logged in.");
+ }
println!("Run 'atuin logout' to log out.");
return Ok(());
}
- // Login can also handle registration, as the registration piece for Hub auth lives on the server
- // (e.g. create a new Hub account, then log in as normal)
- super::login::Cmd {
- username: None,
- password: None,
- key: None,
- from_registration: true,
- }
- .run(settings, store)
- .await?;
- return Ok(());
- }
+ if settings.is_hub_sync() {
+ let required_for_headless = 3;
+ let provided = [
+ self.username.is_some(),
+ self.email.is_some(),
+ self.password.is_some(),
+ ]
+ .iter()
+ .filter(|&b| *b)
+ .count();
+ if provided < required_for_headless {
+ println!(
+ "Username, password, and email are all required for headless registration. Continuing with interactive registration.\n"
+ );
+ }
- if settings.session_token().await.is_ok() {
- println!("You are already logged in.");
- println!("Run 'atuin logout' to log out.");
- return Ok(());
- }
+ if let (Some(username), Some(email), Some(password)) =
+ (&self.username, &self.email, &self.password)
+ {
+ // Headless registration via v0 API (for CI / scripting).
+ let client = auth::auth_client(settings).await;
- println!("Registering for an Atuin Sync account");
+ if password.is_empty() {
+ bail!("please provide a password");
+ }
- let username = or_user_input(username, "username");
- let email = or_user_input(email, "email");
+ let response = client.register(username, email, password).await?;
- let password = password
- .clone()
- .unwrap_or_else(super::login::read_user_password);
+ match response {
+ AuthResponse::Success { session } => {
+ Settings::meta_store()
+ .await?
+ .save_hub_session(&session)
+ .await?;
+ }
+ AuthResponse::TwoFactorRequired => {
+ bail!("unexpected two-factor requirement during registration");
+ }
+ }
- if password.is_empty() {
- bail!("please provide a password");
- }
+ let _key = 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."
+ );
+ } else {
+ // Interactive registration: delegate to the browser OAuth flow.
+ // Registration on Hub happens on the website; the CLI just needs
+ // to authenticate afterwards.
+ super::login::Cmd {
+ username: None,
+ password: None,
+ key: None,
+ totp_code: None,
+ from_registration: true,
+ }
+ .run(settings, store)
+ .await?;
+ }
+ } else {
+ // 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);
- let session =
- api_client::register(settings.sync_address.as_str(), &username, &email, &password).await?;
+ if password.is_empty() {
+ bail!("please provide a password");
+ }
- let meta = Settings::meta_store().await?;
- meta.save_session(&session.session).await?;
+ let session = atuin_client::api_client::register(
+ settings.sync_address.as_str(),
+ &username,
+ &email,
+ &password,
+ )
+ .await?;
- let _key = atuin_client::encryption::load_key(settings)?;
+ let meta = Settings::meta_store().await?;
+ meta.save_session(&session.session).await?;
- 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."
- );
+ let _key = atuin_client::encryption::load_key(settings)?;
- Ok(())
+ 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/atuin/src/shell/atuin.nu b/crates/atuin/src/shell/atuin.nu
index 3bc59edc..d6cf915b 100644
--- a/crates/atuin/src/shell/atuin.nu
+++ b/crates/atuin/src/shell/atuin.nu
@@ -36,11 +36,7 @@ let _atuin_pre_prompt = {||
return
}
with-env { ATUIN_LOG: error } {
- if (version).minor >= 111 or (version).major > 0 {
- job spawn -d atuin {
- ^atuin history end $'--exit=($env.LAST_EXIT_CODE)' -- $env.ATUIN_HISTORY_ID | complete
- } | ignore
- } else if (version).minor >= 104 or (version).major > 0 {
+ if (version).minor >= 104 or (version).major > 0 {
job spawn {
^atuin history end $'--exit=($env.LAST_EXIT_CODE)' -- $env.ATUIN_HISTORY_ID | complete
} | ignore
diff --git a/depot.json b/depot.json
new file mode 100644
index 00000000..e51895a6
--- /dev/null
+++ b/depot.json
@@ -0,0 +1 @@
+{"id":"v6vqpk6559"}
diff --git a/dist-workspace.toml b/dist-workspace.toml
index 62620156..96556337 100644
--- a/dist-workspace.toml
+++ b/dist-workspace.toml
@@ -21,3 +21,10 @@ install-updater = true
unix-archive = ".tar.gz"
# Whether to enable GitHub Attestations
github-attestations = true
+
+[dist.github-custom-runners]
+aarch64-apple-darwin = "depot-macos-latest"
+aarch64-unknown-linux-gnu = "depot-ubuntu-24.04-arm-8"
+aarch64-unknown-linux-musl = "depot-ubuntu-24.04-arm-8"
+x86_64-unknown-linux-gnu = "depot-ubuntu-24.04-8"
+x86_64-unknown-linux-musl = "depot-ubuntu-24.04-8"