diff --git a/.gitignore b/.gitignore index 813bc79..c0563e0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ gitconfig_local *.zwc *.swp *.pyc +version_cache.json5 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ff8c405 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,82 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a dotfiles management system for Linux environments using Nix and Home Manager for declarative configuration. It supports multiple host types: desktops, servers, and WSL instances. + +## Common Commands + +### Fresh Install (Primary Method) +```bash +./apply.sh --nix-host +# Example: ./apply.sh --nix-host personal-desktop +``` +Available hosts are defined in `nix/hosts.json`: personal-desktop, work-desktop, personal-wsl, work-wsl, personal-server, work-server. + +### Apply Nix Configuration Changes +```bash +home-manager switch --flake ~/dot/nix# +``` + +### Python CLI Tool +```bash +dot provision [TAGS] # Run provisioners +dot link # Create symlinks (legacy) +dot clean # Remove symlinks +dot tidy # Format Python code (black, isort, autoflake) +``` + +### Type Checking and Linting +```bash +make mypy # Run mypy on all Python files +``` + +### Theming +```bash +set-theme # Apply base16 color scheme (e.g., outrun-dark) +flavours update all # Required first time setup +``` + +## Architecture + +### Nix Configuration (`nix/`) +- `flake.nix` - Entry point; generates Home Manager configurations from `hosts.json` +- `hosts.json` - Defines hosts and their roles (core, desktop, gaming, wsl) +- `home/roles/` - Host-type configurations (Ansible-style naming): + - `core.nix` - Base packages (neovim, git, fzf, tmux, Python tools) + - `desktop.nix` - GUI packages (i3, kitty, rofi, mpd, media tools) + - `gaming.nix` - Gaming packages + - `wsl.nix` - WSL-specific configuration +- `home/lib/` - Shared modules imported by roles: + - `dotfiles-links.nix` - Maps config files from `config/` to home directory + - `python-environment.nix` - Builds unified Python environment +- `home/features/` - Specific feature modules (e.g., docker) that can be shared across roles + +### Configuration Files (`config/`) +Application configs that get linked to home directory via Home Manager: +- `bash/` - Shell configuration +- `nvim/` - Neovim configuration +- `i3`, `sway` - Window manager configs +- `kitty.conf`, `alacritty/`, `wezterm.lua` - Terminal emulators +- `flavours/` - Base16 theming (schemes in `schemes/custom/`, templates in `templates/custom/`) + +### Python CLI (`cli/`) +- `dot.py` - Main entry point with argparse +- `commands/` - Command implementations (provision, link, tidy, lint, etc.) +- `lib/provision/` - Provisioners for apt, pip, nodejs, docker, neovim, etc. +- `lib/common/` - Shared utilities (shell, git, logging, file operations) + +### Utility Scripts (`bin/`) +Standalone scripts: `set-theme`, `i3-util.sh`, `startup.sh`, `fuzzy-fm`, etc. + +## Key Files + +- `apply.sh` - Bootstrap script for fresh installs (installs Nix, applies Home Manager) +- `links.json` - Legacy dotfile symlink mappings (now mostly handled by `dotfiles-links.nix`) +- `nix/hosts.json` - Host definitions with roles; drives the entire Nix configuration + +## Code Style + +Python code uses black, isort, and autoflake. Run `dot tidy` before committing Python changes. Type hints are encouraged; validate with `make mypy`. diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..02b6aad --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,82 @@ +# GEMINI.md + +This file provides guidance to Gemini when working with code in this repository. + +## Project Overview + +This is a dotfiles management system for Linux environments using Nix and Home Manager for declarative configuration. It supports multiple host types: desktops, servers, and WSL instances. + +## Common Commands + +### Fresh Install (Primary Method) +```bash +./apply.sh --nix-host +# Example: ./apply.sh --nix-host personal-desktop +``` +Available hosts are defined in `nix/hosts.json`: personal-desktop, work-desktop, personal-wsl, work-wsl, personal-server, work-server. + +### Apply Nix Configuration Changes +```bash +home-manager switch --flake ~/dot/nix# +``` + +### Python CLI Tool +```bash +dot provision [TAGS] # Run provisioners +dot link # Create symlinks (legacy) +dot clean # Remove symlinks +dot tidy # Format Python code (black, isort, autoflake) +``` + +### Type Checking and Linting +```bash +make mypy # Run mypy on all Python files +``` + +### Theming +```bash +set-theme # Apply base16 color scheme (e.g., outrun-dark) +flavours update all # Required first time setup +``` + +## Architecture + +### Nix Configuration (`nix/`) +- `flake.nix` - Entry point; generates Home Manager configurations from `hosts.json` +- `hosts.json` - Defines hosts and their roles (core, desktop, gaming, wsl) +- `home/roles/` - Host-type configurations (Ansible-style naming): + - `core.nix` - Base packages (neovim, git, fzf, tmux, Python tools) + - `desktop.nix` - GUI packages (i3, kitty, rofi, mpd, media tools) + - `gaming.nix` - Gaming packages + - `wsl.nix` - WSL-specific configuration +- `home/lib/` - Shared modules imported by roles: + - `dotfiles-links.nix` - Maps config files from `config/` to home directory + - `python-environment.nix` - Builds unified Python environment +- `home/features/` - Specific feature modules (e.g., docker) that can be shared across roles + +### Configuration Files (`config/`) +Application configs that get linked to home directory via Home Manager: +- `bash/` - Shell configuration +- `nvim/` - Neovim configuration +- `i3`, `sway` - Window manager configs +- `kitty.conf`, `alacritty/`, `wezterm.lua` - Terminal emulators +- `flavours/` - Base16 theming (schemes in `schemes/custom/`, templates in `templates/custom/`) + +### Python CLI (`cli/`) +- `dot.py` - Main entry point with argparse +- `commands/` - Command implementations (provision, link, tidy, lint, etc.) +- `lib/provision/` - Provisioners for apt, pip, nodejs, docker, neovim, etc. +- `lib/common/` - Shared utilities (shell, git, logging, file operations) + +### Utility Scripts (`bin/`) +Standalone scripts: `set-theme`, `i3-util.sh`, `startup.sh`, `fuzzy-fm`, etc. + +## Key Files + +- `apply.sh` - Bootstrap script for fresh installs (installs Nix, applies Home Manager) +- `links.json` - Legacy dotfile symlink mappings (now mostly handled by `dotfiles-links.nix`) +- `nix/hosts.json` - Host definitions with roles; drives the entire Nix configuration + +## Code Style + +Python code uses black, isort, and autoflake. Run `dot tidy` before committing Python changes. Type hints are encouraged; validate with `make mypy`. diff --git a/Makefile b/Makefile index 1ba5f65..9824289 100644 --- a/Makefile +++ b/Makefile @@ -19,3 +19,10 @@ windows: mypy: find . -iname '*.py' | xargs mypy --config-file ./mypy.ini mypy --config-file ./mypy.ini ./bin/fzf_cached_wsl + +# Run nixfmt on all Nix files +.PHONY: nixfmt +nixfmt: + # Run this in a nix-shell so we can get the most up-to-date version of + # nixfmt since the --indent option was added fairly recently. + nix-shell -p nixfmt --run "find . -iname '*.nix' | xargs nixfmt --indent=4" diff --git a/README.md b/README.md index 9dca55e..aeb2c69 100644 --- a/README.md +++ b/README.md @@ -1,154 +1,18 @@ # Dotfiles -This repository contains my dotfiles! +This repository contains my dotfiles and uses Nix and Home Manager for +declarative configuration across my machines. -## Manual Setup +## Getting Started -A few things I haven't bothered to automate. +- **Ubuntu Setup Instructions:** + - [setup_ubuntu.md](./doc/setup_ubuntu.md) +- **Windows Setup Instructions:** + - [setup_windows.md](./doc/setup_windows.md) -### Git +## Theme -Create `~/.gitconfig_local` like: +For details on how theming is set up and how to modify a theme or change the +current theme, see: -``` -[user] - email = paul@foo.com - name = Paul Ewing -``` - -### Wallpaper Rotater - -If using `wpr`, create `~/.config/wpr/config.json` like: - -```json -{ - "WallpaperDir": "/home/username/Pictures/Wallpapers", - "DisplayCount": 1, - "Interval":120 -} -``` - -### Dual Boot Clock Fix - -If dual booting with Windows, set hardware clock to local time: - -```bash -timedatectl set-local-rtc 1 -``` - -Without this, clock time in Windows will be off. - -### Applications to Manually Install - -The following aren't in apt and need to be installed manually: - -- Chrome -- Beyond Compare -- Insync -- Discord -- RuneLite - -Alacritty is not yet in the default Ubuntu apt repositories: - -```bash -sudo add-apt-repository ppa:mmstick76/alacritty -sudo apt update -``` - -## Theming - -To make it easier to re-theme everything at once, I use -[base16](https://github.com/chriskempson/base16) and -[flavours](https://github.com/Misterio77/flavours). See: - -The tl;dr of `base16` is that it is a system for designing color schemes. -`base16` schemes consists of a palette of 16 colors - 8 shades and 8 accents. -Templates can then be created to render the base16 scheme into various config -formats for different applications. - -Due to some [turbulence](https://github.com/tinted-theming/home/issues/51) in -the `base16` project, I've added my most used schemes directly to my dotfiles -to avoid things breaking if repositories are ever moved or taken down. I've -also created my own templates rather than using the defaults. - -- [schemes](./config/flavours/schemes/custom) -- [templates](./config/flavours/templates/custom/templates) - -Using the `flavours` application, these templates are rendered directly into my -dotfiles based on the `flavours` config: - -- [flavours/config.toml](./config/flavours/config.toml) - -To apply a new color scheme, download and install -[flavours](https://github.com/Misterio77/flavours/releases/latest). - -The first time running, update sources. Even if using schemes/templates -committed to my dotfiles, this still appears to be necessary: - -```bash -flavours update all -``` - -**Note:** We should add flavours installation to the provision script. - -Once flavours is installed, set the theme using the -[set-theme](./bin/set-theme) script. This not only executes `flavours` but also -reloads config across various applications to smoothly transition themes. - -```bash -set-theme -``` - -The name should match the corresponding base16 scheme yaml file without the -extension. For example: - -```bash -flavours apply outrun-dark -``` - -The official lists of templates and schemes supported by flavours live here: - -- https://github.com/chriskempson/base16-schemes-source/blob/main/list.yaml -- https://github.com/chriskempson/base16-templates-source/blob/master/list.yaml - -Manual steps after changing themes: - -- Reload tmux config - - `:source-file ~/.tmux.conf` - - We should figure out how to automate this - -### TODO - -Some remaining items to tackle in regards to theming: -- Add templates for - - sway - -## Windows 10 - -For setup steps on Windows 10, see: - -[windows_setup.md](./windows_setup.md) - -### WSL - -Some useful things to add to `.localrc` in WSL. - -Remove the background highlighting of folders in ls: - -```bash -LS_COLORS=$LS_COLORS:'ow=1;34:' ; export LS_COLORS -``` - -WezTerm shell integration; this adds some useful features like having new tabs -open in the same directory as the previous: -``` -if [ "$TERM_PROGRAM" = "WezTerm" ] && [ -f "$HOME/wezterm.sh" ]; then - source "$HOME/wezterm.sh" -fi -``` - -For now just manually create and copy the `wezterm.sh` file from here: - -https://raw.githubusercontent.com/wez/wezterm/main/assets/shell-integration/wezterm.sh - -We could make this a bit nicer by automatically downloading it. +[theme.md](./doc/theme.md) diff --git a/apply.sh b/apply.sh new file mode 100755 index 0000000..7969752 --- /dev/null +++ b/apply.sh @@ -0,0 +1,667 @@ +#!/usr/bin/env bash + +set -euo pipefail + +#============================================================================== +# Utilities +#============================================================================== + +# Print a message to stderr. +# +# $*: The message to print. +yell() { >&2 echo "$*"; } + +# Print an error message to stderr and exit. +# +# $*: The error message to print. +die() { yell "ERROR: $*"; exit 1; } + +# Execute a command, and exit if it fails. +# +# $*: The command to execute. +try() { "$@" || die "Command failed: $*"; } + +# Checks if a command is installed and on the PATH. +# +# $1: The command name to check. +# Returns 0 if the command is installed, 1 otherwise. +is_cmd_installed() { + command -v "$1" >/dev/null 2>&1 || return 1 +} + +# Checks if the script is running in a WSL environment. +# +# Returns 0 if in WSL, 1 otherwise. +is_wsl() { + [ -n "${WSL_DISTRO_NAME-}" ] && return 0 || return 1 +} + +#============================================================================== +# Configuration +#============================================================================== +DOTFILES_DIR_DEFAULT="$HOME/dot" +STATE_FILE="$HOME/.local/state/dotfiles/apply.json" +APT_UPDATE_MAX_AGE_SECONDS=86400 # 24 hours + +# Prints the script usage information. +usage() { + cat </dev/null 2>&1; then + jq -r '.apt_last_update // empty' "$STATE_FILE" 2>/dev/null || echo "" + else + # Fallback: extract with grep/sed (works for simple JSON) + grep -o '"apt_last_update":[0-9]*' "$STATE_FILE" 2>/dev/null | sed 's/.*://' || echo "" + fi +} + +# Writes the current timestamp for apt update to the state file. +set_apt_last_update() { + local timestamp + timestamp="$(date +%s)" + + mkdir -p "$(dirname "$STATE_FILE")" + + if command -v jq >/dev/null 2>&1; then + # Use jq to update/create the JSON file + if [[ -f "$STATE_FILE" ]]; then + local tmp + tmp="$(mktemp)" + jq ".apt_last_update = $timestamp" "$STATE_FILE" > "$tmp" && mv "$tmp" "$STATE_FILE" + else + echo "{\"apt_last_update\": $timestamp}" > "$STATE_FILE" + fi + else + # Simple fallback without jq, will clobber other fields + echo "{\"apt_last_update\": $timestamp}" > "$STATE_FILE" + fi +} + +# Checks if the apt update cache is stale. +# +# Returns 0 (true) if stale or missing, 1 (false) if fresh. +is_apt_update_stale() { + local last_update + last_update="$(get_apt_last_update)" + + if [[ -z "$last_update" ]]; then + return 0 # No record, consider stale + fi + + local now age + now="$(date +%s)" + age=$((now - last_update)) + + if [[ $age -ge $APT_UPDATE_MAX_AGE_SECONDS ]]; then + return 0 # Stale + else + return 1 # Fresh + fi +} + +# Runs apt-get update only if the cache is stale. +apt_update_if_stale() { + if is_apt_update_stale; then + local last_update age_hours + last_update="$(get_apt_last_update)" + if [[ -n "$last_update" ]]; then + age_hours=$(( ($(date +%s) - last_update) / 3600 )) + echo "[bootstrap] apt cache is stale (${age_hours}h old), running apt-get update..." + else + echo "[bootstrap] No apt update timestamp found, running apt-get update..." + fi + try sudo apt-get update -y + set_apt_last_update + else + local last_update age_hours + last_update="$(get_apt_last_update)" + age_hours=$(( ($(date +%s) - last_update) / 3600 )) + echo "[bootstrap] apt cache is fresh (${age_hours}h old), skipping apt-get update" + fi +} + +# Runs apt-get update unconditionally and updates the timestamp. +# Used when adding new repositories (e.g., Docker). +apt_update_always() { + echo "[bootstrap] Running apt-get update (forced)..." + try sudo apt-get update -y + set_apt_last_update +} + +#============================================================================== +# APT Upgrade Caching +#============================================================================== + +# Gets the last apt upgrade timestamp from the state file. +# +# Returns an empty string if not found. +get_apt_last_upgrade() { + if [[ ! -f "$STATE_FILE" ]]; then + echo "" + return + fi + if command -v jq >/dev/null 2>&1; then + jq -r '.apt_last_upgrade // empty' "$STATE_FILE" 2>/dev/null || echo "" + else + grep -o '"apt_last_upgrade":[0-9]*' "$STATE_FILE" 2>/dev/null | sed 's/.*://' || echo "" + fi +} + +# Writes the current timestamp for apt upgrade to the state file. +set_apt_last_upgrade() { + local timestamp + timestamp="$(date +%s)" + + mkdir -p "$(dirname "$STATE_FILE")" + + if command -v jq >/dev/null 2>&1; then + if [[ -f "$STATE_FILE" ]]; then + local tmp + tmp="$(mktemp)" + jq ".apt_last_upgrade = $timestamp" "$STATE_FILE" > "$tmp" && mv "$tmp" "$STATE_FILE" + else + echo "{\"apt_last_upgrade\": $timestamp}" > "$STATE_FILE" + fi + else + # Without jq, we need to be careful not to clobber apt_last_update + # Best effort: just write upgrade timestamp (update will fix it next time with jq) + if [[ -f "$STATE_FILE" ]]; then + # Try to preserve existing content + local update_ts + update_ts="$(get_apt_last_update)" + if [[ -n "$update_ts" ]]; then + echo "{\"apt_last_update\": $update_ts, \"apt_last_upgrade\": $timestamp}" > "$STATE_FILE" + else + echo "{\"apt_last_upgrade\": $timestamp}" > "$STATE_FILE" + fi + else + echo "{\"apt_last_upgrade\": $timestamp}" > "$STATE_FILE" + fi + fi +} + +# Checks if the apt upgrade is stale. +is_apt_upgrade_stale() { + local last_upgrade + last_upgrade="$(get_apt_last_upgrade)" + + if [[ -z "$last_upgrade" ]]; then + return 0 # No record, consider stale + fi + + local now age + now="$(date +%s)" + age=$((now - last_upgrade)) + + if [[ $age -ge $APT_UPDATE_MAX_AGE_SECONDS ]]; then + return 0 # Stale + else + return 1 # Fresh + fi +} + +# Runs apt-get dist-upgrade only if the cache is stale. +apt_upgrade_if_stale() { + if is_apt_upgrade_stale; then + local last_upgrade age_hours + last_upgrade="$(get_apt_last_upgrade)" + if [[ -n "$last_upgrade" ]]; then + age_hours=$(( ($(date +%s) - last_upgrade) / 3600 )) + echo "[bootstrap] apt upgrade is stale (${age_hours}h old), running apt-get dist-upgrade..." + else + echo "[bootstrap] No apt upgrade timestamp found, running apt-get dist-upgrade..." + fi + try sudo apt-get dist-upgrade -y + set_apt_last_upgrade + else + local last_upgrade age_hours + last_upgrade="$(get_apt_last_upgrade)" + age_hours=$(( ($(date +%s) - last_upgrade) / 3600 )) + echo "[bootstrap] apt upgrade is fresh (${age_hours}h old), skipping apt-get dist-upgrade" + fi +} + +#============================================================================== +# APT Install Caching +#============================================================================== + +# Get the hash of installed apt packages from the state file. +# Assumes jq is available. +get_apt_pkgs_hash() { + if [[ ! -f "$STATE_FILE" ]]; then + echo "" + return + fi + jq -r '.apt_pkgs_hash // empty' "$STATE_FILE" 2>/dev/null || echo "" +} + +# Calculate and write the hash of installed apt packages to the state file. +# +# Assumes jq is available. +# $*: The list of packages to hash. +set_apt_pkgs_hash() { + local pkgs_hash + pkgs_hash=$(printf "%s\n" "$@" | sort | sha256sum | awk '{print $1}') + + mkdir -p "$(dirname "$STATE_FILE")" + + local tmp + tmp="$(mktemp)" + if [[ -f "$STATE_FILE" ]] && [[ -s "$STATE_FILE" ]]; then + jq ".apt_pkgs_hash = \"$pkgs_hash\"" "$STATE_FILE" > "$tmp" && mv "$tmp" "$STATE_FILE" + else + echo "{\"apt_pkgs_hash\": \"$pkgs_hash\"}" > "$STATE_FILE" + fi +} + +#============================================================================== +# Host & Role Helpers +#============================================================================== + +# Lists the available Nix hosts from the hosts.json file. +nix_hosts() { + jq -r '.hosts | keys[]' "$DOTFILES_DIR/nix/hosts.json" +} + +# Checks if the current NIX_HOST has a specific role. +# +# $1: The role to check for. +# Returns 0 if the host has the role, 1 otherwise. +host_has_role() { + local role="$1" + if [ -z "$NIX_HOST" ] || [ ! -f "$DOTFILES_DIR/nix/hosts.json" ]; then + return 1 + fi + jq -e ".hosts[\"$NIX_HOST\"].roles | index(\"$role\")" \ + "$DOTFILES_DIR/nix/hosts.json" >/dev/null 2>&1 +} + +#============================================================================== +# Installation Functions +#============================================================================== + +# Installs Docker if it's not already installed and the 'core' role is active. +install_docker() { + # TODO: Eventually we're going to move this from core to its own role and + # when we do we'll need to update this condition + if ! host_has_role "core"; then + echo "[bootstrap] (install_docker) core role not enabled, skipping docker install" + return 0 + fi + + if command -v docker >/dev/null 2>&1; then + echo "[bootstrap] (install_docker) Docker is already installed, skipping docker install" + return 0 + fi + + # Add Docker's official GPG key: + try sudo install -m 0755 -d /etc/apt/keyrings + try sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc + try sudo chmod a+r /etc/apt/keyrings/docker.asc + + # Add the repository to Apt sources: + try sudo tee /etc/apt/sources.list.d/docker.sources </dev/null 2>&1 + try sudo usermod -aG docker $USER + + DOCKER_INSTALLED="1" +} + +# Sources the local, non-version-controlled rc file if it exists. +source_localrc() { + local localrc_path="$HOME/.localrc" + + if [ -f "$localrc_path" ]; then + echo "[bootstrap] (source_localrc) Sourcing localrc from path $localrc_path" + source "$HOME/.localrc" + else + echo "[bootstrap] (source_localrc) Localrc path '$localrc_path' doesn't exist; skipping." + fi +} + +# Validates that the NIX_HOST variable is set and is a valid host. +validate_nix_host() { + if [ -z "$NIX_HOST" ]; then + yell "[bootstrap] (validate_nix_host) Error: NIX_HOST is not set." + yell "Either specify the --nix-host argument or export this variable in your ~/.localrc file. Available hosts:" + yell "$(nix_hosts)" + yell "See $DOTFILES_DIR/nix/hosts.json for details." + exit 1 + fi + + if [ ! -f "$DOTFILES_DIR/nix/hosts.json" ]; then + die "hosts.json not found at $DOTFILES_DIR/nix/hosts.json" + fi + + if ! jq -e ".hosts[\"$NIX_HOST\"]" "$DOTFILES_DIR/nix/hosts.json" >/dev/null 2>&1; then + yell "NIX_HOST '$NIX_HOST' is not valid. Available hosts:" + yell "$(nix_hosts)" + yell "See $DOTFILES_DIR/nix/hosts.json for details." + exit 1 + fi +} + +# Installs minimal prerequisite packages using apt. +apt_bootstrap() { + if [[ ! "$DO_APT" -eq 1 ]]; then + echo "[bootstrap] (apt_bootstrap) Skipping apt steps because --no-apt was specified..." + return + fi + + echo "[bootstrap] Installing minimal prerequisites via apt..." + + # You can expand this later, but keep it small. + local pkgs=( + apt-file + ca-certificates + curl + git + jq + libfuse3-3 + locate + software-properties-common + xz-utils + ) + + # Add role-specific packages based on host configuration + if host_has_role "desktop"; then + pkgs+=( + i3 + i3status + kitty + ) + fi + + apt_update_if_stale + + if [[ "$DO_UPGRADE" -eq 1 ]]; then + apt_upgrade_if_stale + fi + + # Check if packages are already installed by comparing hashes + local current_pkgs_hash + current_pkgs_hash=$(printf "%s\n" "${pkgs[@]}" | sort | sha256sum | awk '{print $1}') + + local installed_pkgs_hash + installed_pkgs_hash=$(get_apt_pkgs_hash) + + if [[ "$current_pkgs_hash" == "$installed_pkgs_hash" ]]; then + echo "[bootstrap] apt packages are already up-to-date, skipping install." + else + echo "[bootstrap] New or changed apt packages detected, running install..." + try sudo apt-get install -y "${pkgs[@]}" + set_apt_pkgs_hash "${pkgs[@]}" + fi +} + +# Installs Nix if it's not already installed. +install_nix_if_needed() { + if is_cmd_installed nix; then + echo "[bootstrap] nix already installed." + return 0 + fi + + echo "[bootstrap] Installing Nix (single-user)..." + # Standard installer; can be customized later if needed. + try sh -c 'curl -L https://nixos.org/nix/install | sh -s -- --no-daemon' +} + +# Sources the Nix profile to make `nix` available in the current shell. +source_nix_profile() { + # Make nix available in the current shell, even right after install. + if is_cmd_installed nix; then + return 0 + fi + + # Common install locations for single-user Nix. + if [[ -f "$HOME/.nix-profile/etc/profile.d/nix.sh" ]]; then + # shellcheck disable=SC1090 + source "$HOME/.nix-profile/etc/profile.d/nix.sh" + elif [[ -f "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh" ]]; then + # Multi-user install path + # shellcheck disable=SC1091 + source "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh" + fi + + is_cmd_installed nix || die "nix still not on PATH after sourcing profile." +} + +# Enables Nix experimental features (nix-command and flakes). +enable_nix_experimental() { + # Home Manager via flakes is the nicest iteration experience. + # This just ensures nix can use flakes/commands on fresh installs. + mkdir -p "$HOME/.config/nix" + local conf="$HOME/.config/nix/nix.conf" + + if [[ ! -f "$conf" ]] || ! grep -q "experimental-features" "$conf"; then + echo "[bootstrap] Enabling nix-command + flakes in $conf" + { + echo "experimental-features = nix-command flakes" + } >> "$conf" + fi +} + +# Applies the Home Manager configuration for the current host. +apply_home_manager() { + echo "[bootstrap] Applying Home Manager target: $NIX_HOST" + + # Assumes your repo contains a flake with homeConfigurations. + try nix --extra-experimental-features "nix-command flakes" \ + run "github:nix-community/home-manager" -- \ + switch -b hm-bak --flake "$DOTFILES_DIR/nix#$NIX_HOST" +} + +# Sets the default terminal and editor using update-alternatives. +set_default_terminal_and_editor() { + echo "[bootstrap] Setting system defaults via update-alternatives..." + + if host_has_role "desktop"; then + local kitty_path + kitty_path="$(command -v kitty || true)" + + if [[ -n "$kitty_path" ]]; then + try sudo update-alternatives --install /usr/bin/x-terminal-emulator x-terminal-emulator "$kitty_path" 50 + try sudo update-alternatives --set x-terminal-emulator "$kitty_path" + else + yell "[bootstrap] kitty not found on PATH; skipping terminal alternative" + fi + else + echo "[bootstrap] (set_default_terminal_and_editor) Desktop role not enabled, skipping setting default terminal emulator" + fi + + local nvim_path + nvim_path="$(command -v nvim || true)" + + if [[ -n "$nvim_path" ]]; then + try sudo update-alternatives --install /usr/bin/vi vi "$nvim_path" 60 + try sudo update-alternatives --set vi "$nvim_path" + try sudo update-alternatives --install /usr/bin/vim vim "$nvim_path" 60 + try sudo update-alternatives --set vim "$nvim_path" + try sudo update-alternatives --install /usr/bin/editor editor "$nvim_path" 60 + try sudo update-alternatives --set editor "$nvim_path" + else + yell "[bootstrap] nvim not found on PATH; skipping editor alternatives" + fi +} + +# Installs X11 and Wayland session files for graphical login managers. +install_session_desktop_files() { + echo "[bootstrap] Installing session desktop files..." + + local dot="$DOTFILES_DIR" + local xs_src="$dot/config/xsession.desktop" + local xs_dst="/usr/share/xsessions/xsession.desktop" + + local sway_src="$dot/config/sway-user.desktop" + local sway_dst="/usr/share/wayland-sessions/sway-user.desktop" + + + if ! host_has_role "desktop"; then + echo "[bootstrap] (install_session_desktop_files) Desktop role not enabled, skipping installing session desktop files" + return 0 + fi + + if [[ -f "$xs_src" ]]; then + try sudo install -m 0644 "$xs_src" "$xs_dst" + else + yell "[bootstrap] Missing $xs_src; skipping xsession.desktop" + fi + + if [[ -f "$sway_src" ]]; then + try sudo install -m 0644 "$sway_src" "$sway_dst" + else + yell "[bootstrap] Missing $sway_src; skipping sway-user.desktop" + fi +} + +#============================================================================== +# System Setup Caching +#============================================================================== + +# Checks if the one-time system setup tasks have been completed. +# +# Assumes jq is available. +# Returns 0 if setup is done, 1 otherwise. +is_system_setup_done() { + if [[ ! -f "$STATE_FILE" ]]; then + return 1 + fi + local setup_done + setup_done=$(jq -r '.system_setup_done // false' "$STATE_FILE") + if [[ "$setup_done" == "true" ]]; then + return 0 + else + return 1 + fi +} + +# Marks the one-time system setup tasks as completed in the state file. +# +# Assumes jq is available. +set_system_setup_done() { + mkdir -p "$(dirname "$STATE_FILE")" + local tmp + tmp="$(mktemp)" + if [[ -f "$STATE_FILE" ]] && [[ -s "$STATE_FILE" ]]; then + jq ".system_setup_done = true" "$STATE_FILE" > "$tmp" && mv "$tmp" "$STATE_FILE" + else + echo '{"system_setup_done": true}' > "$STATE_FILE" + fi +} + +# Runs the system-level setup tasks that require sudo, but only if they +# haven't been completed before. +run_sudo_setup_tasks_if_needed() { + if is_system_setup_done; then + echo "[bootstrap] System-level setup already completed, skipping." + return + fi + + echo "[bootstrap] Running system-level setup tasks..." + set_default_terminal_and_editor + install_session_desktop_files + set_system_setup_done + echo "[bootstrap] System-level setup tasks complete." +} + +#============================================================================== +# Main Execution +#============================================================================== +while [[ $# -gt 0 ]]; do + case "$1" in + --dir) DOTFILES_DIR="$2"; shift 2;; + --nix-host) NIX_HOST="$2"; shift 2;; + --no-upgrade) DO_UPGRADE=0; shift;; + --no-apt) DO_APT=0; shift;; + --reset-state) RESET_STATE=1; shift;; + -h|--help) usage; exit 0;; + *) die "Unknown argument: $1";; + esac +done + +main() { + if [[ "$RESET_STATE" -eq 1 ]]; then + echo "[bootstrap] Resetting state file at $STATE_FILE..." + rm -f "$STATE_FILE" + fi + + echo "[bootstrap] Starting on: $(lsb_release -ds 2>/dev/null || uname -a)" + if is_wsl; then + echo "[bootstrap] Detected WSL environment." + fi + + # We need jq for this script to run at all so if it's not installed, get it + if ! command -v jq >/dev/null 2>&1; then + echo "[bootstrap] jq not found, installing..." + apt_update_if_stale + try sudo apt-get install -y jq + fi + + source_localrc + validate_nix_host + apt_bootstrap + install_nix_if_needed + source_nix_profile + enable_nix_experimental + apply_home_manager + run_sudo_setup_tasks_if_needed + + install_docker + + echo "[bootstrap] Done." + + if [[ "$DOCKER_INSTALLED" -eq 1 ]]; then + echo "[bootstrap] Docker was just installed. Either reboot or log out and back in for the group change to take effect." + fi +} + +main "$@" diff --git a/bin/cleanup_logs.sh b/bin/cleanup_logs.sh index 3d3f431..f3088fb 100755 --- a/bin/cleanup_logs.sh +++ b/bin/cleanup_logs.sh @@ -2,6 +2,7 @@ # Delete any log files that haven't been touched in 14 days days="14" -logdir="$HOME/.logs" -[ -d "$logdir" ] && find "$logdir" -ctime +$days -type f -exec rm {} \; +DOTFILES_LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dotfiles/logs" + +[ -d "$DOTFILES_LOG_DIR" ] && find "$DOTFILES_LOG_DIR" -ctime +$days -type f -exec rm {} \; \ No newline at end of file diff --git a/bin/docker_clean.sh b/bin/docker_clean.sh index b251cae..22d69d5 100755 --- a/bin/docker_clean.sh +++ b/bin/docker_clean.sh @@ -1,20 +1,17 @@ #!/usr/bin/env bash -function destroy_containers() { - containers="$(docker ps -a \ - | grep -Ev '^CONTAINER' \ - | awk '{print $1}')" +# IMPROVEMENT: Use docker's built-in filtering with -q flag instead of grep/awk parsing. +# This is more reliable as it avoids issues with column alignment or format changes. +function destroy_containers() { + containers="$(docker ps -aq)" if [ -n "$containers" ]; then echo "$containers" | xargs docker rm -f fi } function destroy_images() { - images="$(docker images \ - | grep -Ev '^REPOSITORY' \ - | awk '{print $3}')" - + images="$(docker images -q)" if [ -n "$images" ]; then echo "$images" | xargs docker rmi fi diff --git a/bin/fzf_cached_wsl b/bin/fzf_cached_wsl index 9d8f40a..d1359f5 100755 --- a/bin/fzf_cached_wsl +++ b/bin/fzf_cached_wsl @@ -85,7 +85,21 @@ def parse_args(): class FuzzyFileFinder: @staticmethod def find_files(directory, on_file): - ignore_patterns = [] + # TODO: It would be cool to support .fzfignore files in sub-directories + # but that gets a little more complicated because we'd need to layer + # them on top of each other while recursively descending and then + # remove them as we traverse back up. Definitely possible but just + # requires more effort than I want to put in right now. + + # TODO: I don't think these need to be separated, we can probably just + # have a single list of ignore patterns that match on either files or + # directories. There may be times where we want a pattern to only match + # against one or the other but I can't think of any specific examples + # right now. It is kind of nice in the .fzfignore file to see which + # patterns are expected to match against directories though. Anyways, + # leaving them separate for now but can revisit later. + file_ignore_patterns = [] + dir_ignore_patterns = [] ignore_file = os.path.join(directory, ".fzfignore") if os.path.isfile(ignore_file): ignore_file_content = None @@ -98,26 +112,43 @@ class FuzzyFileFinder: ) ) for line in lines: - if line.startswith("#"): + if line.lstrip().startswith("#") or line.strip() == "": continue - ignore_patterns.append(line) + elif line.lower().startswith("d "): + dir_ignore_patterns.append(line[2:]) + else: + file_ignore_patterns.append(line) def handle_dir( directory: FileWalker.Directory, ) -> Optional[FileWalker.DirectoryHandlerResult]: - if directory.get_name() == ".git": + name = directory.get_name() + if name == ".git": return FileWalker.DirectoryHandlerResult(skip=True) + for ignore_pattern in dir_ignore_patterns: + path_rel = directory.get_relative_path() + m = re.match(ignore_pattern, path_rel) + if m is not None: + # Keep this commented out for performance except when debugging + # Log.debug("ignoring directory", {"ignore_pattern": ignore_pattern, "dir": path_rel}) + return FileWalker.DirectoryHandlerResult(skip=True) def handle_file( file: FileWalker.File, ) -> Optional[FileWalker.FileHandlerResult]: name = file.get_name() - for ignore_pattern in ignore_patterns: - m = re.match(ignore_pattern, file.get_relative_path()) - if m is not None: - # Keep this commented out for performance except when debugging - # Log.debug("ignoring file", [("ignore_pattern", ignore_pattern), ("file", file.get_relative_path())]) - return + for ignore_pattern in file_ignore_patterns: + path_rel = file.get_relative_path() + try: + m = re.match(ignore_pattern, path_rel) + if m is not None: + # Keep this commented out for performance except when debugging + # Log.debug("ignoring file", {"ignore_pattern": ignore_pattern, "file": path_rel}) + return + # IMPROVEMENT: Use specific exception type instead of bare except + except re.error: + print("ignore_pattern = {}, path_rel = {}".format(ignore_pattern, path_rel)) + raise on_file(file.get_relative_path()) @@ -138,17 +169,17 @@ class Cache: self._should_tidy = tidy self._cache_id = Cache._get_cache_id(directory) - Log.info("cache id determined", [("cache_id", self._cache_id)]) + Log.info("cache id determined", {"cache_id": self._cache_id}) self._existing_cache_files = self._get_existing_cache_files( self._tmp_directory, self._cache_id ) Log.info( "scanned existing cache files", - [ - ("count", len(self._existing_cache_files)), - ("files", self._existing_cache_files), - ], + { + "count": len(self._existing_cache_files), + "files": self._existing_cache_files, + }, ) self._cached_files = set() @@ -186,7 +217,7 @@ class Cache: def _tidy(self) -> None: for cache_file in self._existing_cache_files: - Log.info("removing old cache file", [("cache_file", cache_file)]) + Log.info("removing old cache file", {"cache_file": cache_file}) try: os.remove(cache_file) except FileNotFoundError: @@ -198,15 +229,15 @@ class Cache: # return None. def _load(self) -> set[str]: if not self._use_existing: - Log.info("skipping cache load", [("reason", "--no-cache flag specified")]) + Log.info("skipping cache load", {"reason": "--no-cache flag specified"}) return set() if len(self._existing_cache_files) == 0: - Log.info("skipping cache load", [("reason", "cache file does not exist")]) + Log.info("skipping cache load", {"reason": "cache file does not exist"}) return set() cache_file = self._existing_cache_files[0] - Log.info("loading cache from file", [("cache_file", cache_file)]) + Log.info("loading cache from file", {"cache_file": cache_file}) cache_file_content = None with open(cache_file, "r") as f: @@ -240,7 +271,7 @@ class Cache: def clean(tmp_dir: str): try: - Log.info("removing existing tmp directory", [("tmp_dir", tmp_dir)]) + Log.info("removing existing tmp directory", {"tmp_dir": tmp_dir}) Util.rmdir(tmp_dir) except FileNotFoundError: pass @@ -264,7 +295,11 @@ def main(): Log.init("fzf_cached_wsl", log_level, stdout=False, file=log_file) Log.info( - "fzf_cached_wsl started", [("cache", args.cache), ("directory", args.directory)] + "fzf_cached_wsl started", + { + "cache": args.cache, + "directory": args.directory, + } ) cache = Cache(tmp_dir, args.directory, args.cache, args.tidy) diff --git a/bin/git_diff_with.sh b/bin/git_diff_with.sh new file mode 100755 index 0000000..66f7069 --- /dev/null +++ b/bin/git_diff_with.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash + +function yell () { >&2 echo "$*"; } +function die () { yell "$*"; exit 1; } +function try () { "$@" || die "Command failed: $*"; } + +SCRIPT_PATH="$( realpath "$0" )" +SCRIPT_DIR="$( dirname "$SCRIPT_PATH" )" + +# TODO: This is only half implemented as a hacky test to see if it would be +# useful +# +# Diff the current git repository with a previous commit using Beyond Compare. +# Copies the repository to a temp directory and checks out the specified +# commit/branch/tag. Then runs Beyond compare to do a directory comparison +# between the current directory and the temp directory. +git_diff_with() +{ + # TODO: Document this parameter + local diff_target="$1" + + if [ -z "$diff_target" ]; then + # If diff_target isn't specified, default it to the previous commit hash + diff_target="$(git log -n 1 | grep -E '^commit ' | sed -e 's/^commit //' -e 's/ .*//')" + echo "Defaulting diff_target to $diff_target" + fi + + # TODO: We could make this robust and walk up the file tree but for now + # keep it simple + if [ ! -d "./.git" ]; then + yell "ERROR: Not at the root of a git repository" + return 1 + fi + + local tmp_dir_name tmp_dir + tmp_dir_name="$(basename $(pwd))_${diff_target}_diff" + tmp_dir="$HOME/tmp/${tmp_dir_name}" + + # TODO: DEBUG REMOVE + echo "tmp_dir = $tmp_dir" + + # TODO: Error handling below + + # Make sure the temporary directory doesn't already exist + # TODO: Maybe if it does just leave it and use it? If we're targeting a + # commit hash or tag it's unlikely to have changed. Could add a parameter + # to force re-checkout? + #rm -rf "$tmp_dir" + + if [ ! -d "$tmp_dir" ]; then + + local parent_dir="$(dirname "$tmp_dir")" + # Make sure the parent directory exists + if ! mkdir -p "$parent_dir"; then + yell "ERROR: Parent directory '$parent_dir' does not exist" + return 1 + fi + + if ! cp -r "$(pwd)" "$tmp_dir"; then + yell "ERROR: Failed to copy repository to the diff directory" + return 1 + fi + fi + + if ! cd "$tmp_dir"; then + yell "ERROR: Failed to change current working directory to the diff directory" + return 1 + fi + + # TODO: Find a cleaner way to always get back to the previous working + # directory. Maybe just execute all of this in a sub-shell? + + if ! git reset --hard; then + cd - + yell "ERROR: Git reset in diff directory failed" + return 1 + fi + + if ! git clean -fdx; then + cd - + yell "ERROR: Git clean in diff directory failed" + return 1 + fi + + if ! git checkout "$diff_target"; then + cd - + yell "ERROR: Git checkout in diff directory failed" + return 1 + fi + + cd - + + local bcompare_exe + # TODO: If WSL else... + #bcompare_exe="bcompare" + bcompare_exe="BCompare.exe" + + # TODO: Doesn't work on WSL because we need to canonicalize the path so + # that it's a valid Windows network path. + # e.g. /home/pewing/foo -> \\wsl.localhost\Ubuntu-24.04\home\pewing\foo + # I don't feel like trying to do that in bash, maybe we can make a Python + # utility for various path conversions + echo -e "Executing:\n\"$bcompare_exe\" \"$(pwd)\" \"$tmp_dir\"" + #"$bcompare_exe" "$(pwd)" "$tmp_dir" + "$bcompare_exe" \ + "\\\\WSL.LOCALHOST\\Ubuntu-24.04\\home\\pewing\\dot" \ + "\\\\WSL.LOCALHOST\\Ubuntu-24.04\\home\\pewing\\tmp\\${tmp_dir_name}" +} + +git_diff_with "$1" diff --git a/bin/git_pending_changes.py b/bin/git_pending_changes.py index c54ca7d..711c8d3 100755 --- a/bin/git_pending_changes.py +++ b/bin/git_pending_changes.py @@ -35,16 +35,19 @@ def run_command(cmd: list[str]) -> list[str]: class GitRepoStatus: def __init__(self, dir: str) -> None: + self._dir = dir + # TODO: Differentiate between staged and unstaged and add other file # statuses self.modified_files: list[str] = [] self.untracked_files: list[str] = [] + self.deleted_files: list[str] = [] - lines = run_command(["git", "-C", dir, "status", "--short"]) + lines = run_command(["git", "-C", self._dir, "status", "--short"]) for line in lines: - file_status, file_path = line.split(" ") + file_status, file_path = line.split(" ", 1) file_status = file_status.strip().lower() file_path = file_path.strip().lower() @@ -56,9 +59,12 @@ def __init__(self, dir: str) -> None: self.untracked_files.append(file_path) else: raise Exception( - f"Unknown file status; dir = {dir}, file_status = {file_status}" + f"Unknown file status; dir = {self._dir}, file_status = {file_status}" ) + def print_full_status(self) -> None: + subprocess.call(["git", "-C", self._dir, "status"]) + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="TODO") @@ -92,8 +98,9 @@ def handle_git_repository(dir: str) -> None: if not has_uncommitted_changes(status): return - # TODO: Expand on this, like print a nice status summary - print(dir) + print("\n================================================================================") + print("Repository: " + dir + "\n") + status.print_full_status() def handle_dir( diff --git a/bin/run_vm_guest_additions.sh b/bin/run_vm_guest_additions.sh new file mode 100755 index 0000000..6dbc955 --- /dev/null +++ b/bin/run_vm_guest_additions.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +DOTFILES_LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dotfiles/logs" +LOG_FILE="run_vm_guest_additions_$(date +"%Y%m%d_%H%M%S").log" +LOG_PATH="${DOTFILES_LOG_DIR}/${LOG_FILE}" + +log() { + echo "$*" >> "$LOG_PATH" +} + +# If spice-vdagent is installed, we're probably running in a Virtual Machine. +# Start it up because it enables clipboard sharing between host and guest. +if command -v spice-vdagent >/dev/null 2>&1; then + log "Found spice-vdagent at: $(command -v spice-vdagent)" + if pgrep -x spice-vdagent >/dev/null; then + log "spice-vdagent already running; skipping" + else + log "Starting spice-vdagent" + spice-vdagent >> "$LOG_PATH" 2>&1 + fi +else + log "spice-vdagent command not found; skipping" +fi diff --git a/bin/set-theme b/bin/set-theme index 03d711f..38e84b1 100755 --- a/bin/set-theme +++ b/bin/set-theme @@ -4,8 +4,7 @@ function yell () { >&2 echo "$*"; } function die () { yell "$*"; exit 1; } function try () { "$@" || die "Command failed: $*"; } -script_path="$( realpath "$0" )" -script_dir="$( dirname "$script_path" )" +# IMPROVEMENT: Removed unused script_path and script_dir variables theme="$( flavours list | tr " " "\n" | rofi -i -dmenu )" if test -z "$theme"; then @@ -48,11 +47,8 @@ function get_background_opacity() { background_opacity="$(get_background_opacity "$theme")" sed -Ei "s/^background_opacity.*/background_opacity $background_opacity/" "$DOTFILES/config/kitty.conf" -# TODO: Either add flavours to provision script or install it here if it's -# missing if ! command -v flavours &>/dev/null; then - flavours_url="https://github.com/Misterio77/flavours/releases/latest" - die "Error: flavours is not installed; download and install it from: $flavours_url" + die "Error: flavours is not installed" fi # Run flavours to render the config templates into all dotfiles diff --git a/bin/startup.sh b/bin/startup.sh index 050d063..4ae45f4 100755 --- a/bin/startup.sh +++ b/bin/startup.sh @@ -30,7 +30,8 @@ function start_process() { # If it's not running just run it if [ "$running" = "0" ]; then - $procname $procargs >> "$logfile" 2>&1 & + # BUG FIX: Quoted $procargs to prevent word splitting issues with args containing spaces + $procname "$procargs" >> "$logfile" 2>&1 & return fi @@ -41,7 +42,7 @@ function start_process() { sleep 0.1 done - $procname $procargs >> "$logfile" 2>&1 & + $procname "$procargs" >> "$logfile" 2>&1 & fi } diff --git a/cli/README.md b/cli/README.md index bb00a33..4e6bd7b 100644 --- a/cli/README.md +++ b/cli/README.md @@ -4,45 +4,8 @@ This is a Python CLI tool for various dotfiles operations. ## Bash Auto-Completion (Requires Python 3.7+) -The `DotProvisioner` in the CLI automatically sets up Bash auto-completion so just run: - -```bash -dot provision -``` - -Or: - -```bash -dot provision dot -``` - -### Manual Setup - -Install the `argcomplete` python package: - -```bash -python -m pip install argcomplete -``` - -Generate the auto-completion script: - -```bash -export DOT_BASH_COMPLETION="1" -mkdir -p ~/.bash_completion.d -echo "$( - register-python-argcomplete --external-argcomplete-script $DOTFILES/cli/dot.py dot -)" &>~/.bash_completion.d/dot.bash -``` - -Add this to `.bashrc` or similar: - -```bash -export DOT_BASH_COMPLETION="1" -source "$HOME/.bash_completion.d/dot.bash" -``` - -Reload shell config and the auto-completion script should be sourced into the -shell automatically. +The CLI supports auto-completion via `argcomplete` and it should be configured +automatically by Nix home-manager. ## Code Formatting diff --git a/cli/commands/__init__.py b/cli/commands/__init__.py index 55fbe56..21a34ac 100644 --- a/cli/commands/__init__.py +++ b/cli/commands/__init__.py @@ -7,8 +7,6 @@ from .git_sync import add_git_sync_parser from .link import add_link_parser from .lint import add_lint_parser -from .list import add_list_parser -from .provision import add_provision_parser from .status import add_status_parser from .tidy import add_tidy_parser @@ -21,7 +19,5 @@ def add_command_parsers(parser: argparse.ArgumentParser) -> None: add_git_sync_parser(subparsers) add_link_parser(subparsers) add_lint_parser(subparsers) - add_list_parser(subparsers) - add_provision_parser(subparsers) add_status_parser(subparsers) add_tidy_parser(subparsers) diff --git a/cli/commands/fd/__init__.py b/cli/commands/fd/__init__.py index 8e31cc0..556af65 100644 --- a/cli/commands/fd/__init__.py +++ b/cli/commands/fd/__init__.py @@ -15,11 +15,16 @@ class Config: @staticmethod def load() -> dict[str, str]: - try: - with open(Config._path(), "r") as f: - return json.loads(f.read()) - except FileNotFoundError: - return {} + if not os.path.exists(Config._path()): + Config._init() + with open(Config._path(), "r") as f: + return json.loads(f.read()) + + @staticmethod + def _init() -> None: + os.makedirs(os.path.dirname(Config._path()), exist_ok=True) + with open(Config._path(), "w") as f: + f.write(json.dumps(Config._default(), indent=" ")) @staticmethod def _path(): @@ -27,6 +32,10 @@ def _path(): Config._PATH = f"{home()}/.config/fd/config.json" return Config._PATH + @staticmethod + def _default() -> dict[str, any]: + return {"update": {"git_search_paths": ["~/src"]}} + RegistryEntry = dict[str, str] RegistryEntries = dict[str, RegistryEntry] @@ -96,7 +105,9 @@ def cmd_fd_choose(args: argparse.Namespace) -> None: selected_key = None cmd = ["fzf", "--query", args.query] try: - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stdin=subprocess.PIPE, text=True) + p = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stdin=subprocess.PIPE, text=True + ) selected_key = p.communicate(input=stdin)[0].strip() except FileNotFoundError as e: raise Exception("FZF not installed or not in PATH") from e diff --git a/cli/commands/git_sync.py b/cli/commands/git_sync.py index 5e41734..fa45ef6 100644 --- a/cli/commands/git_sync.py +++ b/cli/commands/git_sync.py @@ -1,12 +1,12 @@ #!/usr/bin/env python -import random -import string import argparse import json +import random +import string -from lib.common.log import Log from lib.common.git import Git, GitCommit +from lib.common.log import Log from lib.common.typing import StringOrNone # TODO: Initial rough implementation is does and a few main cases are tested @@ -17,26 +17,33 @@ # - Break up main function so this is more readable # - Add a '-m/--message' flag to allow users to specify a commit message + def add_git_sync_parser(subparsers: argparse._SubParsersAction) -> None: parser = subparsers.add_parser( "git-sync", # TODO: Add a more descriptive help message help="Simple sync to a remote git repository", ) - parser.add_argument("-d", "--dry-run", action="store_true", help="Dry run the command") - parser.add_argument("-v", "--verbose", action="store_true", help="Extra verbose output") + parser.add_argument( + "-d", "--dry-run", action="store_true", help="Dry run the command" + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help="Extra verbose output" + ) parser.set_defaults(func=cmd_git_sync) def generate_temporary_branch_name(length: int = 8): characters = string.ascii_lowercase + string.digits - random_string = ''.join(random.choice(characters) for _ in range(length)) + random_string = "".join(random.choice(characters) for _ in range(length)) return f"temp-{random_string}" # Find the most recent matching commit between the local and remote # repositories. Returns the index into both arrays of that commit. -def find_common_commit(local_commits: list[GitCommit], remote_commits: list[GitCommit]) -> tuple[int, int]: +def find_common_commit( + local_commits: list[GitCommit], remote_commits: list[GitCommit] +) -> tuple[int, int]: remote_commits_dict = {} for i in range(len(remote_commits)): remote_commit = remote_commits[i] @@ -55,10 +62,15 @@ def find_common_commit(local_commits: list[GitCommit], remote_commits: list[GitC break if remote_commit_index is None: - raise Exception("Failed to find a common commit between the local and remote repositories") + raise Exception( + "Failed to find a common commit between the local and remote repositories" + ) common_commit = local_commits[local_commit_index] - Log.info("common commit found", [("hash", common_commit.hash), ("message", common_commit.message)]) + Log.info( + "common commit found", + {"hash": common_commit.hash, "message": common_commit.message}, + ) return local_commit_index, remote_commit_index @@ -140,7 +152,9 @@ def cmd_git_sync(args: argparse.Namespace) -> None: print_commits(remote_commits) # Find the most recent matching commit between the local and remote - local_commit_index, remote_commit_index = find_common_commit(local_commits, remote_commits) + local_commit_index, remote_commit_index = find_common_commit( + local_commits, remote_commits + ) print( local_commit_index, @@ -184,5 +198,5 @@ def cmd_git_sync(args: argparse.Namespace) -> None: def print_commits(commits: list[GitCommit]): - d = { "commits": [c.to_dict() for c in commits] } + d = {"commits": [c.to_dict() for c in commits]} print(json.dumps(d, indent=4)) diff --git a/cli/commands/list.py b/cli/commands/list.py deleted file mode 100644 index 9fb662f..0000000 --- a/cli/commands/list.py +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env python - -import argparse - -from lib.provision.system_provisioner import SystemProvisioner - - -def add_list_parser(subparsers: argparse._SubParsersAction) -> None: - parser = subparsers.add_parser("list", help="List the available provisioners") - parser.set_defaults(func=cmd_list) - - -def cmd_list(args: argparse.Namespace) -> None: - component_provisioners = SystemProvisioner.get_provisioner_list() - - # Purposefully use print instead of Log here so output is nicer - print("Component provisioners:") - for component_provisioner in component_provisioners: - print(f"- {component_provisioner}") diff --git a/cli/commands/provision.py b/cli/commands/provision.py deleted file mode 100644 index e33fb06..0000000 --- a/cli/commands/provision.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python - -import argparse -import os - -from lib.common.distro_info import DistroInformation -from lib.common.os import OperatingSystem -from lib.common.log import Log -from lib.provision.provisioner import ProvisionerArgs -from lib.provision.system_provisioner import SystemProvisioner -from lib.provision.tag import Tags - - -def add_provision_parser(subparsers: argparse._SubParsersAction) -> None: - parser = subparsers.add_parser( - "provision", help="Run provisioners to configure components" - ) - # TODO: Implement a confirmation - # parser.add_argument( - # "-y", - # "--yes", - # action="store_true", - # help="Don't ask for confirmation", - # ) - parser.add_argument( - "-d", - "--dry-run", - action="store_true", - help="Print provisioning actions without running them", - ) - parser.add_argument( - "-f", - "--force", - action="store_true", - help=( - "Provisioners may try to detect current state and skip " - "unnecessery steps; this forces those steps to be run" - ), - ) - parser.add_argument( - "-t", - "--tags", - default=Tags.default(), - help="Comma delimited list of tags that influence provisioner behavior [x11|wsl]", - ) - parser.add_argument( - "components", - nargs="*", - help="The components to provision; if omitted, all components are provisioned", - ) - parser.set_defaults(func=cmd_provision) - - -def cmd_provision(args: argparse.Namespace) -> None: - if os.getuid() == 0: - raise Exception("do not run as root") - - if OperatingSystem.get().is_linux(): - distro = DistroInformation.get() - Log.info( - "provisioning system", - [ - ("distro.id", distro.id), - ("distro.release", distro.release), - ("distro.codename", distro.codename), - ], - ) - - tags = Tags.parse(args.tags) if isinstance(args.tags, str) else args.tags - - provisioner_args = ProvisionerArgs(args.dry_run, tags) - provisioner = SystemProvisioner(provisioner_args, args.components) - provisioner.provision() diff --git a/cli/lib/common/alternatives.py b/cli/lib/common/alternatives.py index 6da04e9..987a9d9 100644 --- a/cli/lib/common/alternatives.py +++ b/cli/lib/common/alternatives.py @@ -12,7 +12,12 @@ def install( ): Log.info( "Adding alternative", - [("link", link), ("name", name), ("path", path), ("priority", priority)], + { + "link": link, + "name": name, + "path": path, + "priority": priority, + }, ) if dry_run: Log.info("skip adding alternative due to --dry-run") @@ -26,7 +31,7 @@ def install( @staticmethod def set(name: str, path: str, sudo: bool, dry_run: bool): - Log.info("setting alternative", [("name", name), ("path", path)]) + Log.info("setting alternative", {"name": name, "path": path}) if dry_run: Log.info("skip setting alternative due to --dry-run") return diff --git a/cli/lib/common/apt.py b/cli/lib/common/apt.py index a8bbc70..58214f0 100644 --- a/cli/lib/common/apt.py +++ b/cli/lib/common/apt.py @@ -117,7 +117,7 @@ def upgrade(dry_run: bool) -> None: @staticmethod def install(packages: List[str], dry_run: bool) -> None: - Log.info("installing apt packages", [("packages", sorted(packages))]) + Log.info("installing apt packages", {"packages": sorted(packages)}) if dry_run: Log.info("skipping apt install due to --dry-run") return @@ -130,10 +130,12 @@ def install_deb_files(deb_files: List[str], dry_run: bool) -> None: Log.info( "installing deb packages", - [("packages", "[ " + ", ".join(deb_files) + " ]")], + { + "packages": "[ " + ", ".join(deb_files) + " ]", + } ) if dry_run: - Log.info("skipping install", [("reason", "dry run")]) + Log.info("skipping install", {"reason": "dry run"}) else: if subprocess.call(cmd) != 0: raise Exception("Failed to install packages") diff --git a/cli/lib/common/archive.py b/cli/lib/common/archive.py index 58d49fb..78a82eb 100644 --- a/cli/lib/common/archive.py +++ b/cli/lib/common/archive.py @@ -59,14 +59,14 @@ def _extract_tar( def _unzip( path: str, compression_type: CompressionType, dst_dir: str, dry_run: bool ) -> None: - Log.info("unzipping archive", [("path", path), ("dst_dir", dst_dir)]) + Log.info("unzipping archive", {"path": path, "dst_dir": dst_dir}) cmd = ["unzip", "-o", path, "-d", dst_dir] # Compression type is currently ignored for zip files if dry_run: - Log.info("skipping zip file extraction", [("reason", "dry run")]) + Log.info("skipping zip file extraction", {"reason": "dry run"}) else: sh(cmd) diff --git a/cli/lib/common/file_walker.py b/cli/lib/common/file_walker.py index 4d31c1b..e92defb 100644 --- a/cli/lib/common/file_walker.py +++ b/cli/lib/common/file_walker.py @@ -71,7 +71,7 @@ def get_directories(self) -> list["FileWalker.Directory"]: def get_nodes(self) -> list["FileWalker.Node"]: # TODO: How do I make this one line without angering mypy? - nodes: list["FileWalker.Node"] + nodes: list["FileWalker.Node"] = [] nodes += self._files nodes += self._directories return nodes @@ -113,8 +113,9 @@ def _walk(ctx: Context, directory: Directory) -> None: elif dir_entry.is_file(follow_symlinks=True): FileWalker._handle_file(ctx, FileWalker.File(ctx.root, path_rel)) elif dir_entry.is_symlink(): + # BUG FIX: Fixed typo "non-existant" -> "non-existent" Log.debug( - "encountered symlink directory entry with non-existant target" + "encountered symlink directory entry with non-existent target" ) else: Log.warn( @@ -140,7 +141,7 @@ def _handle_dir(ctx: Context, directory: Directory) -> None: if ctx.halt: return - # If handler requested to skip, do nothin + # If handler requested to skip, do nothing if result.skip: return diff --git a/cli/lib/common/git.py b/cli/lib/common/git.py index af90ce8..c19b6b4 100644 --- a/cli/lib/common/git.py +++ b/cli/lib/common/git.py @@ -1,15 +1,15 @@ #!/usr/bin/env python import json -import re import subprocess from lib.common.log import Log +# BUG FIX: Return type annotation was str but function returns list[str] def _execute_git_command( cmd: list[str], strip: bool = True, filter_empty: bool = True -) -> str: +) -> list[str]: p = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True) stdout, _ = p.communicate() if p.returncode != 0: @@ -34,7 +34,8 @@ def is_add_required(self) -> bool: def is_commit_required(self) -> bool: return ( - self.is_add_required() or (len(self.staged_modified) + len(self.staged_added)) > 0 + self.is_add_required() + or (len(self.staged_modified) + len(self.staged_added)) > 0 ) @staticmethod @@ -134,10 +135,10 @@ def __init__(self, url, path): self._path = path def checkout(self, target: str, dry_run: bool) -> None: - Log.debug("checking out git target", [("target", target)]) + Log.debug("checking out git target", {"target": target}) if dry_run: - Log.debug("skipping git checkout", [("reason", "dry run")]) + Log.debug("skipping git checkout", {"reason": "dry run"}) return cmd = [ @@ -155,9 +156,9 @@ def checkout(self, target: str, dry_run: bool) -> None: class Git: @staticmethod def clone(url: str, path: str, dry_run: bool) -> GitRepository: - Log.debug("cloning git repository", [("url", url, "path", path)]) + Log.debug("cloning git repository", {"url": url, "path": path}) if dry_run: - Log.debug("skipping git clone", [("reason", "dry run")]) + Log.debug("skipping git clone", {"reason": "dry run"}) else: if subprocess.call(["git", "clone", url, path]) != 0: raise Exception("Failed to clone git repository") @@ -235,18 +236,19 @@ def add_all() -> None: @staticmethod def commit(message: str) -> None: - Log.debug("commiting staged changes") + # BUG FIX: Fixed typo "commiting" -> "committing" + Log.debug("committing staged changes") subprocess.check_call(["git", "commit", "--message", message]) @staticmethod def push(remote: str, branch: str) -> None: - Log.debug("pushing to remote", [("remote", remote), ("branch", branch)]) + Log.debug("pushing to remote", {"remote": remote, "branch": branch}) cmd = ["git", "push", remote, branch] subprocess.check_call(cmd) @staticmethod def pull(remote: str, branch: str, rebase: bool = False) -> None: - Log.debug("pulling from remote", [("remote", remote), ("branch", branch)]) + Log.debug("pulling from remote", {"remote": remote, "branch": branch}) cmd = ["git", "pull"] if rebase: cmd.append("--rebase") @@ -255,15 +257,15 @@ def pull(remote: str, branch: str, rebase: bool = False) -> None: @staticmethod def create_branch(name: str) -> None: - Log.debug("creating a new branch", [("name", name)]) + Log.debug("creating a new branch", {"name": name}) subprocess.check_call(["git", "branch", name]) @staticmethod def checkout(target: str) -> None: - Log.debug("checking out target (branch/hash/tag)", [("target", target)]) + Log.debug("checking out target (branch/hash/tag)", {"target": target}) subprocess.check_call(["git", "checkout", target]) @staticmethod def cherry_pick(hash: str) -> None: - Log.debug("cherry-picking commit", [("hash", hash)]) + Log.debug("cherry-picking commit", {"hash": hash}) subprocess.check_call(["git", "cherry-pick", hash]) diff --git a/cli/lib/common/group.py b/cli/lib/common/group.py index 2ba26a4..8c83562 100644 --- a/cli/lib/common/group.py +++ b/cli/lib/common/group.py @@ -8,14 +8,14 @@ class Group: @staticmethod def add_user(group: str, user: str, dry_run: bool) -> None: - Log.info("adding user to group", [("user", user), ("group", group)]) + Log.info("adding user to group", {"user": user, "group": group}) if Group._is_user_in_group(group, user): - Log.info("skipping adding user", [("reason", "already a member")]) + Log.info("skipping adding user", {"reason": "already a member"}) return if dry_run: - Log.info("skipping adding user", [("reason", "dry run")]) + Log.info("skipping adding user", {"reason": "dry run"}) else: cmd = ["sudo", "usermod", "-aG", group, user] if subprocess.call(cmd) != 0: diff --git a/cli/lib/common/links.py b/cli/lib/common/links.py index 5bb2a37..ac937cc 100644 --- a/cli/lib/common/links.py +++ b/cli/lib/common/links.py @@ -65,7 +65,7 @@ def create(self) -> None: ) os.remove(self.dst) - Log.info(f"Creating symlink", [("source", self.src), ("target", self.dst)]) + Log.info(f"Creating symlink", {"source": self.src, "target": self.dst}) os.symlink(self.src, self.dst) def delete(self) -> None: diff --git a/cli/lib/common/linter.py b/cli/lib/common/linter.py index 982c69a..daf681f 100644 --- a/cli/lib/common/linter.py +++ b/cli/lib/common/linter.py @@ -93,7 +93,7 @@ def is_python_file(file_name: str) -> bool: @staticmethod def _remove_unused_imports(file: str, dry_run: bool) -> None: - Log.info("removing unused imports", [("file", file)]) + Log.info("removing unused imports", {"file": file}) if not dry_run: sh( ["autoflake", "--in-place", "--remove-all-unused-imports", file], @@ -102,13 +102,13 @@ def _remove_unused_imports(file: str, dry_run: bool) -> None: @staticmethod def _sort_imports(file: str, dry_run: bool) -> None: - Log.info("sorting imports", [("file", file)]) + Log.info("sorting imports", {"file": file}) if not dry_run: sh(["isort", file], check=True) @staticmethod def _format_file(file: str, dry_run: bool) -> None: - Log.info("formatting file", [("file", file)]) + Log.info("formatting file", {"file": file}) if not dry_run: sh(["black", file], check=True) diff --git a/cli/lib/common/log.py b/cli/lib/common/log.py index 27b97f1..e2fc018 100644 --- a/cli/lib/common/log.py +++ b/cli/lib/common/log.py @@ -9,10 +9,8 @@ LogHandler = Union[logging.StreamHandler, logging.FileHandler] LogHandlers = list[LogHandler] -# TODO: Just make this a dictionary instead of a list of KVPs -# LogData = dict[str, Any] +LogData = dict[str, Any] LogDataKvp = tuple[str, Any] -LogData = list[LogDataKvp] # fmt: off _LOG_LEVEL_STRINGS = { @@ -90,28 +88,30 @@ def init( Log._logger = logger + # IMPROVEMENT: Changed mutable default argument {} to None to avoid the + # well-known Python gotcha where mutable defaults are shared across calls @staticmethod - def debug(msg: str, data: LogData = []) -> None: - Log._log(logging.DEBUG, msg, data) + def debug(msg: str, data: Optional[LogData] = None) -> None: + Log._log(logging.DEBUG, msg, data or {}) @staticmethod - def info(msg: str, data: LogData = []) -> None: - Log._log(logging.INFO, msg, data) + def info(msg: str, data: Optional[LogData] = None) -> None: + Log._log(logging.INFO, msg, data or {}) @staticmethod - def warn(msg: str, data: LogData = []) -> None: - Log._log(logging.WARNING, msg, data) + def warn(msg: str, data: Optional[LogData] = None) -> None: + Log._log(logging.WARNING, msg, data or {}) @staticmethod - def error(msg: str, data: LogData = []) -> None: - Log._log(logging.ERROR, msg, data) + def error(msg: str, data: Optional[LogData] = None) -> None: + Log._log(logging.ERROR, msg, data or {}) @staticmethod - def fatal(msg: str, data: LogData = []) -> None: - Log._log(logging.FATAL, msg, data) + def fatal(msg: str, data: Optional[LogData] = None) -> None: + Log._log(logging.FATAL, msg, data or {}) @staticmethod - def _log(level: LogLevel, msg: str, data: LogData = []) -> None: + def _log(level: LogLevel, msg: str, data: LogData) -> None: if Log._logger is None: return Log._logger.log(level, Log._format_msg(msg, data)) @@ -124,7 +124,7 @@ def _format_msg(msg: str, data: LogData) -> str: def _format_data(data: LogData) -> str: if len(data) == 0: return "" - return f" {{ {', '.join([Log._format_kvp(kvp) for kvp in data])} }}" + return f" {{ {', '.join([Log._format_kvp(kvp) for kvp in data.items()])} }}" @staticmethod def _format_kvp(kvp: LogDataKvp) -> str: diff --git a/cli/lib/common/pip.py b/cli/lib/common/pip.py index 7f38181..127eaad 100644 --- a/cli/lib/common/pip.py +++ b/cli/lib/common/pip.py @@ -14,7 +14,7 @@ def install( sudo: bool = False, dry_run: bool = True, ) -> None: - Log.info("installing pip packages", [("packages", packages)]) + Log.info("installing pip packages", {"packages": packages}) if dry_run: Log.info("skipping pip install due to --dry-run") return @@ -22,7 +22,7 @@ def install( cmd = [] if sudo: - cmd.append("sudo") + cmd += ["sudo", "-i"] cmd += ["python", "-m", "pip", "install"] diff --git a/cli/lib/common/shell.py b/cli/lib/common/shell.py index 8827113..48af7a3 100644 --- a/cli/lib/common/shell.py +++ b/cli/lib/common/shell.py @@ -12,10 +12,10 @@ class Shell: @staticmethod def mkdir(path: str, exist_ok: bool, sudo: bool, dry_run: bool) -> None: - Log.info("creating directory", [("path", path)]) + Log.info("creating directory", {"path": path}) if dry_run: - Log.info("skipping directory creation", [("reason", "dry run")]) + Log.info("skipping directory creation", {"reason": "dry run"}) return cmd = [] @@ -30,7 +30,7 @@ def mkdir(path: str, exist_ok: bool, sudo: bool, dry_run: bool) -> None: @staticmethod def rm(path: str, recursive: bool, force: bool, sudo: bool, dry_run: bool) -> None: - Log.info("removing file or directory", [("path", path)]) + Log.info("removing file or directory", {"path": path}) if dry_run: Log.info("skipping removal due to --dry-run") return @@ -47,7 +47,7 @@ def rm(path: str, recursive: bool, force: bool, sudo: bool, dry_run: bool) -> No @staticmethod def mv(src: str, dst: str, sudo: bool, dry_run: bool) -> None: - Log.info("moving file or directory", [("from", src), ("to", dst)]) + Log.info("moving file or directory", {"from": src, "to": dst}) if dry_run: Log.info("skipping directory move due to --dry-run") @@ -60,7 +60,7 @@ def mv(src: str, dst: str, sudo: bool, dry_run: bool) -> None: @staticmethod def ln(source: str, target: str, sudo: bool, dry_run: bool) -> None: - Log.info("creating symbolic link", [("source", source), ("target", target)]) + Log.info("creating symbolic link", {"source": source, "target": target}) if dry_run: Log.info("skipping symbolic link creation due to --dry-run") @@ -73,7 +73,7 @@ def ln(source: str, target: str, sudo: bool, dry_run: bool) -> None: @staticmethod def chmod(mod: str, file: str, sudo: bool, dry_run: bool) -> None: - Log.info("changing file permissions", [("file", file), ("permissions", mod)]) + Log.info("changing file permissions", {"file": file, "permissions": mod}) if dry_run: Log.info("skipping file permission update creation due to --dry-run") @@ -86,13 +86,13 @@ def chmod(mod: str, file: str, sudo: bool, dry_run: bool) -> None: @staticmethod def cd(path: str, dry_run: bool) -> None: - Log.info("changing directory", [("path", path)]) + Log.info("changing directory", {"path": path}) if dry_run: - Log.info("skipping directory change", [("reason", "dry run")]) + Log.info("skipping directory change", {"reason": "dry run"}) return os.chdir(path) @staticmethod def _exec(cmd: list[str]) -> int: - Log.debug("executing shell command", [("command", " ".join(cmd))]) + Log.debug("executing shell command", {"command": " ".join(cmd)}) return subprocess.call(cmd) diff --git a/cli/lib/common/user.py b/cli/lib/common/user.py index 7430d81..b77ae91 100644 --- a/cli/lib/common/user.py +++ b/cli/lib/common/user.py @@ -3,8 +3,8 @@ import os import subprocess -from lib.common.os import OperatingSystem from lib.common.log import Log +from lib.common.os import OperatingSystem # TODO: Created this without realizing group.py already exists, merge the two @@ -57,7 +57,7 @@ def get_groups(self) -> List[str]: ) def _get_groups_linux(self) -> List[str]: - import pwd, grp + import grp # Get the groups that the user is in groups = [g for g in grp.getgrall() if self._name in g.gr_mem] @@ -68,7 +68,7 @@ def _get_groups_linux(self) -> List[str]: # Convert to platform-agnostic Group type return [Group(g.gr_name, g.gr_gid, g.gr_mem) for g in groups] - # TODO: Remove dry run stuff, make a wrapper in provision lib for that + # TODO: Remove dry run stuff # TODO: I actually don't know if this was even used? I think we used group.py def add_to_group(self, group: str, dry_run: bool) -> None: groups = set(self.get_groups()) diff --git a/cli/lib/common/util.py b/cli/lib/common/util.py index 758483b..1ceb1d8 100644 --- a/cli/lib/common/util.py +++ b/cli/lib/common/util.py @@ -4,7 +4,6 @@ import shutil import subprocess import urllib.request -from typing import List from lib.common.log import Log from lib.common.shell import Shell @@ -20,7 +19,7 @@ def sh(cmd: list[str], check: bool = False) -> int: def mkdir_p(path: str, dry_run: bool) -> None: - Log.info("creating directory:", [("path", path)]) + Log.info("creating directory:", {"path": path}) if dry_run: Log.info("skipping directory creation due to --dry-run") @@ -29,7 +28,7 @@ def mkdir_p(path: str, dry_run: bool) -> None: def sudo_mkdir_p(path: str, dry_run: bool) -> None: - Log.info("creating directory:", [("path", path)]) + Log.info("creating directory:", {"path": path}) if dry_run: Log.info("skipping directory creation due to --dry-run") @@ -39,7 +38,7 @@ def sudo_mkdir_p(path: str, dry_run: bool) -> None: def sudo_rmdir(path: str, dry_run: bool) -> None: - Log.info("deleting directory:", [("path", path)]) + Log.info("deleting directory:", {"path": path}) if dry_run: Log.info("skipping directory deletion due to --dry-run") @@ -49,7 +48,7 @@ def sudo_rmdir(path: str, dry_run: bool) -> None: def sudo_mvdir(src: str, dst: str, dry_run: bool) -> None: - Log.info("moving directory:", [("from", src), ("to", dst)]) + Log.info("moving directory:", {"from": src, "to": dst}) if dry_run: Log.info("skipping directory move due to --dry-run") @@ -59,7 +58,7 @@ def sudo_mvdir(src: str, dst: str, dry_run: bool) -> None: def download_file(url: str, path: str, sudo: bool, force: bool, dry_run: bool) -> None: - Log.info("downloading file", [("url", url), ("path", path)]) + Log.info("downloading file", {"url": url, "path": path}) if os.path.isfile(path) and not force: Log.info( @@ -71,7 +70,7 @@ def download_file(url: str, path: str, sudo: bool, force: bool, dry_run: bool) - Shell.mkdir(os.path.dirname(path), True, sudo, dry_run) if dry_run: - Log.info("skipping download", [("path", path), ("reason", "dry run")]) + Log.info("skipping download", {"path": path, "reason": "dry run"}) else: urllib.request.urlretrieve(url, path) @@ -84,10 +83,10 @@ def write_file(path: str, content: str, sudo: bool, dry_run: bool) -> None: dry_run=dry_run, ) - Log.info("creating file", [("path", path), ("sudo", sudo)]) + Log.info("creating file", {"path": path, "sudo": sudo}) if dry_run: - Log.info("skipping file creation", [("reason", "dry run")]) + Log.info("skipping file creation", {"reason": "dry run"}) return # TODO: Handle sudo diff --git a/cli/lib/common/version_cache.py b/cli/lib/common/version_cache.py new file mode 100644 index 0000000..54b6090 --- /dev/null +++ b/cli/lib/common/version_cache.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python + +from __future__ import annotations + +import os +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Optional + +from lib.common.log import Log + +# Prefer a real JSON5 parser if available. We'll fall back to strict JSON. +try: + import json5 as _json_reader # type: ignore +except Exception: + _json_reader = None + +import json as _json_fallback + + +class VersionCache: + """ + A small TTL-based cache for "latest version" lookups. + + File schema (JSON5-compatible): + { + versions: { + foo: { + version: "v1.2.3", + source: "github:owner/repo", + update_time: "2025-12-14T17:42:10Z", + last_attempt: "2025-12-14T17:42:10Z", + last_error: "optional string", + }, + ... + } + } + """ + + CACHE_PATH = Path("./version_cache.json5") + MAX_AGE_DAYS = 7 + ENABLED = True + + @staticmethod + def init( + enabled: bool, + cache_path: Path, + max_age_days: int, + ) -> None: + VersionCache.ENABLED = enabled + VersionCache.CACHE_PATH = cache_path + VersionCache.MAX_AGE_DAYS = max_age_days + + @staticmethod + def get_version( + name: str, + *, + use_stale_on_failed_attempt: bool = True, + failure_cooldown_seconds: Optional[int] = None, + ) -> Optional[Dict[str, Any]]: + """ + Returns the cached entry for `name`, or None if not present / not usable. + + Cache freshness is controlled by VersionCache.MAX_AGE_DAYS: + - MAX_AGE_DAYS > 0 : entry must be newer than that many days + - MAX_AGE_DAYS <= 0: freshness check disabled; always accept cached entry + + Negative caching behavior: + - If the entry is stale (or missing) and there's evidence of a more recent + failed attempt (last_attempt > update_time), then (optionally) return the + cached entry anyway and print a warning, instead of treating it as a miss. + - If failure_cooldown_seconds is set, the "failed attempt" shortcut only + applies when last_attempt is within that cooldown window from now. + """ + if not VersionCache.ENABLED: + return None + + data = VersionCache._load() + versions = data.get("versions", {}) + if not isinstance(versions, dict): + # Corrupt schema; treat as empty. + return None + + entry = versions.get(name) + if not isinstance(entry, dict): + return None + + # If TTL is disabled, return entry immediately + max_age_days = getattr(VersionCache, "MAX_AGE_DAYS", None) + if max_age_days is None or max_age_days <= 0: + return entry + + max_age_seconds = max_age_days * 24 * 60 * 60 + + now = VersionCache._utc_now() + update_dt = VersionCache._parse_iso_utc(entry.get("update_time")) + + if update_dt is not None: + age = (now - update_dt).total_seconds() + if age <= max_age_seconds: + return entry + + # Entry is stale (or missing update_time). Consider negative caching. + if use_stale_on_failed_attempt: + last_attempt_dt = VersionCache._parse_iso_utc(entry.get("last_attempt")) + if last_attempt_dt is not None: + # Failed attempt after last successful update? + failed_after_update = ( + update_dt is None or last_attempt_dt > update_dt + ) + + within_cooldown = True + if failure_cooldown_seconds is not None: + within_cooldown = ( + (now - last_attempt_dt).total_seconds() + <= failure_cooldown_seconds + ) + + if failed_after_update and within_cooldown: + last_err = entry.get("last_error") + msg = ( + f"[VersionCache] Using stale cached version for '{name}' " + f"because a recent fetch attempt failed." + ) + if last_err: + msg += f" Last error: {last_err}" + Log.warn(msg) + return entry + + return None + + @staticmethod + def update_version(name: str, version: str, source: Optional[str] = None) -> Dict[str, Any]: + """ + Upserts a successful version lookup. + - Sets update_time to now (UTC ISO8601, Z). + - Also sets last_attempt to now (since we just attempted). + - Clears last_error. + Returns the updated entry. + """ + data = VersionCache._load() + versions = data.setdefault("versions", {}) + if not isinstance(versions, dict): + data["versions"] = {} + versions = data["versions"] + + now_iso = VersionCache._utc_now_iso() + + entry = versions.get(name) + if not isinstance(entry, dict): + entry = {} + versions[name] = entry + + entry["version"] = version + if source is not None: + entry["source"] = source + entry["update_time"] = now_iso + entry["last_attempt"] = now_iso + entry.pop("last_error", None) + + VersionCache._save(data) + return entry + + @staticmethod + def add_failed_attempt(name: str, error: Optional[str] = None, source: Optional[str] = None) -> Dict[str, Any]: + """ + Records a failed attempt to refresh a version. + - Sets last_attempt to now (UTC ISO8601, Z). + - Stores last_error (if provided). + - Optionally updates source. + Does NOT modify update_time/version. + Returns the updated entry. + """ + data = VersionCache._load() + versions = data.setdefault("versions", {}) + if not isinstance(versions, dict): + data["versions"] = {} + versions = data["versions"] + + now_iso = VersionCache._utc_now_iso() + + entry = versions.get(name) + if not isinstance(entry, dict): + entry = {} + versions[name] = entry + + if source is not None: + entry["source"] = source + entry["last_attempt"] = now_iso + if error is not None: + entry["last_error"] = error + + VersionCache._save(data) + return entry + + # ----------------------------- + # Internal helpers + # ----------------------------- + + @staticmethod + def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + @staticmethod + def _utc_now_iso() -> str: + # Use second precision, Z suffix. + return VersionCache._utc_now().replace(microsecond=0).isoformat().replace("+00:00", "Z") + + @staticmethod + def _parse_iso_utc(value: Any) -> Optional[datetime]: + if not isinstance(value, str) or not value: + return None + try: + s = value.strip() + # Accept "...Z" and "+00:00" + if s.endswith("Z"): + s = s[:-1] + "+00:00" + dt = datetime.fromisoformat(s) + if dt.tzinfo is None: + # Assume UTC if timezone omitted (best-effort) + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + except Exception: + return None + + @staticmethod + def _load() -> Dict[str, Any]: + path = VersionCache.CACHE_PATH + if not path.exists(): + return {"versions": {}} + + text = path.read_text(encoding="utf-8") + + # Try JSON5 if available; else fall back to strict JSON. + if _json_reader is not None: + try: + data = _json_reader.loads(text) + if isinstance(data, dict): + data.setdefault("versions", {}) + return data + except Exception: + pass + + # Strict JSON fallback (will fail if you truly use JSON5 features like comments/trailing commas) + try: + data = _json_fallback.loads(text) + if isinstance(data, dict): + data.setdefault("versions", {}) + return data + except Exception: + # If unreadable, don't clobber the file automatically; just act like empty. + Log.warn(f"[VersionCache] failed to parse cache file: {path}") + return {"versions": {}} + + return {"versions": {}} + + @staticmethod + def _save(data: Dict[str, Any]) -> None: + path = VersionCache.CACHE_PATH + path.parent.mkdir(parents=True, exist_ok=True) + + # Write JSON that is also valid JSON5 (quotes, etc.). Pretty for humans. + serialized = _json_fallback.dumps(data, indent=2, sort_keys=True) + "\n" + + # Atomic write: temp file in same directory, then replace. + tmp_fd = None + tmp_path = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=str(path.parent), + delete=False, + prefix=path.name + ".tmp.", + ) as f: + tmp_fd = f.fileno() + tmp_path = f.name + f.write(serialized) + f.flush() + os.fsync(tmp_fd) + + os.replace(tmp_path, path) + finally: + if tmp_path and os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except Exception: + pass diff --git a/cli/lib/provision/__init__.py b/cli/lib/provision/__init__.py deleted file mode 100644 index 4265cc3..0000000 --- a/cli/lib/provision/__init__.py +++ /dev/null @@ -1 +0,0 @@ -#!/usr/bin/env python diff --git a/cli/lib/provision/provisioner.py b/cli/lib/provision/provisioner.py deleted file mode 100644 index ae2038b..0000000 --- a/cli/lib/provision/provisioner.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python - -from abc import ABC, abstractmethod - -from lib.provision.tag import Tags - - -class ProvisionerArgs: - def __init__(self, dry_run: bool, tags: Tags) -> None: - self.dry_run: bool = dry_run - self.tags: Tags = tags - - -class IProvisioner(ABC): - @abstractmethod - def provision(self) -> None: - pass - - -class IComponentProvisioner(IProvisioner): - def __init__(self) -> None: - pass - - -class ISystemProvisioner(IProvisioner): - def __init__(self) -> None: - pass diff --git a/cli/lib/provision/provisioner_apt.py b/cli/lib/provision/provisioner_apt.py deleted file mode 100644 index bf37747..0000000 --- a/cli/lib/provision/provisioner_apt.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python - -from lib.common.apt import Apt -from lib.provision.provisioner import IComponentProvisioner, ProvisionerArgs -from lib.provision.tag import Tags - -# fmt: off -APT_PACKAGES = { - "core": [ - "apt-utils", - "ca-certificates", - "curl", - "wget", - "gnupg", - "jq", - "software-properties-common", - "apt-file", - "libfuse2", # This is required to use AppImage - "locate", - "fzf", # TODO: Don't install fzf this way, shell integration broken - "net-tools", - "unzip", - "uchardet", - "dos2unix", - ], - "cli-tools": [ - "make", - "build-essential", - "cmake", - "meson", - "htop", - "iotop", - "git", - "vim", - "universal-ctags", # I think this has better c++11 support - "ranger", - "tmux", - "neofetch", - "id3v2", - "calcurse", - "rxvt-unicode", - "clang", - "clangd", - ], - "python-3": [ - "python3", - "python3-dev", - "python3-pip", - ], - "gui-tools": [ - "fonts-font-awesome", # Used for media buttons on polybar - "rofi", # Fuzzy application launcher - "dunst", # Desktop notifications - "feh", # Set wallpaper - "sxiv", # Image viewer - "nitrogen", # Set wallpaper - "pavucontrol", # Pulse Audio frontend - "compton", # Window compositor - "scrot", # Screen capture - "gucharmap", # Useful for debugging font issues - "keepassxc", # Credential manager - "remmina", # RDP session manager - "usb-creator-gtk", # Easily flash bootable USBs - "i3lock", # Lock screen - "meld", # Diff tool - "xclip", # Clipboard for X11 - "wl-clipboard", # Clipboard for Wayland - "xdotool", # X11 automation tool - "kitty", # Kitty terminal emulator - "kitty-terminfo", # Kitty TERMINFO - "webp", # Command line support for webp image files - ], - "media": [ - "inkscape", # Vector graphics editor - "mpv", # Minimal media player - "vlc", # General purpose FOSS media player - "easytag", # Edit ID3 Tags on MP3 files - "blueman", # Bluetooth device support - ], - "gaming": [ - "steam", - "steam-devices", - ], -} -# fmt: on - - -class AptProvisioner(IComponentProvisioner): - def __init__(self, args: ProvisionerArgs) -> None: - self._args = args - - def provision(self) -> None: - Apt.update(self._args.dry_run) - Apt.upgrade(self._args.dry_run) - - packages = ( - APT_PACKAGES["core"] + APT_PACKAGES["cli-tools"] + APT_PACKAGES["python-3"] - ) - - if self._args.tags.has(Tags.x11): - packages += APT_PACKAGES["gui-tools"] - packages += APT_PACKAGES["media"] - packages += APT_PACKAGES["gaming"] - - Apt.install(packages, self._args.dry_run) diff --git a/cli/lib/provision/provisioner_docker.py b/cli/lib/provision/provisioner_docker.py deleted file mode 100644 index 37c925f..0000000 --- a/cli/lib/provision/provisioner_docker.py +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env python - -import os -import re -import urllib - -from lib.common.apt import Apt -from lib.common.dir import Dir -from lib.common.distro_info import DistroInformation -from lib.common.group import Group -from lib.common.semver import Semver -from lib.common.shell import Shell -from lib.common.util import download_file, get_current_user -from lib.provision.provisioner import IComponentProvisioner, ProvisionerArgs - - -class DockerPackage: - def __init__(self, url, name, full_name, version): - self.url = url - self.name = name - self.full_name = full_name - self.version = version - self.semver = Semver.parse(version) - - -class DockerProvisioner(IComponentProvisioner): - def __init__(self, args: ProvisionerArgs) -> None: - self._args = args - - def provision(self) -> None: - distro_info = DistroInformation.get() - - base_url = f"https://download.docker.com/linux/ubuntu/dists/{distro_info.codename}/pool/stable/amd64" - - # TODO: Work in progress detecting latest available package versions - # and comparing against installed versions - installed_packages = Apt.get_installed_packages() - latest_packages = DockerProvisioner._get_latest_package_versions( - base_url, distro_info - ) - for p in latest_packages: - found = False - for i in installed_packages: - if i.name != p.name: - continue - found = True - print(f"{p.name}: {p.version} vs. {i.version}") - if not found: - raise Exception(f"{p.name} not installed") - return - - # TODO: Automatically choose latest version - packages = [ - "containerd.io_1.6.9-1_amd64.deb", - f"docker-ce_24.0.7-1~ubuntu.22.04~{distro_info.codename}_amd64.deb", - f"docker-ce-cli_24.0.7-1~ubuntu.22.04~{distro_info.codename}_amd64.deb", - f"docker-buildx-plugin_0.11.2-1~ubuntu.22.04~{distro_info.codename}_amd64.deb", - f"docker-compose-plugin_2.6.0~ubuntu-{distro_info.codename}_amd64.deb", - ] - - # TODO: Make this a common directory like Dir.tmp() - tmp_dir = os.path.join(Dir.home(), ".tmp") - Shell.mkdir(tmp_dir, True, False, self._args.dry_run) - - for package in packages: - download_file( - f"{base_url}/{package}", - os.path.join(tmp_dir, package), - False, - False, - self._args.dry_run, - ) - - Apt.install_deb_files(packages, self._args.dry_run) - - Group.add_user("docker", get_current_user().pw_name, self._args.dry_run) - - @staticmethod - def _get_latest_package_versions( - base_url: str, distro_info: DistroInformation - ) -> list[DockerPackage]: - version_regex_pattern = "[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+){0,1}" - package_regex_patterns = [ - f".*((containerd\.io)_({version_regex_pattern})_amd64.deb).*", - f".*((docker-ce)_({version_regex_pattern})~ubuntu.{distro_info.release}~{distro_info.codename}_amd64.deb).*", - f".*((docker-ce-cli)_({version_regex_pattern})~ubuntu.{distro_info.release}~{distro_info.codename}_amd64.deb).*", - f".*((docker-buildx-plugin)_({version_regex_pattern})~ubuntu.{distro_info.release}~{distro_info.codename}_amd64.deb).*", - f".*((docker-compose-plugin)_({version_regex_pattern})~ubuntu-{distro_info.codename}_amd64.deb).*", - ] - - def match(line): - for pattern in package_regex_patterns: - m = re.match(pattern, line) - if m is not None: - return m - return None - - response = urllib.request.urlopen(base_url).read().decode("utf-8") - - available_packages = {} - for line in response.split("\n"): - m = match(line) - if m is None: - continue - - full_name = m.group(1) - name = m.group(2) - version = m.group(3) - url = f"{base_url}/{full_name}" - - available_package = DockerPackage(url, name, full_name, version) - - if available_package.name not in available_packages: - available_packages[available_package.name] = [] - available_packages[name].append(available_package) - - latest_packages = [] - for package in available_packages: - sorted_packages = sorted( - available_packages[package], key=lambda p: p.semver, reverse=True - ) - latest_packages.append(sorted_packages[0]) - return latest_packages diff --git a/cli/lib/provision/provisioner_dot.py b/cli/lib/provision/provisioner_dot.py deleted file mode 100644 index f7d5a51..0000000 --- a/cli/lib/provision/provisioner_dot.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env python - -import os -import subprocess - -from lib.common.dir import Dir -from lib.common.log import Log -from lib.common.os import OperatingSystem -from lib.common.util import write_file -from lib.provision.provisioner import IComponentProvisioner, ProvisionerArgs - - -class DotProvisioner(IComponentProvisioner): - def __init__(self, args: ProvisionerArgs) -> None: - self._args = args - - def provision(self) -> None: - write_file( - os.path.join(Dir.home(), ".bash_completion.d", "dot.bash"), - self._generate_dot_cli_completion_script(), - sudo=False, - dry_run=self._args.dry_run, - ) - - def _generate_dot_cli_completion_script(self) -> str: - cmd = [ - "register-python-argcomplete", - "--external-argcomplete-script", - os.path.join(Dir.dot(), "cli/dot.py"), - "dot", - ] - - Log.info("generating dot cli completion script", [("command", " ".join(cmd))]) - - if self._args.dry_run: - Log.info( - "skipping dot cli completion script generation", [("reason", "dry run")] - ) - return "" - - p = subprocess.Popen( - cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True - ) - output, _ = p.communicate() - if p.returncode != 0: - print(p) - raise Exception("register-python-argcomplete script returned non-zero") - return output diff --git a/cli/lib/provision/provisioner_flavours.py b/cli/lib/provision/provisioner_flavours.py deleted file mode 100644 index 34938ba..0000000 --- a/cli/lib/provision/provisioner_flavours.py +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env python - -import os -import subprocess -from typing import Union - -from lib.common.archive import Archive -from lib.common.dir import Dir -from lib.common.github import Github -from lib.common.log import Log -from lib.common.semver import Semver -from lib.common.shell import Shell -from lib.provision.provisioner import IComponentProvisioner, ProvisionerArgs - -FLAVOURS_GITHUB_ORG = "Misterio77" -FLAVOURS_GITHUB_REPO = "flavours" - - -class FlavoursProvisioner(IComponentProvisioner): - def __init__(self, args: ProvisionerArgs) -> None: - self._args = args - - def provision(self) -> None: - latest_version = Github.get_latest_release( - FLAVOURS_GITHUB_ORG, FLAVOURS_GITHUB_REPO - ) - latest_version = Semver.parse(latest_version) - - current_version = FlavoursProvisioner._get_current_version() - if current_version is None: - Log.info(f"Flavours is not installed") - elif current_version < latest_version: - Log.info( - f"Flavours {current_version} is installed but {latest_version} is available" - ) - else: - Log.info(f"Flavours {latest_version} is already installed, nothing to do") - return - - tmp_dir = f"{Dir.home()}/Downloads/flavours/{latest_version}" - archive_filename = f"flavours-{latest_version}-x86_64-linux.tar.gz" - archive_path = os.path.join(tmp_dir, archive_filename) - - base_install_dir = "/opt/flavours" - install_dir = f"/opt/flavours/{latest_version}" - symlink_path = "/usr/local/bin/flavours" - - self._download_release_archive(latest_version, archive_path) - - Log.info("extracting flavours release archive") - Archive.extract(archive_path, tmp_dir, self._args.dry_run) - - Log.info("deleting flavours release archive") - Shell.rm(archive_path, False, False, False, self._args.dry_run) - - Log.info("creating base install directory", [("path", base_install_dir)]) - Shell.mkdir(base_install_dir, True, True, self._args.dry_run) - - Log.info("deleting existing install directory if there is one") - Shell.rm(install_dir, True, True, True, self._args.dry_run) - - Log.info("moving temp directory to install location") - Shell.mv(tmp_dir, install_dir, True, self._args.dry_run) - - Log.info("deleting existing symlink if there is one") - Shell.rm(symlink_path, False, True, True, self._args.dry_run) - - Log.info("creating symlink to executable in install directory") - Shell.ln( - os.path.join(install_dir, "flavours"), - symlink_path, - True, - self._args.dry_run, - ) - - self._flavours_update() - - def _download_release_archive(self, version: str, path: str) -> None: - if os.path.isfile(path): - Log.info("skipping download because file already exists", [("path", path)]) - return - - # Make sure the directory we are downloading to exists - Shell.mkdir(os.path.dirname(path), True, False, self._args.dry_run) - - Log.info("downloading flavours release archive") - Github.download_release_artifact( - FLAVOURS_GITHUB_ORG, - FLAVOURS_GITHUB_REPO, - version, - os.path.basename(path), - path, - True, - False, - False, - self._args.dry_run, - ) - - def _flavours_update(self) -> None: - Log.info("running flavours update") - - if self._args.dry_run: - Log.info("skipping flavours update due to --dry-run") - return - - p = subprocess.Popen( - ["flavours", "update", "all"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - # The shell provision script piped both stdout and stderr to /dev/null - # and didn't check exit code. Is that wise? - exit_code = p.wait() - if p.wait() != 0: - Log.warn( - "Flavours update returned non-zero exit code", - [("exit_code", exit_code)], - ) - - @staticmethod - def _get_current_version() -> Union[str, None]: - try: - p = subprocess.Popen( - ["flavours", "--version"], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - ) - stdout, _ = p.communicate() - if p.returncode != 0: - raise Exception("Flavours returned non-zero exit code") - - version_str = stdout.replace("flavours", "").strip() - return Semver.parse(version_str) - except FileNotFoundError as e: - return None diff --git a/cli/lib/provision/provisioner_i3.py b/cli/lib/provision/provisioner_i3.py deleted file mode 100644 index 797e5df..0000000 --- a/cli/lib/provision/provisioner_i3.py +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env python - -import os -import re -import subprocess -from typing import Tuple, Union - -from lib.common.apt import Apt -from lib.common.git import Git -from lib.common.github import Github -from lib.common.log import Log -from lib.common.semver import Semver -from lib.common.shell import Shell -from lib.provision.provisioner import IComponentProvisioner, ProvisionerArgs -from lib.provision.symlink import Symlink -from lib.provision.tag import Tags - -I3_GITHUB_ORG = "i3" -I3_GITHUB_REPO = "i3" - - -# TODO: Move this somewhere so that all provisioners can share it since we've -# duplicated this logic in several places. -def _i3_prepare_install_dir(install_dir: str, create: bool, dry_run: bool) -> None: - Log.info("deleting existing install directory if there is one") - Shell.rm(install_dir, True, True, True, dry_run) - - if create: - Log.info("creating install directory", [("path", install_dir)]) - Shell.mkdir(install_dir, True, True, dry_run) - else: - base_install_dir = os.path.dirname(install_dir) - Log.info("creating base install directory", [("path", base_install_dir)]) - Shell.mkdir(base_install_dir, True, True, dry_run) - - -def _i3_bootstrap(dry_run: bool): - Log.info("bootstrapping i3") - if dry_run: - Log.info("skipping i3 bootstrap", [("reason", "dry run")]) - return - if subprocess.call(["meson", ".."]) != 0: - raise Exception("meson returned non-zero exit code") - - -def _i3_build(dry_run: bool): - Log.info("building i3") - if dry_run: - Log.info("skipping i3 build", [("reason", "dry run")]) - return - if subprocess.call(["ninja"]) != 0: - raise Exception("ninja returned non-zero exit code") - - -class I3Provisioner(IComponentProvisioner): - def __init__(self, args: ProvisionerArgs) -> None: - super().__init__() - self._args = args - - def provision(self) -> None: - if not self._args.tags.has(Tags.x11): - Log.info("skipping i3 provisioner", [("reason", "x11 tag not present")]) - return - - latest_tag_name, latest_tag_version = I3Provisioner._get_latest_tag() - current_version = I3Provisioner._get_current_version() - print(current_version) - if current_version is None: - Log.info(f"i3 is not installed") - elif current_version < latest_tag_version: - Log.info( - f"i3 {current_version} is installed but {latest_tag_version} is available" - ) - else: - Log.info(f"i3 {latest_tag_version} is already installed, nothing to do") - return - - # TODO: i3-gaps was merged into i3 as of release 4.22 but the version - # in Apt in Ubuntu 22.04 is still 4.20 so build it from source for now. - # Once we move to 24.04 we can probably just install the apt package - # and remove this whole provisioner. - - # Install pre-requisite packages needed to compile i3 from source - # TODO: I'm not sure if this is still accurate since the merge of - # i3-gaps into i3. For example, i3 builds with meson/ninja now and not - # automake so that is almost certainly not necessary. Since all of this - # will hopefully be deleted soon when we can just install from apt it's - # not worth the effort to clean up. - Apt.install( - [ - "libxcb1-dev", - "libxcb-keysyms1-dev", - "libpango1.0-dev", - "libxcb-util0-dev", - "libxcb-icccm4-dev", - "libyajl-dev", - "libstartup-notification0-dev", - "libxcb-randr0-dev", - "libev-dev", - "libxcb-cursor-dev", - "libxcb-xinerama0-dev", - "libxcb-xkb-dev", - "libxkbcommon-dev", - "libxkbcommon-x11-dev", - "autoconf", - "libxcb-xrm0", - "libxcb-xrm-dev", - "libxcb-shape0", - "libxcb-shape0-dev", - "automake", - ], - self._args.dry_run, - ) - - url = f"https://www.github.com/{I3_GITHUB_ORG}/{I3_GITHUB_REPO}" - staging_dir = f"/home/pewing/.tmp/i3/{latest_tag_name}" - build_dir = os.path.join(staging_dir, "build") - cwd = os.getcwd() - - Shell.rm(staging_dir, True, True, False, self._args.dry_run) - repo = Git.clone(url, staging_dir, self._args.dry_run) - repo.checkout(latest_tag_name, self._args.dry_run) - Shell.mkdir(build_dir, True, False, self._args.dry_run) - Shell.cd(build_dir, self._args.dry_run) - _i3_bootstrap(self._args.dry_run) - _i3_build(self._args.dry_run) - Shell.cd(cwd, self._args.dry_run) - - install_dir = os.path.join("/opt/i3", latest_tag_name) - - _i3_prepare_install_dir(install_dir, False, self._args.dry_run) - Shell.mv(staging_dir, install_dir, True, self._args.dry_run) - - executables = [ - "build/i3", - "build/i3-config-wizard", - "build/i3-dump-log", - "build/i3-input", - "build/i3-msg", - "build/i3-nagbar", - "build/i3bar", - "i3-dmenu-desktop", - "i3-save-tree", - "i3-sensible-editor", - "i3-sensible-pager", - "i3-sensible-terminal", - ] - - Log.info("setting up i3wm symlinks") - for exe in executables: - source = os.path.join(install_dir, exe) - target = os.path.join("/usr/local/bin", os.path.basename(source)) - Symlink.create( - source=source, - target=target, - sudo=True, - dry_run=self._args.dry_run, - ) - - @staticmethod - def _get_latest_tag() -> Tuple[str, Semver]: - tags = Github.get_tags(I3_GITHUB_ORG, I3_GITHUB_REPO) - semver_tags = {} - for tag in tags: - tag_name = tag["name"] - m = re.match("[0-9]+\.[0-9]+(\.[0-9]){0,1}", tag_name) - if m is not None: - semver_tags[tag_name] = Semver.parse(tag_name) - return sorted(semver_tags.items(), reverse=True)[0] - - @staticmethod - def _get_current_version() -> Union[str, None]: - try: - p = subprocess.Popen( - ["i3", "--version"], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - ) - stdout, _ = p.communicate() - if p.returncode != 0: - raise Exception("i3 returned non-zero exit code") - m = re.match("i3 version ([0-9]+\.[0-9]+\.[0-9]+)", stdout) - if m is None: - return None - return Semver.parse(m.group(1)) - except FileNotFoundError as e: - return None diff --git a/cli/lib/provision/provisioner_kitty.py b/cli/lib/provision/provisioner_kitty.py deleted file mode 100644 index aabf918..0000000 --- a/cli/lib/provision/provisioner_kitty.py +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env python - -import os -import re -import subprocess -from typing import Union - -from lib.common.archive import Archive -from lib.common.dir import Dir -from lib.common.github import Github -from lib.common.log import Log -from lib.common.semver import Semver -from lib.common.shell import Shell -from lib.provision.provisioner import IComponentProvisioner, ProvisionerArgs - -KITTY_GITHUB_ORG = "kovidgoyal" -KITTY_GITHUB_REPO = "kitty" - - -class KittyProvisioner(IComponentProvisioner): - def __init__(self, args: ProvisionerArgs) -> None: - self._args = args - - def provision(self) -> None: - latest_release = Github.get_latest_release(KITTY_GITHUB_ORG, KITTY_GITHUB_REPO) - latest_version = Semver.parse(latest_release) - - current_version = KittyProvisioner._get_current_version() - if current_version is None: - Log.info(f"Kitty is not installed") - elif current_version < latest_version: - Log.info( - f"Kitty {current_version} is installed but {latest_version} is available" - ) - else: - Log.info(f"Kitty {latest_version} is already installed, nothing to do") - return - - tmp_dir = f"{Dir.home()}/Downloads/kitty/{latest_version}" - archive_filename = f"kitty-{latest_release.replace('v', '')}-x86_64.txz" - archive_path = os.path.join(tmp_dir, archive_filename) - - base_install_dir = "/opt/kitty" - install_dir = f"/opt/kitty/{latest_version}" - symlink_path_kitty = "/usr/local/bin/kitty" - symlink_path_kitten = "/usr/local/bin/kitten" - - Log.info("downloading kitty release archive") - Github.download_release_artifact( - KITTY_GITHUB_ORG, - KITTY_GITHUB_REPO, - latest_release, - archive_filename, - archive_path, - True, - False, - False, - self._args.dry_run, - ) - - Log.info("extracting kitty release archive") - Archive.extract(archive_path, tmp_dir, self._args.dry_run) - - Log.info("deleting kitty release archive") - Shell.rm(archive_path, False, False, False, self._args.dry_run) - - Log.info("creating base install directory", [("path", base_install_dir)]) - Shell.mkdir(base_install_dir, True, True, self._args.dry_run) - - Log.info("deleting existing install directory if there is one") - Shell.rm(install_dir, True, True, True, self._args.dry_run) - - Log.info("moving temp directory to install location") - Shell.mv(tmp_dir, install_dir, True, self._args.dry_run) - - Log.info("deleting existing symlinks") - Shell.rm(symlink_path_kitty, False, True, True, self._args.dry_run) - Shell.rm(symlink_path_kitten, False, True, True, self._args.dry_run) - - Log.info("creating symlinks to executables in install directory") - Shell.ln( - os.path.join(install_dir, "bin/kitty"), - symlink_path_kitty, - True, - self._args.dry_run, - ) - Shell.ln( - os.path.join(install_dir, "bin/kitten"), - symlink_path_kitten, - True, - self._args.dry_run, - ) - - @staticmethod - def _get_current_version() -> Union[str, None]: - try: - p = subprocess.Popen( - ["kitty", "--version"], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - ) - stdout, _ = p.communicate() - if p.returncode != 0: - raise Exception("Kitty returned non-zero exit code") - m = re.match("kitty ([0-9]+.[0-9]+.[0-9]+) created by Kovid Goyal", stdout) - if m is None: - return None - return Semver.parse(m.group(1)) - except FileNotFoundError as e: - return None diff --git a/cli/lib/provision/provisioner_neovim.py b/cli/lib/provision/provisioner_neovim.py deleted file mode 100644 index c1ea300..0000000 --- a/cli/lib/provision/provisioner_neovim.py +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env python - -import os -import re -import subprocess -from typing import Union - -from lib.common.alternatives import Alternatives -from lib.common.dir import Dir -from lib.common.github import Github -from lib.common.log import Log -from lib.common.pip import Pip -from lib.common.semver import Semver -from lib.common.shell import Shell -from lib.provision.provisioner import IComponentProvisioner, ProvisionerArgs - -NEOVIM_GITHUB_ORG = "neovim" -NEOVIM_GITHUB_REPO = "neovim" - - -class NeovimProvisioner(IComponentProvisioner): - def __init__(self, args: ProvisionerArgs) -> None: - self._args = args - - def provision(self) -> None: - latest_version = Github.get_latest_release( - NEOVIM_GITHUB_ORG, NEOVIM_GITHUB_REPO - ) - latest_version = Semver.parse(latest_version) - - current_version = NeovimProvisioner._get_current_version() - if current_version is None: - Log.info(f"Neovim is not installed") - elif current_version < latest_version: - Log.info( - f"Neovim {current_version} is installed but {latest_version} is available" - ) - else: - Log.info(f"Neovim {latest_version} is already installed, nothing to do") - return - - tmp_dir = f"{Dir.home()}/Downloads/neovim/{latest_version}" - appimage_filename = "nvim.appimage" - appimage_path = os.path.join(tmp_dir, appimage_filename) - - base_install_dir = "/opt/neovim" - install_dir = f"/opt/neovim/{latest_version}" - install_path = os.path.join(install_dir, appimage_filename) - symlink_path = "/usr/local/bin/nvim" - - self._download_release_appimage(latest_version, appimage_path) - - Log.info("deleting existing install directory if there is one") - Shell.rm(install_dir, True, True, True, self._args.dry_run) - - Log.info("creating install directory", [("path", install_dir)]) - Shell.mkdir(install_dir, True, True, self._args.dry_run) - - Log.info("moving appimage to install location") - Shell.mv(appimage_path, install_path, True, self._args.dry_run) - - Log.info("making appimage file executable") - Shell.chmod("+x", install_path, False, self._args.dry_run) - - Log.info("deleting existing symlink") - Shell.rm(symlink_path, False, True, True, self._args.dry_run) - - Log.info("creating symlinks to executables in install directory") - Shell.ln(install_path, symlink_path, True, self._args.dry_run) - - Log.info("installing pynvim python modules") - Pip.install(["pynvim"], True, True, self._args.dry_run) - - Log.info("updating alternatives to use nvim") - Alternatives.install( - "/usr/bin/vi", "vi", symlink_path, 60, True, self._args.dry_run - ) - Alternatives.set("vi", symlink_path, True, self._args.dry_run) - Alternatives.install( - "/usr/bin/vim", "vim", symlink_path, 60, True, self._args.dry_run - ) - Alternatives.set("vim", symlink_path, True, self._args.dry_run) - Alternatives.install( - "/usr/bin/editor", "editor", symlink_path, 60, True, self._args.dry_run - ) - Alternatives.set("editor", symlink_path, True, self._args.dry_run) - - def _download_release_appimage(self, version: str, path: str) -> None: - if os.path.isfile(path): - Log.info("skipping download because file already exists", [("path", path)]) - return - - # Make sure the directory we are downloading to exists - Shell.mkdir(os.path.dirname(path), True, False, self._args.dry_run) - - Log.info("downloading neovim release appimage") - Github.download_release_artifact( - NEOVIM_GITHUB_ORG, - NEOVIM_GITHUB_REPO, - version, - os.path.basename(path), - path, - True, - False, - False, - self._args.dry_run, - ) - - @staticmethod - def _get_current_version() -> Union[str, None]: - try: - p = subprocess.Popen( - ["nvim", "--version"], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - ) - stdout, _ = p.communicate() - if p.returncode != 0: - raise Exception("Neovim returned non-zero exit code") - m = re.match("NVIM (v[0-9]+\.[0-9]+\.[0-9]+)", stdout) - if m is None: - return None - return Semver.parse(m.group(1)) - except FileNotFoundError as e: - return None diff --git a/cli/lib/provision/provisioner_nodejs.py b/cli/lib/provision/provisioner_nodejs.py deleted file mode 100644 index 0c14f47..0000000 --- a/cli/lib/provision/provisioner_nodejs.py +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python - -import os -import subprocess - -from lib.common.archive import Archive -from lib.common.dir import Dir -from lib.common.github import Github -from lib.common.log import Log -from lib.common.semver import Semver -from lib.common.shell import Shell -from lib.common.typing import StringOrNone -from lib.common.util import download_file -from lib.provision.provisioner import IComponentProvisioner, ProvisionerArgs - -KITTY_GITHUB_ORG = "nodejs" -KITTY_GITHUB_REPO = "node" - - -class NodeJSProvisioner(IComponentProvisioner): - def __init__(self, args: ProvisionerArgs) -> None: - self._args = args - - def provision(self) -> None: - org = "nodejs" - repo = "node" - - # Get the currently installed nodejs version - current_nodejs_version = NodeJSProvisioner._get_current_version() - Log.info( - "identified current nodejs version", [("version", current_nodejs_version)] - ) - - # Get latest nodejs version and convert to semver (I.E. "v22.2.0") - latest_nodejs_release = Github.get_latest_release(org, repo) - latest_nodejs_version = Semver.parse(latest_nodejs_release) - Log.info( - "identified latest nodejs version", [("version", latest_nodejs_version)] - ) - - # TODO: Make a utility function for this logic? - if current_nodejs_version is None: - Log.info(f"nodejs is not installed") - elif current_nodejs_version < latest_nodejs_version: - Log.info( - f"nodejs {current_nodejs_version} is installed but {latest_nodejs_version} is available" - ) - else: - Log.info( - f"nodejs {latest_nodejs_version} is already installed, nothing to do" - ) - return - - self._install(latest_nodejs_version) - - def _install(self, version: str) -> None: - staging_dir = Dir.staging("nodejs", str(version)) - install_dir = Dir.install("nodejs", str(version)) - - nodejs_archive_name = f"node-{version}-linux-x64.tar.xz" - nodejs_archive_url = f"https://nodejs.org/dist/{version}/{nodejs_archive_name}" - nodejs_archive_path = f"{staging_dir}/{nodejs_archive_name}" - - # Download nodejs release tarball - download_file( - nodejs_archive_url, nodejs_archive_path, False, False, self._args.dry_run - ) - - # Extract node tarball - Log.info( - "extracting nodejs release archive", - [("archive", nodejs_archive_path), ("dst", staging_dir)], - ) - Archive.extract(nodejs_archive_path, staging_dir, self._args.dry_run) - - # Move to install directory - Shell.mkdir(os.path.dirname(install_dir), True, True, self._args.dry_run) - Shell.mv( - os.path.join(staging_dir, f"node-{version}-linux-x64"), - install_dir, - True, - self._args.dry_run, - ) - - nodejs_executables = ["corepack", "node", "npm", "npx"] - - Log.info( - "creating nodejs executable symlinks", [("executables", nodejs_executables)] - ) - - # TODO: We should make a Symlink.create() method that handles deleting existing links and whatnot - for exe in nodejs_executables: - symlink_src_path = os.path.join(install_dir, "bin", exe) - symlink_dst_path = os.path.join("/usr/local/bin", exe) - Shell.rm(symlink_dst_path, False, True, True, self._args.dry_run) - Shell.ln(symlink_src_path, symlink_dst_path, True, self._args.dry_run) - - @staticmethod - def _get_current_version() -> StringOrNone: - try: - p = subprocess.Popen( - ["node", "--version"], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - ) - stdout, _ = p.communicate() - if p.returncode != 0: - raise Exception("node returned non-zero exit code") - - version_str = stdout.strip() - return Semver.parse(version_str) - except FileNotFoundError as e: - return None diff --git a/cli/lib/provision/provisioner_pip.py b/cli/lib/provision/provisioner_pip.py deleted file mode 100644 index 6e7ec53..0000000 --- a/cli/lib/provision/provisioner_pip.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python - -from lib.common.pip import Pip -from lib.provision.provisioner import IComponentProvisioner, ProvisionerArgs - -_PIP_PACKAGES = { - "core": [ - "black", - "mypy", - "isort", - "flake8", - "autoflake", - "ruff", - "argcomplete", - ], -} - - -class PipProvisioner(IComponentProvisioner): - def __init__(self, args: ProvisionerArgs) -> None: - self._args = args - - def provision(self) -> None: - packages = _PIP_PACKAGES["core"] - Pip.install( - packages=packages, - upgrade=True, - sudo=False, - dry_run=self._args.dry_run, - ) diff --git a/cli/lib/provision/provisioner_ripgrep.py b/cli/lib/provision/provisioner_ripgrep.py deleted file mode 100644 index 1007515..0000000 --- a/cli/lib/provision/provisioner_ripgrep.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env python - -import os -import re -import subprocess -from typing import Union - -from lib.common.apt import Apt -from lib.common.dir import Dir -from lib.common.github import Github -from lib.common.log import Log -from lib.common.semver import Semver -from lib.provision.provisioner import IComponentProvisioner, ProvisionerArgs - -RIPGREP_GITHUB_ORG = "BurntSushi" -RIPGREP_GITHUB_REPO = "ripgrep" - - -class RipgrepProvisioner(IComponentProvisioner): - def __init__(self, args: ProvisionerArgs) -> None: - self._args = args - - def provision(self) -> None: - latest_release = Github.get_latest_release( - RIPGREP_GITHUB_ORG, RIPGREP_GITHUB_REPO - ) - - # TODO: Standardize and share this behavior since it's the same in most provisioners - # Maybe have like a `get_action` function on provisioners that returns one of three possible actions: - # No-op, Update, Install, and a reason - current_version = RipgrepProvisioner._get_current_version() - if current_version is None: - Log.info(f"ripgrep is not installed") - elif current_version < Semver.parse(latest_release): - Log.info( - f"ripgrep {current_version} is installed but {latest_release} is available" - ) - else: - Log.info(f"ripgrep {latest_release} is already installed, nothing to do") - return - - staging_dir = Dir.staging("ripgrep", latest_release) - - deb_name = f"ripgrep_{latest_release}-1_amd64.deb" - deb_path = os.path.join(staging_dir, deb_name) - - Github.download_release_artifact( - RIPGREP_GITHUB_ORG, - RIPGREP_GITHUB_REPO, - latest_release, - deb_name, - deb_path, - True, - False, - False, - self._args.dry_run, - ) - - Apt.install_deb_files([deb_path], self._args.dry_run) - - @staticmethod - def _get_current_version() -> Union[str, None]: - try: - p = subprocess.Popen( - ["rg", "--version"], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - ) - stdout, _ = p.communicate() - if p.returncode != 0: - raise Exception("ripgrep returned non-zero exit code") - m = re.match("ripgrep ([0-9]+\.[0-9]+\.[0-9]+)", stdout) - if m is None: - return None - return Semver.parse(m.group(1)) - except FileNotFoundError as e: - return None diff --git a/cli/lib/provision/provisioner_treesitter.py b/cli/lib/provision/provisioner_treesitter.py deleted file mode 100644 index c3138b8..0000000 --- a/cli/lib/provision/provisioner_treesitter.py +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env python - -import os -import re -import subprocess -from typing import Union - -from lib.common.dir import Dir -from lib.common.github import Github -from lib.common.log import Log -from lib.common.semver import Semver -from lib.common.shell import Shell -from lib.provision.provisioner import IComponentProvisioner, ProvisionerArgs - -TREE_SITTER_GITHUB_ORG = "tree-sitter" -TREE_SITTER_GITHUB_REPO = "tree-sitter" - - -# TODO: Move this somewhere so that all provisioners can share it since we've -# duplicated this logic in several places -def prepare_install_dir(install_dir: str, create: bool, dry_run: bool) -> None: - Log.info("deleting existing install directory if there is one") - Shell.rm(install_dir, True, True, True, dry_run) - - if create: - Log.info("creating install directory", [("path", install_dir)]) - Shell.mkdir(install_dir, True, True, dry_run) - else: - base_install_dir = os.path.dirname(install_dir) - Log.info("creating base install directory", [("path", base_install_dir)]) - Shell.mkdir(base_install_dir, True, True, dry_run) - - -class TreeSitterProvisioner(IComponentProvisioner): - def __init__(self, args: ProvisionerArgs) -> None: - self._args = args - - def provision(self) -> None: - latest_version = TreeSitterProvisioner._get_latest_release() - - current_version = TreeSitterProvisioner._get_current_version() - if current_version is None: - Log.info(f"tree-sitter is not installed") - elif current_version < latest_version: - Log.info( - f"tree-sitter {current_version} is installed but {latest_version} is available" - ) - else: - Log.info( - f"tree-sitter {latest_version} is already installed, nothing to do" - ) - return - - staging_dir = Dir.staging("tree-sitter", str(latest_version)) - install_dir = Dir.install("tree-sitter", str(latest_version)) - - exe_name = "tree-sitter-linux-x64" - exe_path_staging = os.path.join(staging_dir, exe_name) - exe_path_install = os.path.join(install_dir, exe_name) - - zip_name = f"{exe_name}.gz" - zip_path_staging = os.path.join(staging_dir, zip_name) - - symlink_path = "/usr/local/bin/tree-sitter" - - self._download_release_zip(str(latest_version), zip_path_staging) - - TreeSitterProvisioner._unzip_executable(zip_path_staging, self._args.dry_run) - - prepare_install_dir(install_dir, True, self._args.dry_run) - - Log.info("moving executable to installation directory") - Shell.mv(exe_path_staging, exe_path_install, True, self._args.dry_run) - - Log.info("making file executable") - Shell.chmod("+x", exe_path_install, True, self._args.dry_run) - - Log.info("deleting existing symlink if there is one") - Shell.rm(symlink_path, False, True, True, self._args.dry_run) - - Log.info("creating symlink to executable in install directory") - Shell.ln(exe_path_install, symlink_path, True, self._args.dry_run) - - def _download_release_zip(self, version: str, path: str) -> None: - if os.path.isfile(path): - Log.info("skipping download because file already exists", [("path", path)]) - return - - Log.info("downloading tree-sitter release archive") - Github.download_release_artifact( - TREE_SITTER_GITHUB_ORG, - TREE_SITTER_GITHUB_REPO, - version, - os.path.basename(path), - path, - True, - False, - False, - self._args.dry_run, - ) - - @staticmethod - def _unzip_executable(zip_path: str, dry_run: bool) -> None: - Log.info("unzipping zip file", [("path", zip_path)]) - if dry_run: - Log.info("skipping apt update due to --dry-run") - else: - if subprocess.call(["gunzip", zip_path]) != 0: - raise Exception("Failed to unzip tree-sitter executable") - - @staticmethod - def _get_latest_release() -> Semver: - releases = Github.get_releases(TREE_SITTER_GITHUB_ORG, TREE_SITTER_GITHUB_REPO) - tags = [r["tag_name"] for r in releases] - tags = filter(lambda t: "pre-release" not in t, tags) - tags = [Semver.parse(t) for t in tags] - tags = sorted(tags, reverse=True) - return tags[0] - - @staticmethod - def _get_current_version() -> Union[str, None]: - try: - p = subprocess.Popen( - ["tree-sitter", "--version"], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - ) - stdout, _ = p.communicate() - if p.returncode != 0: - raise Exception("tree-sitter returned non-zero exit code") - m = re.match("tree-sitter ([0-9]\.[0-9]+\.[0-9]+)", stdout) - if m is None: - return None - return Semver.parse(m.group(1)) - except FileNotFoundError as e: - return None diff --git a/cli/lib/provision/provisioner_win32yank.py b/cli/lib/provision/provisioner_win32yank.py deleted file mode 100644 index 168a45e..0000000 --- a/cli/lib/provision/provisioner_win32yank.py +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env python - -import os -from typing import Union - -from lib.common.archive import Archive -from lib.common.dir import Dir -from lib.common.github import Github -from lib.common.log import Log -from lib.common.semver import Semver -from lib.common.shell import Shell -from lib.common.util import write_file -from lib.provision.provisioner import IComponentProvisioner, ProvisionerArgs -from lib.provision.tag import Tags - -WIN32YANK_GITHUB_ORG = "equalsraf" -WIN32YANK_GITHUB_REPO = "win32yank" - - -class Win32YankProvisioner(IComponentProvisioner): - def __init__(self, args: ProvisionerArgs) -> None: - self._args = args - - def provision(self) -> None: - if not self._args.tags.has(Tags.wsl): - Log.info( - "skipping win32yank provisioner", [("reason", "wsl tag not present")] - ) - return - - # There's an open issue as of implementing this on 2024-05-20 where - # running win32yank.exe from path within the WSL file system is very slow: - # https://github.com/equalsraf/win32yank/issues/22 - # To avoid that, make sure we install this on the Windows C: drive. Set - # this before calling _get_current_version() since we need it to - # construct the path to the version file. - self._install_dir = f"/mnt/c/bin/" - - latest_version = Github.get_latest_release( - WIN32YANK_GITHUB_ORG, WIN32YANK_GITHUB_REPO - ) - latest_version = Semver.parse(latest_version) - - # TODO: Gross but theres' no way to get the version from the - # executable. It doesn't have a `--version` flag and the - # ProductInfo.VersionInfo metadata on the exe isn't populated. - # As a hack, just write the version installed to a text file next to - # the exe called win32yank_version.txt and check that. - - current_version = self._get_current_version() - if current_version is None: - Log.info(f"Win32Yank is not installed") - elif current_version < latest_version: - Log.info( - f"Win32Yank {current_version} is installed but {latest_version} is available" - ) - else: - Log.info(f"Win32Yank {latest_version} is already installed, nothing to do") - return - - self._install(latest_version) - - def _install(self, version: str) -> None: - self._staging_dir = Dir.staging("win32yank", str(version)) - - self._archive_name = f"win32yank-x64.zip" - self._archive_url = f"https://github.com/equalsraf/win32yank/releases/download/${version}/{self._archive_name}" - self._archive_path = f"{self._staging_dir}/{self._archive_name}" - - Github.download_release_artifact( - org=WIN32YANK_GITHUB_ORG, - repo=WIN32YANK_GITHUB_REPO, - release=version, - file=self._archive_name, - path=self._archive_path, - create_dir=True, - sudo=False, - force=False, - dry_run=self._args.dry_run, - ) - - Log.info("extracting win32yank release archive") - Archive.extract(self._archive_path, self._staging_dir, self._args.dry_run) - - Log.info("creating win32yank install directory", [("path", self._install_dir)]) - Shell.mkdir( - path=self._install_dir, - exist_ok=True, - sudo=False, - dry_run=self._args.dry_run, - ) - - Log.info("moving win32yank.exe to install location") - Shell.mv( - src=os.path.join(self._staging_dir, "win32yank.exe"), - dst=os.path.join(self._install_dir, "win32yank.exe"), - sudo=False, - dry_run=self._args.dry_run, - ) - - Log.info("deleting win32yank staging directory") - Shell.rm( - path=self._staging_dir, - recursive=True, - force=True, - sudo=False, - dry_run=self._args.dry_run, - ) - - self._write_version_file(version) - - def _write_version_file(self, version: str) -> None: - Log.info( - "writing version file", - [("path", self._version_file_path()), ("version", version)], - ) - write_file( - path=self._version_file_path(), - content=str(version), - sudo=False, - dry_run=self._args.dry_run, - ) - - def _read_version_file(self) -> Union[str, None]: - Log.info("reading version file", [("path", self._version_file_path())]) - if not os.path.isfile(self._version_file_path()): - return None - with open(self._version_file_path(), "r") as f: - return f.read() - - def _version_file_path(self) -> str: - return os.path.join(self._install_dir, "win32yank_version.txt") - - def _get_current_version(self) -> Union[str, None]: - version_str = self._read_version_file() - if version_str is None: - return None - return Semver.parse(version_str) diff --git a/cli/lib/provision/symlink.py b/cli/lib/provision/symlink.py deleted file mode 100644 index 9ebc59b..0000000 --- a/cli/lib/provision/symlink.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python - -from lib.common.log import Log -from lib.common.shell import Shell - - -class Symlink: - @staticmethod - def create(source: str, target: str, sudo: bool, dry_run: bool) -> None: - Log.info("creating symlink", [("source", source), ("target", target)]) - - if dry_run: - Log.info("skipping symlink creation", [("reason", "dry run")]) - return - - if not os.path.isfile(source): - raise Exception(f"Symlink source doesn't exist: {source}") - - # Delete existing symlink target if there is one - Shell.rm( - path=target, - recursive=False, - force=True, - sudo=sudo, - dry_run=dry_run, - ) - - # Create the symlink - Shell.ln( - source=source, - target=target, - sudo=sudo, - dry_run=dry_run, - ) diff --git a/cli/lib/provision/system_provisioner.py b/cli/lib/provision/system_provisioner.py deleted file mode 100644 index 8d3a3f3..0000000 --- a/cli/lib/provision/system_provisioner.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python - - -from lib.common.log import Log -from lib.provision.provisioner import ISystemProvisioner, ProvisionerArgs -from lib.provision.provisioner_apt import AptProvisioner -from lib.provision.provisioner_dot import DotProvisioner -from lib.provision.provisioner_flavours import FlavoursProvisioner -from lib.provision.provisioner_i3 import I3Provisioner -from lib.provision.provisioner_kitty import KittyProvisioner -from lib.provision.provisioner_neovim import NeovimProvisioner -from lib.provision.provisioner_nodejs import NodeJSProvisioner -from lib.provision.provisioner_pip import PipProvisioner -from lib.provision.provisioner_ripgrep import RipgrepProvisioner -from lib.provision.provisioner_treesitter import TreeSitterProvisioner -from lib.provision.provisioner_win32yank import Win32YankProvisioner - -# As of Python 3.7: -# > The insertion-order preservation nature of dict objects has been declared -# > to be an official part of the Python language spec. -# -# So, these will remain in order when iterating through the map which is -# important because some component provisioners may depend on others having -# already run, such as an apt update. We could enforce this more explicitly -# through a dependency system but that seems like overkill. -# -# TODO: I don't love how this is setup up. Right now this file shouldn't need -# to import all of the component provisioner types. Maybe we can make like a -# ComponentProvisionerRegistry and all of them can register themselves and then -# this class can just grab them from the registry. -# -# fmt: off -_COMPONENT_PROVISIONERS = { - "apt": AptProvisioner, - # TODO: Commenting out for now because this is WIP; currently raises an - # exception when Docker isn't already installed - #"docker": DockerProvisioner, - "kitty": KittyProvisioner, - "flavours": FlavoursProvisioner, - "neovim": NeovimProvisioner, - "tree-sitter": TreeSitterProvisioner, - "ripgrep": RipgrepProvisioner, - "i3": I3Provisioner, - "nodejs": NodeJSProvisioner, - "pip": PipProvisioner, - "dot": DotProvisioner, - "win32yank": Win32YankProvisioner, - # TODO: install_cava "$cache_dir" - # TODO: install_youtube-dl "$cache_dir" "$bin_dir" - # TODO: install_wpr "$cache_dir" "$bin_dir" - # TODO: install_mpd - # TODO: install_ncmpcpp -} -# fmt: on - - -def _all_components() -> list[str]: - return _COMPONENT_PROVISIONERS.keys() - - -class SystemProvisioner(ISystemProvisioner): - def __init__(self, args: ProvisionerArgs, components: list[str]) -> None: - self._args = args - self._components = components if len(components) > 0 else _all_components() - - def provision(self) -> None: - component_provisioners = {} - for component in self._components: - if component not in _COMPONENT_PROVISIONERS: - raise Exception(f"component not supported: {component}") - component_provisioners[component] = _COMPONENT_PROVISIONERS[component]( - self._args - ) - - for component in component_provisioners: - Log.info("provisioning component", [("component", component)]) - component_provisioners[component].provision() - - @staticmethod - def get_provisioner_list() -> list[str]: - return _all_components() diff --git a/cli/lib/provision/tag.py b/cli/lib/provision/tag.py deleted file mode 100644 index 673dcba..0000000 --- a/cli/lib/provision/tag.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env python - -from lib.common.os import OperatingSystem - - -class Tag: - def __init__(self, name: str) -> None: - self.name = name - - -class Tags: - x11 = Tag("x11") - wsl = Tag("wsl") - - def __init__(self, tags: list[Tag]) -> None: - self.tags = tags - - def has(self, tag: Tag) -> bool: - return any(t.name == tag.name for t in self.tags) - - @staticmethod - def default() -> "Tags": - tags = [] - - os = OperatingSystem.get() - - if os.is_wsl(): - tags.append(Tags.wsl) - - # TODO: Detect X11 - if not os.is_wsl(): - tags.append(Tags.x11) - - return Tags(tags) - - @staticmethod - def parse(tag_names: str) -> "Tags": - return Tags([Tag(tag_name) for tag_name in tag_names.split(",")]) diff --git a/config/bash/aliases.sh b/config/bash/aliases.sh index 2a72e08..4bc7e30 100644 --- a/config/bash/aliases.sh +++ b/config/bash/aliases.sh @@ -34,8 +34,8 @@ function set_alias() { alias "$name=$command" } -# Dotfiles CLI -set_alias '1' 'dot' '$DOTFILES/cli/dot.py' +# Re-apply dotfiles +set_alias '0' 'df_apply' '$DOTFILES/apply.sh' # Edit/reload bash configs set_alias '0' 'aliases' '$EDITOR $DOTFILES/config/bash/aliases.sh' @@ -43,6 +43,7 @@ set_alias '0' 'functions' '$EDITOR $DOTFILES/config/bash/functions.sh' set_alias '0' 'reload_aliases' 'source $DOTFILES/config/bash/aliases.sh' set_alias '0' 'reload_functions' 'source $DOTFILES/config/bash/functions.sh' set_alias '0' 'localrc' '$EDITOR $HOME/.localrc' +set_alias '0' 'df_logs' '$EDITOR $HOME/.local/state/dotfiles/logs' # Basic aliases set_alias '0' 'cl' 'clear' @@ -73,11 +74,15 @@ fi # Sorted, human-readable disk usage by depth if _is_installed 'du'; then - set_alias '0' 'du1' 'du -hd1 2>/dev/null | sort -hr' - set_alias '0' 'du2' 'du -hd2 2>/dev/null | sort -hr' - set_alias '0' 'du3' 'du -hd3 2>/dev/null | sort -hr' - set_alias '0' 'du4' 'du -hd4 2>/dev/null | sort -hr' - set_alias '0' 'du5' 'du -hd5 2>/dev/null | sort -hr' + set_alias '0' 'du1' 'du -hd1 2>/dev/null | sort -h' + set_alias '0' 'du2' 'du -hd2 2>/dev/null | sort -h' + set_alias '0' 'du3' 'du -hd3 2>/dev/null | sort -h' + set_alias '0' 'du4' 'du -hd4 2>/dev/null | sort -h' + set_alias '0' 'du5' 'du -hd5 2>/dev/null | sort -h' + set_alias '0' 'du6' 'du -hd6 2>/dev/null | sort -h' + set_alias '0' 'du7' 'du -hd7 2>/dev/null | sort -h' + set_alias '0' 'du8' 'du -hd8 2>/dev/null | sort -h' + set_alias '0' 'du9' 'du -hd9 2>/dev/null | sort -h' fi # Ranger aliases @@ -103,7 +108,8 @@ fi # Apt aliases if _is_installed 'apt'; then set_alias '0' 'apti' 'sudo apt install -y' - set_alias '0' 'apts' 'sudo apt search' + # IMPROVEMENT: Removed sudo from apt search - sudo is unnecessary for search operations + set_alias '0' 'apts' 'apt search' fi # Pacman aliases @@ -214,7 +220,8 @@ if _is_installed 'docker'; then fi if _is_installed 'python'; then - set_alias '0' 'serve' 'python -m SimpleHTTPServer' + #set_alias '0' 'serve' 'python -m SimpleHTTPServer' + set_alias '0' 'serve' 'python -m http.server' fi # On Ubuntu, the system-supplied open file dialog can be very slow to open. @@ -228,3 +235,8 @@ fi if _is_installed 'parsecd'; then set_alias '0' 'parsec' 'parsecd app_daemon=1' fi + +# Alias for WSL to open a new WezTerm window with a Git Bash shell +if _is_wsl; then + set_alias '0' 'git-bash' '_git_bash' +fi diff --git a/config/bash/base16.sh b/config/bash/base16.sh new file mode 100644 index 0000000..74da5cd --- /dev/null +++ b/config/bash/base16.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +# If the shell is non-interactive, don't do anything +[[ $- == *i* ]] || return + +# Base16 color scheme +base16_shell_dir="$HOME/.config/base16-shell" +[ ! -d "$base16_shell_dir" ] && \ + git clone "https://github.com/chriskempson/base16-shell.git" \ + "$base16_shell_dir" + +BASE16_SHELL_SET_BACKGROUND=true +if uname | grep -i 'linux' &>/dev/null; then + BASE16_SHELL_SET_BACKGROUND=false +fi + +BASE16_SHELL="$HOME/.config/base16-shell/" +[ -n "$PS1" ] && \ + [ -s "$BASE16_SHELL/profile_helper.sh" ] && \ + source "$BASE16_SHELL/profile_helper.sh" diff --git a/config/bash/core.sh b/config/bash/core.sh index ecdec2c..f0b6e59 100644 --- a/config/bash/core.sh +++ b/config/bash/core.sh @@ -6,7 +6,7 @@ if [ -z "$DOT_CORE_SOURCED" ]; then DOT_CORE_SOURCED=1 function _is_wsl() { - [ -n "$WSL_DISTRO_NAME" ] && return 0 || return 1 + [ -n "${WSL_DISTRO_NAME-}" ] && return 0 || return 1 } # Using `command -v foo` on WSL is very slow. I profiled this on 2024/03/08 diff --git a/config/bash/functions.sh b/config/bash/functions.sh index 456a107..d33bfa5 100644 --- a/config/bash/functions.sh +++ b/config/bash/functions.sh @@ -35,7 +35,8 @@ function fm() { file_manager="$(xdg-mime query default inode/directory | sed -e 's/\.desktop//')" - if test -z "path"; then + # BUG FIX: Was missing $ before path variable, so condition was always false + if test -z "$path"; then path="." fi @@ -98,10 +99,11 @@ function nvimp() { } # Download the audio from a YouTube video as an MP3 file +# IMPROVEMENT: Changed from youtube-dl to yt-dlp, the actively maintained fork function yt_mp3() { - installed "youtube-dl" || return 1 + installed "yt-dlp" || return 1 - youtube-dl -x --audio-format "mp3" "$1" + yt-dlp -x --audio-format "mp3" "$1" } # WARNING: This shouldn't be called from an interactive shell as the passphrase @@ -284,10 +286,13 @@ function go_test_coverage() { if go test -coverprofile="$tempfile"; then go tool cover -html="$tempfile" else - 1>&1 echo -e "ERROR: Tests failed; to view coverage anyways run:\ngo tool cover -html=\"$tempfile\"" + # BUG FIX: Was using 1>&1 (stdout to stdout, no-op) instead of 1>&2 (stdout to stderr) + 1>&2 echo -e "ERROR: Tests failed; to view coverage anyways run:\ngo tool cover -html=\"$tempfile\"" fi } +# Converts a files binary contents to a nice hex representation and opens that +# in Neovim. function viewhex { installed "xxd" "nvim" || return 1 @@ -313,6 +318,50 @@ function viewhex { fi } +# This function reads a file and outputs a condensed text hex representation of +# its binary contents. For example, given the file hello.txt containing just +# the text: +# +# Hello world, I am here and my name is Paul! +# +# The function can be used as follows: +# +# tohex "hello.txt" "hello.hex.txt" +# +# As a result, hello.hex.txt will be created containing the following text: +# +# 48656c6c6f20776f726c642c204920616d206865726520616e64206d79206e616d65206973205061 +# 756c210a +# +# Each line will contain up to 80 characters representing 40 bytes of data. +# This can be used to copy a binary file across an SSH session without SCP. +function tohex() { + installed "hexdump" "od" || return 1 + + local input_file="$1" + local output_file="$2" + + # All of these work. The first two are effectively the same and don't add + # any whitespace between bytes. The third adds a space between each byte in + # the hex output. + #hexdump -ve '1/1 "%.2x"' "$input_file" > "$output_file" + hexdump -ve '40/1 "%.2x" 1 "\n"' "$input_file" > "$output_file" + #od -tx1 -An -v "$input_file" > "$output_file" +} + +# This function simply reverses the operation performed by tohex(). Given the +# text hex representation, it will output the original file. +# +# Example: tohex "hello.hex.txt" "hello.txt" +function fromhex() { + installed "xxd" || return 1 + + local input_file="$1" + local output_file="$2" + + xxd -p -r "$input_file" "$output_file" +} + function git_show_tool { installed "git" || return 1 @@ -355,6 +404,26 @@ function webp_to_jpg() { echo "Successfully converted $webp_file to $jpg_file" } +# Converts an image file from jpg to png format. +function jpg_to_png() { + installed "convert" || return 1 + + local jpg_file="$1" + local png_file="$2" + + if [[ -z "$jpg_file" || -z "$png_file" ]]; then + 1>&2 echo "Usage: jpg_to_png path_to_img.jpg path_to_img.png" + return 1 + fi + + if ! convert "$jpg_file" "$png_file"; then + 1>&2 echo "ERROR: failed to convert jpg file $jpg_file to png format" + return 1 + fi + + echo "Successfully converted $jpg_file to $png_file" +} + function docker_pss() { local tempfile tempfile="$(mktemp)" @@ -479,6 +548,7 @@ function sdr() { } +# TODO: Allow specifying the output path function tar_directory() { local dir path name @@ -489,8 +559,9 @@ function tar_directory() { return 1 fi - path="$(realpath $dir)" - name="$(basename $path)" + # BUG FIX: Added quotes around $dir and $path to handle paths with spaces + path="$(realpath "$dir")" + name="$(basename "$path")" archive_path="/tmp/$name.tar.gz" @@ -609,3 +680,86 @@ function bluetooth_reset() { echo "Bluetooth restarted!" } + +function set_volume() +{ + installed "amixer" || return 1 + + local volume + volume="$1" + + if [ "$volume" = "" ]; then + volume="25" + fi + + amixer -D pulse sset Master "${volume}%" +} + +function _git_bash() +{ + local wezterm_exe="/mnt/c/Program Files/WezTerm/wezterm-gui.exe" + local wezterm_args=( + "start" + "--domain" "local" + ) + + local bash_exe="C:\\Program Files\\Git\\bin\\bash.exe" + local bash_args=( + "-i" # Interactive + "-l" # Login shell + ) + + "$wezterm_exe" "${wezterm_args[@]}" -- "$bash_exe" "${bash_args[@]}" & +} + +function merge_pdfs() +{ + installed "pdftk" || return 1 + + local input1="$1" + local input2="$2" + local output="$3" + + if [ -z "$input1" ] || [ -z "$input2" ] || [ -z "$output" ]; then + echo "Usage: merge_pdfs " + echo "" + echo "Example: merge_pdfs statement.pdf receipt.pdf statement_and_receipt.pdf" + return 1 + fi + + pdftk "$input1" "$input2" cat output "$output" +} + +str_contains() +{ + local string="$1" + local substring="$2" + if test "${string#*$substring}" != "$string" + then + return 0 # $substring is in $string + else + return 1 # $substring is not in $string + fi +} + +# Clone a Github repository to the specified path or a reasonable default if no +# path is provided +gh_clone() +{ + local org="$1" + local repo="$2" + local path="$3" + + local url="https://github.com/$org/$repo" + + if [ -z "$path" ]; then + path="$HOME/src/github/$org/$repo" + fi + + if [ -e "$path" ]; then + yell "ERROR: Target directory '$path' already exists" + return 1 + fi + + git clone "$url" "$path" +} diff --git a/config/bashrc b/config/bashrc index cc714df..877b459 100644 --- a/config/bashrc +++ b/config/bashrc @@ -15,14 +15,22 @@ src_if_exists() { } declare -a sources=( - "$HOME/.fzf.bash" "$HOME/.localrc" + + # No need to filter this out on non-WSL hosts because Nix won't link it + "$HOME/.wslrc" ) for i in "${sources[@]}"; do - src_if_exists "$i" + src_if_exists "$i" done +# Source fzf shell integrations if we have them +if command -v fzf-share >/dev/null; then + source "$(fzf-share)/key-bindings.bash" + source "$(fzf-share)/completion.bash" +fi + # Prompt black="\[\033[30m\]" red="\[\033[31m\]" @@ -36,6 +44,12 @@ none="\[\033[00m\]" PS1="${white}[${green}\u${white}@${blue}\H${white}:${yellow}\w${white}] " +# Bash auto-completion for dotfiles CLI +export DOT_BASH_COMPLETION="1" +if [ -f "$HOME/.config/bash/completions/dot" ]; then + source "$HOME/.config/bash/completions/dot" +fi + # Node.js configuration if [ -d "$HOME/.nvm" ]; then export NVM_DIR="$HOME/.nvm" @@ -49,33 +63,25 @@ if _is_installed "go"; then fi # Rust configuration -if _is_installed "rust"; then +# BUG FIX: Changed "rust" to "rustc" - "rust" is not a command, rustc is the compiler +if _is_installed "rustc"; then [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" fi -# Bash auto-completion for dotfiles CLI -export DOT_BASH_COMPLETION="1" -source "$HOME/.bash_completion.d/dot.bash" - -# Base16 color scheme -base16_shell_dir="$HOME/.config/base16-shell" -[ ! -d "$base16_shell_dir" ] && \ - git clone "https://github.com/chriskempson/base16-shell.git" \ - "$base16_shell_dir" - -BASE16_SHELL_SET_BACKGROUND=true -if uname | grep -i 'linux' &>/dev/null; then - BASE16_SHELL_SET_BACKGROUND=false -fi -BASE16_SHELL="$HOME/.config/base16-shell/" -[ -n "$PS1" ] && \ - [ -s "$BASE16_SHELL/profile_helper.sh" ] && \ - source "$BASE16_SHELL/profile_helper.sh" +# Initialize base16 color system +. "$DOTFILES/config/bash/base16.sh" +# TODO: Move this into base16.sh. Will need to adjust our flavours configs # Start flavours - bashrc # Base16 Outrun Dark -eval "base16_outrun-dark" +if command -v "base16_outrun-dark" >/dev/null 2>&1; then + eval "base16_outrun-dark" +fi # End flavours - bashrc + +# TODO: I think this was automatically appended and I didn't notice and +# accidentally committed it. We should put this somewhere that makes more +# sense. [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion diff --git a/config/env b/config/env index 9781e8b..f7ee632 100644 --- a/config/env +++ b/config/env @@ -4,41 +4,63 @@ export DOTFILES="$HOME/dot" -contains() { - string="$1" - substring="$2" - if test "${string#*$substring}" != "$string" - then - return 0 # $substring is in $string - else - return 1 # $substring is not in $string - fi +is_in_path() { + case ":$PATH:" in + *":$1:"*) return 0 ;; + *) return 1 ;; + esac } -add_to_path() { - p="$1" - export PATH="${p}:${PATH}" +# BUG FIX: The original implementations had swapped logic - +# "append" was prepending and "prepend" was appending. +append_to_path() { + export PATH="${PATH:+$PATH:}$1" } -try_add_to_path() { - p="$1" - contains "$PATH" "$p" || add_to_path "$p" +prepend_to_path() { + export PATH="$1${PATH:+:$PATH}" } -try_add_to_path "/usr/local/bin" -try_add_to_path "/usr/local/sbin" -try_add_to_path "$HOME/bin" -try_add_to_path "$HOME/.flatpak_aliases" -try_add_to_path "$HOME/.local/bin" -try_add_to_path "$DOTFILES/bin" -try_add_to_path "$HOME/.rvm/bin" -try_add_to_path "$HOME/.npm-global/bin" -try_add_to_path "/usr/local/go/bin" -try_add_to_path "$HOME/go/bin" -try_add_to_path "$HOME/go" -try_add_to_path "$HOME/.mix/escripts" -try_add_to_path "$HOME/box/bin" - -if [ ! "$WSL_DISTRO_NAME" = "" ]; then - try_add_to_path "/mnt/c/bin" +try_append_to_path() { + is_in_path "$1" || append_to_path "$1" +} + +try_prepend_to_path() { + is_in_path "$1" || prepend_to_path "$1" +} + +try_append_to_path "/usr/local/bin" +try_append_to_path "/usr/local/sbin" +try_append_to_path "$HOME/bin" +try_append_to_path "$HOME/.flatpak_aliases" +try_append_to_path "$HOME/.local/bin" +try_append_to_path "$DOTFILES/bin" +try_append_to_path "$HOME/.rvm/bin" +try_append_to_path "$HOME/.npm-global/bin" +try_append_to_path "/usr/local/go/bin" +try_append_to_path "$HOME/go/bin" +try_append_to_path "$HOME/go" +try_append_to_path "$HOME/.mix/escripts" +try_append_to_path "$HOME/box/bin" + +# TODO: Make sure this matches the path we used. Also, do we need both this and +# the pyenv stuff below? Maybe stick to one? +try_prepend_to_path "$HOME/.venv/default/bin" + +if [ -n "${WSL_DISTRO_NAME-}" ]; then + try_append_to_path "/mnt/c/bin" +fi + +# TODO: Not sure if this is the right place for this or if improvements could +# be made. Fine for now but we can probably use try_prepend_to_path or whatever. +# If a pyenv installation exists, initialize it +if [ -d "$HOME/.pyenv" ]; then + export PYENV_ROOT="$HOME/.pyenv" + [[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH" + eval "$(pyenv init - bash)" +fi + +# Nix (single-user) - load Nix profile into PATH +if [ -r "$HOME/.nix-profile/etc/profile.d/nix.sh" ]; then + . "$HOME/.nix-profile/etc/profile.d/nix.sh" fi diff --git a/config/flavours/templates/custom/templates/bashrc.mustache b/config/flavours/templates/custom/templates/bashrc.mustache index 0fed080..4924a87 100644 --- a/config/flavours/templates/custom/templates/bashrc.mustache +++ b/config/flavours/templates/custom/templates/bashrc.mustache @@ -1,5 +1,7 @@ # Base16 {{scheme-name}} -eval "base16_{{scheme-slug}}" +if command -v "base16_{{scheme-slug}}" >/dev/null 2>&1; then + eval "base16_{{scheme-slug}}" +fi diff --git a/config/gitconfig b/config/gitconfig index 61692f0..3b1ffd7 100644 --- a/config/gitconfig +++ b/config/gitconfig @@ -8,6 +8,7 @@ ss = status co = checkout cob = checkout -b + cot = checkout --track br = branch -v cont = rebase --continue rv = remote -v diff --git a/config/i3 b/config/i3 index 6b3c88c..451f506 100644 --- a/config/i3 +++ b/config/i3 @@ -286,3 +286,7 @@ bar { binding_mode $base00 $base0A $base00 } } + +# If we're running in a Virtual Machine, start up any necessary software for +# integration between the host and guest. +exec_always "~/dot/bin/run_vm_guest_additions.sh" diff --git a/config/nvim/UltiSnips/tex.snippets b/config/nvim/UltiSnips/tex.snippets new file mode 100644 index 0000000..b6a4b4e --- /dev/null +++ b/config/nvim/UltiSnips/tex.snippets @@ -0,0 +1,138 @@ +snippet itemize "Itemization (i.e. bulleted list)" +\begin{itemize} + \item $1 +\end{itemize} +endsnippet + +snippet enumerate "Enumeration (i.e. numbered list)" +\begin{enumerate} + \item $1 +\end{enumerate} +endsnippet + +snippet eq "Inline equation" +\( $1 \) +endsnippet + +snippet fn "Function i.e. f(x)" +\xFunc{$1}{$2} +endsnippet + +snippet par "Wrap math term in parenthesis" +\xPar{$1} +endsnippet + +snippet pip "Wrap math term in pipes" +\xPip{$1} +endsnippet + +snippet brk "Wrap math term in square brackets" +\xBrk{$1} +endsnippet + +snippet brc "Wrap math term in curly braces" +\xBrc{$1} +endsnippet + +snippet equation* "Equation without label" +\begin{equation*} + $1 +\end{equation*} +endsnippet + +snippet equation "Equation with label" +\begin{equation} + \xLabelEq{$1} + $2 +\end{equation} + +Using \xRefEq{$1}... +endsnippet + +snippet align* "Aligned multi-line equation without label" +\begin{align*} + x &= 1 \\\\ + y &= 2 +\end{align*} +endsnippet + +snippet align "Aligned equation with label" +\begin{align} + \xLabelEq{$1} + $2 +\end{align} + +Using \xRefEq{$1}... +endsnippet + +# TODO: Collides with the frac snippet in the default LaTeX snippets. Look into +# whether or not there's a way to always prioritize snippets from this file. +#snippet frac "Fraction" +# \frac{$1}{$2} +#endsnippet + +snippet ( "Parenthesis" +\xPar{$1} +endsnippet + +snippet gathered "Centered multiline equation" +\begin{gathered} + $1 \\\\ + $2 +\end{gathered} +endsnippet + +snippet aligned "Aligned multiline equation" +\begin{aligned} + $1 \\\\ + $2 +\end{aligned} +endsnippet + +snippet cases "Math Equation with Multiple Cases" +\begin{cases} + 1 & foo \\\\ + 0 & bar +\end{cases} +endsnippet + +snippet figure "Figure" +\begin{figure}[h] + \centering + \includegraphics[width=0.5\textwidth]{$3.png} + \caption{$1} + \xLabelFig{$2} +\end{figure} + +...as shown in Figure \ref{fig:$2}. +endsnippet + +snippet graph "Graph figure using Tikz" +\begin{figure}[H] + \centering + \begin{tikzpicture} + \begin{axis}[ + axis lines = middle, + xmin = 0, xmax = 10, + ymin = 0, ymax = 5, + xlabel = $x$, ylabel = $y$, + domain=2:8, samples=100, + grid = none + ] + + % Draw a parabola + \addplot[ + arrows={Stealth[length=3mm,width=3mm]-Stealth[length=3mm,width=3mm]}, + domain=2:8, + samples=100, + smooth, + thick, + black, + ] {(0.2)*(x-5)^2 + 1}; + + \end{axis} + \end{tikzpicture} + \caption{#1} + \xLabelFig{#2} +\end{figure} +endsnippet diff --git a/config/nvim/lua/dot/globals.lua b/config/nvim/lua/dot/globals.lua index 1657c45..a0a6b33 100644 --- a/config/nvim/lua/dot/globals.lua +++ b/config/nvim/lua/dot/globals.lua @@ -1,10 +1,18 @@ local vim = vim local Util = require('dot.util') -local VimPlug = require('dot.vim_plug') local M = {} +function M._move_to_column(opts) + local column = tonumber(opts.args) + Util.move_to_column(column) +end + +function M._reload_snippets(commands) + vim.cmd('call UltiSnips#RefreshSnippets()') +end + function M._create_command(name, fn, opts) vim.api.nvim_create_user_command(name, fn, opts) end @@ -23,11 +31,11 @@ function M.init() _G.print_current_filetype = Util.print_current_filetype _G.reload_config = Util.reload_config - _G.install_vim_plug = VimPlug.install - M._create_commands({ { 'ReloadConfig', Util.reload_config, {} }, - { 'CloseTabsToRight', Util.close_tabs_to_right, {} } + { 'CloseTabsToRight', Util.close_tabs_to_right, {} }, + { 'MoveToColumn', M._move_to_column, { nargs = 1 } }, + { 'ReloadSnippets', M._reload_snippets, {} }, }) end diff --git a/config/nvim/lua/dot/lsp_clangd.lua b/config/nvim/lua/dot/lsp_clangd.lua index 4dc7637..abe0b66 100644 --- a/config/nvim/lua/dot/lsp_clangd.lua +++ b/config/nvim/lua/dot/lsp_clangd.lua @@ -35,7 +35,8 @@ function M.configure() -- server attaches to the current buffer local on_attach = function(client, buf) -- Enable completion triggered by - vim.api.nvim_buf_set_option(buf, 'omnifunc', 'v:lua.vim.lsp.omnifunc') + -- IMPROVEMENT: nvim_buf_set_option is deprecated in favor of vim.bo[buf] + vim.bo[buf].omnifunc = 'v:lua.vim.lsp.omnifunc' -- See `:help vim.lsp.*` for documentation on the below functions Map.nnoremapbs(buf, 'gD', 'lua vim.lsp.buf.declaration()') diff --git a/config/nvim/lua/dot/lsp_gopls.lua b/config/nvim/lua/dot/lsp_gopls.lua index ec90111..c86f6f6 100644 --- a/config/nvim/lua/dot/lsp_gopls.lua +++ b/config/nvim/lua/dot/lsp_gopls.lua @@ -19,7 +19,8 @@ function M.configure() -- language server attaches to the current buffer local on_attach = function(client, buf) -- Enable completion triggered by - vim.api.nvim_buf_set_option(buf, 'omnifunc', 'v:lua.vim.lsp.omnifunc') + -- IMPROVEMENT: nvim_buf_set_option is deprecated in favor of vim.bo[buf] + vim.bo[buf].omnifunc = 'v:lua.vim.lsp.omnifunc' -- See `:help vim.lsp.*` for documentation on the below functions Map.nnoremapbs(buf, 'ld', 'lua vim.lsp.buf.definition()') diff --git a/config/nvim/lua/dot/plugins.lua b/config/nvim/lua/dot/plugins.lua index 2d8dc19..7e54146 100644 --- a/config/nvim/lua/dot/plugins.lua +++ b/config/nvim/lua/dot/plugins.lua @@ -1,11 +1,44 @@ local vim = vim -local Plug = vim.fn["plug#"] local Log = require('dot.log') local Map = require('dot.map') local Notifications = require('dot.notifications') local Util = require('dot.util') -local VimPlug = require('dot.vim_plug') + +-- Function to list snippets and allow FZF selection +-- Not currently that useful because it shows all snippets and the list is +-- massive and most of them I would never use, like all of the licensing +-- snippets. Maybe find a way to make this better. A simple solution could be +-- to make a regex filter to remove the ones we don't care about. +function ShowSnippets() + local filetype = vim.bo.filetype -- Get the current filetype + local snippets = vim.fn["UltiSnips#SnippetsInCurrentScope"](1) -- Get available snippets + + -- If no snippets available, notify the user + if vim.tbl_isempty(snippets) then + print("No snippets available for this filetype.") + return + end + + -- Prepare snippets for FZF display + local fzf_snippets = {} + for trigger, info in pairs(snippets) do + local description = info.description or "" + table.insert(fzf_snippets, trigger .. " - " .. description) + end + + -- Use FZF to select a snippet + vim.fn["fzf#run"]({ + source = fzf_snippets, + sink = function(choice) + if choice then + local snippet_trigger = choice:match("^(%S+)") + vim.cmd("call UltiSnips#ExpandSnippetOrJump()") + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(snippet_trigger, true, false, true), 'm', true) + end + end + }) +end local M = {} @@ -14,9 +47,6 @@ local plugins = { is_enabled = function() return os.getenv("NVIM_COPILOT_ENABLED") == "1" end, - init = function() - Plug('github/copilot.vim') - end, configure = function() -- Don't use tab to accept Copilot suggestions which conflicts with -- snippets. Not using my Map.imap() function here because it @@ -39,23 +69,21 @@ local plugins = { }, fzf = { - init = function() - Plug('junegunn/fzf', { ['dir'] = '~/.fzf', ['do'] = './install --bin' }) - Plug('junegunn/fzf.vim') - end, configure = function() -- We should remove this eventually in favor of the below Map.nnoremap('o', ':Files') Map.nnoremap('ft', ':Tags') Map.nnoremap('fo', ':Files') + + -- TODO: Copy/pasted from ChatGPT, clean up to use the Map module + -- like above + -- Map the function to a keybinding (e.g., s) + vim.api.nvim_set_keymap("n", "fs", "lua ShowSnippets()", { noremap = true, silent = true }) end }, telescope = { - init = function() - Plug('nvim-telescope/telescope.nvim') - end, configure = function() Map.nnoremap('tf', ':Telescope find_files') Map.nnoremap('tt', ':Telescope tags') @@ -67,12 +95,6 @@ local plugins = { }, nvim_treesitter = { - init = function() - -- Sometimes the automatic :TSUpdate won't run successfully and then it - -- won't rerun. If cryptic treesitter errors show up after running - -- :PlugUpdate, try running :TSUpdate manually - Plug('nvim-treesitter/nvim-treesitter', { ['do'] = ':TSUpdate'}) - end, configure = function() local Treesitter = require('dot.treesitter') Treesitter.configure() @@ -80,23 +102,17 @@ local plugins = { }, ultisnips = { - init = function() - Plug('SirVer/ultisnips') - Plug('honza/vim-snippets') - end, configure = function() vim.g.UltiSnipsExpandTrigger = "" vim.g.UltiSnipsJumpForwardTrigger = "" vim.g.UltiSnipsJumpBackwardTrigger = "" Map.nnoremaps('ue', ':UltiSnipsEdit') + end }, nvim_markdown = { - init = function() - Plug('ixru/nvim-markdown') - end, configure = function() vim.g.vim_markdown_frontmatter = 1 @@ -107,9 +123,6 @@ local plugins = { }, clang_format = { - init = function() - Plug('rhysd/vim-clang-format') - end, configure = function() -- Format the current C/C++ file with clang-format (Uses vim-clang-format plugin) vim.g['clang_format#detect_style_file'] = 1 @@ -118,9 +131,6 @@ local plugins = { }, lsp_config = { - init = function() - Plug('neovim/nvim-lspconfig') - end, configure = function() local LspClangd = require('dot.lsp_clangd') local LspGopls = require('dot.lsp_gopls') @@ -133,54 +143,18 @@ local plugins = { Map.nnoremap('ls', ':LspStop') end }, - - other = { - init = function() - -- Colorschemes - Plug('chriskempson/base16-vim') - - Plug('rodjek/vim-puppet') - Plug('fatih/vim-go') - Plug('OrangeT/vim-csharp') - Plug('mattn/emmet-vim') - Plug('tpope/vim-vinegar') - Plug('nvie/vim-flake8') - Plug('tikhomirov/vim-glsl') - Plug('martinda/Jenkinsfile-vim-syntax') - Plug('aklt/plantuml-syntax') - Plug('elubow/cql-vim') - Plug('tpope/vim-fugitive') - Plug('igankevich/mesonic') - Plug('hrsh7th/nvim-cmp') - Plug('nvim-lua/popup.nvim') - Plug('nvim-lua/plenary.nvim') - Plug('glepnir/lspsaga.nvim') - Plug('hoob3rt/lualine.nvim') - end - }, } function M.init() - if not VimPlug.is_installed() then - Notifications.add("vim-plug is not installed; install it via `:lua install_vim_plug()`") - return - end - - -- Ever since switching from init.vim to init.lua, it doesn't seem like - -- Neovim autoloads the plug.vim file anymore. I'm not sure why and don't - -- have time right now to dig into it so just manually source it for now. - VimPlug.source() - - vim.call('plug#begin') - - for plugin_name, plugin in pairs(plugins) do - M._init_plugin(plugin_name, plugin) - end - - vim.call('plug#end') - for plugin_name, plugin in pairs(plugins) do - M._configure_plugin(plugin_name, plugin) + if plugin.is_enabled ~= nil and not plugin.is_enabled() then + Log.debug('skip configuring ' .. plugin_name .. ' plugin because it is disabled') + else + Log.debug('configuring ' .. plugin_name .. ' plugin') + if plugin.configure ~= nil then + plugin.configure() + end + end end end diff --git a/config/nvim/lua/dot/settings.lua b/config/nvim/lua/dot/settings.lua index d835f7c..1b5d218 100644 --- a/config/nvim/lua/dot/settings.lua +++ b/config/nvim/lua/dot/settings.lua @@ -58,10 +58,216 @@ function M._directories() vim.opt.directory = { dir, "." } end +function M._get_tab_info(i) + -- Check whether or not the tab is selected + local is_selected = false + if i == vim.fn.tabpagenr() then + is_selected = true + end + + local buflist = vim.fn.tabpagebuflist(i) + local winnr = vim.fn.tabpagewinnr(i) + + -- The title is the name of the file currently open in the buffer focused + -- within the tab. If it doesn't have one because it's an unsaved buffer, + -- fall back to UNSAVED + local title = vim.fn.fnamemodify(vim.fn.bufname(buflist[winnr]), ":t") + if #title == 0 then + title = "UNSAVED" + end + + -- Check if any buffers open in the tab are modified and need to be save + local is_modified = false + for _, i in ipairs(buflist) do + if Util.is_buffer_modified(i) then + Log.debug("buffer " .. i .. " is modified") + is_modified = true + break + end + end + + return { + title = title, + index = i, + is_modified = is_modified, + is_selected = is_selected + } +end + +function M._format_tabline_tab(tab) + local s = "" + + -- Format the modified indicator + s = s .. (tab["is_modified"] and "+" or " ") + + -- Format the tab index + s = s .. "[" .. tab["index"] .. "] " + + -- Format the tab title + s = s .. tab["title"] + + return s +end + +function M._format_tabline_no_truncation(tabs) + local s = "" + for i, tab in pairs(tabs) do + -- Separate tabs with a space + if i > 1 then + s = s .. " " + end + -- Set the tab metadata + s = s .. (tab["is_selected"] and "%#TabLineSel#" or "%#TabLine#") + s = s .. "%" .. tab["index"] .. "T" + + s = s .. M._format_tabline_tab(tab) + end + return s .. '%#TabLineFill#%T' +end + +-- The "Basic" truncation strategy is to hide all of the tabs that don't fit on +-- the tabline and put an indicator on the right of how many more tabs there +-- are. I Personallly like this better than squishing each tab down. I'd rather +-- the first N tabs be readable and just hide the ones that don't fit than make +-- them all less readable. +function M._format_tabline_basic_truncation(tabs, truncation) + -- TODO: We could merge this loop with the one below and do this in one + -- pass but I'm too lazy right now + local tab_contents = {} + for i, tab in pairs(tabs) do + local meta = (tab["is_selected"] and "%#TabLineSel#" or "%#TabLine#") + meta = meta .. "%" .. tab["index"] .. "T" + + local label = M._format_tabline_tab(tab) + + tab_contents[i] = { + meta = meta, + label = label + } + end + + local suffix_base_len = #" ... (x more)" + local current_length = 0 + local truncate_index = 0 + for i, tab_content in pairs(tab_contents) do + -- First, check if we can even fit the truncation suffix. It could be + -- longer than the tab label if the file in the tab has a very short + -- name. If this won't fit, then we actually need to start truncating + -- at the previous tab + local remaining_tabs = (#tabs - i) + 1 + local suffix_len = suffix_base_len + math.floor(remaining_tabs / 10) + if (current_length + suffix_len) > truncation["max_length"] then + truncate_index = math.max(0, i - 1) + break + end + + -- Separate tabs with a space + local separator = "" + if i > 1 then + separator = " " + end + + local tab_len = #tab_content["label"] + #separator + + -- If appending this label would exceed the max length, start + -- truncating at this tab + if (current_length + tab_len) > truncation["max_length"] then + truncate_index = i + break + end + + current_length = current_length + tab_len + end + + -- Now that we know where to start truncating, format the tabline + local s = "" + for i, tab_content in pairs(tab_contents) do + if i >= truncate_index then + break + end + if i > 1 then + s = s .. " " + end + s = s .. tab_content["meta"] .. tab_content["label"] + end + + -- Align the truncation suffix to the right + s = s .. "%=" + + -- Fill any extra space and disassociate the following text from the last tab + s = s .. "%#TabLineFill#%T" + + -- Append the truncation suffix + local truncated_tabs = (#tabs - truncate_index) + 1 + s = s .. "... (" .. tostring(truncated_tabs) .. " more)" + + return s +end + +function M._format_tabline(tabs, truncation) + if truncation["strategy"] == "Basic" then + return M._format_tabline_basic_truncation(tabs, truncation) + else + return M._format_tabline_no_truncation(tabs) + end +end + +-- Tabline generation is done in two steps. The first step collects all of the +-- tab information and the second step formats the tabline. The reason is that +-- we need to be able to check the total length and truncate appropriately. +-- That's harder to do when the string contains characters that are actually +-- printed, like the `%#TabLine#` tokens. So instead, we need to gather all of +-- the desired tab labels, sum their lengths, choose a truncation strategy, and +-- then format everything using that strategy. +function M._generate_tabline() + -- Get all of the tab information + local tabs = {} + for i = 1, vim.fn.tabpagenr('$') do + tabs[i] = M._get_tab_info(i) + end + + -- The length of the tab label prefix (I.E. "+[1] "), which can vary if the + -- tab index is multiple digits. Ignoring the case of >100 tabs; I never + -- use that many and there would be no good way to format that anyways + local prefix_length = #tabs >= 10 and 6 or 5 + + local total_length = 0 + for _, tab in ipairs(tabs) do + local title_len = #tab["title"] + local tab_len = title_len + prefix_length + total_length = total_length + tab_len + end + + -- We separate tabs with a space so account for that as well + total_length = total_length + #tabs - 1 + + local truncation = { + strategy = "None", + total_length = total_length, + max_length = vim.o.columns, + amount = 0, + amount_per_tab = 0, + } + + -- Handle truncating if the tabline is too long + if total_length > truncation["max_length"] then + truncation["strategy"] = "Basic" + truncation["amount"] = truncation["total_length"] - truncation["max_length"] + truncation["amount_per_tab"] = truncation["amount"] - #tabs + end + + return M._format_tabline(tabs, truncation) +end + +function M._tab_line() + vim.o.tabline = "%!v:lua.require'dot.settings'._generate_tabline()" +end + function M.init() M._base() M._indentation() M._directories() + M._tab_line() end return M diff --git a/config/nvim/lua/dot/treesitter.lua b/config/nvim/lua/dot/treesitter.lua index 5fdf65a..4c0d771 100644 --- a/config/nvim/lua/dot/treesitter.lua +++ b/config/nvim/lua/dot/treesitter.lua @@ -34,49 +34,10 @@ function M.configure() -- downloads a ton of parsers I don't care about and that were occasionally -- causing errors. ensure_installed = { - "bash", - "c", - "c_sharp", - "cmake", - "commonlisp", - "cpp", - "css", - "csv", - "disassembly", - "dockerfile", - "elixir", - "erlang", - "gdscript", - "git_rebase", - "gitattributes", - "gitcommit", - "git_config", - "gitignore", - "go", - "godot_resource", - "gomod", - "gosum", - "gowork", - "groovy", - "hcl", - "html", - "ini", - "java", - "javascript", - "json", - "latex", - "lua", - "make", - "markdown", - "markdown_inline", - "meson", - "proto", - "puppet", - "python", - "toml", - "vim", - "xml", - "yaml", + -- Leave this empty because Nix will install the grammars into the + -- Nix store and we don't want the Treesitter plugin trying to + -- install them at runtime because it will fail due to missing + -- write permissions } }) end diff --git a/config/nvim/lua/dot/util.lua b/config/nvim/lua/dot/util.lua index 6c90c91..6f8eba8 100644 --- a/config/nvim/lua/dot/util.lua +++ b/config/nvim/lua/dot/util.lua @@ -4,13 +4,17 @@ local Log = require('dot.log') local M = {} +-- IMPROVEMENT: vim.loop was renamed to vim.uv in Neovim 0.10+ +-- Use vim.uv if available, fall back to vim.loop for older versions +local uv = vim.uv or vim.loop + function M.is_windows() -- TODO: Confirm this is right; only checked it on Linux - return vim.loop.os_uname().sysname == "Windows" + return uv.os_uname().sysname == "Windows" end function M.is_linux() - return vim.loop.os_uname().sysname == "Linux" + return uv.os_uname().sysname == "Linux" end function M.path_sep() @@ -136,4 +140,38 @@ function M.copy_file_and_line() vim.fn.setreg('+', file_and_line) end +--[[ + Truncates a string from the center if it exceeds the specified maximum + length. + + The string is truncated by removing characters from the middle and replacing + them with two periods ('..') if the length of the input string exceeds the + specified maximum length. The resulting truncated string will have a total + length not exceeding the max_length parameter. + + Example: + truncate_center("VeryLongFileName.cpp", 12) => "VeryL..e.cpp" + + Parameters: + str (string): The input string to be truncated. + max_length (number): The maximum allowed length for the resulting string. + + Returns: + string: The possibly truncated string. +]] +function M.truncate_center(str, max_length) + local length = #str + if length <= max_length then + return str + else + local part_length = math.floor((max_length - 2) / 2) + return str:sub(1, part_length) .. ".." .. str:sub(length - part_length + 1, length) + end +end + +-- Returns whether or not a buffer contains unsaved changes +function M.is_buffer_modified(buffer_index) + return vim.fn.getbufvar(buffer_index, '&modified') == 1 +end + return M diff --git a/config/nvim/lua/dot/vim_plug.lua b/config/nvim/lua/dot/vim_plug.lua deleted file mode 100644 index 3937632..0000000 --- a/config/nvim/lua/dot/vim_plug.lua +++ /dev/null @@ -1,34 +0,0 @@ -local vim = vim - -local Log = require('dot.log') -local Util = require('dot.util') - -local M = { - path = Util.path_join(Util.data_dir(), 'site', 'autoload', 'plug.vim'), - url = 'https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim' -} - -function M.is_installed() - return Util.file_exists(M.path) -end - -function M.install() - local curl_command = Util.str_join(' ', '!curl', '-fLo', M.path, '--create-dirs', M.url) - Log.info('Installing vim-plug: ' .. curl_command) - vim.cmd(curl_command) -end - -function M.source() - local source_command = 'source ' .. M.path - Log.info('Sourcing vim-plug: ' .. source_command) - vim.cmd(source_command) -end - -function M.install_plugins() - -- TODO: Untested and currently unused - local autocmd_command = 'PlugInstall --sync' - Log.info('Installing plugins: ' .. autocmd_command) - vim.cmd(autocmd_command) -end - -return M diff --git a/config/vimrc b/config/vimrc index 77147a3..dcd6b13 100644 --- a/config/vimrc +++ b/config/vimrc @@ -36,8 +36,12 @@ set softtabstop=4 set tabstop=4 set expandtab -" Don't use smart indent in markdown files -autocmd FileType markdown setlocal nosmartindent +" IMPROVEMENT: Wrapped autocmd in augroup to prevent duplicate autocmds on config reload +augroup dotfiles_markdown + autocmd! + " Don't use smart indent in markdown files + autocmd FileType markdown setlocal nosmartindent +augroup END " Set the Leader key. I leave the leader key as '\' and remap ' ' to it " instead of setting ' ' as the leader. This is so that showcmd is actually @@ -110,11 +114,15 @@ nnoremap r :source ~/.vimrc xnoremap n :normal " Install vim-plug automatically on Linux if it isn't already +" IMPROVEMENT: Wrapped VimEnter autocmd in augroup to prevent duplicate autocmds if has('unix') if empty(glob('~/.vim/autoload/plug.vim')) silent !curl -fLo ~/.vim/autoload/plug.vim --create-dirs \ \ https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim - autocmd VimEnter * PlugInstall --sync | source $MYVIMRC + augroup dotfiles_plug_install + autocmd! + autocmd VimEnter * PlugInstall --sync | source $MYVIMRC + augroup END endif endif @@ -161,7 +169,11 @@ call plug#end() " Format the current C/C++ file with clang-format (Uses vim-clang-format plugin) let g:clang_format#detect_style_file = 1 -autocmd FileType c,cpp vnoremap q :ClangFormat +" IMPROVEMENT: Wrapped autocmd in augroup to prevent duplicate autocmds on config reload +augroup dotfiles_clang_format + autocmd! + autocmd FileType c,cpp vnoremap q :ClangFormat +augroup END " FZF nnoremap o :Files diff --git a/config/vsvimrc b/config/vsvimrc index 2b0e74a..cf047ee 100644 --- a/config/vsvimrc +++ b/config/vsvimrc @@ -54,6 +54,7 @@ nnoremap 7gt nnoremap 8gt nnoremap 9gt nnoremap 10gt +" BUG FIX: Removed duplicate mapping block that was accidentally included twice nnoremap 1gt nnoremap 2gt nnoremap 3gt @@ -65,28 +66,28 @@ nnoremap 8gt nnoremap 9gt nnoremap 10gt -nnoremap 1gt -nnoremap 2gt -nnoremap 3gt -nnoremap 4gt -nnoremap 5gt -nnoremap 6gt -nnoremap 7gt -nnoremap 8gt -nnoremap 9gt -nnoremap 0gt - nnoremap vo :vsc Edit.GoToFile nnoremap vm :vsc Edit.GoToMember nnoremap vs :vsc Edit.GoToSymbol nnoremap vr :vsc Edit.GoToRecentFile nnoremap vt :vsc Edit.GoToType -" Add gvim as an External Tool as described in this guide: +" To use this mapping, add gvim to Visual Studio as an External Tool +" +" The steps below were taken from the following guide: " https://vim.fandom.com/wiki/Integrate_gvim_with_Visual_Studio " -" Make sure it's at index 4 in the list (1-based) or change this to the -" corresponding index +" 1. In the Visual Studio top menu, navigate to `Tools > External Tools...` +" 2. Click `Add` to create a new external tool +" 3. Fill in the fields as follows: +" - Title: `&Vim` +" - Command: `C:\Program Files (x86)\Vim\vim82\gvim.exe` +" - Set this to the actual path to `gvim.exe` +" - Arguments: +" - `--servername gVimStudio --remote-silent +"execute 'normal! $(CurLine)G$(CurCol)|'" "$(ItemPath)" ` +" +" Ensure that the newly added `&Vim` external tool is the 4th item in the list +" or change this to the corresponding index nnoremap vv :vsc Tools.ExternalCommand4 " Map Visual Studio's native jump behavior which are decent equivalents to the diff --git a/config/wezterm.lua b/config/wezterm.lua index 3000918..7005493 100644 --- a/config/wezterm.lua +++ b/config/wezterm.lua @@ -4,8 +4,51 @@ local act = wezterm.action local config = {} +-- IMPROVEMENT: Added 'local' to helper functions to avoid polluting global namespace +local function get_wsl_domain() + -- This should match the entry in the list output by `wsl --list` that + -- should be used as the default domain + local wsl_domain_name = os.getenv("WEZTERM_WSL_DOMAIN") + if not wsl_domain_name or wsl_domain_name == "" then + wsl_domain_name = "WSL:Ubuntu" + else + wsl_domain_name = "WSL:" .. wsl_domain_name + end + return wsl_domain_name +end + +local function get_shell(tab_info) + local shell = '' + if tab_info.active_pane.domain_name == "local" then + shell = '(Git Bash) ' + elseif tab_info.active_pane.domain_name == get_wsl_domain() then + shell = '(WSL) ' + end + return shell +end + +wezterm.on('format-window-title', function(tab, pane, tabs, panes, config) + local index = '' + if #tabs > 1 then + index = string.format('[%d/%d] ', tab.tab_index + 1, #tabs) + end + return index .. get_shell(tab) .. tab.active_pane.title +end) + +local function tab_title(tab_info) + local title = tab_info.tab_title + if title and #title > 0 then + return title + end + return tab_info.active_pane.title +end + +wezterm.on('format-tab-title', function(tab, tabs, panes, config, hover, max_width) + return get_shell(tab) .. tab_title(tab) +end) + config.default_prog = {"bash"} -config.default_domain = 'WSL:Ubuntu' +config.default_domain = get_wsl_domain() config.launch_menu = { { diff --git a/config/wslrc b/config/wslrc new file mode 100644 index 0000000..23c3a2c --- /dev/null +++ b/config/wslrc @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +# Remove the background highlighting of folders in ls +LS_COLORS=$LS_COLORS:'ow=1;34:' +export LS_COLORS + +if [ "$TERM_PROGRAM" = "WezTerm" ] && [ -f "$HOME/wezterm.sh" ]; then + source "$HOME/wezterm.sh" +fi diff --git a/config/xsession b/config/xsession index 17bd6fe..4959fe3 100755 --- a/config/xsession +++ b/config/xsession @@ -8,9 +8,32 @@ # $ cd ~/dot/config # $ shellcheck --shell=dash -x ./env ./xsession -# Add user-defined fonts -xset +fp /home/paul/.fonts -xset fp rehash +DOTFILES_LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dotfiles/logs" +XSESSION_LOG_FILE="xsession_$(date +"%Y%m%d_%H%M%S").log" +XSESSION_LOG_PATH="${DOTFILES_LOG_DIR}/${XSESSION_LOG_FILE}" + +log() { + echo "$*" >> "$XSESSION_LOG_PATH" +} + +# Initialize log directory and clean up old logs +mkdir -p "$DOTFILES_LOG_DIR" +if [ -x "$HOME/dot/bin/cleanup_logs.sh" ]; then + "$HOME/dot/bin/cleanup_logs.sh" +else + log "Warning: $HOME/dot/bin/cleanup_logs.sh not found or not executable" +fi + +# Log PATH both before and after we source the env file so we can debug issues +# if it's not modifying it correctly +log "Starting at $(date)" +log "PATH: $PATH" + +# Add user-defined fonts (only if directory exists) +if [ -d "$HOME/.fonts" ]; then + xset +fp "$HOME/.fonts" + xset fp rehash +fi # Disable bell xset -b @@ -47,13 +70,32 @@ ulimit -c unlimited # Set up environment variables # shellcheck source=./env -. "$HOME/dot/config/env" -echo "Path = $PATH" > "$HOME/test.txt" +ENV_CONFIG_PATH="$HOME/dot/config/env" +if [ -f "$ENV_CONFIG_PATH" ]; then + . "$ENV_CONFIG_PATH" + log "Sourced ${ENV_CONFIG_PATH} configuration file" + log "PATH: $PATH" +else + log "ERROR: ${ENV_CONFIG_PATH} not found" +fi + +# Wrap exec in a function so we can redirect its output cleanly +exec_i3() { + exec i3 -V -d all +} # Start i3 and log output -logdir="$HOME/.logs" -mkdir -p "$logdir" -"$HOME/dot/bin/cleanup_logs.sh" -i3logfile="$logdir/i3_$(date +"%Y%m%d_%H%M%S").log" -echo "Starting at $(date)" >> "$i3logfile" -exec /usr/local/bin/i3 -V -d all >> "$i3logfile" +log "Checking for i3: $(command -v i3 2>&1)" + +# Try to start i3 and log any errors +if command -v i3 >/dev/null 2>&1; then + log "Found i3 at: $(command -v i3)" + log "DISPLAY is: $DISPLAY" + exec_i3 >> "$XSESSION_LOG_PATH" 2>&1 +else + log "ERROR: i3 not found in PATH" + log "PATH was: $PATH" + # Try to show a notification + notify-send "X Session Failed" "i3 not found in PATH" 2>/dev/null || true + exit 1 +fi diff --git a/doc/nix_todos.md b/doc/nix_todos.md new file mode 100644 index 0000000..55ad370 --- /dev/null +++ b/doc/nix_todos.md @@ -0,0 +1,206 @@ +# Think we can delete this file now, go through and double check maybe then delete + +# Nix Migration TODOs + +This document tracks remaining work to achieve full parity between the old provisioning systems (shell scripts in `provision/` and Python scripts in `cli/lib/provision/`) and the new Nix/Home Manager configuration. + +**Important:** The primary entry point is `apply.sh`, which handles bootstrap/system-level tasks before invoking Home Manager. Many items that cannot or should not be managed by Nix are intentionally handled there. + +## Status Legend +- [ ] Not started +- [~] Partial / In progress +- [x] Complete + +--- + +## 1. Handled by apply.sh (Intentionally Not in Nix) + +These items are managed by `apply.sh` rather than Nix, typically because they require root access, have OpenGL/driver issues with Nix, or are needed before Nix is available. + +### 1.1 APT Bootstrap Packages + +Installed via apt before Nix is available or to avoid Nix packaging issues: + +| Package | Reason | Notes | +|---------|--------|-------| +| `apt-file` | Pre-Nix bootstrap | Ubuntu package search | +| `ca-certificates` | Pre-Nix bootstrap | Required for HTTPS/curl | +| `curl` | Pre-Nix bootstrap | Needed to install Nix itself | +| `git` | Pre-Nix bootstrap | Needed to clone dotfiles | +| `jq` | Pre-Nix bootstrap | Used by apply.sh to parse hosts.json | +| `libfuse` | Pre-Nix bootstrap | AppImage support | +| `locate` | Pre-Nix bootstrap | File search | +| `software-properties-common` | Pre-Nix bootstrap | Ubuntu apt tooling | +| `xz-utils` | Pre-Nix bootstrap | Compression utilities | + +### 1.2 Desktop Packages (OpenGL Issues) + +These are installed via apt to avoid OpenGL/graphics driver issues that are common with Nix on non-NixOS systems: + +| Package | Reason | Notes | +|---------|--------|-------| +| `i3` | OpenGL/driver issues | Window manager | +| `i3status` | Companion to i3 | Status bar | +| `kitty` | OpenGL/driver issues | GPU-accelerated terminal | + +### 1.3 System-Level Configuration + +These require root access and modify system directories: + +| Task | Location in apply.sh | Notes | +|------|---------------------|-------| +| `update-alternatives` for vi/vim/editor | `set_default_terminal_and_editor()` | Points to nvim | +| `update-alternatives` for x-terminal-emulator | `set_default_terminal_and_editor()` | Points to kitty (desktop only) | +| Install `xsession.desktop` | `install_session_desktop_files()` | To `/usr/share/xsessions/` | +| Install `sway-user.desktop` | `install_session_desktop_files()` | To `/usr/share/wayland-sessions/` | + +### 1.4 Docker Installation + +| Task | Location in apply.sh | Notes | +|------|---------------------|-------| +| Install Docker packages | `install_docker()` | docker-ce, containerd, buildx, compose | +| Add user to docker group | `install_docker()` | Requires logout/reboot to take effect | + +### 1.5 Nix Bootstrap + +| Task | Location in apply.sh | Notes | +|------|---------------------|-------| +| Install Nix | `install_nix_if_needed()` | Single-user installation | +| Enable flakes | `enable_nix_experimental()` | Writes to ~/.config/nix/nix.conf | +| Apply Home Manager | `apply_home_manager()` | Runs `home-manager switch` | + +--- + +## 2. Remaining TODOs + +### 2.1 Neovim Plugins (Nix Package Issues) + +| Plugin | Priority | Notes | +|--------|----------|-------| +| markdown.nvim | Low | Commented out in core.nix due to package issues | +| cql-vim | Low | Cassandra CQL support | +| mesonic | Low | Meson build system integration | + +--- + +## 3. Already Complete + +### 3.1 Packages in Nix + +| Category | Packages | +|----------|----------| +| Core utilities | wget, gnupg, jq, plocate, fzf, nettools, unzip, libuchardet, xz, dos2unix | +| CLI tools | gnumake, gcc, cmake, htop, iotop, universal-ctags, ranger, tmux, neofetch, id3v2, calcurse, vim, tree-sitter | +| Search/utils | ripgrep | +| Theming | flavours | +| C/C++ | clang-tools (clangd) | +| Desktop/GUI | font-awesome, rofi, dunst, feh, sxiv, nitrogen, pavucontrol, picom, scrot, gucharmap, keepassxc, remmina, i3lock, meld, xclip, wl-clipboard, xdotool, libwebp, arandr, rxvt-unicode, ventoy | +| Media | inkscape, mpv, vlc, easytag, blueman, mpd, ncmpcpp, cava, yt-dlp | +| Gaming | steam, steam-run, runelite | +| Development | go, gopls, delve, rustup, nodejs, npm, openjdk, dotnet-sdk, meson, ninja | +| Proprietary | bcompare | +| Custom | wpr (via custom derivation in `nix/home/packages/wpr.nix`) | + +### 3.2 Python Packages (via unified environment) + +| Package | Location | +|---------|----------| +| pip, pynvim, mpd2, black, mypy, isort, flake8, autoflake, argcomplete, json5 | core.nix | +| py3status | desktop.nix | +| ruff | core.nix (system package) | + +### 3.3 Neovim + +- Installed via `programs.neovim` with plugins +- treesitter with all grammars via `withAllGrammars` +- LSP config, telescope, copilot, etc. + +### 3.4 Dotfiles Links + +All links from `links.json` are implemented in `dotfiles-links.nix`. + +### 3.5 Activation Scripts + +| Task | Location | +|------|----------| +| dot CLI completion | core.nix `home.activation.dotArgcomplete` | +| MPD directories creation | desktop.nix `home.activation.createMpdDirs` | +| flavours update | core.nix `home.activation.flavoursUpdate` | +| win32yank installation | wsl.nix `home.activation.installWin32yank` | + +### 3.6 WSL-Specific + +| Task | Location | Notes | +|------|----------|-------| +| win32yank | wsl.nix activation script | Downloads to `/mnt/c/bin/` on Windows filesystem | +| BROWSER env var | wsl.nix | Set to `wslview` | + +--- + +## 4. Potentially Obsolete + +Items from old provisioners that are no longer needed: + +| Item | Reason | +|------|--------| +| Building i3-gaps from source | Gaps merged into mainline i3 as of 4.22 | +| `youtube-dl` | Replaced by `yt-dlp` (already in Nix) | +| `compton` | Replaced by `picom` (already in Nix) | +| Custom kitty installation | Now installed via apt in apply.sh | +| Custom neovim AppImage | Now installed via Nix | +| Custom flavours installation | Now installed via Nix | +| Custom ripgrep installation | Now installed via Nix | +| Custom nodejs installation | Now installed via Nix | +| `usb-creator-gtk` | Replaced by `ventoy` | + +--- + +## 5. Manjaro/Arch-Specific (Low Priority) + +The `provision/manjaro.sh` script includes packages not in the Ubuntu scripts. Consider adding if needed: + +| Package | Notes | +|---------|-------| +| `discord` | Chat application | +| `firefox` | Browser | +| `flatpak` | Universal package manager | +| `kicad` | PCB design software | +| `poppler` | PDF utilities | +| `transmission-gtk` | BitTorrent client | +| `sway` ecosystem | mako, swaybg, swayidle, swaylock, waybar | + +--- + +## 6. Future Considerations + +### Moving More to Nix + +Some items currently in apply.sh could potentially move to Nix in the future: + +1. **kitty/i3**: If OpenGL issues are resolved or using NixOS +2. **Docker**: Could use Podman from Nix as alternative +3. **System alternatives**: Could rely solely on Nix profile PATH ordering + +### NixOS Migration + +If migrating to NixOS, many apply.sh tasks would move to system configuration: +- Display manager desktop files +- Docker installation and group management +- System-wide alternatives + +### Version Pinning + +The old provisioners had version caching. Nix handles this via flake.lock: +- Commit flake.lock for reproducibility +- Use `nix flake update` to update nixpkgs +- Consider overlays for packages needing specific versions + +### Custom Packages + +Custom packages are stored in `nix/home/packages/`: +- `wpr.nix` - Personal wpr tool fetched from S3 + +To add new custom packages: +1. Create a `.nix` file in `nix/home/packages/` +2. Use `pkgs.callPackage ../packages/yourpkg.nix { }` in the relevant role +3. Add to the packages list diff --git a/doc/setup_ubuntu.md b/doc/setup_ubuntu.md new file mode 100644 index 0000000..e8f9156 --- /dev/null +++ b/doc/setup_ubuntu.md @@ -0,0 +1,156 @@ +# Ubuntu Setup Instructions + +## New Machine Bootstrapping + +These are the typical steps to perform immediately after the initial Ubuntu +installation. + +Update the system and reboot: +``` +sudo apt update -y && sudo apt upgrade -y +shutdown -r now +``` + +Install Git: +``` +sudo apt install -y git +``` + +Set up an SSH key for Git. There are multiple ways to do this, such as: +- **Option 1:** Generate a new SSH key, log into GitHub in a browser, and add the new key +- **Option 2:** Transfer SSH key from another computer using a flash drive +- **Option 3:** Get SSH key from credential manager + +If generating a new SSH key: +``` +ssh-keygen -t ed25519 -C "git@pcewing.com" +``` + +If copying from a flash drive or credential manager, create the files and paste +them in: +``` +vi ~/.ssh/id_ed25519 +vi ~/.ssh/id_ed25519.pub +``` + +Add the the SSH key to the agent: +``` +chmod 600 ~/.ssh/id_ed25519* +eval "$(ssh-agent -s)" +ssh-add ~/.ssh/id_ed25519 +``` + +If the SSH key was generated, log into GitHub in a browser and add the key in +Settings. + +Clone the dotfiles repository: +``` +# Clone dotfiles +git clone git@github.com:pcewing/dotfiles.git ~/dot +``` + +If necessary, check out the desired branch: +``` +git checkout my-experimental-branch +``` + +Configure the host type of the machine so Nix knows how to provision it. For the list of valid host types, see [hosts.json](../nix/hosts.json). The easiest way is to export a `NIX_HOST` variable in `~/.localrc` as follows: +``` +echo "export NIX_HOST=\"personal-desktop\"" >> ~/.localrc +``` + +Apply the dotfiles configuration: +``` +cd ~/dot +./apply.sh +``` + +**Note:** The first time `apply.sh`, nix profile won't be sourced in the active shell. The easiest workaround is to just open a new shell. + +**TODO:** We should add a message to the end of the output instructing user to restart shell. We could write a file on the first run and check for its existence on subsequent runs. If it does not exist, prompt the user to restart the computer. Probably not a bad idea on the first bootstrap to make sure everything propagates. + +## Daily Operations + +After the initial setup, modifications made to dotfiles can be applied via the following alias: +``` +df_apply +``` + +## Manual Setup Steps + +### Git + +Create `~/.gitconfig_local` like: + +``` +[user] + email = paul@foo.com + name = Paul Ewing +``` + +### Desktop Wallpaper + +#### Basic Wallpaper Setup + +Put logic to apply a wallpaper in `~/set-bg.sh`. For example, download a +wallpaper to `~/Pictures/wallpaper.png` and set it via: +``` +feh --bg-scale "$HOME/Pictures/wallpaper.png" +``` + +You can also set the wallpaper using `nitrogen` and then in the shell script, run: +``` +nitrogen --restore & +``` + +#### Wallpaper Rotater + +If using `wpr`, create `~/.config/wpr/config.json` like: + +```json +{ + "WallpaperDir": "/home/username/Pictures/Wallpapers", + "DisplayCount": 1, + "Interval":120 +} +``` + +### Screen Layout + +When running a multi-monitor setup, set the screen layout by running `arandr`, +configuring the monitors as desired, and then saving the layout to +`~/.screenlayout/config.sh`. + +### Dual Boot Clock Fix + +If dual booting with Windows, set hardware clock to local time: + +```bash +timedatectl set-local-rtc 1 +``` + +Without this, clock time in Windows will be off. + +### Applications to Manually Install + +The following should be installed manually: + +- Chrome + - Reason: This is in Nix but when I tried using the Nix package, it just + crashes immediately and I didn't feel like debugging it. Most likely 3D + acceleration issues like i3wm and kitty had. +- Insync + - Download URL: https://www.insynchq.com/downloads/linux + - Setup: + - `insync start` + - Remember to set sync location to: `$HOME/box` + - Reason: Insync is available in Nix but there's a known bug with the tray + icon not rendering correctly. Given this already requires a fair amount + of manual setup to authenticate and map desired folders, installing it + manually is fine. +- Discord + - Reason: Discord stops working as soon as an upstream update is available + so it's easier to just install it via the official `.deb` and keep it + updated that way +- Visual Studio Code + - Probably could get this from Nix, I just didn't give it a proper go diff --git a/doc/setup_windows.md b/doc/setup_windows.md new file mode 100644 index 0000000..e16c065 --- /dev/null +++ b/doc/setup_windows.md @@ -0,0 +1,3 @@ +## Windows + +**TODO**: Add instructions here, like how the fake "symlinking" works, etc. diff --git a/doc/theme.md b/doc/theme.md new file mode 100644 index 0000000..9f3dfc4 --- /dev/null +++ b/doc/theme.md @@ -0,0 +1,72 @@ +# Theming + +**NOTE:** This is not quite accurate after switching to Nix and home-manager. +Now, dotfiles are effectively copied to their target location instead of +symlinked so when `set-theme` updates them, the changes won't take affect until +the next time Nix configuration is applied. + +To make it easier to re-theme everything at once, I use +[base16](https://github.com/chriskempson/base16) and +[flavours](https://github.com/Misterio77/flavours). See: + +The tl;dr of `base16` is that it is a system for designing color schemes. +`base16` schemes consists of a palette of 16 colors - 8 shades and 8 accents. +Templates can then be created to render the base16 scheme into various config +formats for different applications. + +Due to some [turbulence](https://github.com/tinted-theming/home/issues/51) in +the `base16` project, I've added my most used schemes directly to my dotfiles +to avoid things breaking if repositories are ever moved or taken down. I've +also created my own templates rather than using the defaults. + +- [schemes](./config/flavours/schemes/custom) +- [templates](./config/flavours/templates/custom/templates) + +Using the `flavours` application, these templates are rendered directly into my +dotfiles based on the `flavours` config: + +- [flavours/config.toml](./config/flavours/config.toml) + +To apply a new color scheme, download and install +[flavours](https://github.com/Misterio77/flavours/releases/latest). + +The first time running, update sources. Even if using schemes/templates +committed to my dotfiles, this still appears to be necessary: + +```bash +flavours update all +``` + +**Note:** We should add flavours installation to the provision script. + +Once flavours is installed, set the theme using the +[set-theme](./bin/set-theme) script. This not only executes `flavours` but also +reloads config across various applications to smoothly transition themes. + +```bash +set-theme +``` + +The name should match the corresponding base16 scheme yaml file without the +extension. For example: + +```bash +flavours apply outrun-dark +``` + +The official lists of templates and schemes supported by flavours live here: + +- https://github.com/chriskempson/base16-schemes-source/blob/main/list.yaml +- https://github.com/chriskempson/base16-templates-source/blob/master/list.yaml + +Manual steps after changing themes: + +- Reload tmux config + - `:source-file ~/.tmux.conf` + - We should figure out how to automate this + +### Incomplete + +Some remaining items to tackle in regards to theming: +- Add flavours templates for + - sway diff --git a/doc/todo.md b/doc/todo.md new file mode 100644 index 0000000..5d2510d --- /dev/null +++ b/doc/todo.md @@ -0,0 +1,173 @@ +# To-Do List + +Improvements I'd like to make to my dotfiles. + +## Table of Contents + +- [High Priority](#high-priority) + - [UltiSnips Freezing Issue](#ultisnips-freezing-issue) +- [Python CLI](#python-cli) + - [Bootstrapper](#bootstrapper) + - [Provisioner Groups or Tags](#provisioner-groups-or-tags) + - [Provisioner Command Logging](#provisioner-command-logging) + - [Check for Updates Feature](#check-for-updates-feature) + - [Code Cleanup](#code-cleanup) + - [Necessary Pip Packages](#necessary-pip-packages) +- [FZF Bash Integration](#fzf-bash-integration) +- [Windows support in Python CLI](#windows-support-in-python-cli) +- [wezterm shell integration](#wezterm-shell-integration) +- [I3WM "Virtual Desktops"](#i3wm-"virtual-desktops") +- [Python Tidy/Lint](#python-tidy/lint) +- [Don't symlink vim to neovim](#don't-symlink-vim-to-neovim) +- [Neovim](#neovim) + - [Neovim Healtheck](#neovim-healtheck) +- [Errors](#errors) + - [nvim-lua/completion-nvim](#nvim-lua/completion-nvim) + - [nvim-telescope/telescope.nvim](#nvim-telescope/telescope.nvim) +- [Warnings](#warnings) + - [glepnir/lspsaga.nvim](#glepnir/lspsaga.nvim) + - [nvim-treesitter/nvim-treesitter](#nvim-treesitter/nvim-treesitter) + - [Providers](#providers) + - [nvim-telescope/telescope.nvim](#nvim-telescope/telescope.nvim) + - [neovim/nvim-lspconfig](#neovim/nvim-lspconfig) + +**IMPORTANT NOTE:** A lot of the items in this file may be obsolete with the new Nix setup. Basically everything provisioner related is and I'm sure some other things are as well. We should go through and clean this up so that it's up-to-date. + +## High Priority + +### UltiSnips Freezing Issue + +**Note:** I'm not sure if this is still a problem after switching to Nix. Maybe wait and see if we still encounter this. + +UltiSnips freezes sometimes in Neovim which is really annoying and was marked as won't fix because it's specific to Neovim: + +https://github.com/SirVer/ultisnips/issues/1381 + +We should switch to another snippet plugin, maybe `vim-vsnip` since I see that's what someone else did: + +https://github.com/Sangdol/vimrc/commit/b6c5cf06b761b17d5b39c39a2ae9ad584f48761a + +## XP Submodule + +Use `xp` as a submodule to DRY our Python. + +## Python CLI + +### Git Sync Command + +Implement a simple `dot git-sync` command and a git alias to it like `git sync` +that does something like: + +``` +- Check if there are commits missing from upstream +- If there are, pull +- If there are merge conflicts, abort and print an error + - These should be handled manually +- If the merge is clean, continue on +- Add all local changes +- Commit local changes +- Push +``` + +This is for repositories like my notes where I basically just always want to +keep everything in sync and don't use branches. Optionally, accept a parameter +for commit message. + +#### Progress + +I started on this but it probably isn't bullet-proof yet. What it does: + +- If there are local changes that need to be committed + - Create a temporary branch and resolve/commit the changes in it + - This is a bit complicated and might be bug prone +- Fetch from all remotes +- Detect the most recent matching commit between the local and remote +- Get the number of commits the local repository is missing from remote and + vice versa +- Pull remote commits if there are any missing from local +- If there were local changes, cherry-pick them from the temp branch +- If remote is missing any local commits, push + +One thing I might want to change is to push the temporary branch to remote. I +just encountered an issue where I ran the sync command on my desktop PC and I +think it errored and I forgot to go back and resolve it. Now, working on my +laptop, I'm missing those changes. Had I at least pushed the temp branch, I +could have pulled it down and fixed it on my laptop but since I'm travelling +I'm just out of luck. + +### Code Cleanup + +- Remove the functions like `mkdir_p` in `util.py` and use the alternatives in + `shell.py` +- Use Python native facilities instead of the functions in `shell.py` + - These were used so that we could use `sudo` but now the tool just + elevates itself to root + +### Necessary Pip Packages + +python3 -m pip install typing_extensions + +Also needs to be installed as root if the script elevates +sudo python3 -m pip install typing_extensions + +# Windows Support + +- Get the `clean`/`link` commands to work on Windows +- One thing that would be handy is a command to diff the dotfiles against the + copied locations to see if we updated anything and forgot to backport it into + the repo + - Especially now that we're using Nix on Linux so those could get out of sync too + +## FZF Bash Integration + +**Note:** This statement is not longer accurate since we install fzf via Nix +now; however, I still don't think bash integration is set up so this TODO item +is still valid. + +`~/.fzf.bash` doesn't exist for me, maybe because I'm installing via apt. I'd +like that so I can get fzf `ctrl+r` functionality so update the provision +script to set that up correctly. + +## Python Tidy/Lint + +- [ ] Look into `ruff` since it may replace several other dependencies and also + claims to be much faster + +# Other TODOs (From 2025-01-03) + +- Add to setup doc: + - On multi-monitor setups, run arandr and set up a TODO.sh file + - Set up background wallpaper or whatever will manage it + - Maybe just `nitrogen --restore &` ? + - Can we put an svg in github repo and convert it to png or something? + - So it's text on disk and small in size but then we have a default wallpaper everywhere +- Move base16-shell installation out of bashrc maybe? + - Have nix do this? With current system, it will never update after first installation and it feels weird to have shell init scripts cloning git repositories +- Maybe we can merge some of the shell scripts i3 executes into a single shell script so they can all share the same logging and debugging facilities? +- bcompare in nix is Beyond Compare 4, is it possible to get 5? +- Are we forgetting to execute gtk stuff on i3 startup? Keyring, etc. + - Notice how long it takes `gnome-text-editor` to run; maybe it's a snap? + - Also look at some errors in terminal when running Firefox, Nitrogen, etc. + +- Rust App ideas + - Wallpaper setter? + - Cheat sheet viewer + - Tray icon with reminders about pending local git changes + +- .gitconfig_local + - Can we just make a new email solely for git that we can put in the public repo? + - Like, `git@pcewing.com`? + +## Old + +### I3WM "Virtual Desktops" + +**Note:** I don't realistically think this is worth the effort. Even if we ever +get it working, it will probably be time to switch to Wayland shortly after. + +10 workspaces isn't always enough. It would be nice to do something that +provides a similar workflow to virtual desktops on Windows. Like, 4 virtual +desktops that each have 10 workspaces. Maybe as an MVP, have a keyboard +shortcut that switches between the desktops and remaps keybindings accordingly. + +I've started noodling on a hacky PoC for this in `bin/i3-util.sh` diff --git a/img/wallpaper.svg b/img/wallpaper.svg new file mode 100755 index 0000000..5294e61 --- /dev/null +++ b/img/wallpaper.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/mypy.ini b/mypy.ini index c86bf6d..1854fdd 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3,14 +3,3 @@ warn_return_any = True warn_unused_configs = True disallow_untyped_defs = True - -# Per-module options: - -#[mypy-mycode.foo.*] -#disallow_untyped_defs = True - -#[mypy-mycode.bar] -#warn_return_any = False - -#[mypy-somelibrary] -#ignore_missing_imports = True diff --git a/nix/flake.lock b/nix/flake.lock new file mode 100644 index 0000000..e161f4c --- /dev/null +++ b/nix/flake.lock @@ -0,0 +1,49 @@ +{ + "nodes": { + "home-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1726989464, + "narHash": "sha256-Vl+WVTJwutXkimwGprnEtXc/s/s8sMuXzqXaspIGlwM=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "2f23fa308a7c067e52dfcc30a0758f47043ec176", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "release-24.05", + "repo": "home-manager", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1735563628, + "narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "home-manager": "home-manager", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/nix/flake.nix b/nix/flake.nix new file mode 100644 index 0000000..0242aca --- /dev/null +++ b/nix/flake.nix @@ -0,0 +1,52 @@ +{ + description = "Paul's Home Manager configuration"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05"; + + home-manager = { + url = "github:nix-community/home-manager/release-24.05"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + { + self, + nixpkgs, + home-manager, + ... + }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { inherit system; }; + + # Load host definitions from JSON + hostsData = builtins.fromJSON (builtins.readFile ./hosts.json); + + # Convert role name to module path + roleToModule = role: ./home/roles/${role}.nix; + + # Generate a home-manager configuration for a single host + mkHostConfig = + hostName: hostConfig: + home-manager.lib.homeManagerConfiguration { + inherit pkgs; + modules = [ + { + home.username = hostConfig.username; + home.homeDirectory = "/home/${hostConfig.username}"; + home.stateVersion = "24.05"; + + imports = map roleToModule hostConfig.roles; + } + ]; + }; + + # Generate all homeConfigurations from the JSON + homeConfigurations = builtins.mapAttrs mkHostConfig hostsData.hosts; + in + { + inherit homeConfigurations; + }; +} diff --git a/nix/home/features/development.nix b/nix/home/features/development.nix new file mode 100644 index 0000000..0daedae --- /dev/null +++ b/nix/home/features/development.nix @@ -0,0 +1,59 @@ +# This module provides a set of common software development tools for various +# languages. It can be enabled in a role to provide a baseline development +# environment. +{ + config, + pkgs, + lib, + ... +}: + +{ + options.development.enable = lib.mkEnableOption "development tools"; + + config = lib.mkIf config.development.enable { + home.packages = with pkgs; [ + ######### + # Build Systems + ######### + meson + ninja + + ######### + # Golang + ######### + go + gopls + delve + + ######### + # Rust + ######### + rustup + + ######### + # NodeJS + ######### + nodejs + nodePackages.npm + + ######### + # Java + ######### + openjdk + + ######### + # .NET + ######### + dotnet-sdk + ]; + + # Ensure GOPATH is set + home.sessionVariables = { + GOPATH = "$HOME/go"; + }; + + # Add Go bin to PATH + home.sessionPath = [ "$HOME/go/bin" ]; + }; +} diff --git a/nix/home/lib/dotfiles-links.nix b/nix/home/lib/dotfiles-links.nix new file mode 100644 index 0000000..bda2509 --- /dev/null +++ b/nix/home/lib/dotfiles-links.nix @@ -0,0 +1,98 @@ +{ config, lib, ... }: + +let + dotConfigDir = ../../../config; + + # Helper to classify a link as XDG-compliant (in .config) or a regular + # home-relative dotfile. + link = + { dst, srcPath }: + if lib.hasPrefix ".config/" dst then + { + xdg = true; + key = lib.removePrefix ".config/" dst; + value = { + source = srcPath; + }; + } + else + { + xdg = false; + key = dst; + value = { + source = srcPath; + }; + }; + + # Helper to create a link item for the `items` list. + mk = + dstRel: srcRel: + link { + dst = dstRel; + srcPath = dotConfigDir + "/${srcRel}"; + }; + + # List of all files and directories to be linked into the home directory. + items = [ + (mk ".Xresources" "Xresources") + (mk ".bash_profile" "bash_profile") + (mk ".bashrc" "bashrc") + (mk ".config/dunst/dunstrc" "dunstrc") + (mk ".env" "env") + (mk ".gitconfig" "gitconfig") + (mk ".gvimrc" "gvimrc") + (mk ".config/i3/config" "i3") + (mk ".inputrc" "inputrc") + (mk ".config/mpd/mpd.conf" "mpd") + (mk ".config/ncmpcpp/bindings" "ncmpcpp/bindings") + (mk ".config/ncmpcpp/config" "ncmpcpp/config") + (mk ".config/picom/picom.conf" "picom.conf") + (mk ".profile" "profile") + (mk ".pulse/daemon.conf" "pulse/daemon.conf") + (mk ".config/py3status/config" "py3status.conf") + (mk ".config/ranger/rc.conf" "rangerrc") + (mk ".config/rofi/config.rasi" "rofi/config.rasi") + (mk ".config/rofi/base16.rasi" "rofi/base16.rasi") + (mk ".config/sway/config" "sway") + (mk ".swaysession" "swaysession") + (mk ".tmux.conf" "tmux.conf") + (mk ".vimrc" "vimrc") + (mk ".xinitrc" "xinitrc") + (mk ".xsession" "xsession") + + # directories + (mk ".config/nvim" "nvim") + (mk ".config/flavours" "flavours") + + # individual files under ~/.config + (mk ".config/kitty/kitty.conf" "kitty.conf") + (mk ".config/alacritty/alacritty.yml" "alacritty.yml") + (mk ".config/alacritty/base16.yml" "alacritty/base16.yml") + (mk ".config/alacritty/linux.yml" "alacritty/linux.yml") + (mk ".config/wezterm/wezterm.lua" "wezterm.lua") + ]; + + # Separate items into XDG and home-relative lists. + xdgPairs = builtins.filter (x: x.xdg) items; + homePairs = builtins.filter (x: !x.xdg) items; + + # Convert the lists to attribute sets suitable for home-manager options. + xdgAttr = lib.listToAttrs ( + map (x: { + name = x.key; + value = x.value; + }) xdgPairs + ); + homeAttr = lib.listToAttrs ( + map (x: { + name = x.key; + value = x.value; + }) homePairs + ); +in +{ + xdg.enable = true; + + home.file = homeAttr; + xdg.configFile = xdgAttr; +} diff --git a/nix/home/lib/python-environment.nix b/nix/home/lib/python-environment.nix new file mode 100644 index 0000000..feae220 --- /dev/null +++ b/nix/home/lib/python-environment.nix @@ -0,0 +1,37 @@ +{ + config, + pkgs, + lib, + ... +}: + +let + # Collect all Python package functions from features + allPyPkgs = ps: lib.flatten (map (f: f ps) config.myPython.packageFns); + + # Build the unified Python environment + pythonEnv = pkgs.python3.withPackages allPyPkgs; +in +{ + options.myPython = { + packageFns = lib.mkOption { + type = lib.types.listOf (lib.types.functionTo (lib.types.listOf lib.types.package)); + default = [ ]; + description = "List of functions that take python packages and return packages to include"; + }; + + environment = lib.mkOption { + type = lib.types.package; + description = "The unified Python environment (read-only)"; + readOnly = true; + }; + }; + + config = { + # Expose the Python environment for other modules to reference + myPython.environment = pythonEnv; + + # Build a unified Python environment with all requested packages + home.packages = lib.mkIf (config.myPython.packageFns != [ ]) [ pythonEnv ]; + }; +} diff --git a/nix/home/packages/cql-vim.nix b/nix/home/packages/cql-vim.nix new file mode 100644 index 0000000..f424153 --- /dev/null +++ b/nix/home/packages/cql-vim.nix @@ -0,0 +1,13 @@ +{ pkgs, ... }: + +pkgs.vimUtils.buildVimPlugin { + pname = "cql-vim"; + version = "unstable-2024-01-03"; + src = pkgs.fetchFromGitHub { + owner = "elubow"; + repo = "cql-vim"; + rev = "6f61df5a633c3a91edea7bcb5d5772648df19d1a"; + sha256 = "sha256-GHiXIJpNJj/ysWywWziUuTy21LKjZKe7q3bUkJjeMV0="; + }; + meta.homepage = "https://github.com/elubow/cql-vim"; +} diff --git a/nix/home/packages/mesonic.nix b/nix/home/packages/mesonic.nix new file mode 100644 index 0000000..b58602a --- /dev/null +++ b/nix/home/packages/mesonic.nix @@ -0,0 +1,13 @@ +{ pkgs, ... }: + +pkgs.vimUtils.buildVimPlugin { + pname = "mesonic"; + version = "unstable-2024-01-03"; + src = pkgs.fetchFromGitHub { + owner = "igankevich"; + repo = "mesonic"; + rev = "d6780c3af29ebfc8c631399b2692b928da9bf7bd"; + sha256 = "sha256-XFrV7ZJtVqmUsad/94UZ/ZnPQOKyZ6mmsJlVLtKKAZQ="; + }; + meta.homepage = "https://github.com/igankevich/mesonic"; +} diff --git a/nix/home/packages/nvim-markdown.nix b/nix/home/packages/nvim-markdown.nix new file mode 100644 index 0000000..05a0513 --- /dev/null +++ b/nix/home/packages/nvim-markdown.nix @@ -0,0 +1,13 @@ +{ pkgs, ... }: + +pkgs.vimUtils.buildVimPlugin { + pname = "nvim-markdown"; + version = "unstable-2024-01-03"; + src = pkgs.fetchFromGitHub { + owner = "ixru"; + repo = "nvim-markdown"; + rev = "37850581fdaec153ce84af677d43bf8fce60813a"; + sha256 = "sha256-wjYTO9WqdDEbH4L3dsHqOoeQf0y/Uo6WX94w/D4EuGU="; + }; + meta.homepage = "https://github.com/ixru/nvim-markdown"; +} diff --git a/nix/home/packages/wpr.nix b/nix/home/packages/wpr.nix new file mode 100644 index 0000000..b4cbcd4 --- /dev/null +++ b/nix/home/packages/wpr.nix @@ -0,0 +1,33 @@ +# wpr - Custom X11 Wallpaper Rotator Tool +# https://s3-us-west-2.amazonaws.com/pcewing-wpr/releases/ +{ + lib, + stdenv, + fetchurl, + autoPatchelfHook, +}: + +stdenv.mkDerivation rec { + pname = "wpr"; + version = "0.1.0"; + + src = fetchurl { + url = "https://s3-us-west-2.amazonaws.com/pcewing-wpr/releases/${version}/wpr.${version}.linux-amd64.tar.gz"; + sha256 = "sha256-YuJBoD8JWn1+fBMYdihIRz7qxpM+T+WmC3ruL2TcHWM="; + }; + + nativeBuildInputs = [ autoPatchelfHook ]; + + sourceRoot = "."; + + installPhase = '' + mkdir -p $out/bin + cp wpr $out/bin/ + chmod +x $out/bin/wpr + ''; + + meta = with lib; { + description = "wpr x11 wallpaper rotator tool"; + platforms = platforms.linux; + }; +} diff --git a/nix/home/roles/core.nix b/nix/home/roles/core.nix new file mode 100644 index 0000000..3aec04d --- /dev/null +++ b/nix/home/roles/core.nix @@ -0,0 +1,199 @@ +{ + config, + pkgs, + lib, + ... +}: + +let + wpr = pkgs.callPackage ../packages/wpr.nix { }; + nvim-markdown = pkgs.callPackage ../packages/nvim-markdown.nix { }; + cql-vim = pkgs.callPackage ../packages/cql-vim.nix { }; + mesonic = pkgs.callPackage ../packages/mesonic.nix { }; +in +{ + imports = [ + ../lib/dotfiles-links.nix + ../lib/python-environment.nix + ../features/development.nix + ]; + + # Enable the development feature + development.enable = true; + + # Declare Python packages needed by core + myPython.packageFns = [ + ( + ps: with ps; [ + pip + pynvim + mpd2 + black + mypy + isort + flake8 + autoflake + argcomplete + json5 + ] + ) + ]; + + home.sessionVariables = { + EDITOR = "nvim"; + VISUAL = "nvim"; + }; + + nixpkgs.config.allowUnfree = true; + + home.packages = with pkgs; [ + ################################# + # Core utilities + ################################# + cacert + wget + gnupg + jq + plocate + fzf + nettools + unzip + libuchardet + xz + dos2unix + + ############################### + # Basic command line utilities + ############################### + gnumake + gcc + cmake + htop + iotop + universal-ctags + ranger + tmux + neofetch + id3v2 + calcurse + vim + tree-sitter + + ################# + # Nix tooling + ################# + nixfmt-rfc-style + + ################# + # C/C++ tooling + ################# + clang-tools + + ################# + # Search / utils + ################# + ripgrep + + ################# + # Theming tool + ################# + flavours + + # Ruff is a Rust tool, top-level package + ruff + + ################# + # Custom packages + ################# + wpr + ]; + + # Provide a stable `dot` command that always uses the unified Python env + home.file.".local/bin/dot" = { + executable = true; + text = '' + #!/usr/bin/env bash + # Find python3 from the unified environment in PATH + exec python3 "$HOME/dot/cli/dot.py" "$@" + ''; + }; + + # Ensure ~/.local/bin is on PATH so `dot` is found + home.sessionPath = [ "$HOME/.local/bin" ]; + + programs.git.enable = true; + + programs.neovim = { + enable = true; + defaultEditor = true; + + plugins = with pkgs.vimPlugins; [ + fzf-vim + + telescope-nvim + plenary-nvim + popup-nvim + + nvim-treesitter.withAllGrammars + + ultisnips + vim-snippets + + # Custom nvim-markdown plugin + + vim-clang-format + nvim-lspconfig + + copilot-vim + + base16-vim + vim-puppet + vim-go + vim-csharp + emmet-vim + vim-vinegar + vim-flake8 + vim-glsl + Jenkinsfile-vim-syntax + plantuml-syntax + cql-vim + vim-fugitive + mesonic + + nvim-cmp + lspsaga-nvim + lualine-nvim + ]; + + extraPackages = with pkgs; [ + fzf + ripgrep + fd + clang-tools + nodejs # needed for copilot + some LSP tooling + ]; + }; + + programs.home-manager.enable = true; + + # Optional: generate a static completion file during activation + home.activation.dotArgcomplete = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + if [ -x "$HOME/dot/cli/dot.py" ]; then + mkdir -p "$HOME/.config/bash/completions" + # Use the unified Python environment directly + ${config.myPython.environment}/bin/register-python-argcomplete \ + --external-argcomplete-script "$HOME/dot/cli/dot.py" dot \ + > "$HOME/.config/bash/completions/dot" + fi + ''; + + # Update flavours base16 schemes and templates on activation + home.activation.flavoursUpdate = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + if command -v flavours >/dev/null 2>&1; then + echo "Updating flavours schemes and templates..." + # flavours update can be noisy and sometimes fails on first run, so we + # suppress errors and output + ${pkgs.flavours}/bin/flavours update all >/dev/null 2>&1 || true + fi + ''; +} diff --git a/nix/home/roles/desktop.nix b/nix/home/roles/desktop.nix new file mode 100644 index 0000000..c4a36f8 --- /dev/null +++ b/nix/home/roles/desktop.nix @@ -0,0 +1,118 @@ +{ pkgs, lib, ... }: + +let + wallpaperSvg = ../../../img/wallpaper.svg; + setBgTemplate = ../../../templates/set-bg.sh; +in +{ + imports = [ + ../lib/python-environment.nix + ]; + + # Declare Python packages needed by desktop + myPython.packageFns = [ + ( + ps: with ps; [ + py3status + # mpd2 is already included from core.nix + ] + ) + ]; + + home.sessionVariables = { + TERMINAL = "kitty"; + }; + + home.packages = with pkgs; [ + ######################## + # Desktop / GUI utilities + ######################## + font-awesome + rofi + dunst + feh + sxiv + nitrogen + pavucontrol + picom + scrot + gucharmap + keepassxc + remmina + i3lock + meld + xclip + wl-clipboard + xdotool + libwebp + arandr + librsvg # For SVG to PNG conversion + + # Even though kitty is our primary terminal emulator now, install urxvt as + # a backup because kitty's dependence on 3D acceleration has been + # problematic in the past + rxvt-unicode + + ######## + # Media + ######## + inkscape + mpv + vlc + easytag + blueman + + ######################### + # Music tooling (desktop-y) + ######################### + mpd + (ncmpcpp.override { + visualizerSupport = true; + }) + cava + + ######################### + # Video download + ######################### + yt-dlp + + ######################### + # Proprietary Software + ######################### + bcompare + + ######################### + # System Utilities + ######################### + ventoy + ]; + + # Install ~/set-bg.sh from templates/set-bg.sh only if it doesn't already + # exist. This allows per-machine customization without home-manager + # overwriting it. + home.activation.installSetBgScript = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + target="$HOME/set-bg.sh" + if [ ! -e "$target" ]; then + echo "Installing default $target from template..." + install -m 0755 ${setBgTemplate} "$target" + else + echo "$target already exists; leaving it untouched." + fi + ''; + + # Generate a PNG wallpaper from the source SVG. + home.activation.generateWallpaper = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + echo "Generating default wallpaper from SVG..." + mkdir -p "$HOME/Pictures" + ${pkgs.librsvg}/bin/rsvg-convert \ + -w 3840 -h 2160 \ + -o "$HOME/Pictures/default_wallpaper.png" \ + ${wallpaperSvg} + echo "Wallpaper generated at $HOME/Pictures/default_wallpaper.png" + ''; + + # Make sure mpd runtime directories exist or it will complain on the first startup + home.activation.createMpdDirs = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + mkdir -p "$HOME/.mpd/playlists" "$HOME/.local/share/mpd" + ''; +} diff --git a/nix/home/roles/gaming.nix b/nix/home/roles/gaming.nix new file mode 100644 index 0000000..521c7b6 --- /dev/null +++ b/nix/home/roles/gaming.nix @@ -0,0 +1,12 @@ +{ pkgs, ... }: +{ + # IMPROVEMENT: Removed redundant nixpkgs.config.allowUnfree = true since it's + # already set in core.nix which is always imported alongside gaming.nix + + home.packages = with pkgs; [ + steam + steam-run + + runelite + ]; +} diff --git a/nix/home/roles/wsl.nix b/nix/home/roles/wsl.nix new file mode 100644 index 0000000..a0f2546 --- /dev/null +++ b/nix/home/roles/wsl.nix @@ -0,0 +1,71 @@ +{ pkgs, lib, ... }: + +let + win32yankVersion = "v0.1.1"; + win32yankUrl = "https://github.com/equalsraf/win32yank/releases/download/${win32yankVersion}/win32yank-x64.zip"; + win32yankInstallDir = "/mnt/c/bin"; + win32yankExe = "${win32yankInstallDir}/win32yank.exe"; + win32yankVersionFile = "${win32yankInstallDir}/win32yank_version.txt"; +in +{ + home.sessionVariables = { + BROWSER = "wslview"; + }; + + # Install win32yank to Windows filesystem for clipboard integration + # This needs to be on NTFS (not WSL filesystem) for performance reasons + # IMPROVEMENT: Using run to respect verbose/dry-run flags from home-manager + home.activation.installWin32yank = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + run() { echo "running: $*"; "$@"; } + WIN32YANK_VERSION="${win32yankVersion}" + WIN32YANK_URL="${win32yankUrl}" + WIN32YANK_DIR="${win32yankInstallDir}" + WIN32YANK_EXE="${win32yankExe}" + WIN32YANK_VERSION_FILE="${win32yankVersionFile}" + + install_win32yank() { + echo "Installing win32yank $WIN32YANK_VERSION..." + mkdir -p "$WIN32YANK_DIR" + TMP_DIR=$(mktemp -d) + ${pkgs.curl}/bin/curl -sL "$WIN32YANK_URL" -o "$TMP_DIR/win32yank.zip" + ${pkgs.unzip}/bin/unzip -o "$TMP_DIR/win32yank.zip" -d "$TMP_DIR" + cp "$TMP_DIR/win32yank.exe" "$WIN32YANK_EXE" + chmod +x "$WIN32YANK_EXE" + echo "$WIN32YANK_VERSION" > "$WIN32YANK_VERSION_FILE" + rm -rf "$TMP_DIR" + echo "win32yank $WIN32YANK_VERSION installed to $WIN32YANK_EXE" + } + + # Check if we need to install or update + if [ ! -f "$WIN32YANK_EXE" ]; then + echo "win32yank not found, installing..." + install_win32yank + elif [ ! -f "$WIN32YANK_VERSION_FILE" ]; then + echo "win32yank version file not found, reinstalling..." + install_win32yank + elif [ "$(cat "$WIN32YANK_VERSION_FILE")" != "$WIN32YANK_VERSION" ]; then + echo "win32yank version mismatch (have $(cat "$WIN32YANK_VERSION_FILE"), want $WIN32YANK_VERSION), updating..." + install_win32yank + else + echo "win32yank $WIN32YANK_VERSION already installed" + fi + ''; + + # Link the wslrc file. This is only active for WSL hosts. + home.file.".wslrc".source = ../../../config/wslrc; + + # Download wezterm shell integration script + home.activation.downloadWeztermShellIntegration = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + WEZTERM_SH_URL="https://raw.githubusercontent.com/wez/wezterm/main/assets/shell-integration/wezterm.sh" + WEZTERM_SH_DEST="$HOME/wezterm.sh" + + if [ ! -f "$WEZTERM_SH_DEST" ]; then + echo "Downloading wezterm.sh for shell integration..." + ${pkgs.curl}/bin/curl -sfL "$WEZTERM_SH_URL" -o "$WEZTERM_SH_DEST" + chmod +x "$WEZTERM_SH_DEST" + echo "wezterm.sh installed to $WEZTERM_SH_DEST" + else + echo "wezterm.sh already exists, skipping download." + fi + ''; +} diff --git a/nix/hosts.json b/nix/hosts.json new file mode 100644 index 0000000..26dcb6d --- /dev/null +++ b/nix/hosts.json @@ -0,0 +1,28 @@ +{ + "hosts": { + "personal-desktop": { + "username": "pewing", + "roles": ["core", "desktop", "gaming"] + }, + "work-desktop": { + "username": "pewing", + "roles": ["core", "desktop"] + }, + "personal-wsl": { + "username": "pewing", + "roles": ["core", "wsl"] + }, + "work-wsl": { + "username": "pewing", + "roles": ["core", "wsl"] + }, + "personal-server": { + "username": "pewing", + "roles": ["core"] + }, + "work-server": { + "username": "pewing", + "roles": ["core"] + } + } +} diff --git a/provision/manjaro.sh b/provision/manjaro.sh deleted file mode 100755 index 76771dc..0000000 --- a/provision/manjaro.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bash - -# TODO: This script is very much WIP as I transition to Manjaro - -function pacman_install() { - package="$1" - sudo pacman -Syu --noconfirm $package -} - -function install_insync() { - # Instructions copied from: - # https://help.insynchq.com/en/articles/3417503-linux-installation-guide-unofficial - sudo pacman -S base-devel - git clone https://aur.archlinux.org/insync.git insync_install - cd insync_install - makepkg -si -} - -pacman_install base-devel -pacman_install ctags -pacman_install discord -pacman_install docker -pacman_install firefox -pacman_install flatpak -pacman_install htop -pacman_install i3-gaps -pacman_install i3status -pacman_install inkscape -pacman_install jdk-openjdk -pacman_install keepassxc -pacman_install kicad -pacman_install mlocate -pacman_install mpd -pacman_install mpv -pacman_install ncmpcpp -pacman_install poppler -pacman_install py3status -pacman_install python-pip -pacman_install ranger -pacman_install remmina -pacman_install rofi -pacman_install rxvt-unicode -pacman_install steam -pacman_install tmux -pacman_install transmission-gtk -pacman_install ttf-font-awesome -pacman_install xclip -pacman_install scrot -pacman_install feh - -# Sway is not quite ready for usage as a daily driver; however, this is the -# command to install it along with peripherals. -#pacman_install \ -# mako \ -# sway \ -# swaybg \ -# swayidle \ -# swaylock \ -# waybar \ -# wl-clipboard \ - -# Proprietary packages from the AUR -#pamac build nordvpn-bin -#pamac build bcompare - -# https://github.com/flathub/com.slack.Slack/issues/34 -#flatpak install com.slack.Slack diff --git a/provision/ubuntu.sh b/provision/ubuntu.sh deleted file mode 100755 index 230b8ff..0000000 --- a/provision/ubuntu.sh +++ /dev/null @@ -1,398 +0,0 @@ -#!/usr/bin/env bash - -function yell () { >&2 echo "$*"; } -function die () { yell "$*"; exit 1; } -function try () { "$@" || die "Command failed: $*"; } - -script_path="$( realpath "$0" )" -script_dir="$( dirname "$script_path" )" - -print_header() { - local header="$1" - - echo -e "\\n" - echo "$header" - echo "========================================" -} - -apt_update() { - echo "(Apt) Updating package lists... " - try sudo apt-get -y update -} - -apt_dist_upgrade() { - echo "(Apt) Upgrading packages... " - try sudo apt-get -y dist-upgrade -} - -apt_install() { - local packages="$@" - - echo "(Apt) Installing $packages... " - try sudo apt-get -y install $packages -} - -pip_install() { - local packages="$@" - - echo "(Pip) Installing $packages... " - try python3 -m pip install $packages -} - -function get_latest_github_release() { - local org="$1" - local repo="$2" - - local api_url="https://api.github.com/repos/$org/$repo/releases/latest" - echo "$( curl --silent "$api_url" | jq -r .tag_name )" -} - -###################################### -# Application Installation Functions # -###################################### - -configure_xsession() { - local src_path="$1" - local dst_path="$2" - - echo "Configuring xsession... " - - [ -f "$src_path" ] || die "File $src_path does not exist!" - - try sudo rm -f "$dst_path" - try sudo cp "$src_path" "$dst_path" - try sudo chmod 644 "$dst_path" -} - -configure_wayland_session() { - local src_path="$1" - local dst_path="$2" - - echo "Configuring wayland session... " - - [ -f "$src_path" ] || die "File $src_path does not exist!" - - try sudo rm -f "$dst_path" - try sudo cp "$src_path" "$dst_path" - try sudo chmod 644 "$dst_path" -} - -install_apt_packages() { - local p - - # Core utitilies - p="apt-utils" - p+=" ca-certificates" - p+=" curl" - p+=" wget" - p+=" gnupg" - p+=" jq" - p+=" software-properties-common" - p+=" apt-file" - - # Basic command line utitilies - p+=" make" - p+=" build-essential" - p+=" cmake" - p+=" meson" - p+=" htop" - p+=" iotop" - p+=" git" - p+=" vim" - #p+=" exuberant-ctags" - p+=" universal-ctags" # I think this has better c++11 support - p+=" ranger" - p+=" tmux" - p+=" neofetch" - p+=" id3v2" - p+=" calcurse" - - # Python - p+=" python python-dev" # Python 2.7 - p+=" python3 python3-dev python3-pip" # Python 3.x - - # General GUI Applications - p+=" fonts-font-awesome" # Used for media buttons on polybar - p+=" rofi" # Fuzzy application launcher - p+=" dunst" # Desktop notifications - p+=" feh" # Set wallpaper - p+=" sxiv" # Image viewer - p+=" nitrogen" # Set wallpaper - p+=" pavucontrol" # Pulse Audio frontend - p+=" compton" # Window compositor - p+=" scrot" # Screen capture - p+=" gucharmap" # Useful for debugging font issues - p+=" keepassxc" # Credential manager - p+=" remmina" # RDP session manager - p+=" usb-creator-gtk" # Easily flash bootable USBs - p+=" i3lock" # Lock screen - p+=" meld" # Diff tool - p+=" xclip" # Clipboard for X11 - p+=" wl-clipboard" # Clipboard for Wayland - p+=" xdotool" # X11 automation tool - p+=" kitty" # Kitty terminal emulator - p+=" kitty-terminfo" # Kitty TERMINFO - - # Media - p+=" inkscape" # Vector graphics editor - p+=" mpv" # Minimal media player - p+=" vlc" # General purpose FOSS media player - p+=" easytag" # Edit ID3 Tags on MP3 files - p+=" blueman" # Bluetooth device support - - # Gaming - p+=" steam" - p+=" steam-devices" - - apt_install "$p" -} - -install_neovim() { - print_header "Installing neovim" - - local nvim_path="/usr/local/bin/nvim" - if [ -f "$nvim_path" ]; then - echo "$nvim_path already exists, skipping installation..." - return - fi - - try sudo mkdir -p /opt/neovim - curl -LO https://github.com/neovim/neovim/releases/latest/download/nvim.appimage - try sudo chmod u+x nvim.appimage - try sudo mv nvim.appimage /opt/neovim/nvim - try sudo ln -s /opt/neovim/nvim $nvim_path - - echo "Installing pynvim python modules..." - try sudo pip3 install --upgrade pynvim - - echo "Updating alternatives to use nvim..." - try sudo update-alternatives --install /usr/bin/vi vi "$nvim_path" 60 - try sudo update-alternatives --set vi "$nvim_path" - try sudo update-alternatives --install /usr/bin/vim vim "$nvim_path" 60 - try sudo update-alternatives --set vim "$nvim_path" - try sudo update-alternatives --install /usr/bin/editor editor "$nvim_path" 60 - try sudo update-alternatives --set editor "$nvim_path" -} - -install_cava() { - local cache_dir="$1" - - local version="$(get_latest_github_release "karlstav" "cava")" - - print_header "Installing cava ($version)" - - local cava_dir="$cache_dir/cava/$version" - local cava_exe="$cava_dir/cava" - if [ -f "$cava_exe" ]; then - echo "$cava_exe already exists, skipping installation..." - return - fi - - echo "Installing pre-requisites..." - apt_install "libfftw3-dev libasound2-dev libncursesw5-dev libpulse-dev libtool" - - echo "Cloning the cava repository" - try mkdir -p "$(dirname -- "$cava_dir")" - try git clone "https://github.com/karlstav/cava" "$cava_dir" - - local pwd; pwd="$(pwd)" - try cd "$cava_dir" - - echo "Building cava $version..." - try ./autogen.sh - try ./configure - try make - - echo "Installing cava $version..." - try sudo make install - - try cd "$pwd" -} - -install_i3gaps() { - local cache_dir="$1" - - local version="$(get_latest_github_release "airblader" "i3")" - - print_header "Installing i3-gaps ($version)" - - local i3gaps_dir="$cache_dir/i3gaps/$version" - local i3gaps_exe="$i3gaps_dir/build/i3" - if [ -f "$i3gaps_exe" ]; then - echo "$i3gaps_exe already exists, skipping installation..." - return - fi - - echo "Installing pre-requisites..." - apt_install "libxcb1-dev libxcb-keysyms1-dev libpango1.0-dev libxcb-util0-dev libxcb-icccm4-dev libyajl-dev libstartup-notification0-dev libxcb-randr0-dev libev-dev libxcb-cursor-dev libxcb-xinerama0-dev libxcb-xkb-dev libxkbcommon-dev libxkbcommon-x11-dev autoconf libxcb-xrm0 libxcb-xrm-dev libxcb-shape0 libxcb-shape0-dev automake" - - echo "Cloning the i3-gaps repository" - try mkdir -p "$(dirname -- "$i3gaps_dir")" - try git clone "https://www.github.com/Airblader/i3" "$i3gaps_dir" - - local pwd; pwd="$(pwd)" - try cd "$i3gaps_dir" - - echo "Checkout out version $version..." - try git checkout "$version" - - echo "Building i3-gaps $version..." - try mkdir -p build - try cd build - try meson .. - try ninja - - echo "Installing i3-gaps $version..." - try sudo meson install - - apt_install "i3status" - pip_install "py3status" - - try cd "$pwd" -} - -install_youtube-dl() { - local cache_dir="$1" - local bin_dir="$2" - - local version="$(get_latest_github_release "ytdl-org" "youtube-dl")" - - print_header "Installing youtube-dl ($version)" - - local ytdl_dir="$cache_dir/youtube-dl/$version" - local ytdl_exe="$ytdl_dir/youtube-dl" - if [ -f "$ytdl_exe" ]; then - echo "$ytdl_exe already exists, skipping installation..." - return - fi - - echo "Downloading youtube-dl $version..." - mkdir -p "$ytdl_dir" - try curl -L "https://yt-dl.org/downloads/$version/youtube-dl" -o "$ytdl_exe" - - echo "Installing youtube-dl $version..." - try chmod a+rx "$ytdl_exe" - try rm -f "$bin_dir/youtube-dl" - try ln -s "$ytdl_exe" "$bin_dir/youtube-dl" -} - -install_urxvt() { - print_header "Installing rxvt-unicode" - - if [ "$(command -v urxvt)" = "" ]; then - apt_install rxvt-unicode - else - echo "Skipping installation because urxvt is already installed..." - fi - - echo "Setting urxvt as the default terminal emulator..." - try sudo update-alternatives --set x-terminal-emulator "$(command -v urxvt)" -} - -install_wpr() { - local cache_dir="$1" - local bin_dir="$2" - - local version="0.1.0" - - print_header "Installing wpr..." - - local wpr_dir="$cache_dir/wpr/$version" - local wpr_exe="$wpr_dir/wpr" - if [ -f "$wpr_exe" ]; then - echo "$wpr_exe already exists, skipping installation..." - return - fi - - echo "Downloading wpr $version..." - mkdir -p "$wpr_dir" - local tarball_name="wpr.$version.linux-amd64.tar.gz" - local s3_url="https://s3-us-west-2.amazonaws.com" - local url="$s3_url/pcewing-wpr/releases/$version/$tarball_name" - try curl -L "$url" -o "$wpr_dir/$tarball_name" - - echo "Installing wpr $version..." - try tar --directory "$wpr_dir" -xvf "$wpr_dir/$tarball_name" - try chmod a+rx "$wpr_exe" - try rm -f "$bin_dir/wpr" - try ln -s "$wpr_exe" "$bin_dir/wpr" -} - -install_mpd() { - print_header "Installing mpd" - - if [ ! -z "$(command -v mpd)" ]; then - echo "mpd is already installed, skipping installation..." - return - fi - - apt_install "mpd" - - echo "Disabling the mpd service..." - try sudo systemctl stop mpd.service - try sudo systemctl stop mpd.socket - try sudo systemctl disable mpd.service - try sudo systemctl disable mpd.socket - - echo "Configuring mpd..." - mkdir -p "$HOME/.mpd" - mkdir -p "$HOME/.mpd/playlists" - mkdir -p "$HOME/.local/share/mpd" - - pip_install "python-mpd2" -} - -install_ncmpcpp() { - print_header "Installing ncmpcpp" - - if [ ! -z "$(command -v ncmpcpp)" ]; then - echo "ncmpcpp is already installed, skipping installation..." - return - fi - - apt_install "ncmpcpp" - - echo "Configuring ncmpcpp..." - mkdir -p "$HOME/.config/ncmpcpp" -} - -######## -# Main # -######## - -[[ -z "$DOTFILES" ]] && DOTFILES="$HOME/dot" - -source "$DOTFILES/config/bash/functions.sh" - -# For applications that are built from source, we will put them here -cache_dir="$HOME/.dotcache" && mkdir -p "$cache_dir" -bin_dir="$HOME/bin" && mkdir -p "$bin_dir" - -# Make sure apt is ready to use -apt_update -apt_dist_upgrade - -# Install everything via apt that is available in the default repositories -install_apt_packages - -# Set up a simple xsession desktop file that display managers will recognize. -# This will execute /etc/X11/Xsession which in turn executes the .xsession in -# the user's home directory. -configure_xsession \ - "$DOTFILES/config/xsession.desktop" \ - "/usr/share/xsessions/xsession.desktop" - -# Set up a wayland session for sway that the display manager will recognize -configure_wayland_session \ - "$DOTFILES/config/sway-user.desktop" \ - "/usr/share/wayland-sessions/sway-user.desktop" - -# Install everything else that needs special attention -install_urxvt -install_neovim -install_i3gaps "$cache_dir" -install_cava "$cache_dir" -install_youtube-dl "$cache_dir" "$bin_dir" -install_wpr "$cache_dir" "$bin_dir" -install_mpd -install_ncmpcpp diff --git a/provision/ubuntu_22.04.sh b/provision/ubuntu_22.04.sh deleted file mode 100755 index edd186a..0000000 --- a/provision/ubuntu_22.04.sh +++ /dev/null @@ -1,484 +0,0 @@ -#!/usr/bin/env bash - -function yell () { >&2 echo "$*"; } -function die () { yell "$*"; exit 1; } -function try () { "$@" || die "Command failed: $*"; } - -SCRIPT_PATH="$( realpath "$0" )" -SCRIPT_DIR="$( dirname "$SCRIPT_PATH" )" - -print_header() { - local header="$1" - - echo -e "\\n" - echo "$header" - echo "========================================" -} - -apt_update() { - echo "(Apt) Updating package lists... " - try sudo apt-get -y update -} - -apt_dist_upgrade() { - echo "(Apt) Upgrading packages... " - try sudo apt-get -y dist-upgrade -} - -apt_install() { - local packages="$@" - - echo "(Apt) Installing $packages... " - try sudo apt-get -y install $packages -} - -pip_install() { - local packages="$@" - - echo "(Pip) Installing $packages... " - try python3 -m pip install $packages -} - -function get_latest_github_release() { - local org="$1" - local repo="$2" - - local api_url="https://api.github.com/repos/$org/$repo/releases/latest" - echo "$( curl --silent "$api_url" | jq -r .tag_name )" -} - -###################################### -# Application Installation Functions # -###################################### - -configure_xsession() { - local src_path="$1" - local dst_path="$2" - - echo "Configuring xsession... " - - [ -f "$src_path" ] || die "File $src_path does not exist!" - - try sudo rm -f "$dst_path" - try sudo cp "$src_path" "$dst_path" - try sudo chmod 644 "$dst_path" -} - -configure_wayland_session() { - local src_path="$1" - local dst_path="$2" - - echo "Configuring wayland session... " - - [ -f "$src_path" ] || die "File $src_path does not exist!" - - try sudo rm -f "$dst_path" - try sudo cp "$src_path" "$dst_path" - try sudo chmod 644 "$dst_path" -} - -install_apt_packages() { - local p - - # Core utitilies - p="apt-utils" - p+=" ca-certificates" - p+=" curl" - p+=" wget" - p+=" gnupg" - p+=" jq" - p+=" software-properties-common" - p+=" apt-file" - p+=" libfuse2" # This is required to use AppImage - p+=" locate" - p+=" fzf" - p+=" net-tools" - p+=" unzip" - p+=" uchardet" # Useful for detecting text file encoding - - # Basic command line utitilies - p+=" make" - p+=" build-essential" - p+=" cmake" - p+=" meson" - p+=" htop" - p+=" iotop" - p+=" git" - p+=" vim" - #p+=" exuberant-ctags" - p+=" universal-ctags" # I think this has better c++11 support - p+=" ranger" - p+=" tmux" - p+=" neofetch" - p+=" id3v2" - p+=" calcurse" - p+=" rxvt-unicode" - p+=" clang" - p+=" clangd" - - # Python - p+=" python3 python3-dev python3-pip" # Python 3.x - - # General GUI Applications - p+=" fonts-font-awesome" # Used for media buttons on polybar - p+=" rofi" # Fuzzy application launcher - p+=" dunst" # Desktop notifications - p+=" feh" # Set wallpaper - p+=" sxiv" # Image viewer - p+=" nitrogen" # Set wallpaper - p+=" pavucontrol" # Pulse Audio frontend - p+=" compton" # Window compositor - p+=" scrot" # Screen capture - p+=" gucharmap" # Useful for debugging font issues - p+=" keepassxc" # Credential manager - p+=" remmina" # RDP session manager - p+=" usb-creator-gtk" # Easily flash bootable USBs - p+=" i3lock" # Lock screen - p+=" meld" # Diff tool - p+=" xclip" # Clipboard for X11 - p+=" wl-clipboard" # Clipboard for Wayland - p+=" xdotool" # X11 automation tool - p+=" kitty" # Kitty terminal emulator - p+=" kitty-terminfo" # Kitty TERMINFO - p+=" webp" # Command line support for webp image files - - # Media - p+=" inkscape" # Vector graphics editor - p+=" mpv" # Minimal media player - p+=" vlc" # General purpose FOSS media player - p+=" easytag" # Edit ID3 Tags on MP3 files - p+=" blueman" # Bluetooth device support - - # Gaming - p+=" steam" - p+=" steam-devices" - - apt_install "$p" -} - -install_neovim() { - print_header "Installing neovim" - - local nvim_path="/usr/local/bin/nvim" - if [ -f "$nvim_path" ]; then - echo "$nvim_path already exists, skipping installation..." - return - fi - - try sudo mkdir -p /opt/neovim - curl -LO https://github.com/neovim/neovim/releases/latest/download/nvim.appimage - try sudo chmod u+x nvim.appimage - try sudo mv nvim.appimage /opt/neovim/nvim - try sudo ln -s /opt/neovim/nvim $nvim_path - - echo "Installing pynvim python modules..." - try sudo pip3 install --upgrade pynvim - - echo "Updating alternatives to use nvim..." - try sudo update-alternatives --install /usr/bin/vi vi "$nvim_path" 60 - try sudo update-alternatives --set vi "$nvim_path" - try sudo update-alternatives --install /usr/bin/vim vim "$nvim_path" 60 - try sudo update-alternatives --set vim "$nvim_path" - try sudo update-alternatives --install /usr/bin/editor editor "$nvim_path" 60 - try sudo update-alternatives --set editor "$nvim_path" -} - -install_cava() { - local cache_dir="$1" - - local version="$(get_latest_github_release "karlstav" "cava")" - - print_header "Installing cava ($version)" - - local cava_dir="$cache_dir/cava/$version" - local cava_exe="$cava_dir/cava" - if [ -f "$cava_exe" ]; then - echo "$cava_exe already exists, skipping installation..." - return - fi - - echo "Installing pre-requisites..." - apt_install "libfftw3-dev libasound2-dev libncursesw5-dev libpulse-dev libtool" - - echo "Cloning the cava repository" - try mkdir -p "$(dirname -- "$cava_dir")" - try git clone "https://github.com/karlstav/cava" "$cava_dir" - - local pwd; pwd="$(pwd)" - try cd "$cava_dir" - - echo "Building cava $version..." - try ./autogen.sh - try ./configure - try make - - echo "Installing cava $version..." - try sudo make install - - try cd "$pwd" -} - -install_i3gaps() { - local cache_dir="$1" - - local version="$(get_latest_github_release "airblader" "i3")" - - print_header "Installing i3-gaps ($version)" - - local i3gaps_dir="$cache_dir/i3gaps/$version" - local i3gaps_exe="$i3gaps_dir/build/i3" - if [ -f "$i3gaps_exe" ]; then - echo "$i3gaps_exe already exists, skipping installation..." - return - fi - - echo "Installing pre-requisites..." - apt_install "libxcb1-dev libxcb-keysyms1-dev libpango1.0-dev libxcb-util0-dev libxcb-icccm4-dev libyajl-dev libstartup-notification0-dev libxcb-randr0-dev libev-dev libxcb-cursor-dev libxcb-xinerama0-dev libxcb-xkb-dev libxkbcommon-dev libxkbcommon-x11-dev autoconf libxcb-xrm0 libxcb-xrm-dev libxcb-shape0 libxcb-shape0-dev automake" - - echo "Cloning the i3-gaps repository" - try mkdir -p "$(dirname -- "$i3gaps_dir")" - try git clone "https://www.github.com/Airblader/i3" "$i3gaps_dir" - - local pwd; pwd="$(pwd)" - try cd "$i3gaps_dir" - - echo "Checkout out version $version..." - try git checkout "$version" - - echo "Building i3-gaps $version..." - try mkdir -p build - try cd build - try meson .. - try ninja - - echo "Installing i3-gaps $version..." - try sudo meson install - - apt_install "i3status" - pip_install "py3status" - - try cd "$pwd" -} - -# TODO: youtube-dl was discontinued. Update this to install yt-dlp instead: -# https://www.linuxadictos.com/en/yt-dlp-fork-sucesor-del-descontinuado-youtube-dl-que-permite-descargar-videos-de-decenas-de-plataformas.html -install_youtube-dl() { - local cache_dir="$1" - local bin_dir="$2" - - local version="$(get_latest_github_release "ytdl-org" "youtube-dl")" - - print_header "Installing youtube-dl ($version)" - - local ytdl_dir="$cache_dir/youtube-dl/$version" - local ytdl_exe="$ytdl_dir/youtube-dl" - if [ -f "$ytdl_exe" ]; then - echo "$ytdl_exe already exists, skipping installation..." - return - fi - - echo "Downloading youtube-dl $version..." - mkdir -p "$ytdl_dir" - try curl -L "https://yt-dl.org/downloads/$version/youtube-dl" -o "$ytdl_exe" - - echo "Installing youtube-dl $version..." - try chmod a+rx "$ytdl_exe" - try rm -f "$bin_dir/youtube-dl" - try ln -s "$ytdl_exe" "$bin_dir/youtube-dl" -} - -install_kitty() { - local tmp_dir version tar_file cwd kitty_path - - cwd="$(pwd)" - - version="$(get_latest_github_release "kovidgoyal" "kitty")" - - if [ -z "$version" ]; then - die "Failed to determine latest kitty release" - fi - - tmp_dir="$HOME/Downloads/kitty/$version" - try mkdir -p "$tmp_dir" - try cd "$tmp_dir" - - # Make sure to strip the 'v' from the version out of the file name - tar_file="kitty-${version/v/}-x86_64.txz" - try wget "https://github.com/kovidgoyal/kitty/releases/download/$version/$tar_file" - - try tar -xJf "$tar_file" - try rm "$tar_file" - - try sudo mkdir -p "/opt/kitty" - try sudo rm -rf "/opt/kitty/$version" - try sudo mv "$tmp_dir" "/opt/kitty/$version" - - try sudo rm "/usr/local/bin/kitty" - try sudo ln -s "/opt/kitty/$version/bin/kitty" "/usr/local/bin/kitty" - try sudo rm "/usr/local/bin/kitten" - try sudo ln -s "/opt/kitty/$version/bin/kitten" "/usr/local/bin/kitten" - - kitty_path="$(command -v kitty)" - - echo "Setting the default terminal emulator to $default_terminal" - try sudo update-alternatives --install /usr/bin/x-terminal-emulator x-terminal-emulator "$kitty_path" 50 - try sudo update-alternatives --set x-terminal-emulator "$kitty_path" - - try cd "$cwd" -} - -install_wpr() { - local cache_dir="$1" - local bin_dir="$2" - - local version="0.1.0" - - print_header "Installing wpr..." - - local wpr_dir="$cache_dir/wpr/$version" - local wpr_exe="$wpr_dir/wpr" - if [ -f "$wpr_exe" ]; then - echo "$wpr_exe already exists, skipping installation..." - return - fi - - echo "Downloading wpr $version..." - mkdir -p "$wpr_dir" - local tarball_name="wpr.$version.linux-amd64.tar.gz" - local s3_url="https://s3-us-west-2.amazonaws.com" - local url="$s3_url/pcewing-wpr/releases/$version/$tarball_name" - try curl -L "$url" -o "$wpr_dir/$tarball_name" - - echo "Installing wpr $version..." - try tar --directory "$wpr_dir" -xvf "$wpr_dir/$tarball_name" - try chmod a+rx "$wpr_exe" - try rm -f "$bin_dir/wpr" - try ln -s "$wpr_exe" "$bin_dir/wpr" -} - -install_mpd() { - print_header "Installing mpd" - - if [ ! -z "$(command -v mpd)" ]; then - echo "mpd is already installed, skipping installation..." - return - fi - - apt_install "mpd" - - echo "Disabling the system mpd service..." - try sudo systemctl stop --now mpd.service - try sudo systemctl stop --now mpd.socket - try sudo systemctl disable mpd.service - try sudo systemctl disable mpd.socket - try sudo systemctl mask mpd.service - try sudo systemctl mask mpd.socket - - echo "Disabling the user mpd service..." - try systemctl stop --now --user mpd.service - try systemctl stop --now --user mpd.socket - try systemctl disable --user mpd.service - try systemctl disable --user mpd.socket - try systemctl mask --user mpd.service - try systemctl mask --user mpd.socket - - echo "Configuring mpd..." - mkdir -p "$HOME/.mpd" - mkdir -p "$HOME/.mpd/playlists" - mkdir -p "$HOME/.local/share/mpd" - - # TODO: This doesn't seem to be working? Or maybe it's because I updated - # Ubuntu and my pip packages disappeared? But this was missing and breaking - # my i3 status bar. - pip_install "python-mpd2" -} - -install_ncmpcpp() { - print_header "Installing ncmpcpp" - - if [ ! -z "$(command -v ncmpcpp)" ]; then - echo "ncmpcpp is already installed, skipping installation..." - return - fi - - apt_install "ncmpcpp" - - echo "Configuring ncmpcpp..." - mkdir -p "$HOME/.config/ncmpcpp" -} - -install_flavours() { - local tmp_dir version tar_file cwd - - cwd="$(pwd)" - - version="$(get_latest_github_release "Misterio77" "flavours")" - - if [ -z "$version" ]; then - die "Failed to determine latest flavours release" - fi - - tmp_dir="$HOME/Downloads/flavours/$version" - try mkdir -p "$tmp_dir" - try cd "$tmp_dir" - - tar_file="flavours-${version}-x86_64-linux.tar.gz" - try wget "https://github.com/Misterio77/flavours/releases/download/$version/$tar_file" - - try tar -xzf "$tar_file" - try rm "$tar_file" - - try sudo mkdir -p "/opt/flavours" - try sudo rm -rf "/opt/flavours/$version" - try sudo mv "$tmp_dir" "/opt/flavours/$version" - try sudo rm -f "/usr/local/bin/flavours" - try sudo ln -s "/opt/flavours/$version/flavours" "/usr/local/bin/flavours" - - flavours update all &>/dev/null - - try cd "$cwd" -} - -######## -# Main # -######## - -[[ -z "$DOTFILES" ]] && DOTFILES="$( realpath "$SCRIPT_DIR/.." )" - -source "$DOTFILES/config/bash/functions.sh" - -# For applications that are built from source, we will put them here -cache_dir="$HOME/.dotcache" && mkdir -p "$cache_dir" -bin_dir="$HOME/bin" && mkdir -p "$bin_dir" - -# Make sure apt is ready to use -apt_update -apt_dist_upgrade - -# Install everything via apt that is available in the default repositories -install_apt_packages - -# Set up a simple xsession desktop file that display managers will recognize. -# This will execute /etc/X11/Xsession which in turn executes the .xsession in -# the user's home directory. -configure_xsession \ - "$DOTFILES/config/xsession.desktop" \ - "/usr/share/xsessions/xsession.desktop" - -# Set up a wayland session for sway that the display manager will recognize -configure_wayland_session \ - "$DOTFILES/config/sway-user.desktop" \ - "/usr/share/wayland-sessions/sway-user.desktop" - -# Install everything else that needs special attention -install_kitty -install_neovim -install_i3gaps "$cache_dir" -#install_cava "$cache_dir" -install_youtube-dl "$cache_dir" "$bin_dir" -install_wpr "$cache_dir" "$bin_dir" -install_mpd -install_ncmpcpp -install_flavours - -# TODO: Install picom from source diff --git a/templates/set-bg.sh b/templates/set-bg.sh new file mode 100644 index 0000000..e3e54e9 --- /dev/null +++ b/templates/set-bg.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +# Set background wallpaper to the default +if command -v feh >/dev/null 2>&1; then + if [ -f "$HOME/Pictures/default_wallpaper.png" ]; then + feh --bg-scale "$HOME/Pictures/default_wallpaper.png" + fi +fi diff --git a/todo.md b/todo.md deleted file mode 100644 index a8538e5..0000000 --- a/todo.md +++ /dev/null @@ -1,398 +0,0 @@ -# To-Do List - -Improvements I'd like to make to my dotfiles. - -## Table of Contents - -- [Misc](#misc) -- [Python CLI](#python-cli) - - [Bootstrapper](#bootstrapper) - - [Provisioner Groups or Tags](#provisioner-groups-or-tags) - - [Provisioner Command Logging](#provisioner-command-logging) - - [Check for Updates Feature](#check-for-updates-feature) - - [Code Cleanup](#code-cleanup) - - [Necessary Pip Packages](#necessary-pip-packages) -- [FZF Bash Integration](#fzf-bash-integration) -- [Windows support in Python CLI](#windows-support-in-python-cli) -- [wezterm shell integration](#wezterm-shell-integration) -- [I3WM "Virtual Desktops"](#i3wm-"virtual-desktops") -- [Python Tidy/Lint](#python-tidy/lint) -- [Don't symlink vim to neovim](#don't-symlink-vim-to-neovim) -- [Neovim Healtheck](#neovim-healtheck) -- [Errors](#errors) - - [nvim-lua/completion-nvim](#nvim-lua/completion-nvim) - - [nvim-telescope/telescope.nvim](#nvim-telescope/telescope.nvim) -- [Warnings](#warnings) - - [glepnir/lspsaga.nvim](#glepnir/lspsaga.nvim) - - [nvim-treesitter/nvim-treesitter](#nvim-treesitter/nvim-treesitter) - - [Providers](#providers) - - [nvim-telescope/telescope.nvim](#nvim-telescope/telescope.nvim) - - [neovim/nvim-lspconfig](#neovim/nvim-lspconfig) - -## Misc - -- [ ] Clean up Neovim Healthcheck (**Neovim Healtheck** section) - -## Python CLI - -### Git Sync Command - -Implement a simple `dot git-sync` command and a git alias to it like `git sync` -that does something like: - -``` -- Check if there are commits missing from upstream -- If there are, pull -- If there are merge conflicts, abort and print an error - - These should be handled manually -- If the merge is clean, continue on -- Add all local changes -- Commit local changes -- Push -``` - -This is for repositories like my notes where I basically just always want to -keep everything in sync and don't use branches. Optionally, accept a parameter -for commit message. - -### Implement More Provisioners - -Add a "proprietary" tag for: - -- Insync -- Beyond Compare 4 / 5 -- Parsec - -### Logging Noise - -We should change most of the logs to debug to reduce noise in the output. - -### Bootstrapper - -- Write a shell script to bootstrap so that `dot` can be run. - - [ ] Maybe put bootstrapper in a Gist so it's easier to grab on a new - system and have it set up git ssh keys, clone the dotfiles repo, etc? - -- Things it needs: - - Install Python and pip packages - - `python -m pip install --user --upgrade argcomplete` - -### Provisioner Groups or Tags - -It would be nice to have "groups" of provisioners so it's easy to -include/exclude a set of features. For example, a group for all of the X11 -applications so that they can be excluded when installing on a system without a -DE/WM. Or a group for WSL. - -Maybe it would be better to just have tags? So for example I could say: - -``` -dot provision --tags=wsl ... -``` - -And then the provisioners could just respect those tags. That's probably easier -actually. - -We could also default those tags intelligently by detecting whether or not -we're running in WSL, X11, etc. - -Maybe we could even just use tags for distro? Like rather than having separate -library folders for `jammy`, etc. In many cases the distro isn't going to -matter, especially when the only difference is version. If we ever want to -write a provisioner for an entirely different distro like Manjaro, we can -tackle that then but realistically, YAGNI. - -Instead of just tags, maybe we expand slightly to "attributes" which are -effectively key-value pairs instead of just values. So we could have attributes -like: - -``` -distro_family = "ubuntu", "centos" -distro_version = "20.04", 7.9 -wsl = true, false -window_manager = "i3", None -desktop_environment = "i3", None -graphical_environment = true, false -``` - -Then when running, these are all auto-detected but can be overriden via command -line options like so: - -``` -dot provision --attr "wsl=true" -``` - -I'll need to make sure `argparse` supports specifying options multiple times -but if not, we can just make the option `--attributes` and expect the value to -be a comma-delimited list. - -There are some attributes that we won't be able to default. Like say we want -one that dictates whether or not to install gaming software like `steam`. That -would just have to default to `False` and then if we want that installed we -could either do it manually afterwards by specifying the provisioner: - -``` -dot provision steam -``` - -Or add that attribute when provisioning everything: - -``` -dot provision --all --attr "gaming=true" -``` - -I think this is the approach I like the best so far. - -### Provisioner Command Logging - -When provisioners run external commands like `apt update` that generate a lot -of output, it makes the CLI output confusing. Maybe we can dump command output -to a log file so it's hidden during execution and then link to it in the case -of an exception/error? - -### Check for Updates Feature - -Dry-run is nice for seeing what will happen when I run the script but it would -be even better to have a "Check for updates" command. So I could see if there's -a new version of Neovim for example. - -Maybe like a `status` command? So it could be used like: - -``` -dot provision --status neovim -``` - -And would output something like: - -``` -Status: -- Neovim: Up-to-Date (0.9.5) <-- In green -``` - -Or: - -``` -Status: -- Neovim: Update available (0.9.4 -> 0.9.5) <-- In yellow -``` - -And could be run for multiple (Or `--all` provisioners): - -``` -$ dot provision --status --all - -Status: -- Neovim: Up-to-Date (0.9.5) <-- In green -- Flavours: Update available (v0.7.1 -> v0.7.2) <-- In yellow -``` - -### Code Cleanup - -- Remove the functions like `mkdir_p` in `util.py` and use the alternatives in - `shell.py` -- Use Python native facilities instead of the functions in `shell.py` - - These were used so that we could use `sudo` but now the tool just - elevates itself to root - -### Necessary Pip Packages - -python3 -m pip install typing_extensions - -Also needs to be installed as root if the script elevates -sudo python3 -m pip install typing_extensions - -## FZF Bash Integration - -`~/.fzf.bash` doesn't exist for me, maybe because I'm installing via apt. I'd -like that so I can get fzf `ctrl+r` functionality so update the provision -script to set that up correctly. - -## Windows support in Python CLI - -- Don't need to implement full provisioning but at least get clean/link commands to work on Windows -- Might be nice to have a script to provision WezTerm on Windows - -## wezterm shell integration - -Automatically download wezterm.sh and source it in ~/.localrc or at least document this for WSL setup - -## I3WM "Virtual Desktops" - -10 workspaces isn't always enough. It would be nice to do something that -provides a similar workflow to virtual desktops on Windows. Like, 4 virtual -desktops that each have 10 workspaces. Maybe as an MVP, have a keyboard -shortcut that switches between the desktops and remaps keybindings accordingly. - -I've started noodling on a hacky PoC for this in `bin/i3-util.sh` - -## Python Tidy/Lint - -- [ ] Look into `ruff` since it may replace several other dependencies and also - claims to be much faster -- [ ] Set up a pre-commit hook to ensure files are always linted? - - [ ] Probably can't do this without significant work to fix all static - typing - -## Don't symlink vim to neovim - -Now that we've split our configs let's not link `vi` and `vim` to Neovim. - -## Visual Studio Key Bindings - -Long term: Figure out a way to version control these properly - -Short term: Document how to set things up the way I like them - -- Install `VsVim` extension -- Reset keyboard shortcuts to default - - Open the `Tools -> Options...` menu - - Navigate to `Environment -> Keyboard` in the left-hand side - - At the top, select `(Default)` from the first drop-down and click the - `Reset` button -- Select `VsVim` keyboard shortcut overrides - - Open the `Tools -> Options...` menu - - Navigate to `VsVim -> Keyboard` in the left-hand side - - For the following keyboard shortcuts, select `VsVim` in the `Handled by` - drop-down - - `Ctrl+]` (Go to definition) - - `Ctrl+D` Scroll down half a page - - `Ctrl+I` (Traverse down jumpstack) - - `Ctrl+O` (Traverse up jumpstack) - - `Ctrl+R` (Redo) - - `Ctrl+U` Scroll up half a page - - `Ctrl+V` Visual selection mode - -## Neovim Healtheck - -- In nvim, run `:healthcheck` and go through the errors/warnings: - -### Errors - -#### nvim-lua/completion-nvim - -``` -completion: require("completion.health").check() - -- ERROR Failed to run healthcheck for "completion" plugin. Exception: - function health#check, line 25 - Vim(eval):E5108: Error executing lua [string "luaeval()"]:1: attempt to call field 'check' (a nil value) - stack traceback: - [string "luaeval()"]:1: in main chunk - -============================================================================== -completion_nvim: health#completion_nvim#check - -general ~ -- OK neovim version is supported - -completion source ~ -- OK all completion sources are valid - -snippet source ~ -- ERROR Your snippet source is not available! Possible values are: UltiSnips, Neosnippet, vim-vsnip, snippets.nvim -``` - -#### nvim-telescope/telescope.nvim - -``` -============================================================================== -telescope: require("telescope.health").check() - -Checking for required plugins ~ -- OK plenary installed. -- OK nvim-treesitter installed. - -Checking external dependencies ~ -- ERROR rg: not found. `live-grep` finder will not function without [BurntSushi/ripgrep](https://github.com/BurntSushi/ripgrep) installed. -- WARNING fd: not found. Install [sharkdp/fd](https://github.com/sharkdp/fd) for extended capabilities - -===== Installed extensions ===== ~ -``` - -### Warnings - -#### glepnir/lspsaga.nvim - -``` -============================================================================== -lspsaga: require("lspsaga.health").check() - -Lspsaga.nvim report ~ -- WARNING `tree-sitter` executable not found -- OK tree-sitter `markdown` parser found -- OK tree-sitter `markdown_inline` parser found -``` - -#### nvim-treesitter/nvim-treesitter - -``` -============================================================================== -nvim-treesitter: require("nvim-treesitter.health").check() - -Installation ~ -- WARNING `tree-sitter` executable not found (parser generator, only needed for :TSInstallFromGrammar, not required for :TSInstall) -- WARNING `node` executable not found (only needed for :TSInstallFromGrammar, not required for :TSInstall) -- OK `git` executable found. -- OK `cc` executable found. Selected from { vim.NIL, "cc", "gcc", "clang", "cl", "zig" } - Version: cc (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0 -- OK Neovim was compiled with tree-sitter runtime ABI version 14 (required >=13). Parsers must be compatible with runtime ABI. -``` - -#### Providers - -Is there a way we can say we intentionally don't want these providers to get -the warnings to go away? - -``` -============================================================================== -provider: health#provider#check - -Ruby provider (optional) ~ -- WARNING `ruby` and `gem` must be in $PATH. - - ADVICE: - - Install Ruby and verify that `ruby` and `gem` commands work. - -Node.js provider (optional) ~ -- WARNING `node` and `npm` (or `yarn`, `pnpm`) must be in $PATH. - - ADVICE: - - Install Node.js and verify that `node` and `npm` (or `yarn`, `pnpm`) commands work. - -Perl provider (optional) ~ -- WARNING "Neovim::Ext" cpan module is not installed - - ADVICE: - - See :help |provider-perl| for more information. - - You may disable this provider (and warning) by adding `let g:loaded_perl_provider = 0` to your init.vim -``` - -#### nvim-telescope/telescope.nvim - -``` -============================================================================== -telescope: require("telescope.health").check() - -Checking for required plugins ~ -- OK plenary installed. -- OK nvim-treesitter installed. - -Checking external dependencies ~ -- ERROR rg: not found. `live-grep` finder will not function without [BurntSushi/ripgrep](https://github.com/BurntSushi/ripgrep) installed. -- WARNING fd: not found. Install [sharkdp/fd](https://github.com/sharkdp/fd) for extended capabilities - -===== Installed extensions ===== ~ -``` - -#### neovim/nvim-lspconfig - -``` -============================================================================== -vim.lsp: require("vim.lsp.health").check() - -- LSP log level : TRACE -- WARNING Log level TRACE will cause degraded performance and high disk usage -- Log path: /home/pewing/.local/state/nvim/lsp.log -- Log size: 333 KB - -vim.lsp: Active Clients ~ -- No active clients -``` -