From c51330c1c27aee3d5cc34b51e8f0fa664939f38a Mon Sep 17 00:00:00 2001 From: "Gu://em_" Date: Mon, 4 May 2026 12:54:56 +0200 Subject: [PATCH] Brand new installer and updaters --- config/.config/colors/colors.css | 18 - config/.config/waybar/Atlas/style_minimal.css | 292 ------ config/.config/waybar/config.jsonc | 1 - .../{Boussole => horizontal}/config.jsonc | 0 .../waybar/{Boussole => horizontal}/style.css | 0 config/.config/waybar/style.css | 1 - .../waybar/{Atlas => vertical}/config.jsonc | 0 .../waybar/{Atlas => vertical}/style.css | 0 packages/amd-cpu.pkgs | 1 + packages/aur.pkgs | 2 +- packages/intel-cpu.pkgs | 1 + packages/{intel.pkgs => intel-gpu.pkgs} | 1 - scripts/install.sh | 839 ++++++++++++++++++ scripts/update.sh | 531 +++++++++++ 14 files changed, 1373 insertions(+), 314 deletions(-) delete mode 100644 config/.config/colors/colors.css delete mode 100644 config/.config/waybar/Atlas/style_minimal.css delete mode 120000 config/.config/waybar/config.jsonc rename config/.config/waybar/{Boussole => horizontal}/config.jsonc (100%) rename config/.config/waybar/{Boussole => horizontal}/style.css (100%) delete mode 120000 config/.config/waybar/style.css rename config/.config/waybar/{Atlas => vertical}/config.jsonc (100%) rename config/.config/waybar/{Atlas => vertical}/style.css (100%) create mode 100644 packages/amd-cpu.pkgs create mode 100644 packages/intel-cpu.pkgs rename packages/{intel.pkgs => intel-gpu.pkgs} (63%) create mode 100644 scripts/install.sh create mode 100644 scripts/update.sh diff --git a/config/.config/colors/colors.css b/config/.config/colors/colors.css deleted file mode 100644 index 8e0278d..0000000 --- a/config/.config/colors/colors.css +++ /dev/null @@ -1,18 +0,0 @@ -/* Swaync */ -@define-color active #e64e4e; -@define-color background_dark #1c2128; -@define-color background_light #22272e; -@define-color background_transparent alpha(#18191c, 0.7); -@define-color foreground #e4e8ed; -@define-color border #333940; -@define-color alert #ea4545; - - -/* Waybar */ -@define-color foreground #e4e8ed; -@define-color background #18191c; - -/* @define-color accentColor #b6c8d3; */ -@define-color accentColor #ec775c; - -@define-color charging #6bc46d; diff --git a/config/.config/waybar/Atlas/style_minimal.css b/config/.config/waybar/Atlas/style_minimal.css deleted file mode 100644 index 8c01bc1..0000000 --- a/config/.config/waybar/Atlas/style_minimal.css +++ /dev/null @@ -1,292 +0,0 @@ -@import "colors.css"; -@define-color active @accentColor; - -* { - font-size: 16px; - font-family: "JetBrainsMono Nerd Font,JetBrainsMono NF"; - min-width: 8px; - min-height: 0px; - border: none; - border-radius: 0; - box-shadow: none; - text-shadow: none; - padding: 0px; - -} - -window#waybar { - transition-property: background-color; - transition-duration: 0.5s; - border-radius: 4px; - border: 1px solid alpha(@active, 0.2); - background: @background; - background: alpha(@background, 0.7); - color: lighter(@active); -} - -menu, -tooltip { - border-radius: 2px; - padding: 2px; - border: 1px solid @active; - background: @background; - - color: lighter(@active); -} - -menu label, -tooltip label { - font-size: 14px; - color: lighter(@active); -} - -#submap, -#tray>.needs-attention { - animation-name: blink-active; - animation-duration: 1s; - animation-timing-function: linear; - animation-iteration-count: infinite; - animation-direction: alternate; -} - -.modules-right { - margin: 0px 6px 4px 6px; - border-radius: 4px; - background: alpha(@background, 0); - color: lighter(@active); -} - -.modules-left { - transition-property: background-color; - transition-duration: 0.5s; - margin: 6px 5px 6px 5px; /* Testing with 5 pixels on left/right */ - border-radius: 4px; - background: alpha(@background, 0.0); - color: lighter(@active); - border: 1px solid alpha(@active, 0.0); -} - -#gcpu, -#custom-github, -#memory, -#disk, -#together, -#submap, -#custom-weather, -#custom-recorder, -#connection, -#cnoti, -#brightness, -#power, -#custom-updates, -#tray, -/*#audio,*/ /* Duplicate with #sound */ -#sound, -#privacy { /*Controls all the right modules for some reason*/ - border-radius: 2px; - margin: 2px 1px 3px 1px; - background: alpha(darker(@active), 0.0); - border: 1px solid alpha(darker(@active), 0.0); -} - -/* Override specific parameters*/ - -#brightness, -#sound { - padding: 1px 0px; -} - -#custom-notifications { - padding-left: 4px; -} - -#custom-hotspot, -#custom-github, -#custom-notifications { - font-size: 14px; -} - -#custom-hotspot { - padding-right: 2px; -} - -#custom-vpn, -#custom-hotspot { - background: alpha(darker(@active), 0.3); -} - -#privacy-item { - padding: 6px 0px 6px 6px; -} - -#gcpu { - padding: 8px 0px 8px 0px; -} - -#custom-cpu-icon { - font-size: 25px; -} - -#custom-cputemp, -#disk, -#memory, -#cpu { - font-size: 14px; - font-weight: bold; -} - -#custom-github { - padding-top: 2px; - padding-right: 4px; -} - -#custom-dmark { - color: alpha(@foreground, 0.3); -} - -#submap { - margin-bottom: 0px; -} - -#workspaces { - margin: 0px 2px; - padding: 2px 0px; - border-radius: 8px; -} - -#workspaces button { - transition-property: background-color; - transition-duration: 0.1s; - color: @foreground; - background: transparent; - border-radius: 4px; - color: alpha(@foreground, 0.3); - padding: 2px 0px; -} - -#workspaces button.urgent { - font-weight: bold; - color: @foreground; -} - -#workspaces button.active { - padding: 2px 0px; - background: alpha(@active, 0.4); - color: lighter(@active); - border-radius: 2px; -} - -#network.wifi { - padding-right: 5px; - margin: 2px 0px; -} - -#network.disconnected { - padding-right: 5px; - margin: 2px 0px; -} - -#network.ethernet { - padding-right: 3px; - margin: 2px 0px; -} - -#submap { - min-width: 0px; - margin: 4px 6px 4px 6px; -} - -#custom-weather, -#tray { - padding: 4px 0px 4px 0px; -} - -#bluetooth { - padding-top: 2px; -} - -#battery { - border-radius: 8px; - padding: 4px 0px; - margin: 4px 2px 4px 2px; -} - -#battery.discharging.warning { - animation-name: blink-yellow; - animation-duration: 1s; - animation-timing-function: linear; - animation-iteration-count: infinite; - animation-direction: alternate; -} - -#battery.discharging.critical { - animation-name: blink-red; - animation-duration: 1s; - animation-timing-function: linear; - animation-iteration-count: infinite; - animation-direction: alternate; -} - -#clock { - font-weight: bold; - padding: 4px 2px 2px 2px; -} - -#pulseaudio.mic { - border-radius: 4px; - color: lighter(@active); - padding-left: 2px; -} - -#backlight-slider slider, -#pulseaudio-slider slider { - background-color: transparent; - box-shadow: none; -} - -#backlight-slider trough, -#pulseaudio-slider trough { - margin-top: 4px; - min-width: 6px; - min-height: 60px; - border-radius: 8px; - background-color: alpha(@background, 0.6); -} - -#backlight-slider highlight, -#pulseaudio-slider highlight { - border-radius: 8px; - background-color: lighter(@active); -} - -#bluetooth.discoverable, -#bluetooth.discovering, -#bluetooth.pairable { - border-radius: 8px; - animation-name: blink-active; - animation-duration: 1s; - animation-timing-function: linear; - animation-iteration-count: infinite; - animation-direction: alternate; -} - -@keyframes blink-active { - to { - background-color: @active; - color: @foreground; - } -} - -@keyframes blink-red { - to { - background-color: #c64d4f; - color: @foreground; - } -} - -@keyframes blink-yellow { - to { - background-color: #cf9022; - color: @foreground; - } -} diff --git a/config/.config/waybar/config.jsonc b/config/.config/waybar/config.jsonc deleted file mode 120000 index 1b9ff12..0000000 --- a/config/.config/waybar/config.jsonc +++ /dev/null @@ -1 +0,0 @@ -Boussole/config.jsonc \ No newline at end of file diff --git a/config/.config/waybar/Boussole/config.jsonc b/config/.config/waybar/horizontal/config.jsonc similarity index 100% rename from config/.config/waybar/Boussole/config.jsonc rename to config/.config/waybar/horizontal/config.jsonc diff --git a/config/.config/waybar/Boussole/style.css b/config/.config/waybar/horizontal/style.css similarity index 100% rename from config/.config/waybar/Boussole/style.css rename to config/.config/waybar/horizontal/style.css diff --git a/config/.config/waybar/style.css b/config/.config/waybar/style.css deleted file mode 120000 index e7230cf..0000000 --- a/config/.config/waybar/style.css +++ /dev/null @@ -1 +0,0 @@ -Boussole/style.css \ No newline at end of file diff --git a/config/.config/waybar/Atlas/config.jsonc b/config/.config/waybar/vertical/config.jsonc similarity index 100% rename from config/.config/waybar/Atlas/config.jsonc rename to config/.config/waybar/vertical/config.jsonc diff --git a/config/.config/waybar/Atlas/style.css b/config/.config/waybar/vertical/style.css similarity index 100% rename from config/.config/waybar/Atlas/style.css rename to config/.config/waybar/vertical/style.css diff --git a/packages/amd-cpu.pkgs b/packages/amd-cpu.pkgs new file mode 100644 index 0000000..35a60e1 --- /dev/null +++ b/packages/amd-cpu.pkgs @@ -0,0 +1 @@ +amd-ucode diff --git a/packages/aur.pkgs b/packages/aur.pkgs index 9caabcc..7105d9d 100644 --- a/packages/aur.pkgs +++ b/packages/aur.pkgs @@ -1 +1 @@ - rofi-bluetooth-git +rofi-bluetooth-git diff --git a/packages/intel-cpu.pkgs b/packages/intel-cpu.pkgs new file mode 100644 index 0000000..2052a8a --- /dev/null +++ b/packages/intel-cpu.pkgs @@ -0,0 +1 @@ +intel-ucode diff --git a/packages/intel.pkgs b/packages/intel-gpu.pkgs similarity index 63% rename from packages/intel.pkgs rename to packages/intel-gpu.pkgs index 2d9fee7..c36c61d 100644 --- a/packages/intel.pkgs +++ b/packages/intel-gpu.pkgs @@ -1,2 +1 @@ intel-media-driver -intel-ucode \ No newline at end of file diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..fe71b1b --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,839 @@ +#!/usr/bin/env bash + +# ============================================================================= +# Atlas Desktop Installer +# for Arch Linux +# ============================================================================= +# By Gu://em_ +# Co-Authored with Claude Sonnet 4.6 + +set -euo pipefail + +# ── Constants ───────────────────────────────────────────────────────────────── +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CONFIG_DIR="$REPO_DIR/config" +PACKAGES_DIR="$REPO_DIR/packages" +SCRIPTS_DIR="$REPO_DIR/scripts" +DOTFILES_DIR="$HOME/.atlas-dotfiles" +STATE_DIR="$DOTFILES_DIR/.state" +PKG_STATE_DIR="$STATE_DIR/packages" +WAYBAR_CFG_DIR="$HOME/.config/waybar" + +# ── Colours & styles ────────────────────────────────────────────────────────── +ESC=$'\e' +BOLD="${ESC}[1m" DIM="${ESC}[2m" RESET="${ESC}[0m" +BLACK="${ESC}[30m" RED="${ESC}[31m" GREEN="${ESC}[32m" +YELLOW="${ESC}[33m" BLUE="${ESC}[34m" MAGENTA="${ESC}[35m" +CYAN="${ESC}[36m" WHITE="${ESC}[37m" +BG_BLACK="${ESC}[40m" BG_BLUE="${ESC}[44m" +HIDE_CURSOR="${ESC}[?25l" SHOW_CURSOR="${ESC}[?25h" +CLEAR_LINE="${ESC}[2K\r" + +# Ensure cursor is restored on exit +trap 'printf "%s" "$SHOW_CURSOR"; tput cnorm 2>/dev/null || true' EXIT +trap 'printf "%s" "$SHOW_CURSOR"; tput cnorm 2>/dev/null || true; exit 1' INT TERM + +# ── Logging helpers ─────────────────────────────────────────────────────────── +PHASE_NUM=0 + +ok() { printf " ${GREEN}${BOLD}✔${RESET} %s\n" "$*"; } +info() { printf " ${CYAN}·${RESET} %s\n" "$*"; } +warn() { printf " ${YELLOW}⚠${RESET} %s\n" "$*"; } +err() { printf " ${RED}${BOLD}✘${RESET} %s\n" "$*" >&2; } +die() { err "$*"; exit 1; } +blank() { echo; } + +indent() { sed 's/^/ /'; } + +phase() { + (( PHASE_NUM++ )) || true + blank + printf "${BOLD}${BLUE}┌─ Phase %d ─ %s${RESET}\n" "$PHASE_NUM" "$*" +} + +step() { + printf "${BLUE}│${RESET} ${BOLD}%s${RESET}\n" "$*" +} + +phase_done() { + printf "${BLUE}└─${GREEN} done${RESET}\n" +} + +# ── Spinner ─────────────────────────────────────────────────────────────────── +_SPINNER_PID="" +_SPINNER_FRAMES=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + +spinner_start() { + local msg="$1" + printf "%s" "$HIDE_CURSOR" + ( + local i=0 + while true; do + printf "${CLEAR_LINE} ${CYAN}${_SPINNER_FRAMES[$((i % ${#_SPINNER_FRAMES[@]}))]}${RESET} %s" "$msg" + sleep 0.08 + (( i++ )) || true + done + ) & + _SPINNER_PID=$! +} + +spinner_stop() { + local status="${1:-ok}" # ok | fail + if [[ -n $_SPINNER_PID ]]; then + kill "$_SPINNER_PID" 2>/dev/null; wait "$_SPINNER_PID" 2>/dev/null || true + _SPINNER_PID="" + fi + printf "%s" "$SHOW_CURSOR" + printf "%s" "$CLEAR_LINE" + if [[ $status == ok ]]; then + ok "$2" + else + err "$2" + fi +} + +run_quiet() { + # run_quiet "label" cmd [args...] + local label="$1"; shift + spinner_start "$label…" + local tmp; tmp=$(mktemp) + if "$@" >"$tmp" 2>&1; then + spinner_stop ok "$label" + rm -f "$tmp" + else + local rc=$? + spinner_stop fail "$label" + warn "Output:" + cat "$tmp" | indent + rm -f "$tmp" + return $rc + fi +} + +# ── UI primitives ───────────────────────────────────────────────────────────── +header() { + clear + printf "${BOLD}${BLUE}" + cat << 'EOF' + ╔══════════════════════════════════════════════════════════╗ + ║ Some ASCII Art ║ + ╠══════════════════════════════════════════════════════════╣ + ║ Installer · Arch Linux ║ + ╚══════════════════════════════════════════════════════════╝ +EOF + printf "${RESET}\n" +} + +divider() { + printf " ${DIM}%s${RESET}\n" "──────────────────────────────────────────────────────" +} + +confirm() { + local msg="$1" default="${2:-y}" + local prompt; [[ $default == y ]] && prompt="${GREEN}Y${RESET}/n" || prompt="y/${RED}N${RESET}" + local ans + read -rp " ${YELLOW}?${RESET} $msg [${prompt}] " ans + ans="${ans:-$default}" + [[ ${ans,,} == y ]] +} + +press_enter() { + read -rp " ${DIM}Press Enter to continue…${RESET}" _ + blank +} + +# ── State helpers ───────────────────────────────────────────────────────────── +state_get() { grep -m1 "^${1}=" "$STATE_DIR/install.state" 2>/dev/null | cut -d= -f2- || true; } +state_set() { + local key="$1" val="$2" + local f="$STATE_DIR/install.state" + if grep -q "^${key}=" "$f" 2>/dev/null; then + sed -i "s|^${key}=.*|${key}=${val}|" "$f" + else + echo "${key}=${val}" >> "$f" + fi +} + +# ── Package helpers ─────────────────────────────────────────────────────────── +read_pkg_file() { + local file="$1" + [[ -f $file ]] || return + grep -v '^\s*#' "$file" | grep -v '^\s*$' | sort -u +} + +save_pkg_list() { + local group="$1"; shift + mkdir -p "$PKG_STATE_DIR" + printf '%s\n' "$@" > "$PKG_STATE_DIR/${group}" +} + +pacman_install() { + sudo pacman -S --needed --noconfirm "$@" +} + +paru_install() { + paru -S --needed --noconfirm "$@" +} + +pkg_installed() { + pacman -Q "$1" &>/dev/null +} + +# ── Hardware detection ──────────────────────────────────────────────────────── +detect_hardware() { + HW_GPU="unknown" + HW_CPU="unknown" + + if grep -qi intel /proc/cpuinfo 2>/dev/null; then HW_CPU="intel"; fi + if grep -qi amd /proc/cpuinfo 2>/dev/null; then HW_CPU="amd"; fi + + if lspci 2>/dev/null | grep -qi "nvidia"; then HW_GPU="nvidia" + elif lspci 2>/dev/null | grep -qi "amd.*radeon\|advanced micro"; then HW_GPU="amd" + elif lspci 2>/dev/null | grep -qi "intel.*graphics"; then HW_GPU="intel" + fi +} + +# ───────────────────────────────────────────────────────────────────────────── +# STEP 0 — Preflight +# ───────────────────────────────────────────────────────────────────────────── +preflight() { + phase "Preflight checks" + + # Arch Linux check + step "Checking system" + if [[ ! -f /etc/arch-release ]]; then + err "This installer only supports Arch Linux." + exit 1 + fi + ok "Arch Linux detected" + + # Not root + if [[ $EUID -eq 0 ]]; then + die "Do not run this script as root. It will use sudo when needed." + fi + ok "Running as user: ${BOLD}${USER}${RESET}" + + # sudo available + if ! sudo -v 2>/dev/null; then + die "sudo is required. Make sure $USER is in the sudoers group." + fi + ok "sudo access confirmed" + + # git + command -v git &>/dev/null || die "git is not installed." + ok "git found" + + # Config dir + if [[ ! -d $CONFIG_DIR ]]; then + die "config/ directory not found in $REPO_DIR" + fi + ok "Repo looks good: ${DIM}$REPO_DIR${RESET}" + + detect_hardware + info "CPU: ${BOLD}${HW_CPU^^}${RESET} GPU: ${BOLD}${HW_GPU^^}${RESET}" + + phase_done +} + +# ───────────────────────────────────────────────────────────────────────────── +# STEP 1 — Welcome & choices +# ───────────────────────────────────────────────────────────────────────────── +welcome() { + header + cat << EOF + Welcome! This script will set up the Atlas Desktop on your machine. + It will: + + ${CYAN}·${RESET} Install required packages (pacman + paru for AUR) + ${CYAN}·${RESET} Link your dotfiles into ${BOLD}~/${RESET} using GNU Stow + ${CYAN}·${RESET} Enable necessary system services + ${CYAN}·${RESET} Configure the updater so you stay in sync + + Nothing will be modified until you confirm below. + +EOF + divider + blank + confirm "Ready to begin?" y || { echo; info "Goodbye!"; exit 0; } +} + +# ───────────────────────────────────────────────────────────────────────────── +# STEP 2 — Waybar layout picker +# ───────────────────────────────────────────────────────────────────────────── +choose_waybar() { + phase "Waybar layout" + blank + + # Vertical preview + printf " ${BOLD}${CYAN}1) Vertical${RESET} ${DIM}(sidebar, left or right)${RESET}\n\n" + printf "${DIM}" + cat << 'EOF' + ┌────┐ + │ 1 │ ← workspace indicators + │ 2 │ + │ │ + │ │ + │ 󰕾 │ ← volume + │ 󰖩 │ ← network + │ 12 │ + │ 34 │ ← clock + │  │ ← power + └────┘ +EOF + printf "${RESET}\n" + + # Horizontal preview + printf " ${BOLD}${CYAN}2) Horizontal${RESET} ${DIM}(bottom bar)${RESET}\n\n" + printf "${DIM}" + cat << 'EOF' + ┌─────────────────────────────────────────────┐ + │ ● ● ○ ○ 12:34 󰕾 󰖩  │ + └─────────────────────────────────────────────┘ +EOF + printf "${RESET}\n" + + divider + local choice + while true; do + read -rp " ${YELLOW}?${RESET} Choose a layout ${BOLD}[1/2]${RESET}: " choice + case "$choice" in + 1) WAYBAR_LAYOUT="vertical"; ok "Vertical layout selected"; break ;; + 2) WAYBAR_LAYOUT="horizontal"; ok "Horizontal layout selected"; break ;; + *) warn "Please enter 1 or 2." ;; + esac + done + state_set "waybar_layout" "$WAYBAR_LAYOUT" + phase_done +} + +# ───────────────────────────────────────────────────────────────────────────── +# STEP 3 — Security profile +# ───────────────────────────────────────────────────────────────────────────── +choose_security() { + phase "Security & stability" + blank + cat << EOF + The following extras are ${BOLD}recommended for most users${RESET} but are + optional for those who manage their own setup: + + ${GREEN}·${RESET} ${BOLD}UFW${RESET} — simple firewall (blocks unsolicited inbound traffic) + ${GREEN}·${RESET} ${BOLD}AppArmor${RESET} — mandatory access control (limits process capabilities) + ${GREEN}·${RESET} ${BOLD}Zram${RESET} — compressed swap-in-RAM (better performance, less disk wear) + + ${DIM}Recommended unless you already manage these yourself.${RESET} + +EOF + if confirm "Install security & stability extras?" y; then + INSTALL_SECURITY=true + ok "Security extras will be installed" + else + INSTALL_SECURITY=false + info "Skipping — you can install them later" + fi + state_set "install_security" "$INSTALL_SECURITY" + phase_done +} + +# ───────────────────────────────────────────────────────────────────────────── +# STEP 4 — Optional package groups +# ───────────────────────────────────────────────────────────────────────────── +choose_optional_groups() { + phase "Optional packages" + blank + + local optional_file="$PACKAGES_DIR/optional.groups" + if [[ ! -f $optional_file ]]; then + info "No optional.groups file found — skipping" + CHOSEN_OPTIONAL_GROUPS=() + phase_done + return + fi + + mapfile -t ALL_OPTIONAL < <(grep -v '^\s*#' "$optional_file" | grep -v '^\s*$') + + if (( ${#ALL_OPTIONAL[@]} == 0 )); then + info "No optional groups defined — skipping" + CHOSEN_OPTIONAL_GROUPS=() + phase_done + return + fi + + info "Select which optional groups to install:" + blank + + CHOSEN_OPTIONAL_GROUPS=() + for group in "${ALL_OPTIONAL[@]}"; do + # Show a short description if .desc file exists + local desc_file="$PACKAGES_DIR/${group}.desc" + local desc="" + [[ -f $desc_file ]] && desc=" ${DIM}— $(cat "$desc_file")${RESET}" + if confirm " Install ${BOLD}${group}${RESET}${desc}?" n; then + CHOSEN_OPTIONAL_GROUPS+=("$group") + fi + done + + if (( ${#CHOSEN_OPTIONAL_GROUPS[@]} )); then + ok "Selected: ${CHOSEN_OPTIONAL_GROUPS[*]}" + else + info "No optional groups selected" + fi + + # Save choices + local joined; joined=$(IFS=','; echo "${CHOSEN_OPTIONAL_GROUPS[*]}") + state_set "optional_groups" "$joined" + phase_done +} + +# ───────────────────────────────────────────────────────────────────────────── +# STEP 5 — Summary & confirm +# ───────────────────────────────────────────────────────────────────────────── +confirm_summary() { + phase "Summary" + blank + printf " Here's what will happen:\n\n" + + printf " ${BOLD}Dotfiles${RESET}\n" + printf " ${DIM} %s → %s${RESET}\n" "$CONFIG_DIR" "$HOME" + blank + + printf " ${BOLD}Waybar layout:${RESET} %s\n" "${WAYBAR_LAYOUT}" + blank + + printf " ${BOLD}Hardware packages:${RESET}\n" + printf " CPU: %s · GPU: %s\n" "${HW_CPU}" "${HW_GPU}" + blank + + if [[ $INSTALL_SECURITY == true ]]; then + printf " ${GREEN}✔${RESET} Security extras: UFW, AppArmor, Zram\n" + else + printf " ${DIM}· Security extras: skipped${RESET}\n" + fi + blank + + if (( ${#CHOSEN_OPTIONAL_GROUPS[@]} )); then + printf " ${BOLD}Optional groups:${RESET} %s\n" "${CHOSEN_OPTIONAL_GROUPS[*]}" + else + printf " ${DIM}· No optional groups${RESET}\n" + fi + blank + + printf " ${BOLD}Services to enable:${RESET}\n" + printf " ly.service" + [[ $INSTALL_SECURITY == true ]] && printf " · apparmor.service · ufw · systemd-zram-setup@zram0" + printf "\n" + blank + + divider + blank + confirm "Proceed with installation?" y || { info "Aborted — nothing was changed."; exit 0; } + blank +} + +# ───────────────────────────────────────────────────────────────────────────── +# STEP 6 — System update +# ───────────────────────────────────────────────────────────────────────────── +system_update() { + phase "System update" + step "Syncing pacman databases and updating system" + run_quiet "Updating system" sudo pacman -Syu --noconfirm + phase_done +} + +# ───────────────────────────────────────────────────────────────────────────── +# STEP 7 — Bootstrap paru +# ───────────────────────────────────────────────────────────────────────────── +bootstrap_paru() { + phase "AUR helper (paru)" + + if pkg_installed paru; then + ok "paru is already installed" + phase_done + return + fi + + step "Installing paru from AUR" + info "Requires: base-devel, git, cargo (rust)" + + run_quiet "Installing base-devel" sudo pacman -S --needed --noconfirm base-devel git + + # Install rust if needed (paru needs cargo) + if ! command -v cargo &>/dev/null; then + run_quiet "Installing rust" sudo pacman -S --needed --noconfirm rust + fi + + local tmp_dir; tmp_dir=$(mktemp -d) + spinner_start "Cloning paru…" + git clone --depth=1 https://aur.archlinux.org/paru.git "$tmp_dir/paru" &>/dev/null + spinner_stop ok "Cloned paru" + + step "Building paru (this may take a moment)" + ( + cd "$tmp_dir/paru" + run_quiet "Building paru" makepkg -si --noconfirm + ) + rm -rf "$tmp_dir" + + if pkg_installed paru; then + ok "paru installed successfully" + else + die "paru installation failed" + fi + phase_done +} + +# ───────────────────────────────────────────────────────────────────────────── +# STEP 8 — Install packages +# ───────────────────────────────────────────────────────────────────────────── +install_packages() { + phase "Installing packages" + + local pkg_file pkgs group + + # Helper: install a group and save state + install_group() { + local group="$1" + local pkg_file="$PACKAGES_DIR/${group}.pkgs" + [[ -f $pkg_file ]] || { info "No file for group '${group}' — skipping"; return; } + + mapfile -t pkgs < <(read_pkg_file "$pkg_file") + (( ${#pkgs[@]} == 0 )) && { info "${group}: empty — skipping"; return; } + + step "Group: ${group} (${#pkgs[@]} packages)" + if [[ $group == aur ]]; then + run_quiet " Installing ${group}" paru_install "${pkgs[@]}" + else + run_quiet " Installing ${group}" pacman_install "${pkgs[@]}" + fi + save_pkg_list "$group" "${pkgs[@]}" + } + + # stow itself (needed for the next step) + run_quiet "Ensuring stow is present" sudo pacman -S --needed --noconfirm stow + + # Core + install_group "core" + + # Hardware + install_group "${HW_CPU}-cpu" 2>/dev/null || true + install_group "${HW_GPU}-gpu" 2>/dev/null || true + + # Security extras + if [[ $INSTALL_SECURITY == true ]]; then + local sec_pkgs=(ufw apparmor zram-generator) + step "Security extras" + run_quiet " Installing security packages" pacman_install "${sec_pkgs[@]}" + save_pkg_list "security" "${sec_pkgs[@]}" + fi + + # Optional chosen groups + for group in "${CHOSEN_OPTIONAL_GROUPS[@]}"; do + install_group "$group" + done + + # AUR group (always, if paru is present) + install_group "aur" + + phase_done +} + +# ───────────────────────────────────────────────────────────────────────────── +# STEP 9 — Stow dotfiles +# ───────────────────────────────────────────────────────────────────────────── +stow_dotfiles() { + phase "Stowing dotfiles" + + step "Linking ${CONFIG_DIR} → ${HOME}" + + # The config/ dir IS the stow package (its contents mirror $HOME) + # We treat config/ as a single stow package named "config" + # stow will create symlinks in $HOME for everything inside config/ + + # Backup any existing files that would conflict + local conflicts=() + while IFS= read -r link; do + local target="$HOME/${link#$CONFIG_DIR/}" + if [[ -e $target && ! -L $target ]]; then + conflicts+=("$target") + fi + done < <(find "$CONFIG_DIR" -type f) + + if (( ${#conflicts[@]} )); then + warn "${#conflicts[@]} existing file(s) will be backed up (.bak):" + for f in "${conflicts[@]}"; do + info " $f → ${f}.bak" + mv "$f" "${f}.bak" + done + fi + + # Create DOTFILES_DIR as a symlink to CONFIG_DIR, or use CONFIG_DIR directly + # We symlink ~/.atlas-dotfiles → repo's config dir so stow state lives in the repo + if [[ -L $DOTFILES_DIR ]]; then + local existing_target; existing_target=$(readlink -f "$DOTFILES_DIR") + if [[ $existing_target != "$CONFIG_DIR" ]]; then + warn "~/.atlas-dotfiles already points elsewhere: $existing_target" + warn "Removing and re-linking to $CONFIG_DIR" + rm "$DOTFILES_DIR" + ln -s "$CONFIG_DIR" "$DOTFILES_DIR" + fi + elif [[ -d $DOTFILES_DIR ]]; then + warn "~/.atlas-dotfiles exists as a real directory. Renaming to ~/.atlas-dotfiles.bak" + mv "$DOTFILES_DIR" "${DOTFILES_DIR}.bak" + ln -s "$CONFIG_DIR" "$DOTFILES_DIR" + else + ln -s "$CONFIG_DIR" "$DOTFILES_DIR" + fi + + ok "~/.atlas-dotfiles → $CONFIG_DIR" + + # Now run stow from inside the repo's parent, treating config as the package + run_quiet "Stowing config" \ + stow --dir="$REPO_DIR" --target="$HOME" --restow config + + phase_done +} + +# ───────────────────────────────────────────────────────────────────────────── +# STEP 10 — Waybar config linking +# ───────────────────────────────────────────────────────────────────────────── +setup_waybar() { + phase "Waybar configuration" + + local layout_dir="$WAYBAR_CFG_DIR/${WAYBAR_LAYOUT}" + + if [[ ! -d $layout_dir ]]; then + warn "Waybar layout directory not found: $layout_dir" + warn "Make sure config/.config/waybar/${WAYBAR_LAYOUT}/ exists in the repo" + phase_done + return + fi + + step "Linking ${WAYBAR_LAYOUT} layout as active config" + + # Remove existing active config/style links (not the layout dirs themselves) + rm -f "$WAYBAR_CFG_DIR/config" "$WAYBAR_CFG_DIR/style.css" \ + "$WAYBAR_CFG_DIR/config.jsonc" + + # Link config file (support config, config.json, config.jsonc) + local cfg_file="" + for name in config config.json config.jsonc; do + [[ -f "$layout_dir/$name" ]] && { cfg_file="$name"; break; } + done + + if [[ -n $cfg_file ]]; then + ln -sf "$layout_dir/$cfg_file" "$WAYBAR_CFG_DIR/config" + ok "config → ${WAYBAR_LAYOUT}/${cfg_file}" + else + warn "No config file found in $layout_dir" + fi + + # Link style.css + if [[ -f "$layout_dir/style.css" ]]; then + ln -sf "$layout_dir/style.css" "$WAYBAR_CFG_DIR/style.css" + ok "style.css → ${WAYBAR_LAYOUT}/style.css" + else + warn "No style.css found in $layout_dir" + fi + + state_set "waybar_layout" "$WAYBAR_LAYOUT" + phase_done +} + +# ───────────────────────────────────────────────────────────────────────────── +# STEP 11 — Enable services +# ───────────────────────────────────────────────────────────────────────────── +enable_services() { + phase "Enabling services" + + enable_service() { + local svc="$1" + if systemctl is-enabled "$svc" &>/dev/null; then + ok "$svc (already enabled)" + else + if sudo systemctl enable "$svc" &>/dev/null; then + ok "$svc" + else + warn "Failed to enable $svc (service may not be installed)" + fi + fi + } + + # Display manager + step "Display manager" + # Disable any currently-enabled DM first to avoid conflicts + for dm in gdm sddm lightdm lxdm greetd; do + systemctl is-enabled "${dm}.service" &>/dev/null && \ + sudo systemctl disable "${dm}.service" &>/dev/null || true + done + enable_service "ly.service" + + # Security services (if chosen) + if [[ $INSTALL_SECURITY == true ]]; then + blank + step "Security & stability" + enable_service "apparmor.service" + enable_service "ufw.service" + + # UFW default policy + spinner_start "Configuring UFW defaults" + sudo ufw default deny incoming &>/dev/null + sudo ufw default allow outgoing &>/dev/null + sudo ufw --force enable &>/dev/null + spinner_stop ok "UFW configured (deny incoming, allow outgoing)" + + # Zram — write generator config if not present + local zram_conf="/etc/systemd/zram-generator.conf" + if [[ ! -f $zram_conf ]]; then + spinner_start "Writing zram config" + printf '[zram0]\nzram-size = ram / 2\ncompression-algorithm = zstd\n' \ + | sudo tee "$zram_conf" >/dev/null + spinner_stop ok "Zram configured (50% RAM, zstd)" + else + ok "Zram config already exists" + fi + enable_service "systemd-zram-setup@zram0.service" + fi + + # Pipewire (audio) + blank + step "Audio (Pipewire)" + systemctl --user enable --now pipewire.service &>/dev/null && ok "pipewire.service" || warn "pipewire.service not found" + systemctl --user enable --now pipewire-pulse.service &>/dev/null && ok "pipewire-pulse.service" || warn "pipewire-pulse not found" + systemctl --user enable --now wireplumber.service &>/dev/null && ok "wireplumber.service" || warn "wireplumber not found" + + phase_done +} + +# ───────────────────────────────────────────────────────────────────────────── +# STEP 12 — Install updater +# ───────────────────────────────────────────────────────────────────────────── +install_updater() { + phase "Dotfiles updater" + + local updater_src="$SCRIPTS_DIR/update.sh" + local updater_link="$HOME/.local/bin/atlas-update" + + if [[ ! -f $updater_src ]]; then + warn "Updater not found at $updater_src — skipping" + phase_done + return + fi + + mkdir -p "$HOME/.local/bin" + chmod +x "$updater_src" + + # Create symlink (stow might also handle this if update.sh is in config/) + # Here we link directly so it's always in PATH regardless of stow layout + ln -sf "$updater_src" "$updater_link" + ok "atlas-update → $updater_src" + info "Run ${BOLD}atlas-update${RESET} any time to sync with upstream" + + # Save repo path into state so updater knows where it lives + mkdir -p "$STATE_DIR" + state_set "repo_dir" "$REPO_DIR" + state_set "last_commit" "$(git -C "$REPO_DIR" rev-parse HEAD)" + + phase_done +} + +# ───────────────────────────────────────────────────────────────────────────── +# STEP 13 — Final screen +# ───────────────────────────────────────────────────────────────────────────── +finish() { + blank + printf "${BOLD}${GREEN}" + cat << 'EOF' + ╔══════════════════════════════════════════════════════════╗ + ║ ║ + ║ ✔ Installation complete! ║ + ║ ║ + ╚══════════════════════════════════════════════════════════╝ +EOF + printf "${RESET}\n" + + cat << EOF + ${BOLD}What to do next:${RESET} + + ${CYAN}·${RESET} ${BOLD}Reboot${RESET} to start Ly and load your Hyprland session + ${DIM}sudo reboot${RESET} + + ${CYAN}·${RESET} ${BOLD}Update dotfiles${RESET} any time by running: + ${DIM}atlas-update${RESET} + + ${CYAN}·${RESET} ${BOLD}Switch Waybar layout${RESET} (vertical ↔ horizontal): + ${DIM}atlas-waybar [vertical|horizontal]${RESET} + + ${CYAN}·${RESET} Your chosen settings are saved in: + ${DIM}$STATE_DIR/install.state${RESET} + +EOF + + if [[ $INSTALL_SECURITY == true ]]; then + printf " ${GREEN}·${RESET} ${BOLD}UFW${RESET} is active. To allow a port: ${DIM}sudo ufw allow ${RESET}\n" + blank + fi + + divider + printf " ${DIM}Atlas Desktop — %s${RESET}\n\n" "$(git -C "$REPO_DIR" rev-parse --short HEAD)" +} + +# ───────────────────────────────────────────────────────────────────────────── +# Waybar switcher (can be called directly: atlas-waybar vertical) +# ───────────────────────────────────────────────────────────────────────────── +waybar_switch() { + local layout="${1:-}" + if [[ -z $layout ]]; then + local current; current=$(state_get "waybar_layout" || echo "unknown") + echo "Current layout: $current" + echo "Usage: atlas-waybar [vertical|horizontal]" + return + fi + case "$layout" in + vertical|horizontal) ;; + *) die "Unknown layout '$layout'. Choose: vertical, horizontal" ;; + esac + WAYBAR_LAYOUT="$layout" + setup_waybar + ok "Waybar switched to ${layout}. Restart waybar to apply." +} + +# ───────────────────────────────────────────────────────────────────────────── +# Main +# ───────────────────────────────────────────────────────────────────────────── +main() { + # Allow standalone waybar switching + if [[ "${1:-}" == "waybar" ]]; then + shift + waybar_switch "$@" + exit 0 + fi + + mkdir -p "$STATE_DIR" "$PKG_STATE_DIR" + [[ -f "$STATE_DIR/install.state" ]] || touch "$STATE_DIR/install.state" + + # Init arrays + CHOSEN_OPTIONAL_GROUPS=() + INSTALL_SECURITY=false + WAYBAR_LAYOUT="horizontal" + HW_CPU="unknown" + HW_GPU="unknown" + + welcome + preflight + choose_waybar + choose_security + choose_optional_groups + confirm_summary + + # From here on we're actually making changes + system_update + bootstrap_paru + install_packages + stow_dotfiles + setup_waybar + enable_services + install_updater + + finish +} + +main "$@" diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100644 index 0000000..4bfc37b --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,531 @@ +#!/usr/bin/env bash + +# ============================================================================= +# Atlas Updater +# Arch Linux · GNU Stow · pacman / paru +# ============================================================================= +# By Gu://em_ +# Co-Authored with Claude Sonnet 4.6 + +set -euo pipefail + +# ── Paths ──────────────────────────────────────────────────────────────────── +DOTFILES_DIR="${DOTFILES_DIR:-$HOME/.atlas-dotfiles}" +STATE_DIR="$DOTFILES_DIR/.state" +PKG_STATE_DIR="$STATE_DIR/packages" +STATE_FILE="$STATE_DIR/update.state" +PACKAGES_DIR="$DOTFILES_DIR/packages" +STOW_TARGET="$HOME" + +# ── Colours ────────────────────────────────────────────────────────────────── +if [[ -t 1 ]]; then + BOLD=$'\e[1m'; RESET=$'\e[0m' + RED=$'\e[31m'; GREEN=$'\e[32m'; YELLOW=$'\e[33m' + BLUE=$'\e[34m'; CYAN=$'\e[36m'; DIM=$'\e[2m' +else + BOLD=''; RESET=''; RED=''; GREEN=''; YELLOW=''; BLUE=''; CYAN=''; DIM='' +fi + +# ── Helpers ────────────────────────────────────────────────────────────────── +section() { echo; echo "${BOLD}${BLUE}══ $* ══${RESET}"; } +ok() { echo " ${GREEN}✔${RESET} $*"; } +info() { echo " ${CYAN}·${RESET} $*"; } +warn() { echo " ${YELLOW}⚠${RESET} $*"; } +err() { echo " ${RED}✘${RESET} $*" >&2; } +die() { err "$*"; exit 1; } +dim() { echo "${DIM}$*${RESET}"; } + +# Ask yes/no; default is supplied as 'y' or 'n' +confirm() { + local msg="$1" default="${2:-y}" + local prompt; [[ $default == y ]] && prompt="[Y/n]" || prompt="[y/N]" + read -rp " ${YELLOW}?${RESET} $msg $prompt " ans + ans="${ans:-$default}" + [[ ${ans,,} == y ]] +} + +# Numbered menu; sets $REPLY to chosen index (1-based) +pick_one() { + local prompt="$1"; shift + local options=("$@") + echo " ${YELLOW}?${RESET} $prompt" + for i in "${!options[@]}"; do + printf " %s) %s\n" "$((i+1))" "${options[$i]}" + done + while true; do + read -rp " Choice: " ans + if [[ $ans =~ ^[0-9]+$ ]] && (( ans >= 1 && ans <= ${#options[@]} )); then + REPLY=$ans; return + fi + echo " Invalid choice, try again." + done +} + +# ── State helpers ───────────────────────────────────────────────────────────── +state_get() { grep -m1 "^${1}=" "$STATE_FILE" 2>/dev/null | cut -d= -f2- || true; } +state_set() { + local key="$1" val="$2" + if grep -q "^${key}=" "$STATE_FILE" 2>/dev/null; then + sed -i "s|^${key}=.*|${key}=${val}|" "$STATE_FILE" + else + echo "${key}=${val}" >> "$STATE_FILE" + fi +} + +# ── Dependency check ────────────────────────────────────────────────────────── +check_deps() { + local missing=() + for cmd in git stow pacman; do + command -v "$cmd" &>/dev/null || missing+=("$cmd") + done + if (( ${#missing[@]} )); then + die "Missing required tools: ${missing[*]}. Install them first." + fi + # Prefer paru, fall back to pacman -S for AUR-less installs + if command -v paru &>/dev/null; then + AUR_HELPER="paru" + elif command -v yay &>/dev/null; then + AUR_HELPER="yay" + else + AUR_HELPER="" + warn "No AUR helper found (yay/paru). AUR packages will be skipped." + fi +} + +# ── Hardware detection ──────────────────────────────────────────────────────── +detect_hardware_groups() { + local groups=() + # CPU + if grep -qi intel /proc/cpuinfo 2>/dev/null; then + groups+=("intel-cpu") + elif grep -qi amd /proc/cpuinfo 2>/dev/null; then + groups+=("amd-cpu") + fi + # GPU + if lspci 2>/dev/null | grep -qi "intel.*graphics"; then + groups+=("intel-gpu") + fi + if lspci 2>/dev/null | grep -qi "amd.*radeon\|advanced micro.*display"; then + groups+=("amd-gpu") + fi + if lspci 2>/dev/null | grep -qi "nvidia"; then + groups+=("nvidia") + fi + echo "${groups[@]}" +} + +# ── Package-group helpers ───────────────────────────────────────────────────── + +# Return the .pkgs file for a group name (strip suffixes variants) +pkg_file_for_group() { + local group="$1" + # Try exact match first, then with .pkgs extension + local f + for f in "$PACKAGES_DIR/${group}.pkgs" "$PACKAGES_DIR/${group}"; do + [[ -f $f ]] && { echo "$f"; return; } + done +} + +# Read packages from a .pkgs file (skip comments & blanks) +read_pkg_file() { + local file="$1" + [[ -f $file ]] || return + grep -v '^\s*#' "$file" | grep -v '^\s*$' | sort -u +} + +# Load previously-saved list for a group +saved_pkg_list() { + local group="$1" + local f="$PKG_STATE_DIR/${group}" + [[ -f $f ]] && cat "$f" || true +} + +# Save current list for a group +save_pkg_list() { + local group="$1" + shift + mkdir -p "$PKG_STATE_DIR" + printf '%s\n' "$@" > "$PKG_STATE_DIR/${group}" +} + +# Detect all available groups from packages/ dir +all_pkg_groups() { + [[ -d $PACKAGES_DIR ]] || return + find "$PACKAGES_DIR" -maxdepth 1 -name '*.pkgs' -printf '%f\n' \ + | sed 's/\.pkgs$//' | sort +} + +# Read user-chosen optional groups from state +chosen_optional_groups() { + state_get "optional_groups" | tr ',' '\n' | grep -v '^$' || true +} + +# ── Git operations ──────────────────────────────────────────────────────────── +git_fetch_and_check() { + section "Checking for updates" + cd "$DOTFILES_DIR" + + info "Fetching origin…" + git fetch origin main --quiet + + local local_hash remote_hash + local_hash=$(git rev-parse HEAD) + remote_hash=$(git rev-parse origin/main) + LAST_KNOWN_HASH=$(state_get "last_commit" || true) + + if [[ $local_hash == $remote_hash ]]; then + ok "Already up to date (${local_hash:0:8})" + HAS_UPDATE=false + return + fi + + info "New commits available:" + git log --oneline "${local_hash}..${remote_hash}" | while read -r line; do + dim " $line" + done + HAS_UPDATE=true + NEW_HASH="$remote_hash" + OLD_HASH="$local_hash" +} + +# ── Conflict detection ──────────────────────────────────────────────────────── +collect_changed_files() { + # Files changed between old commit and new commit in the repo + git -C "$DOTFILES_DIR" diff --name-only "${OLD_HASH}" "${NEW_HASH}" \ + | grep -v '^packages/' | grep -v '^\.' || true +} + +handle_conflicts() { + local repo_changed_files + mapfile -t repo_changed_files < <(collect_changed_files) + + if (( ${#repo_changed_files[@]} == 0 )); then + return + fi + + section "Checking for local conflicts" + + local conflicts=() + for rel in "${repo_changed_files[@]}"; do + # The stow target path + local target="$STOW_TARGET/$rel" + # Check if the symlink target (i.e. the real file in dotfiles) was + # locally overridden — compare stowed hash vs. live hash + if [[ -f $target && ! -L $target ]]; then + # File exists but is NOT a symlink → user edited after un-stowing + conflicts+=("$rel") + elif [[ -L $target ]]; then + # It's a symlink — compare live dotfiles version vs. what we last recorded + local link_dest + link_dest=$(readlink -f "$target" 2>/dev/null || true) + local repo_file="$DOTFILES_DIR/$rel" + # If the symlink points back into the repo, compare against old hash + if [[ $link_dest == $repo_file ]]; then + local old_blob new_blob + old_blob=$(git -C "$DOTFILES_DIR" show "${OLD_HASH}:${rel}" 2>/dev/null \ + | sha256sum | cut -d' ' -f1 || echo "") + local live_blob + live_blob=$(sha256sum "$link_dest" 2>/dev/null | cut -d' ' -f1 || echo "") + # If live differs from old-repo → user modified through symlink + if [[ -n $old_blob && $old_blob != $live_blob ]]; then + conflicts+=("$rel") + fi + fi + fi + done + + if (( ${#conflicts[@]} == 0 )); then + ok "No local conflicts" + return + fi + + warn "${#conflicts[@]} conflict(s) detected — files modified locally:" + for f in "${conflicts[@]}"; do + echo " ${YELLOW}${f}${RESET}" + done + + for rel in "${conflicts[@]}"; do + echo + info "Conflict: ${BOLD}${rel}${RESET}" + pick_one "Which version to keep?" \ + "Upstream (repo) — discard local changes" \ + "Local — keep my version, skip this file" + case $REPLY in + 1) + ok "Will use upstream version of $rel" + SKIP_FILES+=("__NONE__") # placeholder so array stays parallel + ;; + 2) + warn "Skipping $rel — your local version will be kept" + SKIP_FILES+=("$rel") + ;; + esac + done +} + +# ── Apply git update ────────────────────────────────────────────────────────── +apply_git_update() { + section "Pulling updates" + cd "$DOTFILES_DIR" + + # Stash any locally-modified tracked files so pull doesn't fail + if ! git diff --quiet HEAD; then + info "Stashing local modifications…" + git stash push -m "updater-auto-stash" --quiet + GIT_STASHED=true + else + GIT_STASHED=false + fi + + git merge --ff-only origin/main --quiet + ok "Repository updated to ${NEW_HASH:0:8}" + + # Re-apply stash if we created one (for files user chose to keep locally) + if [[ $GIT_STASHED == true ]]; then + info "Re-applying local stash…" + git stash pop --quiet || { + warn "Stash pop had conflicts — your stash is still saved (git stash list)" + } + fi +} + +# ── Stow ───────────────────────────────────────────────────────────────────── +apply_stow() { + section "Stowing dotfiles" + cd "$DOTFILES_DIR" + + # Discover stow packages (top-level dirs, excluding hidden & packages/) + local stow_pkgs=() + while IFS= read -r d; do + local name + name=$(basename "$d") + [[ $name == packages ]] && continue + stow_pkgs+=("$name") + done < <(find "$DOTFILES_DIR" -mindepth 1 -maxdepth 1 -type d ! -name '.*' | sort) + + if (( ${#stow_pkgs[@]} == 0 )); then + warn "No stow packages found in $DOTFILES_DIR" + return + fi + + for pkg in "${stow_pkgs[@]}"; do + if stow --restow --target="$STOW_TARGET" "$pkg" 2>/tmp/stow_err; then + ok "stow: $pkg" + else + warn "stow: $pkg (see error below)" + while IFS= read -r line; do + echo " ${DIM}${line}${RESET}" + done < /tmp/stow_err + fi + done + rm -f /tmp/stow_err +} + +# ── Package management ──────────────────────────────────────────────────────── + +# Install packages via pacman (official) or AUR helper +install_pkgs() { + local pkgs=("$@") + (( ${#pkgs[@]} == 0 )) && return + if sudo pacman -S --needed --noconfirm "${pkgs[@]}" 2>/dev/null; then + return + fi + # Some might be AUR-only — try AUR helper for the remainder + if [[ -n $AUR_HELPER ]]; then + "$AUR_HELPER" -S --needed --noconfirm "${pkgs[@]}" 2>/dev/null || true + fi +} + +# Remove packages (orphan-safe: only if nothing else depends on them) +remove_pkgs() { + local pkgs=("$@") + (( ${#pkgs[@]} == 0 )) && return + # Filter to only actually-installed packages + local installed=() + for p in "${pkgs[@]}"; do + pacman -Q "$p" &>/dev/null && installed+=("$p") + done + (( ${#installed[@]} == 0 )) && return + sudo pacman -Rns --noconfirm "${installed[@]}" 2>/dev/null || true +} + +process_pkg_group() { + local group="$1" + local pkg_file + pkg_file=$(pkg_file_for_group "$group") + + if [[ -z $pkg_file ]]; then + warn "No package file found for group '$group' — skipping" + return + fi + + mapfile -t new_pkgs < <(read_pkg_file "$pkg_file") + mapfile -t old_pkgs < <(saved_pkg_list "$group") + + # Diff + mapfile -t to_install < <(comm -23 \ + <(printf '%s\n' "${new_pkgs[@]}" | sort) \ + <(printf '%s\n' "${old_pkgs[@]+"${old_pkgs[@]}"}" | sort)) + mapfile -t to_remove < <(comm -13 \ + <(printf '%s\n' "${new_pkgs[@]}" | sort) \ + <(printf '%s\n' "${old_pkgs[@]+"${old_pkgs[@]}"}" | sort)) + + local changed=false + + if (( ${#to_install[@]} )); then + info " [${group}] Installing: ${to_install[*]}" + install_pkgs "${to_install[@]}" && changed=true + fi + + if (( ${#to_remove[@]} )); then + if confirm " [${group}] Remove packages no longer in group: ${to_remove[*]}?" y; then + remove_pkgs "${to_remove[@]}" && changed=true + fi + fi + + if [[ $changed == false ]] && (( ${#new_pkgs[@]} )); then + ok " [${group}] Up to date" + fi + + # Always save current list (ensures state file exists after first run) + save_pkg_list "$group" "${new_pkgs[@]}" +} + +process_packages() { + section "Package management" + + if [[ ! -d $PACKAGES_DIR ]]; then + info "No packages/ directory found — skipping" + return + fi + + # 1. Auto groups (hardware-based) + local hw_groups + mapfile -t hw_groups < <(detect_hardware_groups) + if (( ${#hw_groups[@]} )); then + info "Detected hardware groups: ${hw_groups[*]}" + for g in "${hw_groups[@]}"; do + process_pkg_group "$g" + done + fi + + # 2. Core / always-on groups (everything that isn't hw-specific and not optional) + # Convention: groups NOT listed in optional.groups file are automatic + local optional_file="$PACKAGES_DIR/optional.groups" + local optional_group_names=() + if [[ -f $optional_file ]]; then + mapfile -t optional_group_names < <(grep -v '^\s*#' "$optional_file" | grep -v '^\s*$') + fi + + local hw_pattern + hw_pattern=$(printf '%s|' "${hw_groups[@]}" | sed 's/|$//') + [[ -z $hw_pattern ]] && hw_pattern='__NOMATCH__' + + while IFS= read -r group; do + # Skip hw groups already processed + echo "$group" | grep -qE "^(${hw_pattern})$" && continue + # Skip optional groups (handled below) + local is_optional=false + for og in "${optional_group_names[@]}"; do + [[ $group == $og ]] && { is_optional=true; break; } + done + $is_optional && continue + process_pkg_group "$group" + done < <(all_pkg_groups) + + # 3. Optional groups — interactive first-time, then remembered + if (( ${#optional_group_names[@]} )); then + local chosen + mapfile -t chosen < <(chosen_optional_groups) + + if (( ${#chosen[@]} == 0 )); then + echo + info "Optional package groups available. Choose which to install:" + local selected=() + for og in "${optional_group_names[@]}"; do + if confirm " Install group '${og}'?" n; then + selected+=("$og") + fi + done + if (( ${#selected[@]} )); then + state_set "optional_groups" "$(IFS=,; echo "${selected[*]}")" + chosen=("${selected[@]}") + fi + fi + + for og in "${chosen[@]}"; do + process_pkg_group "$og" + done + fi +} + +# ── AUR group (separate because needs AUR helper) ───────────────────────────── +process_aur_group() { + [[ -z $AUR_HELPER ]] && { warn "Skipping AUR group (no AUR helper)"; return; } + local pkg_file + pkg_file=$(pkg_file_for_group "aur") + [[ -z $pkg_file ]] && return + + section "AUR packages" + mapfile -t new_pkgs < <(read_pkg_file "$pkg_file") + mapfile -t old_pkgs < <(saved_pkg_list "aur") + + mapfile -t to_install < <(comm -23 \ + <(printf '%s\n' "${new_pkgs[@]}" | sort) \ + <(printf '%s\n' "${old_pkgs[@]+"${old_pkgs[@]}"}" | sort)) + mapfile -t to_remove < <(comm -13 \ + <(printf '%s\n' "${new_pkgs[@]}" | sort) \ + <(printf '%s\n' "${old_pkgs[@]+"${old_pkgs[@]}"}" | sort)) + + if (( ${#to_install[@]} )); then + info "Installing AUR: ${to_install[*]}" + "$AUR_HELPER" -S --needed --noconfirm "${to_install[@]}" || true + fi + if (( ${#to_remove[@]} )); then + if confirm "Remove AUR packages no longer listed: ${to_remove[*]}?" y; then + remove_pkgs "${to_remove[@]}" + fi + fi + (( ${#to_install[@]} == 0 && ${#to_remove[@]} == 0 )) && ok "AUR packages up to date" + save_pkg_list "aur" "${new_pkgs[@]}" +} + +# ── Entry point ─────────────────────────────────────────────────────────────── +main() { + echo + echo "${BOLD} Hyprland Dotfiles Updater${RESET} ${DIM}(Arch Linux)${RESET}" + echo " ${DIM}Target: ${DOTFILES_DIR}${RESET}" + + [[ -d $DOTFILES_DIR/.git ]] || die "$DOTFILES_DIR is not a git repository." + check_deps + + mkdir -p "$STATE_DIR" "$PKG_STATE_DIR" + [[ -f $STATE_FILE ]] || touch "$STATE_FILE" + + HAS_UPDATE=false + GIT_STASHED=false + SKIP_FILES=() + NEW_HASH="" + OLD_HASH="" + + git_fetch_and_check + + if [[ $HAS_UPDATE == true ]]; then + handle_conflicts + apply_git_update + apply_stow + state_set "last_commit" "$NEW_HASH" + fi + + process_packages + process_aur_group + + section "Done" + ok "Dotfiles are up to date." + if [[ $HAS_UPDATE == true ]]; then + dim " Updated: ${OLD_HASH:0:8} → ${NEW_HASH:0:8}" + fi + echo +} + +main "$@"