diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml new file mode 100644 index 0000000..ca4890e --- /dev/null +++ b/.github/workflows/shellcheck.yml @@ -0,0 +1,46 @@ +name: Shell Lint + +on: + push: + paths: + - '**.sh' + - '.github/workflows/shellcheck.yml' + pull_request: + paths: + - '**.sh' + - '.github/workflows/shellcheck.yml' + +jobs: + shellcheck: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install ShellCheck + run: sudo apt-get update -y && sudo apt-get install -y shellcheck + + - name: Run ShellCheck + run: | + set -euo pipefail + echo "Scanning shell scripts..." + # Find all tracked *.sh files plus executable scripts with bash shebang + SCRIPTS=$(git ls-files '*.sh') + # Optionally include non-.sh executables with bash shebang + while IFS= read -r f; do + if head -1 "$f" | grep -qE '^#!.*bash'; then + SCRIPTS+=$'\n'$f + fi + done < <(git ls-files | grep -v '\.sh$') + + # De-duplicate list + echo "$SCRIPTS" | sort -u > /tmp/scripts.list + echo "Files to check:"; cat /tmp/scripts.list + # Run shellcheck (excluding vendor or 3rd-party dirs if any in future) + xargs -a /tmp/scripts.list -r shellcheck --severity=style + + - name: Upload SARIF (optional code scanning) + if: false + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif diff --git a/README.md b/README.md new file mode 100644 index 0000000..f09b51a --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# Dotfiles + +[![Shell Lint](https://github.com/JoannaaKL/dotfiles/actions/workflows/shellcheck.yml/badge.svg)](https://github.com/JoannaaKL/dotfiles/actions/workflows/shellcheck.yml) + +Opinionated workstation + Codespaces bootstrap. + +## Features + +- Idempotent `install.sh` with: + - Environment detection (local vs Codespaces) + - Neovim install + minimal config bootstrap + - Spaceship ZSH theme setup + - Meslo Nerd Font install (skips if present) + - Safe dotfile symlinking (backs up existing files) + - Optional GitHub Copilot CLI extension + - Delta (syntax highlighted pager) install +- Managed Codespaces SSH config export (`export_codespace_cfg`) +- Codespace cleanup utility (`clean-codespaces.sh --dry-run`) +- Shell linting via GitHub Actions + local `make lint`. + +## Quick Start + +Clone then run: + +```bash +./install.sh +``` + +Start a new shell (or `source ~/.zshrc`) to pick up changes. + +## Linting Shell Scripts + +Run locally: + +```bash +make lint +``` + +The CI workflow runs automatically on pushes and pull requests touching any `*.sh` files. + +## Codespaces SSH Config Export + +```bash +./export_codespace_cfg +``` + +This inserts/updates a managed block in `~/.ssh/config` between markers: + +```text +# >>> codespaces (managed) >>> +# <<< codespaces (managed) <<< +``` + +## Cleaning Shutdown Codespaces + +Preview: + +```bash +./clean-codespaces.sh --dry-run +``` + +Execute deletions: + +```bash +./clean-codespaces.sh +``` + +## Future Ideas + +- Brewfile / apt manifest abstraction +- Tmux plugin manager integration +- Advanced Neovim config module diff --git a/clean-codespaces.sh b/clean-codespaces.sh index ebbfced..97ff321 100755 --- a/clean-codespaces.sh +++ b/clean-codespaces.sh @@ -1,9 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' -clean(){ - items=$(gh codespace list --json name,state | jq -c -r '.[] | select(.state | contains("Shutdown")) | .name') - for i in $items; do - echo "Deleting codespace $i" - result=$(gh codespace delete -c $i -f) - done -} -clean +command -v jq >/dev/null 2>&1 || { echo "jq is required" >&2; exit 1; } +command -v gh >/dev/null 2>&1 || { echo "GitHub CLI (gh) is required" >&2; exit 1; } + +dry_run=false +if [[ "${1:-}" == "--dry-run" ]]; then + dry_run=true +fi + +names=$(gh codespace list --json name,state | jq -r '.[] | select(.state=="Shutdown") | .name') +if [[ -z "$names" ]]; then + echo "No shutdown codespaces to delete" + exit 0 +fi + +echo "Found shutdown codespaces:" >&2 +# shellcheck disable=SC2001 +echo "${names}" | sed 's/^/ - /' + +for name in $names; do + if $dry_run; then + echo "[dry-run] Would delete: $name" + else + echo "Deleting codespace: $name" + if ! gh codespace delete -c "$name" -f; then + echo "Failed to delete $name" >&2 + fi + fi +done diff --git a/export_codespace_cfg b/export_codespace_cfg index 4486857..18a3585 100755 --- a/export_codespace_cfg +++ b/export_codespace_cfg @@ -1,8 +1,33 @@ -echo "Remove old codespace_config" - rm -rf codespace_config -echo "Remove old entries from .ssh/config" - less ~/.ssh/config_backup > ~/.ssh/config -echo "Save ssh config for available codespaces" - gh codespace ssh --config | sed -e 's/ -- -i \/Users\/joannaakl\/.ssh\/codespaces.auto//g; s/IdentityFile \/Users\/joannaakl\/.ssh\/codespaces.auto//g' > codespace_config -echo "Save cleaned codespaces ssh config in .ssh/config" - cat codespace_config >> ~/.ssh/config +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +command -v gh >/dev/null 2>&1 || { echo "gh CLI required" >&2; exit 1; } + +SSH_DIR="$HOME/.ssh" +CONFIG_FILE="$SSH_DIR/config" +BACKUP="$CONFIG_FILE.$(date +%Y%m%d%H%M%S).bak" +MANAGED_START="# >>> codespaces (managed) >>>" +MANAGED_END="# <<< codespaces (managed) <<<" + +mkdir -p "$SSH_DIR" +[[ -f "$CONFIG_FILE" ]] && cp "$CONFIG_FILE" "$BACKUP" + +echo "Generating Codespaces SSH config block..." >&2 +TMP=$(mktemp) +gh codespace ssh --config | sed -E 's@ -- -i [^ ]*codespaces\.auto@@g; s@IdentityFile [^ ]*codespaces\.auto@@g' > "$TMP" + +# Remove previous managed block if present +if grep -q "$MANAGED_START" "$CONFIG_FILE" 2>/dev/null; then + awk -v start="$MANAGED_START" -v end="$MANAGED_END" 'BEGIN{skip=0} {if($0==start){skip=1} else if($0==end){skip=0; next} if(!skip) print}' "$CONFIG_FILE" > "$CONFIG_FILE.tmp" + mv "$CONFIG_FILE.tmp" "$CONFIG_FILE" +fi + +{ + echo "$MANAGED_START" + cat "$TMP" + echo "$MANAGED_END" +} >> "$CONFIG_FILE" + +rm -f "$TMP" +echo "Updated $CONFIG_FILE (backup: $BACKUP)" >&2 diff --git a/helpers.sh b/helpers.sh index d7258b6..c08f295 100644 --- a/helpers.sh +++ b/helpers.sh @@ -1,54 +1,65 @@ -#!/bin/bash +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' # Helper functions for dotfiles installation - -# Constants ALREADY_INSTALLED_MSG="is already installed" -codespaces_setup(){ - echo "***Codespaces setup***" - merge_zsh_config - install "tmux" "apt install tmux" - install "bat" "apt install bat" - install "rg" "apt-get install ripgrep" - codespaces_install_shellcheck - echo "***Codespaces setup done***" -} +log_helpers(){ printf "[helpers] %s\n" "$*"; } -local_setup(){ - echo "***Local setup***" - install "tmux" "brew install tmux" - install "bat" "brew install bat" - install "rg" "brew install ripgrep" - local_install_shellcheck - echo "***Local setup done***" +# Generic install wrapper: install_if_missing +install_if_missing(){ + local bin="$1"; shift + if command -v "$bin" >/dev/null 2>&1; then + log_helpers "$bin $ALREADY_INSTALLED_MSG" + return 0 + fi + log_helpers "Installing $bin" + "$@" } -codespaces_install_shellcheck() { - scversion="latest" - wget -qO- "https://github.com/koalaman/shellcheck/releases/download/${scversion?}/shellcheck-${scversion?}.linux.x86_64.tar.xz" | tar -xJv - cp "shellcheck-${scversion}/shellcheck" /usr/bin/ +codespaces_install_shellcheck(){ + local scversion="v0.10.0" # pinned for reproducibility + if command -v shellcheck >/dev/null 2>&1; then + log_helpers "shellcheck $ALREADY_INSTALLED_MSG"; return 0; fi + local url="https://github.com/koalaman/shellcheck/releases/download/${scversion}/shellcheck-${scversion}.linux.x86_64.tar.xz" + local tmpdir + tmpdir="$(mktemp -d)" + curl -fsSL "$url" | tar -xJ -C "$tmpdir" + sudo install -m 0755 "$tmpdir/shellcheck-${scversion}/shellcheck" /usr/local/bin/shellcheck shellcheck --version } -local_install_shellcheck() { - brew install shellcheck -} - -install(){ - pkg_name=$1 - cmd_if_not_installed=$2 - if command -v "$pkg_name" &> /dev/null - then - echo "$pkg_name $ALREADY_INSTALLED_MSG" +local_install_shellcheck(){ + if command -v shellcheck >/dev/null 2>&1; then + log_helpers "shellcheck $ALREADY_INSTALLED_MSG"; return 0; fi + if command -v brew >/dev/null 2>&1; then + brew install shellcheck else - echo "$pkg_name is not installed, installing..." - eval "$cmd_if_not_installed" + log_helpers "brew not found; skipping shellcheck" fi } +codespaces_setup(){ + log_helpers "*** Codespaces setup ***" + install_if_missing tmux bash -c 'sudo apt-get update -y && sudo apt-get install -y tmux' + install_if_missing bat sudo apt-get install -y bat + install_if_missing rg sudo apt-get install -y ripgrep + codespaces_install_shellcheck + log_helpers "*** Codespaces setup done ***" +} + +local_setup(){ + log_helpers "*** Local setup ***" + install_if_missing tmux brew install tmux + install_if_missing bat brew install bat + install_if_missing rg brew install ripgrep + local_install_shellcheck + log_helpers "*** Local setup done ***" +} + setup(){ - if [ -n "$CODESPACES" ]; then + if [[ -n "${CODESPACES:-}" ]]; then codespaces_setup else local_setup diff --git a/install.sh b/install.sh index 2c5ce42..d47247e 100755 --- a/install.sh +++ b/install.sh @@ -1,128 +1,160 @@ -#!/bin/bash -script_dir=$(dirname "$(readlink -f "$0")") -delimiter="***********" +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' -# Source helper functions -source "$script_dir/helpers.sh" -create_symlinks() { - # Get the directory in which this script lives. - echo "$delimiter Creating symlinks $delimiter" - echo "$script_dir" - # Get a list of all files in this directory that start with a dot. - files=$(find . -maxdepth 1 -type f -name ".*") +# Portable script directory resolution (macOS + Linux) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export SCRIPT_DIR - # Create a symbolic link to each file in the home directory. - for file in $files; do - name="$(basename "$file")" - if [ -n "${CODESPACES}" ]; then - echo "Removing existing $name" - rm -rf ~/"$name" +# Source helper functions (defines ALREADY_INSTALLED_MSG & setup) +source "$SCRIPT_DIR/helpers.sh" + +# ------------- Logging helpers ------------- +log(){ printf "\033[1;34m[dotfiles]\033[0m %s\n" "$*"; } +warn(){ printf "\033[1;33m[warn]\033[0m %s\n" "$*"; } +err(){ printf "\033[1;31m[err]\033[0m %s\n" "$*" >&2; } +section(){ printf "\n\033[1;32m== %s ==\033[0m\n" "$*"; } +trap 'err "Failed near line $LINENO"' ERR + +# ------------- Config ------------- +DOTFILES=(.gitconfig .gitignore .gitmessage .zshrc .tmux.conf .spaceship.zsh) + +symlink_dotfiles(){ + section "Symlinking dotfiles" + for name in "${DOTFILES[@]}"; do + local source_path="$SCRIPT_DIR/$name" + local target_path="$HOME/$name" + [[ -e "$source_path" ]] || { warn "Skipping missing $source_path"; continue; } + if [[ -L "$target_path" && "$(readlink "$target_path")" == "$source_path" ]]; then + log "$name already linked"; continue; fi + if [[ -e "$target_path" && ! -L "$target_path" ]]; then + local backup + backup="${target_path}.bak.$(date +%Y%m%d%H%M%S)" + mv "$target_path" "$backup" + log "Backed up $target_path -> $backup" fi - echo "Creating symlink to $name in home directory." - ln -s "$script_dir/$name" ~/"$name" + ln -sfn "$source_path" "$target_path" + log "Linked $target_path -> $source_path" done - echo "$delimiter Creating symlinks done $delimiter" } -install_fonts() { - if [ ! -d "$HOME/fonts" ]; then - echo "$delimiter Installing fonts $delimiter" - FONT_DIR="$HOME/fonts" - git clone https://github.com/powerline/fonts.git "$FONT_DIR" --depth=1 - cd "$FONT_DIR" || exit - ./install.sh - cd .. - rm -rf "$FONT_DIR" - echo "$delimiter Installing fonts done $delimiter" - fi -} - -install_spaceship() { - echo "$delimiter Setting up the Spaceship theme $delimiter" - ZSH_CUSTOM="$HOME/.oh-my-zsh/custom" - - if [ -d "$ZSH_CUSTOM/themes/spaceship-prompt" ]; then - echo "Spaceship theme $ALREADY_INSTALLED_MSG" - echo "$delimiter Setting up the Spaceship theme done $delimiter" +install_fonts(){ + section "Installing fonts (Meslo Nerd Font)" + local dest="$HOME/.local/share/fonts" + mkdir -p "$dest" + # Detect existing Meslo Nerd Font via glob + ( + shopt -s nullglob nocaseglob 2>/dev/null || true + local meslo_candidates=("$dest"/*Meslo*"Nerd Font"*.ttf) + if (( ${#meslo_candidates[@]} > 0 )); then + log "Meslo Nerd Font already present" + exit 0 + fi + ) + if [[ $? -eq 0 ]]; then return 0 fi - - echo "Installing Spaceship theme..." - git clone https://github.com/spaceship-prompt/spaceship-prompt.git "$ZSH_CUSTOM/themes/spaceship-prompt" --depth=1 - ln -s "$ZSH_CUSTOM/themes/spaceship-prompt/spaceship.zsh-theme" "$ZSH_CUSTOM/themes/spaceship.zsh-theme" - echo "$delimiter Setting up the Spaceship theme done $delimiter" + local tmp_zip + tmp_zip="$(mktemp)" + if curl -fsSL -o "$tmp_zip" https://github.com/ryanoasis/nerd-fonts/releases/latest/download/Meslo.zip; then + unzip -oq "$tmp_zip" -d "$dest" + command -v fc-cache >/dev/null && fc-cache -f >/dev/null 2>&1 || true + log "Fonts installed" + else + warn "Font download failed; skipping" + fi } -install_nvim() { - echo "$delimiter Setting up nvim $delimiter" - - # Check if nvim is already installed - if command -v nvim &> /dev/null; then - echo "nvim $ALREADY_INSTALLED_MSG" - echo "$delimiter Setting up nvim done $delimiter" - return 0 - fi - - if [ -n "${CODESPACES}" ]; then - mkdir -p ~/bin - curl -LO https://github.com/neovim/neovim/releases/latest/download/nvim.appimage - chmod u+x nvim.appimage - ./nvim.appimage --appimage-extract +install_spaceship(){ + section "Setting up Spaceship theme" + local zsh_custom="$HOME/.oh-my-zsh/custom" + local theme_dir="$zsh_custom/themes/spaceship-prompt" + if [[ -d "$theme_dir" ]]; then + log "Spaceship theme $ALREADY_INSTALLED_MSG"; return 0; fi + git clone https://github.com/spaceship-prompt/spaceship-prompt.git "$theme_dir" --depth=1 + ln -sfn "$theme_dir/spaceship.zsh-theme" "$zsh_custom/themes/spaceship.zsh-theme" + log "Spaceship theme installed" +} - ln -s "$(pwd)/squashfs-root/usr/bin/nvim" ~/bin/nvim +install_neovim(){ + section "Setting up Neovim" + if command -v nvim >/dev/null 2>&1; then + log "nvim $ALREADY_INSTALLED_MSG"; return 0; fi + if [[ -n "${CODESPACES:-}" ]]; then + mkdir -p "$HOME/bin" + local nvim_cache_dir="$HOME/.local/share/neovim-appimage" + mkdir -p "$nvim_cache_dir" + local appimage_url="https://github.com/neovim/neovim/releases/latest/download/nvim.appimage" + local appimage_path="$nvim_cache_dir/nvim.appimage" + if [[ ! -f "$appimage_path" ]]; then + curl -fsSL -o "$appimage_path" "$appimage_url" + chmod +x "$appimage_path" + fi + if [[ ! -d "$nvim_cache_dir/squashfs-root" ]]; then + (cd "$nvim_cache_dir" && "$appimage_path" --appimage-extract >/dev/null) + fi + ln -sfn "$nvim_cache_dir/squashfs-root/usr/bin/nvim" "$HOME/bin/nvim" else + command -v brew >/dev/null 2>&1 || { err "Homebrew is required to install neovim"; return 1; } brew install neovim fi - echo "$delimiter Setting up nvim done $delimiter" + if [[ ! -f "$HOME/.config/nvim/init.lua" && ! -f "$HOME/.config/nvim/init.vim" ]]; then + mkdir -p "$HOME/.config/nvim" + cat > "$HOME/.config/nvim/init.lua" <<'EOF' +vim.opt.number = true +vim.opt.relativenumber = true +vim.opt.termguicolors = true +EOF + fi + log "Neovim installed" } -install_git_delta() { - if [ -z "${CODESPACES}" ]; then - brew install git-delta +install_git_delta(){ + section "Installing git delta" + if command -v delta >/dev/null 2>&1; then + log "delta $ALREADY_INSTALLED_MSG"; return 0; fi + if [[ -n "${CODESPACES:-}" ]]; then + curl -fsSL https://github.com/dandavison/delta/releases/latest/download/delta-linux.tar.gz -o /tmp/delta.tgz || { warn "delta download failed"; return 0; } + tar -xzf /tmp/delta.tgz -C /tmp + local bin_path + bin_path="$(find /tmp -maxdepth 2 -type f -name delta | head -n1)" + install -m 0755 "$bin_path" "$HOME/bin/delta" else - curl -sS https://webi.sh/delta | sh - ln -s "$(pwd)/.local/share/delta" ".local/share/delta" + brew install git-delta fi } -install_gh_copilot() { - echo "$delimiter Installing GitHub Copilot CLI extension $delimiter" - - # Check if gh CLI is installed first - if ! command -v gh &> /dev/null; then - echo "GitHub CLI (gh) is not installed. Please install it first." - return 1 - fi - - # Check if the extension is already installed +install_gh_copilot(){ + section "Installing GitHub Copilot CLI extension" + if ! command -v gh >/dev/null 2>&1; then + warn "gh not installed; skipping Copilot extension"; return 0; fi if gh extension list | grep -q "github/gh-copilot"; then - echo "GitHub Copilot CLI extension $ALREADY_INSTALLED_MSG" - else - echo "Installing GitHub Copilot CLI extension..." - if ! gh extension install github/gh-copilot; then - echo "Failed to install GitHub Copilot CLI extension" - return 1 - fi - echo "GitHub Copilot CLI extension installed successfully" - fi - - echo "$delimiter Installing GitHub Copilot CLI extension done $delimiter" + log "GitHub Copilot CLI extension $ALREADY_INSTALLED_MSG"; return 0; fi + gh extension install github/gh-copilot || warn "Failed to install gh-copilot extension" } -merge_zsh_config(){ - echo "Copying contents of .zshrc from $HOME/.zshrc to $script_dir/.zshrc" - if [[ -e "$HOME/.zshrc" ]]; then - less "$HOME/.zshrc" >> "$script_dir/.zshrc" - fi +preflight(){ + section "Preflight checks" + local required=(git curl) + for c in "${required[@]}"; do + command -v "$c" >/dev/null 2>&1 || { err "Missing required tool: $c"; exit 1; } + done + log "Preflight OK" +} + +main(){ + preflight + setup # environment-specific base install (helpers.sh) + install_neovim + install_git_delta + install_gh_copilot + symlink_dotfiles + install_fonts + install_spaceship + export SPACESHIP_CONFIG="$HOME/.spaceship.zsh" + export PATH="$HOME/.local/bin:$HOME/bin:$PATH" + section "Bootstrap complete" + log "Start a new shell or run: source ~/.zshrc" } -setup -install_nvim -install_gh_copilot -create_symlinks -install_fonts -install_spaceship -SPACESHIP_CONFIG="$(pwd)/.spaceship.zsh" -PATH="$HOME/.local/bin:$PATH" -export SPACESHIP_CONFIG -export PATH +main "$@"