diff --git a/README.md b/README.md index 4880934..8367b7d 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,99 @@ -# Atlas Desktop installer +# Atlas Desktop -This project is an automated installer for the Atlas Desktop. +A fully automated Archlinux setup for people that care about their computer. -## Disclaimer +```sh +curl -fsSL https://forge.oblic-parallels.fr/guillm/atlas-desktop/raw/branch/main/scripts/install.sh | bash +``` -### ⚠ Use at your own risk. -Some platforms may not yet be supported such as Nvidia graphic cards for which you'll have to install all the drivers yourself. Sadly I don't have any equipment to test that out. - -Also you will very probably miss some software you're used to. I'll strongly recommend you to check the [Additionnal packages](https://hedgedoc.oblic-parallels.fr/s/JSR33pjd_#Additional-packages) section to know about some open source utilities that fit perfectly within the desktop. -More generally give a look to the [Quick start guide](https://hedgedoc.oblic-parallels.fr/s/JSR33pjd_) - -If something doesn't work or behaves anormally don't hesitate to contact me so that it will be fixed for future users. +## What's included + +- **Window manager**: Hyprland +- **Status bar**: Waybar +- **Display manager**: Ly +- **Launcher**: Rofi +- **Notifications center**: Swaync +- **Terminal**: Foot +- **Shell**: Zsh + Zinit + Powerlevel10k ## Requirements -You'll just need an already functional (even basic) **Arch Linux** system with `sudo` installed. -Setup will take care of configuring AppArmor and Zram if it hasn't been done yet. +- A basic Arch Linux install (cli is enough, no DE required) +- A user account with `sudo` access +- `git` installed (`sudo pacman -Sy git`) +- Internet connection + +Setup will take care of configuring AppArmor and Zram or even firewall if you haven't done it yet. + +## Installation + +**Disclaimer**: Some platforms may not yet be supported such as Nvidia graphic cards for which you'll have to install all the drivers yourself. Sadly I don't have any equipment to test that out. -## Usage - -Very simple, clone the project (or download it directly from the web interface) -```sh= -git clone https://forge.oblic-parallels.fr/guillm/atlas-install +```sh +curl -fsSL https://forge.oblic-parallels.fr/guillm/atlas-desktop/raw/branch/main/scripts/install.sh | bash ``` -And run `setup.sh` -```sh= -./setup.sh -``` - Then just follow the instructions and you should be good to go ! -If you encounter any error or bug, don't hesitate to open an issue on this repo. +After the setup finishes you may need to reboot in order to login: +```sh +sudo reboot +``` + +> **Note**: If you encounter any error or bug, don't hesitate to open an issue on this repo. + +> **Note:** After installation the original clone is no longer needed. +> `~/.dotfiles` is the real repo and is what the updater tracks. + ## Configuration -I suggest you to give a look to the packages that will be installed on your system as it's important to know how things will work or to disable some things you wouldn't necessarely want. -I particularly think of helix which is used as the default text editor but may not suit some people needs. +I suggest you to give a look to the [Quick start guide](https://hedgedoc.oblic-parallels.fr/s/JSR33pjd_) that explains how the system works if you're not used to it. +As you will very probably miss some software you're used to. I'll strongly recommend you to check the [Additionnal packages](https://hedgedoc.oblic-parallels.fr/s/JSR33pjd_#Additional-packages) section to know about some open source utilities that fit perfectly within the desktop. Note that the default keyboard layout is QWERTY (us-fr). You can switch with the AZERTY layout by using `Mod+Ctrl+Space`. That said, the default layout already has french accents if you need them. If you need to modify the layout, go to `~/.config/hypr/config/default/kb_layouts`. -### Defaults -I'm working on a system to easily change the default programs. I don't know yet if it will result into anything but it's worth trying. +## Staying up to date + +```sh +atlas-update +``` + +This will: + +- Fetch origin/main and show new commits +- Detect conflicts between upstream changes and local edits, asking you which to keep per file +- Pull and re-stow config/ +- Re-prompt for any new optional groups added upstream + + +## Troubleshooting + +**Stow reports conflicts on first install** +- Existing real files that clash with stow links are automatically renamed to `.bak`. + +**An AUR package failed to install** +- The installer reports it and continues. It won't be saved to state, so `atlas-update` will retry it next run. + +**`atlas-update` says "already up to date" but a package is missing** +- Delete its state file to force a re-sync: +```bash +rm ~/.atlas-dotfiles/.state/packages/mygroup.pkgs +atlas-update +``` + +**Waybar doesn't reload after switching layout** +- Reload desktop using `Mod+Shift+R` or run the following command: +```bash +pkill waybar && waybar & +``` + ## Future improvements +- Waybar switch script +- A defaults system allowing you to easily choose your default programs - Nvidia cards handling -- Cleaner way to enable/disable some packages / configuratuions 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/doc/Developpers.md b/doc/Developpers.md new file mode 100644 index 0000000..a965e50 --- /dev/null +++ b/doc/Developpers.md @@ -0,0 +1,57 @@ +# Developpers documentation + +## Repository structure + +``` +dotfiles/ +├── install.sh +├── scripts/ +│ └── update.sh ← dotfiles-update +├── packages/ +│ ├── core.pkgs ← always installed +│ ├── desktop.pkgs ← always installed (automatic) +│ ├── aur.pkgs ← AUR packages (paru) +│ ├── intel-cpu.pkgs ← auto: Intel CPU +│ ├── amd-cpu.pkgs ← auto: AMD CPU +│ ├── intel-gpu.pkgs ← auto: Intel GPU +│ ├── amd-gpu.pkgs ← auto: AMD GPU +│ ├── nvidia.pkgs ← auto: NVIDIA GPU +│ ├── gaming.pkgs ← optional +│ ├── development.pkgs ← optional +│ ├── ... +│ ├── optional.groups ← lists which groups are optional +│ └── .desc ← one-line description shown in installer +└── config/ ← stow package; mirrors $HOME + ├── .config/ + │ ├── hypr/ + │ ├── waybar/ + │ │ ├── vertical/ + │ │ └── horizontal/ + │ ├── rofi/ + │ └── ... + └── .local/ + └── bin/ + └── atlas-update +``` + +### Package group conventions + +| File | When installed | +|---|---| +| `core.pkgs` | Always | +| Any unlisted `*.pkgs` (e.g. `desktop.pkgs`) | Always (automatic) | +| `aur.pkgs` | Always, via paru | +| `intel-gpu.pkgs`, `amd-gpu.pkgs`, etc. | Auto-detected via `lspci` / `/proc/cpuinfo` | +| Groups listed in `optional.groups` | User-chosen during install, remembered on updates | + +To add a new automatic group: create `packages/mygroup.pkgs` and commit. +To add a new optional group: also add its name to `packages/optional.groups` and optionally a `packages/mygroup.desc` one-liner. + + +## Package state tracking + +After each install or update, a snapshot of every installed group is saved to `~/.dotfiles/.state/packages/`. On the next run, each group is diffed: + +- **New packages** in the `.pkgs` file → installed automatically +- **Removed packages** → you are asked before uninstalling +Delete `~/.dotfiles/.state/` to reset all state and force a full reinstall on the next update. 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/packages/optional.groups b/packages/optional.groups new file mode 100644 index 0000000..e69de29 diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..e4b1cfc --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,965 @@ +#!/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' + ╔══════════════════════════════════════════════════════════════════╗ + ║ _ _ _____ _ _ ║ + ║ /\ | | | | | __ \ | | | | ║ + ║ / \ | |_| | __ _ ___ | | | | ___ ___| | _| |_ ___ _ __ ║ + ║ / /\ \| __| |/ _` / __| | | | |/ _ \/ __| |/ / __/ _ \| '_ \ ║ + ║ / ____ \ |_| | (_| \__ \ | |__| | __/\__ \ <| || (_) | |_) | ║ + ║ /_/ \_\__|_|\__,_|___/ |_____/ \___||___/_|\_\\__\___/| .__/ ║ + ║ | | ║ + ║ |_| ║ + ╠══════════════════════════════════════════════════════════════════╣ + ║ 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 + # Note: AUR packages are installed one-by-one so a single failure does not abort + # the whole group. Official repo packages are bulk-installed with a per- + # package fallback. Only successfully installed packages are saved to 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)" + + local succeeded=() failed=() + + if [[ $group == aur ]]; then + for pkg in "${pkgs[@]}"; do + local tmp; tmp=$(mktemp) + if [[ $IS_TTY == true ]]; then + spinner_start " [aur] $pkg" + if paru -S --needed --noconfirm "$pkg" >"$tmp" 2>&1; then + spinner_stop ok "[aur] $pkg"; succeeded+=("$pkg") + else + spinner_stop fail "[aur] $pkg" + warn "paru output:"; cat "$tmp" | indent; failed+=("$pkg") + fi + else + printf " ... [aur] %s\n" "$pkg" + if paru -S --needed --noconfirm "$pkg" >"$tmp" 2>&1; then + ok "[aur] $pkg"; succeeded+=("$pkg") + else + err "[aur] $pkg" + warn "paru output:"; cat "$tmp" | indent; failed+=("$pkg") + fi + fi + rm -f "$tmp" + done + else + run_quiet " Installing ${group}" pacman_install "${pkgs[@]}" + local tmp; tmp=$(mktemp) + if pacman_install "${pkgs[@]}" >"$tmp" 2>&1; then + succeeded=("${pkgs[@]}"); rm -f "$tmp" + else + rm -f "$tmp" + warn "Bulk install failed for group '${group}' — retrying per package" + for pkg in "${pkgs[@]}"; do + local tmp2; tmp2=$(mktemp) + if [[ $IS_TTY == true ]]; then + spinner_start " [${group}] $pkg" + if pacman_install "$pkg" >"$tmp2" 2>&1; then + spinner_stop ok "[${group}] $pkg"; succeeded+=("$pkg") + else + spinner_stop fail "[${group}] $pkg" + warn "pacman output:"; cat "$tmp2" | indent; failed+=("$pkg") + fi + else + printf " ... [%s] %s\n" "$group" "$pkg" + if pacman_install "$pkg" >"$tmp2" 2>&1; then + ok "[${group}] $pkg"; succeeded+=("$pkg") + else + err "[${group}] $pkg" + warn "pacman output:"; cat "$tmp2" | indent; failed+=("$pkg") + fi + fi + rm -f "$tmp2" + done + fi + fi + + if (( ${#succeeded[@]} )); then + save_pkg_list "$group" "${succeeded[@]}" + fi + + if (( ${#failed[@]} )); then + warn "Failed in group '${group}' (will retry on next update):" + for pkg in "${failed[@]}"; do + printf " - %s\n" "$pkg" + done + fi + return 0 + } + + # stow itself (needed for the next step) + run_quiet "Ensuring stow is present" sudo pacman -S --needed --noconfirm stow + + # Build the set of groups that are handled specially so we can exclude them + # when iterating all .pkgs files for automatic groups. + local hw_groups=("${HW_CPU}-cpu" "${HW_GPU}-gpu") + local optional_group_names=() + local optional_file="$PACKAGES_DIR/optional.groups" + if [[ -f $optional_file ]]; then + mapfile -t optional_group_names < <(grep -v '^\s*#' "$optional_file" | grep -v '^\s*$') + fi + + is_special_group() { + local g="$1" + # Returns 0 if the group is hw, optional, aur, or security (handled separately) + [[ $g == aur || $g == security ]] && return 0 + for hw in "${hw_groups[@]}"; do [[ $g == "$hw" ]] && return 0; done + for og in "${optional_group_names[@]}"; do [[ $g == "$og" ]] && return 0; done + return 1 + } + + # 1. Core + install_group "core" + + # 2. Hardware (auto-detected) + install_group "${HW_CPU}-cpu" + install_group "${HW_GPU}-gpu" + + # 3. All remaining automatic groups (every .pkgs file that is not special) + while IFS= read -r pkg_file; do + local group; group=$(basename "$pkg_file" .pkgs) + is_special_group "$group" && continue + [[ $group == core ]] && continue # already done above + install_group "$group" + done < <(find "$PACKAGES_DIR" -maxdepth 1 -name "*.pkgs" | sort) + + + # 4. 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 + + # 5. Optional chosen groups + for group in "${CHOSEN_OPTIONAL_GROUPS[@]}"; do + install_group "$group" + done + + # 6. AUR group + install_group "aur" + + phase_done +} + +# ───────────────────────────────────────────────────────────────────────────── +# STEP 9 — Clone into ~/.atlas-dotfiles and stow +# ───────────────────────────────────────────────────────────────────────────── +stow_dotfiles() { + phase "Cloning and stowing dotfiles" + + # ~/.atlas-dotfiles is the permanent home of the repo. You can delete the original + # clone after installation without breaking anything. + local remote_url + remote_url=$(git -C "$REPO_DIR" remote get-url origin 2>/dev/null || true) + + if [[ -z $remote_url ]]; then + die "Could not determine remote URL from $REPO_DIR (is origin set?)" + fi + + step "Setting up ~/.atlas-dotfiles as the permanent repo" + info "Remote: $remote_url" + + if [[ -d $DOTFILES_DIR/.git ]]; then + # Already a git repo — verify it points to the same remote + local existing_remote + existing_remote=$(git -C "$DOTFILES_DIR" remote get-url origin 2>/dev/null || true) + if [[ $existing_remote == $remote_url ]]; then + ok "~/.atlas-dotfiles already cloned from correct remote" + run_quiet "Pulling latest" git -C "$DOTFILES_DIR" pull --ff-only origin main + else + warn "~/.atlas-dotfiles exists but points to a different remote:" + warn " existing: $existing_remote" + warn " expected: $remote_url" + if confirm "Replace it with the correct repo?" y; then + rm -rf "$DOTFILES_DIR" + run_quiet "Cloning dotfiles" git clone "$remote_url" "$DOTFILES_DIR" + else + die "Aborted — ~/.atlas-dotfiles remote mismatch" + fi + fi + elif [[ -e $DOTFILES_DIR ]]; then + warn "~/.atlas-dotfiles exists but is not a git repo — moving to ~/.atlas-dotfiles.bak" + mv "$DOTFILES_DIR" "${DOTFILES_DIR}.bak" + run_quiet "Cloning dotfiles" git clone "$remote_url" "$DOTFILES_DIR" + else + run_quiet "Cloning dotfiles" git clone "$remote_url" "$DOTFILES_DIR" + fi + + #TODO Checkout ? + + ok "~/.atlas-dotfiles → $remote_url" + + # Backup any real files that would conflict with stow links + local stow_config_dir="$DOTFILES_DIR/config" + local conflicts=() + while IFS= read -r link; do + local rel="${link#$stow_config_dir/}" + local target="$HOME/$rel" + if [[ -e $target && ! -L $target ]]; then + conflicts+=("$target") + fi + done < <(find "$stow_config_dir" -type f 2>/dev/null) + + 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 + + # Stow from ~/.atlas-dotfiles treating config/ as the stow package + run_quiet "Stowing config" \ + stow --dir="$DOTFILES_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_in_dotfiles="$DOTFILES_DIR/scripts/update.sh" + local updater_link="$HOME/.local/bin/dotfiles-update" + + if [[ ! -f $updater_in_dotfiles ]]; then + warn "update.sh not found in ~/.atlas-dotfiles/scripts/ — skipping" + info "Expected: $updater_in_dotfiles" + phase_done + return + fi + + chmod +x "$updater_in_dotfiles" + mkdir -p "$HOME/.local/bin" + + # Symlink into PATH — stow may also handle this if scripts/ is a stow pkg, + # but we do it explicitly here so it works regardless of stow layout. + ln -sf "$updater_in_dotfiles" "$updater_link" + ok "dotfiles-update → $updater_in_dotfiles" + info "Run ${BOLD}dotfiles-update${RESET} any time to sync with upstream" + + # Initialise state inside the permanent repo + local state_dir="$DOTFILES_DIR/.state" + mkdir -p "$state_dir" + # Point state file at the permanent repo, not the installer's location + grep -q "^repo_dir=" "$state_dir/update.state" 2>/dev/null || echo "repo_dir=$DOTFILES_DIR" >> "$state_dir/update.state" + # Stamp the current HEAD so the first `dotfiles-update` knows the baseline + local head; head=$(git -C "$DOTFILES_DIR" rev-parse HEAD) + if grep -q "^last_commit=" "$state_dir/update.state" 2>/dev/null; then + sed -i "s|^last_commit=.*|last_commit=${head}|" "$state_dir/update.state" + else + echo "last_commit=$head" >> "$state_dir/update.state" + fi + + 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..52dd255 --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,561 @@ +#!/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_DIR="$DOTFILES_DIR" +STOW_PKG="config" +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" +} + +# ── Change detection ──────────────────────────────────────────────────────────────── + +# Files changed inside config/ (stow package) — used for conflict detection. +# Paths are stripped of the "config/" prefix so they match $HOME-relative targets. +collect_changed_config_files() { + git -C "$DOTFILES_DIR" diff --name-only "${OLD_HASH}" "${NEW_HASH}" \ + | grep '^config/' \ + | sed 's|^config/||' \ + || true +} + +# Returns 0 (true) if any file under packages/ changed between the two commits. +packages_changed() { + git -C "$DOTFILES_DIR" diff --name-only "${OLD_HASH}" "${NEW_HASH}" \ + | grep -q '^packages/' +} + +# Returns 0 (true) if any file under scripts/ changed between the two commits. +scripts_changed() { + git -C "$DOTFILES_DIR" diff --name-only "${OLD_HASH}" "${NEW_HASH}" \ + | grep -q '^scripts/' +} + +handle_conflicts() { + local repo_changed_files + mapfile -t repo_changed_files < <(collect_changed_config_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" + + local err_file; err_file=$(mktemp) + if stow --dir="$STOW_DIR" --target="$STOW_TARGET" --restow "$STOW_PKG" 2>"$err_file"; then + ok "stow: $STOW_PKG" + else + warn "stow: $STOW_PKG (errors below)" + while IFS= read -r line; do + echo " ${DIM}${line}${RESET}" + done < "$err_file" + fi + rm -f "$err_file" +} + +# ── 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} Atlas Desktop Updater${RESET}" + echo "${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 + + # Re-stow config/ whenever dotfiles changed + apply_stow + + # Re-stow scripts/ when it changed (keeps ~/.local/bin helpers up to date) + if scripts_changed; then + info "scripts/ changed — re-linking binaries" + local err_file; err_file=$(mktemp) + if stow --dir="$DOTFILES_DIR" --target="$HOME" --restow scripts \ + 2>"$err_file"; then + ok "stow: scripts" + else + warn "stow: scripts (errors below)" + while IFS= read -r line; do + echo " ${DIM}${line}${RESET}" + done < "$err_file" + fi + rm -f "$err_file" + fi + + state_set "last_commit" "$NEW_HASH" + + # Re-process packages whenever packages/ changed, even if no git update + # produced new dotfiles — package lists are independent of stowed files. + if packages_changed; then + info "packages/ changed — running full package sync" + fi + fi + + # Package sync runs on every invocation (diff vs saved state catches changes). + # This also handles the case where a previous run partially failed. + 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 "$@"