From 36f0caa66eaf616485e153e655514182a31aeb5a Mon Sep 17 00:00:00 2001 From: andwati Date: Wed, 15 Jul 2026 17:02:27 +0300 Subject: [PATCH] Add modular dev environment installer, dotfiles, and desnap script Adds a TUI-driven, rollback-capable installer (install/install.sh + install/modules/*.sh) covering JS/Python/systems toolchains, dev tooling, browsers, chat apps, GNOME extras, and misc setup (wakatime key, ssh/gpg perms, apt mirror benchmarking), all sourced from each vendor's official install method. bootstrap.sh stows the zsh/git/tmux/vscode dotfiles and runs the installer; desnap.sh purges snapd and replaces default snaps with apt/Flatpak equivalents. --- .gitignore | 2 + README.md | 139 +++++++++++++- bootstrap.sh | 54 ++++++ desnap.sh | 141 ++++++++++++++ git/.gitconfig | 24 +++ git/.gitignore_global | 4 + install/install.sh | 253 +++++++++++++++++++++++++ install/lib.sh | 100 ++++++++++ install/modules/00-base.sh | 9 + install/modules/bun.sh | 7 + install/modules/chrome.sh | 14 ++ install/modules/cli-tools.sh | 3 + install/modules/cursor.sh | 36 ++++ install/modules/deno.sh | 7 + install/modules/discord.sh | 10 + install/modules/docker.sh | 20 ++ install/modules/firefox.sh | 20 ++ install/modules/ghcli.sh | 7 + install/modules/gnome.sh | 15 ++ install/modules/go.sh | 34 ++++ install/modules/jetbrains-toolbox.sh | 24 +++ install/modules/mirrors.sh | 79 ++++++++ install/modules/mpv.sh | 3 + install/modules/node.sh | 12 ++ install/modules/nvm.sh | 20 ++ install/modules/onedrive.sh | 21 ++ install/modules/perms.sh | 22 +++ install/modules/pipenv.sh | 12 ++ install/modules/pipx.sh | 4 + install/modules/pnpm.sh | 7 + install/modules/poetry.sh | 7 + install/modules/postman.sh | 22 +++ install/modules/protonvpn.sh | 25 +++ install/modules/pyenv.sh | 16 ++ install/modules/rust.sh | 7 + install/modules/signal.sh | 7 + install/modules/slack.sh | 7 + install/modules/spotify.sh | 7 + install/modules/telegram.sh | 23 +++ install/modules/uv.sh | 6 + install/modules/vscode.sh | 54 ++++++ install/modules/wakatime.sh | 36 ++++ install/modules/zig.sh | 65 +++++++ tmux/.tmux.conf | 7 + vscode/.config/Code/User/settings.json | 58 ++++++ vscode/extensions.txt | 77 ++++++++ zsh/.zsh_aliases | 9 + zsh/.zshrc | 15 ++ 48 files changed, 1550 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100755 bootstrap.sh create mode 100755 desnap.sh create mode 100644 git/.gitconfig create mode 100644 git/.gitignore_global create mode 100755 install/install.sh create mode 100644 install/lib.sh create mode 100644 install/modules/00-base.sh create mode 100644 install/modules/bun.sh create mode 100644 install/modules/chrome.sh create mode 100644 install/modules/cli-tools.sh create mode 100644 install/modules/cursor.sh create mode 100644 install/modules/deno.sh create mode 100644 install/modules/discord.sh create mode 100644 install/modules/docker.sh create mode 100644 install/modules/firefox.sh create mode 100644 install/modules/ghcli.sh create mode 100644 install/modules/gnome.sh create mode 100644 install/modules/go.sh create mode 100644 install/modules/jetbrains-toolbox.sh create mode 100644 install/modules/mirrors.sh create mode 100644 install/modules/mpv.sh create mode 100644 install/modules/node.sh create mode 100644 install/modules/nvm.sh create mode 100644 install/modules/onedrive.sh create mode 100644 install/modules/perms.sh create mode 100644 install/modules/pipenv.sh create mode 100644 install/modules/pipx.sh create mode 100644 install/modules/pnpm.sh create mode 100644 install/modules/poetry.sh create mode 100644 install/modules/postman.sh create mode 100644 install/modules/protonvpn.sh create mode 100644 install/modules/pyenv.sh create mode 100644 install/modules/rust.sh create mode 100644 install/modules/signal.sh create mode 100644 install/modules/slack.sh create mode 100644 install/modules/spotify.sh create mode 100644 install/modules/telegram.sh create mode 100644 install/modules/uv.sh create mode 100644 install/modules/vscode.sh create mode 100644 install/modules/wakatime.sh create mode 100644 install/modules/zig.sh create mode 100644 tmux/.tmux.conf create mode 100644 vscode/.config/Code/User/settings.json create mode 100644 vscode/extensions.txt create mode 100644 zsh/.zsh_aliases create mode 100644 zsh/.zshrc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f31b3e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +*.swp diff --git a/README.md b/README.md index 40e6fb8..c051c9a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,139 @@ # dotfiles -My configuration configuration files that keep me from flipping a table after logging in. + +My configuration files, plus a modular installer for the tools I use on a +fresh Ubuntu box (targeting 26.04). Everything installs from each vendor's +official source — apt repos with their real signing keys, or their official +install scripts/tarballs. No PPAs of unclear provenance, no snaps unless the +vendor's own instructions use one (Ubuntu's own default snaps are actively +replaced — see `desnap.sh` below). + +## Quick start + +On a fresh Ubuntu install: + +```sh +git clone ~/dotfiles +cd ~/dotfiles +./bootstrap.sh +``` + +That will: + +1. Install `stow`, `zsh`, `curl`, `git`. +2. Symlink the dotfiles (`zsh/`, `git/`, `tmux/`, `vscode/`) into `$HOME` with + GNU Stow. +3. Prompt once for your git name/email/signing key, saved to + `~/.gitconfig.local` (gitignored — never committed, so this repo stays + shareable). +4. Set zsh as your default shell. +5. Run `install/install.sh`, which shows a checklist (see below) of every + tool to install. + +Open a new shell afterwards (or `exec zsh`) to pick up PATH changes. + +## Picking what to install + +Run interactively with no arguments and `install/install.sh` shows a +`whiptail` checklist — space to toggle, enter to confirm. Everything's +checked by default; uncheck what you don't want. If `whiptail` isn't +available it's installed automatically; if that's not possible either it +falls back to a plain numbered prompt. + +Non-interactive options: + +```sh +./bootstrap.sh nvm node pnpm docker # only these modules (+ base deps) +./install/install.sh --list # list all module names +./install/install.sh --except discord,slack,spotify +./install/install.sh --all # skip the checklist, install everything +./install/install.sh --dry-run # show what would run without doing it +``` + +`--dry-run` works with any of the above (`--dry-run --all`, +`--dry-run nvm docker`, ...) and also works on `bootstrap.sh` and +`desnap.sh`. + +## Resilience: a failed module doesn't kill the run + +Every module runs in its own subshell with `errexit` on. If a command in a +module fails partway through, that module's own changes (packages +installed, repos added, directories created, group memberships granted — +whatever it called `rollback_push` for) are unwound in reverse order, the +failure is recorded, and the **next module still runs**. At the end you get +a summary of what succeeded and what failed, and the script exits nonzero +only if something actually failed. Every module is also idempotent — safe +to re-run individually or all together. + +## What gets installed + +| Category | Tools | +|---|---| +| System | apt mirror benchmarking, `~/.ssh`/`~/.gnupg` permission fixes, eza/ripgrep | +| JS/TS runtimes | nvm, node (via nvm, LTS), pnpm, deno, bun | +| Python | pyenv, pipx, pipenv, poetry, uv | +| Systems languages | go, rust (rustup), zig + zls | +| Dev tooling | VS Code (+ your extensions/settings, mirrored to Cursor), Cursor, GitHub CLI (`gh`), Docker Engine | +| Apps | Chrome, Firefox, Discord, Slack, Postman, JetBrains Toolbox, mpv, Spotify, Telegram, Signal, ProtonVPN, OneDrive (abraunegg) | +| Desktop | GNOME Tweaks, Extension Manager, AppIndicator support | +| Misc | Wakatime API key setup | + +## Layout + +``` +bootstrap.sh entrypoint: stows dotfiles, then runs install/install.sh +desnap.sh standalone: purges snapd, replaces default snaps with apt/Flatpak +install/ + lib.sh shared shell helpers (apt/repo/rollback/profile-line helpers) + install.sh module runner — TUI checklist, dry-run, rollback, summary + modules/*.sh one file per tool, each defines install_() +zsh/ git/ tmux/ vscode/ stow packages — mirror their target layout under $HOME +``` + +To add a new tool: drop a new `install/modules/.sh` defining +`install_()`, and add `` (+ a description) to `ALL_MODULES` / +`MODULE_DESC` in `install/install.sh`. + +## desnap.sh + +Purges snapd entirely and pins it out of apt so it can't come back on +upgrade. Before removing anything it works out, per installed snap, whether +there's a real replacement: + +- Snaps we already have an official-source module for (Firefox) get that + module. +- Known default-Ubuntu snaps with a plain apt equivalent (Thunderbird, + snap-store → gnome-software) get that apt package. +- GNOME/core runtime snaps (content-only, no user-facing app) are skipped. +- Anything else gets Flatpak + Flathub set up and is listed at the end so + you can `flatpak search`/`install` it yourself, rather than guessing an + app id. + +Destructive and asks for confirmation (`-y`/`--yes` to skip the prompt); +supports `--dry-run` to preview the plan first. + +```sh +./desnap.sh --dry-run +./desnap.sh +``` + +## Notes + +- `mirrors` benchmarks official Ubuntu mirrors (speed + a freshness check on + `Last-Modified`) and rewrites apt's sources to the fastest one — handles + both the legacy `sources.list` and the 24.04+/26.04 deb822 + `ubuntu.sources` format. Runs early so the rest of the install benefits. + `security.ubuntu.com` is left alone on purpose. +- `firefox` removes the transitional apt stub / snap package first and pins + Mozilla's official apt repo, since stock Ubuntu ships Firefox as a snap. +- `docker`, `onedrive` each check whether their upstream repo has packages + for the running Ubuntu codename/version yet and fall back to the most + recent known-supported one if it's too new (relevant right after a fresh + 26.04 install). +- `vscode` installs every extension listed in `vscode/extensions.txt` + (exported with `code --list-extensions`) and mirrors `settings.json` to + Cursor via symlink, so both stay in sync from one source of truth. +- `wakatime` prompts once for your API key and writes `~/.wakatime.cfg` + (chmod 600); leave the prompt blank to skip. +- Tool PATH/init lines are appended idempotently to the end of `zsh/.zshrc` + by `profile_add` in `install/lib.sh` — re-running a module never + duplicates a line. diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..cbefd9c --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +export DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=install/lib.sh +source "$DOTFILES_DIR/install/lib.sh" + +DRY_RUN=0 +for a in "$@"; do + if [[ "$a" == "--dry-run" ]]; then DRY_RUN=1; fi +done +export DRY_RUN + +STOW_PACKAGES=(zsh git tmux vscode) + +if [[ "$DRY_RUN" == "1" ]]; then + log "[dry-run] would install base packages: stow zsh curl git" + log "[dry-run] would stow: ${STOW_PACKAGES[*]} -> \$HOME" +else + log "installing base packages" + apt_install stow zsh curl git + + log "stowing dotfiles from $DOTFILES_DIR" + stow -v -t "$HOME" -d "$DOTFILES_DIR" "${STOW_PACKAGES[@]}" +fi + +if [[ "$DRY_RUN" == "1" ]]; then + log "[dry-run] would prompt for git identity + signing key -> ~/.gitconfig.local" +elif [[ ! -f "$HOME/.gitconfig.local" ]]; then + read -rp "git user.name: " git_name + read -rp "git user.email [andwatiian@gmail.com]: " git_email + git_email="${git_email:-andwatiian@gmail.com}" + read -rp "git signing key (GPG key id) [1F20FED0AA05B0DA]: " git_signkey + git_signkey="${git_signkey:-1F20FED0AA05B0DA}" + { + echo "[user]" + echo " name = $git_name" + echo " email = $git_email" + if [[ -n "$git_signkey" ]]; then echo " signingkey = $git_signkey"; fi + } > "$HOME/.gitconfig.local" + ok "wrote $HOME/.gitconfig.local" +fi + +if [[ "$DRY_RUN" == "1" ]]; then + if [[ "$SHELL" != *zsh* ]]; then log "[dry-run] would set default shell to zsh"; fi +elif [[ "$SHELL" != *zsh* ]]; then + log "setting zsh as default shell" + chsh -s "$(command -v zsh)" "$USER" +fi + +log "running tool installer" +"$DOTFILES_DIR/install/install.sh" "$@" + +ok "all done — open a new shell (or 'exec zsh') to pick up PATH changes" diff --git a/desnap.sh b/desnap.sh new file mode 100755 index 0000000..d51aa4d --- /dev/null +++ b/desnap.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +set -euo pipefail + +export DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=install/lib.sh +source "$DOTFILES_DIR/install/lib.sh" + +DRY_RUN=0 +ASSUME_YES=0 +for a in "$@"; do + case "$a" in + --dry-run) DRY_RUN=1 ;; + -y|--yes) ASSUME_YES=1 ;; + esac +done + +if ! has_cmd snap; then + ok "snapd not installed, nothing to do" + exit 0 +fi + +mapfile -t snaps < <(snap list 2>/dev/null | awk 'NR>1 {print $1}') + +if [[ ${#snaps[@]} -eq 0 ]]; then + log "no snaps installed" +else + log "snaps currently installed: ${snaps[*]}" +fi + +# snapd's own bundled core/bare snaps can't be removed while anything else +# depends on them, so they're removed last, in their own pass, below. +core_snaps=(snapd core core18 core20 core22 core24 bare) + +# Known default-Ubuntu snaps mapped to their real, official-source +# replacement: an existing dotfiles module (reused so it stays one source of +# truth), a plain apt package, or "skip" for snap-only infra with no +# user-facing equivalent. Anything not listed here that isn't a GNOME/core +# runtime snap falls back to Flatpak/Flathub instead of a guessed package. +declare -A SNAP_REPLACEMENTS=( + [firefox]="module:firefox" + [thunderbird]="apt:thunderbird" + [snap-store]="apt:gnome-software" + [snapd-desktop-integration]="skip" + [gtk-common-themes]="skip" +) + +_replacement_for() { + local snap="$1" + if [[ -n "${SNAP_REPLACEMENTS[$snap]:-}" ]]; then + echo "${SNAP_REPLACEMENTS[$snap]}" + return + fi + case "$snap" in + gnome-*|core*|bare) echo "skip" ;; + *) echo "unmapped" ;; + esac +} + +declare -a to_remove=() modules_to_run=() apt_pkgs=() unmapped=() +for s in "${snaps[@]}"; do + case " ${core_snaps[*]} " in *" $s "*) continue ;; esac + to_remove+=("$s") + + action=$(_replacement_for "$s") + case "$action" in + module:*) modules_to_run+=("${action#module:}") ;; + apt:*) apt_pkgs+=("${action#apt:}") ;; + skip) : ;; + unmapped) unmapped+=("$s") ;; + esac +done + +if [[ "$DRY_RUN" == "1" ]]; then + log "[dry-run] would remove: ${to_remove[*]:-} (plus core/base snaps)" + log "[dry-run] would purge snapd and pin /etc/apt/preferences.d/nosnap.pref" + if [[ ${#modules_to_run[@]} -gt 0 ]]; then + log "[dry-run] would reinstall via existing modules: ${modules_to_run[*]}" + fi + if [[ ${#apt_pkgs[@]} -gt 0 ]]; then + log "[dry-run] would reinstall via apt: ${apt_pkgs[*]}" + fi + if [[ ${#unmapped[@]} -gt 0 ]]; then + log "[dry-run] no apt equivalent for: ${unmapped[*]} — would set up Flatpak/Flathub instead" + fi + exit 0 +fi + +if [[ "$ASSUME_YES" != "1" ]]; then + warn "this removes ALL snap packages, purges snapd, and blocks it from ever being reinstalled by apt." + read -rp "continue? [y/N] " reply + [[ "$reply" =~ ^[Yy]$ ]] || { log "aborted"; exit 1; } +fi + +for s in "${to_remove[@]}"; do + log "removing snap: $s" + sudo snap remove --purge "$s" || warn "failed to remove $s, continuing" +done +for s in "${core_snaps[@]}"; do + if snap list "$s" >/dev/null 2>&1; then + log "removing snap: $s" + sudo snap remove --purge "$s" || true + fi +done + +log "purging snapd" +sudo apt-get purge -y snapd +sudo rm -rf /var/cache/snapd/ "$HOME/snap" + +log "pinning snapd out of apt so it can't come back on upgrade" +sudo tee /etc/apt/preferences.d/nosnap.pref >/dev/null <<'EOF' +Package: snapd +Pin: release a=* +Pin-Priority: -10 +EOF + +for m in "${modules_to_run[@]}"; do + log "installing $m from its official source (snap replacement)" + # shellcheck disable=SC1090 + source "$DOTFILES_DIR/install/modules/$m.sh" + "install_${m//-/_}" +done + +if [[ ${#apt_pkgs[@]} -gt 0 ]]; then + log "installing apt replacements: ${apt_pkgs[*]}" + apt_install "${apt_pkgs[@]}" +fi + +if [[ ${#unmapped[@]} -gt 0 ]]; then + warn "no apt equivalent known for: ${unmapped[*]} — setting up Flatpak/Flathub so you can still get them" + apt_install flatpak + if has_cmd gnome-shell; then + apt_install gnome-software-plugin-flatpak + fi + sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + warn "search/install with: flatpak search && flatpak install flathub " + for s in "${unmapped[@]}"; do + warn " - $s" + done +fi + +ok "desnapped — snapd is purged and pinned out, replacements installed from apt/official sources" diff --git a/git/.gitconfig b/git/.gitconfig new file mode 100644 index 0000000..2253128 --- /dev/null +++ b/git/.gitconfig @@ -0,0 +1,24 @@ +[core] + editor = vim + excludesfile = ~/.gitignore_global + +[init] + defaultBranch = main + +[pull] + rebase = false + +[alias] + st = status + co = checkout + br = branch + lg = log --oneline --graph --decorate --all + +[commit] + gpgsign = true + +[tag] + gpgSign = true + +[include] + path = ~/.gitconfig.local diff --git a/git/.gitignore_global b/git/.gitignore_global new file mode 100644 index 0000000..710e2f3 --- /dev/null +++ b/git/.gitignore_global @@ -0,0 +1,4 @@ +.DS_Store +*.swp +node_modules/ +.env diff --git a/install/install.sh b/install/install.sh new file mode 100755 index 0000000..31a7d0a --- /dev/null +++ b/install/install.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODULES_DIR="$SCRIPT_DIR/modules" +export DOTFILES_DIR="${DOTFILES_DIR:-$(cd "$SCRIPT_DIR/.." && pwd)}" +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" +trap 'rm -f "$(_apt_marker)"' EXIT + +ALL_MODULES=( + 00-base mirrors perms cli-tools + nvm node pnpm deno bun + pyenv pipx pipenv poetry uv + vscode cursor ghcli docker go rust zig + chrome discord slack firefox postman jetbrains-toolbox mpv spotify telegram signal protonvpn onedrive + gnome wakatime +) + +declare -A MODULE_DESC=( + [00-base]="core apt packages (curl, git, build-essential, jq, stow, ...)" + [mirrors]="benchmark + switch apt to the fastest fresh Ubuntu mirror" + [perms]="fix ~/.ssh and ~/.gnupg permissions" + [cli-tools]="eza, ripgrep + shell aliases" + [nvm]="Node Version Manager" + [node]="Node.js LTS (via nvm)" + [pnpm]="pnpm package manager" + [deno]="Deno runtime" + [bun]="Bun runtime" + [pyenv]="Python version manager" + [pipx]="pipx (isolated python CLI installs)" + [pipenv]="pipenv (via pipx)" + [poetry]="Poetry python packaging" + [uv]="uv python package/venv manager" + [vscode]="VS Code + your extensions/settings (mirrors to Cursor too)" + [cursor]="Cursor editor (official AppImage)" + [ghcli]="GitHub CLI (gh)" + [docker]="Docker Engine + compose plugin" + [go]="Go toolchain" + [rust]="Rust (rustup)" + [zig]="Zig + zls (language server)" + [chrome]="Google Chrome" + [discord]="Discord" + [slack]="Slack" + [firefox]="Firefox (Mozilla apt repo, not snap)" + [postman]="Postman" + [jetbrains-toolbox]="JetBrains Toolbox" + [mpv]="mpv media player" + [spotify]="Spotify" + [telegram]="Telegram Desktop" + [signal]="Signal Desktop" + [protonvpn]="ProtonVPN (official GNOME app)" + [onedrive]="OneDrive client (abraunegg, official OBS repo)" + [gnome]="GNOME Tweaks, Extension Manager, AppIndicator support" + [wakatime]="Wakatime API key setup" +) + +SUMMARY_OK=() +SUMMARY_FAILED=() + +usage() { + cat <&1 1>&2 2>&3) || { warn "selection cancelled"; return 1; } + + eval "local -a selected=($choices)" + printf '%s\n' "${selected[@]}" +} + +plain_select_modules() { + echo "Select modules (space-separated numbers, or 'all'):" >&2 + local i=1 m + for m in "${ALL_MODULES[@]}"; do + printf ' %2d) %-20s %s\n' "$i" "$m" "${MODULE_DESC[$m]:-}" >&2 + i=$((i + 1)) + done + read -rp "> " reply + if [[ "$reply" == "all" || -z "$reply" ]]; then + printf '%s\n' "${ALL_MODULES[@]}" + return + fi + local -a out=() + local n + for n in $reply; do + [[ "$n" =~ ^[0-9]+$ ]] || continue + out+=("${ALL_MODULES[$((n - 1))]}") + done + printf '%s\n' "${out[@]}" +} + +run_module() { + local slug="$1" + local file="$MODULES_DIR/${slug}.sh" + local fn="install_${slug//-/_}" + + if [[ ! -f "$file" ]]; then + err "unknown module: $slug" + SUMMARY_FAILED+=("$slug") + return + fi + + if [[ "${DRY_RUN:-0}" == "1" ]]; then + log "[dry-run] would install: $slug — ${MODULE_DESC[$slug]:-}" + SUMMARY_OK+=("$slug (dry-run)") + return + fi + + log "installing $slug" + + # Run in a subshell so a failing command genuinely aborts the module + # (errexit) instead of limping on to the next line. Note: `if ( ... ); + # then` or `( ... ) || ...` would silently defeat -e here — bash suspends + # errexit for the *whole* tested command, including inside a nested + # subshell, even if that subshell re-sets -e itself. The subshell must run + # as a genuinely unconditional statement, under a `set +e` bracket so its + # own nonzero exit doesn't also kill the outer script, with $? captured + # right after and -e restored immediately. + set +e + ( + set -Eeuo pipefail + trap 'rollback_run' ERR + # shellcheck disable=SC1090 + source "$file" + rollback_reset + "$fn" + ) + local status=$? + set -e + + if [[ $status -eq 0 ]]; then + ok "$slug done" + SUMMARY_OK+=("$slug") + else + err "$slug failed — its changes were rolled back" + SUMMARY_FAILED+=("$slug") + fi +} + +print_summary() { + echo + log "summary" + if [[ ${#SUMMARY_OK[@]} -gt 0 ]]; then printf ' ok: %s\n' "${SUMMARY_OK[*]}"; fi + if [[ ${#SUMMARY_FAILED[@]} -gt 0 ]]; then printf ' failed: %s\n' "${SUMMARY_FAILED[*]}"; fi + return 0 +} + +main() { + local -a explicit=() except=() + local dry_run=0 force_all=0 + + while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --list) list_modules; exit 0 ;; + --dry-run) dry_run=1; shift ;; + --all|--no-tui) force_all=1; shift ;; + --except) + IFS=',' read -ra except <<< "${2:-}" + shift 2 + ;; + -*) + err "unknown flag: $1"; usage; exit 1 ;; + *) explicit+=("$1"); shift ;; + esac + done + + export DRY_RUN=$dry_run + + local -a targets=() + local m s skipped + if [[ ${#except[@]} -gt 0 ]]; then + for m in "${ALL_MODULES[@]}"; do + skipped=0 + for s in "${except[@]}"; do + if [[ "$m" == "$s" ]]; then skipped=1; fi + done + [[ $skipped -eq 1 ]] || targets+=("$m") + done + elif [[ ${#explicit[@]} -gt 0 ]]; then + targets=("${explicit[@]}") + elif [[ $force_all -eq 0 && -t 0 && -t 1 ]]; then + local tui_out + if ! tui_out=$(tui_select_modules); then + err "no modules selected" + exit 1 + fi + mapfile -t targets <<< "$tui_out" + else + targets=("${ALL_MODULES[@]}") + fi + + if [[ ${#targets[@]} -eq 0 ]]; then + warn "nothing selected" + exit 0 + fi + + local -a ordered=(00-base) + for m in "${targets[@]}"; do + [[ "$m" == "00-base" ]] || ordered+=("$m") + done + + for m in "${ordered[@]}"; do + run_module "$m" + done + + print_summary + [[ ${#SUMMARY_FAILED[@]} -eq 0 ]] +} + +main "$@" diff --git a/install/lib.sh b/install/lib.sh new file mode 100644 index 0000000..963a7bd --- /dev/null +++ b/install/lib.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Shared helpers sourced by install.sh, bootstrap.sh, desnap.sh, and every +# module in install/modules/. + +C_RESET=$'\033[0m'; C_BLUE=$'\033[1;34m'; C_GREEN=$'\033[1;32m'; C_YELLOW=$'\033[1;33m'; C_RED=$'\033[1;31m' + +log() { printf '%s==>%s %s\n' "$C_BLUE" "$C_RESET" "$*"; } +ok() { printf '%s ok%s %s\n' "$C_GREEN" "$C_RESET" "$*"; } +warn() { printf '%s warn%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; } +err() { printf '%s !!%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; } + +has_cmd() { command -v "$1" >/dev/null 2>&1; } + +deb_arch() { dpkg --print-architecture; } +ubuntu_codename() { . /etc/os-release; echo "$VERSION_CODENAME"; } + +# Each module now runs in its own subshell (see run_module in install.sh), +# so a plain shell variable can't track "already updated" across modules — +# it'd reset every time. Use a marker file scoped to this whole run ($$ is +# stable across subshells) instead; apt_invalidate removes it when a repo +# changes so the next apt_install re-syncs. +_apt_marker() { echo "/tmp/.dotfiles-apt-updated.$$"; } + +apt_update_once() { + local marker + marker=$(_apt_marker) + if [[ ! -f "$marker" ]]; then + sudo apt-get update -qq + touch "$marker" + fi +} + +apt_invalidate() { rm -f "$(_apt_marker)"; } + +# apt_install — installs packages and, for any package that wasn't +# already present, registers a rollback that removes it again. Packages the +# user already had are left alone on rollback. +apt_install() { + apt_update_once + local -a new_pkgs=() + local p + for p in "$@"; do + dpkg -s "$p" >/dev/null 2>&1 || new_pkgs+=("$p") + done + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "$@" + if [[ ${#new_pkgs[@]} -gt 0 ]]; then + rollback_push "sudo DEBIAN_FRONTEND=noninteractive apt-get remove -y ${new_pkgs[*]}" + fi +} + +# apt_add_repo +# Registers a signed apt repo the way every vendor's official install docs do +# it (dearmor the key into /etc/apt/keyrings or similar, write a .list file), +# forces the next apt_install to refresh package lists, and registers a +# rollback that removes the repo + keyring again. +apt_add_repo() { + local list_name="$1" keyring="$2" key_url="$3" repo_line="$4" + sudo install -d -m 0755 "$(dirname "$keyring")" + curl -fsSL "$key_url" | sudo gpg --dearmor --yes -o "$keyring" + sudo chmod a+r "$keyring" + echo "$repo_line" | sudo tee "/etc/apt/sources.list.d/${list_name}.list" >/dev/null + rollback_push "sudo rm -f '/etc/apt/sources.list.d/${list_name}.list' '$keyring'" + apt_invalidate +} + +add_line_once() { + local file="$1" line="$2" + mkdir -p "$(dirname "$file")" + touch "$file" + grep -qxF "$line" "$file" || printf '%s\n' "$line" >> "$file" +} + +# profile_add — idempotently append a PATH/init line to ~/.zshrc so +# re-running a module never duplicates it. Not rollback-tracked: a stray +# export pointing at a directory that got rolled back is inert, not broken. +profile_add() { add_line_once "$HOME/.zshrc" "$1"; } + +# --- transactional rollback ------------------------------------------------- +# +# Each module gets a fresh rollback stack (see run_module in install.sh). +# Anything a module does that should be undone if a *later* command in the +# same module fails should call rollback_push with a shell command string. +# On success the stack is simply discarded; on failure it's run in reverse. + +ROLLBACK_ACTIONS=() + +rollback_reset() { ROLLBACK_ACTIONS=(); } +rollback_push() { ROLLBACK_ACTIONS+=("$1"); } + +rollback_run() { + local i + for (( i=${#ROLLBACK_ACTIONS[@]}-1; i>=0; i-- )); do + warn "rollback: ${ROLLBACK_ACTIONS[$i]}" + eval "${ROLLBACK_ACTIONS[$i]}" || true + done + ROLLBACK_ACTIONS=() +} + +track_new_dir() { rollback_push "rm -rf '$1'"; } +track_new_sudo_dir() { rollback_push "sudo rm -rf '$1'"; } diff --git a/install/modules/00-base.sh b/install/modules/00-base.sh new file mode 100644 index 0000000..92808a6 --- /dev/null +++ b/install/modules/00-base.sh @@ -0,0 +1,9 @@ +install_00_base() { + if ! grep -qi ubuntu /etc/os-release 2>/dev/null; then + warn "this installer targets Ubuntu; continuing anyway" + fi + apt_install \ + curl wget git ca-certificates gnupg lsb-release jq \ + software-properties-common apt-transport-https \ + build-essential unzip xz-utils stow +} diff --git a/install/modules/bun.sh b/install/modules/bun.sh new file mode 100644 index 0000000..7d4baac --- /dev/null +++ b/install/modules/bun.sh @@ -0,0 +1,7 @@ +install_bun() { + if has_cmd bun; then ok "bun already installed"; return; fi + track_new_dir "$HOME/.bun" + NO_MODIFY_PATH=1 bash -c "$(curl -fsSL https://bun.sh/install)" + profile_add 'export BUN_INSTALL="$HOME/.bun"' + profile_add 'export PATH="$BUN_INSTALL/bin:$PATH"' +} diff --git a/install/modules/chrome.sh b/install/modules/chrome.sh new file mode 100644 index 0000000..551c63e --- /dev/null +++ b/install/modules/chrome.sh @@ -0,0 +1,14 @@ +install_chrome() { + if has_cmd google-chrome-stable; then + ok "chrome already installed" + return + fi + + local deb + deb=$(mktemp --suffix=.deb) + curl -fsSL https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -o "$deb" + apt_update_once + sudo apt-get install -y "$deb" + rm -f "$deb" + rollback_push "sudo apt-get remove -y google-chrome-stable" +} diff --git a/install/modules/cli-tools.sh b/install/modules/cli-tools.sh new file mode 100644 index 0000000..34a6a4f --- /dev/null +++ b/install/modules/cli-tools.sh @@ -0,0 +1,3 @@ +install_cli_tools() { + apt_install eza ripgrep +} diff --git a/install/modules/cursor.sh b/install/modules/cursor.sh new file mode 100644 index 0000000..5af46b9 --- /dev/null +++ b/install/modules/cursor.sh @@ -0,0 +1,36 @@ +install_cursor() { + if [[ -x /opt/cursor/cursor.AppImage ]]; then + ok "cursor already installed" + return + fi + + apt_install libfuse2 + + local appimage + appimage=$(mktemp --suffix=.AppImage) + curl -fsSL https://downloader.cursor.sh/linux/appImage/x64 -o "$appimage" + chmod +x "$appimage" + + track_new_sudo_dir /opt/cursor + sudo mkdir -p /opt/cursor + sudo mv "$appimage" /opt/cursor/cursor.AppImage + sudo ln -sf /opt/cursor/cursor.AppImage /usr/local/bin/cursor + rollback_push "sudo rm -f /usr/local/bin/cursor" + + sudo tee /usr/share/applications/cursor.desktop >/dev/null <<'EOF' +[Desktop Entry] +Name=Cursor +Exec=/opt/cursor/cursor.AppImage --no-sandbox %U +Icon=cursor +Terminal=false +Type=Application +Categories=Development; +EOF + rollback_push "sudo rm -f /usr/share/applications/cursor.desktop" + + # Reuses vscode.sh's mirror step to symlink settings + install the same + # extensions into the newly-installed Cursor. + # shellcheck disable=SC1090 + source "${DOTFILES_DIR:?}/install/modules/vscode.sh" + _vscode_mirror_cursor +} diff --git a/install/modules/deno.sh b/install/modules/deno.sh new file mode 100644 index 0000000..1f9d5aa --- /dev/null +++ b/install/modules/deno.sh @@ -0,0 +1,7 @@ +install_deno() { + if has_cmd deno; then ok "deno already installed"; return; fi + track_new_dir "$HOME/.deno" + curl -fsSL https://deno.land/install.sh | sh + profile_add 'export DENO_INSTALL="$HOME/.deno"' + profile_add 'export PATH="$DENO_INSTALL/bin:$PATH"' +} diff --git a/install/modules/discord.sh b/install/modules/discord.sh new file mode 100644 index 0000000..6ff406d --- /dev/null +++ b/install/modules/discord.sh @@ -0,0 +1,10 @@ +install_discord() { + if has_cmd discord; then ok "discord already installed"; return; fi + local deb + deb=$(mktemp --suffix=.deb) + curl -fsSL 'https://discord.com/api/download?platform=linux&format=deb' -o "$deb" + apt_update_once + sudo apt-get install -y "$deb" + rm -f "$deb" + rollback_push "sudo apt-get remove -y discord" +} diff --git a/install/modules/docker.sh b/install/modules/docker.sh new file mode 100644 index 0000000..867dabd --- /dev/null +++ b/install/modules/docker.sh @@ -0,0 +1,20 @@ +install_docker() { + if has_cmd docker; then ok "docker already installed"; return; fi + apt_install ca-certificates curl + + local codename + codename=$(ubuntu_codename) + if ! curl -fsSL -o /dev/null "https://download.docker.com/linux/ubuntu/dists/${codename}/Release"; then + warn "Docker has no repo for '${codename}' yet, falling back to 'noble' (24.04)" + codename=noble + fi + + apt_add_repo docker /etc/apt/keyrings/docker.asc \ + https://download.docker.com/linux/ubuntu/gpg \ + "deb [arch=$(deb_arch) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu ${codename} stable" + apt_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + + sudo usermod -aG docker "$USER" + rollback_push "sudo gpasswd -d '$USER' docker" + warn "log out/in (or run 'newgrp docker') for docker group membership to take effect" +} diff --git a/install/modules/firefox.sh b/install/modules/firefox.sh new file mode 100644 index 0000000..d8063e2 --- /dev/null +++ b/install/modules/firefox.sh @@ -0,0 +1,20 @@ +install_firefox() { + if [[ -f /etc/apt/preferences.d/mozilla ]]; then + ok "firefox (deb) already installed" + return + fi + + log "removing snap firefox / transitional apt stub in favor of Mozilla's official apt repo" + sudo apt-get remove -y firefox 2>/dev/null || true + sudo snap remove firefox 2>/dev/null || true + + apt_add_repo mozilla /etc/apt/keyrings/packages.mozilla.org.asc \ + https://packages.mozilla.org/apt/repo-signing-key.gpg \ + "deb [signed-by=/etc/apt/keyrings/packages.mozilla.org.asc] https://packages.mozilla.org/apt mozilla main" + + printf 'Package: *\nPin: origin packages.mozilla.org\nPin-Priority: 1000\n' \ + | sudo tee /etc/apt/preferences.d/mozilla >/dev/null + rollback_push "sudo rm -f /etc/apt/preferences.d/mozilla" + + apt_install firefox +} diff --git a/install/modules/ghcli.sh b/install/modules/ghcli.sh new file mode 100644 index 0000000..3a528c0 --- /dev/null +++ b/install/modules/ghcli.sh @@ -0,0 +1,7 @@ +install_ghcli() { + if has_cmd gh; then ok "gh already installed"; return; fi + apt_add_repo github-cli /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + "deb [arch=$(deb_arch) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" + apt_install gh +} diff --git a/install/modules/gnome.sh b/install/modules/gnome.sh new file mode 100644 index 0000000..bd16d0c --- /dev/null +++ b/install/modules/gnome.sh @@ -0,0 +1,15 @@ +install_gnome() { + if ! has_cmd gnome-shell; then + warn "gnome-shell not found; skipping (not running GNOME?)" + return + fi + + apt_install gnome-tweaks gnome-shell-extension-manager dconf-editor \ + gnome-shell-extension-appindicator + + if has_cmd gnome-extensions; then + gnome-extensions enable appindicatorsupport@rgcjonas.gmail.com 2>/dev/null || true + fi + + ok "log out/in for the AppIndicator extension to take full effect (tray icons for Discord/Slack/Signal etc.)" +} diff --git a/install/modules/go.sh b/install/modules/go.sh new file mode 100644 index 0000000..8190b29 --- /dev/null +++ b/install/modules/go.sh @@ -0,0 +1,34 @@ +install_go() { + local latest + latest=$(curl -fsSL 'https://go.dev/VERSION?m=text' | head -n1) + + if has_cmd go && go version | grep -q "$latest"; then + ok "go already at $latest" + return + fi + + local arch + case "$(uname -m)" in + x86_64) arch=amd64 ;; + aarch64) arch=arm64 ;; + *) err "unsupported arch $(uname -m)"; return 1 ;; + esac + + local tarball="/tmp/${latest}.linux-${arch}.tar.gz" + curl -fsSL "https://go.dev/dl/${latest}.linux-${arch}.tar.gz" -o "$tarball" + + local backup="" + if [[ -d /usr/local/go ]]; then + backup="/usr/local/go.bak.$$" + sudo mv /usr/local/go "$backup" + rollback_push "sudo rm -rf /usr/local/go; sudo mv '$backup' /usr/local/go" + else + rollback_push "sudo rm -rf /usr/local/go" + fi + + sudo tar -C /usr/local -xzf "$tarball" + rm -f "$tarball" + if [[ -n "$backup" ]]; then sudo rm -rf "$backup"; fi + + profile_add 'export PATH="/usr/local/go/bin:$HOME/go/bin:$PATH"' +} diff --git a/install/modules/jetbrains-toolbox.sh b/install/modules/jetbrains-toolbox.sh new file mode 100644 index 0000000..8101eb7 --- /dev/null +++ b/install/modules/jetbrains-toolbox.sh @@ -0,0 +1,24 @@ +install_jetbrains_toolbox() { + if [[ -x /opt/jetbrains-toolbox/jetbrains-toolbox ]]; then + ok "jetbrains toolbox already installed" + return + fi + + local url tarball extract_dir + url=$(curl -fsSL 'https://data.services.jetbrains.com/products/releases?code=TBA&latest=true&type=release' \ + | jq -r '.TBA[0].downloads.linux.link') + + tarball=$(mktemp --suffix=.tar.gz) + curl -fsSL "$url" -o "$tarball" + extract_dir=$(mktemp -d) + tar -xzf "$tarball" -C "$extract_dir" --strip-components=1 + rm -f "$tarball" + + sudo rm -rf /opt/jetbrains-toolbox + track_new_sudo_dir /opt/jetbrains-toolbox + sudo mv "$extract_dir" /opt/jetbrains-toolbox + sudo ln -sf /opt/jetbrains-toolbox/jetbrains-toolbox /usr/local/bin/jetbrains-toolbox + rollback_push "sudo rm -f /usr/local/bin/jetbrains-toolbox" + + ok "run 'jetbrains-toolbox' once to finish setup; it installs its own desktop entry" +} diff --git a/install/modules/mirrors.sh b/install/modules/mirrors.sh new file mode 100644 index 0000000..26f2ade --- /dev/null +++ b/install/modules/mirrors.sh @@ -0,0 +1,79 @@ +install_mirrors() { + local codename + codename=$(ubuntu_codename) + + local candidates + candidates=$(curl -fsSL --max-time 5 https://mirrors.ubuntu.com/mirrors.txt) || { + err "could not fetch the official Ubuntu mirror list" + return 1 + } + + log "benchmarking mirrors for speed + freshness (this takes a few seconds)" + + local best_url="" best_time="" url resp t last_modified age_hours checked=0 + while IFS= read -r url; do + if [[ -z "$url" || "$url" == \#* ]]; then continue; fi + if [[ $checked -ge 12 ]]; then break; fi + checked=$((checked + 1)) + + resp=$(curl -o /dev/null -sD - --max-time 3 -w '\n%{time_total}' "${url}dists/${codename}/Release" 2>/dev/null) || continue + if [[ -z "$resp" ]]; then continue; fi + + t=$(tail -n1 <<< "$resp") + if [[ -z "$t" ]]; then continue; fi + + last_modified=$(grep -i '^Last-Modified:' <<< "$resp" | head -n1 | cut -d' ' -f2- | tr -d '\r') + if [[ -n "$last_modified" ]]; then + age_hours=$(( ($(date +%s) - $(date -d "$last_modified" +%s 2>/dev/null || echo 0)) / 3600 )) + if [[ $age_hours -gt 72 ]]; then continue; fi + fi + + if [[ -z "$best_time" ]] || awk -v a="$t" -v b="$best_time" 'BEGIN{exit !(a/dev/null | grep -q '^pipenv '; then + ok "pipenv already installed" + return + fi + pipx install pipenv + rollback_push "pipx uninstall pipenv" +} diff --git a/install/modules/pipx.sh b/install/modules/pipx.sh new file mode 100644 index 0000000..08a3910 --- /dev/null +++ b/install/modules/pipx.sh @@ -0,0 +1,4 @@ +install_pipx() { + apt_install pipx + profile_add 'export PATH="$HOME/.local/bin:$PATH"' +} diff --git a/install/modules/pnpm.sh b/install/modules/pnpm.sh new file mode 100644 index 0000000..ecadb85 --- /dev/null +++ b/install/modules/pnpm.sh @@ -0,0 +1,7 @@ +install_pnpm() { + if has_cmd pnpm; then ok "pnpm already installed"; return; fi + track_new_dir "$HOME/.local/share/pnpm" + curl -fsSL https://get.pnpm.io/install.sh | sh - + profile_add 'export PNPM_HOME="$HOME/.local/share/pnpm"' + profile_add 'case ":$PATH:" in *":$PNPM_HOME:"*) ;; *) export PATH="$PNPM_HOME:$PATH" ;; esac' +} diff --git a/install/modules/poetry.sh b/install/modules/poetry.sh new file mode 100644 index 0000000..0bf5b84 --- /dev/null +++ b/install/modules/poetry.sh @@ -0,0 +1,7 @@ +install_poetry() { + if has_cmd poetry; then ok "poetry already installed"; return; fi + track_new_dir "$HOME/.local/share/pypoetry" + rollback_push "rm -f '$HOME/.local/bin/poetry'" + curl -sSL https://install.python-poetry.org | python3 - + profile_add 'export PATH="$HOME/.local/bin:$PATH"' +} diff --git a/install/modules/postman.sh b/install/modules/postman.sh new file mode 100644 index 0000000..2b6218e --- /dev/null +++ b/install/modules/postman.sh @@ -0,0 +1,22 @@ +install_postman() { + if [[ -x /opt/Postman/Postman ]]; then ok "postman already installed"; return; fi + + local tarball + tarball=$(mktemp --suffix=.tar.gz) + curl -fsSL https://dl.pstmn.io/download/latest/linux_64 -o "$tarball" + track_new_sudo_dir /opt/Postman + sudo tar -xzf "$tarball" -C /opt + rm -f "$tarball" + sudo ln -sf /opt/Postman/Postman /usr/local/bin/postman + rollback_push "sudo rm -f /usr/local/bin/postman /usr/share/applications/postman.desktop" + + sudo tee /usr/share/applications/postman.desktop >/dev/null <<'EOF' +[Desktop Entry] +Name=Postman +Exec=/opt/Postman/Postman %U +Icon=/opt/Postman/app/resources/app/assets/icon.png +Terminal=false +Type=Application +Categories=Development; +EOF +} diff --git a/install/modules/protonvpn.sh b/install/modules/protonvpn.sh new file mode 100644 index 0000000..9b06418 --- /dev/null +++ b/install/modules/protonvpn.sh @@ -0,0 +1,25 @@ +install_protonvpn() { + if dpkg -s proton-vpn-gnome-desktop >/dev/null 2>&1; then + ok "protonvpn already installed" + return + fi + + local list_url deb_name deb + list_url="https://repo.protonvpn.com/debian/dists/stable/main/binary-all/" + deb_name=$(curl -fsSL "$list_url" | grep -oE 'protonvpn-stable-release_[0-9.]+_all\.deb' | sort -V | tail -n1) + + if [[ -z "$deb_name" ]]; then + err "could not find the protonvpn-stable-release package on repo.protonvpn.com" + return 1 + fi + + deb=$(mktemp --suffix=.deb) + curl -fsSL "${list_url}${deb_name}" -o "$deb" + apt_update_once + sudo apt-get install -y "$deb" + rm -f "$deb" + rollback_push "sudo apt-get remove -y protonvpn-stable-release" + + apt_invalidate + apt_install proton-vpn-gnome-desktop +} diff --git a/install/modules/pyenv.sh b/install/modules/pyenv.sh new file mode 100644 index 0000000..84d3130 --- /dev/null +++ b/install/modules/pyenv.sh @@ -0,0 +1,16 @@ +install_pyenv() { + apt_install make build-essential libssl-dev zlib1g-dev libbz2-dev \ + libreadline-dev libsqlite3-dev wget curl llvm libncursesw5-dev \ + xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev + + if [[ -d "$HOME/.pyenv" ]]; then + ok "pyenv already installed" + else + track_new_dir "$HOME/.pyenv" + curl -fsSL https://pyenv.run | bash + fi + + profile_add 'export PYENV_ROOT="$HOME/.pyenv"' + profile_add 'export PATH="$PYENV_ROOT/bin:$PATH"' + profile_add 'eval "$(pyenv init - zsh)"' +} diff --git a/install/modules/rust.sh b/install/modules/rust.sh new file mode 100644 index 0000000..88d81c2 --- /dev/null +++ b/install/modules/rust.sh @@ -0,0 +1,7 @@ +install_rust() { + if has_cmd rustc; then ok "rust already installed"; return; fi + track_new_dir "$HOME/.cargo" + track_new_dir "$HOME/.rustup" + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path + profile_add '. "$HOME/.cargo/env"' +} diff --git a/install/modules/signal.sh b/install/modules/signal.sh new file mode 100644 index 0000000..4abd63f --- /dev/null +++ b/install/modules/signal.sh @@ -0,0 +1,7 @@ +install_signal() { + if has_cmd signal-desktop; then ok "signal already installed"; return; fi + apt_add_repo signal-xenial /usr/share/keyrings/signal-desktop-keyring.gpg \ + https://updates.signal.org/desktop/apt/keys.asc \ + "deb [arch=amd64 signed-by=/usr/share/keyrings/signal-desktop-keyring.gpg] https://updates.signal.org/desktop/apt xenial main" + apt_install signal-desktop +} diff --git a/install/modules/slack.sh b/install/modules/slack.sh new file mode 100644 index 0000000..f91fc7e --- /dev/null +++ b/install/modules/slack.sh @@ -0,0 +1,7 @@ +install_slack() { + if has_cmd slack; then ok "slack already installed"; return; fi + apt_add_repo slack /etc/apt/trusted.gpg.d/slack.gpg \ + https://packagecloud.io/slacktechnologies/slack/gpgkey \ + "deb https://packagecloud.io/slacktechnologies/slack/debian/ jessie main" + apt_install slack-desktop +} diff --git a/install/modules/spotify.sh b/install/modules/spotify.sh new file mode 100644 index 0000000..c3fe35e --- /dev/null +++ b/install/modules/spotify.sh @@ -0,0 +1,7 @@ +install_spotify() { + if has_cmd spotify; then ok "spotify already installed"; return; fi + apt_add_repo spotify /etc/apt/trusted.gpg.d/spotify.gpg \ + https://download.spotify.com/debian/pubkey_6224F9941A8AA6D1.gpg \ + "deb http://repository.spotify.com stable non-free" + apt_install spotify-client +} diff --git a/install/modules/telegram.sh b/install/modules/telegram.sh new file mode 100644 index 0000000..d4a5b38 --- /dev/null +++ b/install/modules/telegram.sh @@ -0,0 +1,23 @@ +install_telegram() { + if [[ -x /opt/Telegram/Telegram ]]; then ok "telegram already installed"; return; fi + + local tarball + tarball=$(mktemp --suffix=.tar.xz) + curl -fsSL https://telegram.org/dl/desktop/linux -o "$tarball" + track_new_sudo_dir /opt/Telegram + sudo tar -xJf "$tarball" -C /opt + rm -f "$tarball" + sudo ln -sf /opt/Telegram/Telegram /usr/local/bin/telegram + rollback_push "sudo rm -f /usr/local/bin/telegram /usr/share/applications/telegram.desktop" + + sudo tee /usr/share/applications/telegram.desktop >/dev/null <<'EOF' +[Desktop Entry] +Name=Telegram +Exec=/opt/Telegram/Telegram -- %u +Icon=/opt/Telegram/telegram.svg +Terminal=false +Type=Application +Categories=Network;InstantMessaging; +MimeType=x-scheme-handler/tg; +EOF +} diff --git a/install/modules/uv.sh b/install/modules/uv.sh new file mode 100644 index 0000000..f186658 --- /dev/null +++ b/install/modules/uv.sh @@ -0,0 +1,6 @@ +install_uv() { + if has_cmd uv; then ok "uv already installed"; return; fi + rollback_push "rm -f '$HOME/.local/bin/uv' '$HOME/.local/bin/uvx'" + curl -LsSf https://astral.sh/uv/install.sh | sh + profile_add 'export PATH="$HOME/.local/bin:$PATH"' +} diff --git a/install/modules/vscode.sh b/install/modules/vscode.sh new file mode 100644 index 0000000..900c2c3 --- /dev/null +++ b/install/modules/vscode.sh @@ -0,0 +1,54 @@ +install_vscode() { + if has_cmd code; then + ok "vscode already installed" + else + apt_add_repo vscode /etc/apt/keyrings/packages.microsoft.gpg \ + https://packages.microsoft.com/keys/microsoft.asc \ + "deb [arch=$(deb_arch) signed-by=/etc/apt/keyrings/packages.microsoft.gpg] https://packages.microsoft.com/repos/code stable main" + apt_install code + fi + + _vscode_install_extensions code + _vscode_mirror_cursor +} + +# Installs every extension id listed in vscode/extensions.txt (exported from +# this machine with `code --list-extensions`) into whichever editor binary +# is passed — reused for both `code` and `cursor` since Cursor is a VS Code +# fork that reads the same extension id format. +_vscode_install_extensions() { + local bin="$1" + local list="${DOTFILES_DIR:?}/vscode/extensions.txt" + has_cmd "$bin" || return 0 + [[ -f "$list" ]] || return 0 + + local installed + installed=$("$bin" --list-extensions 2>/dev/null || true) + + local ext + while IFS= read -r ext; do + if [[ -z "$ext" || "$ext" == \#* ]]; then continue; fi + if grep -qxF "$ext" <<< "$installed"; then continue; fi + log "$bin: installing extension $ext" + "$bin" --install-extension "$ext" --force >/dev/null 2>&1 || warn "$bin: failed to install $ext" + done < "$list" + return 0 +} + +# Cursor reads ~/.config/Cursor/User/settings.json the same way VS Code +# reads ~/.config/Code/User/settings.json. Symlinking it to the (stowed) +# Code settings keeps both in sync from one source of truth in this repo. +_vscode_mirror_cursor() { + local src="$HOME/.config/Code/User/settings.json" + local dest_dir="$HOME/.config/Cursor/User" + [[ -f "$src" ]] || return 0 + + mkdir -p "$dest_dir" + if [[ ! -e "$dest_dir/settings.json" ]]; then + ln -sf "$src" "$dest_dir/settings.json" + ok "mirrored VS Code settings to Cursor" + fi + + if has_cmd cursor; then _vscode_install_extensions cursor; fi + return 0 +} diff --git a/install/modules/wakatime.sh b/install/modules/wakatime.sh new file mode 100644 index 0000000..647196e --- /dev/null +++ b/install/modules/wakatime.sh @@ -0,0 +1,36 @@ +install_wakatime() { + local cfg="$HOME/.wakatime.cfg" + + if grep -q '^api_key' "$cfg" 2>/dev/null; then + ok "wakatime already configured in $cfg" + return + fi + + local key + read -rsp "Wakatime API key (leave blank to skip): " key + echo + if [[ -z "$key" ]]; then + warn "no key entered, skipping wakatime setup" + return + fi + + local fresh=0 + [[ -f "$cfg" ]] || fresh=1 + + if [[ -f "$cfg" ]]; then + grep -v '^api_key' "$cfg" > "${cfg}.tmp" 2>/dev/null || true + mv "${cfg}.tmp" "$cfg" + if grep -q '^\[settings\]' "$cfg"; then + sed -i "/^\[settings\]/a api_key = ${key}" "$cfg" + else + { echo "[settings]"; echo "api_key = ${key}"; cat "$cfg"; } > "${cfg}.tmp" + mv "${cfg}.tmp" "$cfg" + fi + else + printf '[settings]\napi_key = %s\n' "$key" > "$cfg" + fi + + chmod 600 "$cfg" + if [[ $fresh -eq 1 ]]; then rollback_push "rm -f '$cfg'"; fi + ok "wrote $cfg" +} diff --git a/install/modules/zig.sh b/install/modules/zig.sh new file mode 100644 index 0000000..d4ac4bb --- /dev/null +++ b/install/modules/zig.sh @@ -0,0 +1,65 @@ +install_zig() { + local index latest + index=$(curl -fsSL https://ziglang.org/download/index.json) + latest=$(jq -r 'keys[]' <<< "$index" | grep -v '^master$' | sort -V | tail -n1) + + if has_cmd zig && zig version 2>/dev/null | grep -qx "$latest"; then + ok "zig already at $latest" + else + local url tarball extract_dir + url=$(jq -r --arg v "$latest" '.[$v]["x86_64-linux"].tarball' <<< "$index") + tarball=$(mktemp --suffix=.tar.xz) + curl -fsSL "$url" -o "$tarball" + + extract_dir=$(mktemp -d) + tar -xJf "$tarball" -C "$extract_dir" --strip-components=1 + rm -f "$tarball" + + sudo rm -rf /opt/zig + track_new_sudo_dir /opt/zig + sudo mv "$extract_dir" /opt/zig + sudo ln -sf /opt/zig/zig /usr/local/bin/zig + rollback_push "sudo rm -f /usr/local/bin/zig" + fi + + _zig_install_zls +} + +# zls (the Zig language server) is versioned against a specific zig build; +# zigtools' own select-version API returns the right zls release for +# whatever zig version is currently installed. +_zig_install_zls() { + if ! has_cmd zig; then return 0; fi + + local zig_version resp url + zig_version=$(zig version) + resp=$(curl -fsSL "https://releases.zigtools.org/v1/zls/select-version?zig_version=${zig_version}&compatibility=only-runtime") + url=$(jq -r '.["x86_64-linux"].tarball // empty' <<< "$resp" 2>/dev/null) + + if [[ -z "$url" ]]; then + warn "no matching zls build for zig $zig_version, skipping" + return 0 + fi + + if [[ -x /opt/zls/zls ]]; then + local current + current=$(/opt/zls/zls --version 2>/dev/null || echo "") + if [[ "$current" == *"$zig_version"* ]]; then + ok "zls already matches zig $zig_version" + return 0 + fi + fi + + local tarball extract_dir + tarball=$(mktemp --suffix=.tar.xz) + curl -fsSL "$url" -o "$tarball" + extract_dir=$(mktemp -d) + tar -xJf "$tarball" -C "$extract_dir" + rm -f "$tarball" + + sudo rm -rf /opt/zls + track_new_sudo_dir /opt/zls + sudo mv "$extract_dir" /opt/zls + sudo ln -sf /opt/zls/zls /usr/local/bin/zls + rollback_push "sudo rm -f /usr/local/bin/zls" +} diff --git a/tmux/.tmux.conf b/tmux/.tmux.conf new file mode 100644 index 0000000..1332b40 --- /dev/null +++ b/tmux/.tmux.conf @@ -0,0 +1,7 @@ +set -g mouse on +set -g history-limit 10000 +set -g base-index 1 +setw -g pane-base-index 1 +setw -g mode-keys vi +set -g status-style bg=black,fg=white +set -g status-right '%Y-%m-%d %H:%M' diff --git a/vscode/.config/Code/User/settings.json b/vscode/.config/Code/User/settings.json new file mode 100644 index 0000000..6b7a23e --- /dev/null +++ b/vscode/.config/Code/User/settings.json @@ -0,0 +1,58 @@ +{ + "json.schemaDownload.trustedDomains": { + "https://biomejs.dev": true, + "https://developer.microsoft.com/json-schemas/": true, + "https://json-schema.org/": true, + "https://json.schemastore.org/": true, + "https://raw.githubusercontent.com/devcontainers/spec/": true, + "https://raw.githubusercontent.com/microsoft/vscode/": true, + "https://schemastore.azurewebsites.net/": true, + "https://turbo.build": true, + "https://turborepo.dev": true, + "https://ui.shadcn.com": true, + "https://www.schemastore.org/": true + }, + "claudeCode.preferredLocation": "panel", + "git.confirmSync": false, + "diffEditor.renderSideBySide": false, + "git.autofetch": true, + "git.enableSmartCommit": true, + "files.autoSave": "onFocusChange", + "editor.formatOnSave": true, + "gitlens.ai.model": "vscode", + "gitlens.ai.vscode.model": "copilot:gpt-4.1", + "security.allowedUNCHosts": [ + "wsl.localhost" + ], + "diffEditor.ignoreTrimWhitespace": true, + "redhat.telemetry.enabled": false, + "explorer.confirmDelete": false, + "chat.viewSessions.orientation": "stacked", + "snyk.securityAtInception.autoConfigureSnykMcpServer": true, + "snyk.securityAtInception.executionFrequency": "On Code Generation", + "snyk.yesWelcomeNotification": false, + "diffEditor.maxComputationTime": 0, + + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "workbench.colorTheme": "GitHub Dark", + "workbench.iconTheme": "material-icon-theme", + "workbench.productIconTheme": "material-product-icons", + "workbench.editor.enablePreview": false, + "cSpell.enabledNotifications": { + "Lines too Long": false, + "Average Word Length too Long": false, + "Maximum Word Length Exceeded": false + }, + "editor.fontFamily": "'Monaspace Argon NF','Droid Sans Mono', monospace", + "terminal.integrated.fontFamily": "'Monaspace Argon NF'", + "cSpell.userWords": [ + "Mikro" + ], + "editor.foldingImportsByDefault": true, + "js/ts.updateImportsOnFileMove.enabled": "always", + "editor.codeActionsOnSave": { + + } +} \ No newline at end of file diff --git a/vscode/extensions.txt b/vscode/extensions.txt new file mode 100644 index 0000000..1d1ed7a --- /dev/null +++ b/vscode/extensions.txt @@ -0,0 +1,77 @@ +ahmadalli.vscode-nginx-conf +alexcvzz.vscode-sqlite +angular.ng-template +anthropic.claude-code +biomejs.biome +bradlc.vscode-tailwindcss +catppuccin.catppuccin-vsc +charliermarsh.ruff +dart-code.dart-code +dart-code.flutter +davidanson.vscode-markdownlint +dbaeumer.vscode-eslint +donjayamanne.githistory +dsznajder.es7-react-js-snippets +eamodio.gitlens +esbenp.prettier-vscode +expo.vscode-expo-tools +github.github-vscode-theme +github.vscode-github-actions +github.vscode-pull-request-github +golang.go +llvm-vs-code-extensions.lldb-dap +mechatroner.rainbow-csv +mermaidchart.vscode-mermaid-chart +mhutchie.git-graph +ms-azuretools.vscode-containers +ms-azuretools.vscode-docker +ms-kubernetes-tools.vscode-kubernetes-tools +ms-python.black-formatter +ms-python.debugpy +ms-python.python +ms-python.vscode-pylance +ms-python.vscode-python-envs +ms-toolsai.jupyter +ms-toolsai.jupyter-keymap +ms-toolsai.jupyter-renderers +ms-toolsai.vscode-jupyter-cell-tags +ms-toolsai.vscode-jupyter-slideshow +ms-vscode-remote.remote-containers +ms-vscode.cmake-tools +ms-vscode.cpp-devtools +ms-vscode.cpptools +ms-vscode.cpptools-extension-pack +ms-vscode.cpptools-themes +ms-vscode.makefile-tools +ms-vscode.powershell +ms-vscode.vscode-chat-customizations-evaluations +njpwerner.autodocstring +njqdev.vscode-python-typehint +nuxtr.nuxtr-vscode +openai.chatgpt +phplasma.csv-to-table +pkief.material-icon-theme +pkief.material-product-icons +prisma.prisma +redhat.java +redhat.vscode-yaml +redis.redis-for-vscode +ritwickdey.liveserver +rust-lang.rust-analyzer +samuelcolvin.jinjahtml +shakram02.bash-beautify +snyk-security.snyk-vulnerability-scanner +streetsidesoftware.code-spell-checker +tamasfe.even-better-toml +tomoki1207.pdf +twxs.cmake +usernamehw.errorlens +vadimcn.vscode-lldb +vscjava.vscode-gradle +vscjava.vscode-java-debug +vscjava.vscode-java-dependency +vscjava.vscode-java-pack +vscjava.vscode-java-test +vscjava.vscode-maven +vue.volar +wakatime.vscode-wakatime diff --git a/zsh/.zsh_aliases b/zsh/.zsh_aliases new file mode 100644 index 0000000..a6f3a99 --- /dev/null +++ b/zsh/.zsh_aliases @@ -0,0 +1,9 @@ +alias ls='eza' +alias ll='eza -lah' +alias la='eza -a' +alias lt='eza --tree' +alias grep='rg' +alias gs='git status' +alias gd='git diff' +alias gco='git checkout' +alias dc='docker compose' diff --git a/zsh/.zshrc b/zsh/.zshrc new file mode 100644 index 0000000..8a3b3d8 --- /dev/null +++ b/zsh/.zshrc @@ -0,0 +1,15 @@ +HISTFILE="$HOME/.zsh_history" +HISTSIZE=50000 +SAVEHIST=50000 +setopt HIST_IGNORE_DUPS SHARE_HISTORY EXTENDED_HISTORY INC_APPEND_HISTORY + +autoload -Uz compinit && compinit + +export EDITOR="vim" +export PATH="$HOME/.local/bin:$PATH" + +[[ -f "$HOME/.zsh_aliases" ]] && source "$HOME/.zsh_aliases" + +PROMPT='%F{blue}%~%f %(?.%F{green}.%F{red})❯%f ' + +# --- devenv installers (managed by install/lib.sh profile_add, appended below) ---