├── .cargo └── audit.toml ├── .codespellrc ├── .dockerignore ├── .gitattributes ├── .github ├── DISCUSSION_TEMPLATE │ └── support.yml ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug.yaml ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── codespell.yml │ ├── docker.yaml │ ├── installer.yml │ ├── nix.yml │ ├── release.yml │ ├── rust.yml │ ├── shellcheck.yml │ └── update-nix-deps.yml ├── .gitignore ├── .mailmap ├── .rustfmt.toml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── CONTRIBUTORS ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── atuin.nix ├── atuin.plugin.zsh ├── cliff.toml ├── crates ├── atuin-client │ ├── Cargo.toml │ ├── config.toml │ ├── migrations │ │ ├── 20210422143411_create_history.sql │ │ ├── 20220505083406_create-events.sql │ │ ├── 20220806155627_interactive_search_index.sql │ │ ├── 20230315220114_drop-events.sql │ │ └── 20230319185725_deleted_at.sql │ ├── record-migrations │ │ ├── 20230531212437_create-records.sql │ │ └── 20231127090831_create-store.sql │ ├── src │ │ ├── api_client.rs │ │ ├── database.rs │ │ ├── encryption.rs │ │ ├── history.rs │ │ ├── history │ │ │ ├── builder.rs │ │ │ └── store.rs │ │ ├── import │ │ │ ├── bash.rs │ │ │ ├── fish.rs │ │ │ ├── mod.rs │ │ │ ├── nu.rs │ │ │ ├── nu_histdb.rs │ │ │ ├── replxx.rs │ │ │ ├── resh.rs │ │ │ ├── xonsh.rs │ │ │ ├── xonsh_sqlite.rs │ │ │ ├── zsh.rs │ │ │ └── zsh_histdb.rs │ │ ├── kv.rs │ │ ├── lib.rs │ │ ├── login.rs │ │ ├── logout.rs │ │ ├── ordering.rs │ │ ├── record │ │ │ ├── encryption.rs │ │ │ ├── mod.rs │ │ │ ├── sqlite_store.rs │ │ │ ├── store.rs │ │ │ └── sync.rs │ │ ├── register.rs │ │ ├── secrets.rs │ │ ├── settings.rs │ │ ├── settings │ │ │ ├── dotfiles.rs │ │ │ └── scripts.rs │ │ ├── sync.rs │ │ ├── theme.rs │ │ └── utils.rs │ └── tests │ │ └── data │ │ ├── xonsh-history.sqlite │ │ └── xonsh │ │ ├── xonsh-82eafbf5-9f43-489a-80d2-61c7dc6ef542.json │ │ └── xonsh-de16af90-9148-4461-8df3-5b5659c6420d.json ├── atuin-common │ ├── Cargo.toml │ └── src │ │ ├── api.rs │ │ ├── calendar.rs │ │ ├── lib.rs │ │ ├── record.rs │ │ ├── shell.rs │ │ └── utils.rs ├── atuin-daemon │ ├── Cargo.toml │ ├── build.rs │ ├── proto │ │ └── history.proto │ └── src │ │ ├── client.rs │ │ ├── history.rs │ │ ├── lib.rs │ │ ├── server.rs │ │ └── server │ │ └── sync.rs ├── atuin-dotfiles │ ├── Cargo.toml │ └── src │ │ ├── lib.rs │ │ ├── shell.rs │ │ ├── shell │ │ ├── bash.rs │ │ ├── fish.rs │ │ ├── xonsh.rs │ │ └── zsh.rs │ │ ├── store.rs │ │ └── store │ │ ├── alias.rs │ │ └── var.rs ├── atuin-history │ ├── Cargo.toml │ ├── benches │ │ └── smart_sort.rs │ └── src │ │ ├── lib.rs │ │ ├── sort.rs │ │ └── stats.rs ├── atuin-scripts │ ├── Cargo.toml │ ├── migrations │ │ ├── 20250326160051_create_scripts.down.sql │ │ ├── 20250326160051_create_scripts.up.sql │ │ ├── 20250402170430_unique_names.down.sql │ │ └── 20250402170430_unique_names.up.sql │ └── src │ │ ├── database.rs │ │ ├── execution.rs │ │ ├── lib.rs │ │ ├── settings.rs │ │ ├── store.rs │ │ └── store │ │ ├── record.rs │ │ └── script.rs ├── atuin-server-database │ ├── Cargo.toml │ └── src │ │ ├── calendar.rs │ │ ├── lib.rs │ │ └── models.rs ├── atuin-server-postgres │ ├── Cargo.toml │ ├── build.rs │ ├── migrations │ │ ├── 20210425153745_create_history.sql │ │ ├── 20210425153757_create_users.sql │ │ ├── 20210425153800_create_sessions.sql │ │ ├── 20220419082412_add_count_trigger.sql │ │ ├── 20220421073605_fix_count_trigger_delete.sql │ │ ├── 20220421174016_larger-commands.sql │ │ ├── 20220426172813_user-created-at.sql │ │ ├── 20220505082442_create-events.sql │ │ ├── 20220610074049_history-length.sql │ │ ├── 20230315220537_drop-events.sql │ │ ├── 20230315224203_create-deleted.sql │ │ ├── 20230515221038_trigger-delete-only.sql │ │ ├── 20230623070418_records.sql │ │ ├── 20231202170508_create-store.sql │ │ ├── 20231203124112_create-store-idx.sql │ │ ├── 20240108124837_drop-some-defaults.sql │ │ ├── 20240614104159_idx-cache.sql │ │ ├── 20240621110731_user-verified.sql │ │ └── 20240702094825_idx_cache_index.sql │ └── src │ │ ├── lib.rs │ │ └── wrappers.rs ├── atuin-server │ ├── Cargo.toml │ ├── server.toml │ └── src │ │ ├── handlers │ │ ├── health.rs │ │ ├── history.rs │ │ ├── mod.rs │ │ ├── record.rs │ │ ├── status.rs │ │ ├── user.rs │ │ └── v0 │ │ │ ├── me.rs │ │ │ ├── mod.rs │ │ │ ├── record.rs │ │ │ └── store.rs │ │ ├── lib.rs │ │ ├── metrics.rs │ │ ├── router.rs │ │ ├── settings.rs │ │ └── utils.rs └── atuin │ ├── Cargo.toml │ ├── LICENSE │ ├── README.md │ ├── build.rs │ ├── src │ ├── command │ │ ├── CONTRIBUTORS │ │ ├── client.rs │ │ ├── client │ │ │ ├── account.rs │ │ │ ├── account │ │ │ │ ├── change_password.rs │ │ │ │ ├── delete.rs │ │ │ │ ├── login.rs │ │ │ │ ├── logout.rs │ │ │ │ ├── register.rs │ │ │ │ └── verify.rs │ │ │ ├── daemon.rs │ │ │ ├── default_config.rs │ │ │ ├── doctor.rs │ │ │ ├── dotfiles.rs │ │ │ ├── dotfiles │ │ │ │ ├── alias.rs │ │ │ │ └── var.rs │ │ │ ├── history.rs │ │ │ ├── import.rs │ │ │ ├── info.rs │ │ │ ├── init.rs │ │ │ ├── init │ │ │ │ ├── bash.rs │ │ │ │ ├── fish.rs │ │ │ │ ├── xonsh.rs │ │ │ │ └── zsh.rs │ │ │ ├── kv.rs │ │ │ ├── scripts.rs │ │ │ ├── search.rs │ │ │ ├── search │ │ │ │ ├── cursor.rs │ │ │ │ ├── duration.rs │ │ │ │ ├── engines.rs │ │ │ │ ├── engines │ │ │ │ │ ├── db.rs │ │ │ │ │ └── skim.rs │ │ │ │ ├── history_list.rs │ │ │ │ ├── inspector.rs │ │ │ │ └── interactive.rs │ │ │ ├── stats.rs │ │ │ ├── store.rs │ │ │ ├── store │ │ │ │ ├── pull.rs │ │ │ │ ├── purge.rs │ │ │ │ ├── push.rs │ │ │ │ ├── rebuild.rs │ │ │ │ ├── rekey.rs │ │ │ │ └── verify.rs │ │ │ ├── sync.rs │ │ │ ├── sync │ │ │ │ └── status.rs │ │ │ └── wrapped.rs │ │ ├── contributors.rs │ │ ├── external.rs │ │ ├── gen_completions.rs │ │ ├── mod.rs │ │ └── server.rs │ ├── main.rs │ ├── shell │ │ ├── .gitattributes │ │ ├── atuin.bash │ │ ├── atuin.fish │ │ ├── atuin.nu │ │ ├── atuin.xsh │ │ └── atuin.zsh │ └── sync.rs │ └── tests │ ├── common │ └── mod.rs │ ├── sync.rs │ └── users.rs ├── default.nix ├── demo.gif ├── deny.toml ├── dist-workspace.toml ├── docker-compose.yml ├── docs ├── .gitignore ├── ru │ ├── config_ru.md │ ├── import_ru.md │ ├── key-binding_ru.md │ ├── list_ru.md │ ├── search_ru.md │ ├── server_ru.md │ ├── shell-completions_ru.md │ ├── stats_ru.md │ └── sync_ru.md └── zh-CN │ ├── README.md │ ├── config.md │ ├── docker.md │ ├── import.md │ ├── k8s.md │ ├── key-binding.md │ ├── list.md │ ├── search.md │ ├── server.md │ ├── shell-completions.md │ ├── stats.md │ └── sync.md ├── flake.lock ├── flake.nix ├── install.sh ├── k8s ├── atuin.yaml ├── namespaces.yaml └── secrets.yaml ├── rust-toolchain.toml └── systemd ├── atuin-server.service └── atuin-server.sysusers /.cargo/audit.toml: -------------------------------------------------------------------------------- 1 | [advisories] 2 | ignore = [ 3 | # This is a vuln on RSA. RSA is in our lockfile, but not in cargo-tree. 4 | # It is a issue with sqlx/cargo, and does not affect Atuin. 5 | # See: 6 | # - https://github.com/launchbadge/sqlx/issues/3211 7 | # - https://github.com/rust-lang/cargo/issues/10801 8 | "RUSTSEC-2023-0071" 9 | ] 10 | -------------------------------------------------------------------------------- /.codespellrc: -------------------------------------------------------------------------------- 1 | [codespell] 2 | # Ref: https://github.com/codespell-project/codespell#using-a-config-file 3 | skip = .git*,*.lock,.codespellrc,CODE_OF_CONDUCT.md,CONTRIBUTORS 4 | check-hidden = true 5 | # ignore-regex = 6 | ignore-words-list = crate,ratatui,inbetween 7 | 8 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | ./target 2 | Dockerfile 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sh eol=lf 2 | *.nix eol=lf 3 | *.zsh eol=lf 4 | 5 | *.sql eol=lf 6 | -------------------------------------------------------------------------------- /.github/DISCUSSION_TEMPLATE/support.yml: -------------------------------------------------------------------------------- 1 | body: 2 | - type: input 3 | attributes: 4 | label: Operating System 5 | description: What operating system are you using? 6 | placeholder: "Example: macOS Big Sur" 7 | validations: 8 | required: true 9 | 10 | - type: input 11 | attributes: 12 | label: Shell 13 | description: What shell are you using? 14 | placeholder: "Example: zsh 5.8.1" 15 | validations: 16 | required: true 17 | 18 | - type: dropdown 19 | attributes: 20 | label: Version 21 | description: What version of atuin are you running? 22 | multiple: false 23 | options: # how often will I forget to update this? a lot. 24 | - v17.0.0 (Default) 25 | - v16.0.0 26 | - v15.0.0 27 | - v14.0.1 28 | - v14.0.0 29 | - v13.0.1 30 | - v13.0.0 31 | - v12.0.0 32 | - v11.0.0 33 | - v0.10.0 34 | - v0.9.1 35 | - v0.9.0 36 | - v0.8.1 37 | - v0.8.0 38 | - v0.7.2 39 | - v0.7.1 40 | - v0.7.0 41 | - v0.6.4 42 | - v0.6.3 43 | default: 0 44 | validations: 45 | required: true 46 | 47 | - type: checkboxes 48 | attributes: 49 | label: Self hosted 50 | description: Are you self hosting atuin server? 51 | options: 52 | - label: I am self hosting atuin server 53 | 54 | - type: checkboxes 55 | attributes: 56 | label: Search the issues 57 | description: Did you search the issues and discussions for your problem? 58 | options: 59 | - label: I checked that someone hasn't already asked about the same issue 60 | required: true 61 | 62 | - type: textarea 63 | attributes: 64 | label: Behaviour 65 | description: "Please describe the issue - what you expected to happen, what actually happened" 66 | 67 | - type: textarea 68 | attributes: 69 | label: Logs 70 | description: "If possible, please include logs from atuin, especially if you self host the server - ATUIN_LOG=debug" 71 | 72 | - type: textarea 73 | attributes: 74 | label: Extra information 75 | description: "Anything else you'd like to add?" 76 | 77 | - type: checkboxes 78 | attributes: 79 | label: Code of Conduct 80 | description: The Code of Conduct helps create a safe space for everyone. We require 81 | that everyone agrees to it. 82 | options: 83 | - label: I agree to follow this project's [Code of Conduct](https://github.com/atuinsh/atuin/blob/main/CODE_OF_CONDUCT.md) 84 | required: true 85 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [atuinsh] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: ["bug", "triage"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this bug report! 10 | - type: textarea 11 | id: what-expected 12 | attributes: 13 | label: What did you expect to happen? 14 | placeholder: Tell us what you expected to see! 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: what-happened 19 | attributes: 20 | label: What happened? 21 | placeholder: Tell us what you see! 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: doctor 26 | validations: 27 | required: true 28 | attributes: 29 | label: Atuin doctor output 30 | description: Please run 'atuin doctor' and share the output. If it fails to run, share any errors. This requires Atuin >=v18.1.0 31 | render: yaml 32 | - type: checkboxes 33 | id: terms 34 | attributes: 35 | label: Code of Conduct 36 | description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/atuinsh/atuin/blob/main/CODE_OF_CONDUCT.md) 37 | options: 38 | - label: I agree to follow this project's Code of Conduct 39 | required: true 40 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "docker" # See documentation for possible values 13 | directory: "/" # Location of package manifests 14 | schedule: 15 | interval: "weekly" 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | schedule: 19 | interval: "weekly" 20 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Checks 4 | - [ ] I am happy for maintainers to push small adjustments to this PR, to speed up the review cycle 5 | - [ ] I have checked that there are no existing pull requests for the same thing 6 | -------------------------------------------------------------------------------- /.github/workflows/codespell.yml: -------------------------------------------------------------------------------- 1 | # Codespell configuration is within .codespellrc 2 | --- 3 | name: Codespell 4 | 5 | on: 6 | push: 7 | branches: [main] 8 | pull_request: 9 | branches: [main] 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | codespell: 16 | name: Check for spelling errors 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | - name: Codespell 23 | uses: codespell-project/actions-codespell@v2 24 | with: 25 | # This is regenerated from commit history 26 | # we cannot rewrite commit history, and I'd rather not correct it 27 | # every time 28 | exclude_file: CHANGELOG.md 29 | -------------------------------------------------------------------------------- /.github/workflows/installer.yml: -------------------------------------------------------------------------------- 1 | name: Install 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | paths: .github/workflows/installer.yml 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | install: 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest, macos-14] 17 | runs-on: ${{ matrix.os }} 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Install zsh for ubuntu 23 | if: matrix.os == 'ubuntu-latest' 24 | run: | 25 | sudo apt install zsh 26 | 27 | - name: Test install script on bash 28 | run: | 29 | /bin/bash -c "$(curl --proto '=https' --tlsv1.2 -sSf https://setup.atuin.sh)" 30 | [ -d "$HOME/.atuin" ] && source $HOME/.atuin/bin/env 31 | atuin --help 32 | 33 | - name: Test install script on zsh 34 | shell: zsh {0} 35 | run: | 36 | /bin/bash -c "$(curl --proto '=https' --tlsv1.2 -sSf https://setup.atuin.sh)" 37 | [ -d "$HOME/.atuin" ] && source $HOME/.atuin/bin/env 38 | atuin --help 39 | -------------------------------------------------------------------------------- /.github/workflows/nix.yml: -------------------------------------------------------------------------------- 1 | # Verify the Nix build is working 2 | # Failures will usually occur due to an out of date Rust version 3 | # That can be updated to the latest version in nixpkgs-unstable with `nix flake update` 4 | name: Nix 5 | on: 6 | push: 7 | branches: [ main ] 8 | paths-ignore: 9 | - 'ui/**' 10 | pull_request: 11 | branches: [ main ] 12 | paths-ignore: 13 | - 'ui/**' 14 | 15 | jobs: 16 | check: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: cachix/install-nix-action@v31 22 | 23 | - name: Run nix flake check 24 | run: nix flake check --print-build-logs 25 | 26 | build-test: 27 | runs-on: ubuntu-latest 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | - uses: cachix/install-nix-action@v31 32 | 33 | - name: Run nix build 34 | run: nix build --print-build-logs 35 | -------------------------------------------------------------------------------- /.github/workflows/shellcheck.yml: -------------------------------------------------------------------------------- 1 | name: Shellcheck 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | shellcheck: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Run shellcheck 16 | uses: ludeeus/action-shellcheck@master 17 | env: 18 | SHELLCHECK_OPTS: "-e SC2148" 19 | -------------------------------------------------------------------------------- /.github/workflows/update-nix-deps.yml: -------------------------------------------------------------------------------- 1 | name: Update Nix Deps 2 | on: 3 | workflow_dispatch: # allows manual triggering 4 | schedule: 5 | - cron: '0 0 1 * *' # runs monthly on the first day of the month at 00:00 6 | 7 | jobs: 8 | lockfile: 9 | runs-on: ubuntu-latest 10 | if: github.repository == 'atuinsh/atuin' 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | - name: Install Nix 15 | uses: DeterminateSystems/nix-installer-action@main 16 | - name: Update flake.lock 17 | uses: DeterminateSystems/update-flake-lock@main 18 | with: 19 | pr-title: "chore(deps): update flake.lock" 20 | pr-labels: | 21 | dependencies 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /target 3 | */target 4 | .env 5 | .idea/ 6 | .vscode/ 7 | result 8 | publish.sh 9 | .envrc 10 | 11 | ui/backend/target 12 | ui/backend/gen 13 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | networkException 2 | Violet Shreve 3 | Chris Rose 4 | Conrad Ludgate 5 | Cristian Le 6 | Dennis Trautwein 7 | Ellie Huxtable 8 | Ellie Huxtable 9 | Frank Hamand 10 | Jakob Schrettenbrunner 11 | Nemo157 12 | Richard de Boer 13 | Sandro 14 | TymanWasTaken 15 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | reorder_imports = true 2 | # uncomment once stable 3 | #imports_granularity = "crate" 4 | #group_imports = "StdExternalCrate" 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you so much for considering contributing to Atuin! We really appreciate it <3 4 | 5 | Development dependencies 6 | 7 | 1. A rust toolchain ([rustup](https://rustup.rs) recommended) 8 | 9 | We commit to supporting the latest stable version of Rust - nothing more, nothing less, no nightly. 10 | 11 | Before working on anything, we suggest taking a copy of your Atuin data directory (`~/.local/share/atuin` on most \*nix platforms). If anything goes wrong, you can always restore it! 12 | 13 | While data directory backups are always a good idea, you can instruct Atuin to use custom path using the following environment variables: 14 | 15 | ```shell 16 | export ATUIN_DB_PATH=/tmp/atuin_dev.db 17 | export ATUIN_RECORD_STORE_PATH=/tmp/atuin_records.db 18 | ``` 19 | 20 | It is also recommended to update your `$PATH` so that the pre-exec scripts would use the locally built version: 21 | 22 | ```shell 23 | export PATH="./target/release:$PATH" 24 | ``` 25 | 26 | These 3 variables can be added in a local `.envrc` file, read by [direnv](https://direnv.net/). 27 | 28 | ## PRs 29 | 30 | It can speed up the review cycle if you consent to maintainers pushing to your branch. This will only be in the case of small fixes or adjustments, and not anything large. If you feel OK with this, please check the box on the template! 31 | 32 | ## What to work on? 33 | 34 | Any issues labeled "bug" or "help wanted" would be fantastic, just drop a comment and feel free to ask for help! 35 | 36 | If there's anything you want to work on that isn't already an issue, either open a feature request or get in touch on the [forum](https://forum.atuin.sh)/Discord. 37 | 38 | ## Setup 39 | 40 | ``` 41 | git clone https://github.com/atuinsh/atuin 42 | cd atuin 43 | cargo build 44 | ``` 45 | 46 | ## Running 47 | 48 | When iterating on a feature, it's useful to use `cargo run` 49 | 50 | For example, if working on a search feature 51 | 52 | ``` 53 | cargo run -- search --a-new-flag 54 | ``` 55 | 56 | While iterating on the server, I find it helpful to run a new user on my system, with `sync_server` set to be `localhost`. 57 | 58 | ## Tests 59 | 60 | Our test coverage is currently not the best, but we are working on it! Generally tests live in the file next to the functionality they are testing, and are executed just with `cargo test`. 61 | 62 | 63 | ## Migrations 64 | 65 | Be careful creating database migrations - once your database has migrated ahead of current stable, there is no going back 66 | 67 | ### Stickers 68 | 69 | We try to ship anyone contributing to Atuin a sticker! Only contributors get a shiny one. Fill out [this form](https://notionforms.io/forms/contributors-stickers) if you'd like one. 70 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["crates/*"] 3 | 4 | resolver = "2" 5 | exclude = ["ui/backend"] 6 | 7 | [workspace.package] 8 | version = "18.5.0" 9 | authors = ["Ellie Huxtable "] 10 | rust-version = "1.86" 11 | license = "MIT" 12 | homepage = "https://atuin.sh" 13 | repository = "https://github.com/atuinsh/atuin" 14 | readme = "README.md" 15 | 16 | [workspace.dependencies] 17 | async-trait = "0.1.58" 18 | base64 = "0.22" 19 | log = "0.4" 20 | time = { version = "0.3.36", features = [ 21 | "serde-human-readable", 22 | "macros", 23 | "local-offset", 24 | ] } 25 | clap = { version = "4.5.7", features = ["derive"] } 26 | config = { version = "0.15.8", default-features = false, features = ["toml"] } 27 | directories = "5.0.1" 28 | eyre = "0.6" 29 | fs-err = "3.1" 30 | interim = { version = "0.2.0", features = ["time_0_3"] } 31 | itertools = "0.13.0" 32 | rand = { version = "0.8.5", features = ["std"] } 33 | semver = "1.0.20" 34 | serde = { version = "1.0.202", features = ["derive"] } 35 | serde_json = "1.0.119" 36 | tokio = { version = "1", features = ["full"] } 37 | uuid = { version = "1.9", features = ["v4", "v7", "serde"] } 38 | whoami = "1.5.1" 39 | typed-builder = "0.18.2" 40 | pretty_assertions = "1.3.0" 41 | thiserror = "1.0" 42 | rustix = { version = "0.38.34", features = ["process", "fs"] } 43 | tower = "0.4" 44 | tracing = "0.1" 45 | sql-builder = "3" 46 | tempfile = { version = "3.19" } 47 | minijinja = "2.9.0" 48 | 49 | [workspace.dependencies.tracing-subscriber] 50 | version = "0.3" 51 | features = ["ansi", "fmt", "registry", "env-filter"] 52 | 53 | [workspace.dependencies.reqwest] 54 | version = "0.11" 55 | features = ["json", "rustls-tls-native-roots"] 56 | default-features = false 57 | 58 | [workspace.dependencies.sqlx] 59 | version = "0.8" 60 | features = ["runtime-tokio-rustls", "time", "postgres", "uuid"] 61 | 62 | # The profile that 'cargo dist' will build with 63 | [profile.dist] 64 | inherits = "release" 65 | lto = "thin" 66 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM lukemathwalker/cargo-chef:latest-rust-1.86.0-slim-bookworm AS chef 2 | WORKDIR app 3 | 4 | FROM chef AS planner 5 | COPY . . 6 | RUN cargo chef prepare --recipe-path recipe.json 7 | 8 | FROM chef AS builder 9 | 10 | # Ensure working C compile setup (not installed by default in arm64 images) 11 | RUN apt update && apt install build-essential -y 12 | 13 | COPY --from=planner /app/recipe.json recipe.json 14 | RUN cargo chef cook --release --recipe-path recipe.json 15 | 16 | COPY . . 17 | RUN cargo build --release --bin atuin 18 | 19 | FROM debian:bookworm-20250407-slim AS runtime 20 | 21 | RUN useradd -c 'atuin user' atuin && mkdir /config && chown atuin:atuin /config 22 | # Install ca-certificates for webhooks to work 23 | RUN apt update && apt install ca-certificates -y && rm -rf /var/lib/apt/lists/* 24 | WORKDIR app 25 | 26 | USER atuin 27 | 28 | ENV TZ=Etc/UTC 29 | ENV RUST_LOG=atuin::api=info 30 | ENV ATUIN_CONFIG_DIR=/config 31 | 32 | COPY --from=builder /app/target/release/atuin /usr/local/bin 33 | ENTRYPOINT ["/usr/local/bin/atuin"] 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ellie Huxtable 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /atuin.nix: -------------------------------------------------------------------------------- 1 | # Atuin package definition 2 | # 3 | # This file will be similar to the package definition in nixpkgs: 4 | # https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/at/atuin/package.nix 5 | # 6 | # Helpful documentation: https://github.com/NixOS/nixpkgs/blob/master/doc/languages-frameworks/rust.section.md 7 | { 8 | lib, 9 | stdenv, 10 | installShellFiles, 11 | rustPlatform, 12 | libiconv, 13 | Security, 14 | SystemConfiguration, 15 | AppKit, 16 | }: 17 | rustPlatform.buildRustPackage { 18 | name = "atuin"; 19 | 20 | src = lib.cleanSource ./.; 21 | 22 | cargoLock = { 23 | lockFile = ./Cargo.lock; 24 | # Allow dependencies to be fetched from git and avoid having to set the outputHashes manually 25 | allowBuiltinFetchGit = true; 26 | }; 27 | 28 | nativeBuildInputs = [installShellFiles]; 29 | 30 | buildInputs = lib.optionals stdenv.isDarwin [libiconv Security SystemConfiguration AppKit]; 31 | 32 | postInstall = '' 33 | installShellCompletion --cmd atuin \ 34 | --bash <($out/bin/atuin gen-completions -s bash) \ 35 | --fish <($out/bin/atuin gen-completions -s fish) \ 36 | --zsh <($out/bin/atuin gen-completions -s zsh) 37 | ''; 38 | 39 | doCheck = false; 40 | 41 | meta = with lib; { 42 | description = "Replacement for a shell history which records additional commands context with optional encrypted synchronization between machines"; 43 | homepage = "https://github.com/atuinsh/atuin"; 44 | license = licenses.mit; 45 | mainProgram = "atuin"; 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /atuin.plugin.zsh: -------------------------------------------------------------------------------- 1 | # shellcheck disable=2148,SC2168,SC1090,SC2125 2 | local FOUND_ATUIN=$+commands[atuin] 3 | 4 | if [[ $FOUND_ATUIN -eq 1 ]]; then 5 | source <(atuin init zsh) 6 | fi 7 | -------------------------------------------------------------------------------- /crates/atuin-client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "atuin-client" 3 | edition = "2024" 4 | description = "client library for atuin" 5 | 6 | rust-version = { workspace = true } 7 | version = { workspace = true } 8 | authors = { workspace = true } 9 | license = { workspace = true } 10 | homepage = { workspace = true } 11 | repository = { workspace = true } 12 | 13 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 14 | 15 | [features] 16 | default = ["sync", "daemon"] 17 | sync = ["urlencoding", "reqwest", "sha2", "hex"] 18 | daemon = [] 19 | check-update = [] 20 | 21 | [dependencies] 22 | atuin-common = { path = "../atuin-common", version = "18.5.0" } 23 | 24 | log = { workspace = true } 25 | base64 = { workspace = true } 26 | time = { workspace = true, features = ["macros", "formatting", "parsing"] } 27 | clap = { workspace = true } 28 | eyre = { workspace = true } 29 | directories = { workspace = true } 30 | uuid = { workspace = true } 31 | whoami = { workspace = true } 32 | interim = { workspace = true } 33 | config = { workspace = true } 34 | serde = { workspace = true } 35 | serde_json = { workspace = true } 36 | humantime = "2.1.0" 37 | async-trait = { workspace = true } 38 | itertools = { workspace = true } 39 | rand = { workspace = true } 40 | shellexpand = "3" 41 | sqlx = { workspace = true, features = ["sqlite", "regexp"] } 42 | minspan = "0.1.1" 43 | regex = "1.10.5" 44 | serde_regex = "1.1.0" 45 | fs-err = { workspace = true } 46 | sql-builder = { workspace = true } 47 | memchr = "2.7" 48 | rmp = { version = "0.8.14" } 49 | typed-builder = { workspace = true } 50 | tokio = { workspace = true } 51 | semver = { workspace = true } 52 | thiserror = { workspace = true } 53 | futures = "0.3" 54 | crypto_secretbox = "0.1.1" 55 | generic-array = { version = "0.14", features = ["serde"] } 56 | serde_with = "3.8.1" 57 | 58 | # encryption 59 | rusty_paseto = { version = "0.7.0", default-features = false } 60 | rusty_paserk = { version = "0.4.0", default-features = false, features = ["v4", "serde"] } 61 | 62 | # sync 63 | urlencoding = { version = "2.1.0", optional = true } 64 | reqwest = { workspace = true, optional = true } 65 | hex = { version = "0.4", optional = true } 66 | sha2 = { version = "0.10", optional = true } 67 | indicatif = "0.17.7" 68 | tiny-bip39 = "=1.0.0" 69 | 70 | # theme 71 | crossterm = { version = "0.28.1", features = ["serde"] } 72 | palette = { version = "0.7.5", features = ["serializing"] } 73 | lazy_static = "1.4.0" 74 | strum_macros = "0.26.3" 75 | strum = { version = "0.26.2", features = ["strum_macros"] } 76 | 77 | [dev-dependencies] 78 | tokio = { version = "1", features = ["full"] } 79 | pretty_assertions = { workspace = true } 80 | testing_logger = "0.1.1" 81 | -------------------------------------------------------------------------------- /crates/atuin-client/migrations/20210422143411_create_history.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | create table if not exists history ( 3 | id text primary key, 4 | timestamp integer not null, 5 | duration integer not null, 6 | exit integer not null, 7 | command text not null, 8 | cwd text not null, 9 | session text not null, 10 | hostname text not null, 11 | 12 | unique(timestamp, cwd, command) 13 | ); 14 | 15 | create index if not exists idx_history_timestamp on history(timestamp); 16 | create index if not exists idx_history_command on history(command); 17 | -------------------------------------------------------------------------------- /crates/atuin-client/migrations/20220505083406_create-events.sql: -------------------------------------------------------------------------------- 1 | create table if not exists events ( 2 | id text primary key, 3 | timestamp integer not null, 4 | hostname text not null, 5 | event_type text not null, 6 | 7 | history_id text not null 8 | ); 9 | 10 | -- Ensure there is only ever one of each event type per history item 11 | create unique index history_event_idx ON events(event_type, history_id); 12 | -------------------------------------------------------------------------------- /crates/atuin-client/migrations/20220806155627_interactive_search_index.sql: -------------------------------------------------------------------------------- 1 | -- Interactive search filters by command then by the max(timestamp) for that 2 | -- command. Create an index that covers those 3 | create index if not exists idx_history_command_timestamp on history( 4 | command, 5 | timestamp 6 | ); 7 | -------------------------------------------------------------------------------- /crates/atuin-client/migrations/20230315220114_drop-events.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | drop table events; 3 | -------------------------------------------------------------------------------- /crates/atuin-client/migrations/20230319185725_deleted_at.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | alter table history add column deleted_at integer; 3 | -------------------------------------------------------------------------------- /crates/atuin-client/record-migrations/20230531212437_create-records.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | create table if not exists records ( 3 | id text primary key, 4 | parent text unique, -- null if this is the first one 5 | host text not null, 6 | 7 | timestamp integer not null, 8 | tag text not null, 9 | version text not null, 10 | data blob not null, 11 | cek blob not null 12 | ); 13 | 14 | create index host_idx on records (host); 15 | create index tag_idx on records (tag); 16 | create index host_tag_idx on records (host, tag); 17 | -------------------------------------------------------------------------------- /crates/atuin-client/record-migrations/20231127090831_create-store.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | create table if not exists store ( 3 | id text primary key, -- globally unique ID 4 | 5 | idx integer, -- incrementing integer ID unique per (host, tag) 6 | host text not null, -- references the host row 7 | tag text not null, 8 | 9 | timestamp integer not null, 10 | version text not null, 11 | data blob not null, 12 | cek blob not null 13 | ); 14 | 15 | create unique index record_uniq ON store(host, tag, idx); 16 | -------------------------------------------------------------------------------- /crates/atuin-client/src/import/mod.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::Read; 3 | use std::path::PathBuf; 4 | 5 | use async_trait::async_trait; 6 | use eyre::{Result, bail}; 7 | use memchr::Memchr; 8 | 9 | use crate::history::History; 10 | 11 | pub mod bash; 12 | pub mod fish; 13 | pub mod nu; 14 | pub mod nu_histdb; 15 | pub mod replxx; 16 | pub mod resh; 17 | pub mod xonsh; 18 | pub mod xonsh_sqlite; 19 | pub mod zsh; 20 | pub mod zsh_histdb; 21 | 22 | #[async_trait] 23 | pub trait Importer: Sized { 24 | const NAME: &'static str; 25 | async fn new() -> Result; 26 | async fn entries(&mut self) -> Result; 27 | async fn load(self, loader: &mut impl Loader) -> Result<()>; 28 | } 29 | 30 | #[async_trait] 31 | pub trait Loader: Sync + Send { 32 | async fn push(&mut self, hist: History) -> eyre::Result<()>; 33 | } 34 | 35 | fn unix_byte_lines(input: &[u8]) -> impl Iterator { 36 | UnixByteLines { 37 | iter: memchr::memchr_iter(b'\n', input), 38 | bytes: input, 39 | i: 0, 40 | } 41 | } 42 | 43 | struct UnixByteLines<'a> { 44 | iter: Memchr<'a>, 45 | bytes: &'a [u8], 46 | i: usize, 47 | } 48 | 49 | impl<'a> Iterator for UnixByteLines<'a> { 50 | type Item = &'a [u8]; 51 | 52 | fn next(&mut self) -> Option { 53 | let j = self.iter.next()?; 54 | let out = &self.bytes[self.i..j]; 55 | self.i = j + 1; 56 | Some(out) 57 | } 58 | 59 | fn count(self) -> usize 60 | where 61 | Self: Sized, 62 | { 63 | self.iter.count() 64 | } 65 | } 66 | 67 | fn count_lines(input: &[u8]) -> usize { 68 | unix_byte_lines(input).count() 69 | } 70 | 71 | fn get_histpath(def: D) -> Result 72 | where 73 | D: FnOnce() -> Result, 74 | { 75 | if let Ok(p) = std::env::var("HISTFILE") { 76 | Ok(PathBuf::from(p)) 77 | } else { 78 | def() 79 | } 80 | } 81 | 82 | fn get_histfile_path(def: D) -> Result 83 | where 84 | D: FnOnce() -> Result, 85 | { 86 | get_histpath(def).and_then(is_file) 87 | } 88 | 89 | fn get_histdir_path(def: D) -> Result 90 | where 91 | D: FnOnce() -> Result, 92 | { 93 | get_histpath(def).and_then(is_dir) 94 | } 95 | 96 | fn read_to_end(path: PathBuf) -> Result> { 97 | let mut bytes = Vec::new(); 98 | let mut f = File::open(path)?; 99 | f.read_to_end(&mut bytes)?; 100 | Ok(bytes) 101 | } 102 | fn is_file(p: PathBuf) -> Result { 103 | if p.is_file() { 104 | Ok(p) 105 | } else { 106 | bail!("Could not find history file {:?}. Try setting $HISTFILE", p) 107 | } 108 | } 109 | fn is_dir(p: PathBuf) -> Result { 110 | if p.is_dir() { 111 | Ok(p) 112 | } else { 113 | bail!( 114 | "Could not find history directory {:?}. Try setting $HISTFILE", 115 | p 116 | ) 117 | } 118 | } 119 | 120 | #[cfg(test)] 121 | mod tests { 122 | use super::*; 123 | 124 | #[derive(Default)] 125 | pub struct TestLoader { 126 | pub buf: Vec, 127 | } 128 | 129 | #[async_trait] 130 | impl Loader for TestLoader { 131 | async fn push(&mut self, hist: History) -> Result<()> { 132 | self.buf.push(hist); 133 | Ok(()) 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /crates/atuin-client/src/import/nu.rs: -------------------------------------------------------------------------------- 1 | // import old shell history! 2 | // automatically hoover up all that we can find 3 | 4 | use std::path::PathBuf; 5 | 6 | use async_trait::async_trait; 7 | use directories::BaseDirs; 8 | use eyre::{Result, eyre}; 9 | use time::OffsetDateTime; 10 | 11 | use super::{Importer, Loader, unix_byte_lines}; 12 | use crate::history::History; 13 | use crate::import::read_to_end; 14 | 15 | #[derive(Debug)] 16 | pub struct Nu { 17 | bytes: Vec, 18 | } 19 | 20 | fn get_histpath() -> Result { 21 | let base = BaseDirs::new().ok_or_else(|| eyre!("could not determine data directory"))?; 22 | let config_dir = base.config_dir().join("nushell"); 23 | 24 | let histpath = config_dir.join("history.txt"); 25 | if histpath.exists() { 26 | Ok(histpath) 27 | } else { 28 | Err(eyre!("Could not find history file.")) 29 | } 30 | } 31 | 32 | #[async_trait] 33 | impl Importer for Nu { 34 | const NAME: &'static str = "nu"; 35 | 36 | async fn new() -> Result { 37 | let bytes = read_to_end(get_histpath()?)?; 38 | Ok(Self { bytes }) 39 | } 40 | 41 | async fn entries(&mut self) -> Result { 42 | Ok(super::count_lines(&self.bytes)) 43 | } 44 | 45 | async fn load(self, h: &mut impl Loader) -> Result<()> { 46 | let now = OffsetDateTime::now_utc(); 47 | 48 | let mut counter = 0; 49 | for b in unix_byte_lines(&self.bytes) { 50 | let s = match std::str::from_utf8(b) { 51 | Ok(s) => s, 52 | Err(_) => continue, // we can skip past things like invalid utf8 53 | }; 54 | 55 | let cmd: String = s.replace("<\\n>", "\n"); 56 | 57 | let offset = time::Duration::nanoseconds(counter); 58 | counter += 1; 59 | 60 | let entry = History::import().timestamp(now - offset).command(cmd); 61 | 62 | h.push(entry.build().into()).await?; 63 | } 64 | 65 | Ok(()) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /crates/atuin-client/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(unsafe_code)] 2 | 3 | #[macro_use] 4 | extern crate log; 5 | 6 | #[cfg(feature = "sync")] 7 | pub mod api_client; 8 | #[cfg(feature = "sync")] 9 | pub mod sync; 10 | 11 | pub mod database; 12 | pub mod encryption; 13 | pub mod history; 14 | pub mod import; 15 | pub mod kv; 16 | pub mod login; 17 | pub mod logout; 18 | pub mod ordering; 19 | pub mod record; 20 | pub mod register; 21 | pub mod secrets; 22 | pub mod settings; 23 | pub mod theme; 24 | 25 | mod utils; 26 | -------------------------------------------------------------------------------- /crates/atuin-client/src/login.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use atuin_common::api::LoginRequest; 4 | use eyre::{Context, Result, bail}; 5 | use tokio::fs::File; 6 | use tokio::io::AsyncWriteExt; 7 | 8 | use crate::{ 9 | api_client, 10 | encryption::{Key, decode_key, encode_key, load_key}, 11 | record::{sqlite_store::SqliteStore, store::Store}, 12 | settings::Settings, 13 | }; 14 | 15 | pub async fn login( 16 | settings: &Settings, 17 | store: &SqliteStore, 18 | username: String, 19 | password: String, 20 | key: String, 21 | ) -> Result { 22 | // try parse the key as a mnemonic... 23 | let key = match bip39::Mnemonic::from_phrase(&key, bip39::Language::English) { 24 | Ok(mnemonic) => encode_key(Key::from_slice(mnemonic.entropy()))?, 25 | Err(err) => { 26 | match err.downcast_ref::() { 27 | Some(err) => { 28 | match err { 29 | // assume they copied in the base64 key 30 | bip39::ErrorKind::InvalidWord => key, 31 | bip39::ErrorKind::InvalidChecksum => { 32 | bail!("key mnemonic was not valid") 33 | } 34 | bip39::ErrorKind::InvalidKeysize(_) 35 | | bip39::ErrorKind::InvalidWordLength(_) 36 | | bip39::ErrorKind::InvalidEntropyLength(_, _) => { 37 | bail!("key was not the correct length") 38 | } 39 | } 40 | } 41 | _ => { 42 | // unknown error. assume they copied the base64 key 43 | key 44 | } 45 | } 46 | } 47 | }; 48 | 49 | let key_path = settings.key_path.as_str(); 50 | let key_path = PathBuf::from(key_path); 51 | 52 | if !key_path.exists() { 53 | if decode_key(key.clone()).is_err() { 54 | bail!("the specified key was invalid"); 55 | } 56 | 57 | let mut file = File::create(key_path).await?; 58 | file.write_all(key.as_bytes()).await?; 59 | } else { 60 | // we now know that the user has logged in specifying a key, AND that the key path 61 | // exists 62 | 63 | // 1. check if the saved key and the provided key match. if so, nothing to do. 64 | // 2. if not, re-encrypt the local history and overwrite the key 65 | let current_key: [u8; 32] = load_key(settings)?.into(); 66 | 67 | let encoded = key.clone(); // gonna want to save it in a bit 68 | let new_key: [u8; 32] = decode_key(key) 69 | .context("could not decode provided key - is not valid base64")? 70 | .into(); 71 | 72 | if new_key != current_key { 73 | println!("\nRe-encrypting local store with new key"); 74 | 75 | store.re_encrypt(¤t_key, &new_key).await?; 76 | 77 | println!("Writing new key"); 78 | let mut file = File::create(key_path).await?; 79 | file.write_all(encoded.as_bytes()).await?; 80 | } 81 | } 82 | 83 | let session = api_client::login( 84 | settings.sync_address.as_str(), 85 | LoginRequest { username, password }, 86 | ) 87 | .await?; 88 | 89 | let session_path = settings.session_path.as_str(); 90 | let mut file = File::create(session_path).await?; 91 | file.write_all(session.session.as_bytes()).await?; 92 | 93 | Ok(session.session) 94 | } 95 | -------------------------------------------------------------------------------- /crates/atuin-client/src/logout.rs: -------------------------------------------------------------------------------- 1 | use eyre::{Context, Result}; 2 | use fs_err::remove_file; 3 | 4 | use crate::settings::Settings; 5 | 6 | pub fn logout(settings: &Settings) -> Result<()> { 7 | let session_path = settings.session_path.as_str(); 8 | 9 | if settings.logged_in() { 10 | remove_file(session_path).context("Failed to remove session file")?; 11 | println!("You have logged out!"); 12 | } else { 13 | println!("You are not logged in"); 14 | } 15 | 16 | Ok(()) 17 | } 18 | -------------------------------------------------------------------------------- /crates/atuin-client/src/ordering.rs: -------------------------------------------------------------------------------- 1 | use minspan::minspan; 2 | 3 | use super::{history::History, settings::SearchMode}; 4 | 5 | pub fn reorder_fuzzy(mode: SearchMode, query: &str, res: Vec) -> Vec { 6 | match mode { 7 | SearchMode::Fuzzy => reorder(query, |x| &x.command, res), 8 | _ => res, 9 | } 10 | } 11 | 12 | fn reorder(query: &str, f: F, res: Vec) -> Vec 13 | where 14 | F: Fn(&A) -> &String, 15 | A: Clone, 16 | { 17 | let mut r = res.clone(); 18 | let qvec = &query.chars().collect(); 19 | r.sort_by_cached_key(|h| { 20 | // TODO for fzf search we should sum up scores for each matched term 21 | let (from, to) = match minspan::span(qvec, &(f(h).chars().collect())) { 22 | Some(x) => x, 23 | // this is a little unfortunate: when we are asked to match a query that is found nowhere, 24 | // we don't want to return a None, as the comparison behaviour would put the worst matches 25 | // at the front. therefore, we'll return a set of indices that are one larger than the longest 26 | // possible legitimate match. This is meaningless except as a comparison. 27 | None => (0, res.len()), 28 | }; 29 | 1 + to - from 30 | }); 31 | r 32 | } 33 | -------------------------------------------------------------------------------- /crates/atuin-client/src/record/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod encryption; 2 | pub mod sqlite_store; 3 | pub mod store; 4 | 5 | #[cfg(feature = "sync")] 6 | pub mod sync; 7 | -------------------------------------------------------------------------------- /crates/atuin-client/src/record/store.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use eyre::Result; 3 | 4 | use atuin_common::record::{EncryptedData, HostId, Record, RecordId, RecordIdx, RecordStatus}; 5 | 6 | /// A record store stores records 7 | /// In more detail - we tend to need to process this into _another_ format to actually query it. 8 | /// As is, the record store is intended as the source of truth for arbitrary data, which could 9 | /// be shell history, kvs, etc. 10 | #[async_trait] 11 | pub trait Store { 12 | // Push a record 13 | async fn push(&self, record: &Record) -> Result<()> { 14 | self.push_batch(std::iter::once(record)).await 15 | } 16 | 17 | // Push a batch of records, all in one transaction 18 | async fn push_batch( 19 | &self, 20 | records: impl Iterator> + Send + Sync, 21 | ) -> Result<()>; 22 | 23 | async fn get(&self, id: RecordId) -> Result>; 24 | 25 | async fn delete(&self, id: RecordId) -> Result<()>; 26 | async fn delete_all(&self) -> Result<()>; 27 | 28 | async fn len_all(&self) -> Result; 29 | async fn len(&self, host: HostId, tag: &str) -> Result; 30 | async fn len_tag(&self, tag: &str) -> Result; 31 | 32 | async fn last(&self, host: HostId, tag: &str) -> Result>>; 33 | async fn first(&self, host: HostId, tag: &str) -> Result>>; 34 | 35 | async fn re_encrypt(&self, old_key: &[u8; 32], new_key: &[u8; 32]) -> Result<()>; 36 | async fn verify(&self, key: &[u8; 32]) -> Result<()>; 37 | async fn purge(&self, key: &[u8; 32]) -> Result<()>; 38 | 39 | /// Get the next `limit` records, after and including the given index 40 | async fn next( 41 | &self, 42 | host: HostId, 43 | tag: &str, 44 | idx: RecordIdx, 45 | limit: u64, 46 | ) -> Result>>; 47 | 48 | /// Get the first record for a given host and tag 49 | async fn idx( 50 | &self, 51 | host: HostId, 52 | tag: &str, 53 | idx: RecordIdx, 54 | ) -> Result>>; 55 | 56 | async fn status(&self) -> Result; 57 | 58 | /// Get all records for a given tag 59 | async fn all_tagged(&self, tag: &str) -> Result>>; 60 | } 61 | -------------------------------------------------------------------------------- /crates/atuin-client/src/register.rs: -------------------------------------------------------------------------------- 1 | use eyre::Result; 2 | use tokio::fs::File; 3 | use tokio::io::AsyncWriteExt; 4 | 5 | use crate::{api_client, settings::Settings}; 6 | 7 | pub async fn register( 8 | settings: &Settings, 9 | username: String, 10 | email: String, 11 | password: String, 12 | ) -> Result { 13 | let session = 14 | api_client::register(settings.sync_address.as_str(), &username, &email, &password).await?; 15 | 16 | let path = settings.session_path.as_str(); 17 | let mut file = File::create(path).await?; 18 | file.write_all(session.session.as_bytes()).await?; 19 | 20 | let _key = crate::encryption::load_key(settings)?; 21 | 22 | Ok(session.session) 23 | } 24 | -------------------------------------------------------------------------------- /crates/atuin-client/src/settings/dotfiles.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Serialize, Deserialize, Clone, Default)] 4 | pub struct Settings { 5 | #[serde(alias = "enable")] 6 | pub enabled: bool, 7 | } 8 | -------------------------------------------------------------------------------- /crates/atuin-client/src/settings/scripts.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Serialize, Deserialize, Clone)] 4 | pub struct Settings { 5 | pub database_path: String, 6 | } 7 | 8 | impl Default for Settings { 9 | fn default() -> Self { 10 | let dir = atuin_common::utils::data_dir(); 11 | let path = dir.join("scripts.db"); 12 | 13 | Self { 14 | database_path: path.to_string_lossy().to_string(), 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /crates/atuin-client/src/utils.rs: -------------------------------------------------------------------------------- 1 | pub(crate) fn get_hostname() -> String { 2 | std::env::var("ATUIN_HOST_NAME").unwrap_or_else(|_| { 3 | whoami::fallible::hostname().unwrap_or_else(|_| "unknown-host".to_string()) 4 | }) 5 | } 6 | 7 | pub(crate) fn get_username() -> String { 8 | std::env::var("ATUIN_HOST_USER").unwrap_or_else(|_| whoami::username()) 9 | } 10 | 11 | /// Returns a pair of the hostname and username, separated by a colon. 12 | pub(crate) fn get_host_user() -> String { 13 | format!("{}:{}", get_hostname(), get_username()) 14 | } 15 | -------------------------------------------------------------------------------- /crates/atuin-client/tests/data/xonsh-history.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atuinsh/atuin/5cd23537b07fa37ed593eaec5cfe968be2a4ac9e/crates/atuin-client/tests/data/xonsh-history.sqlite -------------------------------------------------------------------------------- /crates/atuin-common/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "atuin-common" 3 | edition = "2024" 4 | description = "common library for atuin" 5 | 6 | rust-version = { workspace = true } 7 | version = { workspace = true } 8 | authors = { workspace = true } 9 | license = { workspace = true } 10 | homepage = { workspace = true } 11 | repository = { workspace = true } 12 | 13 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 14 | 15 | [dependencies] 16 | time = { workspace = true } 17 | serde = { workspace = true } 18 | uuid = { workspace = true } 19 | typed-builder = { workspace = true } 20 | eyre = { workspace = true } 21 | sqlx = { workspace = true } 22 | semver = { workspace = true } 23 | thiserror = { workspace = true } 24 | directories = { workspace = true } 25 | sysinfo = "0.30.7" 26 | base64 = { workspace = true } 27 | getrandom = "0.2" 28 | 29 | lazy_static = "1.4.0" 30 | 31 | [dev-dependencies] 32 | pretty_assertions = { workspace = true } 33 | -------------------------------------------------------------------------------- /crates/atuin-common/src/calendar.rs: -------------------------------------------------------------------------------- 1 | // Calendar data 2 | use serde::{Serialize, Deserialize}; 3 | 4 | pub enum TimePeriod { 5 | YEAR, 6 | MONTH, 7 | DAY, 8 | } 9 | 10 | #[derive(Debug, Serialize, Deserialize)] 11 | pub struct TimePeriodInfo { 12 | pub count: u64, 13 | 14 | // TODO: Use this for merkle tree magic 15 | pub hash: String, 16 | } 17 | -------------------------------------------------------------------------------- /crates/atuin-common/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(unsafe_code)] 2 | 3 | /// Defines a new UUID type wrapper 4 | macro_rules! new_uuid { 5 | ($name:ident) => { 6 | #[derive( 7 | Debug, 8 | Copy, 9 | Clone, 10 | PartialEq, 11 | Eq, 12 | Hash, 13 | PartialOrd, 14 | Ord, 15 | serde::Serialize, 16 | serde::Deserialize, 17 | )] 18 | #[serde(transparent)] 19 | pub struct $name(pub Uuid); 20 | 21 | impl sqlx::Type for $name 22 | where 23 | Uuid: sqlx::Type, 24 | { 25 | fn type_info() -> ::TypeInfo { 26 | Uuid::type_info() 27 | } 28 | } 29 | 30 | impl<'r, DB: sqlx::Database> sqlx::Decode<'r, DB> for $name 31 | where 32 | Uuid: sqlx::Decode<'r, DB>, 33 | { 34 | fn decode( 35 | value: DB::ValueRef<'r>, 36 | ) -> std::result::Result { 37 | Uuid::decode(value).map(Self) 38 | } 39 | } 40 | 41 | impl<'q, DB: sqlx::Database> sqlx::Encode<'q, DB> for $name 42 | where 43 | Uuid: sqlx::Encode<'q, DB>, 44 | { 45 | fn encode_by_ref( 46 | &self, 47 | buf: &mut DB::ArgumentBuffer<'q>, 48 | ) -> Result> 49 | { 50 | self.0.encode_by_ref(buf) 51 | } 52 | } 53 | }; 54 | } 55 | 56 | pub mod api; 57 | pub mod record; 58 | pub mod shell; 59 | pub mod utils; 60 | -------------------------------------------------------------------------------- /crates/atuin-daemon/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "atuin-daemon" 3 | edition = "2024" 4 | version = { workspace = true } 5 | description = "The daemon crate for Atuin" 6 | 7 | authors.workspace = true 8 | rust-version.workspace = true 9 | license.workspace = true 10 | homepage.workspace = true 11 | repository.workspace = true 12 | readme.workspace = true 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [dependencies] 17 | atuin-client = { path = "../atuin-client", version = "18.5.0" } 18 | atuin-dotfiles = { path = "../atuin-dotfiles", version = "18.5.0" } 19 | atuin-history = { path = "../atuin-history", version = "18.5.0" } 20 | 21 | time = { workspace = true } 22 | uuid = { workspace = true } 23 | tokio = { workspace = true } 24 | tower = { workspace = true } 25 | eyre = { workspace = true } 26 | tracing = { workspace = true } 27 | tracing-subscriber = { workspace = true } 28 | 29 | dashmap = "5.5.3" 30 | tonic-types = "0.12.0" 31 | tonic = "0.12" 32 | prost = "0.13" 33 | prost-types = "0.13" 34 | tokio-stream = {version="0.1.14", features=["net"]} 35 | hyper-util = "0.1" 36 | 37 | rand.workspace = true 38 | 39 | [target.'cfg(target_os = "linux")'.dependencies] 40 | listenfd = "1.0.1" 41 | 42 | [build-dependencies] 43 | protox = "0.8.0" 44 | tonic-build = "0.12" 45 | -------------------------------------------------------------------------------- /crates/atuin-daemon/build.rs: -------------------------------------------------------------------------------- 1 | use std::{env, fs, path::PathBuf}; 2 | 3 | use protox::prost::Message; 4 | 5 | fn main() -> std::io::Result<()> { 6 | let proto_paths = ["proto/history.proto"]; 7 | let proto_include_dirs = ["proto"]; 8 | 9 | let file_descriptors = protox::compile(proto_paths, proto_include_dirs).unwrap(); 10 | 11 | let file_descriptor_path = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR not set")) 12 | .join("file_descriptor_set.bin"); 13 | fs::write(&file_descriptor_path, file_descriptors.encode_to_vec()).unwrap(); 14 | 15 | tonic_build::configure() 16 | .build_server(true) 17 | .file_descriptor_set_path(&file_descriptor_path) 18 | .skip_protoc_run() 19 | .compile_protos(&proto_paths, &proto_include_dirs) 20 | } 21 | -------------------------------------------------------------------------------- /crates/atuin-daemon/proto/history.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package history; 3 | 4 | message StartHistoryRequest { 5 | // If people are still using my software in ~530 years, they can figure out a u128 migration 6 | uint64 timestamp = 1; // nanosecond unix epoch 7 | string command = 2; 8 | string cwd = 3; 9 | string session = 4; 10 | string hostname = 5; 11 | } 12 | 13 | message EndHistoryRequest { 14 | string id = 1; 15 | int64 exit = 2; 16 | uint64 duration = 3; 17 | } 18 | 19 | message StartHistoryReply { 20 | string id = 1; 21 | } 22 | 23 | message EndHistoryReply { 24 | string id = 1; 25 | uint64 idx = 2; 26 | } 27 | 28 | service History { 29 | rpc StartHistory(StartHistoryRequest) returns (StartHistoryReply); 30 | rpc EndHistory(EndHistoryRequest) returns (EndHistoryReply); 31 | } 32 | -------------------------------------------------------------------------------- /crates/atuin-daemon/src/client.rs: -------------------------------------------------------------------------------- 1 | use eyre::{Context, Result}; 2 | #[cfg(windows)] 3 | use tokio::net::TcpStream; 4 | use tonic::transport::{Channel, Endpoint, Uri}; 5 | use tower::service_fn; 6 | 7 | use hyper_util::rt::TokioIo; 8 | 9 | #[cfg(unix)] 10 | use tokio::net::UnixStream; 11 | 12 | use atuin_client::history::History; 13 | 14 | use crate::history::{ 15 | EndHistoryRequest, StartHistoryRequest, history_client::HistoryClient as HistoryServiceClient, 16 | }; 17 | 18 | pub struct HistoryClient { 19 | client: HistoryServiceClient, 20 | } 21 | 22 | // Wrap the grpc client 23 | impl HistoryClient { 24 | #[cfg(unix)] 25 | pub async fn new(path: String) -> Result { 26 | let log_path = path.clone(); 27 | let channel = Endpoint::try_from("http://atuin_local_daemon:0")? 28 | .connect_with_connector(service_fn(move |_: Uri| { 29 | let path = path.clone(); 30 | 31 | async move { 32 | Ok::<_, std::io::Error>(TokioIo::new(UnixStream::connect(path.clone()).await?)) 33 | } 34 | })) 35 | .await 36 | .wrap_err_with(|| { 37 | format!( 38 | "failed to connect to local atuin daemon at {}. Is it running?", 39 | &log_path 40 | ) 41 | })?; 42 | 43 | let client = HistoryServiceClient::new(channel); 44 | 45 | Ok(HistoryClient { client }) 46 | } 47 | 48 | #[cfg(not(unix))] 49 | pub async fn new(port: u64) -> Result { 50 | let channel = Endpoint::try_from("http://atuin_local_daemon:0")? 51 | .connect_with_connector(service_fn(move |_: Uri| { 52 | let url = format!("127.0.0.1:{}", port); 53 | 54 | async move { 55 | Ok::<_, std::io::Error>(TokioIo::new(TcpStream::connect(url.clone()).await?)) 56 | } 57 | })) 58 | .await 59 | .wrap_err_with(|| { 60 | format!( 61 | "failed to connect to local atuin daemon at 127.0.0.1:{}. Is it running?", 62 | port 63 | ) 64 | })?; 65 | 66 | let client = HistoryServiceClient::new(channel); 67 | 68 | Ok(HistoryClient { client }) 69 | } 70 | 71 | pub async fn start_history(&mut self, h: History) -> Result { 72 | let req = StartHistoryRequest { 73 | command: h.command, 74 | cwd: h.cwd, 75 | hostname: h.hostname, 76 | session: h.session, 77 | timestamp: h.timestamp.unix_timestamp_nanos() as u64, 78 | }; 79 | 80 | let resp = self.client.start_history(req).await?; 81 | 82 | Ok(resp.into_inner().id) 83 | } 84 | 85 | pub async fn end_history( 86 | &mut self, 87 | id: String, 88 | duration: u64, 89 | exit: i64, 90 | ) -> Result<(String, u64)> { 91 | let req = EndHistoryRequest { id, duration, exit }; 92 | 93 | let resp = self.client.end_history(req).await?; 94 | let resp = resp.into_inner(); 95 | 96 | Ok((resp.id, resp.idx)) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /crates/atuin-daemon/src/history.rs: -------------------------------------------------------------------------------- 1 | tonic::include_proto!("history"); 2 | -------------------------------------------------------------------------------- /crates/atuin-daemon/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod client; 2 | pub mod history; 3 | pub mod server; 4 | -------------------------------------------------------------------------------- /crates/atuin-daemon/src/server/sync.rs: -------------------------------------------------------------------------------- 1 | use eyre::Result; 2 | use rand::Rng; 3 | use tokio::time::{self, MissedTickBehavior}; 4 | 5 | use atuin_client::database::Sqlite as HistoryDatabase; 6 | use atuin_client::{ 7 | encryption, 8 | history::store::HistoryStore, 9 | record::{sqlite_store::SqliteStore, sync}, 10 | settings::Settings, 11 | }; 12 | 13 | use atuin_dotfiles::store::{AliasStore, var::VarStore}; 14 | 15 | pub async fn worker( 16 | settings: Settings, 17 | store: SqliteStore, 18 | history_store: HistoryStore, 19 | history_db: HistoryDatabase, 20 | ) -> Result<()> { 21 | tracing::info!("booting sync worker"); 22 | 23 | let encryption_key: [u8; 32] = encryption::load_key(&settings)?.into(); 24 | let host_id = Settings::host_id().expect("failed to get host_id"); 25 | let alias_store = AliasStore::new(store.clone(), host_id, encryption_key); 26 | let var_store = VarStore::new(store.clone(), host_id, encryption_key); 27 | 28 | // Don't backoff by more than 30 mins (with a random jitter of up to 1 min) 29 | let max_interval: f64 = 60.0 * 30.0 + rand::thread_rng().gen_range(0.0..60.0); 30 | 31 | let mut ticker = time::interval(time::Duration::from_secs(settings.daemon.sync_frequency)); 32 | 33 | // IMPORTANT: without this, if we miss ticks because a sync takes ages or is otherwise delayed, 34 | // we may end up running a lot of syncs in a hot loop. No bueno! 35 | ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); 36 | 37 | loop { 38 | ticker.tick().await; 39 | tracing::info!("sync worker tick"); 40 | 41 | if !settings.logged_in() { 42 | tracing::debug!("not logged in, skipping sync tick"); 43 | continue; 44 | } 45 | 46 | let res = sync::sync(&settings, &store).await; 47 | 48 | if let Err(e) = res { 49 | tracing::error!("sync tick failed with {e}"); 50 | 51 | let mut rng = rand::thread_rng(); 52 | 53 | let mut new_interval = ticker.period().as_secs_f64() * rng.gen_range(2.0..2.2); 54 | 55 | if new_interval > max_interval { 56 | new_interval = max_interval; 57 | } 58 | 59 | ticker = time::interval(time::Duration::from_secs(new_interval as u64)); 60 | ticker.reset_after(time::Duration::from_secs(new_interval as u64)); 61 | 62 | tracing::error!("backing off, next sync tick in {new_interval}"); 63 | } else { 64 | let (uploaded, downloaded) = res.unwrap(); 65 | 66 | tracing::info!( 67 | uploaded = ?uploaded, 68 | downloaded = ?downloaded, 69 | "sync complete" 70 | ); 71 | 72 | history_store 73 | .incremental_build(&history_db, &downloaded) 74 | .await?; 75 | 76 | alias_store.build().await?; 77 | var_store.build().await?; 78 | 79 | // Reset backoff on success 80 | if ticker.period().as_secs() != settings.daemon.sync_frequency { 81 | ticker = time::interval(time::Duration::from_secs(settings.daemon.sync_frequency)); 82 | } 83 | 84 | // store sync time 85 | tokio::task::spawn_blocking(Settings::save_sync_time).await??; 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /crates/atuin-dotfiles/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "atuin-dotfiles" 3 | description = "The dotfiles crate for Atuin" 4 | edition = "2024" 5 | version = { workspace = true } 6 | 7 | authors.workspace = true 8 | rust-version.workspace = true 9 | license.workspace = true 10 | homepage.workspace = true 11 | repository.workspace = true 12 | readme.workspace = true 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [dependencies] 17 | atuin-common = { path = "../atuin-common", version = "18.5.0" } 18 | atuin-client = { path = "../atuin-client", version = "18.5.0" } 19 | 20 | eyre = { workspace = true } 21 | tokio = { workspace = true } 22 | rmp = { version = "0.8.14" } 23 | rand = { workspace = true } 24 | serde = { workspace = true } 25 | crypto_secretbox = "0.1.1" 26 | -------------------------------------------------------------------------------- /crates/atuin-dotfiles/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod shell; 2 | pub mod store; 3 | -------------------------------------------------------------------------------- /crates/atuin-dotfiles/src/shell/bash.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::store::{AliasStore, var::VarStore}; 4 | 5 | async fn cached_aliases(path: PathBuf, store: &AliasStore) -> String { 6 | match tokio::fs::read_to_string(path).await { 7 | Ok(aliases) => aliases, 8 | Err(r) => { 9 | // we failed to read the file for some reason, but the file does exist 10 | // fallback to generating new aliases on the fly 11 | 12 | store.posix().await.unwrap_or_else(|e| { 13 | format!("echo 'Atuin: failed to read and generate aliases: \n{r}\n{e}'",) 14 | }) 15 | } 16 | } 17 | } 18 | 19 | async fn cached_vars(path: PathBuf, store: &VarStore) -> String { 20 | match tokio::fs::read_to_string(path).await { 21 | Ok(vars) => vars, 22 | Err(r) => { 23 | // we failed to read the file for some reason, but the file does exist 24 | // fallback to generating new vars on the fly 25 | 26 | store.posix().await.unwrap_or_else(|e| { 27 | format!("echo 'Atuin: failed to read and generate vars: \n{r}\n{e}'",) 28 | }) 29 | } 30 | } 31 | } 32 | 33 | /// Return bash dotfile config 34 | /// 35 | /// Do not return an error. We should not prevent the shell from starting. 36 | /// 37 | /// In the worst case, Atuin should not function but the shell should start correctly. 38 | /// 39 | /// While currently this only returns aliases, it will be extended to also return other synced dotfiles 40 | pub async fn alias_config(store: &AliasStore) -> String { 41 | // First try to read the cached config 42 | let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.bash"); 43 | 44 | if aliases.exists() { 45 | return cached_aliases(aliases, store).await; 46 | } 47 | 48 | if let Err(e) = store.build().await { 49 | return format!("echo 'Atuin: failed to generate aliases: {}'", e); 50 | } 51 | 52 | cached_aliases(aliases, store).await 53 | } 54 | 55 | pub async fn var_config(store: &VarStore) -> String { 56 | // First try to read the cached config 57 | let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.bash"); 58 | 59 | if vars.exists() { 60 | return cached_vars(vars, store).await; 61 | } 62 | 63 | if let Err(e) = store.build().await { 64 | return format!("echo 'Atuin: failed to generate vars: {}'", e); 65 | } 66 | 67 | cached_vars(vars, store).await 68 | } 69 | -------------------------------------------------------------------------------- /crates/atuin-dotfiles/src/shell/fish.rs: -------------------------------------------------------------------------------- 1 | // Configuration for fish 2 | use std::path::PathBuf; 3 | 4 | use crate::store::{AliasStore, var::VarStore}; 5 | 6 | async fn cached_aliases(path: PathBuf, store: &AliasStore) -> String { 7 | match tokio::fs::read_to_string(path).await { 8 | Ok(aliases) => aliases, 9 | Err(r) => { 10 | // we failed to read the file for some reason, but the file does exist 11 | // fallback to generating new aliases on the fly 12 | 13 | store.posix().await.unwrap_or_else(|e| { 14 | format!("echo 'Atuin: failed to read and generate aliases: \n{r}\n{e}'",) 15 | }) 16 | } 17 | } 18 | } 19 | 20 | async fn cached_vars(path: PathBuf, store: &VarStore) -> String { 21 | match tokio::fs::read_to_string(path).await { 22 | Ok(vars) => vars, 23 | Err(r) => { 24 | // we failed to read the file for some reason, but the file does exist 25 | // fallback to generating new vars on the fly 26 | 27 | store.posix().await.unwrap_or_else(|e| { 28 | format!("echo 'Atuin: failed to read and generate vars: \n{r}\n{e}'",) 29 | }) 30 | } 31 | } 32 | } 33 | 34 | /// Return fish dotfile config 35 | /// 36 | /// Do not return an error. We should not prevent the shell from starting. 37 | /// 38 | /// In the worst case, Atuin should not function but the shell should start correctly. 39 | /// 40 | /// While currently this only returns aliases, it will be extended to also return other synced dotfiles 41 | pub async fn alias_config(store: &AliasStore) -> String { 42 | // First try to read the cached config 43 | let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.fish"); 44 | 45 | if aliases.exists() { 46 | return cached_aliases(aliases, store).await; 47 | } 48 | 49 | if let Err(e) = store.build().await { 50 | return format!("echo 'Atuin: failed to generate aliases: {}'", e); 51 | } 52 | 53 | cached_aliases(aliases, store).await 54 | } 55 | 56 | pub async fn var_config(store: &VarStore) -> String { 57 | // First try to read the cached config 58 | let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.fish"); 59 | 60 | if vars.exists() { 61 | return cached_vars(vars, store).await; 62 | } 63 | 64 | if let Err(e) = store.build().await { 65 | return format!("echo 'Atuin: failed to generate vars: {}'", e); 66 | } 67 | 68 | cached_vars(vars, store).await 69 | } 70 | -------------------------------------------------------------------------------- /crates/atuin-dotfiles/src/shell/xonsh.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::store::{AliasStore, var::VarStore}; 4 | 5 | async fn cached_aliases(path: PathBuf, store: &AliasStore) -> String { 6 | match tokio::fs::read_to_string(path).await { 7 | Ok(aliases) => aliases, 8 | Err(r) => { 9 | // we failed to read the file for some reason, but the file does exist 10 | // fallback to generating new aliases on the fly 11 | 12 | store.xonsh().await.unwrap_or_else(|e| { 13 | format!("echo 'Atuin: failed to read and generate aliases: \n{r}\n{e}'",) 14 | }) 15 | } 16 | } 17 | } 18 | 19 | async fn cached_vars(path: PathBuf, store: &VarStore) -> String { 20 | match tokio::fs::read_to_string(path).await { 21 | Ok(vars) => vars, 22 | Err(r) => { 23 | // we failed to read the file for some reason, but the file does exist 24 | // fallback to generating new vars on the fly 25 | 26 | store.xonsh().await.unwrap_or_else(|e| { 27 | format!("echo 'Atuin: failed to read and generate vars: \n{r}\n{e}'",) 28 | }) 29 | } 30 | } 31 | } 32 | 33 | /// Return xonsh dotfile config 34 | /// 35 | /// Do not return an error. We should not prevent the shell from starting. 36 | /// 37 | /// In the worst case, Atuin should not function but the shell should start correctly. 38 | /// 39 | /// While currently this only returns aliases, it will be extended to also return other synced dotfiles 40 | pub async fn alias_config(store: &AliasStore) -> String { 41 | // First try to read the cached config 42 | let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.xsh"); 43 | 44 | if aliases.exists() { 45 | return cached_aliases(aliases, store).await; 46 | } 47 | 48 | if let Err(e) = store.build().await { 49 | return format!("echo 'Atuin: failed to generate aliases: {}'", e); 50 | } 51 | 52 | cached_aliases(aliases, store).await 53 | } 54 | 55 | pub async fn var_config(store: &VarStore) -> String { 56 | // First try to read the cached config 57 | let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.xsh"); 58 | 59 | if vars.exists() { 60 | return cached_vars(vars, store).await; 61 | } 62 | 63 | if let Err(e) = store.build().await { 64 | return format!("echo 'Atuin: failed to generate vars: {}'", e); 65 | } 66 | 67 | cached_vars(vars, store).await 68 | } 69 | -------------------------------------------------------------------------------- /crates/atuin-dotfiles/src/shell/zsh.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::store::{AliasStore, var::VarStore}; 4 | 5 | async fn cached_aliases(path: PathBuf, store: &AliasStore) -> String { 6 | match tokio::fs::read_to_string(path).await { 7 | Ok(aliases) => aliases, 8 | Err(r) => { 9 | // we failed to read the file for some reason, but the file does exist 10 | // fallback to generating new aliases on the fly 11 | 12 | store.posix().await.unwrap_or_else(|e| { 13 | format!("echo 'Atuin: failed to read and generate aliases: \n{r}\n{e}'",) 14 | }) 15 | } 16 | } 17 | } 18 | 19 | async fn cached_vars(path: PathBuf, store: &VarStore) -> String { 20 | match tokio::fs::read_to_string(path).await { 21 | Ok(aliases) => aliases, 22 | Err(r) => { 23 | // we failed to read the file for some reason, but the file does exist 24 | // fallback to generating new vars on the fly 25 | 26 | store.posix().await.unwrap_or_else(|e| { 27 | format!("echo 'Atuin: failed to read and generate aliases: \n{r}\n{e}'",) 28 | }) 29 | } 30 | } 31 | } 32 | 33 | /// Return zsh dotfile config 34 | /// 35 | /// Do not return an error. We should not prevent the shell from starting. 36 | /// 37 | /// In the worst case, Atuin should not function but the shell should start correctly. 38 | /// 39 | /// While currently this only returns aliases, it will be extended to also return other synced dotfiles 40 | pub async fn alias_config(store: &AliasStore) -> String { 41 | // First try to read the cached config 42 | let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.zsh"); 43 | 44 | if aliases.exists() { 45 | return cached_aliases(aliases, store).await; 46 | } 47 | 48 | if let Err(e) = store.build().await { 49 | return format!("echo 'Atuin: failed to generate aliases: {}'", e); 50 | } 51 | 52 | cached_aliases(aliases, store).await 53 | } 54 | 55 | pub async fn var_config(store: &VarStore) -> String { 56 | // First try to read the cached config 57 | let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.zsh"); 58 | 59 | if vars.exists() { 60 | return cached_vars(vars, store).await; 61 | } 62 | 63 | if let Err(e) = store.build().await { 64 | return format!("echo 'Atuin: failed to generate aliases: {}'", e); 65 | } 66 | 67 | cached_vars(vars, store).await 68 | } 69 | -------------------------------------------------------------------------------- /crates/atuin-dotfiles/src/store/alias.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /crates/atuin-history/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "atuin-history" 3 | description = "The history crate for Atuin" 4 | edition = "2024" 5 | version = { workspace = true } 6 | 7 | authors.workspace = true 8 | rust-version.workspace = true 9 | license.workspace = true 10 | homepage.workspace = true 11 | repository.workspace = true 12 | readme.workspace = true 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [dependencies] 17 | atuin-client = { path = "../atuin-client", version = "18.5.0" } 18 | 19 | time = { workspace = true } 20 | serde = { workspace = true } 21 | crossterm = { version = "0.28.1", features = ["use-dev-tty"] } 22 | unicode-segmentation = "1.11.0" 23 | 24 | [dev-dependencies] 25 | divan = "0.1.14" 26 | rand = { workspace = true } 27 | 28 | [[bench]] 29 | name = "smart_sort" 30 | harness = false 31 | -------------------------------------------------------------------------------- /crates/atuin-history/benches/smart_sort.rs: -------------------------------------------------------------------------------- 1 | use atuin_client::history::History; 2 | use atuin_history::sort::sort; 3 | 4 | use rand::Rng; 5 | 6 | fn main() { 7 | // Run registered benchmarks. 8 | divan::main(); 9 | } 10 | 11 | // Smart sort usually runs on 200 entries, test on a few sizes 12 | #[divan::bench(args=[100, 200, 400, 800, 1600, 10000])] 13 | fn smart_sort(lines: usize) { 14 | // benchmark a few different sizes of "history" 15 | // first we need to generate some history. This will use a whole bunch of memory, sorry 16 | let mut rng = rand::thread_rng(); 17 | let now = time::OffsetDateTime::now_utc().unix_timestamp(); 18 | 19 | let possible_commands = ["echo", "ls", "cd", "grep", "atuin", "curl"]; 20 | let mut commands = Vec::::with_capacity(lines); 21 | 22 | for _ in 0..lines { 23 | let command = possible_commands[rng.gen_range(0..possible_commands.len())]; 24 | 25 | let command = History::import() 26 | .command(command) 27 | .timestamp(time::OffsetDateTime::from_unix_timestamp(rng.gen_range(0..now)).unwrap()) 28 | .build() 29 | .into(); 30 | 31 | commands.push(command); 32 | } 33 | 34 | let _ = sort("curl", commands); 35 | } 36 | -------------------------------------------------------------------------------- /crates/atuin-history/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod sort; 2 | pub mod stats; 3 | -------------------------------------------------------------------------------- /crates/atuin-history/src/sort.rs: -------------------------------------------------------------------------------- 1 | use atuin_client::history::History; 2 | 3 | type ScoredHistory = (f64, History); 4 | 5 | // Fuzzy search already comes sorted by minspan 6 | // This sorting should be applicable to all search modes, and solve the more "obvious" issues 7 | // first. 8 | // Later on, we can pass in context and do some boosts there too. 9 | pub fn sort(query: &str, input: Vec) -> Vec { 10 | // This can totally be extended. We need to be _careful_ that it's not slow. 11 | // We also need to balance sorting db-side with sorting here. SQLite can do a lot, 12 | // but some things are just much easier/more doable in Rust. 13 | 14 | let mut scored = input 15 | .into_iter() 16 | .map(|h| { 17 | // If history is _prefixed_ with the query, score it more highly 18 | let score = if h.command.starts_with(query) { 19 | 2.0 20 | } else if h.command.contains(query) { 21 | 1.75 22 | } else { 23 | 1.0 24 | }; 25 | 26 | // calculate how long ago the history was, in seconds 27 | let now = time::OffsetDateTime::now_utc().unix_timestamp(); 28 | let time = h.timestamp.unix_timestamp(); 29 | let diff = std::cmp::max(1, now - time); // no /0 please 30 | 31 | // prefer newer history, but not hugely so as to offset the other scoring 32 | // the numbers will get super small over time, but I don't want time to overpower other 33 | // scoring 34 | #[allow(clippy::cast_precision_loss)] 35 | let time_score = 1.0 + (1.0 / diff as f64); 36 | let score = score * time_score; 37 | 38 | (score, h) 39 | }) 40 | .collect::>(); 41 | 42 | scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap().reverse()); 43 | 44 | // Remove the scores and return the history 45 | scored.into_iter().map(|(_, h)| h).collect::>() 46 | } 47 | -------------------------------------------------------------------------------- /crates/atuin-scripts/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "atuin-scripts" 3 | edition = "2024" 4 | version = { workspace = true } 5 | description = "The scripts crate for Atuin" 6 | 7 | authors.workspace = true 8 | rust-version.workspace = true 9 | license.workspace = true 10 | homepage.workspace = true 11 | repository.workspace = true 12 | readme.workspace = true 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [dependencies] 17 | atuin-client = { path = "../atuin-client", version = "18.5.0-beta.1" } 18 | atuin-common = { path = "../atuin-common", version = "18.5.0-beta.1" } 19 | 20 | tracing = { workspace = true } 21 | tracing-subscriber = { workspace = true } 22 | rmp = { version = "0.8.14" } 23 | uuid = { workspace = true } 24 | eyre = { workspace = true } 25 | tokio = { workspace = true } 26 | serde = { workspace = true } 27 | typed-builder = { workspace = true } 28 | pretty_assertions = { workspace = true } 29 | sql-builder = { workspace = true } 30 | sqlx = { workspace = true } 31 | tempfile = { workspace = true } 32 | minijinja = { workspace = true } 33 | serde_json = { workspace = true } -------------------------------------------------------------------------------- /crates/atuin-scripts/migrations/20250326160051_create_scripts.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE scripts; 2 | DROP TABLE script_tags; -------------------------------------------------------------------------------- /crates/atuin-scripts/migrations/20250326160051_create_scripts.up.sql: -------------------------------------------------------------------------------- 1 | -- Add up migration script here 2 | CREATE TABLE scripts ( 3 | id TEXT PRIMARY KEY, 4 | name TEXT NOT NULL, 5 | description TEXT NOT NULL, 6 | shebang TEXT NOT NULL, 7 | script TEXT NOT NULL, 8 | inserted_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 9 | ); 10 | 11 | CREATE TABLE script_tags ( 12 | id INTEGER PRIMARY KEY, 13 | script_id TEXT NOT NULL, 14 | tag TEXT NOT NULL 15 | ); 16 | 17 | CREATE UNIQUE INDEX idx_script_tags ON script_tags (script_id, tag); -------------------------------------------------------------------------------- /crates/atuin-scripts/migrations/20250402170430_unique_names.down.sql: -------------------------------------------------------------------------------- 1 | -- Add down migration script here 2 | alter table scripts drop index name_uniq_idx; -------------------------------------------------------------------------------- /crates/atuin-scripts/migrations/20250402170430_unique_names.up.sql: -------------------------------------------------------------------------------- 1 | -- Add up migration script here 2 | create unique index name_uniq_idx ON scripts(name); -------------------------------------------------------------------------------- /crates/atuin-scripts/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod database; 2 | pub mod execution; 3 | pub mod settings; 4 | pub mod store; 5 | -------------------------------------------------------------------------------- /crates/atuin-scripts/src/settings.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /crates/atuin-scripts/src/store.rs: -------------------------------------------------------------------------------- 1 | use eyre::{Result, bail}; 2 | 3 | use atuin_client::record::sqlite_store::SqliteStore; 4 | use atuin_client::record::{encryption::PASETO_V4, store::Store}; 5 | use atuin_common::record::{Host, HostId, Record, RecordId, RecordIdx}; 6 | use record::ScriptRecord; 7 | use script::{SCRIPT_TAG, SCRIPT_VERSION, Script}; 8 | 9 | use crate::database::Database; 10 | 11 | pub mod record; 12 | pub mod script; 13 | 14 | #[derive(Debug, Clone)] 15 | pub struct ScriptStore { 16 | pub store: SqliteStore, 17 | pub host_id: HostId, 18 | pub encryption_key: [u8; 32], 19 | } 20 | 21 | impl ScriptStore { 22 | pub fn new(store: SqliteStore, host_id: HostId, encryption_key: [u8; 32]) -> Self { 23 | ScriptStore { 24 | store, 25 | host_id, 26 | encryption_key, 27 | } 28 | } 29 | 30 | async fn push_record(&self, record: ScriptRecord) -> Result<(RecordId, RecordIdx)> { 31 | let bytes = record.serialize()?; 32 | let idx = self 33 | .store 34 | .last(self.host_id, SCRIPT_TAG) 35 | .await? 36 | .map_or(0, |p| p.idx + 1); 37 | 38 | let record = Record::builder() 39 | .host(Host::new(self.host_id)) 40 | .version(SCRIPT_VERSION.to_string()) 41 | .tag(SCRIPT_TAG.to_string()) 42 | .idx(idx) 43 | .data(bytes) 44 | .build(); 45 | 46 | let id = record.id; 47 | 48 | self.store 49 | .push(&record.encrypt::(&self.encryption_key)) 50 | .await?; 51 | 52 | Ok((id, idx)) 53 | } 54 | 55 | pub async fn create(&self, script: Script) -> Result<()> { 56 | let record = ScriptRecord::Create(script); 57 | self.push_record(record).await?; 58 | Ok(()) 59 | } 60 | 61 | pub async fn update(&self, script: Script) -> Result<()> { 62 | let record = ScriptRecord::Update(script); 63 | self.push_record(record).await?; 64 | Ok(()) 65 | } 66 | 67 | pub async fn delete(&self, script_id: uuid::Uuid) -> Result<()> { 68 | let record = ScriptRecord::Delete(script_id); 69 | self.push_record(record).await?; 70 | Ok(()) 71 | } 72 | 73 | pub async fn scripts(&self) -> Result> { 74 | let records = self.store.all_tagged(SCRIPT_TAG).await?; 75 | let mut ret = Vec::with_capacity(records.len()); 76 | 77 | for record in records.into_iter() { 78 | let script = match record.version.as_str() { 79 | SCRIPT_VERSION => { 80 | let decrypted = record.decrypt::(&self.encryption_key)?; 81 | 82 | ScriptRecord::deserialize(&decrypted.data, SCRIPT_VERSION) 83 | } 84 | version => bail!("unknown history version {version:?}"), 85 | }?; 86 | 87 | ret.push(script); 88 | } 89 | 90 | Ok(ret) 91 | } 92 | 93 | pub async fn build(&self, database: Database) -> Result<()> { 94 | // Get all the scripts from the database - they are already sorted by timestamp 95 | let scripts = self.scripts().await?; 96 | 97 | for script in scripts { 98 | match script { 99 | ScriptRecord::Create(script) => { 100 | database.save(&script).await?; 101 | } 102 | ScriptRecord::Update(script) => database.update(&script).await?, 103 | ScriptRecord::Delete(id) => database.delete(&id.to_string()).await?, 104 | } 105 | } 106 | 107 | Ok(()) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /crates/atuin-server-database/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "atuin-server-database" 3 | edition = "2024" 4 | description = "server database library for atuin" 5 | 6 | version = { workspace = true } 7 | authors = { workspace = true } 8 | license = { workspace = true } 9 | homepage = { workspace = true } 10 | repository = { workspace = true } 11 | 12 | [dependencies] 13 | atuin-common = { path = "../atuin-common", version = "18.5.0" } 14 | 15 | tracing = { workspace = true } 16 | time = { workspace = true } 17 | eyre = { workspace = true } 18 | serde = { workspace = true } 19 | async-trait = { workspace = true } 20 | -------------------------------------------------------------------------------- /crates/atuin-server-database/src/calendar.rs: -------------------------------------------------------------------------------- 1 | // Calendar data 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use time::Month; 5 | 6 | pub enum TimePeriod { 7 | Year, 8 | Month { year: i32 }, 9 | Day { year: i32, month: Month }, 10 | } 11 | 12 | #[derive(Debug, Serialize, Deserialize)] 13 | pub struct TimePeriodInfo { 14 | pub count: u64, 15 | 16 | // TODO: Use this for merkle tree magic 17 | pub hash: String, 18 | } 19 | -------------------------------------------------------------------------------- /crates/atuin-server-database/src/models.rs: -------------------------------------------------------------------------------- 1 | use time::OffsetDateTime; 2 | 3 | pub struct History { 4 | pub id: i64, 5 | pub client_id: String, // a client generated ID 6 | pub user_id: i64, 7 | pub hostname: String, 8 | pub timestamp: OffsetDateTime, 9 | 10 | /// All the data we have about this command, encrypted. 11 | /// 12 | /// Currently this is an encrypted msgpack object, but this may change in the future. 13 | pub data: String, 14 | 15 | pub created_at: OffsetDateTime, 16 | } 17 | 18 | pub struct NewHistory { 19 | pub client_id: String, 20 | pub user_id: i64, 21 | pub hostname: String, 22 | pub timestamp: OffsetDateTime, 23 | 24 | /// All the data we have about this command, encrypted. 25 | /// 26 | /// Currently this is an encrypted msgpack object, but this may change in the future. 27 | pub data: String, 28 | } 29 | 30 | pub struct User { 31 | pub id: i64, 32 | pub username: String, 33 | pub email: String, 34 | pub password: String, 35 | pub verified: Option, 36 | } 37 | 38 | pub struct Session { 39 | pub id: i64, 40 | pub user_id: i64, 41 | pub token: String, 42 | } 43 | 44 | pub struct NewUser { 45 | pub username: String, 46 | pub email: String, 47 | pub password: String, 48 | } 49 | 50 | pub struct NewSession { 51 | pub user_id: i64, 52 | pub token: String, 53 | } 54 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "atuin-server-postgres" 3 | edition = "2024" 4 | description = "server postgres database library for atuin" 5 | 6 | version = { workspace = true } 7 | authors = { workspace = true } 8 | license = { workspace = true } 9 | homepage = { workspace = true } 10 | repository = { workspace = true } 11 | 12 | [dependencies] 13 | atuin-common = { path = "../atuin-common", version = "18.5.0" } 14 | atuin-server-database = { path = "../atuin-server-database", version = "18.5.0" } 15 | 16 | eyre = { workspace = true } 17 | tracing = { workspace = true } 18 | time = { workspace = true } 19 | serde = { workspace = true } 20 | sqlx = { workspace = true } 21 | async-trait = { workspace = true } 22 | uuid = { workspace = true } 23 | metrics = "0.21.1" 24 | futures-util = "0.3" 25 | url = "2.5.2" 26 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/build.rs: -------------------------------------------------------------------------------- 1 | // generated by `sqlx migrate build-script` 2 | fn main() { 3 | // trigger recompilation when a new migration is added 4 | println!("cargo:rerun-if-changed=migrations"); 5 | } 6 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/migrations/20210425153745_create_history.sql: -------------------------------------------------------------------------------- 1 | create table history ( 2 | id bigserial primary key, 3 | client_id text not null unique, -- the client-generated ID 4 | user_id bigserial not null, -- allow multiple users 5 | hostname text not null, -- a unique identifier from the client (can be hashed, random, whatever) 6 | timestamp timestamp not null, -- one of the few non-encrypted metadatas 7 | 8 | data varchar(8192) not null, -- store the actual history data, encrypted. I don't wanna know! 9 | 10 | created_at timestamp not null default current_timestamp 11 | ); 12 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/migrations/20210425153757_create_users.sql: -------------------------------------------------------------------------------- 1 | create table users ( 2 | id bigserial primary key, -- also store our own ID 3 | username varchar(32) not null unique, -- being able to contact users is useful 4 | email varchar(128) not null unique, -- being able to contact users is useful 5 | password varchar(128) not null unique 6 | ); 7 | 8 | -- the prior index is case sensitive :( 9 | CREATE UNIQUE INDEX email_unique_idx on users (LOWER(email)); 10 | CREATE UNIQUE INDEX username_unique_idx on users (LOWER(username)); 11 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/migrations/20210425153800_create_sessions.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | create table sessions ( 3 | id bigserial primary key, 4 | user_id bigserial, 5 | token varchar(128) unique not null 6 | ); 7 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/migrations/20220419082412_add_count_trigger.sql: -------------------------------------------------------------------------------- 1 | -- Prior to this, the count endpoint was super naive and just ran COUNT(1). 2 | -- This is slow asf. Now that we have an amount of actual traffic, 3 | -- stop doing that! 4 | -- This basically maintains a count, so we can read ONE row, instead of ALL the 5 | -- rows. Much better. 6 | -- Future optimisation could use some sort of cache so we don't even need to hit 7 | -- postgres at all. 8 | 9 | create table total_history_count_user( 10 | id bigserial primary key, 11 | user_id bigserial, 12 | total integer -- try and avoid using keywords - hence total, not count 13 | ); 14 | 15 | create or replace function user_history_count() 16 | returns trigger as 17 | $func$ 18 | begin 19 | if (TG_OP='INSERT') then 20 | update total_history_count_user set total = total + 1 where user_id = new.user_id; 21 | 22 | if not found then 23 | insert into total_history_count_user(user_id, total) 24 | values ( 25 | new.user_id, 26 | (select count(1) from history where user_id = new.user_id) 27 | ); 28 | end if; 29 | 30 | elsif (TG_OP='DELETE') then 31 | update total_history_count_user set total = total - 1 where user_id = new.user_id; 32 | 33 | if not found then 34 | insert into total_history_count_user(user_id, total) 35 | values ( 36 | new.user_id, 37 | (select count(1) from history where user_id = new.user_id) 38 | ); 39 | end if; 40 | end if; 41 | 42 | return NEW; -- this is actually ignored for an after trigger, but oh well 43 | end; 44 | $func$ 45 | language plpgsql volatile -- pldfplplpflh 46 | cost 100; -- default value 47 | 48 | create trigger tg_user_history_count 49 | after insert or delete on history 50 | for each row 51 | execute procedure user_history_count(); 52 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/migrations/20220421073605_fix_count_trigger_delete.sql: -------------------------------------------------------------------------------- 1 | -- the old version of this function used NEW in the delete part when it should 2 | -- use OLD 3 | 4 | create or replace function user_history_count() 5 | returns trigger as 6 | $func$ 7 | begin 8 | if (TG_OP='INSERT') then 9 | update total_history_count_user set total = total + 1 where user_id = new.user_id; 10 | 11 | if not found then 12 | insert into total_history_count_user(user_id, total) 13 | values ( 14 | new.user_id, 15 | (select count(1) from history where user_id = new.user_id) 16 | ); 17 | end if; 18 | 19 | elsif (TG_OP='DELETE') then 20 | update total_history_count_user set total = total - 1 where user_id = old.user_id; 21 | 22 | if not found then 23 | insert into total_history_count_user(user_id, total) 24 | values ( 25 | old.user_id, 26 | (select count(1) from history where user_id = old.user_id) 27 | ); 28 | end if; 29 | end if; 30 | 31 | return NEW; -- this is actually ignored for an after trigger, but oh well 32 | end; 33 | $func$ 34 | language plpgsql volatile -- pldfplplpflh 35 | cost 100; -- default value 36 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/migrations/20220421174016_larger-commands.sql: -------------------------------------------------------------------------------- 1 | -- Make it 4x larger. Most commands are less than this, but as it's base64 2 | -- SOME are more than 8192. Should be enough for now. 3 | ALTER TABLE history ALTER COLUMN data TYPE varchar(32768); 4 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/migrations/20220426172813_user-created-at.sql: -------------------------------------------------------------------------------- 1 | alter table users add column created_at timestamp not null default now(); 2 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/migrations/20220505082442_create-events.sql: -------------------------------------------------------------------------------- 1 | create type event_type as enum ('create', 'delete'); 2 | 3 | create table events ( 4 | id bigserial primary key, 5 | client_id text not null unique, -- the client-generated ID 6 | user_id bigserial not null, -- allow multiple users 7 | hostname text not null, -- a unique identifier from the client (can be hashed, random, whatever) 8 | timestamp timestamp not null, -- one of the few non-encrypted metadatas 9 | 10 | event_type event_type, 11 | data text not null, -- store the actual history data, encrypted. I don't wanna know! 12 | 13 | created_at timestamp not null default current_timestamp 14 | ); 15 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/migrations/20220610074049_history-length.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | alter table history alter column data type text; 3 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/migrations/20230315220537_drop-events.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | drop table events; 3 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/migrations/20230315224203_create-deleted.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | alter table history add column if not exists deleted_at timestamp; 3 | 4 | -- queries will all be selecting the ids of history for a user, that has been deleted 5 | create index if not exists history_deleted_index on history(client_id, user_id, deleted_at); 6 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/migrations/20230515221038_trigger-delete-only.sql: -------------------------------------------------------------------------------- 1 | -- We do not need to run the trigger on deletes, as the only time we are deleting history is when the user 2 | -- has already been deleted 3 | -- This actually slows down deleting all the history a good bit! 4 | 5 | create or replace function user_history_count() 6 | returns trigger as 7 | $func$ 8 | begin 9 | if (TG_OP='INSERT') then 10 | update total_history_count_user set total = total + 1 where user_id = new.user_id; 11 | 12 | if not found then 13 | insert into total_history_count_user(user_id, total) 14 | values ( 15 | new.user_id, 16 | (select count(1) from history where user_id = new.user_id) 17 | ); 18 | end if; 19 | end if; 20 | 21 | return NEW; -- this is actually ignored for an after trigger, but oh well 22 | end; 23 | $func$ 24 | language plpgsql volatile -- pldfplplpflh 25 | cost 100; -- default value 26 | 27 | create or replace trigger tg_user_history_count 28 | after insert on history 29 | for each row 30 | execute procedure user_history_count(); 31 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/migrations/20230623070418_records.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | create table records ( 3 | id uuid primary key, -- remember to use uuidv7 for happy indices <3 4 | client_id uuid not null, -- I am too uncomfortable with the idea of a client-generated primary key 5 | host uuid not null, -- a unique identifier for the host 6 | parent uuid default null, -- the ID of the parent record, bearing in mind this is a linked list 7 | timestamp bigint not null, -- not a timestamp type, as those do not have nanosecond precision 8 | version text not null, 9 | tag text not null, -- what is this? history, kv, whatever. Remember clients get a log per tag per host 10 | data text not null, -- store the actual history data, encrypted. I don't wanna know! 11 | cek text not null, 12 | 13 | user_id bigint not null, -- allow multiple users 14 | created_at timestamp not null default current_timestamp 15 | ); 16 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/migrations/20231202170508_create-store.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | create table store ( 3 | id uuid primary key, -- remember to use uuidv7 for happy indices <3 4 | client_id uuid not null, -- I am too uncomfortable with the idea of a client-generated primary key, even though it's fine mathematically 5 | host uuid not null, -- a unique identifier for the host 6 | idx bigint not null, -- the index of the record in this store, identified by (host, tag) 7 | timestamp bigint not null, -- not a timestamp type, as those do not have nanosecond precision 8 | version text not null, 9 | tag text not null, -- what is this? history, kv, whatever. Remember clients get a log per tag per host 10 | data text not null, -- store the actual history data, encrypted. I don't wanna know! 11 | cek text not null, 12 | 13 | user_id bigint not null, -- allow multiple users 14 | created_at timestamp not null default current_timestamp 15 | ); 16 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/migrations/20231203124112_create-store-idx.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | create unique index record_uniq ON store(user_id, host, tag, idx); 3 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/migrations/20240108124837_drop-some-defaults.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | alter table history alter column user_id drop default; 3 | alter table sessions alter column user_id drop default; 4 | alter table total_history_count_user alter column user_id drop default; 5 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/migrations/20240614104159_idx-cache.sql: -------------------------------------------------------------------------------- 1 | create table store_idx_cache( 2 | id bigserial primary key, 3 | user_id bigint, 4 | 5 | host uuid, 6 | tag text, 7 | idx bigint 8 | ); 9 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/migrations/20240621110731_user-verified.sql: -------------------------------------------------------------------------------- 1 | alter table users add verified_at timestamp with time zone default null; 2 | 3 | create table user_verification_token( 4 | id bigserial primary key, 5 | user_id bigint unique references users(id), 6 | token text, 7 | valid_until timestamp with time zone 8 | ); 9 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/migrations/20240702094825_idx_cache_index.sql: -------------------------------------------------------------------------------- 1 | create unique index store_idx_cache_uniq on store_idx_cache(user_id, host, tag); 2 | -------------------------------------------------------------------------------- /crates/atuin-server-postgres/src/wrappers.rs: -------------------------------------------------------------------------------- 1 | use ::sqlx::{FromRow, Result}; 2 | use atuin_common::record::{EncryptedData, Host, Record}; 3 | use atuin_server_database::models::{History, Session, User}; 4 | use sqlx::{Row, postgres::PgRow}; 5 | use time::PrimitiveDateTime; 6 | 7 | pub struct DbUser(pub User); 8 | pub struct DbSession(pub Session); 9 | pub struct DbHistory(pub History); 10 | pub struct DbRecord(pub Record); 11 | 12 | impl<'a> FromRow<'a, PgRow> for DbUser { 13 | fn from_row(row: &'a PgRow) -> Result { 14 | Ok(Self(User { 15 | id: row.try_get("id")?, 16 | username: row.try_get("username")?, 17 | email: row.try_get("email")?, 18 | password: row.try_get("password")?, 19 | verified: row.try_get("verified_at")?, 20 | })) 21 | } 22 | } 23 | 24 | impl<'a> ::sqlx::FromRow<'a, PgRow> for DbSession { 25 | fn from_row(row: &'a PgRow) -> ::sqlx::Result { 26 | Ok(Self(Session { 27 | id: row.try_get("id")?, 28 | user_id: row.try_get("user_id")?, 29 | token: row.try_get("token")?, 30 | })) 31 | } 32 | } 33 | 34 | impl<'a> ::sqlx::FromRow<'a, PgRow> for DbHistory { 35 | fn from_row(row: &'a PgRow) -> ::sqlx::Result { 36 | Ok(Self(History { 37 | id: row.try_get("id")?, 38 | client_id: row.try_get("client_id")?, 39 | user_id: row.try_get("user_id")?, 40 | hostname: row.try_get("hostname")?, 41 | timestamp: row 42 | .try_get::("timestamp")? 43 | .assume_utc(), 44 | data: row.try_get("data")?, 45 | created_at: row 46 | .try_get::("created_at")? 47 | .assume_utc(), 48 | })) 49 | } 50 | } 51 | 52 | impl<'a> ::sqlx::FromRow<'a, PgRow> for DbRecord { 53 | fn from_row(row: &'a PgRow) -> ::sqlx::Result { 54 | let timestamp: i64 = row.try_get("timestamp")?; 55 | let idx: i64 = row.try_get("idx")?; 56 | 57 | let data = EncryptedData { 58 | data: row.try_get("data")?, 59 | content_encryption_key: row.try_get("cek")?, 60 | }; 61 | 62 | Ok(Self(Record { 63 | id: row.try_get("client_id")?, 64 | host: Host::new(row.try_get("host")?), 65 | idx: idx as u64, 66 | timestamp: timestamp as u64, 67 | version: row.try_get("version")?, 68 | tag: row.try_get("tag")?, 69 | data, 70 | })) 71 | } 72 | } 73 | 74 | impl From for Record { 75 | fn from(other: DbRecord) -> Record { 76 | Record { ..other.0 } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /crates/atuin-server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "atuin-server" 3 | edition = "2024" 4 | description = "server library for atuin" 5 | 6 | rust-version = { workspace = true } 7 | version = { workspace = true } 8 | authors = { workspace = true } 9 | license = { workspace = true } 10 | homepage = { workspace = true } 11 | repository = { workspace = true } 12 | 13 | [dependencies] 14 | atuin-common = { path = "../atuin-common", version = "18.5.0" } 15 | atuin-server-database = { path = "../atuin-server-database", version = "18.5.0" } 16 | 17 | tracing = { workspace = true } 18 | time = { workspace = true } 19 | eyre = { workspace = true } 20 | config = { workspace = true } 21 | serde = { workspace = true } 22 | serde_json = { workspace = true } 23 | rand = { workspace = true } 24 | tokio = { workspace = true } 25 | async-trait = { workspace = true } 26 | axum = "0.7" 27 | axum-server = { version = "0.7", features = ["tls-rustls-no-provider"] } 28 | fs-err = { workspace = true } 29 | tower = { workspace = true } 30 | tower-http = { version = "0.6", features = ["trace"] } 31 | reqwest = { workspace = true } 32 | rustls = { version = "0.23", features = ["ring"], default-features = false } 33 | argon2 = "0.5" 34 | semver = { workspace = true } 35 | metrics-exporter-prometheus = "0.12.1" 36 | metrics = "0.21.1" 37 | postmark = {version= "0.10.2", features=["reqwest", "reqwest-rustls-tls"]} 38 | -------------------------------------------------------------------------------- /crates/atuin-server/server.toml: -------------------------------------------------------------------------------- 1 | ## host to bind, can also be passed via CLI args 2 | # host = "127.0.0.1" 3 | 4 | ## port to bind, can also be passed via CLI args 5 | # port = 8888 6 | 7 | ## whether to allow anyone to register an account 8 | # open_registration = false 9 | 10 | ## URI for postgres (using development creds here) 11 | # db_uri="postgres://username:password@localhost/atuin" 12 | 13 | ## Maximum size for one history entry 14 | # max_history_length = 8192 15 | 16 | ## Maximum size for one record entry 17 | ## 1024 * 1024 * 1024 18 | # max_record_size = 1073741824 19 | 20 | ## Webhook to be called when user registers on the servers 21 | # register_webhook_username = "" 22 | 23 | ## Default page size for requests 24 | # page_size = 1100 25 | 26 | # [metrics] 27 | # enable = false 28 | # host = 127.0.0.1 29 | # port = 9001 30 | 31 | # [tls] 32 | # enable = false 33 | # cert_path = "" 34 | # pkey_path = "" 35 | -------------------------------------------------------------------------------- /crates/atuin-server/src/handlers/health.rs: -------------------------------------------------------------------------------- 1 | use axum::{Json, http, response::IntoResponse}; 2 | 3 | use serde::Serialize; 4 | 5 | #[derive(Serialize)] 6 | pub struct HealthResponse { 7 | pub status: &'static str, 8 | } 9 | 10 | pub async fn health_check() -> impl IntoResponse { 11 | ( 12 | http::StatusCode::OK, 13 | Json(HealthResponse { status: "healthy" }), 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /crates/atuin-server/src/handlers/mod.rs: -------------------------------------------------------------------------------- 1 | use atuin_common::api::{ErrorResponse, IndexResponse}; 2 | use atuin_server_database::Database; 3 | use axum::{Json, extract::State, http, response::IntoResponse}; 4 | 5 | use crate::router::AppState; 6 | 7 | pub mod health; 8 | pub mod history; 9 | pub mod record; 10 | pub mod status; 11 | pub mod user; 12 | pub mod v0; 13 | 14 | const VERSION: &str = env!("CARGO_PKG_VERSION"); 15 | 16 | pub async fn index(state: State>) -> Json { 17 | let homage = r#""Through the fathomless deeps of space swims the star turtle Great A'Tuin, bearing on its back the four giant elephants who carry on their shoulders the mass of the Discworld." -- Sir Terry Pratchett"#; 18 | 19 | // Error with a -1 response 20 | // It's super unlikely this will happen 21 | let count = state.database.total_history().await.unwrap_or(-1); 22 | 23 | let version = state 24 | .settings 25 | .fake_version 26 | .clone() 27 | .unwrap_or(VERSION.to_string()); 28 | 29 | Json(IndexResponse { 30 | homage: homage.to_string(), 31 | total_history: count, 32 | version, 33 | }) 34 | } 35 | 36 | impl IntoResponse for ErrorResponseStatus<'_> { 37 | fn into_response(self) -> axum::response::Response { 38 | (self.status, Json(self.error)).into_response() 39 | } 40 | } 41 | 42 | pub struct ErrorResponseStatus<'a> { 43 | pub error: ErrorResponse<'a>, 44 | pub status: http::StatusCode, 45 | } 46 | 47 | pub trait RespExt<'a> { 48 | fn with_status(self, status: http::StatusCode) -> ErrorResponseStatus<'a>; 49 | fn reply(reason: &'a str) -> Self; 50 | } 51 | 52 | impl<'a> RespExt<'a> for ErrorResponse<'a> { 53 | fn with_status(self, status: http::StatusCode) -> ErrorResponseStatus<'a> { 54 | ErrorResponseStatus { 55 | error: self, 56 | status, 57 | } 58 | } 59 | 60 | fn reply(reason: &'a str) -> ErrorResponse<'a> { 61 | Self { 62 | reason: reason.into(), 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /crates/atuin-server/src/handlers/record.rs: -------------------------------------------------------------------------------- 1 | use axum::{Json, http::StatusCode, response::IntoResponse}; 2 | use serde_json::json; 3 | use tracing::instrument; 4 | 5 | use super::{ErrorResponse, ErrorResponseStatus, RespExt}; 6 | use crate::router::UserAuth; 7 | use atuin_server_database::Database; 8 | 9 | use atuin_common::record::{EncryptedData, Record}; 10 | 11 | #[instrument(skip_all, fields(user.id = user.id))] 12 | pub async fn post( 13 | UserAuth(user): UserAuth, 14 | ) -> Result<(), ErrorResponseStatus<'static>> { 15 | // anyone who has actually used the old record store (a very small number) will see this error 16 | // upon trying to sync. 17 | // 1. The status endpoint will say that the server has nothing 18 | // 2. The client will try to upload local records 19 | // 3. Sync will fail with this error 20 | 21 | // If the client has no local records, they will see the empty index and do nothing. For the 22 | // vast majority of users, this is the case. 23 | return Err( 24 | ErrorResponse::reply("record store deprecated; please upgrade") 25 | .with_status(StatusCode::BAD_REQUEST), 26 | ); 27 | } 28 | 29 | #[instrument(skip_all, fields(user.id = user.id))] 30 | pub async fn index(UserAuth(user): UserAuth) -> axum::response::Response { 31 | let ret = json!({ 32 | "hosts": {} 33 | }); 34 | 35 | ret.to_string().into_response() 36 | } 37 | 38 | #[instrument(skip_all, fields(user.id = user.id))] 39 | pub async fn next( 40 | UserAuth(user): UserAuth, 41 | ) -> Result>>, ErrorResponseStatus<'static>> { 42 | let records = Vec::new(); 43 | 44 | Ok(Json(records)) 45 | } 46 | -------------------------------------------------------------------------------- /crates/atuin-server/src/handlers/status.rs: -------------------------------------------------------------------------------- 1 | use axum::{Json, extract::State, http::StatusCode}; 2 | use tracing::instrument; 3 | 4 | use super::{ErrorResponse, ErrorResponseStatus, RespExt}; 5 | use crate::router::{AppState, UserAuth}; 6 | use atuin_server_database::Database; 7 | 8 | use atuin_common::api::*; 9 | 10 | const VERSION: &str = env!("CARGO_PKG_VERSION"); 11 | 12 | #[instrument(skip_all, fields(user.id = user.id))] 13 | pub async fn status( 14 | UserAuth(user): UserAuth, 15 | state: State>, 16 | ) -> Result, ErrorResponseStatus<'static>> { 17 | let db = &state.0.database; 18 | 19 | let deleted = db.deleted_history(&user).await.unwrap_or(vec![]); 20 | 21 | let count = match db.count_history_cached(&user).await { 22 | // By default read out the cached value 23 | Ok(count) => count, 24 | 25 | // If that fails, fallback on a full COUNT. Cache is built on a POST 26 | // only 27 | Err(_) => match db.count_history(&user).await { 28 | Ok(count) => count, 29 | Err(_) => { 30 | return Err(ErrorResponse::reply("failed to query history count") 31 | .with_status(StatusCode::INTERNAL_SERVER_ERROR)); 32 | } 33 | }, 34 | }; 35 | 36 | tracing::debug!(user = user.username, "requested sync status"); 37 | 38 | Ok(Json(StatusResponse { 39 | count, 40 | deleted, 41 | username: user.username, 42 | version: VERSION.to_string(), 43 | page_size: state.settings.page_size, 44 | })) 45 | } 46 | -------------------------------------------------------------------------------- /crates/atuin-server/src/handlers/v0/me.rs: -------------------------------------------------------------------------------- 1 | use axum::Json; 2 | use tracing::instrument; 3 | 4 | use crate::handlers::ErrorResponseStatus; 5 | use crate::router::UserAuth; 6 | 7 | use atuin_common::api::*; 8 | 9 | #[instrument(skip_all, fields(user.id = user.id))] 10 | pub async fn get( 11 | UserAuth(user): UserAuth, 12 | ) -> Result, ErrorResponseStatus<'static>> { 13 | Ok(Json(MeResponse { 14 | username: user.username, 15 | })) 16 | } 17 | -------------------------------------------------------------------------------- /crates/atuin-server/src/handlers/v0/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod me; 2 | pub(crate) mod record; 3 | pub(crate) mod store; 4 | -------------------------------------------------------------------------------- /crates/atuin-server/src/handlers/v0/store.rs: -------------------------------------------------------------------------------- 1 | use axum::{extract::Query, extract::State, http::StatusCode}; 2 | use metrics::counter; 3 | use serde::Deserialize; 4 | use tracing::{error, instrument}; 5 | 6 | use crate::{ 7 | handlers::{ErrorResponse, ErrorResponseStatus, RespExt}, 8 | router::{AppState, UserAuth}, 9 | }; 10 | use atuin_server_database::Database; 11 | 12 | #[derive(Deserialize)] 13 | pub struct DeleteParams {} 14 | 15 | #[instrument(skip_all, fields(user.id = user.id))] 16 | pub async fn delete( 17 | _params: Query, 18 | UserAuth(user): UserAuth, 19 | state: State>, 20 | ) -> Result<(), ErrorResponseStatus<'static>> { 21 | let State(AppState { 22 | database, 23 | settings: _, 24 | }) = state; 25 | 26 | if let Err(e) = database.delete_store(&user).await { 27 | counter!("atuin_store_delete_failed", 1); 28 | error!("failed to delete store {e:?}"); 29 | 30 | return Err(ErrorResponse::reply("failed to delete store") 31 | .with_status(StatusCode::INTERNAL_SERVER_ERROR)); 32 | } 33 | 34 | counter!("atuin_store_deleted", 1); 35 | 36 | Ok(()) 37 | } 38 | -------------------------------------------------------------------------------- /crates/atuin-server/src/metrics.rs: -------------------------------------------------------------------------------- 1 | use std::time::Instant; 2 | 3 | use axum::{ 4 | extract::{MatchedPath, Request}, 5 | middleware::Next, 6 | response::IntoResponse, 7 | }; 8 | use metrics_exporter_prometheus::{Matcher, PrometheusBuilder, PrometheusHandle}; 9 | 10 | pub fn setup_metrics_recorder() -> PrometheusHandle { 11 | const EXPONENTIAL_SECONDS: &[f64] = &[ 12 | 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 13 | ]; 14 | 15 | PrometheusBuilder::new() 16 | .set_buckets_for_metric( 17 | Matcher::Full("http_requests_duration_seconds".to_string()), 18 | EXPONENTIAL_SECONDS, 19 | ) 20 | .unwrap() 21 | .install_recorder() 22 | .unwrap() 23 | } 24 | 25 | /// Middleware to record some common HTTP metrics 26 | /// Generic over B to allow for arbitrary body types (eg Vec, Streams, a deserialized thing, etc) 27 | /// Someday tower-http might provide a metrics middleware: https://github.com/tower-rs/tower-http/issues/57 28 | pub async fn track_metrics(req: Request, next: Next) -> impl IntoResponse { 29 | let start = Instant::now(); 30 | 31 | let path = match req.extensions().get::() { 32 | Some(matched_path) => matched_path.as_str().to_owned(), 33 | _ => req.uri().path().to_owned(), 34 | }; 35 | 36 | let method = req.method().clone(); 37 | 38 | // Run the rest of the request handling first, so we can measure it and get response 39 | // codes. 40 | let response = next.run(req).await; 41 | 42 | let latency = start.elapsed().as_secs_f64(); 43 | let status = response.status().as_u16().to_string(); 44 | 45 | let labels = [ 46 | ("method", method.to_string()), 47 | ("path", path), 48 | ("status", status), 49 | ]; 50 | 51 | metrics::increment_counter!("http_requests_total", &labels); 52 | metrics::histogram!("http_requests_duration_seconds", latency, &labels); 53 | 54 | response 55 | } 56 | -------------------------------------------------------------------------------- /crates/atuin-server/src/utils.rs: -------------------------------------------------------------------------------- 1 | use eyre::Result; 2 | use semver::{Version, VersionReq}; 3 | 4 | pub fn client_version_min(user_agent: &str, req: &str) -> Result { 5 | if user_agent.is_empty() { 6 | return Ok(false); 7 | } 8 | 9 | let version = user_agent.replace("atuin/", ""); 10 | 11 | let req = VersionReq::parse(req)?; 12 | let version = Version::parse(version.as_str())?; 13 | 14 | Ok(req.matches(&version)) 15 | } 16 | -------------------------------------------------------------------------------- /crates/atuin/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "atuin" 3 | edition = "2024" 4 | description = "atuin - magical shell history" 5 | readme = "./README.md" 6 | 7 | rust-version = { workspace = true } 8 | version = { workspace = true } 9 | authors = { workspace = true } 10 | license = { workspace = true } 11 | homepage = { workspace = true } 12 | repository = { workspace = true } 13 | 14 | [package.metadata.binstall] 15 | pkg-url = "{ repo }/releases/download/v{ version }/{ name }-{ target }.tar.gz" 16 | bin-dir = "{ name }-{ target }/{ bin }{ binary-ext }" 17 | pkg-fmt = "tgz" 18 | 19 | [package.metadata.deb] 20 | maintainer = "Ellie Huxtable " 21 | copyright = "2021, Ellie Huxtable " 22 | license-file = ["LICENSE"] 23 | depends = "$auto" 24 | section = "utility" 25 | 26 | [package.metadata.rpm] 27 | package = "atuin" 28 | 29 | [package.metadata.rpm.cargo] 30 | buildflags = ["--release"] 31 | 32 | [package.metadata.rpm.targets] 33 | atuin = { path = "/usr/bin/atuin" } 34 | 35 | [features] 36 | default = ["client", "sync", "server", "clipboard", "check-update", "daemon"] 37 | client = ["atuin-client"] 38 | sync = ["atuin-client/sync"] 39 | daemon = ["atuin-client/daemon", "atuin-daemon"] 40 | server = ["atuin-server", "atuin-server-postgres"] 41 | clipboard = ["arboard"] 42 | check-update = ["atuin-client/check-update"] 43 | 44 | [dependencies] 45 | atuin-server-postgres = { path = "../atuin-server-postgres", version = "18.5.0", optional = true } 46 | atuin-server = { path = "../atuin-server", version = "18.5.0", optional = true } 47 | atuin-client = { path = "../atuin-client", version = "18.5.0", optional = true, default-features = false } 48 | atuin-common = { path = "../atuin-common", version = "18.5.0" } 49 | atuin-dotfiles = { path = "../atuin-dotfiles", version = "18.5.0" } 50 | atuin-history = { path = "../atuin-history", version = "18.5.0" } 51 | atuin-daemon = { path = "../atuin-daemon", version = "18.5.0", optional = true, default-features = false } 52 | atuin-scripts = { path = "../atuin-scripts", version = "18.5.0" } 53 | 54 | log = { workspace = true } 55 | time = { workspace = true } 56 | eyre = { workspace = true } 57 | indicatif = "0.17.5" 58 | serde = { workspace = true } 59 | serde_json = { workspace = true } 60 | crossterm = { version = "0.28.1", features = ["use-dev-tty"] } 61 | unicode-width = "0.1" 62 | itertools = { workspace = true } 63 | tokio = { workspace = true } 64 | async-trait = { workspace = true } 65 | interim = { workspace = true } 66 | clap = { workspace = true } 67 | clap_complete = "4.5.8" 68 | clap_complete_nushell = "4.5.4" 69 | fs-err = { workspace = true } 70 | rpassword = "7.0" 71 | semver = { workspace = true } 72 | rustix = { workspace = true } 73 | runtime-format = "0.1.3" 74 | tiny-bip39 = "1" 75 | futures-util = "0.3" 76 | fuzzy-matcher = "0.3.7" 77 | colored = "2.0.4" 78 | ratatui = "0.29.0" 79 | tracing = "0.1" 80 | tracing-subscriber = { workspace = true } 81 | uuid = { workspace = true } 82 | sysinfo = "0.30.7" 83 | regex = "1.10.5" 84 | tempfile = { workspace = true } 85 | 86 | [target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies] 87 | arboard = { version = "3.4", optional = true } 88 | 89 | [target.'cfg(target_os = "linux")'.dependencies] 90 | arboard = { version = "3.4", optional = true, features = ["wayland-data-control"] } 91 | 92 | [dev-dependencies] 93 | tracing-tree = "0.4" 94 | -------------------------------------------------------------------------------- /crates/atuin/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ellie Huxtable 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /crates/atuin/README.md: -------------------------------------------------------------------------------- 1 | ../../README.md -------------------------------------------------------------------------------- /crates/atuin/build.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | fn main() { 3 | let output = Command::new("git").args(["rev-parse", "HEAD"]).output(); 4 | 5 | let sha = match output { 6 | Ok(sha) => String::from_utf8(sha.stdout).unwrap(), 7 | Err(_) => String::from("NO_GIT"), 8 | }; 9 | 10 | println!("cargo:rustc-env=GIT_HASH={}", sha); 11 | } 12 | -------------------------------------------------------------------------------- /crates/atuin/src/command/CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | ../../../../CONTRIBUTORS -------------------------------------------------------------------------------- /crates/atuin/src/command/client/account.rs: -------------------------------------------------------------------------------- 1 | use clap::{Args, Subcommand}; 2 | use eyre::Result; 3 | 4 | use atuin_client::record::sqlite_store::SqliteStore; 5 | use atuin_client::settings::Settings; 6 | 7 | pub mod change_password; 8 | pub mod delete; 9 | pub mod login; 10 | pub mod logout; 11 | pub mod register; 12 | pub mod verify; 13 | 14 | #[derive(Args, Debug)] 15 | pub struct Cmd { 16 | #[command(subcommand)] 17 | command: Commands, 18 | } 19 | 20 | #[derive(Subcommand, Debug)] 21 | pub enum Commands { 22 | /// Login to the configured server 23 | Login(login::Cmd), 24 | 25 | /// Register a new account 26 | Register(register::Cmd), 27 | 28 | /// Log out 29 | Logout, 30 | 31 | /// Delete your account, and all synced data 32 | Delete, 33 | 34 | /// Change your password 35 | ChangePassword(change_password::Cmd), 36 | 37 | /// Verify your account 38 | Verify(verify::Cmd), 39 | } 40 | 41 | impl Cmd { 42 | pub async fn run(self, settings: Settings, store: SqliteStore) -> Result<()> { 43 | match self.command { 44 | Commands::Login(l) => l.run(&settings, &store).await, 45 | Commands::Register(r) => r.run(&settings).await, 46 | Commands::Logout => logout::run(&settings), 47 | Commands::Delete => delete::run(&settings).await, 48 | Commands::ChangePassword(c) => c.run(&settings).await, 49 | Commands::Verify(c) => c.run(&settings).await, 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/account/change_password.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use eyre::{Result, bail}; 3 | 4 | use atuin_client::{api_client, settings::Settings}; 5 | use rpassword::prompt_password; 6 | 7 | #[derive(Parser, Debug)] 8 | pub struct Cmd { 9 | #[clap(long, short)] 10 | pub current_password: Option, 11 | 12 | #[clap(long, short)] 13 | pub new_password: Option, 14 | } 15 | 16 | impl Cmd { 17 | pub async fn run(self, settings: &Settings) -> Result<()> { 18 | run(settings, self.current_password, self.new_password).await 19 | } 20 | } 21 | 22 | pub async fn run( 23 | settings: &Settings, 24 | current_password: Option, 25 | new_password: Option, 26 | ) -> Result<()> { 27 | let client = api_client::Client::new( 28 | &settings.sync_address, 29 | settings.session_token()?.as_str(), 30 | settings.network_connect_timeout, 31 | settings.network_timeout, 32 | )?; 33 | 34 | let current_password = current_password.clone().unwrap_or_else(|| { 35 | prompt_password("Please enter the current password: ").expect("Failed to read from input") 36 | }); 37 | 38 | if current_password.is_empty() { 39 | bail!("please provide the current password"); 40 | } 41 | 42 | let new_password = new_password.clone().unwrap_or_else(|| { 43 | prompt_password("Please enter the new password: ").expect("Failed to read from input") 44 | }); 45 | 46 | if new_password.is_empty() { 47 | bail!("please provide a new password"); 48 | } 49 | 50 | client 51 | .change_password(current_password, new_password) 52 | .await?; 53 | 54 | println!("Account password successfully changed!"); 55 | 56 | Ok(()) 57 | } 58 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/account/delete.rs: -------------------------------------------------------------------------------- 1 | use atuin_client::{api_client, settings::Settings}; 2 | use eyre::{Result, bail}; 3 | use std::fs::remove_file; 4 | use std::path::PathBuf; 5 | 6 | pub async fn run(settings: &Settings) -> Result<()> { 7 | let session_path = settings.session_path.as_str(); 8 | 9 | if !PathBuf::from(session_path).exists() { 10 | bail!("You are not logged in"); 11 | } 12 | 13 | let client = api_client::Client::new( 14 | &settings.sync_address, 15 | settings.session_token()?.as_str(), 16 | settings.network_connect_timeout, 17 | settings.network_timeout, 18 | )?; 19 | 20 | client.delete().await?; 21 | 22 | // Fixes stale session+key when account is deleted via CLI. 23 | if PathBuf::from(session_path).exists() { 24 | remove_file(PathBuf::from(session_path))?; 25 | } 26 | 27 | println!("Your account is deleted"); 28 | 29 | Ok(()) 30 | } 31 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/account/logout.rs: -------------------------------------------------------------------------------- 1 | use atuin_client::settings::Settings; 2 | use eyre::Result; 3 | 4 | pub fn run(settings: &Settings) -> Result<()> { 5 | atuin_client::logout::logout(settings) 6 | } 7 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/account/register.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use eyre::{Result, bail}; 3 | use tokio::{fs::File, io::AsyncWriteExt}; 4 | 5 | use atuin_client::{api_client, settings::Settings}; 6 | 7 | #[derive(Parser, Debug)] 8 | pub struct Cmd { 9 | #[clap(long, short)] 10 | pub username: Option, 11 | 12 | #[clap(long, short)] 13 | pub password: Option, 14 | 15 | #[clap(long, short)] 16 | pub email: Option, 17 | } 18 | 19 | impl Cmd { 20 | pub async fn run(self, settings: &Settings) -> Result<()> { 21 | run(settings, self.username, self.email, self.password).await 22 | } 23 | } 24 | 25 | pub async fn run( 26 | settings: &Settings, 27 | username: Option, 28 | email: Option, 29 | password: Option, 30 | ) -> Result<()> { 31 | use super::login::or_user_input; 32 | println!("Registering for an Atuin Sync account"); 33 | 34 | let username = or_user_input(username, "username"); 35 | let email = or_user_input(email, "email"); 36 | 37 | let password = password 38 | .clone() 39 | .unwrap_or_else(super::login::read_user_password); 40 | 41 | if password.is_empty() { 42 | bail!("please provide a password"); 43 | } 44 | 45 | let session = 46 | api_client::register(settings.sync_address.as_str(), &username, &email, &password).await?; 47 | 48 | let path = settings.session_path.as_str(); 49 | let mut file = File::create(path).await?; 50 | file.write_all(session.session.as_bytes()).await?; 51 | 52 | let _key = atuin_client::encryption::load_key(settings)?; 53 | 54 | println!( 55 | "Registration successful! Please make a note of your key (run 'atuin key') and keep it safe." 56 | ); 57 | println!( 58 | "You will need it to log in on other devices, and we cannot help recover it if you lose it." 59 | ); 60 | 61 | Ok(()) 62 | } 63 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/account/verify.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use eyre::Result; 3 | 4 | use atuin_client::{api_client, settings::Settings}; 5 | 6 | #[derive(Parser, Debug)] 7 | pub struct Cmd { 8 | #[clap(long, short)] 9 | pub token: Option, 10 | } 11 | 12 | impl Cmd { 13 | pub async fn run(self, settings: &Settings) -> Result<()> { 14 | run(settings, self.token).await 15 | } 16 | } 17 | 18 | pub async fn run(settings: &Settings, token: Option) -> Result<()> { 19 | let client = api_client::Client::new( 20 | &settings.sync_address, 21 | settings.session_token()?.as_str(), 22 | settings.network_connect_timeout, 23 | settings.network_timeout, 24 | )?; 25 | 26 | let (email_sent, verified) = client.verify(token).await?; 27 | 28 | match (email_sent, verified) { 29 | (true, false) => { 30 | println!("Verification sent! Please check your inbox"); 31 | } 32 | 33 | (false, true) => { 34 | println!("Your account is verified"); 35 | } 36 | 37 | (false, false) => { 38 | println!( 39 | "Your Atuin server does not have mail setup. This is not required, though your account cannot be verified. Speak to your admin." 40 | ); 41 | } 42 | 43 | _ => { 44 | println!( 45 | "Invalid email and verification status. This is a bug. Please open an issue: https://github.com/atuinsh/atuin" 46 | ); 47 | } 48 | } 49 | 50 | Ok(()) 51 | } 52 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/daemon.rs: -------------------------------------------------------------------------------- 1 | use eyre::Result; 2 | 3 | use atuin_client::{database::Sqlite, record::sqlite_store::SqliteStore, settings::Settings}; 4 | use atuin_daemon::server::listen; 5 | 6 | pub async fn run(settings: Settings, store: SqliteStore, history_db: Sqlite) -> Result<()> { 7 | listen(settings, store, history_db).await?; 8 | 9 | Ok(()) 10 | } 11 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/default_config.rs: -------------------------------------------------------------------------------- 1 | use atuin_client::settings::Settings; 2 | 3 | pub fn run() { 4 | println!("{}", Settings::example_config()); 5 | } 6 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/dotfiles.rs: -------------------------------------------------------------------------------- 1 | use clap::Subcommand; 2 | use eyre::Result; 3 | 4 | use atuin_client::{record::sqlite_store::SqliteStore, settings::Settings}; 5 | 6 | mod alias; 7 | mod var; 8 | 9 | #[derive(Subcommand, Debug)] 10 | #[command(infer_subcommands = true)] 11 | pub enum Cmd { 12 | /// Manage shell aliases with Atuin 13 | #[command(subcommand)] 14 | Alias(alias::Cmd), 15 | 16 | /// Manage shell and environment variables with Atuin 17 | #[command(subcommand)] 18 | Var(var::Cmd), 19 | } 20 | 21 | impl Cmd { 22 | pub async fn run(self, settings: &Settings, store: SqliteStore) -> Result<()> { 23 | match self { 24 | Self::Alias(cmd) => cmd.run(settings, store).await, 25 | Self::Var(cmd) => cmd.run(settings, store).await, 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/dotfiles/var.rs: -------------------------------------------------------------------------------- 1 | use clap::Subcommand; 2 | use eyre::{Context, Result}; 3 | 4 | use atuin_client::{encryption, record::sqlite_store::SqliteStore, settings::Settings}; 5 | 6 | use atuin_dotfiles::{shell::Var, store::var::VarStore}; 7 | 8 | #[derive(Subcommand, Debug)] 9 | #[command(infer_subcommands = true)] 10 | pub enum Cmd { 11 | /// Set a variable 12 | Set { 13 | name: String, 14 | value: String, 15 | 16 | #[clap(long, short, action)] 17 | no_export: bool, 18 | }, 19 | 20 | /// Delete a variable 21 | Delete { name: String }, 22 | 23 | /// List all variables 24 | List, 25 | } 26 | 27 | impl Cmd { 28 | async fn set(&self, store: VarStore, name: String, value: String, export: bool) -> Result<()> { 29 | let vars = store.vars().await?; 30 | let found: Vec = vars.into_iter().filter(|a| a.name == name).collect(); 31 | let show_export = if export { "export " } else { "" }; 32 | 33 | if found.is_empty() { 34 | println!("Setting '{show_export}{name}={value}'."); 35 | } else { 36 | println!( 37 | "Overwriting alias '{show_export}{name}={}' with '{name}={value}'.", 38 | found[0].value 39 | ); 40 | } 41 | 42 | store.set(&name, &value, export).await?; 43 | 44 | Ok(()) 45 | } 46 | 47 | async fn list(&self, store: VarStore) -> Result<()> { 48 | let vars = store.vars().await?; 49 | 50 | for i in vars.iter().filter(|v| !v.export) { 51 | println!("{}={}", i.name, i.value); 52 | } 53 | 54 | for i in vars.iter().filter(|v| v.export) { 55 | println!("export {}={}", i.name, i.value); 56 | } 57 | 58 | Ok(()) 59 | } 60 | 61 | async fn delete(&self, store: VarStore, name: String) -> Result<()> { 62 | let mut vars = store.vars().await?.into_iter(); 63 | 64 | if let Some(var) = vars.find(|var| var.name == name) { 65 | println!("Deleting '{name}={}'.", var.value); 66 | store.delete(&name).await?; 67 | } else { 68 | eprintln!("Cannot delete '{name}': Var not set."); 69 | } 70 | 71 | Ok(()) 72 | } 73 | 74 | pub async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> { 75 | if !settings.dotfiles.enabled { 76 | eprintln!( 77 | "Dotfiles are not enabled. Add\n\n[dotfiles]\nenabled = true\n\nto your configuration file to enable them.\n" 78 | ); 79 | eprintln!("The default configuration file is located at ~/.config/atuin/config.toml."); 80 | return Ok(()); 81 | } 82 | 83 | let encryption_key: [u8; 32] = encryption::load_key(settings) 84 | .context("could not load encryption key")? 85 | .into(); 86 | let host_id = Settings::host_id().expect("failed to get host_id"); 87 | 88 | let var_store = VarStore::new(store, host_id, encryption_key); 89 | 90 | match self { 91 | Self::Set { 92 | name, 93 | value, 94 | no_export, 95 | } => { 96 | self.set(var_store, name.clone(), value.clone(), !no_export) 97 | .await 98 | } 99 | Self::Delete { name } => self.delete(var_store, name.clone()).await, 100 | Self::List => self.list(var_store).await, 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/info.rs: -------------------------------------------------------------------------------- 1 | use atuin_client::settings::Settings; 2 | 3 | use crate::VERSION; 4 | 5 | pub fn run(settings: &Settings) { 6 | let config = atuin_common::utils::config_dir(); 7 | let mut config_file = config.clone(); 8 | config_file.push("config.toml"); 9 | let mut sever_config = config; 10 | sever_config.push("server.toml"); 11 | 12 | let config_paths = format!( 13 | "Config files:\nclient config: {:?}\nserver config: {:?}\nclient db path: {:?}\nkey path: {:?}\nsession path: {:?}", 14 | config_file.to_string_lossy(), 15 | sever_config.to_string_lossy(), 16 | settings.db_path, 17 | settings.key_path, 18 | settings.session_path 19 | ); 20 | 21 | let env_vars = format!( 22 | "Env Vars:\nATUIN_CONFIG_DIR = {:?}", 23 | std::env::var("ATUIN_CONFIG_DIR").unwrap_or_else(|_| "None".into()) 24 | ); 25 | 26 | let general_info = format!("Version info:\nversion: {VERSION}"); 27 | 28 | let print_out = format!("{config_paths}\n\n{env_vars}\n\n{general_info}"); 29 | 30 | println!("{print_out}"); 31 | } 32 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/init/bash.rs: -------------------------------------------------------------------------------- 1 | use atuin_dotfiles::store::{AliasStore, var::VarStore}; 2 | use eyre::Result; 3 | 4 | pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) { 5 | let base = include_str!("../../../shell/atuin.bash"); 6 | 7 | let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() { 8 | (false, false) 9 | } else { 10 | (!disable_ctrl_r, !disable_up_arrow) 11 | }; 12 | 13 | println!("__atuin_bind_ctrl_r={bind_ctrl_r}"); 14 | println!("__atuin_bind_up_arrow={bind_up_arrow}"); 15 | println!("{base}"); 16 | } 17 | 18 | pub async fn init( 19 | aliases: AliasStore, 20 | vars: VarStore, 21 | disable_up_arrow: bool, 22 | disable_ctrl_r: bool, 23 | ) -> Result<()> { 24 | init_static(disable_up_arrow, disable_ctrl_r); 25 | 26 | let aliases = atuin_dotfiles::shell::bash::alias_config(&aliases).await; 27 | let vars = atuin_dotfiles::shell::bash::var_config(&vars).await; 28 | 29 | println!("{aliases}"); 30 | println!("{vars}"); 31 | 32 | Ok(()) 33 | } 34 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/init/fish.rs: -------------------------------------------------------------------------------- 1 | use atuin_dotfiles::store::{AliasStore, var::VarStore}; 2 | use eyre::Result; 3 | 4 | pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) { 5 | let base = include_str!("../../../shell/atuin.fish"); 6 | 7 | println!("{base}"); 8 | 9 | // In fish 4.0 and above the option bind -k doesn't exist anymore. 10 | // We keep it for compatibility with fish 3.x 11 | if std::env::var("ATUIN_NOBIND").is_err() { 12 | const BIND_CTRL_R: &str = r"bind \cr _atuin_search"; 13 | const BIND_CTRL_R_INS: &str = r"bind -M insert \cr _atuin_search"; 14 | const BIND_UP_ARROW_INS: &str = r"bind -M insert -k up _atuin_bind_up 15 | bind -M insert \eOA _atuin_bind_up 16 | bind -M insert \e\[A _atuin_bind_up"; 17 | 18 | let bind_up_arrow = match std::env::var("FISH_VERSION") { 19 | Ok(ref version) if version.starts_with("4.") => r"bind up _atuin_bind_up", 20 | Ok(_) => r"bind -k up _atuin_bind_up", 21 | 22 | // do nothing - we can't panic or error as this could be in use in 23 | // non-fish pipelines 24 | _ => "", 25 | } 26 | .to_string(); 27 | 28 | if !disable_ctrl_r { 29 | println!("{BIND_CTRL_R}"); 30 | } 31 | if !disable_up_arrow { 32 | println!( 33 | r"{bind_up_arrow} 34 | bind \eOA _atuin_bind_up 35 | bind \e\[A _atuin_bind_up" 36 | ); 37 | } 38 | 39 | println!("if bind -M insert > /dev/null 2>&1"); 40 | if !disable_ctrl_r { 41 | println!("{BIND_CTRL_R_INS}"); 42 | } 43 | if !disable_up_arrow { 44 | println!("{BIND_UP_ARROW_INS}"); 45 | } 46 | println!("end"); 47 | } 48 | } 49 | 50 | pub async fn init( 51 | aliases: AliasStore, 52 | vars: VarStore, 53 | disable_up_arrow: bool, 54 | disable_ctrl_r: bool, 55 | ) -> Result<()> { 56 | init_static(disable_up_arrow, disable_ctrl_r); 57 | 58 | let aliases = atuin_dotfiles::shell::fish::alias_config(&aliases).await; 59 | let vars = atuin_dotfiles::shell::fish::var_config(&vars).await; 60 | 61 | println!("{aliases}"); 62 | println!("{vars}"); 63 | 64 | Ok(()) 65 | } 66 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/init/xonsh.rs: -------------------------------------------------------------------------------- 1 | use atuin_dotfiles::store::{AliasStore, var::VarStore}; 2 | use eyre::Result; 3 | 4 | pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) { 5 | let base = include_str!("../../../shell/atuin.xsh"); 6 | 7 | let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() { 8 | (false, false) 9 | } else { 10 | (!disable_ctrl_r, !disable_up_arrow) 11 | }; 12 | println!( 13 | "_ATUIN_BIND_CTRL_R={}", 14 | if bind_ctrl_r { "True" } else { "False" } 15 | ); 16 | println!( 17 | "_ATUIN_BIND_UP_ARROW={}", 18 | if bind_up_arrow { "True" } else { "False" } 19 | ); 20 | println!("{base}"); 21 | } 22 | 23 | pub async fn init( 24 | aliases: AliasStore, 25 | vars: VarStore, 26 | disable_up_arrow: bool, 27 | disable_ctrl_r: bool, 28 | ) -> Result<()> { 29 | init_static(disable_up_arrow, disable_ctrl_r); 30 | 31 | let aliases = atuin_dotfiles::shell::xonsh::alias_config(&aliases).await; 32 | let vars = atuin_dotfiles::shell::xonsh::var_config(&vars).await; 33 | 34 | println!("{aliases}"); 35 | println!("{vars}"); 36 | 37 | Ok(()) 38 | } 39 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/init/zsh.rs: -------------------------------------------------------------------------------- 1 | use atuin_dotfiles::store::{AliasStore, var::VarStore}; 2 | use eyre::Result; 3 | 4 | pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) { 5 | let base = include_str!("../../../shell/atuin.zsh"); 6 | 7 | println!("{base}"); 8 | 9 | if std::env::var("ATUIN_NOBIND").is_err() { 10 | const BIND_CTRL_R: &str = r"bindkey -M emacs '^r' atuin-search 11 | bindkey -M viins '^r' atuin-search-viins 12 | bindkey -M vicmd '/' atuin-search"; 13 | 14 | const BIND_UP_ARROW: &str = r"bindkey -M emacs '^[[A' atuin-up-search 15 | bindkey -M vicmd '^[[A' atuin-up-search-vicmd 16 | bindkey -M viins '^[[A' atuin-up-search-viins 17 | bindkey -M emacs '^[OA' atuin-up-search 18 | bindkey -M vicmd '^[OA' atuin-up-search-vicmd 19 | bindkey -M viins '^[OA' atuin-up-search-viins 20 | bindkey -M vicmd 'k' atuin-up-search-vicmd"; 21 | 22 | if !disable_ctrl_r { 23 | println!("{BIND_CTRL_R}"); 24 | } 25 | if !disable_up_arrow { 26 | println!("{BIND_UP_ARROW}"); 27 | } 28 | } 29 | } 30 | 31 | pub async fn init( 32 | aliases: AliasStore, 33 | vars: VarStore, 34 | disable_up_arrow: bool, 35 | disable_ctrl_r: bool, 36 | ) -> Result<()> { 37 | init_static(disable_up_arrow, disable_ctrl_r); 38 | 39 | let aliases = atuin_dotfiles::shell::zsh::alias_config(&aliases).await; 40 | let vars = atuin_dotfiles::shell::zsh::var_config(&vars).await; 41 | 42 | println!("{aliases}"); 43 | println!("{vars}"); 44 | 45 | Ok(()) 46 | } 47 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/search/duration.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | use std::{ops::ControlFlow, time::Duration}; 3 | 4 | #[allow(clippy::module_name_repetitions)] 5 | pub fn format_duration_into(dur: Duration, f: &mut fmt::Formatter<'_>) -> fmt::Result { 6 | fn item(unit: &'static str, value: u64) -> ControlFlow<(&'static str, u64)> { 7 | if value > 0 { 8 | ControlFlow::Break((unit, value)) 9 | } else { 10 | ControlFlow::Continue(()) 11 | } 12 | } 13 | 14 | // impl taken and modified from 15 | // https://github.com/tailhook/humantime/blob/master/src/duration.rs#L295-L331 16 | // Copyright (c) 2016 The humantime Developers 17 | fn fmt(f: Duration) -> ControlFlow<(&'static str, u64), ()> { 18 | let secs = f.as_secs(); 19 | let nanos = f.subsec_nanos(); 20 | 21 | let years = secs / 31_557_600; // 365.25d 22 | let year_days = secs % 31_557_600; 23 | let months = year_days / 2_630_016; // 30.44d 24 | let month_days = year_days % 2_630_016; 25 | let days = month_days / 86400; 26 | let day_secs = month_days % 86400; 27 | let hours = day_secs / 3600; 28 | let minutes = day_secs % 3600 / 60; 29 | let seconds = day_secs % 60; 30 | 31 | let millis = nanos / 1_000_000; 32 | let micros = nanos / 1_000; 33 | 34 | // a difference from our impl than the original is that 35 | // we only care about the most-significant segment of the duration. 36 | // If the item call returns `Break`, then the `?` will early-return. 37 | // This allows for a very consise impl 38 | item("y", years)?; 39 | item("mo", months)?; 40 | item("d", days)?; 41 | item("h", hours)?; 42 | item("m", minutes)?; 43 | item("s", seconds)?; 44 | item("ms", u64::from(millis))?; 45 | item("us", u64::from(micros))?; 46 | item("ns", u64::from(nanos))?; 47 | ControlFlow::Continue(()) 48 | } 49 | 50 | match fmt(dur) { 51 | ControlFlow::Break((unit, value)) => write!(f, "{value}{unit}"), 52 | ControlFlow::Continue(()) => write!(f, "0s"), 53 | } 54 | } 55 | 56 | #[allow(clippy::module_name_repetitions)] 57 | pub fn format_duration(f: Duration) -> String { 58 | struct F(Duration); 59 | impl fmt::Display for F { 60 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 61 | format_duration_into(self.0, f) 62 | } 63 | } 64 | F(f).to_string() 65 | } 66 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/search/engines.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use atuin_client::{ 3 | database::{Context, Database}, 4 | history::History, 5 | settings::{FilterMode, SearchMode, Settings}, 6 | }; 7 | use eyre::Result; 8 | 9 | use super::cursor::Cursor; 10 | 11 | pub mod db; 12 | pub mod skim; 13 | 14 | pub fn engine(search_mode: SearchMode) -> Box { 15 | match search_mode { 16 | SearchMode::Skim => Box::new(skim::Search::new()) as Box<_>, 17 | mode => Box::new(db::Search(mode)) as Box<_>, 18 | } 19 | } 20 | 21 | pub struct SearchState { 22 | pub input: Cursor, 23 | pub filter_mode: FilterMode, 24 | pub context: Context, 25 | } 26 | 27 | impl SearchState { 28 | pub(crate) fn rotate_filter_mode(&mut self, settings: &Settings, offset: isize) { 29 | let mut i = settings 30 | .search 31 | .filters 32 | .iter() 33 | .position(|&m| m == self.filter_mode) 34 | .unwrap_or_default(); 35 | for _ in 0..settings.search.filters.len() { 36 | i = (i.wrapping_add_signed(offset)) % settings.search.filters.len(); 37 | let mode = settings.search.filters[i]; 38 | if self.filter_mode_available(mode, settings) { 39 | self.filter_mode = mode; 40 | break; 41 | } 42 | } 43 | } 44 | 45 | fn filter_mode_available(&self, mode: FilterMode, settings: &Settings) -> bool { 46 | match mode { 47 | FilterMode::Workspace => settings.workspaces && self.context.git_root.is_some(), 48 | _ => true, 49 | } 50 | } 51 | } 52 | 53 | #[async_trait] 54 | pub trait SearchEngine: Send + Sync + 'static { 55 | async fn full_query( 56 | &mut self, 57 | state: &SearchState, 58 | db: &mut dyn Database, 59 | ) -> Result>; 60 | 61 | async fn query(&mut self, state: &SearchState, db: &mut dyn Database) -> Result> { 62 | if state.input.as_str().is_empty() { 63 | Ok(db 64 | .list(&[state.filter_mode], &state.context, Some(200), true, false) 65 | .await? 66 | .into_iter() 67 | .collect::>()) 68 | } else { 69 | self.full_query(state, db).await 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/search/engines/db.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use atuin_client::{ 3 | database::Database, database::OptFilters, history::History, settings::SearchMode, 4 | }; 5 | use eyre::Result; 6 | 7 | use super::{SearchEngine, SearchState}; 8 | 9 | pub struct Search(pub SearchMode); 10 | 11 | #[async_trait] 12 | impl SearchEngine for Search { 13 | async fn full_query( 14 | &mut self, 15 | state: &SearchState, 16 | db: &mut dyn Database, 17 | ) -> Result> { 18 | Ok(db 19 | .search( 20 | self.0, 21 | state.filter_mode, 22 | &state.context, 23 | state.input.as_str(), 24 | OptFilters { 25 | limit: Some(200), 26 | ..Default::default() 27 | }, 28 | ) 29 | .await 30 | // ignore errors as it may be caused by incomplete regex 31 | .map_or(Vec::new(), |r| r.into_iter().collect())) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/stats.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use eyre::Result; 3 | use interim::parse_date_string; 4 | use time::{Duration, OffsetDateTime, Time}; 5 | 6 | use atuin_client::{ 7 | database::{Database, current_context}, 8 | settings::Settings, 9 | theme::Theme, 10 | }; 11 | 12 | use atuin_history::stats::{compute, pretty_print}; 13 | 14 | #[derive(Parser, Debug)] 15 | #[command(infer_subcommands = true)] 16 | pub struct Cmd { 17 | /// Compute statistics for the specified period, leave blank for statistics since the beginning. See [this](https://docs.atuin.sh/reference/stats/) for more details. 18 | period: Vec, 19 | 20 | /// How many top commands to list 21 | #[arg(long, short, default_value = "10")] 22 | count: usize, 23 | 24 | /// The number of consecutive commands to consider 25 | #[arg(long, short, default_value = "1")] 26 | ngram_size: usize, 27 | } 28 | 29 | impl Cmd { 30 | pub async fn run(&self, db: &impl Database, settings: &Settings, theme: &Theme) -> Result<()> { 31 | let context = current_context(); 32 | let words = if self.period.is_empty() { 33 | String::from("all") 34 | } else { 35 | self.period.join(" ") 36 | }; 37 | 38 | let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0); 39 | let last_night = now.replace_time(Time::MIDNIGHT); 40 | 41 | let history = if words.as_str() == "all" { 42 | db.list(&[], &context, None, false, false).await? 43 | } else if words.trim() == "today" { 44 | let start = last_night; 45 | let end = start + Duration::days(1); 46 | db.range(start, end).await? 47 | } else if words.trim() == "month" { 48 | let end = last_night; 49 | let start = end - Duration::days(31); 50 | db.range(start, end).await? 51 | } else if words.trim() == "week" { 52 | let end = last_night; 53 | let start = end - Duration::days(7); 54 | db.range(start, end).await? 55 | } else if words.trim() == "year" { 56 | let end = last_night; 57 | let start = end - Duration::days(365); 58 | db.range(start, end).await? 59 | } else { 60 | let start = parse_date_string(&words, now, settings.dialect.into())?; 61 | let end = start + Duration::days(1); 62 | db.range(start, end).await? 63 | }; 64 | 65 | let stats = compute(settings, &history, self.count, self.ngram_size); 66 | 67 | if let Some(stats) = stats { 68 | pretty_print(stats, self.ngram_size, theme); 69 | } 70 | 71 | Ok(()) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/store/pull.rs: -------------------------------------------------------------------------------- 1 | use clap::Args; 2 | use eyre::Result; 3 | 4 | use atuin_client::{ 5 | database::Database, 6 | record::store::Store, 7 | record::sync::Operation, 8 | record::{sqlite_store::SqliteStore, sync}, 9 | settings::Settings, 10 | }; 11 | 12 | #[derive(Args, Debug)] 13 | pub struct Pull { 14 | /// The tag to push (eg, 'history'). Defaults to all tags 15 | #[arg(long, short)] 16 | pub tag: Option, 17 | 18 | /// Force push records 19 | /// This will first wipe the local store, and then download all records from the remote 20 | #[arg(long, default_value = "false")] 21 | pub force: bool, 22 | } 23 | 24 | impl Pull { 25 | pub async fn run( 26 | &self, 27 | settings: &Settings, 28 | store: SqliteStore, 29 | db: &dyn Database, 30 | ) -> Result<()> { 31 | if self.force { 32 | println!("Forcing local overwrite!"); 33 | println!("Clearing local store"); 34 | 35 | store.delete_all().await?; 36 | } 37 | 38 | // We can actually just use the existing diff/etc to push 39 | // 1. Diff 40 | // 2. Get operations 41 | // 3. Filter operations by 42 | // a) are they a download op? 43 | // b) are they for the host/tag we are pushing here? 44 | let (diff, _) = sync::diff(settings, &store).await?; 45 | let operations = sync::operations(diff, &store).await?; 46 | 47 | let operations = operations 48 | .into_iter() 49 | .filter(|op| match op { 50 | // No noops or downloads thx 51 | Operation::Noop { .. } | Operation::Upload { .. } => false, 52 | 53 | // pull, so yes plz to downloads! 54 | Operation::Download { tag, .. } => { 55 | if self.force { 56 | return true; 57 | } 58 | 59 | if let Some(t) = self.tag.clone() { 60 | if t != *tag { 61 | return false; 62 | } 63 | } 64 | 65 | true 66 | } 67 | }) 68 | .collect(); 69 | 70 | let (_, downloaded) = sync::sync_remote(operations, &store, settings).await?; 71 | 72 | println!("Downloaded {} records", downloaded.len()); 73 | 74 | crate::sync::build(settings, &store, db, Some(&downloaded)).await?; 75 | 76 | Ok(()) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/store/purge.rs: -------------------------------------------------------------------------------- 1 | use clap::Args; 2 | use eyre::Result; 3 | 4 | use atuin_client::{ 5 | encryption::load_key, 6 | record::{sqlite_store::SqliteStore, store::Store}, 7 | settings::Settings, 8 | }; 9 | 10 | #[derive(Args, Debug)] 11 | pub struct Purge {} 12 | 13 | impl Purge { 14 | pub async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> { 15 | println!("Purging local records that cannot be decrypted"); 16 | 17 | let key = load_key(settings)?; 18 | 19 | match store.purge(&key.into()).await { 20 | Ok(()) => println!("Local store purge completed OK"), 21 | Err(e) => println!("Failed to purge local store: {e:?}"), 22 | } 23 | 24 | Ok(()) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/store/push.rs: -------------------------------------------------------------------------------- 1 | use atuin_common::record::HostId; 2 | use clap::Args; 3 | use eyre::Result; 4 | use uuid::Uuid; 5 | 6 | use atuin_client::{ 7 | api_client::Client, 8 | record::sync::Operation, 9 | record::{sqlite_store::SqliteStore, sync}, 10 | settings::Settings, 11 | }; 12 | 13 | #[derive(Args, Debug)] 14 | pub struct Push { 15 | /// The tag to push (eg, 'history'). Defaults to all tags 16 | #[arg(long, short)] 17 | pub tag: Option, 18 | 19 | /// The host to push, in the form of a UUID host ID. Defaults to the current host. 20 | #[arg(long)] 21 | pub host: Option, 22 | 23 | /// Force push records 24 | /// This will override both host and tag, to be all hosts and all tags. First clear the remote store, then upload all of the 25 | /// local store 26 | #[arg(long, default_value = "false")] 27 | pub force: bool, 28 | } 29 | 30 | impl Push { 31 | pub async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> { 32 | let host_id = Settings::host_id().expect("failed to get host_id"); 33 | 34 | if self.force { 35 | println!("Forcing remote store overwrite!"); 36 | println!("Clearing remote store"); 37 | 38 | let client = Client::new( 39 | &settings.sync_address, 40 | settings.session_token()?.as_str(), 41 | settings.network_connect_timeout, 42 | settings.network_timeout * 10, // we may be deleting a lot of data... so up the 43 | // timeout 44 | ) 45 | .expect("failed to create client"); 46 | 47 | client.delete_store().await?; 48 | } 49 | 50 | // We can actually just use the existing diff/etc to push 51 | // 1. Diff 52 | // 2. Get operations 53 | // 3. Filter operations by 54 | // a) are they an upload op? 55 | // b) are they for the host/tag we are pushing here? 56 | let (diff, _) = sync::diff(settings, &store).await?; 57 | let operations = sync::operations(diff, &store).await?; 58 | 59 | let operations = operations 60 | .into_iter() 61 | .filter(|op| match op { 62 | // No noops or downloads thx 63 | Operation::Noop { .. } | Operation::Download { .. } => false, 64 | 65 | // push, so yes plz to uploads! 66 | Operation::Upload { host, tag, .. } => { 67 | if self.force { 68 | return true; 69 | } 70 | 71 | if let Some(h) = self.host { 72 | if HostId(h) != *host { 73 | return false; 74 | } 75 | } else if *host != host_id { 76 | return false; 77 | } 78 | 79 | if let Some(t) = self.tag.clone() { 80 | if t != *tag { 81 | return false; 82 | } 83 | } 84 | 85 | true 86 | } 87 | }) 88 | .collect(); 89 | 90 | let (uploaded, _) = sync::sync_remote(operations, &store, settings).await?; 91 | 92 | println!("Uploaded {uploaded} records"); 93 | 94 | Ok(()) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/store/rebuild.rs: -------------------------------------------------------------------------------- 1 | use atuin_dotfiles::store::{AliasStore, var::VarStore}; 2 | use atuin_scripts::store::ScriptStore; 3 | use clap::Args; 4 | use eyre::{Result, bail}; 5 | 6 | use atuin_client::{ 7 | database::Database, encryption, history::store::HistoryStore, 8 | record::sqlite_store::SqliteStore, settings::Settings, 9 | }; 10 | 11 | #[derive(Args, Debug)] 12 | pub struct Rebuild { 13 | pub tag: String, 14 | } 15 | 16 | impl Rebuild { 17 | pub async fn run( 18 | &self, 19 | settings: &Settings, 20 | store: SqliteStore, 21 | database: &dyn Database, 22 | ) -> Result<()> { 23 | // keep it as a string and not an enum atm 24 | // would be super cool to build this dynamically in the future 25 | // eg register handles for rebuilding various tags without having to make this part of the 26 | // binary big 27 | match self.tag.as_str() { 28 | "history" => { 29 | self.rebuild_history(settings, store.clone(), database) 30 | .await?; 31 | } 32 | 33 | "dotfiles" => { 34 | self.rebuild_dotfiles(settings, store.clone()).await?; 35 | } 36 | 37 | "scripts" => { 38 | self.rebuild_scripts(settings, store.clone()).await?; 39 | } 40 | 41 | tag => bail!("unknown tag: {tag}"), 42 | } 43 | 44 | Ok(()) 45 | } 46 | 47 | async fn rebuild_history( 48 | &self, 49 | settings: &Settings, 50 | store: SqliteStore, 51 | database: &dyn Database, 52 | ) -> Result<()> { 53 | let encryption_key: [u8; 32] = encryption::load_key(settings)?.into(); 54 | 55 | let host_id = Settings::host_id().expect("failed to get host_id"); 56 | let history_store = HistoryStore::new(store, host_id, encryption_key); 57 | 58 | history_store.build(database).await?; 59 | 60 | Ok(()) 61 | } 62 | 63 | async fn rebuild_dotfiles(&self, settings: &Settings, store: SqliteStore) -> Result<()> { 64 | let encryption_key: [u8; 32] = encryption::load_key(settings)?.into(); 65 | 66 | let host_id = Settings::host_id().expect("failed to get host_id"); 67 | 68 | let alias_store = AliasStore::new(store.clone(), host_id, encryption_key); 69 | let var_store = VarStore::new(store.clone(), host_id, encryption_key); 70 | 71 | alias_store.build().await?; 72 | var_store.build().await?; 73 | 74 | Ok(()) 75 | } 76 | 77 | async fn rebuild_scripts(&self, settings: &Settings, store: SqliteStore) -> Result<()> { 78 | let encryption_key: [u8; 32] = encryption::load_key(settings)?.into(); 79 | let host_id = Settings::host_id().expect("failed to get host_id"); 80 | let script_store = ScriptStore::new(store, host_id, encryption_key); 81 | let database = 82 | atuin_scripts::database::Database::new(settings.scripts.database_path.clone(), 1.0) 83 | .await?; 84 | 85 | script_store.build(database).await?; 86 | 87 | Ok(()) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/store/rekey.rs: -------------------------------------------------------------------------------- 1 | use clap::Args; 2 | use eyre::{Result, bail}; 3 | use tokio::{fs::File, io::AsyncWriteExt}; 4 | 5 | use atuin_client::{ 6 | encryption::{Key, decode_key, encode_key, generate_encoded_key, load_key}, 7 | record::sqlite_store::SqliteStore, 8 | record::store::Store, 9 | settings::Settings, 10 | }; 11 | 12 | #[derive(Args, Debug)] 13 | pub struct Rekey { 14 | /// The new key to use for encryption. Omit for a randomly-generated key 15 | key: Option, 16 | } 17 | 18 | impl Rekey { 19 | pub async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> { 20 | let key = if let Some(key) = self.key.clone() { 21 | println!("Re-encrypting store with specified key"); 22 | 23 | let key = match bip39::Mnemonic::from_phrase(&key, bip39::Language::English) { 24 | Ok(mnemonic) => encode_key(Key::from_slice(mnemonic.entropy()))?, 25 | Err(err) => { 26 | match err.downcast_ref::() { 27 | Some(err) => { 28 | match err { 29 | // assume they copied in the base64 key 30 | bip39::ErrorKind::InvalidWord => key, 31 | bip39::ErrorKind::InvalidChecksum => { 32 | bail!("key mnemonic was not valid") 33 | } 34 | bip39::ErrorKind::InvalidKeysize(_) 35 | | bip39::ErrorKind::InvalidWordLength(_) 36 | | bip39::ErrorKind::InvalidEntropyLength(_, _) => { 37 | bail!("key was not the correct length") 38 | } 39 | } 40 | } 41 | _ => { 42 | // unknown error. assume they copied the base64 key 43 | key 44 | } 45 | } 46 | } 47 | }; 48 | 49 | key 50 | } else { 51 | println!("Re-encrypting store with freshly-generated key"); 52 | let (_, encoded) = generate_encoded_key()?; 53 | encoded 54 | }; 55 | 56 | let current_key: [u8; 32] = load_key(settings)?.into(); 57 | let new_key: [u8; 32] = decode_key(key.clone())?.into(); 58 | 59 | store.re_encrypt(¤t_key, &new_key).await?; 60 | 61 | println!("Store rewritten. Saving new key"); 62 | let mut file = File::create(settings.key_path.clone()).await?; 63 | file.write_all(key.as_bytes()).await?; 64 | 65 | Ok(()) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/store/verify.rs: -------------------------------------------------------------------------------- 1 | use clap::Args; 2 | use eyre::Result; 3 | 4 | use atuin_client::{ 5 | encryption::load_key, 6 | record::{sqlite_store::SqliteStore, store::Store}, 7 | settings::Settings, 8 | }; 9 | 10 | #[derive(Args, Debug)] 11 | pub struct Verify {} 12 | 13 | impl Verify { 14 | pub async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> { 15 | println!("Verifying local store can be decrypted with the current key"); 16 | 17 | let key = load_key(settings)?; 18 | 19 | match store.verify(&key.into()).await { 20 | Ok(()) => println!("Local store encryption verified OK"), 21 | Err(e) => println!("Failed to verify local store encryption: {e:?}"), 22 | } 23 | 24 | Ok(()) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /crates/atuin/src/command/client/sync/status.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::{SHA, VERSION}; 4 | use atuin_client::{api_client, database::Database, settings::Settings}; 5 | use colored::Colorize; 6 | use eyre::Result; 7 | 8 | pub async fn run(settings: &Settings, db: &impl Database) -> Result<()> { 9 | let session_path = settings.session_path.as_str(); 10 | 11 | if !PathBuf::from(session_path).exists() { 12 | println!("You are not logged in to a sync server - cannot show sync status"); 13 | 14 | return Ok(()); 15 | } 16 | 17 | let client = api_client::Client::new( 18 | &settings.sync_address, 19 | settings.session_token()?.as_str(), 20 | settings.network_connect_timeout, 21 | settings.network_timeout, 22 | )?; 23 | 24 | let status = client.status().await?; 25 | let last_sync = Settings::last_sync()?; 26 | 27 | println!("Atuin v{VERSION} - Build rev {SHA}\n"); 28 | 29 | println!("{}", "[Local]".green()); 30 | 31 | if settings.auto_sync { 32 | println!("Sync frequency: {}", settings.sync_frequency); 33 | println!("Last sync: {}", last_sync.to_offset(settings.timezone.0)); 34 | } 35 | 36 | if !settings.sync.records { 37 | let local_count = db.history_count(false).await?; 38 | let deleted_count = db.history_count(true).await? - local_count; 39 | 40 | println!("History count: {local_count}"); 41 | println!("Deleted history count: {deleted_count}\n"); 42 | } 43 | 44 | if settings.auto_sync { 45 | println!("{}", "[Remote]".green()); 46 | println!("Address: {}", settings.sync_address); 47 | println!("Username: {}", status.username); 48 | } 49 | 50 | Ok(()) 51 | } 52 | -------------------------------------------------------------------------------- /crates/atuin/src/command/contributors.rs: -------------------------------------------------------------------------------- 1 | static CONTRIBUTORS: &str = include_str!("CONTRIBUTORS"); 2 | 3 | pub fn run() { 4 | println!("\n{CONTRIBUTORS}"); 5 | } 6 | -------------------------------------------------------------------------------- /crates/atuin/src/command/external.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write as _; 2 | use std::process::Command; 3 | use std::{io, process}; 4 | 5 | use clap::CommandFactory; 6 | use clap::builder::{StyledStr, Styles}; 7 | use eyre::Result; 8 | 9 | use crate::Atuin; 10 | 11 | pub fn run(args: &[String]) -> Result<()> { 12 | let subcommand = &args[0]; 13 | let bin = format!("atuin-{subcommand}"); 14 | let mut cmd = Command::new(&bin); 15 | cmd.args(&args[1..]); 16 | 17 | let spawn_result = match cmd.spawn() { 18 | Ok(child) => Ok(child), 19 | Err(e) => match e.kind() { 20 | io::ErrorKind::NotFound => { 21 | let output = render_not_found(subcommand, &bin); 22 | Err(output) 23 | } 24 | _ => Err(e.to_string().into()), 25 | }, 26 | }; 27 | 28 | match spawn_result { 29 | Ok(mut child) => { 30 | let status = child.wait()?; 31 | if status.success() { 32 | Ok(()) 33 | } else { 34 | process::exit(status.code().unwrap_or(1)); 35 | } 36 | } 37 | Err(e) => { 38 | eprintln!("{}", e.ansi()); 39 | process::exit(1); 40 | } 41 | } 42 | } 43 | 44 | fn render_not_found(subcommand: &str, bin: &str) -> StyledStr { 45 | let mut output = StyledStr::new(); 46 | let styles = Styles::styled(); 47 | let mut atuin_cmd = Atuin::command(); 48 | let usage = atuin_cmd.render_usage(); 49 | 50 | let error = styles.get_error(); 51 | let invalid = styles.get_invalid(); 52 | let literal = styles.get_literal(); 53 | 54 | let _ = write!(output, "{error}error:{error:#} "); 55 | let _ = write!( 56 | output, 57 | "unrecognized subcommand '{invalid}{subcommand}{invalid:#}' " 58 | ); 59 | let _ = write!( 60 | output, 61 | "and no executable named '{invalid}{bin}{invalid:#}' found in your PATH" 62 | ); 63 | let _ = write!(output, "\n\n"); 64 | let _ = write!(output, "{usage}"); 65 | let _ = write!(output, "\n\n"); 66 | let _ = write!( 67 | output, 68 | "For more information, try '{literal}--help{literal:#}'." 69 | ); 70 | 71 | output 72 | } 73 | -------------------------------------------------------------------------------- /crates/atuin/src/command/gen_completions.rs: -------------------------------------------------------------------------------- 1 | use clap::{CommandFactory, Parser, ValueEnum}; 2 | use clap_complete::{Generator, Shell, generate, generate_to}; 3 | use clap_complete_nushell::Nushell; 4 | use eyre::Result; 5 | 6 | // clap put nushell completions into a separate package due to the maintainers 7 | // being a little less committed to support them. 8 | // This means we have to do a tiny bit of legwork to combine these completions 9 | // into one command. 10 | #[derive(Debug, Clone, ValueEnum)] 11 | #[value(rename_all = "lower")] 12 | pub enum GenShell { 13 | Bash, 14 | Elvish, 15 | Fish, 16 | Nushell, 17 | PowerShell, 18 | Zsh, 19 | } 20 | 21 | impl Generator for GenShell { 22 | fn file_name(&self, name: &str) -> String { 23 | match self { 24 | // clap_complete 25 | Self::Bash => Shell::Bash.file_name(name), 26 | Self::Elvish => Shell::Elvish.file_name(name), 27 | Self::Fish => Shell::Fish.file_name(name), 28 | Self::PowerShell => Shell::PowerShell.file_name(name), 29 | Self::Zsh => Shell::Zsh.file_name(name), 30 | 31 | // clap_complete_nushell 32 | Self::Nushell => Nushell.file_name(name), 33 | } 34 | } 35 | 36 | fn generate(&self, cmd: &clap::Command, buf: &mut dyn std::io::prelude::Write) { 37 | match self { 38 | // clap_complete 39 | Self::Bash => Shell::Bash.generate(cmd, buf), 40 | Self::Elvish => Shell::Elvish.generate(cmd, buf), 41 | Self::Fish => Shell::Fish.generate(cmd, buf), 42 | Self::PowerShell => Shell::PowerShell.generate(cmd, buf), 43 | Self::Zsh => Shell::Zsh.generate(cmd, buf), 44 | 45 | // clap_complete_nushell 46 | Self::Nushell => Nushell.generate(cmd, buf), 47 | } 48 | } 49 | } 50 | 51 | #[derive(Debug, Parser)] 52 | pub struct Cmd { 53 | /// Set the shell for generating completions 54 | #[arg(long, short)] 55 | shell: GenShell, 56 | 57 | /// Set the output directory 58 | #[arg(long, short)] 59 | out_dir: Option, 60 | } 61 | 62 | impl Cmd { 63 | pub fn run(self) -> Result<()> { 64 | let Cmd { shell, out_dir } = self; 65 | 66 | let mut cli = crate::Atuin::command(); 67 | 68 | match out_dir { 69 | Some(out_dir) => { 70 | generate_to(shell, &mut cli, env!("CARGO_PKG_NAME"), &out_dir)?; 71 | } 72 | None => { 73 | generate( 74 | shell, 75 | &mut cli, 76 | env!("CARGO_PKG_NAME"), 77 | &mut std::io::stdout(), 78 | ); 79 | } 80 | } 81 | 82 | Ok(()) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /crates/atuin/src/command/mod.rs: -------------------------------------------------------------------------------- 1 | use clap::Subcommand; 2 | use eyre::Result; 3 | 4 | #[cfg(not(windows))] 5 | use rustix::{fs::Mode, process::umask}; 6 | 7 | #[cfg(feature = "client")] 8 | mod client; 9 | 10 | #[cfg(feature = "server")] 11 | mod server; 12 | 13 | mod contributors; 14 | 15 | mod gen_completions; 16 | 17 | mod external; 18 | 19 | #[derive(Subcommand)] 20 | #[command(infer_subcommands = true)] 21 | pub enum AtuinCmd { 22 | #[cfg(feature = "client")] 23 | #[command(flatten)] 24 | Client(client::Cmd), 25 | 26 | /// Start an atuin server 27 | #[cfg(feature = "server")] 28 | #[command(subcommand)] 29 | Server(server::Cmd), 30 | 31 | /// Generate a UUID 32 | Uuid, 33 | 34 | Contributors, 35 | 36 | /// Generate shell completions 37 | GenCompletions(gen_completions::Cmd), 38 | 39 | #[command(external_subcommand)] 40 | External(Vec), 41 | } 42 | 43 | impl AtuinCmd { 44 | pub fn run(self) -> Result<()> { 45 | #[cfg(not(windows))] 46 | { 47 | // set umask before we potentially open/create files 48 | // or in other words, 077. Do not allow any access to any other user 49 | let mode = Mode::RWXG | Mode::RWXO; 50 | umask(mode); 51 | } 52 | 53 | match self { 54 | #[cfg(feature = "client")] 55 | Self::Client(client) => client.run(), 56 | 57 | #[cfg(feature = "server")] 58 | Self::Server(server) => server.run(), 59 | Self::Contributors => { 60 | contributors::run(); 61 | Ok(()) 62 | } 63 | Self::Uuid => { 64 | println!("{}", atuin_common::utils::uuid_v7().as_simple()); 65 | Ok(()) 66 | } 67 | Self::GenCompletions(gen_completions) => gen_completions.run(), 68 | Self::External(args) => external::run(&args), 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /crates/atuin/src/command/server.rs: -------------------------------------------------------------------------------- 1 | use std::net::SocketAddr; 2 | 3 | use atuin_server_postgres::Postgres; 4 | use tracing_subscriber::{EnvFilter, fmt, prelude::*}; 5 | 6 | use clap::Parser; 7 | use eyre::{Context, Result}; 8 | 9 | use atuin_server::{Settings, example_config, launch, launch_metrics_server}; 10 | 11 | #[derive(Parser, Debug)] 12 | #[clap(infer_subcommands = true)] 13 | pub enum Cmd { 14 | /// Start the server 15 | Start { 16 | /// The host address to bind 17 | #[clap(long)] 18 | host: Option, 19 | 20 | /// The port to bind 21 | #[clap(long, short)] 22 | port: Option, 23 | }, 24 | 25 | /// Print server example configuration 26 | DefaultConfig, 27 | } 28 | 29 | impl Cmd { 30 | #[tokio::main] 31 | pub async fn run(self) -> Result<()> { 32 | tracing_subscriber::registry() 33 | .with(fmt::layer()) 34 | .with(EnvFilter::from_default_env()) 35 | .init(); 36 | 37 | tracing::trace!(command = ?self, "server command"); 38 | 39 | match self { 40 | Self::Start { host, port } => { 41 | let settings = Settings::new().wrap_err("could not load server settings")?; 42 | let host = host.as_ref().unwrap_or(&settings.host).clone(); 43 | let port = port.unwrap_or(settings.port); 44 | let addr = SocketAddr::new(host.parse()?, port); 45 | 46 | if settings.metrics.enable { 47 | tokio::spawn(launch_metrics_server( 48 | settings.metrics.host.clone(), 49 | settings.metrics.port, 50 | )); 51 | } 52 | 53 | launch::(settings, addr).await 54 | } 55 | Self::DefaultConfig => { 56 | println!("{}", example_config()); 57 | Ok(()) 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /crates/atuin/src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::pedantic, clippy::nursery)] 2 | #![allow(clippy::use_self, clippy::missing_const_for_fn)] // not 100% reliable 3 | 4 | use clap::Parser; 5 | use eyre::Result; 6 | 7 | use command::AtuinCmd; 8 | 9 | mod command; 10 | 11 | #[cfg(feature = "sync")] 12 | mod sync; 13 | 14 | const VERSION: &str = env!("CARGO_PKG_VERSION"); 15 | const SHA: &str = env!("GIT_HASH"); 16 | 17 | static HELP_TEMPLATE: &str = "\ 18 | {before-help}{name} {version} 19 | {author} 20 | {about} 21 | 22 | {usage-heading} 23 | {usage} 24 | 25 | {all-args}{after-help}"; 26 | 27 | /// Magical shell history 28 | #[derive(Parser)] 29 | #[command( 30 | author = "Ellie Huxtable ", 31 | version = VERSION, 32 | help_template(HELP_TEMPLATE), 33 | )] 34 | struct Atuin { 35 | #[command(subcommand)] 36 | atuin: AtuinCmd, 37 | } 38 | 39 | impl Atuin { 40 | fn run(self) -> Result<()> { 41 | self.atuin.run() 42 | } 43 | } 44 | 45 | fn main() -> Result<()> { 46 | Atuin::parse().run() 47 | } 48 | -------------------------------------------------------------------------------- /crates/atuin/src/shell/.gitattributes: -------------------------------------------------------------------------------- 1 | * eol=lf 2 | -------------------------------------------------------------------------------- /crates/atuin/src/shell/atuin.fish: -------------------------------------------------------------------------------- 1 | set -gx ATUIN_SESSION (atuin uuid) 2 | set --erase ATUIN_HISTORY_ID 3 | 4 | function _atuin_preexec --on-event fish_preexec 5 | if not test -n "$fish_private_mode" 6 | set -g ATUIN_HISTORY_ID (atuin history start -- "$argv[1]") 7 | end 8 | end 9 | 10 | function _atuin_postexec --on-event fish_postexec 11 | set -l s $status 12 | 13 | if test -n "$ATUIN_HISTORY_ID" 14 | ATUIN_LOG=error atuin history end --exit $s -- $ATUIN_HISTORY_ID &>/dev/null & 15 | disown 16 | end 17 | 18 | set --erase ATUIN_HISTORY_ID 19 | end 20 | 21 | function _atuin_search 22 | set -l keymap_mode 23 | switch $fish_key_bindings 24 | case fish_vi_key_bindings 25 | switch $fish_bind_mode 26 | case default 27 | set keymap_mode vim-normal 28 | case insert 29 | set keymap_mode vim-insert 30 | end 31 | case '*' 32 | set keymap_mode emacs 33 | end 34 | 35 | # In fish 3.4 and above we can use `"$(some command)"` to keep multiple lines separate; 36 | # but to support fish 3.3 we need to use `(some command | string collect)`. 37 | # https://fishshell.com/docs/current/relnotes.html#id24 (fish 3.4 "Notable improvements and fixes") 38 | set -l ATUIN_H (ATUIN_SHELL_FISH=t ATUIN_LOG=error ATUIN_QUERY=(commandline -b) atuin search --keymap-mode=$keymap_mode $argv -i 3>&1 1>&2 2>&3 | string collect) 39 | 40 | if test -n "$ATUIN_H" 41 | if string match --quiet '__atuin_accept__:*' "$ATUIN_H" 42 | set -l ATUIN_HIST (string replace "__atuin_accept__:" "" -- "$ATUIN_H" | string collect) 43 | commandline -r "$ATUIN_HIST" 44 | commandline -f repaint 45 | commandline -f execute 46 | return 47 | else 48 | commandline -r "$ATUIN_H" 49 | end 50 | end 51 | 52 | commandline -f repaint 53 | end 54 | 55 | function _atuin_bind_up 56 | # Fallback to fish's builtin up-or-search if we're in search or paging mode 57 | if commandline --search-mode; or commandline --paging-mode 58 | up-or-search 59 | return 60 | end 61 | 62 | # Only invoke atuin if we're on the top line of the command 63 | set -l lineno (commandline --line) 64 | 65 | switch $lineno 66 | case 1 67 | _atuin_search --shell-up-key-binding 68 | case '*' 69 | up-or-search 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /crates/atuin/src/shell/atuin.nu: -------------------------------------------------------------------------------- 1 | # Source this in your ~/.config/nushell/config.nu 2 | $env.ATUIN_SESSION = (atuin uuid) 3 | hide-env -i ATUIN_HISTORY_ID 4 | 5 | # Magic token to make sure we don't record commands run by keybindings 6 | let ATUIN_KEYBINDING_TOKEN = $"# (random uuid)" 7 | 8 | let _atuin_pre_execution = {|| 9 | if ($nu | get -i history-enabled) == false { 10 | return 11 | } 12 | let cmd = (commandline) 13 | if ($cmd | is-empty) { 14 | return 15 | } 16 | if not ($cmd | str starts-with $ATUIN_KEYBINDING_TOKEN) { 17 | $env.ATUIN_HISTORY_ID = (atuin history start -- $cmd) 18 | } 19 | } 20 | 21 | let _atuin_pre_prompt = {|| 22 | let last_exit = $env.LAST_EXIT_CODE 23 | if 'ATUIN_HISTORY_ID' not-in $env { 24 | return 25 | } 26 | with-env { ATUIN_LOG: error } { 27 | do { atuin history end $'--exit=($last_exit)' -- $env.ATUIN_HISTORY_ID } | complete 28 | 29 | } 30 | hide-env ATUIN_HISTORY_ID 31 | } 32 | 33 | def _atuin_search_cmd [...flags: string] { 34 | let nu_version = do { 35 | let version = version 36 | let major = $version.major? 37 | if $major != null { 38 | # These members are only available in versions > 0.92.2 39 | [$major $version.minor $version.patch] 40 | } else { 41 | # So fall back to the slower parsing when they're missing 42 | $version.version | split row '.' | into int 43 | } 44 | } 45 | [ 46 | $ATUIN_KEYBINDING_TOKEN, 47 | ([ 48 | `with-env { ATUIN_LOG: error, ATUIN_QUERY: (commandline) } {`, 49 | (if $nu_version.0 <= 0 and $nu_version.1 <= 90 { 'commandline' } else { 'commandline edit' }), 50 | (if $nu_version.1 >= 92 { '(run-external atuin search' } else { '(run-external --redirect-stderr atuin search' }), 51 | ($flags | append [--interactive] | each {|e| $'"($e)"'}), 52 | (if $nu_version.1 >= 92 { ' e>| str trim)' } else {' | complete | $in.stderr | str substring ..-1)'}), 53 | `}`, 54 | ] | flatten | str join ' '), 55 | ] | str join "\n" 56 | } 57 | 58 | $env.config = ($env | default {} config).config 59 | $env.config = ($env.config | default {} hooks) 60 | $env.config = ( 61 | $env.config | upsert hooks ( 62 | $env.config.hooks 63 | | upsert pre_execution ( 64 | $env.config.hooks | get -i pre_execution | default [] | append $_atuin_pre_execution) 65 | | upsert pre_prompt ( 66 | $env.config.hooks | get -i pre_prompt | default [] | append $_atuin_pre_prompt) 67 | ) 68 | ) 69 | 70 | $env.config = ($env.config | default [] keybindings) 71 | -------------------------------------------------------------------------------- /crates/atuin/src/shell/atuin.xsh: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | from prompt_toolkit.application.current import get_app 4 | from prompt_toolkit.filters import Condition 5 | from prompt_toolkit.keys import Keys 6 | 7 | 8 | $ATUIN_SESSION=$(atuin uuid).rstrip('\n') 9 | 10 | @events.on_precommand 11 | def _atuin_precommand(cmd: str): 12 | cmd = cmd.rstrip("\n") 13 | $ATUIN_HISTORY_ID = $(atuin history start -- @(cmd)).rstrip("\n") 14 | 15 | 16 | @events.on_postcommand 17 | def _atuin_postcommand(cmd: str, rtn: int, out, ts): 18 | if "ATUIN_HISTORY_ID" not in ${...}: 19 | return 20 | 21 | duration = ts[1] - ts[0] 22 | # Duration is float representing seconds, but atuin expects integer of nanoseconds 23 | nanos = round(duration * 10 ** 9) 24 | with ${...}.swap(ATUIN_LOG="error"): 25 | # This causes the entire .xonshrc to be re-executed, which is incredibly slow 26 | # This happens when using a subshell and using output redirection at the same time 27 | # For more details, see https://github.com/xonsh/xonsh/issues/5224 28 | # (atuin history end --exit @(rtn) -- $ATUIN_HISTORY_ID &) > /dev/null 2>&1 29 | atuin history end --exit @(rtn) --duration @(nanos) -- $ATUIN_HISTORY_ID > /dev/null 2>&1 30 | del $ATUIN_HISTORY_ID 31 | 32 | 33 | def _search(event, extra_args: list[str]): 34 | buffer = event.current_buffer 35 | cmd = ["atuin", "search", "--interactive", *extra_args] 36 | # We need to explicitly pass in xonsh env, in case user has set XDG_HOME or something else that matters 37 | env = ${...}.detype() 38 | env["ATUIN_SHELL_XONSH"] = "t" 39 | env["ATUIN_QUERY"] = buffer.text 40 | 41 | p = subprocess.run(cmd, stderr=subprocess.PIPE, encoding="utf-8", env=env) 42 | result = p.stderr.rstrip("\n") 43 | # redraw prompt - necessary if atuin is configured to run inline, rather than fullscreen 44 | event.cli.renderer.erase() 45 | 46 | if not result: 47 | return 48 | 49 | buffer.reset() 50 | if result.startswith("__atuin_accept__:"): 51 | buffer.insert_text(result[17:]) 52 | buffer.validate_and_handle() 53 | else: 54 | buffer.insert_text(result) 55 | 56 | 57 | @events.on_ptk_create 58 | def _custom_keybindings(bindings, **kw): 59 | if _ATUIN_BIND_CTRL_R: 60 | @bindings.add(Keys.ControlR) 61 | def r_search(event): 62 | _search(event, extra_args=[]) 63 | 64 | if _ATUIN_BIND_UP_ARROW: 65 | @Condition 66 | def should_search(): 67 | buffer = get_app().current_buffer 68 | # disable keybind when there is an active completion, so 69 | # that up arrow can be used to navigate completion menu 70 | if buffer.complete_state is not None: 71 | return False 72 | # similarly, disable when buffer text contains multiple lines 73 | if '\n' in buffer.text: 74 | return False 75 | 76 | return True 77 | 78 | @bindings.add(Keys.Up, filter=should_search) 79 | def up_search(event): 80 | _search(event, extra_args=["--shell-up-key-binding"]) 81 | -------------------------------------------------------------------------------- /crates/atuin/src/sync.rs: -------------------------------------------------------------------------------- 1 | use atuin_dotfiles::store::{AliasStore, var::VarStore}; 2 | use atuin_scripts::store::ScriptStore; 3 | use eyre::{Context, Result}; 4 | 5 | use atuin_client::{ 6 | database::Database, history::store::HistoryStore, record::sqlite_store::SqliteStore, 7 | settings::Settings, 8 | }; 9 | use atuin_common::record::RecordId; 10 | 11 | // This is the only crate that ties together all other crates. 12 | // Therefore, it's the only crate where functions tying together all stores can live 13 | 14 | /// Rebuild all stores after a sync 15 | /// Note: for history, this only does an _incremental_ sync. Hence the need to specify downloaded 16 | /// records. 17 | pub async fn build( 18 | settings: &Settings, 19 | store: &SqliteStore, 20 | db: &dyn Database, 21 | downloaded: Option<&[RecordId]>, 22 | ) -> Result<()> { 23 | let encryption_key: [u8; 32] = atuin_client::encryption::load_key(settings) 24 | .context("could not load encryption key")? 25 | .into(); 26 | 27 | let host_id = Settings::host_id().expect("failed to get host_id"); 28 | 29 | let downloaded = downloaded.unwrap_or(&[]); 30 | 31 | let history_store = HistoryStore::new(store.clone(), host_id, encryption_key); 32 | let alias_store = AliasStore::new(store.clone(), host_id, encryption_key); 33 | let var_store = VarStore::new(store.clone(), host_id, encryption_key); 34 | let script_store = ScriptStore::new(store.clone(), host_id, encryption_key); 35 | 36 | history_store.incremental_build(db, downloaded).await?; 37 | 38 | alias_store.build().await?; 39 | var_store.build().await?; 40 | 41 | let script_db = 42 | atuin_scripts::database::Database::new(settings.scripts.database_path.clone(), 1.0).await?; 43 | script_store.build(script_db).await?; 44 | 45 | Ok(()) 46 | } 47 | -------------------------------------------------------------------------------- /crates/atuin/tests/sync.rs: -------------------------------------------------------------------------------- 1 | use atuin_common::{api::AddHistoryRequest, utils::uuid_v7}; 2 | use time::OffsetDateTime; 3 | 4 | mod common; 5 | 6 | #[tokio::test] 7 | async fn sync() { 8 | let path = format!("/{}", uuid_v7().as_simple()); 9 | let (address, shutdown, server) = common::start_server(&path).await; 10 | 11 | let client = common::register(&address).await; 12 | let hostname = uuid_v7().as_simple().to_string(); 13 | let now = OffsetDateTime::now_utc(); 14 | 15 | let data1 = uuid_v7().as_simple().to_string(); 16 | let data2 = uuid_v7().as_simple().to_string(); 17 | 18 | client 19 | .post_history(&[ 20 | AddHistoryRequest { 21 | id: uuid_v7().as_simple().to_string(), 22 | timestamp: now, 23 | data: data1.clone(), 24 | hostname: hostname.clone(), 25 | }, 26 | AddHistoryRequest { 27 | id: uuid_v7().as_simple().to_string(), 28 | timestamp: now, 29 | data: data2.clone(), 30 | hostname: hostname.clone(), 31 | }, 32 | ]) 33 | .await 34 | .unwrap(); 35 | 36 | let history = client 37 | .get_history(OffsetDateTime::UNIX_EPOCH, OffsetDateTime::UNIX_EPOCH, None) 38 | .await 39 | .unwrap(); 40 | 41 | assert_eq!(history.history, vec![data1, data2]); 42 | 43 | shutdown.send(()).unwrap(); 44 | server.await.unwrap(); 45 | } 46 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | (import 2 | ( 3 | let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in 4 | fetchTarball { 5 | url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; 6 | sha256 = lock.nodes.flake-compat.locked.narHash; 7 | } 8 | ) 9 | { src = ./.; } 10 | ).defaultNix 11 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atuinsh/atuin/5cd23537b07fa37ed593eaec5cfe968be2a4ac9e/demo.gif -------------------------------------------------------------------------------- /dist-workspace.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cargo:."] 3 | 4 | # Config for 'dist' 5 | [dist] 6 | # Path that installers should place binaries in 7 | install-path = "~/.atuin/bin" 8 | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) 9 | cargo-dist-version = "0.28.3" 10 | # CI backends to support 11 | ci = "github" 12 | # The installers to generate for each app 13 | installers = ["shell"] 14 | # Target platforms to build apps for (Rust target-triple syntax) 15 | targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"] 16 | # Which actions to run on pull requests 17 | pr-run-mode = "plan" 18 | # Whether to install an updater program 19 | install-updater = true 20 | # The archive format to use for non-windows builds (defaults .tar.xz) 21 | unix-archive = ".tar.gz" 22 | # Whether to enable GitHub Attestations 23 | github-attestations = true 24 | 25 | [dist.github-custom-runners] 26 | aarch64-apple-darwin = "macos-14" 27 | aarch64-unknown-linux-gnu = "buildjet-2vcpu-ubuntu-2204-arm" 28 | aarch64-unknown-linux-musl = "buildjet-2vcpu-ubuntu-2204-arm" 29 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | services: 3 | atuin: 4 | restart: always 5 | image: ghcr.io/atuinsh/atuin: 6 | command: server start 7 | volumes: 8 | - "./config:/config" 9 | links: 10 | - postgresql:db 11 | ports: 12 | - 8888:8888 13 | environment: 14 | ATUIN_HOST: "0.0.0.0" 15 | ATUIN_OPEN_REGISTRATION: "true" 16 | ATUIN_DB_URI: postgres://$ATUIN_DB_USERNAME:$ATUIN_DB_PASSWORD@db/$ATUIN_DB_NAME 17 | RUST_LOG: info,atuin_server=debug 18 | postgresql: 19 | image: postgres:14 20 | restart: unless-stopped 21 | volumes: # Don't remove permanent storage for index database files! 22 | - "./database:/var/lib/postgresql/data/" 23 | environment: 24 | POSTGRES_USER: ${ATUIN_DB_USERNAME} 25 | POSTGRES_PASSWORD: ${ATUIN_DB_PASSWORD} 26 | POSTGRES_DB: ${ATUIN_DB_NAME} 27 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docs/ru/import_ru.md: -------------------------------------------------------------------------------- 1 | # `atuin import` 2 | 3 | Autin может импортировать историю из "старого" файла истории 4 | 5 | `atuin import auto` предпринимает попытку определить тип командного интерфейса 6 | (через \$SHELL) и запускает нужный скрипт импорта. 7 | 8 | К сожалению, эти файлы содержат не так много информации, как Autin, так что не 9 | все функции будут доступны с импортированными данными. 10 | 11 | # zsh 12 | 13 | ``` 14 | atuin import zsh 15 | ``` 16 | 17 | Если у вас есть HISTFILE, то эта команда должна сработать. Иначе, попробуйте 18 | 19 | ``` 20 | HISTFILE=/path/to/history/file atuin import zsh 21 | ``` 22 | 23 | Этот параметр поддерживает как и упрощённый, так и полный формат. 24 | 25 | # bash 26 | 27 | TODO 28 | -------------------------------------------------------------------------------- /docs/ru/key-binding_ru.md: -------------------------------------------------------------------------------- 1 | # Key binding 2 | 3 | По умолчанию, Autin будет переназначать Ctrl-r и клавишу 'стрелка вверх'. 4 | Если вы не хотите этого, установите параметр ATUIN_NOBIND прежде чем вызывать `atuin init` 5 | 6 | Например, 7 | 8 | ``` 9 | export ATUIN_NOBIND="true" 10 | eval "$(atuin init zsh)" 11 | ``` 12 | 13 | Таким образом вы можете разрешить переназначение клавиш Autin, если это необходимо. 14 | Делайте это до инициализирующего вызова. 15 | 16 | # zsh 17 | 18 | Autin устанавливает виджет ZLE "atuin-search" 19 | 20 | ``` 21 | export ATUIN_NOBIND="true" 22 | eval "$(atuin init zsh)" 23 | 24 | bindkey '^r' atuin-search 25 | 26 | # зависит от режима терминала 27 | bindkey '^[[A' atuin-search 28 | bindkey '^[OA' atuin-search 29 | ``` 30 | 31 | # bash 32 | 33 | ``` 34 | export ATUIN_NOBIND="true" 35 | eval "$(atuin init bash)" 36 | 37 | # Переопределите ctrl-r, и любые другие сочетания горячих клавиш тут 38 | bind -x '"\C-r": __atuin_history' 39 | ``` 40 | -------------------------------------------------------------------------------- /docs/ru/list_ru.md: -------------------------------------------------------------------------------- 1 | # Вывад истории на экран 2 | 3 | ``` 4 | atuin history list 5 | ``` 6 | 7 | | Аргумент | Описание | 8 | | -------------- | ------------------------------------------------------------------------------ | 9 | | `--cwd/-c` | Каталог, историю команд которой необходимо вывести (по умолчанию все каталоги) | 10 | | `--session/-s` | Выводит историю команд только текущей сессии (по умолчанию false) | 11 | | `--human` | Читаемый формат для времени и периодов времени (по умолчанию false) | 12 | -------------------------------------------------------------------------------- /docs/ru/search_ru.md: -------------------------------------------------------------------------------- 1 | # `atuin search` 2 | 3 | ``` 4 | atuin search 5 | ``` 6 | 7 | Поиск в Atuin также поддерживает wildcards со знаками `*` или `%`. 8 | По умолчанию, должен быть указан префикс (т.е. все запросы автоматически дополняются wildcard -ами) 9 | 10 | | Аргумент | Описание | 11 | | ------------------ | ------------------------------------------------------------------------------------------- | 12 | | `--cwd/-c` | Каталог, для которого отображается история (по умолчанию, все каталоги)) | 13 | | `--exclude-cwd` | Исключить команды которые запускались в этом каталоге (по умолчанию none) | 14 | | `--exit/-e` | Фильтровать по exit code (по умолчанию none) | 15 | | `--exclude-exit` | Исключить команды, которые завершились с указанным значением (по умолчанию none) | 16 | | `--before` | Включить только команды, которые были запущены до указанного времени (по умолчанию none) | 17 | | `--after` | Включить только команды, которые были запущены после указанного времени (по умолчанию none) | 18 | | `--interactive/-i` | Открыть интерактивный поисковой графический интерфейс (по умолчанию false) | 19 | | `--human` | Использовать читаемое формавтирование для времени и периодов времени (по умолчанию false) | 20 | 21 | ## Примеры 22 | 23 | ``` 24 | # Начать интерактивный поиск с текстовым пользовательским интерфейсом 25 | atuin search -i 26 | 27 | # Начать интерактивный поиск с текстовым пользовательским интерфейсом и уже введённым запросом 28 | atuin search -i atuin 29 | 30 | # Искать по всем командам, начиная с cargo, которые успешно завершились 31 | atuin search --exit 0 cargo 32 | 33 | # Искать по всем командам которые завершились ошибками и были вызваны в текущей папке и были запущены до первого апреля 2021 34 | atuin search --exclude-exit 0 --before 01/04/2021 --cwd . 35 | 36 | # Искать по всем командам, начиная с cargo, которые успешно завершились и были запущены после трёх часо дня вчера 37 | atuin search --exit 0 --after "yesterday 3pm" cargo 38 | ``` 39 | -------------------------------------------------------------------------------- /docs/ru/shell-completions_ru.md: -------------------------------------------------------------------------------- 1 | # `atuin gen-completions` 2 | 3 | [Shell completions](https://en.wikipedia.org/wiki/Command-line_completion) для Atuin 4 | могут бять сгенерированы путём указания каталога для вывода и желаемого shell через субкомманду `gen-completions`. 5 | 6 | ``` 7 | $ atuin gen-completions --shell bash --out-dir $HOME 8 | 9 | Shell completion for BASH is generated in "/home/user" 10 | ``` 11 | 12 | Возможные команды для аргумента `--shell`могут быть следующими: 13 | 14 | - `bash` 15 | - `fish` 16 | - `zsh` 17 | - `powershell` 18 | - `elvish` 19 | 20 | Также рекомендуем прочитать [supported shells](./../../README.md#supported-shells). 21 | -------------------------------------------------------------------------------- /docs/ru/stats_ru.md: -------------------------------------------------------------------------------- 1 | # `atuin stats` 2 | 3 | Atuin также может выводить статистику, основанную на истории. Пока что в очень простом виде, 4 | но скоро должно появиться больше возможностей. 5 | 6 | Статистика выводится пока только на английском 7 | Statistics in english only 8 | # TODO 9 | 10 | ``` 11 | $ atuin stats day last friday 12 | 13 | +---------------------+------------+ 14 | | Statistic | Value | 15 | +---------------------+------------+ 16 | | Most used command | git status | 17 | +---------------------+------------+ 18 | | Commands ran | 450 | 19 | +---------------------+------------+ 20 | | Unique commands ran | 213 | 21 | +---------------------+------------+ 22 | 23 | $ atuin stats day 01/01/21 # also accepts absolute dates 24 | ``` 25 | 26 | Также, может быть выведена статистика всей известной Autin истории: 27 | 28 | ``` 29 | $ atuin stats all 30 | 31 | +---------------------+-------+ 32 | | Statistic | Value | 33 | +---------------------+-------+ 34 | | Most used command | ls | 35 | +---------------------+-------+ 36 | | Commands ran | 8190 | 37 | +---------------------+-------+ 38 | | Unique commands ran | 2996 | 39 | +---------------------+-------+ 40 | ``` 41 | -------------------------------------------------------------------------------- /docs/ru/sync_ru.md: -------------------------------------------------------------------------------- 1 | # `atuin sync` 2 | 3 | Autin может сделать резервную копию вашей истории на сервер чтобы обеспечить использование 4 | разными компьютерами одной и той же истории. Вся история будет зашифрована двусторонним шифрованием, 5 | так что сервер _никогда_ не получит ваши данные! 6 | 7 | Можно сделать свой сервер (запустив `atuin server start`, об этом написано в других 8 | файлах документациии), но у меня есть свой https://api.atuin.sh. Это серверный адрес по умолчанию, 9 | который может быть изменён в [конфигурации](config_ru.md). Опять же, я _не_ могу получить ваши данные 10 | и они мне не нужны. 11 | 12 | ## Частота синхронизации 13 | 14 | Синхронизация будет происходить автоматически, если обратное не было указано в конфигурации. 15 | Отконфигурировать сей параметр можно в [config](config_ru.md) 16 | 17 | ## Синхронизация 18 | 19 | Синхронизироваться также можно вручную, используя команду `atuin sync` 20 | 21 | ## Регистрация 22 | 23 | Можно зарегистрировать аккаунт для синхронизации: 24 | 25 | ``` 26 | atuin register -u -e -p 27 | ``` 28 | 29 | Имена пользователей должны быть уникальны, и электронная почта должна использваться 30 | только для срочных уведомлений (изменения политик, нарушения безопасности и т.д.) 31 | 32 | Псоле регистрации, вы уже сразу вошли в свой аккаунт :) С этого момента синхронизация 33 | будет проходить автоматически 34 | 35 | ## Ключ 36 | 37 | Поскольку все данные шифруются, Autin при работе сгенерирует ваш ключ. Он будет сохранён в 38 | каталоге с данными Autin (`~/.local/share/atuin` на системах с GNU/Linux) 39 | 40 | Также можно сделать это самим: 41 | 42 | ``` 43 | atuin key 44 | ``` 45 | 46 | Никогда не передавайте никому этот ключ! 47 | 48 | ## Вход 49 | 50 | Если вы хотите войти с другого компьютера, вам потребуется ключ безопасности (`atuin key`). 51 | 52 | ``` 53 | atuin login -u -p -k 54 | ``` 55 | 56 | ## Выход 57 | 58 | ``` 59 | atuin logout 60 | ``` 61 | -------------------------------------------------------------------------------- /docs/zh-CN/config.md: -------------------------------------------------------------------------------- 1 | # 配置 2 | 3 | Atuin 维护两个配置文件,存储在 `~/.config/atuin/` 中。 我们将数据存储在 `~/.local/share/atuin` 中(除非被 XDG\_\* 覆盖)。 4 | 5 | 您可以通过设置更改配置目录的路径 `ATUIN_CONFIG_DIR`。 例如 6 | 7 | ``` 8 | export ATUIN_CONFIG_DIR = /home/ellie/.atuin 9 | ``` 10 | 11 | ## 客户端配置 12 | 13 | ``` 14 | ~/.config/atuin/config.toml 15 | ``` 16 | 17 | 客户端运行在用户的机器上,除非你运行的是服务器,否则这就是你所关心的。 18 | 19 | 见 [config.toml](../../atuin-client/config.toml) 中的例子 20 | 21 | ### `dialect` 22 | 23 | 这配置了 [stats](stats.md) 命令解析日期的方式。 它有两个可能的值 24 | 25 | ``` 26 | dialect = "uk" 27 | ``` 28 | 29 | 或者 30 | 31 | ``` 32 | dialect = "us" 33 | ``` 34 | 35 | 默认为 "us". 36 | 37 | ### `auto_sync` 38 | 39 | 配置登录时是否自动同步。默认为 true 40 | 41 | ``` 42 | auto_sync = true/false 43 | ``` 44 | 45 | ### `sync_address` 46 | 47 | 同步的服务器地址! 默认为 `https://api.atuin.sh` 48 | 49 | ``` 50 | sync_address = "https://api.atuin.sh" 51 | ``` 52 | 53 | ### `sync_frequency` 54 | 55 | 多长时间与服务器自动同步一次。这可以用一种"人类可读"的格式给出。例如,`10s`,`20m`,`1h`,等等。默认为 `1h` 。 56 | 57 | 如果设置为 `0`,Atuin将在每个命令之后进行同步。一些服务器可能有潜在的速率限制,这不会造成任何问题。 58 | 59 | ``` 60 | sync_frequency = "1h" 61 | ``` 62 | 63 | ### `db_path` 64 | 65 | Atuin SQlite数据库的路径。默认为 66 | `~/.local/share/atuin/history.db` 67 | 68 | ``` 69 | db_path = "~/.history.db" 70 | ``` 71 | 72 | ### `key_path` 73 | 74 | Atuin加密密钥的路径。默认为 75 | `~/.local/share/atuin/key` 76 | 77 | ``` 78 | key = "~/.atuin-key" 79 | ``` 80 | 81 | ### `session_path` 82 | 83 | Atuin服务器会话文件的路径。默认为 84 | `~/.local/share/atuin/session` 。 这本质上只是一个API令牌 85 | 86 | ``` 87 | key = "~/.atuin-session" 88 | ``` 89 | 90 | ### `search_mode` 91 | 92 | 使用哪种搜索模式。Atuin 支持 "prefix"(前缀)、"fulltext"(全文) 和 "fuzzy"(模糊)搜索模式。前缀(prefix)搜索语法为 "query\*",全文(fulltext)搜索语法为 "\*query\*",而模糊搜索适用的搜索语法 [如下所述](#fuzzy-search-syntax) 。 93 | 94 | 默认配置为 "fuzzy" 95 | 96 | ### `filter_mode` 97 | 98 | 搜索时要使用的默认过滤器 99 | 100 | | 模式 | 描述 | 101 | |--------------- | --------------- | 102 | | global (default) | 从所有主机、所有会话、所有目录中搜索历史记录 | 103 | | host | 仅从该主机搜索历史记录 | 104 | | session | 仅从当前会话中搜索历史记录 | 105 | | directory | 仅从当前目录搜索历史记录| 106 | 107 | 过滤模式仍然可以通过 ctrl-r 来切换 108 | 109 | 110 | ``` 111 | search_mode = "fulltext" 112 | ``` 113 | 114 | #### `fuzzy` 的搜索语法 115 | 116 | `fuzzy` 搜索语法的基础是 [fzf 搜索语法](https://github.com/junegunn/fzf#search-syntax) 。 117 | 118 | | 内容 | 匹配类型 | 描述 | 119 | | --------- | -------------------------- | ------------------------------------ | 120 | | `sbtrkt` | fuzzy-match | 匹配 `sbtrkt` 的项目 | 121 | | `'wild` | exact-match (quoted) | 包含 `wild` 的项目 | 122 | | `^music` | prefix-exact-match | 以 `music` 开头的项目 | 123 | | `.mp3$` | suffix-exact-match | 以 `.mp3` 结尾的项目 | 124 | | `!fire` | inverse-exact-match | 不包括 `fire` 的项目 | 125 | | `!^music` | inverse-prefix-exact-match | 不以 `music` 开头的项目 | 126 | | `!.mp3$` | inverse-suffix-exact-match | 不以 `.mp3` 结尾的项目 | 127 | 128 | 129 | 单个条形字符术语充当 OR 运算符。 例如,以下查询匹配以 `core` 开头并以 `go`、`rb` 或 `py` 结尾的条目。 130 | 131 | ``` 132 | ^core go$ | rb$ | py$ 133 | ``` 134 | 135 | ## 服务端配置 136 | 137 | `// TODO` 138 | -------------------------------------------------------------------------------- /docs/zh-CN/docker.md: -------------------------------------------------------------------------------- 1 | # Docker 2 | 3 | Atuin 提供了一个 docker 镜像(image),可以更轻松地将服务器部署为容器(container)。 4 | 5 | ```sh 6 | docker run -d -v "$USER/.config/atuin:/config" ghcr.io/ellie/atuin:latest server start 7 | ``` 8 | 9 | # Docker Compose 10 | 11 | 使用已有的 docker 镜像(image)来托管你自己的 Atuin,可以使用提供的 docker-compose 文件来完成 12 | 13 | 在 docker-compose.yml 同级目录下创建一个 .env 文件,内容如下: 14 | 15 | ``` 16 | ATUIN_DB_USERNAME=atuin 17 | # 填写你的密码 18 | ATUIN_DB_PASSWORD=really-insecure 19 | ``` 20 | 21 | 创建 `docker-compose.yml` 文件: 22 | 23 | ```yaml 24 | version: '3.5' 25 | services: 26 | atuin: 27 | restart: always 28 | image: ghcr.io/ellie/atuin:main 29 | command: server start 30 | volumes: 31 | - "./config:/config" 32 | links: 33 | - postgresql:db 34 | ports: 35 | - 8888:8888 36 | environment: 37 | ATUIN_HOST: "0.0.0.0" 38 | ATUIN_OPEN_REGISTRATION: "true" 39 | ATUIN_DB_URI: postgres://$ATUIN_DB_USERNAME:$ATUIN_DB_PASSWORD@db/atuin 40 | postgresql: 41 | image: postgres:14 42 | restart: unless-stopped 43 | volumes: # 不要删除索引数据库文件的永久存储空间! 44 | - "./database:/var/lib/postgresql/data/" 45 | environment: 46 | POSTGRES_USER: $ATUIN_DB_USERNAME 47 | POSTGRES_PASSWORD: $ATUIN_DB_PASSWORD 48 | POSTGRES_DB: atuin 49 | ``` 50 | 51 | 使用 `docker-compose` 启动服务: 52 | 53 | ```sh 54 | docker-compose up -d 55 | ``` 56 | 57 | ## 使用 systemd 管理你的 atuin 服务器 58 | 59 | 以下 `systemd` 的配置文件用来管理你的 `docker-compose` 托管服务: 60 | 61 | ``` 62 | [Unit] 63 | Description=Docker Compose Atuin Service 64 | Requires=docker.service 65 | After=docker.service 66 | 67 | [Service] 68 | # Where the docker-compose file is located 69 | WorkingDirectory=/srv/atuin-server 70 | ExecStart=/usr/bin/docker-compose up 71 | ExecStop=/usr/bin/docker-compose down 72 | TimeoutStartSec=0 73 | Restart=on-failure 74 | StartLimitBurst=3 75 | 76 | [Install] 77 | WantedBy=multi-user.target 78 | ``` 79 | 80 | 启用服务: 81 | 82 | ```sh 83 | systemctl enable --now atuin 84 | ``` 85 | 86 | 检查服务是否正常运行: 87 | 88 | ```sh 89 | systemctl status atuin 90 | ``` 91 | -------------------------------------------------------------------------------- /docs/zh-CN/import.md: -------------------------------------------------------------------------------- 1 | # `atuin import` 2 | 3 | Atuin 可以从您的“旧”历史文件中导入您的历史记录 4 | 5 | `atuin import auto` 将尝试找出你的 shell(通过 \$SHELL)并运行正确的导入器 6 | 7 | 不幸的是,这些旧文件没有像 Atuin 那样存储尽可能多的信息,因此并非所有功能都可用于导入的数据。 8 | 9 | # zsh 10 | 11 | ``` 12 | atuin import zsh 13 | ``` 14 | 15 | 如果你设置了 HISTFILE,这应该会被选中!如果没有,可以尝试以下操作 16 | 17 | ``` 18 | HISTFILE=/path/to/history/file atuin import zsh 19 | ``` 20 | 21 | 这支持简单和扩展形式 22 | 23 | # bash 24 | 25 | TODO 26 | -------------------------------------------------------------------------------- /docs/zh-CN/key-binding.md: -------------------------------------------------------------------------------- 1 | # 键位绑定 2 | 3 | 默认情况下, Atuin 将会重新绑定 Ctrl-r 和 `up` 键。如果你不想使用默认绑定,请在调用 `atuin init` 之前设置 ATUIN_NOBIND 4 | 5 | 例如: 6 | 7 | ``` 8 | export ATUIN_NOBIND="true" 9 | eval "$(atuin init zsh)" 10 | ``` 11 | 12 | 如果需要,你可以在调用 `atuin init` 之后对 Atuin 重新进行键绑定 13 | 14 | # zsh 15 | 16 | Atuin 定义了 ZLE 部件 "atuin-search" 17 | 18 | ``` 19 | export ATUIN_NOBIND="true" 20 | eval "$(atuin init zsh)" 21 | 22 | bindkey '^r' atuin-search 23 | 24 | # 取决于终端模式 25 | bindkey '^[[A' atuin-search 26 | bindkey '^[OA' atuin-search 27 | ``` 28 | 29 | # bash 30 | 31 | ``` 32 | export ATUIN_NOBIND="true" 33 | eval "$(atuin init bash)" 34 | 35 | # 绑定到 ctrl-r, 也可以在这里添加任何其他你想要的绑定方式 36 | bind -x '"\C-r": __atuin_history' 37 | ``` 38 | 39 | # fish 40 | 41 | ``` 42 | set -gx ATUIN_NOBIND "true" 43 | atuin init fish | source 44 | 45 | # 在 normal 和 insert 模式下绑定到 ctrl-r,你也可以在此处添加其他键位绑定 46 | bind \cr _atuin_search 47 | bind -M insert \cr _atuin_search 48 | ``` 49 | -------------------------------------------------------------------------------- /docs/zh-CN/list.md: -------------------------------------------------------------------------------- 1 | # 历史记录列表 2 | 3 | ``` 4 | atuin history list 5 | ``` 6 | 7 | | 参数 | 描述 | 8 | | -------------- | ----------------------------------------------------- | 9 | | `--cwd/-c` | 要列出历史记录的目录(默认:所有目录) | 10 | | `--session/-s` | 只对当前会话启用列表历史(默认:false) | 11 | | `--human` | 对时间戳和持续时间使用人类可读的格式(默认:false)。 | 12 | -------------------------------------------------------------------------------- /docs/zh-CN/search.md: -------------------------------------------------------------------------------- 1 | # `atuin search` 2 | 3 | ``` 4 | atuin search 5 | ``` 6 | 7 | Atuin 搜索还支持带有 `*` 或 `%` 字符的通配符。 默认情况下,会执行前缀搜索(即,所有查询都会自动附加通配符)。 8 | 9 | | 参数 | 描述 | 10 | | ------------------ | ----------------------------------------------------- | 11 | | `--cwd/-c` | 列出历史记录的目录(默认:所有目录) | 12 | | `--exclude-cwd` | 不包括在此目录中运行的命令(默认值:none) | 13 | | `--exit/-e` | 按退出代码过滤(默认:none) | 14 | | `--exclude-exit` | 不包括以该值退出的命令(默认值:none) | 15 | | `--before` | 仅包括在此时间之前运行的命令(默认值:none) | 16 | | `--after` | 仅包含在此时间之后运行的命令(默认值:none) | 17 | | `--interactive/-i` | 打开交互式搜索 UI(默认值:false) | 18 | | `--human` | 对时间戳和持续时间使用人类可读的格式(默认值:false) | 19 | 20 | ## 举例 21 | 22 | ``` 23 | # 打开交互式搜索 TUI 24 | atuin search -i 25 | 26 | # 打开预装了查询的交互式搜索 TUI 27 | atuin search -i atuin 28 | 29 | # 搜索所有以 cargo 开头且成功退出的命令。 30 | atuin search --exit 0 cargo 31 | 32 | # 从当前目录中搜索所有在2021年4月1日之前运行且失败的命令。 33 | atuin search --exclude-exit 0 --before 01/04/2021 --cwd . 34 | 35 | # 搜索所有以 cargo 开头,成功退出且是在昨天下午3点之后运行的命令。 36 | atuin search --exit 0 --after "yesterday 3pm" cargo 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/zh-CN/server.md: -------------------------------------------------------------------------------- 1 | # `atuin server` 2 | 3 | Atuin 允许您运行自己的同步服务器,以防您不想使用我(ellie)托管的服务器 :) 4 | 5 | 目前只有一个子命令,`atuin server start`,它将启动 Atuin http 同步服务器。 6 | 7 | ``` 8 | USAGE: 9 | atuin server start [OPTIONS] 10 | 11 | FLAGS: 12 | --help Prints help information 13 | -V, --version Prints version information 14 | 15 | OPTIONS: 16 | -h, --host 17 | -p, --port 18 | ``` 19 | 20 | ## 配置 21 | 22 | 服务器的配置与客户端的配置是分开的,即使它们是相同的二进制文件。服务器配置可以在 `~/.config/atuin/server.toml` 找到。 23 | 24 | 它看起来像这样: 25 | 26 | ```toml 27 | host = "0.0.0.0" 28 | port = 8888 29 | open_registration = true 30 | db_uri="postgres://user:password@hostname/database" 31 | ``` 32 | 33 | 另外,配置也可以用环境变量来提供。 34 | 35 | ```sh 36 | ATUIN_HOST="0.0.0.0" 37 | ATUIN_PORT=8888 38 | ATUIN_OPEN_REGISTRATION=true 39 | ATUIN_DB_URI="postgres://user:password@hostname/database" 40 | ``` 41 | 42 | ### host 43 | 44 | Atuin 服务器应该监听的地址 45 | 46 | 默认为 `127.0.0.1`. 47 | 48 | ### port 49 | 50 | Atuin 服务器应该监听的端口 51 | 52 | 默认为 `8888`. 53 | 54 | ### open_registration 55 | 56 | 如果为 `true` ,atuin 将接受新用户注册。如果您不希望其他人能够使用您的服务器,请在创建自己的账号后将此设置为 `false` 57 | 58 | 默认为 `false`. 59 | 60 | ### db_uri 61 | 62 | 一个有效的 postgres URI, 用户和历史记录数据将被保存到其中。 63 | 64 | ### path 65 | 66 | path 指的是给 server 添加的路由前缀。值为空字符串将不会添加路由前缀。 67 | 68 | 默认为 `""` 69 | 70 | ## 容器部署说明 71 | 72 | 你可以在容器中部署自己的 atuin 服务器: 73 | 74 | * 有关 docker 配置的示例,请参考 [docker](docker.md)。 75 | * 有关 kubernetes 配置的示例,请参考 [k8s](k8s.md)。 76 | -------------------------------------------------------------------------------- /docs/zh-CN/shell-completions.md: -------------------------------------------------------------------------------- 1 | # `atuin gen-completions` 2 | 3 | Atuin 的 [Shell 补全](https://en.wikipedia.org/wiki/Command-line_completion) 可以通过 `gen-completions` 子命令指定输出目录和所需的 shell 来生成。 4 | 5 | ``` 6 | $ atuin gen-completions --shell bash --out-dir $HOME 7 | 8 | Shell completion for BASH is generated in "/home/user" 9 | ``` 10 | 11 | `--shell` 参数的可能值如下: 12 | 13 | - `bash` 14 | - `fish` 15 | - `zsh` 16 | - `powershell` 17 | - `elvish` 18 | 19 | 此外, 请参阅 [支持的 Shells](./README.md#支持的-Shells). 20 | -------------------------------------------------------------------------------- /docs/zh-CN/stats.md: -------------------------------------------------------------------------------- 1 | # `atuin stats` 2 | 3 | Atuin 还可以根据你的历史记录进行计算统计数据 - 目前这只是一个小的基本功能,但更多功能即将推出 4 | 5 | ``` 6 | $ atuin stats day last friday 7 | 8 | +---------------------+------------+ 9 | | Statistic | Value | 10 | +---------------------+------------+ 11 | | Most used command | git status | 12 | +---------------------+------------+ 13 | | Commands ran | 450 | 14 | +---------------------+------------+ 15 | | Unique commands ran | 213 | 16 | +---------------------+------------+ 17 | 18 | $ atuin stats day 01/01/21 # 也接受绝对日期 19 | ``` 20 | 21 | 它还可以计算所有已知历史记录的统计数据。 22 | 23 | ``` 24 | $ atuin stats all 25 | 26 | +---------------------+-------+ 27 | | Statistic | Value | 28 | +---------------------+-------+ 29 | | Most used command | ls | 30 | +---------------------+-------+ 31 | | Commands ran | 8190 | 32 | +---------------------+-------+ 33 | | Unique commands ran | 2996 | 34 | +---------------------+-------+ 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/zh-CN/sync.md: -------------------------------------------------------------------------------- 1 | # `atuin sync` 2 | 3 | Atuin 可以将您的历史记录备份到服务器,并使用它来确保多台机器具有相同的 shell 历史记录。 这都是端到端加密的,因此服务器操作员_永远_看不到您的数据! 4 | 5 | 任何人都可以托管一个服务器(尝试 `atuin server start`,更多文档将在后面介绍),但我(ellie)在 https://api.atuin.sh 上托管了一个。这是默认的服务器地址,可以在 [配置](config.md) 中更改。 同样,我_不能_看到您的数据,也不想。 6 | 7 | ## 同步频率 8 | 9 | 除非另有配置,否则同步将自动执行。同步的频率可在 [配置](config.md) 中配置。 10 | 11 | ## 同步 12 | 13 | 你可以通过 `atuin sync` 来手动触发同步 14 | 15 | ## 注册 16 | 17 | 注册一个同步账号 18 | 19 | ``` 20 | atuin register -u -e -p 21 | ``` 22 | 23 | 用户名(USERNAME)必须是唯一的,电子邮件(EMAIL)仅用于重要通知(安全漏洞、服务更改等) 24 | 25 | 注册后,意味着你也已经登录了 :) 同步应该从这里自动发生! 26 | 27 | ## 密钥 28 | 29 | 由于你的数据是加密的, Atuin 将为你生成一个密钥。它被存储在 Atuin 的数据目录里( Linux 上为 `~/.local/share/atuin`) 30 | 31 | 你也可以通过以下方式获得它 32 | 33 | ``` 34 | atuin key 35 | ``` 36 | 37 | 千万不要跟任何人分享这个! 38 | 39 | ## 登录 40 | 41 | 如果你想登录到一个新的机器上,你需要你的加密密钥(`atuin key`)。 42 | 43 | ``` 44 | atuin login -u -p -k 45 | ``` 46 | 47 | ## 登出 48 | 49 | ``` 50 | atuin logout 51 | ``` 52 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "fenix": { 4 | "inputs": { 5 | "nixpkgs": [ 6 | "nixpkgs" 7 | ], 8 | "rust-analyzer-src": "rust-analyzer-src" 9 | }, 10 | "locked": { 11 | "lastModified": 1742366221, 12 | "narHash": "sha256-GhWGWyGUvTF7H2DDGlQehsve1vRqIKAFhxy6D82Nj3Q=", 13 | "owner": "nix-community", 14 | "repo": "fenix", 15 | "rev": "a074d1bc9fd34f6b3a9049c5a61a82aea2044801", 16 | "type": "github" 17 | }, 18 | "original": { 19 | "owner": "nix-community", 20 | "repo": "fenix", 21 | "type": "github" 22 | } 23 | }, 24 | "flake-compat": { 25 | "flake": false, 26 | "locked": { 27 | "lastModified": 1733328505, 28 | "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", 29 | "owner": "edolstra", 30 | "repo": "flake-compat", 31 | "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", 32 | "type": "github" 33 | }, 34 | "original": { 35 | "owner": "edolstra", 36 | "repo": "flake-compat", 37 | "type": "github" 38 | } 39 | }, 40 | "flake-utils": { 41 | "inputs": { 42 | "systems": "systems" 43 | }, 44 | "locked": { 45 | "lastModified": 1731533236, 46 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 47 | "owner": "numtide", 48 | "repo": "flake-utils", 49 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "numtide", 54 | "repo": "flake-utils", 55 | "type": "github" 56 | } 57 | }, 58 | "nixpkgs": { 59 | "locked": { 60 | "lastModified": 1742272065, 61 | "narHash": "sha256-ud8vcSzJsZ/CK+r8/v0lyf4yUntVmDq6Z0A41ODfWbE=", 62 | "owner": "NixOS", 63 | "repo": "nixpkgs", 64 | "rev": "3549532663732bfd89993204d40543e9edaec4f2", 65 | "type": "github" 66 | }, 67 | "original": { 68 | "owner": "NixOS", 69 | "ref": "nixpkgs-unstable", 70 | "repo": "nixpkgs", 71 | "type": "github" 72 | } 73 | }, 74 | "root": { 75 | "inputs": { 76 | "fenix": "fenix", 77 | "flake-compat": "flake-compat", 78 | "flake-utils": "flake-utils", 79 | "nixpkgs": "nixpkgs" 80 | } 81 | }, 82 | "rust-analyzer-src": { 83 | "flake": false, 84 | "locked": { 85 | "lastModified": 1742296961, 86 | "narHash": "sha256-gCpvEQOrugHWLimD1wTFOJHagnSEP6VYBDspq96Idu0=", 87 | "owner": "rust-lang", 88 | "repo": "rust-analyzer", 89 | "rev": "15d87419f1a123d8f888d608129c3ce3ff8f13d4", 90 | "type": "github" 91 | }, 92 | "original": { 93 | "owner": "rust-lang", 94 | "ref": "nightly", 95 | "repo": "rust-analyzer", 96 | "type": "github" 97 | } 98 | }, 99 | "systems": { 100 | "locked": { 101 | "lastModified": 1681028828, 102 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 103 | "owner": "nix-systems", 104 | "repo": "default", 105 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 106 | "type": "github" 107 | }, 108 | "original": { 109 | "owner": "nix-systems", 110 | "repo": "default", 111 | "type": "github" 112 | } 113 | } 114 | }, 115 | "root": "root", 116 | "version": 7 117 | } 118 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 4 | flake-utils.url = "github:numtide/flake-utils"; 5 | flake-compat = { 6 | url = "github:edolstra/flake-compat"; 7 | flake = false; 8 | }; 9 | fenix = { 10 | url = "github:nix-community/fenix"; 11 | inputs.nixpkgs.follows = "nixpkgs"; 12 | }; 13 | }; 14 | outputs = 15 | { self 16 | , nixpkgs 17 | , flake-utils 18 | , fenix 19 | , ... 20 | }: 21 | flake-utils.lib.eachDefaultSystem 22 | (system: 23 | let 24 | pkgs = nixpkgs.outputs.legacyPackages.${system}; 25 | in 26 | { 27 | packages.atuin = pkgs.callPackage ./atuin.nix { 28 | inherit (pkgs.darwin.apple_sdk.frameworks) Security SystemConfiguration AppKit; 29 | rustPlatform = 30 | let 31 | toolchain = 32 | fenix.packages.${system}.fromToolchainFile 33 | { 34 | file = ./rust-toolchain.toml; 35 | sha256 = "sha256-X/4ZBHO3iW0fOenQ3foEvscgAPJYl2abspaBThDOukI="; 36 | }; 37 | in 38 | pkgs.makeRustPlatform { 39 | cargo = toolchain; 40 | rustc = toolchain; 41 | }; 42 | }; 43 | packages.default = self.outputs.packages.${system}.atuin; 44 | 45 | devShells.default = self.packages.${system}.default.overrideAttrs (super: { 46 | nativeBuildInputs = with pkgs; 47 | super.nativeBuildInputs 48 | ++ [ 49 | cargo-edit 50 | clippy 51 | rustfmt 52 | ]; 53 | RUST_SRC_PATH = "${pkgs.rustPlatform.rustLibSrc}"; 54 | 55 | shellHook = '' 56 | echo >&2 "Setting development database path" 57 | export ATUIN_DB_PATH="/tmp/atuin_dev.db" 58 | export ATUIN_RECORD_STORE_PATH="/tmp/atuin_records.db" 59 | 60 | if [ -e "''${ATUIN_DB_PATH}" ]; then 61 | echo >&2 "''${ATUIN_DB_PATH} already exists, you might want to double-check that" 62 | fi 63 | 64 | if [ -e "''${ATUIN_RECORD_STORE_PATH}" ]; then 65 | echo >&2 "''${ATUIN_RECORD_STORE_PATH} already exists, you might want to double-check that" 66 | fi 67 | ''; 68 | }); 69 | }) 70 | // { 71 | overlays.default = final: prev: { 72 | inherit (self.packages.${final.system}) atuin; 73 | }; 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | set -eu 3 | 4 | cat << EOF 5 | _______ _______ __ __ ___ __ _ 6 | | _ || || | | || | | | | | 7 | | |_| ||_ _|| | | || | | |_| | 8 | | | | | | |_| || | | | 9 | | | | | | || | | _ | 10 | | _ | | | | || | | | | | 11 | |__| |__| |___| |_______||___| |_| |__| 12 | 13 | Magical shell history 14 | 15 | Atuin setup 16 | https://github.com/atuinsh/atuin 17 | https://forum.atuin.sh 18 | 19 | Please file an issue or reach out on the forum if you encounter any problems! 20 | 21 | =============================================================================== 22 | 23 | EOF 24 | 25 | __atuin_install_binary(){ 26 | curl --proto '=https' --tlsv1.2 -LsSf https://github.com/atuinsh/atuin/releases/latest/download/atuin-installer.sh | sh 27 | } 28 | 29 | if ! command -v curl > /dev/null; then 30 | echo "curl not installed. Please install curl." 31 | exit 32 | elif ! command -v sed > /dev/null; then 33 | echo "sed not installed. Please install sed." 34 | exit 35 | fi 36 | 37 | 38 | __atuin_install_binary 39 | 40 | # TODO: Check which shell is in use 41 | # Use of single quotes around $() is intentional here 42 | # shellcheck disable=SC2016 43 | if ! grep -q "atuin init zsh" "${ZDOTDIR:-$HOME}/.zshrc"; then 44 | printf '\neval "$(atuin init zsh)"\n' >> "${ZDOTDIR:-$HOME}/.zshrc" 45 | fi 46 | 47 | # Use of single quotes around $() is intentional here 48 | # shellcheck disable=SC2016 49 | 50 | if ! grep -q "atuin init bash" ~/.bashrc; then 51 | curl https://raw.githubusercontent.com/rcaloras/bash-preexec/master/bash-preexec.sh -o ~/.bash-preexec.sh 52 | printf '\n[[ -f ~/.bash-preexec.sh ]] && source ~/.bash-preexec.sh\n' >> ~/.bashrc 53 | echo 'eval "$(atuin init bash)"' >> ~/.bashrc 54 | fi 55 | 56 | if [ -f "$HOME/.config/fish/config.fish" ]; then 57 | # Check if the line already exists to prevent duplicates 58 | if ! grep -q "atuin init fish" "$HOME/.config/fish/config.fish"; then 59 | # Detect BSD or GNU sed 60 | if sed --version >/dev/null 2>&1; then 61 | # GNU 62 | sed -i '/if status is-interactive/,/end/ s/end$/ atuin init fish | source\ 63 | end/' "$HOME/.config/fish/config.fish" 64 | else 65 | # BSD (macOS) 66 | sed -i '' '/if status is-interactive/,/end/ s/end$/ atuin init fish | source\ 67 | end/' "$HOME/.config/fish/config.fish" 68 | fi 69 | fi 70 | fi 71 | 72 | cat << EOF 73 | 74 | 75 | 76 | _______ __ __ _______ __ _ ___ _ __ __ _______ __ __ 77 | | || | | || _ || | | || | | | | | | || || | | | 78 | |_ _|| |_| || |_| || |_| || |_| | | |_| || _ || | | | 79 | | | | || || || _| | || | | || |_| | 80 | | | | || || _ || |_ |_ _|| |_| || | 81 | | | | _ || _ || | | || _ | | | | || | 82 | |___| |__| |__||__| |__||_| |__||___| |_| |___| |_______||_______| 83 | 84 | 85 | 86 | Thanks for installing Atuin! I really hope you like it. 87 | 88 | If you have any issues, please open an issue on GitHub or visit our forum (https://forum.atuin.sh)! 89 | 90 | If you love Atuin, please give us a star on GitHub! It really helps ⭐️ https://github.com/atuinsh/atuin 91 | 92 | Please run "atuin register" to get setup with sync, or "atuin login" if you already have an account 93 | 94 | EOF 95 | 96 | -------------------------------------------------------------------------------- /k8s/namespaces.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: atuin-namespace 5 | labels: 6 | name: atuin 7 | -------------------------------------------------------------------------------- /k8s/secrets.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: atuin-secrets 5 | type: Opaque 6 | stringData: 7 | ATUIN_DB_USERNAME: atuin 8 | ATUIN_DB_PASSWORD: seriously-insecure 9 | ATUIN_HOST: "127.0.0.1" 10 | ATUIN_PORT: "8888" 11 | ATUIN_OPEN_REGISTRATION: "true" 12 | ATUIN_DB_URI: "postgres://atuin:seriously-insecure@localhost/atuin" 13 | immutable: true 14 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.86" 3 | -------------------------------------------------------------------------------- /systemd/atuin-server.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Start the Atuin server syncing service 3 | After=network-online.target 4 | Wants=network-online.target systemd-networkd-wait-online.service 5 | 6 | [Service] 7 | ExecStart=atuin server start 8 | Restart=on-failure 9 | User=atuin 10 | Group=atuin 11 | 12 | Environment=ATUIN_CONFIG_DIR=/etc/atuin 13 | ReadWritePaths=/etc/atuin 14 | 15 | # Hardening options 16 | CapabilityBoundingSet= 17 | AmbientCapabilities= 18 | NoNewPrivileges=true 19 | ProtectHome=true 20 | ProtectSystem=strict 21 | ProtectKernelTunables=true 22 | ProtectKernelModules=true 23 | ProtectControlGroups=true 24 | PrivateTmp=true 25 | PrivateDevices=true 26 | LockPersonality=true 27 | 28 | [Install] 29 | WantedBy=multi-user.target 30 | -------------------------------------------------------------------------------- /systemd/atuin-server.sysusers: -------------------------------------------------------------------------------- 1 | u atuin - "Atuin synchronized shell history" 2 | --------------------------------------------------------------------------------