diff options
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 @@ -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(¤t_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" |
